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 onlyversion-- start at1.0.0author,license,links.repositoryplateVersion-- the semver range of Plate versions your theme is compatible with (e.g.^1.0.0); use>=0.0.1during 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.jsonis 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.