A Go blog publishing framework. Ships blog CRUD, admin dashboard, privacy-first analytics, RSS, and sitemap out of the box. You own the templates, pubengine handles everything else.
Built with Echo, templ, HTMX, Tailwind CSS, and SQLite.
pubengine is a Go module, not a standalone app. You import it, provide your own templ templates via a ViewFuncs struct, and pubengine wires up all the handlers, middleware, database, caching, and analytics. Think of it like Django for Go blogs: convention over configuration with full template ownership.
+-----------------+ +-------------------+
| Your Project | | pubengine |
| | | |
| main.go |------>| Handlers |
| views/*.templ | | Middleware |
| assets/ | | Store (SQLite) |
| src/ | | Cache |
| public/ | | Analytics |
| | | RSS / Sitemap |
| ViewFuncs{ | | Rate Limiter |
| Home: ..., | | Session / CSRF |
| Post: ..., | | Markdown |
| } | | Image Library |
+-----------------+ +-------------------+
go install github.com/eringen/pubengine/cmd/pubengine@latestpubengine new github.com/yourname/myblog
cd myblogThis generates a complete project:
myblog/
├── main.go # ~40 lines: config + ViewFuncs wiring
├── go.mod
├── views/
│ ├── home.templ # Home page with blog listing
│ ├── post.templ # Single post with related posts
│ ├── admin.templ # Admin login + dashboard + editor
│ ├── nav.templ # Head, Nav, Footer
│ ├── notfound.templ # 404 page
│ ├── servererror.templ # 500 page
│ └── helpers.go # Type aliases for BlogPost, PageMeta
├── assets/
│ └── tailwind.css # Tailwind directives
├── src/
│ └── app.js # Custom JavaScript entry point
├── public/
│ ├── robots.txt
│ └── favicon.svg
├── data/ # SQLite databases (auto created)
├── Makefile
├── package.json
├── tailwind.config.js
└── .env.example
go mod tidy
npm install
make runYour blog is running at http://localhost:3000. Admin dashboard at /admin/.
Every pubengine site follows the same structure:
package main
import (
"log"
"github.com/eringen/pubengine"
"myblog/views"
)
func main() {
app := pubengine.New(
pubengine.SiteConfig{
Name: pubengine.EnvOr("SITE_NAME", "My Blog"),
URL: pubengine.EnvOr("SITE_URL", "http://localhost:3000"),
Description: pubengine.EnvOr("SITE_DESCRIPTION", "A blog about things"),
Author: pubengine.EnvOr("SITE_AUTHOR", "Your Name"),
Addr: pubengine.EnvOr("ADDR", ":3000"),
DatabasePath: pubengine.EnvOr("DATABASE_PATH", "data/blog.db"),
AdminPassword: pubengine.MustEnv("ADMIN_PASSWORD"),
SessionSecret: pubengine.MustEnv("ADMIN_SESSION_SECRET"),
CookieSecure: pubengine.EnvOr("COOKIE_SECURE", "") == "true",
},
pubengine.ViewFuncs{
Home: views.Home,
HomePartial: views.HomePartial,
BlogSection: views.BlogSection,
Post: views.Post,
PostPartial: views.PostPartial,
AdminLogin: views.AdminLogin,
AdminDashboard: views.AdminDashboard,
AdminFormPartial: views.AdminFormPartial,
NotFound: views.NotFound,
ServerError: views.ServerError,
},
)
defer app.Close()
if err := app.Start(); err != nil {
log.Fatal(err)
}
}This is the core inversion of control mechanism. You provide templ components, pubengine calls them from its handlers:
type ViewFuncs struct {
// Full page renders (initial page load)
Home func(posts []BlogPost, activeTag string, tags []string, siteURL string) templ.Component
Post func(post BlogPost, posts []BlogPost, siteURL string) templ.Component
// HTMX partial renders (SPA like navigation)
HomePartial func(posts []BlogPost, activeTag string, tags []string, siteURL string) templ.Component
BlogSection func(posts []BlogPost, activeTag string, tags []string) templ.Component
PostPartial func(post BlogPost, posts []BlogPost, siteURL string) templ.Component
// Admin pages
AdminLogin func(showError bool, csrfToken string, googleLoginURL string) templ.Component
AdminDashboard func(posts []BlogPost, message string, csrfToken string) templ.Component
AdminFormPartial func(post BlogPost, csrfToken string) templ.Component
AdminImages func(images []Image, csrfToken string) templ.Component
// Error pages
NotFound func() templ.Component
ServerError func() templ.Component
}The framework handles when to call full vs. partial renders based on HTMX headers automatically.
All configuration in one struct:
| Field | Type | Default | Description |
|---|---|---|---|
Name |
string |
"Blog" |
Site name for nav, footer, RSS, JSON-LD |
URL |
string |
"http://localhost:3000" |
Canonical URL for sitemap, RSS, OpenGraph |
Description |
string |
"" |
Site description for RSS and meta tags |
Author |
string |
"" |
Author name for JSON-LD structured data |
Addr |
string |
":3000" |
Server listen address |
DatabasePath |
string |
"data/blog.db" |
SQLite database path |
AnalyticsEnabled |
bool |
false |
Enable built in analytics |
AnalyticsDatabasePath |
string |
"data/analytics.db" |
Analytics SQLite path |
AdminPassword |
string |
required | Admin login password |
SessionSecret |
string |
required | Session cookie encryption secret |
CookieSecure |
bool |
false |
Set true when behind HTTPS |
GoogleClientID |
string |
"" |
Google OAuth client ID (optional) |
GoogleClientSecret |
string |
"" |
Google OAuth client secret (optional) |
GoogleAdminEmail |
string |
"" |
Allowed Google email for admin login (optional) |
PostCacheTTL |
time.Duration |
5m |
In memory post cache TTL |
Configure additional behavior with option functions:
// Add custom routes (runs after pubengine's routes)
pubengine.WithCustomRoutes(func(a *pubengine.App) {
a.Echo.GET("/about/", handleAbout)
a.Echo.Static("/portfolio", "portfolio")
})
// Change the static assets directory (default: "public")
pubengine.WithStaticDir("static")The App struct exposes the underlying components for advanced use:
app := pubengine.New(cfg, views)
app.Config // SiteConfig
app.Echo // *echo.Echo, the HTTP server
app.Store // *Store, SQLite operations
app.Cache // *PostCache, in memory cache
app.Views // ViewFuncstype BlogPost struct {
Title string
Date string // "2024-01-15" format
Tags []string
Summary string
Link string // "/blog/my-post" (auto generated)
Slug string // "my-post"
Content string // Markdown source
Published bool
}type PageMeta struct {
Title string // Page title and og:title
Description string // Meta description and og:description
URL string // Canonical URL and og:url
OGType string // "website" or "article"
}pubengine registers these routes automatically:
| Method | Path | Description |
|---|---|---|
GET |
/ |
Home page with blog listing |
GET |
/blog/:slug/ |
Single blog post |
GET |
/feed.xml |
RSS feed |
GET |
/sitemap.xml |
XML sitemap |
GET |
/robots.txt |
Robots.txt (from static dir) |
GET |
/favicon.svg |
Favicon (from static dir) |
GET |
/public/* |
Static assets |
| Method | Path | Description |
|---|---|---|
GET |
/admin/ |
Login page or dashboard |
POST |
/admin/login/ |
Process login |
POST |
/admin/logout/ |
Logout |
GET |
/admin/post/:slug/ |
Edit post form (HTMX) |
POST |
/admin/save/ |
Create or update post |
DELETE |
/admin/post/:slug/ |
Delete post |
GET |
/admin/images/ |
Image library (HTMX) |
POST |
/admin/images/upload/ |
Upload image |
DELETE |
/admin/images/:filename/ |
Delete image |
| Method | Path | Description |
|---|---|---|
POST |
/api/analytics/collect |
Track page view |
GET |
/admin/analytics/ |
Analytics dashboard |
GET |
/admin/analytics/api/stats |
Stats JSON |
GET |
/admin/analytics/fragments/stats |
Stats HTML fragment |
GET |
/admin/analytics/api/bot-stats |
Bot stats JSON |
GET |
/admin/analytics/fragments/bot-stats |
Bot stats HTML fragment |
pubengine exports utility functions for use in your templates:
// URL and path helpers
pubengine.BuildURL(base, "blog", slug) // "https://example.com/blog/my-post/"
pubengine.PathEscape(tag) // URL safe tag encoding
pubengine.Slugify("My Post Title") // "my-post-title"
// Tag helpers
pubengine.JoinTags(tags) // "go, web, sqlite"
pubengine.FilterEmpty(tags) // Remove empty strings
pubengine.FilterRelatedPosts(current, all) // Posts sharing tags
// JSON-LD structured data
pubengine.WebsiteJsonLD(cfg) // WebSite schema
pubengine.BlogPostingJsonLD(post, cfg) // BlogPosting schema
// Environment helpers (for main.go)
pubengine.EnvOr("KEY", "default") // Get env var with fallback
pubengine.MustEnv("KEY") // Get env var or log.Fatal
// Template rendering
pubengine.Render(c, component) // Render as HTTP 200
pubengine.RenderStatus(c, 404, component) // Render with status code
// Auth helpers
pubengine.IsAdmin(c) // Check if session is authenticated
pubengine.CsrfToken(c) // Extract CSRF token from contextpubengine includes a custom markdown renderer (pubengine/markdown package) with no external dependencies.
| Syntax | Output |
|---|---|
**bold** or __bold__ |
bold |
*italic* or _italic_ |
italic |
`code` |
Inline code |
# Heading 1 |
<h1> |
## Heading 2 |
<h2> |
### Heading 3 |
<h3> |
[text](url) |
Link (same tab) |
[text](url)^ |
Link (new tab, adds target="_blank") |
{style} |
Image with inline CSS |
{style|w|h} |
Image with dimensions |
- item |
Unordered list |
1. item |
Ordered list |
> quote |
Blockquote |
``` |
Code block |
```lang |
Code block with language badge |
|col|col| |
Table |
--- |
Horizontal rule |
import "github.com/eringen/pubengine/markdown"
// In a templ component:
@markdown.Markdown(post.Content)import "github.com/eringen/pubengine/markdown"
var buf bytes.Buffer
markdown.RenderMarkdown(&buf, "**hello** world")
// buf.String() == "<p><strong>hello</strong> world\n</p>"All text is HTML escaped before formatting. Only http, https, mailto, and tel URL schemes are allowed. Bold/italic regex runs only on text outside HTML tags to prevent URL corruption. First image gets fetchpriority="high" for LCP optimization. Inline code content is protected from bold/italic formatting.
pubengine includes a built in, privacy first analytics system. No cookies, no third party scripts, no personal data stored.
IP addresses are hashed with a salted SHA-256 (salt rotates, stored in DB). Visitor IDs are derived from IP + User Agent hash (no cookies). Bot traffic is detected and tracked separately. The system respects Do Not Track (DNT) headers. Data retention is configurable with automatic cleanup (default: 365 days). All data stays in your SQLite database.
pubengine.SiteConfig{
AnalyticsEnabled: true,
AnalyticsDatabasePath: "data/analytics.db",
// ...
}The framework ships analytics.js as an embedded asset, automatically served at /public/analytics.js. Include it in your template <head>:
<script src="/public/analytics.js" defer></script>The script tracks page views, time on page, and handles HTMX navigation. It uses navigator.sendBeacon for reliable unload tracking.
The analytics dashboard is available at /admin/analytics/ (requires admin login). The admin nav bar includes a link to it. It shows:
- Realtime visitors (last 5 minutes)
- Unique visitors and total page views
- Average time on page
- Top pages and latest visits (last 10)
- Browser, OS, and device breakdown
- Referrer sources
- Daily/hourly/monthly view charts
- Bot traffic (separate tab with independent period selection)
The dashboard is fully self contained. Its CSS (admin.css) and JS (dashboard.min.js) are embedded in the binary alongside htmx.min.js.
The analytics collect endpoint is rate limited to 60 requests per IP per minute to prevent flooding.
pubengine supports an optional Google OAuth login for the admin panel. When configured, a "Sign in with Google" button appears on the login page alongside the password form. Password login always remains available as a fallback.
- Create OAuth credentials in the Google Cloud Console
- Set the authorized redirect URI to
https://yourdomain.com/admin/auth/google/callback - Set the environment variables:
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_ADMIN_EMAIL=you@gmail.comOr in your SiteConfig:
pubengine.SiteConfig{
GoogleClientID: pubengine.EnvOr("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: pubengine.EnvOr("GOOGLE_CLIENT_SECRET", ""),
GoogleAdminEmail: pubengine.EnvOr("GOOGLE_ADMIN_EMAIL", ""),
// ...
}All three fields must be set for Google login to be enabled. Only the email matching GOOGLE_ADMIN_EMAIL (case-insensitive) is allowed to log in.
pubengine configures a production ready middleware stack:
- NonWWWRedirect redirects
www.to bare domain - RequestLogger logs method, URI, status code, latency
- Recover provides panic recovery with error logging
- Security headers include CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- Session uses cookie based sessions (gorilla/sessions, 12 hour expiry)
- CSRF provides token based protection (skipped for analytics endpoint)
- Trailing slash enforces consistent URL format
- Cache-Control sets static assets to 1 year immutable, pages to 1 hour, admin to no-store
SQLite at data/blog.db (auto created on first run).
CREATE TABLE posts (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
tags TEXT NOT NULL, -- comma delimited: ",go,web,"
summary TEXT NOT NULL,
content TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 1
);Separate SQLite at data/analytics.db.
CREATE TABLE visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_id TEXT NOT NULL,
session_id TEXT NOT NULL,
ip_hash TEXT NOT NULL,
browser TEXT NOT NULL,
os TEXT NOT NULL,
device TEXT NOT NULL,
path TEXT NOT NULL,
referrer TEXT,
screen_size TEXT,
timestamp DATETIME NOT NULL,
duration_sec INTEGER DEFAULT 0
);
CREATE TABLE bot_visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_name TEXT NOT NULL,
ip_hash TEXT NOT NULL,
user_agent TEXT NOT NULL,
path TEXT NOT NULL,
timestamp DATETIME NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);Both databases use WAL mode with tuned pragmas (busy_timeout, synchronous=NORMAL, 8MB cache, 256MB mmap) for concurrent read performance.
The Store provides all blog CRUD operations:
store, err := pubengine.NewStore("data/blog.db")
defer store.Close()
// Published posts (for public pages)
posts, _ := store.ListPosts("") // all published, newest first
posts, _ := store.ListPosts("go") // filtered by tag (case insensitive)
post, _ := store.GetPost("my-slug") // single published post
tags, _ := store.ListTags() // unique tags from published posts
// All posts (for admin)
posts, _ := store.ListAllPosts() // including drafts
post, _ := store.GetPostAny("my-slug") // regardless of published status
// Write operations
store.SavePost(post) // insert or replace
store.DeletePost("my-slug") // delete by slugThe PostCache wraps the store with an in memory cache:
cache := pubengine.NewPostCache(store, 5*time.Minute)
posts, _ := cache.ListPosts("") // from cache if fresh, else DB
tags, _ := cache.ListTags() // from cache
post, _ := cache.GetPost("slug") // from cached post list
cache.Invalidate() // clear on write operationspubengine/
├── pubengine.go # App struct, New(), Start(), Close()
├── config.go # SiteConfig, Option functions
├── types.go # BlogPost, PageMeta, Image
├── store.go # SQLite blog CRUD
├── cache.go # In memory post cache
├── handlers.go # Blog handlers (home, post, feed, sitemap)
├── admin.go # Admin handlers (login, save, delete, images)
├── middleware.go # Security headers, sessions, CSRF, cache
├── render.go # Render helpers
├── helpers.go # Slugify, BuildURL, JSON-LD, tag utils
├── images.go # Image upload, resize, library
├── limiter.go # Login rate limiter
├── rss.go # RSS XML generation
├── sitemap.go # Sitemap XML generation
├── embed.go # Embedded static assets
├── embedded/
│ ├── htmx.min.js # HTMX library
│ ├── analytics.js # Client side tracking script
│ ├── dashboard.min.js # Analytics dashboard JS
│ └── admin.css # Analytics dashboard styles
├── markdown/
│ ├── markdown.go # Custom markdown renderer
│ └── markdown_test.go
├── analytics/
│ ├── analytics.go # IP hashing, UA parsing, bot detection
│ ├── store.go # Analytics SQLite operations
│ ├── handlers.go # Collection + dashboard handlers
│ ├── limiter.go # Analytics rate limiter
│ ├── sqlcgen/ # Generated SQL (sqlc)
│ └── templates/ # Analytics dashboard templ templates
├── scaffold/
│ ├── scaffold.go # embed.FS for templates
│ └── templates/ # Project scaffolding templates
├── cmd/pubengine/
│ ├── main.go # CLI entry point
│ └── new.go # Scaffold logic
├── store_test.go
├── limiter_test.go
└── go.mod
pubengine new github.com/yourname/myblogCreates a new project directory with everything needed to run a blog. The last segment of the module path becomes the directory name (myblog).
Template variables:
{{.ProjectName}}is the directory name (e.g.,myblog){{.ModuleName}}is the full module path (e.g.,github.com/yourname/myblog){{.SiteName}}is the title cased name (e.g.,Myblog)
pubengine versionAfter pubengine new, the generated Makefile and package.json provide:
make run # Generate templates, build CSS + JS, start server
make templ # Regenerate templ templates
make css # Build Tailwind CSS
make css-prod # Production CSS (minified)
make js # Bundle and minify src/app.js
make test # Run Go tests
make build-linux # Cross compile for Linuxnpm run css # Build Tailwind CSS (minified)
npm run css:watch # Watch mode for CSS
npm run js # Bundle and minify src/app.js via esbuild
npm run js:watch # Watch mode for JS
npm run build # Build both CSS and JS| Variable | Required | Default | Description |
|---|---|---|---|
ADMIN_PASSWORD |
yes | Admin login password | |
ADMIN_SESSION_SECRET |
yes | Session encryption secret (32+ chars) | |
SITE_NAME |
no | Blog |
Site name for nav, RSS, JSON-LD |
SITE_URL |
no | http://localhost:3000 |
Canonical URL for sitemap and OpenGraph |
SITE_DESCRIPTION |
no | "" |
Description for RSS and meta tags |
SITE_AUTHOR |
no | "" |
Author name for JSON-LD |
COOKIE_SECURE |
no | false |
Set true behind HTTPS |
GOOGLE_CLIENT_ID |
no | "" |
Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
no | "" |
Google OAuth client secret |
GOOGLE_ADMIN_EMAIL |
no | "" |
Allowed Google email for admin login |
DATABASE_PATH |
no | data/blog.db |
Blog SQLite path |
ANALYTICS_DATABASE_PATH |
no | data/analytics.db |
Analytics SQLite path |
ADDR |
no | :3000 |
Server listen address |
| Package | Version | Purpose |
|---|---|---|
| echo/v4 | v4.14.0 | HTTP framework |
| templ | v0.3.960 | Type safe HTML templates |
| modernc.org/sqlite | v1.44.2 | Pure Go SQLite driver |
| gorilla/sessions | v1.2.2 | Cookie session management |
| echo-contrib | v0.17.1 | Echo session middleware |
No JavaScript framework dependencies. HTMX and the analytics script are embedded in the binary.
# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run benchmarks
go test -bench=. ./...Test coverage includes store operations, rate limiting, and markdown rendering.
pubengine compiles to a single binary. Deploy it with your public/ directory and a data/ directory for SQLite:
# Build for Linux
GOOS=linux GOARCH=amd64 go build -o mysite .
# On the server
./mysite
# Needs: public/ directory, data/ directory (auto created), env vars setThe binary embeds HTMX, the analytics script, the analytics dashboard JS, and the admin CSS. User assets (CSS, JS, fonts, images) live in the public/ directory alongside the binary.
MIT