A local theme editor and preview server for Masthead.
Run masthead preview inside a theme directory: your theme renders in a
frame, and a settings sidebar beside it shows every token your
manifest.json declares and every option the page or post you're looking at
declares. Change one and it re-renders — no database, no Phoenix app, no
upload step.
It reproduces Masthead's real render pipeline:
manifest.jsonparsing + validation (identical rules to upload)- the Solid/Liquid sandbox with the same custom filters
(
asset_url,strftime,iso8601,where_tag,search) - token →
:root { --… }CSS injection (file tokens wrapped inurl(…), empty file tokens skipped,object/listtokens skipped) - the content pipeline (Earmark markdown + the same HTML sanitizer),
including Liquid bodies for
html-format pages and posts - theme pages (
templates/pages/<name>.liquid) and their per-page options, merged against the sidecar<name>.jsonschema - the manifest's
render_version, so a theme previews against the exact render contract it names (see below) - the exact route table and controller logic of the public site
The renderer, presenter, manifest, sandbox, filters, CSS sanitizer and content scrubber are faithful copies of the host application's modules, so a template that previews correctly here renders the same way in production.
brew install dijkstrasoftware/tap/mastheadTo track the latest main instead of the newest release:
brew install --HEAD dijkstrasoftware/tap/mastheadThe formula lives in the dijkstrasoftware/homebrew-tap repository.
Requires Elixir (~> 1.15; developed on 1.18 / OTP 28).
git clone https://github.com/dijkstrasoftware/masthead-cli ~/personal/masthead_cli
cd ~/personal/masthead_cli
mix deps.get
mix escript.build # produces ./mastheadPut it on your PATH:
# option A: symlink the built binary
ln -s "$PWD/masthead" ~/.local/bin/masthead
# option B: install into ~/.mix/escripts (then add it to PATH)
mix escript.installmasthead new my-theme # scaffold a new theme from the template
cd my-theme
masthead preview # serves http://localhost:4010
masthead preview --port 4020 # pick a port
masthead preview --dir ../other # point elsewhere
masthead preview --no-editor # serve the theme bare, no sidebar
masthead validate # check manifest + templates, no server
masthead package # zip the theme to ~/Desktop
masthead doctor # report Erlang/Elixir versions, flag mismatches
masthead helpmasthead preview opens an editor shell at http://localhost:4010: the theme
renders in an iframe, with the settings sidebar next to it. Any theme URL you
open lands there, and clicking through your theme's own navigation moves the
sidebar with you.
The sidebar has a route picker at the top — Viewing, which jumps to any page or post in your preview content — and then three tabs:
- Content — the preview's own posts and pages. Add one, remove one, or edit a title, slug, excerpt, date, tags, format or body in place — and set that item's options right there, whichever page or post it is. You never have to write a content file to try your theme against something else.
- Theme — every token in
manifest.json, grouped bycategory. - Page options / Post options — the same options for whatever is in
the frame right now, without hunting for it in the list. A page's schema
comes from its sidecar config (
templates/pages/<name>.json) when it is a theme page, otherwise from the manifest'spage_options; a post's comes frompost_options. The post list and search have neither, same as in production. Both tabs edit one value — whichever you use, it is the same entry inpreview.local.json.
Editing a field re-renders the page on the server. That matters: a list
token your template loops over, or a boolean your template branches on,
produces exactly the HTML the platform would produce — not a CSS-variable
approximation.
Everything else reloads by itself too: save a .liquid file, theme.css or
manifest.json and the frame refreshes.
Every value the sidebar writes goes into preview.local.json in the theme
directory:
{
"tokens": {
"accent": "#d9480f",
"footer": { "tagline": "Made in Utrecht" },
"footer_links": [{ "label": "Docs", "url": "/docs" }]
},
"pages": {
"home": { "page_options": { "hero": { "heading": "Hello" } } }
},
"posts": {
"hello-world": { "post_options": { "featured_image": "/assets/cover.jpg" } }
},
"content": {
"posts": [{ "title": "Hello world", "slug": "hello-world", "body": "Hi." }]
}
}content appears once you edit a post's or page's content in the sidebar:
the whole list is copied in, seeded from whatever was rendering, and owns that
kind (posts or pages) from then on. Editing only an item's options
leaves content alone — the seed still owns the list, and just that slug
carries an override. Removing an item drops the options it left behind, so a
later item reusing the slug starts clean.
This is a preview-only scratchpad. Nothing here is applied, uploaded, or
visible to the platform — it exists so your edits survive a refresh. It layers
on top of the preview.json seed you hand-author (which is never rewritten),
masthead package leaves it out of the zip, and masthead preview adds it to
your .gitignore for you. Reset in the sidebar throws it all away.
A manifest token, a page_options or post_options field, and a theme
page's sidecar field are all the same declaration, with the same types:
| Type | Control |
|---|---|
color |
colour picker + hex field |
string, length, number, url |
text input |
text |
textarea |
select |
dropdown over options (required) |
boolean |
checkbox — reaches templates as a real boolean |
file |
asset picker (see below) |
object |
a group of nested scalar fields under one key |
list |
a repeatable group: add, reorder (drag), remove |
object and list nest one level deep — their fields must be scalars. A
list can declare default items and an item_label (the "Add …" button).
{
"key": "footer_links",
"label": "Footer links",
"type": "list",
"item_label": "Link",
"default": [],
"fields": [
{ "key": "label", "label": "Label", "type": "string", "default": "" },
{ "key": "url", "label": "URL", "type": "url", "default": "" }
]
}{% for link in theme.tokens.footer_links %}
<a href="{{ link.url }}">{{ link.label | escape }}</a>
{% endfor %}Scalar tokens also become CSS custom properties (--accent); object and
list tokens are template-only and never reach the :root block.
Masthead freezes its renderer and versions it, so a theme keeps rendering the way it did the day it was written. Your manifest names the contract it was built against:
{ "name": "Studio", "slug": "studio", "version": "1.0.0", "render_version": "v1" }render_version |
Page options are declared as | Templates read |
|---|---|---|
absent (or "beta") |
metadata |
page.metadata.<key> |
"v1" |
page_options + post_options |
page.page_options.<key>, post.post_options.<key> |
post_options only exist under v1 — declaring them on a beta theme is a
validation error, because the beta renderer cannot expose them. An unknown
render_version is rejected too, so a typo fails masthead validate rather
than silently rendering against the wrong contract.
Preview renders against whichever version your manifest names, and
masthead validate prints it (renders as). To move an existing theme to
v1: add "render_version": "v1", rename the manifest's metadata key (and
any templates/pages/*.json one) to page_options, and change
page.metadata. to page.page_options. in your templates.
masthead new NAME scaffolds a theme in a NAME/ directory by cloning the
theme template — a
complete, valid starter with a theme page and example object/list tokens.
The template's git history is removed so you start clean, and the manifest's
name/slug are set from NAME:
masthead new my-theme
cd my-theme
masthead previewThe directory name seeds the slug (lowercased, hyphenated), so
masthead new "My Theme" produces slug my-theme and name My Theme. It
requires git on your PATH and won't overwrite a non-empty directory.
manifest.json
theme.css
templates/
layout.liquid index.liquid post.liquid
page.liquid not_found.liquid
pages/ (optional — theme pages: <name>.liquid + <name>.json)
assets/ (optional — images, fonts, extra css)
preview.json (optional — your preview content; not packaged)
preview.local.json (written by the editor; gitignored, not packaged)
Files under assets/ are served at /assets/..., matching
{{ 'logo.png' | asset_url: theme.asset_base }}.
masthead package bundles the theme into an installable Masthead theme zip —
exactly the files the platform consumes, and nothing else:
manifest.json
theme.css
templates/{layout,index,post,page,not_found}.liquid
templates/pages/*.liquid + *.json (theme pages + their settings)
assets/… (only whitelisted file types, no symlinks)
Dev-only files (preview/, preview.json, preview.local.json, README.md,
.git, .DS_Store, stray *.zip, …) are left out. The theme is validated
first (manifest + all templates + page configs must parse), so a broken theme
won't package, and the platform's upload limits (5 MB zip, 25 MB unpacked, 200
files) plus the reserved-slug / asset-type rules are checked up front and
reported as warnings.
masthead package # -> ~/Desktop/<slug>-<version>.zip
masthead package --out ~/Downloads # into a directory
masthead package -o ./dist/theme.zip # exact file path
masthead package --bump minor # bump manifest version firstWith no configuration, the preview ships sample content — a few lorem ipsum posts and pages — so every theme has something to render. The copy is filler on purpose: it can't be mistaken for your own, and it keeps your eye on the layout. The shape is real (headings, lists, a blockquote, a code block, links, tags, dates), so your CSS gets exercised.
There are three ways to change it, and they layer in this order:
- The Content tab in the sidebar — no files, no restart. The first edit
copies whatever is showing into
preview.local.json, which owns it from then on; Reset hands it back. preview.json— a committed seed you hand-author (below).- Markdown files in
preview/posts/andpreview/pages/(below), which win overpreview.json's inline arrays.
Customise it with a preview.json in the theme directory:
{
"site": {
"name": "Acme Co.",
"title": "Acme — we make things",
"description": "A short site description.",
"slug": "acme",
"css_overrides": ".intro { letter-spacing: -0.02em; }",
"homepage": "home"
},
"tokens": { "accent": "#d9480f" },
"pages": [
{ "title": "Home", "slug": "home", "format": "theme", "template": "home" }
],
"posts": [
{ "title": "Hello", "slug": "hello", "published_at": "2026-01-15",
"tags": ["Writing"], "body": "A post body." }
]
}site.homepageis the slug of the page served at/(just like setting a homepage in the Masthead admin). Omit it to render theindextemplate.tokensseed the token values; the sidebar's edits layer on top.pageswith"format": "theme"pick atemplates/pages/<template>.liquid.- A post's
tagsdrivetags,?tag=filtering,posts_by_tagand thewhere_tagfilter, exactly as on the live site.
Drop one file per post/page into preview/posts/ and preview/pages/, with
an optional JSON front matter block:
---
{ "title": "About", "slug": "about", "format": "markdown",
"page_options": { "layout": "wide" }, "show_in_nav": true }
---
## About us
Markdown body goes here.Anything omitted is inferred from the filename (about.md → slug about,
title About). published_at accepts an ISO-8601 datetime or a date. Posts
are ordered newest-first, pages by title — matching the live site's queries.
These files take precedence over inline posts / pages arrays in
preview.json.
Same contract as production:
| Variable | Shape |
|---|---|
site |
name, title, description, slug, css_overrides, homepage_slug |
theme |
name, slug, version, asset_base, tokens.<key>, css |
post |
title, slug, excerpt, published_at, url, tags, post_options.<key> (v1) |
posts |
list of the above |
page |
title, slug, format, template, url, page_options.<key> (metadata.<key> on beta) |
pages |
list of the above (nav: homepage + show_in_nav:false excluded) |
tags / current_tag |
the site's tags (with active) and the one being filtered on |
posts_by_tag |
posts_by_tag["slug"] → the posts carrying that tag |
search_query / search_count |
on /search only |
body_html |
rendered post/page body (emit raw, do not escape) |
content |
(layout only) the rendered inner template |
Solid does not auto-escape — call | escape on user strings, exactly as
in production.
- File fields: production stores an upload id and resolves it to a URL
via the uploads table. There are no uploads locally, so a
filevalue is used verbatim as a URL/path — the sidebar offers a picker over your theme'sassets/folder, plus a free-text field for any URL. Empty file tokens emit no CSS declaration, just like production. - The editor injects a small script into the theme's HTML (to keep the
frame and the sidebar in sync). Production never touches theme output. Run
masthead preview --no-editorto see the theme exactly as it ships.
mix test # renderer, manifest, packager, settings, router
mix format
mix compile --warnings-as-errors