Skip to content

Theme Developer Guide

This guide covers everything you need to create, validate, and run a custom Plate theme. For development environment setup, see docs/DEVELOPER.md. For a technical overview of how the theme layer fits into Plate, see docs/ARCHITECTURE.md.


What is a theme?

A Plate theme is a directory of Handlebars templates and optional static assets. Plate loads and validates the theme at startup, uses its templates to render every page served to visitors, and hot-reloads the theme automatically when the active theme changes in Snackbox settings. The Snackbox API provides the content; the theme controls how that content looks.

Plate ships with one built-in theme, Picnic, which is the reference implementation of the theme contract and the recommended starting point for new themes.


Prerequisites

  • Node.js 24 or later — required to run Plate and its tooling

You have two options for accessing the theme tooling:

Option A — npx (no clone required)

npx @cozybadgerde/plate theme:validate /path/to/my-theme

This downloads and runs the Plate CLI on demand. Use this when you just want to scaffold or validate a theme without setting up a full development environment.

Option B — clone the repository

git clone https://gitlab.com/cozybadgerde/applications/plate.git
cd plate
npm install

Use this when you need to run the contract compliance tests, contribute to Plate itself, or develop against the very latest unreleased changes.


Creating a theme

The fastest way to start is with theme:init, which scaffolds a minimal but valid theme directory:

# via npx
npx @cozybadgerde/plate theme:init my-theme

# or from a repository clone
npm run theme:init -- my-theme

Alternatively, copy Picnic and rename it for a richer starting point:

cp -r /path/to/plate/src/themes/picnic my-theme

Open my-theme/manifest.json and update at minimum:

  • name -- must match the directory name, lowercase letters, digits, and hyphens only
  • version -- start at 1.0.0
  • author, license, links.repository
  • plateVersion -- the semver range of Plate versions your theme is compatible with (e.g. ^1.0.0); use >=0.0.1 during development

Then run the validator to confirm the theme loads cleanly:

# via npx
npx @cozybadgerde/plate theme:validate my-theme

# or from a repository clone
npm run theme:validate -- my-theme

Directory structure

my-theme/
├── manifest.json          # required -- validated at startup and on every reload
├── templates/
│   ├── home.hbs           # required
│   ├── 404.hbs            # required
│   ├── 500.hbs            # required
│   ├── posts.hbs          # optional
│   ├── post.hbs           # optional
│   ├── page.hbs           # optional
│   ├── tags.hbs           # optional
│   └── tag.hbs            # optional
├── partials/              # optional -- Handlebars partials, any structure
└── assets/                # optional -- static files served by Plate

The directory name must match the name field in manifest.json. A theme with an invalid manifest will not load.

When an optional template file is absent, Plate falls back to the built-in Picnic template for that page type.


manifest.json

The manifest declares what the theme provides and how to configure it.

Required fields

Field Description
name Theme identifier; must match the directory name
version SemVer version string
author.name Author or organization name
license SPDX license identifier (e.g. MIT, BSD-3-Clause)
plateVersion SemVer range of compatible Plate versions
links.repository URL to the theme source repository

Minimal manifest

{
  "name": "my-theme",
  "version": "1.0.0",
  "author": { "name": "Your Name" },
  "license": "MIT",
  "plateVersion": "^1.0.0",
  "links": { "repository": "https://example.com/my-theme" }
}

Optional manifest fields

{
  "description": "A clean theme for personal blogs.",
  "author": {
    "name": "Your Name",
    "url": "https://example.com",
    "email": "you@example.com"
  },
  "links": {
    "repository": "https://example.com/my-theme",
    "docs": "https://docs.example.com/my-theme",
    "issues": "https://example.com/my-theme/issues"
  }
}

i18n

The optional i18n field lets a theme ship translated UI strings for any language. Each key is a BCP-47 language code; each value is a flat map of string keys to translated values.

"i18n": {
  "en": { "back": "Back", "noPostsYet": "No posts yet." },
  "de": { "back": "Zurück", "noPostsYet": "Noch keine Beiträge." }
}

Plate resolves the block matching site.language (set in Snackbox). If the language is not present, it falls back to en. If en is also absent, theme.i18n is an empty object.

In templates, access strings via {{theme.i18n.<key>}}. Inside an {{#each}} block use {{../theme.i18n.<key>}} to reach the parent scope.

The Picnic theme ships en and de translations as the reference implementation. Themes with no i18n field receive an empty object — templates that reference {{theme.i18n.*}} will render empty strings for those keys.

Settings

Settings are entirely optional. A theme with a fixed layout and hardcoded values needs no settings at all.

When settings are declared, Plate reads the default value from each setting and makes it available in every template under theme.settings.*. Settings are a convenience for the theme developer: instead of scattering the same value across multiple templates, you declare it once in the manifest and reference it everywhere via theme.settings.*.

"settings": {
  "accentColor": {
    "type": "color",
    "label": "Accent color",
    "description": "Primary accent color used for links and highlights.",
    "default": "#3b82f6"
  },
  "showAuthor": {
    "type": "boolean",
    "label": "Show author on posts",
    "default": true
  },
  "contentWidth": {
    "type": "number",
    "label": "Content width (px)",
    "default": 960
  },
  "footerText": {
    "type": "string",
    "label": "Footer text",
    "default": ""
  },
  "fontStyle": {
    "type": "string",
    "label": "Font style",
    "default": "sans"
  }
}

Supported setting types: color, boolean, number, string.

Access settings in templates:

<style>a { color: {{theme.settings.accentColor}}; }</style>

Note that Snackbox also exposes site.brand_color -- a site-wide color set by the operator. If you want your theme to respect that value and fall back to your theme setting when it is not configured, use a conditional:

<style>
  :root {
    --accent: {{#if site.brand_color}}{{site.brand_color}}{{else}}{{theme.settings.accentColor}}{{/if}};
  }
</style>

This is the pattern Picnic uses.

See the scope gotcha below when using settings inside {{#each}} blocks.


Template context

Plate injects a context object into every template. The variables available depend on the template being rendered. The tables below are a reference summary — contracts/THEME-CONTRACT.md is the authoritative specification.

Global (all templates)

Variable Type Description
site.title string Site title
site.description string Site description
site.slogan string Site slogan
site.logo_url string or null Logo URL
site.language string Language code (e.g. en)
site.announcement_text string Announcement banner text
site.announcement_active boolean Whether the announcement banner is active
mainNav array Primary navigation items, each with label and url
secondaryNav array Secondary navigation items, each with label and url
socialAccounts array Social profile links, each with name and url
tags array All tags, each with name and slug
theme.assetsUrl string Base URL for theme static assets
theme.settings object Theme settings values keyed by setting name
theme.i18n object Resolved UI strings for the active site language (empty object if theme has no i18n)

Home and posts templates

Variable Type Description
featuredPosts array Featured posts (home template only) — posts with featured: true, separated from the regular list so the theme can display them distinctly
posts array Posts, each with title, slug, excerpt, published_at, tags, authors
pagination.page number Current page number
pagination.pages number Total number of pages
pagination.total number Total post count
pagination.hasNext boolean Whether a next page exists
pagination.hasPrev boolean Whether a previous page exists

Post template

Variable Type Description
post object Post data with title, slug, published_at, tags, authors
post.title_image string|null Optional URL of the post's cover or hero image
post.readingTime number Estimated reading time in minutes (minimum 1)
contentHtml string Rendered HTML; use triple braces: {{{contentHtml}}}

Page template

Variable Type Description
page object Page data with title, slug
contentHtml string Rendered HTML; use triple braces: {{{contentHtml}}}

Tags template

No additional variables. The global tags array contains all tags, each with name and slug.

Tag template

Variable Type Description
tag object Tag data with name and slug
posts array Posts with this tag
pagination object Same pagination object as home/posts

Error templates (404, 500)

Variable Type Description
error.status number HTTP status code
error.message string Error message

Assets

Put CSS, fonts, images, and other static files in the assets/ directory. Plate serves them at a stable, versioned path exposed via theme.assetsUrl.

Always reference assets through this variable. Never hardcode paths.

<link rel="stylesheet" href="{{theme.assetsUrl}}/css/style.css">
<img src="{{theme.assetsUrl}}/images/logo.svg" alt="Logo">

Handlebars helpers

Plate registers the following helpers globally. They are available in all themes. The table below is a reference summary — contracts/THEME-CONTRACT.md is the authoritative specification.

Helper Example Description
formatDate {{formatDate post.published_at}} Formats an ISO date string as a long readable date (e.g. "April 22, 2026")
formatDate (short) {{formatDate post.published_at "short"}} Formats as a short locale date (e.g. "4/22/2026")
eq {{#if (eq a b)}} Equality check
gt {{#if (gt a b)}} Greater-than check
add {{add pagination.page 1}} Adds two numbers
sub {{sub pagination.page 1}} Subtracts two numbers
limit {{#each (limit posts 3)}} Returns the first N items of an array
or {{#if (or a b)}} Returns true if either argument is truthy
not {{#if (not a)}} Returns the boolean negation of its argument
includes {{#if (includes tags "news")}} Returns true if an array contains a value

Handlebars scope inside {{#each}}

Handlebars 4.7 does not walk up the scope chain for dotted paths inside {{#each}} blocks. Accessing theme.settings.* directly inside an each block silently returns undefined.

Use the ../ prefix to reference the parent scope:

{{! Wrong -- theme.settings resolves to undefined inside #each }}
{{#each posts}}
  {{#if theme.settings.showExcerpts}}...{{/if}}
{{/each}}

{{! Correct }}
{{#each posts}}
  {{#if ../theme.settings.showExcerpts}}...{{/if}}
{{/each}}

This applies to any parent-scope variable accessed from within an {{#each}} block: site, theme, pagination, tags, and so on.


The theme contract

The contract defines the minimum HTML output each template must produce and the manifest schema every theme must satisfy. See contracts/THEME-CONTRACT.md for the full specification.

Picnic is the canonical implementation of the contract. If you are unsure how to implement something, reading the Picnic source is the fastest path to a working answer.


Validating and testing your theme

Validate the manifest and template files

# via npx
npx @cozybadgerde/plate theme:validate my-theme

# or from a repository clone
npm run theme:validate -- my-theme

This checks:

  • manifest.json is valid against the schema
  • Required template files (home.hbs, 404.hbs, 500.hbs) are present on disk

Lint the CSS

# from a repository clone only
npm run theme:lint:css -- "my-theme/**/*.css"

Validate and lint in one shot

# via npx
npx @cozybadgerde/plate theme:check my-theme

# or from a repository clone
npm run theme:check -- my-theme

Run contract compliance tests

The contract compliance test suite renders each template against a real fixture and checks the required HTML output. This requires a repository clone. Point THEME_NAME and THEMES_DIR at your theme and run:

THEME_NAME=my-theme THEMES_DIR=/path/to/themes-directory npm run theme:test:contract

If all tests pass, your theme is contract-compliant.

Live preview

theme:dev starts a local Plate instance wired to the built-in Snackbox fixture so you can see your theme in a browser with realistic content:

# via npx
npx @cozybadgerde/plate theme:dev my-theme

# or from a repository clone
npm run theme:dev -- my-theme

Plate starts on http://localhost:3000. The fixture provides sample posts, pages, tags, navigation, and social links. Changes to templates and assets are picked up on save thanks to hot-reload. Changes to manifest.json require a server restart to take effect.

Testing against a real Snackbox

When you need to test against a live Snackbox instance instead of the fixture, copy the example config and point Plate at your theme:

cp configs/.env.example .env

Then edit .env:

SNACKBOX_API_URL=http://localhost:8080
THEMES_DIR=/path/to/themes-directory
THEME_NAME=my-theme

Run npm run dev. THEME_NAME locks Plate to your theme regardless of Snackbox settings, which is what you want during development.