Theme Development
Theme Development Guide
Themes control how your Minnow site looks. A theme is a directory with a theme.yaml manifest and HTML templates that use a simple {{ variable }} syntax.
Theme Structure
data/themes/my-theme/
theme.yaml # Theme manifest (required)
theme.php # Optional PHP bootstrap
templates/
default.html # Fallback template
home.html # Blog listing
front.html # Static front page
post.html # Single blog post
archive.html # Category/tag archives
404.html # Not found page
single-work.html # Custom post type single
archive-work.html # Custom post type archive
page-about.html # Page-specific template
parts/
header.html # Reusable header
footer.html # Reusable footer
assets/
css/
js/
img/
Themes live in data/themes/. The active theme is configured in the admin settings.
theme.yaml
Basic Configuration
name: My Theme
version: 1.0.0
description: A custom Minnow theme
Templates
Map template keys to HTML files:
templates:
# Simple string format
default: templates/default.html
home: templates/home.html
post: templates/post.html
archive: templates/archive.html
404: templates/404.html
# Object format (with filters)
post:
file: templates/post.html
filters:
content: markdown
Post Types & Taxonomies
Themes can register content types that are specific to the theme's design:
post_types:
work:
label: Work
singular: Work
plural: Work
public: true
hierarchical: true
has_archive: true
supports: [title, editor, thumbnail, excerpt]
menu_icon: briefcase
menu_position: 6
rewrite:
slug: work
taxonomies:
project-type:
label: Project Type
singular: Project Type
plural: Project Types
object_types: [work]
hierarchical: true
public: true
show_in_menu: true
show_admin_column: true
rewrite:
slug: project-type
When you register a post type in your theme, create matching templates using the single-{type} and archive-{type} naming convention:
templates:
single-work: templates/single-work.html
archive-work: templates/archive-work.html
See the Custom Post Types documentation for the full configuration reference.
Theme Field Defaults
Define default values available in all templates:
fields:
color_scheme:
default: light
cta_text:
default: Get Started
These are available as {{ color_scheme }} and {{ cta_text }} in every template.
Custom Routes
Add special route handlers like documentation sections:
routes:
- id: docs-route
match:
prefix: /docs
handler:
type: docs_section
config:
manifest: docs/manifest.yaml
docs_root: docs/pages
prefix: /docs
template: docs
fallback_template: default
priority: 20
Template Syntax
Variables
{{ title }}
{{ page.title }}
{{ site.name }}
Use dot notation for nested properties. Variables are automatically HTML-escaped.
Variables with Defaults
{{ featured_image_url | /assets/default.jpg }}
{{ excerpt | No excerpt available }}
The value after | is used when the variable is empty or missing.
Conditionals
{{ #if featured_image_url }}
<img src="{{ featured_image_url }}" alt="{{ featured_image_alt }}">
{{ /if }}
Values are considered falsy: false, null, '', [], 0, '0'.
Loops
Iterate over arrays of objects:
{{ #posts }}
<article>
<h2><a href="{{ url }}">{{ title }}</a></h2>
<time datetime="{{ date_iso }}">{{ date }}</time>
{{ #if excerpt }}
<p>{{ excerpt }}</p>
{{ /if }}
</article>
{{ /posts }}
Inside a loop, each property of the current item is available directly (e.g. {{ title }} not {{ posts.title }}).
Part Includes
Include reusable parts from the parts/ directory:
{{ @header }}
<main>
{{ content }}
</main>
{{ @footer }}
{{ @header }} loads parts/header.html. Parts are processed first, so they can contain variables, loops, and conditionals.
Processing Order
- Part includes (
{{ @name }}) - Loops (
{{ #array }}...{{ /array }}) - Conditionals (
{{ #if key }}...{{ /if }}) - Variables with defaults (
{{ key | default }}) - Plain variables (
{{ key }})
Template Resolution
Minnow automatically selects the right template based on the page being viewed. Each page type tries templates in a specific order, falling back through the chain.
| Page Type | Template Cascade |
|---|---|
| Front page | front > home > default |
| Blog listing | home > default |
| Single post | post > default |
| Page | page-{slug} > default |
| Category/tag archive | archive > default |
| CPT single | single-{type} > post > default |
| CPT archive | archive-{type} > archive > default |
| 404 | 404 > default |
Context Variables
Global (All Templates)
| Variable | Description |
|---|---|
site.name |
Site name |
site.description |
Site tagline |
menu.primary |
Rendered primary menu HTML |
assets |
Theme assets URL (/data/themes/{slug}/assets) |
head |
HTML head extras |
scripts |
Footer scripts |
title |
Page title for <title> tag |
meta_description |
Meta description |
Home / Blog Listing
| Variable | Description |
|---|---|
posts |
Array of posts, each with title, url, date, date_iso, excerpt |
pagination |
Rendered pagination HTML |
Single Post
| Variable | Description |
|---|---|
content |
Rendered post content |
page.title |
Post title |
page.date |
Formatted date |
page.date_iso |
ISO 8601 date |
page.excerpt |
Post excerpt |
page.categories |
Rendered category links |
page.tags |
Rendered tag links |
page.nav |
Previous/next post navigation |
featured_image_url |
Featured image URL |
featured_image_alt |
Featured image alt text |
ACF/custom fields are injected at the top level (e.g. {{ my_custom_field }}).
Page
| Variable | Description |
|---|---|
content |
Rendered page content |
page.title |
Page title |
featured_image_url |
Featured image URL |
Archive
| Variable | Description |
|---|---|
posts |
Array of posts |
archive.title |
Archive title (e.g. "Category: News") |
archive.type |
category or tag |
archive.description |
Term description |
pagination |
Rendered pagination HTML |
CPT Single
Same as single post, plus:
| Variable | Description |
|---|---|
post_type |
Post type slug |
post_type_label |
Post type label |
{taxonomy_name} |
Array of terms, each with name, slug, url |
CPT Archive
Same as archive, plus:
| Variable | Description |
|---|---|
post_type |
Post type slug |
post_type_label |
Post type label |
posts[].featured_image_url |
Featured image per post (if CPT supports thumbnails) |
Docs Route
| Variable | Description |
|---|---|
content |
Rendered markdown content |
page.title |
Doc page title |
docs.title |
Documentation section title |
docs.base_url |
Base URL path |
docs_nav |
Array of nav items with title, url, active_class |
Filters
Filters transform context values before rendering. Define them in the template's object format:
templates:
post:
file: templates/post.html
filters:
content: markdown
bio: [markdown, 'markdown:details']
| Filter | Description |
|---|---|
markdown |
Render Markdown to HTML with shortcode processing |
markdown:details |
Markdown with <details> block support |
Theme PHP Bootstrap
For advanced functionality, create a theme.php file. You must enable it in the manifest:
allow_php: true
<?php
// theme.php — runs after theme loads
// Add data to template context
add_filter('minnow.theme.context', function($context) {
$context['current_year'] = date('Y');
return $context;
});
// Add scripts/styles
add_action('minnow.head', function() {
echo '<link rel="stylesheet" href="/data/themes/my-theme/assets/css/extra.css">';
});
Frontend URL Routing
The frontend resolves URLs in this order:
- Query parameters (
?p=ID,?page_id=ID) - Custom theme routes (docs sections, etc.)
- Home page (
/) - Blog post slugs (
/blog/{slug}/or/{year}/{month}/{slug}/) - Category archives (
/category/{slug}/) - Tag archives (
/tag/{slug}/) - CPT single (
/{cpt-slug}/{post-slug}/) - CPT archive (
/{cpt-slug}/) - CPT taxonomy archive (
/{tax-slug}/{term-slug}/) - Pages by path (
/{slug}/or/{parent}/{child}/) - 404
Complete Example
theme.yaml:
name: Portfolio Theme
version: 1.0.0
post_types:
work:
label: Work
singular: Work
plural: Work
public: true
has_archive: true
supports: [title, editor, thumbnail, excerpt]
menu_icon: briefcase
rewrite:
slug: work
taxonomies:
project-type:
label: Project Type
object_types: [work]
hierarchical: true
public: true
templates:
default: templates/default.html
home: templates/home.html
front: templates/front.html
post: templates/post.html
archive: templates/archive.html
single-work: templates/single-work.html
archive-work: templates/archive-work.html
404: templates/404.html
parts/header.html:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="{{ assets }}/css/style.css">
{{ head }}
</head>
<body>
<header>
<a href="/">{{ site.name }}</a>
<nav>{{ menu.primary }}</nav>
</header>
parts/footer.html:
<footer>
<p>{{ site.description }}</p>
</footer>
{{ scripts }}
</body>
</html>
templates/archive-work.html:
{{ @header }}
<h1>{{ archive.title | Work }}</h1>
<div class="grid">
{{ #posts }}
<div class="card">
{{ #if featured_image_url }}
<img src="{{ featured_image_url }}" alt="{{ featured_image_alt }}">
{{ /if }}
<h2><a href="{{ url }}">{{ title }}</a></h2>
<p>{{ excerpt }}</p>
</div>
{{ /posts }}
</div>
{{ pagination }}
{{ @footer }}
templates/single-work.html:
{{ @header }}
<article>
<h1>{{ page.title }}</h1>
{{ #if featured_image_url }}
<img src="{{ featured_image_url }}" alt="{{ featured_image_alt }}">
{{ /if }}
{{ content }}
{{ #if project-type }}
<div class="tags">
{{ #project-type }}
<a href="{{ url }}">{{ name }}</a>
{{ /project-type }}
</div>
{{ /if }}
</article>
{{ @footer }}