Skip to content

Repository files navigation

pubengine

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, talkDOM, Tailwind CSS, and SQLite.

How it works

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 |
+-----------------+ +-------------------+

Quick start

Install the CLI

go install github.com/eringen/pubengine/cmd/pubengine@latest

Scaffold a new project

pubengine new github.com/yourname/myblog
cd myblog

This 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

Run it

go mod tidy
npm install
make run

Your blog is running at http://localhost:3000. Admin dashboard at /admin/.

Usage

The main.go pattern

Every pubengine site follows the same structure:

package main
import (
"log""github.com/eringen/pubengine""myblog/views"
)
funcmain() {
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,
},
)
deferapp.Close()
iferr:=app.Start(); err!=nil {
log.Fatal(err)
}
}

ViewFuncs

This is the core inversion of control mechanism. You provide templ components, pubengine calls them from its handlers:

typeViewFuncsstruct {
// Full page renders (initial page load)Homefunc(posts []BlogPost, activeTagstring, tags []string, siteURLstring) templ.ComponentPostfunc(postBlogPost, posts []BlogPost, siteURLstring) templ.Component// talkDOM partial renders (SPA like navigation)HomePartialfunc(posts []BlogPost, activeTagstring, tags []string, siteURLstring) templ.ComponentBlogSectionfunc(posts []BlogPost, activeTagstring, tags []string) templ.ComponentPostPartialfunc(postBlogPost, posts []BlogPost, siteURLstring) templ.Component// Admin pagesAdminLoginfunc(showErrorbool, csrfTokenstring, googleLoginURLstring) templ.ComponentAdminDashboardfunc(posts []BlogPost, messagestring, csrfTokenstring) templ.ComponentAdminFormPartialfunc(postBlogPost, csrfTokenstring) templ.ComponentAdminImagesfunc(images []Image, csrfTokenstring) templ.Component// Error pagesNotFoundfunc() templ.ComponentServerErrorfunc() templ.Component
}

The framework handles when to call full vs. partial renders based on talkDOM headers automatically.

SiteConfig

All configuration in one struct:

FieldTypeDefaultDescription
Namestring"Blog"Site name for nav, footer, RSS, JSON-LD
URLstring"http://localhost:3000"Canonical URL for sitemap, RSS, OpenGraph
Descriptionstring""Site description for RSS and meta tags
Authorstring""Author name for JSON-LD structured data
Addrstring":3000"Server listen address
DatabasePathstring"data/blog.db"SQLite database path
AnalyticsEnabledboolfalseEnable built in analytics
AnalyticsDatabasePathstring"data/analytics.db"Analytics SQLite path
AdminPasswordstringrequiredAdmin login password
SessionSecretstringrequiredSession cookie encryption secret
CookieSecureboolfalseSet true when behind HTTPS
GoogleClientIDstring""Google OAuth client ID (optional)
GoogleClientSecretstring""Google OAuth client secret (optional)
GoogleAdminEmailstring""Allowed Google email for admin login (optional)
PostCacheTTLtime.Duration5mIn memory post cache TTL

Options

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")

Accessing the App

The App struct exposes the underlying components for advanced use:

app:=pubengine.New(cfg, views)
app.Config// SiteConfigapp.Echo// *echo.Echo, the HTTP serverapp.Store// *Store, SQLite operationsapp.Cache// *PostCache, in memory cacheapp.Views// ViewFuncs

Core types

BlogPost

typeBlogPoststruct {
TitlestringDatestring// "2024-01-15" formatTags []stringSummarystringLinkstring// "/blog/my-post" (auto generated)Slugstring// "my-post"Contentstring// Markdown sourcePublishedbool
}

PageMeta

typePageMetastruct {
Titlestring// Page title and og:titleDescriptionstring// Meta description and og:descriptionURLstring// Canonical URL and og:urlOGTypestring// "website" or "article"
}

Routes

pubengine registers these routes automatically:

Public

MethodPathDescription
GET/Home page with blog listing
GET/blog/:slug/Single blog post
GET/feed.xmlRSS feed
GET/sitemap.xmlXML sitemap
GET/robots.txtRobots.txt (from static dir)
GET/favicon.svgFavicon (from static dir)
GET/public/*Static assets

Admin

MethodPathDescription
GET/admin/Login page or dashboard
POST/admin/login/Process login
POST/admin/logout/Logout
GET/admin/post/:slug/Edit post form (talkDOM)
POST/admin/save/Create or update post
DELETE/admin/post/:slug/Delete post
GET/admin/images/Image library (talkDOM)
POST/admin/images/upload/Upload image
DELETE/admin/images/:filename/Delete image

Analytics (when enabled)

MethodPathDescription
POST/api/analytics/collectTrack page view
GET/admin/analytics/Analytics dashboard
GET/admin/analytics/api/statsStats JSON
GET/admin/analytics/fragments/statsStats HTML fragment
GET/admin/analytics/api/bot-statsBot stats JSON
GET/admin/analytics/fragments/bot-statsBot stats HTML fragment

Helper functions

pubengine exports utility functions for use in your templates:

// URL and path helperspubengine.BuildURL(base, "blog", slug) // "https://example.com/blog/my-post/"pubengine.PathEscape(tag) // URL safe tag encodingpubengine.Slugify("My Post Title") // "my-post-title"// Tag helperspubengine.JoinTags(tags) // "go, web, sqlite"pubengine.FilterEmpty(tags) // Remove empty stringspubengine.FilterRelatedPosts(current, all) // Posts sharing tags// JSON-LD structured datapubengine.WebsiteJsonLD(cfg) // WebSite schemapubengine.BlogPostingJsonLD(post, cfg) // BlogPosting schema// Environment helpers (for main.go)pubengine.EnvOr("KEY", "default") // Get env var with fallbackpubengine.MustEnv("KEY") // Get env var or log.Fatal// Template renderingpubengine.Render(c, component) // Render as HTTP 200pubengine.RenderStatus(c, 404, component) // Render with status code// Auth helperspubengine.IsAdmin(c) // Check if session is authenticatedpubengine.CsrfToken(c) // Extract CSRF token from context

Markdown

pubengine includes a custom markdown renderer (pubengine/markdown package) with no external dependencies.

Supported syntax

SyntaxOutput
**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")
![alt](url){style}Image with inline CSS
![alt](url){style|w|h}Image with dimensions
- itemUnordered list
1. itemOrdered list
> quoteBlockquote
```Code block
```langCode block with language badge
|col|col|Table
---Horizontal rule

Usage in templates

import"github.com/eringen/pubengine/markdown"// In a templ component:
@markdown.Markdown(post.Content)

Programmatic usage

import"github.com/eringen/pubengine/markdown"varbuf bytes.Buffermarkdown.RenderMarkdown(&buf, "**hello** world")
// buf.String() == "<p><strong>hello</strong> world\n</p>"

Security

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.

Analytics

pubengine includes a built in, privacy first analytics system. No cookies, no third party scripts, no personal data stored.

How it works

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.

Enabling analytics

pubengine.SiteConfig{
AnalyticsEnabled: true,
AnalyticsDatabasePath: "data/analytics.db",
// ...
}

Client side tracking

The framework ships analytics.js as an embedded asset, automatically served at /public/analytics.js. Include it in your template <head>:

<scriptsrc="/public/analytics.js" defer></script>

The script tracks page views, time on page, and handles talkDOM navigation. It uses navigator.sendBeacon for reliable unload tracking.

Dashboard

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 talkdom.js.

Rate limiting

The analytics collect endpoint is rate limited to 60 requests per IP per minute to prevent flooding.

Google OAuth login

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.

Setup

  1. Create OAuth credentials in the Google Cloud Console
  2. Set the authorized redirect URI to https://yourdomain.com/admin/auth/google/callback
  3. Set the environment variables:
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_ADMIN_EMAIL=you@gmail.com

Or 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.

Middleware

pubengine configures a production ready middleware stack:

  1. NonWWWRedirect redirects www. to bare domain
  2. RequestLogger logs method, URI, status code, latency
  3. Recover provides panic recovery with error logging
  4. Security headers include CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
  5. Session uses cookie based sessions (gorilla/sessions, 12 hour expiry)
  6. CSRF provides token based protection (skipped for analytics endpoint)
  7. Trailing slash enforces consistent URL format
  8. Cache-Control sets static assets to 1 year immutable, pages to 1 hour, admin to no-store

Database

Blog database

SQLite at data/blog.db (auto created on first run).

CREATETABLEposts (
slug TEXTPRIMARY KEY,
title TEXTNOT NULL,
dateTEXTNOT NULL,
tags TEXTNOT NULL, -- comma delimited: ",go,web,"
summary TEXTNOT NULL,
content TEXTNOT NULL,
published INTEGERNOT NULL DEFAULT 1
);

Analytics database

Separate SQLite at data/analytics.db.

CREATETABLEvisits (
id INTEGERPRIMARY KEY AUTOINCREMENT,
visitor_id TEXTNOT NULL,
session_id TEXTNOT NULL,
ip_hash TEXTNOT NULL,
browser TEXTNOT NULL,
os TEXTNOT NULL,
device TEXTNOT NULL,
pathTEXTNOT NULL,
referrer TEXT,
screen_size TEXT,
timestamp DATETIME NOT NULL,
duration_sec INTEGER DEFAULT 0
);
CREATETABLEbot_visits (
id INTEGERPRIMARY KEY AUTOINCREMENT,
bot_name TEXTNOT NULL,
ip_hash TEXTNOT NULL,
user_agent TEXTNOT NULL,
pathTEXTNOT NULL,
timestamp DATETIME NOT NULL
);
CREATETABLEsettings (
key TEXTPRIMARY KEY,
value TEXTNOT NULL
);

Both databases use WAL mode with tuned pragmas (busy_timeout, synchronous=NORMAL, 8MB cache, 256MB mmap) for concurrent read performance.

Store API

The Store provides all blog CRUD operations:

store, err:=pubengine.NewStore("data/blog.db")
deferstore.Close()
// Published posts (for public pages)posts, _:=store.ListPosts("") // all published, newest firstposts, _:=store.ListPosts("go") // filtered by tag (case insensitive)post, _:=store.GetPost("my-slug") // single published posttags, _:=store.ListTags() // unique tags from published posts// All posts (for admin)posts, _:=store.ListAllPosts() // including draftspost, _:=store.GetPostAny("my-slug") // regardless of published status// Write operationsstore.SavePost(post) // insert or replacestore.DeletePost("my-slug") // delete by slug

Cache API

The PostCache wraps the store with an in memory cache:

cache:=pubengine.NewPostCache(store, 5*time.Minute)
posts, _:=cache.ListPosts("") // from cache if fresh, else DBtags, _:=cache.ListTags() // from cachepost, _:=cache.GetPost("slug") // from cached post listcache.Invalidate() // clear on write operations

Project structure

pubengine/
├── 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/
│ ├── talkdom.js # talkDOM 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

CLI

pubengine new

pubengine new github.com/yourname/myblog

Creates 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 version

pubengine version

Scaffolded project commands

After pubengine new, the generated Makefile and package.json provide:

Make targets

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 Linux

npm scripts

npm 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

Environment variables

VariableRequiredDefaultDescription
ADMIN_PASSWORDyesAdmin login password
ADMIN_SESSION_SECRETyesSession encryption secret (32+ chars)
SITE_NAMEnoBlogSite name for nav, RSS, JSON-LD
SITE_URLnohttp://localhost:3000Canonical URL for sitemap and OpenGraph
SITE_DESCRIPTIONno""Description for RSS and meta tags
SITE_AUTHORno""Author name for JSON-LD
COOKIE_SECUREnofalseSet true behind HTTPS
GOOGLE_CLIENT_IDno""Google OAuth client ID
GOOGLE_CLIENT_SECRETno""Google OAuth client secret
GOOGLE_ADMIN_EMAILno""Allowed Google email for admin login
DATABASE_PATHnodata/blog.dbBlog SQLite path
ANALYTICS_DATABASE_PATHnodata/analytics.dbAnalytics SQLite path
ADDRno:3000Server listen address

Dependencies

PackageVersionPurpose
echo/v4v4.14.0HTTP framework
templv0.3.960Type safe HTML templates
modernc.org/sqlitev1.44.2Pure Go SQLite driver
gorilla/sessionsv1.2.2Cookie session management
echo-contribv0.17.1Echo session middleware

No JavaScript framework dependencies. talkDOM and the analytics script are embedded in the binary.

Testing

# 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.

Deployment

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 set

The binary embeds talkDOM, 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.

License

MIT MIT

About

A minimal blog engine built with Go, HTMX, and SQLite. One binary, no JS frameworks, just write and publish.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages