Theme Authoring Guide
ZeroPress themes are static template packages for runtime: "0.6". A theme owns markup, CSS, reusable theme assets, and optional client-side progressive enhancement. Build tooling owns preview-data validation, Markdown rendering, route generation, asset emission, sitemap/feed/search artifacts, and public file passthrough.
Use this guide when you want to build or review a real theme. Use Theme Runtime Reference for quick lookup, Theme Runtime v0.6 Long-Form Spec for exact contract details, and Theme Manifest Runtime v0.6 Schema for machine-readable validation.
Start From A Starter
Create a starter theme and fixture preview-data:
npx @zeropress/create-theme --name my-docs-theme --template docs
Preview the generated theme locally:
npx @zeropress/theme dev ./my-docs-theme/theme --data ./my-docs-theme/preview-data.json
Validate it:
npx @zeropress/theme validate ./my-docs-theme/theme
Package it:
npx @zeropress/theme pack ./my-docs-theme/theme
Theme Project Shape
Required files:
theme/
theme.json
layout.html
index.html
post.html
page.html
assets/
style.css
Common optional files:
theme/
archive.html
category.html
tag.html
404.html
partials/
header.html
footer.html
post-card.html
content-enhancements.html
assets/
theme.js
layout.html is the shared page shell. It must contain exactly one content slot:
<main>
{{slot:content}}
</main>
Do not put direct <script> tags in layout.html. Put shared scripts in a partial such as {{partial:content-enhancements}}, then include that partial from the layout.
Site Project Shape
When you build directly with @zeropress/build, the normal site shape is:
my-site/
preview-data.json
theme/
theme.json
layout.html
index.html
post.html
page.html
partials/
assets/
style.css
theme.js
public/
favicon.ico
robots.txt
sitemap.xsl
vendor/
Responsibilities:
preview-data.jsonowns content, site metadata, menus, widgets, collections, and routing policy.theme/owns deterministic rendering through the v0.6 runtime.theme/assets/owns reusable theme CSS, JavaScript, icons, and decorative images.public/owns site-specific passthrough files such as favicons, PDFs, uploaded images, source Markdown, and vendor bundles.
Theme assets are emitted under /assets/ and may be content-hashed. References such as /assets/style.css and /assets/theme.js are rewritten to the emitted hashed path. Public files are copied from the site public root to the output root. Generated ZeroPress output wins over public files.
Reusable themes should not hard-code site-specific analytics tokens, verification tags, vendor filenames, product copy, or copyright symbols. Use named partials, documented public paths, site.meta, page.data, or custom_html depending on who should own the value.
Manifest Basics
Use runtime: "0.6" and validate against the current schema:
{
"$schema": "https://schemas.zeropress.dev/theme-runtime/v0.6/schema.json",
"name": "My Theme",
"namespace": "my-company",
"slug": "my-theme",
"version": "0.1.0",
"license": "MIT",
"runtime": "0.6",
"description": "A ZeroPress theme.",
"features": {
"comments": false,
"newsletter": false,
"post_index": true,
"search": false
},
"menu_slots": {
"primary": {
"title": "Primary Menu",
"description": "Main navigation menu"
}
},
"collection_slots": {
"featured": {
"title": "Featured",
"description": "Curated cards shown on the home page."
}
}
}
The theme manifest root object is closed. Unknown root fields are invalid.
Common fields:
namenamespaceslugversionlicenseruntimedescriptionfeaturesmenu_slotswidget_areassite_metacollection_slotslinks
Use SPDX identifiers such as MIT for open-source themes. Use LicenseRef-* for commercial, marketplace, proprietary, or otherwise non-SPDX terms:
{
"license": "LicenseRef-Commercial",
"links": {
"marketplace": "https://example.com/theme",
"support": "mailto:support@example.com",
"license": "https://example.com/theme/license"
}
}
links is optional public metadata. Supported keys are homepage, repository, documentation, support, marketplace, and license. Values must be absolute http, https, or mailto URLs.
features is optional. If it is omitted, feature defaults are applied per flag:
| Feature | Omitted behavior | Theme author meaning |
|---|---|---|
comments |
false |
Add true only when the theme includes comments UI. |
newsletter |
No core build behavior | Use as capability metadata for markup-only newsletter UI. |
post_index |
true |
Set false when the theme should not render post index routes. |
search |
false |
Add true only when the theme includes static search UI. |
Rendering Model
The responsibility split is:
- build prepares structured route data and safe rendered content
- templates render deterministic HTML
- theme CSS styles the contract output
- theme JS progressively enhances optional UI after first render
Every HTML route receives common data:
siteroutemenuswidgetscollectionstaxonomiesmeta
Post routes receive post; page routes receive page; listing routes receive structured listing data such as posts.items[], pagination, taxonomy, or archive groups.
Template Syntax
Templates support variable paths, conditionals, strict comparison helpers, loops, partials, partial arguments, and comments:
{{#if path}}...{{#else}}...{{/if}}
{{#if path}}...{{#else_if other.path}}...{{#else}}...{{/if}}
{{#if_eq loop.index 4}}...{{/if}}
{{#if_eq route.url item.url}}...{{#else_if_starts_with route.url item.url}}...{{/if}}
{{#if_neq loop.last true}}, {{/if}}
{{#if_in route.type "post" "page" "front_page"}}...{{/if}}
{{#if_starts_with route.url item.url}}...{{/if}}
{{#for item in path}}...{{/for}}
{{partial:post-card post=post variant="compact" show_excerpt=true}}
{{! inline comment }}
{{!-- block comment --}}
Important rules:
- General JavaScript expressions are not supported.
and,or,>,<, arithmetic, slicing, and array index expressions are not supported.- Comparison helpers use strict comparison and never coerce types.
- Comparison helper branches may be mixed inside one conditional block, and the recommended close tag is
{{/if}}. - Concrete close tags such as
{{/if_eq}}are accepted in v0.6 for compatibility, but are planned for removal in v0.7. {{#if_eq loop.index 4}}can match;{{#if_eq loop.index "4"}}does not.- Use
{{#if site.footer.attribution}}, not{{#if_eq site.footer.attribution}}, for truthiness checks. - Unquoted partial argument values are typed literals or path aliases, not strings. Use
variant="compact", notvariant=compact.
Partials
Partials share the parent render context. Arguments are optional aliases exposed under partial.*, useful when one reusable fragment renders different object types.
{{#for item in collections.work.items}}
{{partial:project-card project=item variant="featured"}}
{{/for}}
{{#for post in posts.items}}
{{partial:project-card project=post variant="compact"}}
{{/for}}
Inside partials/project-card.html, read partial.project:
<article class="project-card project-card--{{partial.variant}}">
<h2><a href="{{partial.project.url}}">{{partial.project.title}}</a></h2>
{{#if partial.project.excerpt}}<p>{{partial.project.excerpt}}</p>{{/if}}
</article>
Missing path aliases render as empty/falsey values. Guard optional aliases with {{#if partial.project}} when needed.
Route Templates
index.html can render:
- a front page (
route.type: "front_page") - a post index (
route.type: "post_index") - the default combined root route when both use
/
Use route and pagination before rendering route-specific UI:
{{#if route.is_post_index}}
{{#for post in posts.items}}
{{partial:post-card post=post}}
{{/for}}
{{#if pagination.enabled}}
{{partial:pagination}}
{{/if}}
{{/if}}
Common route fields:
route.typeroute.is_front_pageroute.is_post_indexroute.pathroute.url
There is no route.is_post. For post-specific branching outside post.html, use:
{{#if_eq route.type "post"}}
{{partial:post-enhancements}}
{{/if}}
When a site uses a page as the front page, that page is rendered at /, its normal page route is not emitted, and the root render has route.type: "front_page".
Listing Data
Post lists and pagination are structured data, not pre-rendered HTML.
Post index, category, and tag routes commonly use:
posts.items[]paginationtaxonomyon taxonomy routes
{{#for post in posts.items}}
<article class="post-list-item">
<h2><a href="{{post.url}}">{{post.title}}</a></h2>
<p>{{post.excerpt}}</p>
<p>{{post.published_at}} · {{post.reading_time}}</p>
</article>
{{/for}}
Pagination fields include:
pagination.enabledpagination.current_pagepagination.total_pagespagination.has_prevpagination.has_nextpagination.prev_urlpagination.next_urlpagination.pages[]pagination.window[]
Archive routes use archive.groups[], where each group has label, year, month, and items[].
Post And Page Data
Common post fields:
post.titlepost.slugpost.urlpost.excerptpost.featured_imagepost.featured_mediapost.htmlpost.published_atpost.published_at_isopost.updated_atpost.updated_at_isopost.reading_timepost.authorpost.categories[]post.tags[]post.prevpost.nextpost.collection_cursorspost.comments_enabledpost.metapost.datapost.toc[]
Common page fields:
page.titlepage.slugpage.urlpage.excerptpage.featured_imagepage.featured_mediapage.htmlpage.updated_atpage.updated_at_isopage.metapage.datapage.collection_cursorspage.toc[]
Use meta for scalar flags and metadata. Use data for structured values that templates should iterate.
Page update timestamps are optional. Build Pages can derive them from Git history, and direct preview-data authors may provide page.updated_at_iso. When present, ZeroPress formats page.updated_at using the site locale, timezone, date style, and time style:
{{#if page.updated_at_iso}}
<time datetime="{{page.updated_at_iso}}" data-zp-local-date>{{page.updated_at}}</time>
{{/if}}
{
"meta": {
"layout": "case-study"
},
"data": {
"facts": [
{ "label": "Role", "value": "Design Engineering" },
{ "label": "Year", "value": "2026" }
],
"stack": ["ZeroPress", "Cloudflare"]
}
}
{{#for fact in page.data.facts}}
<dt>{{fact.label}}</dt>
<dd>{{fact.value}}</dd>
{{/for}}
If a data value does not match the structure a theme expects, normal template behavior applies. For example, {{#for fact in page.data.facts}} renders empty when facts is not an array.
Page Layout Variants
ZeroPress does not use dynamic page.<template>.html files. Keep page.html as the fixed page route template, branch by scalar metadata, and delegate variant markup to named partials.
{{#if_eq page.meta.layout "case-study"}}
{{partial:page-case-study page=page}}
{{#else_if_eq page.meta.layout "landing"}}
{{partial:page-landing page=page}}
{{#else}}
{{partial:page-default page=page}}
{{/if}}
This keeps routing and validation predictable while still allowing landing pages, case studies, portfolio pages, and other page variants.
Markdown Bodies And H1
For Markdown pages and posts, page.html and post.html may already contain the Markdown H1.
Avoid this duplicate heading pattern:
<h1>{{page.title}}</h1>
{{page.html}}
Prefer choosing one visible H1 source. For Markdown-first documents, let the Markdown body provide the document heading:
<article class="prose">
{{page.html}}
</article>
If a theme intentionally expects body Markdown without a top-level heading, rendering page.title as an H1 is acceptable. The important rule is to choose one source for the visible H1, not both.
Build Pages may set page.meta.source_markdown_url for a View this page as Markdown link:
{{#if page.meta.source_markdown_url}}
<p class="page-source-link">
<a href="{{page.meta.source_markdown_url}}">View this page as Markdown</a>
</p>
{{/if}}
Do not assume this value exists. @zeropress/build-pages can disable Markdown source copying, and non-Build-Pages preview-data generators may omit it.
Menus
Menus are optional maps from preview-data.
{{#if menus.primary.items}}
<nav aria-label="Primary">
{{#for item in menus.primary.items}}
<a href="{{item.url}}" target="{{item.target}}">
{{#if item.meta.icon}}<span class="icon icon-{{item.meta.icon}}"></span>{{/if}}
<span>{{item.title}}</span>
{{#if item.meta.badge}}<span class="badge">{{item.meta.badge}}</span>{{/if}}
</a>
{{/for}}
</nav>
{{/if}}
Menu items may include scalar meta values for custom navigation UI, such as icons, badges, and accents. Menu meta is not a raw HTML channel; values are escaped through normal template rendering.
Menu items should point to real navigation targets. Do not add dummy links such
as url: "#", same-page placeholder hashes such as url: "#section", or links
for pages that do not exist yet. A hash is valid only when it is attached to a
real path, such as /deployment/#github-pages, and the target page exists. Omit
future pages from the menu, or create a real stub page for the planned URL.
The built-in {{menu:primary}} helper renders normal menu markup. Use manual iteration when a theme needs custom icon or badge markup.
Hyphenated ids such as docs-sidebar are valid path segments:
{{#for item in menus.docs-sidebar.items}}
<a href="{{item.url}}">{{item.title}}</a>
{{/for}}
Collections
Named collections are curated page/post groups. Use them for hero rails, featured work, quick links, docs series, portfolio highlights, and magazine sections.
Declare collection_slots in theme.json only when the theme directly references named collection paths such as collections.featured.items:
{
"collection_slots": {
"featured": {
"title": "Featured",
"description": "Curated cards shown on the home page."
}
}
}
Do not declare site-specific collection slots when the theme only uses generic route cursors such as page.collection_cursor or post.collection_cursor. In that case, the site may choose any collection ids, and the theme stays reusable across different documentation structures.
Render matching collection data defensively:
{{#if collections.featured.count}}
<section>
{{#for item in collections.featured.items}}
<a href="{{item.url}}">{{item.title}}</a>
{{/for}}
</section>
{{/if}}
Detail routes can use collection cursors:
{{#if page.collection_cursor.collection_title}}
<p class="eyebrow">{{page.collection_cursor.collection_title}}</p>
{{/if}}
{{#if page.collection_cursor.next}}
<a href="{{page.collection_cursor.next.url}}">
Next: {{page.collection_cursor.next.title}}
</a>
{{/if}}
page.collection_cursor and post.collection_cursor are convenience aliases for the first matching collection cursor in preview-data collection order. They are useful for generic docs-style previous/next pagination where the theme does not need to know the collection id.
Cursor objects expose collection metadata and route position:
collection_cursor.collection_id
collection_cursor.collection_title
collection_cursor.index
collection_cursor.position
collection_cursor.count
collection_cursor.first
collection_cursor.last
collection_cursor.prev
collection_cursor.next
Use collection_title for docs-style eyebrow or group labels when the page title itself should remain the document H1.
If a page or post belongs to multiple collections, every matching cursor remains available under collection_cursors.<id>:
{{#if page.collection_cursors.work.next}}
<a href="{{page.collection_cursors.work.next.url}}">
Next: {{page.collection_cursors.work.next.title}}
</a>
{{/if}}
Use collection_cursor for the default route-level reading order. Use collection_cursors.<id> when the template must render a specific collection’s previous/next links.
collection_slots is authoring metadata only. It does not require preview-data to provide those collections and it does not change build behavior. Missing collection_slots is not a problem for themes that do not read collections.<id> directly.
Site Metadata
Use first-class site fields for shared identity data before inventing theme-specific keys. For example, themes should prefer site.logo.src over site.meta.logo_url:
<a class="brand" href="/">
{{#if site.logo.src}}
<img
class="brand__logo"
src="{{site.logo.src}}"
alt="{{#if site.logo.alt}}{{site.logo.alt}}{{#else}}{{site.title}}{{/if}}"
>
{{/if}}
<span>{{site.title}}</span>
</a>
site.logo is theme-facing render data. Its src is already normalized by build: public Build Pages logos remain root-relative, and admin/media-host logos may be resolved against site.media_base_url.
site.meta is the site-level scalar extension area. ZeroPress does not interpret the keys and does not coerce values to match theme.json.site_meta hints.
Preview-data can provide:
{
"site": {
"meta": {
"issue": "Spring 2026",
"show_sponsor_banner": false
}
}
}
A reusable theme can document those optional keys in theme.json:
{
"site_meta": {
"issue": {
"title": "Issue",
"description": "Short label shown near magazine-style content.",
"type": "string"
},
"show_sponsor_banner": {
"title": "Show Sponsor Banner",
"description": "Whether to render the sponsor banner.",
"type": "boolean",
"default": false
}
}
}
Templates read the values from site.meta:
{{#if site.meta.issue}}
<p class="issue-label">{{site.meta.issue}}</p>
{{/if}}
{{#if site.meta.show_sponsor_banner}}
{{partial:sponsor-banner}}
{{/if}}
String "0" is truthy. Boolean false, null, and an empty string are falsy.
Footer
site.footer is optional theme-facing footer display data:
site.footer.copyright_text: plain footer text. ZeroPress does not add a copyright symbol automatically.site.footer.attribution: normalized by build astrueunless preview-data explicitly sets it tofalse.
Footer example:
<footer class="site-footer">
{{#if site.footer.copyright_text}}
<p>{{site.footer.copyright_text}}</p>
{{#else_if site.title}}
<p>{{site.title}}</p>
{{/if}}
{{#if site.footer.attribution}}
<p>Published with <a href="https://zeropress.app" target="_blank" rel="noreferrer noopener">ZeroPress</a></p>
{{/if}}
</footer>
Media
Media fields such as post.featured_image, page.featured_image, and post.author.avatar may be absolute URLs, root-relative paths, or relative paths.
When site.media_base_url is non-empty, relative media paths are resolved against it. When site.media_base_url is empty, relative media paths are preserved as written so themes can use site-local public/ assets.
Generators that know media dimensions may provide content.media[]. When ZeroPress can match a media string to a registry entry, it adds a derived companion object:
- posts receive
post.featured_media - pages receive
page.featured_media - post authors receive
post.author.avatar_media
Example:
{{#if post.featured_media.srcset}}
<img
src="{{post.featured_image}}"
srcset="{{post.featured_media.srcset}}"
sizes="(min-width: 900px) 720px, 100vw"
width="{{post.featured_media.width}}"
height="{{post.featured_media.height}}"
alt="{{post.featured_media.alt}}"
loading="lazy"
decoding="async"
>
{{#else_if post.featured_image}}
<img src="{{post.featured_image}}" alt="{{post.featured_media.alt}}" loading="lazy" decoding="async">
{{/if}}
srcset is available only when preview-data uses site.media_delivery_mode: "media_domain" with a non-empty site.media_base_url and the matched image is a managed raster media file under that media host.
Markdown Styling
ZeroPress renders common Markdown conventions. Themes should style:
- tables
- strikethrough
- task lists
- GitHub alert blocks with
zp-alert - fenced code blocks with
language-*andhljs-*classes - raw HTML media blocks such as
figure,figcaption,picture,source, andimg
Build-core highlights fenced code blocks with highlight.js. The <code> element keeps the language-* class and highlighted tokens use hljs-* span classes. A client-side highlight.js script is usually not needed for Markdown rendered by ZeroPress.
.prose pre {
overflow-x: auto;
padding: 1rem;
border-radius: 0.5rem;
}
.prose pre code {
display: block;
}
.prose .hljs-keyword,
.prose .hljs-selector-tag {
color: var(--code-keyword);
}
.prose .hljs-string,
.prose .hljs-attr {
color: var(--code-string);
}
.prose .hljs-comment {
color: var(--code-comment);
}
Markdown headings receive stable id attributes and generate page.toc[] or post.toc[] entries for h2 through h4. Theme templates own the visible TOC UI:
{{#if page.toc}}
<aside class="page-toc" aria-label="Table of contents">
<ol>
{{#for item in page.toc}}
<li class="toc-level-{{item.level}}">
<a href="{{item.href}}">{{item.title}}</a>
</li>
{{/for}}
</ol>
</aside>
{{/if}}
Mermaid fences remain readable code blocks by default. Add client-side progressive enhancement when a theme wants diagrams.
Search
Static search UI is theme-owned. ZeroPress native builds can emit:
Set features.search: true when the theme includes static search UI. Omitted features.search behaves like false, so themes without search UI do not need to declare anything.
Native search is enabled only when both conditions are true:
- preview-data does not set
site.search: false - the active theme declares
features.search: true
Wrap visible search UI with site.search; build-core exposes this as the effective value after combining theme capability and site preference:
{{#if site.search}}
<form role="search">...</form>
{{/if}}
/_zeropress/search.json/_zeropress/search.js/_zeropress/search_pagefind.js
The generated adapter is a dependency-free ESM module:
const searchApi = await import("/_zeropress/search.js");
const result = await searchApi.search("query", { limit: 10 });
Themes should provide the form, result list, empty state, keyboard behavior, and dialog behavior. The search adapter provides loading, tokenization, scoring, and a Pagefind-like result shape.
Use the following hook names when wiring theme-owned search UI. These are
conventions for theme JavaScript, not automatic ZeroPress UI behavior:
{{#if site.search}}
<form role="search" data-zp-search>
<input type="search" data-zp-search-input>
<button type="submit" data-zp-search-submit>Search</button>
<p data-zp-search-status></p>
<div data-zp-search-results></div>
</form>
{{/if}}
Mark the actual searchable post/page body with data-pagefind-body so Pagefind post-build indexing does not accidentally include archives, tag pages, or 404 pages:
<div
class="prose"
{{#if site.search}}{{#if_neq page.discoverability "delist"}}data-pagefind-body{{/if}}{{/if}}
>
{{page.html}}
</div>
For advanced search quality, a site can run Pagefind after the ZeroPress build and replace /_zeropress/search.js with /_zeropress/search_pagefind.js. Do not probe both engines on every page view; choose the provider for the site or theme build.
See Static Search for details.
Integrations
Use named partials or trusted custom_html for site integrations:
<head>
{{partial:tracker}}
</head>
<body>
{{slot:content}}
{{partial:content-enhancements}}
</body>
Good uses:
- analytics snippets
- Mermaid loader
- code-copy buttons
- heading UI
- client-side datetime formatting
Generator metadata is a site/build concern. Themes should not hard-code <meta name="generator">; use preview-data site.expose_generator.
Discoverability
post.discoverability and page.discoverability are build policy fields, not permission or membership fields.
defaultleaves automatic discovery unchanged.noindexadds HTML robotsnoindex.delistremoves the document from automatic discovery outputs while still rendering the direct route.
Themes should not treat discoverability as access control. A delisted document can still appear through explicit menus, explicit collections, or manual links chosen by the site author.
Validation Checklist
- Use
runtime: "0.6". - Validate against Theme Manifest Runtime v0.6 Schema.
- Keep
layout.htmlscript-free and include scripts through partials. - Keep class names, data attributes, CSS selectors, and JS selectors aligned.
- Avoid duplicate Markdown H1 output.
- Use typed comparison literals, such as
{{#if_eq loop.index 4}}, not string guesses such as"4". - Use
route.type, not invented flags. - Treat menus, widgets, collections,
site.meta,page.data, andpost.dataas optional. - Keep reusable theme files in
theme/assets/. - Keep site-owned files in public passthrough.
- Do not use footer attribution or generator meta as hard-coded theme branding.