Theme Development Guide
Theme Development Guide
Audience: Theme developers and front-end engineers
Prerequisite: Familiarity with HTML, CSS, and Gohtml/templatesyntax
Last Updated: 2026-03-30
This guide covers how to build, customize, and override BlogFlow themes. Start with the directory structure to understand the layout, then work through templates, functions, and styling.
1. Theme Directory Structure
A BlogFlow theme is a directory containing templates, static assets, and an optional manifest:
my-theme/
├── theme.yaml # Theme metadata (optional)
├── templates/
│ ├── base.html # Master layout — all pages extend this
│ ├── post.html # Single blog post
│ ├── list.html # Post listing / homepage
│ ├── page.html # Static page (about, contact)
│ ├── 404.html # Not found page
│ └── partials/
│ ├── header.html # Site header / navigation
│ ├── footer.html # Site footer
│ ├── post-meta.html # Post date, tags, reading time
│ └── pagination.html # Previous / next page controls
└── static/
├── css/
│ └── main.css # Primary stylesheet
├── images/
│ └── favicon.svg # Site icon
└── js/ # Client-side scripts (if any)
Activate a custom theme:
blogflow --content ./content --theme ./my-theme
Or in site.yaml:
theme:
name: "my-theme"
path: "./my-theme"
2. Go html/template Syntax Basics
BlogFlow templates use Go’s standard
html/template package, which provides
automatic HTML escaping for XSS safety.
Actions
<!-- Output a value (auto-escaped) -->
{{ .Site.Title }}
<!-- Conditional -->
{{ if .Post }}
<h1>{{ .Post.Title }}</h1>
{{ else }}
<h1>{{ .Title }}</h1>
{{ end }}
<!-- Range over a slice -->
{{ range .Posts }}
<article>
<h2>{{ .Title }}</h2>
<p>{{ .Summary }}</p>
</article>
{{ end }}
<!-- Range with empty fallback -->
{{ range .Posts }}
<article>{{ .Title }}</article>
{{ else }}
<p>No posts yet.</p>
{{ end }}
<!-- Variable assignment -->
{{ $name := .Site.Author.Name }}
<span>{{ $name }}</span>
<!-- With (rebind dot) -->
{{ with .Post }}
<h1>{{ .Title }}</h1>
{{ end }}
Template Blocks
The base template defines named blocks that child templates override:
<!-- base.html -->
<html>
<head>
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
</head>
<body>
{{ template "partials/header.html" . }}
<main>{{ block "content" . }}{{ end }}</main>
{{ template "partials/footer.html" . }}
</body>
</html>
<!-- post.html — overrides blocks from base.html -->
{{ define "title" }}{{ .Post.Title }} — {{ .Site.Title }}{{ end }}
{{ define "content" }}
<article>
<h1>{{ .Post.Title }}</h1>
{{ template "post-meta" .Post }}
<div class="post-content">{{ .Post.Content }}</div>
</article>
{{ end }}
Including Partials
{{ template "partials/header.html" . }}
{{ template "partials/footer.html" . }}
{{ template "post-meta" .Post }}
{{ template "pagination" . }}
The dot (.) passes the current data context to the partial.
3. Available Template Functions
BlogFlow registers these functions in addition to Go’s built-in template functions:
| Function | Signature | Description |
|---|---|---|
formatDate |
(t time.Time, layout string) string |
Format a time value using a Go time layout string. |
now |
() time.Time |
Returns the current time. |
lower |
(s string) string |
Convert string to lowercase. |
upper |
(s string) string |
Convert string to uppercase. |
truncate |
(s string, n int) string |
Truncate to n runes at word boundary, appending …. |
readingTime |
(content string) int |
Estimated reading time in minutes (~200 wpm). |
urlize |
(s string) string |
Convert to URL-safe slug: lowercase, spaces→hyphens, strip special chars. |
add |
(a, b int) int |
Integer addition. |
sub |
(a, b int) int |
Integer subtraction. |
seq |
(start, end int) ([]int, error) |
Generate an integer sequence. Max range: 10,000. |
Usage Examples
<!-- Format a post date -->
<time datetime="{{ formatDate .Date "2006-01-02" }}">
{{ formatDate .Date "January 2, 2006" }}
</time>
<!-- Show reading time (readingTime always returns at least 1) -->
{{ $min := readingTime .Content }}
<span>{{ $min }} min read</span>
<!-- Generate a tag URL -->
<a href="/tags/{{ urlize .Tag }}">{{ .Tag }}</a>
<!-- Truncate a summary -->
<p>{{ truncate .Summary 120 }}</p>
<!-- Pagination arithmetic -->
<span>Page {{ .Pagination.CurrentPage }} of {{ .Pagination.TotalPages }}</span>
<!-- Previous / Next links using PrevPage and NextPage (int fields) -->
{{ if .Pagination.HasPrev }}<a href="?page={{ .Pagination.PrevPage }}">← Prev</a>{{ end }}
{{ if .Pagination.HasNext }}<a href="?page={{ .Pagination.NextPage }}">Next →</a>{{ end }}
<!-- Generate page number sequence -->
{{ range seq 1 .Pagination.TotalPages }}
<a href="?page={{ . }}">{{ . }}</a>
{{ end }}
<!-- Copyright year -->
<footer>© {{ formatDate now "2006" }} {{ .Site.Author.Name }}</footer>
4. Template Data Context
Every template receives a PageData struct as its root context (.):
PageData
| Field | Type | Available In | Description |
|---|---|---|---|
.Site |
SiteConfig |
All templates | Site metadata from configuration. |
.Feed |
FeedConfig |
All templates | Feed settings (enabled, type, items). |
.Post |
*Post |
post.html |
The current blog post. |
.Page |
*Post |
page.html |
The current static page. |
.Posts |
[]*Post |
list.html |
List of posts for current page. |
.Tag |
string |
list.html (tag) |
Current tag filter (empty on homepage). |
.Title |
string |
All templates | Page title override. |
.Pagination |
*Pagination |
list.html |
Pagination metadata. |
.Search |
*SearchData |
All templates | Search affordance state (always set); results on search.html. |
Post (and Page)
Posts and pages share the same structure:
| Field | Type | Description |
|---|---|---|
.Title |
string |
Title from front matter. |
.Slug |
string |
URL segment. |
.Date |
time.Time |
Publication date. |
.Updated |
time.Time |
Last modified date (zero if unset). |
.Draft |
bool |
Draft status. |
.Tags |
[]string |
Tag list. |
.Categories |
[]string |
Category list. |
.Author |
string |
Author name (post-level or site default). |
.Description |
string |
Summary for SEO / feeds. |
.Template |
string |
Custom template name (if specified). |
.Image |
string |
Featured image URL. |
.Content |
template.HTML |
Rendered HTML from Markdown (safe to emit). |
.Summary |
string |
Auto-generated plain-text summary (~200 chars). |
.ReadingTime |
int |
Estimated reading time in minutes. |
.Path |
string |
Original .md file path relative to content root. |
SiteConfig
| Field | Type | Description |
|---|---|---|
.Site.Title |
string |
Site display name. |
.Site.Description |
string |
Meta description. |
.Site.BaseURL |
string |
Canonical base URL. |
.Site.Language |
string |
BCP 47 language tag (e.g., "en"). |
.Site.Author.Name |
string |
Default author name. |
.Site.Author.Email |
string |
Default author email. |
FeedConfig
| Field | Type | Description |
|---|---|---|
.Feed.Enabled |
bool |
Whether feed generation is on. |
.Feed.Type |
string |
"atom" or "rss". |
.Feed.Items |
int |
Maximum items in the feed. |
Pagination
| Field | Type | Description |
|---|---|---|
.Pagination.CurrentPage |
int |
Current page number (1-indexed). |
.Pagination.TotalPages |
int |
Total number of pages. |
.Pagination.HasPrev |
bool |
Whether a previous page exists. |
.Pagination.HasNext |
bool |
Whether a next page exists. |
.Pagination.PrevPage |
int |
Previous page number. |
.Pagination.NextPage |
int |
Next page number. |
SearchData
.Search is set on every page so the header/nav can render or omit a
global search box based on .Search.Enabled. On search.html it also carries
the query, results, and pagination. Search is opt-in — enable it with
search.enabled: true in site.yaml (takes effect at startup; toggling
requires a restart).
| Field | Type | Description |
|---|---|---|
.Search.Enabled |
bool |
Whether the /search route is registered. Guard header UI with this. |
.Search.Executed |
bool |
Whether a query was run (vs. the empty landing form). |
.Search.Query |
string |
The user’s query, echoed (auto-escaped). |
.Search.Results |
[]SearchResult |
Current page of hits. |
.Search.Total |
int |
Total matching posts. |
.Search.Page |
int |
Current page number. |
.Search.TotalPages |
int |
Total result pages. |
.Search.HasPrev / .HasNext |
bool |
Whether prev/next result pages exist. |
.Search.PrevURL / .NextURL |
string |
URLs for prev/next result pages. |
.Search.Error |
string |
Accessible validation/unavailable message (empty when none). |
.Search.Truncated |
bool |
Whether the index was capped (results may be incomplete). |
Each SearchResult exposes .Title, .URL, .Slug, .Date (time.Time),
.Excerpt, .Tags, and .Score. The relevance .Score is available for
custom themes (e.g. to sort or display), but the default theme intentionally
does not show numeric scores in the reader-visible result body.
5. Partial Templates
Partials live in templates/partials/ and are included with the template
action. The template name is the {{define "name"}} identifier inside the
file — not the file path:
{{ template "partials/header.html" . }}
Default Partials
BlogFlow ships these partials in the embedded defaults:
{{define}} Name |
File | Purpose |
|---|---|---|
partials/header.html |
partials/header.html |
Site header with navigation and title link. |
partials/footer.html |
partials/footer.html |
Site footer with copyright and attribution. |
post-meta |
partials/post-meta.html |
Post metadata: date, reading time, tag links. |
pagination |
partials/pagination.html |
Previous / next page navigation with aria labels. |
search-result |
partials/search-result.html |
A single search hit (title, date, excerpt, tags). |
search-pagination |
partials/search-pagination.html |
Search results prev/next navigation. |
Naming convention: The
{{define}}name must match exactly what{{template}}uses. The default header and footer use full-path names (partials/header.html,partials/footer.html), while post-meta and pagination use short names. When overriding a partial, keep the same{{define}}name so existing{{template}}calls continue to resolve.
Creating Custom Partials
Add new partials to your theme’s templates/partials/ directory:
<!-- templates/partials/social-links.html -->
<nav class="social-links" aria-label="Social media">
{{ with .Site.Author.Name }}
<a href="https://github.com/{{ urlize . }}">GitHub</a>
{{ end }}
</nav>
Then include it in any template:
{{ template "social-links" . }}
6. Overriding Default Templates via Overlay FS
BlogFlow’s overlay filesystem lets you selectively replace default templates without forking the entire theme. Place files at the same relative path in your theme directory to shadow the defaults.
Resolution Order
The overlay FS checks layers in this order (highest priority first):
- Theme layer — your custom
--themedirectory - Content layer — the
--contentdirectory - Config layer — configuration files
- Defaults layer — embedded defaults (
embed.FS)
Override Examples
Override just the header:
my-theme/
└── templates/
└── partials/
└── header.html # ← shadows defaults/templates/partials/header.html
All other templates (base.html, post.html, footer.html, etc.) continue
to use the embedded defaults.
Override the base layout:
my-theme/
└── templates/
└── base.html # ← shadows defaults/templates/base.html
Override CSS only (no template changes):
my-theme/
└── static/
└── css/
└── main.css # ← shadows defaults/static/css/main.css
How It Works
When BlogFlow resolves a template path like templates/post.html:
- Check theme layer → if found, use it
- Check content layer → if found, use it
- Check config layer → if found, use it
- Check defaults layer → use the embedded version
The overlay FS implements Go’s fs.FS, fs.ReadFileFS, fs.ReadDirFS, and
fs.StatFS interfaces. ReadDir returns the union of directory entries
across all layers, with higher layers shadowing lower ones.
Safety Features
- Max file size: 64 MB per file
- Symlink escape detection: disk-backed layers reject symlinks that escape the layer root
- Negative cache: absent paths are cached (up to 100,000 entries) to avoid repeated layer traversal
- Hot reload: layers can be invalidated and replaced at runtime (used by sync strategies)
7. CSS Architecture
Custom Properties
The default theme uses CSS custom properties for consistent theming:
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-link: #0066cc;
--color-link-hover: #004499;
--color-border: #e0e0e0;
--color-code-bg: #f5f5f5;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
--max-width: 42rem;
}
Override these in your custom main.css to change the entire color scheme
without rewriting styles.
Dark Mode
Implement dark mode with a prefers-color-scheme media query:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-text: #e0e0e0;
--color-link: #66b3ff;
--color-link-hover: #99ccff;
--color-border: #333333;
--color-code-bg: #2d2d2d;
}
}
Responsive Design
The default theme is mobile-first with a fluid layout:
body {
max-width: var(--max-width);
margin: 0 auto;
padding: 1rem;
}
/* Wider viewports */
@media (min-width: 768px) {
body {
padding: 2rem;
}
}
Syntax Highlighting
Code blocks use CSS classes compatible with
Chroma syntax highlighter. The default
theme includes base Chroma styles. Customize by overriding the .chroma and
related classes:
.chroma .kw { color: #007020; font-weight: bold; } /* Keyword */
.chroma .s { color: #4070a0; } /* String */
.chroma .c { color: #60a0b0; font-style: italic; } /* Comment */
8. Static Assets
Serving Static Files
All files under static/ in your theme (or the defaults) are served at the
/static/ URL prefix:
| File Path | Served At |
|---|---|
static/css/main.css |
/static/css/main.css |
static/images/favicon.svg |
/static/images/favicon.svg |
static/js/app.js |
/static/js/app.js |
Referencing in Templates
<!-- CSS -->
<link rel="stylesheet" href="/static/css/main.css">
<!-- Favicon -->
<link rel="icon" href="/static/images/favicon.svg" type="image/svg+xml">
<!-- JavaScript -->
<script src="/static/js/app.js" defer></script>
Adding Custom Assets
Place any static files in your theme’s static/ directory. They are served
through the overlay FS, so theme assets shadow default assets at the same path.
my-theme/
└── static/
├── css/
│ ├── main.css # Overrides default CSS
│ └── syntax.css # Additional stylesheet
├── images/
│ └── logo.svg # Custom logo
└── js/
└── theme-toggle.js # Dark mode toggle
Content-Security-Policy
BlogFlow enforces a strict Content-Security-Policy via an HTTP header set
in the server middleware (internal/server/server.go,
securityHeadersMiddleware). The default policy is:
default-src 'none'; script-src 'none'; object-src 'none';
connect-src 'none'; style-src 'self'; img-src 'self' https: data:;
font-src 'self' https:; base-uri 'self'; form-action 'self';
frame-ancestors 'self'
The base.html meta tag contains a similar CSP as supplementary
defense-in-depth. However, when the HTTP header is present (which it always
is when served by BlogFlow), browsers ignore the meta tag CSP entirely. The
meta tag only matters if someone serves the HTML files through a different
server that omits the header.
To allow external resources (CDN fonts, analytics scripts, etc.), you must
modify the securityHeadersMiddleware in internal/server/server.go — editing
the base.html meta tag alone has no effect.
⚠️
frame-ancestorscannot be set via a<meta>tag — the browser spec forbids it. This directive is only effective in the HTTP header, which BlogFlow already sets.
9. Full-Text Search
BlogFlow ships an optional, server-rendered full-text search that requires no client-side JavaScript. It is disabled by default.
Enabling search
Add to site.yaml:
search:
enabled: true # opt-in; evaluated at startup (restart to toggle)
max_results: 20 # results per page
min_query_length: 2 # minimum normalized query runes
max_query_length: 128 # maximum normalized query runes
max_query_terms: 32 # maximum unique normalized query terms
excerpt_length: 200 # excerpt display runes
max_docs: 10000 # index admission caps (memory safety)
max_tokens: 2000000
max_index_bytes: 67108864 # 64 MiB logical budget
When enabled, BlogFlow registers GET /search?q=...&page=N and the default
theme renders a global search box in the header. When disabled, the route is
not registered (requests 404) and no search affordance is emitted anywhere.
enabled is evaluated when the router is built — changing it requires a
restart; other tunables reload with content.
Templates and override points
| File | Purpose |
|---|---|
templates/search.html |
The search page (form, results region, states). |
templates/partials/search-result.html |
One result item ({{define "search-result"}}). |
templates/partials/search-pagination.html |
Results prev/next nav ({{define "search-pagination"}}). |
Override any of these through the overlay filesystem exactly like other templates (Section 6). If a custom theme omits a search partial, the embedded default is used, so you can override just the page or just a partial.
Within search.html, the search-specific data lives under .Search (see the
SearchData table in Section 4). The search-result partial receives a
single SearchResult as its dot; the search-pagination partial receives the
.Search value.
Accessibility contract
The default search UI is keyboard-navigable and screen-reader friendly: the
form uses role="search", the query input has an associated label, the result
count is exposed via role="status", the no-results state is explicit, and
pagination uses real <a> links inside a labelled <nav>. Preserve these
semantics when overriding the templates.