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