Authoring a theme

Build a Cusp theme: package layout, the manifest, signing, the install workflow, local dev, and design tokens

A Cusp theme is a signed package that customises the public-facing site. It ships TypeScript template modules, design-token defaults, and static assets, and it installs from R2 at runtime — no redeploy, no editing the Worker. Cusp ships Aurora, the built-in default theme compiled into the Worker — it needs no install and is what a fresh deploy renders with. On top of Aurora there are four installable first-party themes (Twenty Twenty-Six, Spring 2027, Quarto 2027, and Almanac), packaged as signed tarballs bundled with the Worker; the example-site seed installs them so an editor can switch between them and Aurora from /admin/themes without ever opening the install dialog. Everything in this guide applies to those packaged themes and to one you author yourself — Aurora is the exception, since it is part of the Worker build rather than an installable package.

Read examples/first-party-theme/twenty-twenty-six alongside this page. It is the smallest complete worked example: a manifest, tokens.json, the required page and listing templates, a home template, assets, and a packed tarball under dist/.

Package layout

A theme is a flat directory tree. Every key under a versioned prefix is immutable for the life of that version: re-installing the same theme and version overwrites identically, and changing content means cutting a new version. The manifest is the only stable surface — every other path is referenced from it, so you can reorganise your template layout freely as long as the manifest points at the new paths.

  • manifest.json — the canonical, signed descriptor (see the next section for the full field list).
  • manifest.sig — the detached Ed25519 signature over the canonicalised tarball.
  • templates/ — render-adapter modules. page.ts and listing.ts are required; home.ts, sitemap.ts, feed.ts, and 404.ts are optional. Each is a default export — export default function render(document, context) returning a string. If you omit 404.ts, Cusp renders not-found pages through your page.ts with a synthetic 'Page not found' document, so the 404 still wears your theme's chrome (ADR 0056).
  • templates/blocks/ — per-block-type renderers for any blocks the theme contributes.
  • tokens.json — design-token defaults (colour, font, type scale, spacing, radius, layout). Required.
  • assets/ — CSS, fonts, images. Served public and immutable from /_themes/{themeId}/{version}/assets/{path}.
  • previews/ — optional low-fidelity block previews for the admin editor.
  • screenshot.png — a preview image referenced from the manifest.

Templates are plain TypeScript, not .astro files — the Worker's bundler does not process .astro from R2 at runtime. Theme code never enters the Worker bundle: at render time the active theme's compiled JavaScript is loaded from R2 through the Dynamic Worker Loader as a sandboxed sub-isolate. That sub-isolate receives no host bindings (no DB, no BUCKET, no KV) and no outbound network access. A theme is a pure function from (document, context) to HTML; the host constructs every datum the theme reads and filters anything sensitive out of the render context.

The manifest

manifest.json is validated by the install workflow's schema validator on install and on upgrade. The validator collects every error in one pass rather than failing on the first, so an install failure surfaces the whole list. The fields are:

  • id — required. Lowercase, 3–63 chars, matching ^[a-z][a-z0-9-]{1,61}[a-z0-9]$. "core" and "plugin" are reserved.
  • version — required. Strict MAJOR.MINOR.PATCH semver (no pre-release tags in v1).
  • name — required. 1–80 chars.
  • description — optional. Up to 500 chars.
  • author — optional object: name (required if present), optional email and https:// url.
  • license — optional. Up to 64 chars (an SPDX identifier, e.g. "MIT").
  • homepage — optional. Must be an https:// URL.
  • cuspMin — required. The minimum Cusp version the theme supports (semver).
  • cuspMax — optional. A semver, an X.x range, or a ^X.Y.Z caret range. Install is rejected if the running Cusp version falls outside [cuspMin, cuspMax].
  • templates — required object mapping render slots to paths. page and listing are required keys; home, sitemap, feed, and 404 are optional.
  • tokens — required. Path to your tokens.json.
  • assets — required. Path to the asset directory.
  • previews — optional. Path to the block-preview directory.
  • screenshot — required. Path to the preview image.
  • blocks — optional array (max 100) of theme-contributed block types. Each entry needs key, name, category, rendererPath, and schema; previewPath is optional. Keys must be namespaced under your theme id (e.g. twenty-twenty-six/pullquote) and cannot start with core/ or plugin/.
  • fieldTypes — optional array. Reserved; theme-contributed field types are not supported in v1, so this must be empty if present.
  • integrity — required object with a files map of theme-relative path → sha256:<64-hex> digest. Every shipped file is verified against this map after unpacking.

Signing

Themes are signed with detached Ed25519 over the SHA-256 hash of a canonicalised, uncompressed USTAR tarball. Canonicalisation (lexicographic file order, mtime and uid and gid zeroed, mode 0644, no symlinks or PAX headers, manifest.sig excluded from the payload) makes the hash deterministic across machines. The tarball is distributed gzipped for size; verification recomputes the hash from the un-gzipped bytes.

The signature file records the algorithm, a stable keyId, the payload hash, and the signature. Cusp bundles a registry of trusted public keys keyed by keyId; the verifier rejects any package whose keyId is unknown or past its validity window. The registry is plural, so keys rotate with overlapping validity windows without breaking in-flight installs. The private key is never in the repository and never enters the Worker bundle — only the public key ships.

For local development you do not need to sign at all. Set the security.allow_unsigned_themes setting to true and the verifier short-circuits the signature check. This is a single, deployment-wide flag — there is no per-theme allowlist and no shared dev keypair. Production deployments leave it false (the default) and the admin Health page shows a prominent warning whenever it is on.

The install workflow

Installing a theme runs the InstallPackage workflow. Each step is independently retryable and idempotent against the immutable R2 keys it writes, so a failure between steps leaves a recoverable state rather than a half-installed theme.

  1. Download — fetch the source to a staging R2 key namespaced by the workflow instance, so parallel installs do not collide and retries overwrite cleanly.
  2. Verify — recompute and check the signature (skipped when allow_unsigned_themes is true), validate the manifest with the install workflow's schema validator, verify each file against the integrity map, check the Cusp version range (cuspMin to cuspMax), and reject block-key collisions against block_definitions.
  3. Install to R2 — unpack to themes/{themeId}/{version}/, writing every verified file verbatim, and set immutable cache headers on assets. The workflow does not run a compiler: the theme-author CLI already compiled each template from .ts to .js before the tarball was signed, so the render path only ever reads .js (ADR 0028 Amendment 1).
  4. Register the row — write the themes row through the versioned-write helper and record a theme_installations row tracking install state.
  5. Register blocks — insert theme-contributed block_definitions through the chunked-write repository (batches of 50), each owned by the theme.
  6. No purge — install never invalidates the rendered HTML cache. Install is preparatory; activation owns the purge.
  7. Clean up — delete the staging tarball.

Install and activation are deliberately separate. Installing a theme does not switch the live site to it. Activation is a settings change: POST /api/admin/themes/{id}/activate writes the active-theme pointer through the versioned-write helper and purges the theme surrogate key so cached pages re-render against the new theme. Because activation is just a versioned setting, time travel inherits theme history for free, and rolling back is another settings save.

v1 installs from two sources: the bundled first-party themes, resolved through an internal _first_party:// source URL the seed flow uses, and any HTTPS URL pasted into the admin's install dialog. Host your packed .tar.gz anywhere the deployment's Worker can reach it and the paste-URL flow installs it. The downloader runs an SSRF guard that rejects private and loopback addresses, so you cannot point a production install at localhost — see the next section for the local-dev path.

Local theme development

Pack a theme into a signed (or, in dev, unsigned) tarball with theme:pack. It produces the canonical USTAR artefact under dist/ that the install workflow's verifier expects byte-for-byte.

pnpm theme:keygen                                      # one-time, prints a keypair
export CUSP_THEME_SIGNING_KEY=<base64url-private-key>
pnpm theme:pack ./path/to/your-theme --key-id my-org-2026

For the inner loop against a running wrangler dev, theme:dev packs the directory and uploads the bytes to an admin endpoint that stages them in R2 and kicks the install workflow through an internal _uploaded:// source URL. This keeps the SSRF guard absolute — no localhost carve-out, no tunnel — while every install path still flows through the same verifier. Three one-time setup steps:

  1. Add CUSP_THEME_DEV=1 to .dev.vars (gitignored). The upload route 404s unless this is set and the Dynamic Worker Loader binding is absent, which is only true under wrangler dev — production can never reach it.
  2. Set security.allow_unsigned_themes to true on the dev server. The upload route hard-rejects with 403 until it is on.
  3. Copy your admin session cookie (__Host-cusp_session) from the browser into CUSP_DEV_SESSION in the shell where you run theme:dev.
export CUSP_THEME_SIGNING_KEY=<value>   # any keypair; the verifier short-circuits in dev
export CUSP_DEV_SESSION=<cookie-value>
pnpm theme:dev ./path/to/your-theme --watch

With --watch the CLI re-installs after each save; saves within a 3-second quiet period collapse into one re-install so the watch loop stays under the install rate limit. Build artefacts, hidden files, and editor swap files are filtered. The CLI prints the canonical themeId and version on every install so you know what to activate.

One caveat: local wrangler dev does not expose the Dynamic Worker Loader, so a dev-installed theme installs and activates against the dev server's D1 and R2 but its templates do not render until a real deploy. The admin Themes list flags this so you do not mistake the active badge for a working render swap. The install state itself is fully exercisable locally.

Rendering images

The host resolves each core/image block's mediaId to a media row before your template runs and threads it through the render context. If the media has been deleted, the resolution returns nothing — your template must render a fallback rather than emit a broken <img>. The first-party convention, which you should follow, is to render a <figure> with no <img> at all and put the block's alt text into a <figcaption> as the fallback:

<!-- media present -->
<figure class="cusp-block-image">
  <picture>
    <source type="image/avif" srcset="…" />
    <source type="image/webp" srcset="…" />
    <img src="…" alt="Sourdough loaf, just scored" loading="lazy" decoding="async" />
  </picture>
</figure>

<!-- media deleted: no <img>, alt text as the caption -->
<figure class="cusp-block-image block-image--missing">
  <figcaption>Sourdough loaf, just scored</figcaption>
</figure>

When the alt text is empty the first-party themes fall back to a generic "Image not found" caption. Mirror this in your own block renderers so a deleted asset degrades to readable text instead of a broken image icon.

Design tokens and the customise contract

Your tokens.json declares default values for colour, fonts, type scale, spacing, radius, and layout. On every public render the host emits a <style> block into the document head declaring each resolved token as a CSS custom property, with any editor overrides already overlaid. To make your theme customise-aware, reference those custom properties from your stylesheet instead of your own namespace.

The path-to-name transform is mechanical: a dot-joined token path becomes a hyphen-joined custom-property name prefixed with --cusp-.

  • color.accent — var(--cusp-color-accent)
  • color.fg.muted — var(--cusp-color-fg-muted)
  • text.h1.size — var(--cusp-text-h1-size)
  • font.body.stack — var(--cusp-font-body-stack)
  • layout.gutter.mobile — var(--cusp-layout-gutter-mobile)
  • space.0 — var(--cusp-space-0)

So a rule that read color: var(--my-theme-accent) becomes color: var(--cusp-color-accent), with color.accent declared in tokens.json as the default. An editor can then override color.accent from /admin/customise and the change takes effect site-wide — no theme rebuild. Because the resolved tokens are baked into a per-render <style> block inside every prerendered page, saving an override drains the whole rendered/ prefix in R2 and bumps the cache epoch, so every page re-renders with the new value on its next request (ADR 0055). The static stylesheet stays immutable-cached; only the per-render <style> block changes. Overrides resolve in a fixed order: a theme-specific override wins over a cross-theme core override, which wins over your tokens.json default.

Two naming rules follow from the path grammar. Each path segment must match lowercase ASCII alphanumerics, with no hyphens, underscores, or camelCase — use lineheight not lineHeight, maxwidth not maxWidth. Numeric segments are permitted, including numeric-leading ones like space.0 for spacing scales. And token values are checked against a CSS-injection denylist: no angle brackets, semicolons, braces, backslashes, newlines, or expression(/url(/@import substrings. Background images belong in your static stylesheet's url(...) form, not in an overridable token.

Theme-distinctive chrome — the site title, brand mark, skip link — deliberately stays hard-coded as part of the theme's identity. If you want to expose one as customisable, just declare its path in tokens.json and reference var(--cusp-{path}) in the stylesheet.


The package format, manifest, signing scheme, install workflow, and sandbox are pinned in ADR 0028; the local-dev install path in ADR 0031; the design-token overlay in ADR 0032.

Extending Cusp