Minnow

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

  1. Part includes ({{ @name }})
  2. Loops ({{ #array }}...{{ /array }})
  3. Conditionals ({{ #if key }}...{{ /if }})
  4. Variables with defaults ({{ key | default }})
  5. 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:

  1. Query parameters (?p=ID, ?page_id=ID)
  2. Custom theme routes (docs sections, etc.)
  3. Home page (/)
  4. Blog post slugs (/blog/{slug}/ or /{year}/{month}/{slug}/)
  5. Category archives (/category/{slug}/)
  6. Tag archives (/tag/{slug}/)
  7. CPT single (/{cpt-slug}/{post-slug}/)
  8. CPT archive (/{cpt-slug}/)
  9. CPT taxonomy archive (/{tax-slug}/{term-slug}/)
  10. Pages by path (/{slug}/ or /{parent}/{child}/)
  11. 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 }}