Writing a Sandra theme
Putting content into a site is a different job — see The content API.
A theme decides what a site can be made of: which blocks exist, what fields they take, how they render, and how one entry looks as its own page.
There is no build step. A theme is declarative data — JSON and Liquid, validated on install, so there is nothing to compile: you write files, zip the folder, and upload it.
Nothing in a theme ever runs on the server (D33). A theme may ship one browser script, and that runs where every other script does — in the reader's browser, and in a preview inside a sandbox. Its Liquid never executes; it is rendered by a sandboxed engine that can only emit values already escaped.
Start from the example
Rather than a blank folder, start from Starter — a complete, deliberately minimal theme: five block types, one entry template, one declared collection, and a stylesheet. Every file is commented with what it is demonstrating.
curl -O https://sandracms.com/starter-theme.zip
It is the shortest useful answer to "what does a theme look like". Rename the keys, replace the CSS, and you have your own. What it covers:
| File | Shows | |
|---|---|---|
theme.json | palette roles, a declared collection | |
blocks/starter_menu | a slot block, and nav.items | |
blocks/starter_hero | escaping, `\ | rich`, guarding unset fields |
blocks/starter_text | a repeater, and why it is not an entry | |
blocks/starter_posts | listing a collection, and the entries drop | |
blocks/starter_footer | site.fields, the custom-field bag | |
entries/blog.liquid | one entry as its own page | |
assets/theme.css | the --c-{role} palette contract |
Starter is also the only theme source you can read: Sandra Theme ships built-in and is not downloadable.
theme.json
blocks/{key}/schema.json
blocks/{key}/template.liquid
entries/{profile}.liquid
assets/theme.css ← your stylesheet
assets/theme.js ← optional, and the only script (see JavaScript)
assets/fonts/…, assets/img/… ← anything it references
Zip the contents of that folder, not the folder itself — though one wrapping directory is tolerated, since that is what most tools produce.
Limits, all refused at validation rather than silently truncated:
| Package size | 8 MB, compressed and expanded |
| Files in the package | 400 |
| Block types | 200 |
assets/theme.js | 48 KB, and it is the only .js a package may contain |
| Media upload (separate, per file) | 24 MB |
Anything outside the four path shapes above is ignored rather than rejected, so a stray .DS_Store will not fail an install.
Check it before you install it
Writing a theme is iterative, and a half-installed theme is worse than none. So ask first, as many times as you like:
POST /v1/themes/validate multipart `theme`, or JSON {"content_base64": "…"}
It reports every problem at once — field-addressable, the same shape as any other validation error — and installs nothing:
{
"ok": false,
"errors": [
{ "field": "blocks.masthead.template",
"message": "This template did not parse: Variable was not properly terminated with: }}" },
{ "field": "blocks.footer.fields.note.type",
"message": "'php' is not a field type. See /v1/capabilities." }
]
}
When it says ok, POST /v1/themes with the same body installs it. Or upload the zip in the Studio, which does both.
Working inside the engine's own repo?
bin/php bin/build-theme.php <directory>packages a folder and runs these same checks locally before writing the zip. It is a convenience, not a requirement — the API above is the real contract.
theme.json
{
"key": "plainly",
"version": "1.0.0",
"name": "Plainly",
"palette": { "ink": { "label": "Ink", "default": "#141414" } },
"collections": { "blog": { "label": "Writing", "profile": "blog" } }
}
key is lowercase letters, numbers and underscores. version is three numbers. A new version is a new install, never an edit in place, so a site authored against 1.0 can still be explained after 1.1 exists (D16).
Shipping an update
You cannot publish. Installing a theme puts it on the server; it does not put it on anybody's live site, and it does not move a site onto it — a site points at one specific version and stays there until its owner says otherwise. So the last step of shipping an update is always somebody else's.
What you control is whether they can see there is one to take:
- Bump the version. The owner's Studio then says the new version is ready, shows what changes, and offers a one-press update. This is the path to use for anything you want someone to actually adopt.
- Re-upload the same version and the templates are replaced where they stand. Sites already on that version change immediately, with no decision to make — which is what you want while you are still building, and not what you want once somebody is relying on it. Their published site keeps the old HTML until they publish again; the Studio tells them so, but it is a nudge, not a version they chose.
Either way the site owner has to press Publish for anything to reach the live site. Say so when you hand a theme over — it is the step people forget.
collections declares what the theme expects. Declaration is not creation: the owner accepts it, and the field set is copied into their site. After that the fields are theirs, and no theme upgrade or switch can reshape them (D36).
profile and role are different words for different things
This trips people up, so it is worth being blunt about:
| What it is | Vocabulary | Example | |
|---|---|---|---|
Block role | what a block is for | SCHEMA_ROLES, 18 of them | hero, navigation, entry_list |
Collection profile | which shape a collection follows | COLLECTION_PROFILES | blog |
They never overlap. entry_list is a block that shows a list of entries; blog is what a collection of posts is. An entry template is named after the profile — entries/blog.liquid — never after a block role.
Block types
blocks/{key}/schema.json is the same format the engine already uses:
{
"label": "Opening",
"category": "hero",
"slot": "hero",
"role": "hero",
"fields": {
"heading": { "type": "text", "label": "Heading", "maxLength": 120 },
"body": { "type": "richtext", "label": "Body" },
"_bg": { "type": "palette_colour", "label": "Background", "group": "Appearance" }
}
}
slotismenu,hero,footer, or absent for page content — see Slot blocks below.roleis what the block is for — a block role, not a collection profile. Names are yours —pbr_mastheadis fine — but the role is how anything else finds it: a theme switch, an agent asking for "a call to action", a page preset. See/v1/capabilitiesfor the vocabulary. It is closed: a typo is refused rather than accepted and unmatched. Omit it if nothing here fits — an honestnullbeats a role that will be mapped onto wrongly.
| Role | What it is for |
|---|---|
navigation | Site navigation |
hero | Opening statement at the top of a page |
page_title | A page heading, where a full hero would be too much |
footer | Site footer |
text | Prose, with or without an image |
cta | A call to action |
feature_list | Several short selling points |
gallery | Images shown together |
faq | Questions and answers |
team | People |
testimonial | A quote or endorsement |
pricing | What things cost |
contact | How to make contact |
hours | When you are open |
product_list | Several products or menu items |
product_detail | One product in full |
entry_list | A list of entries from a collection |
entry_detail | One entry rendered in full |
entry_list and entry_detail are for blocks that show entries from a collection. They are block roles, not collection profiles — see below.
_bgand_accentare the engine's appearance fields. Include_bgunless the block genuinely cannot be tinted.
You cannot invent a field type. The vocabulary is text, textarea, richtext, lines, link, image, select, toggle, repeater, palette_colour, date, number, collection. This is where editorial control lives: every field a theme uses is validated and escaped by the engine, whoever wrote the theme (D30).
Templates
Liquid, in a sandbox with no filesystem and no arbitrary method calls.
<header class="pl-opening"{% if block.appearance != '' %} style="{{ block.appearance }}"{% endif %}>
<div class="pl-inner">
<h1>{{ block.heading }}</h1>
{%- if block.body != '' %}<div class="pl-rich">{{ block.body | rich }}</div>{% endif %}
</div>
</header>
Every value arrives already escaped. {{ block.heading }} is safe by construction. | rich is the only raw output path, and it works on richtext fields only — applying it to a plain field throws rather than trusting you.
What survives a richtext write
| rich is the read side. The write side sanitises, and it matters most when importing existing HTML, so here is exactly what happens:
| Kept | p br div h2 h3 strong b em i u a blockquote ul ol li pre code img |
| Removed entirely | script, style, template — contents and all, because their contents are code, not writing |
| Unwrapped | anything else — figure, table and the rest lose the tag, and their text is kept |
| Attributes | href on a, src and alt on img. Everything else goes, on every tag — class, style, width, and every on* handler |
| Rewritten | every <a> gains rel="noopener nofollow"; entities normalise to characters, so — is stored as — |
Unwrapping rather than deleting is deliberate: losing a tag costs you formatting, losing the text costs you the content. Import something with tables and you get the words, not an empty gap — so check the result rather than assuming it round tripped.
script, style and template are the exception, because the same reasoning inverts: their contents are not writing. Keeping the text of a tracking snippet put trackEvent('pageview'); on the page as a paragraph — inert, and obviously not what anyone meant.
img is the exception to that reasoning, which is why it is allowed. An image has no inner text, so unwrapping it left nothing at all — a migration lost its pictures with no trace in the output. src is held to the same rule as href: http, https, mailto, tel or a relative path. An image whose src does not pass is removed entirely rather than left as an empty box.
A media token works as a src. Put the token add_image gave you straight into the markup and the engine resolves it, the same way it resolves an image field — pointing at the engine in a preview and inside the bundle at publish, at the right depth for the page it is on:
<img src="m_8bfd0624fa74" alt="The rendered output">
Do not hard-code assets/media/…. That is the bundle's internal layout and it is not a theme's to know; it also bakes in how deep the page happens to sit, so one nesting change breaks every inline image at once.
Put {{ block.appearance }} on the root element, or the block cannot be tinted.
What a template can see:
| Drop | What it holds |
|---|---|
block.* | this block's fields, plus appearance and type |
site.* | name, home, fields.* (custom fields) |
page.* | title, slug, fields.* |
nav.items | label, url, active, children — already base-path aware |
entries.* | published entries, by collection key and by profile — in an entry template too, so an article can list its siblings |
appearance.* | palette roles, fonts, type scale |
Never hand-write a leading slash, and never build a URL from a slug. Use {{ item.url }}, {{ site.home }} and {{ entry.url }} as they come.
They are not the same string on every surface, and they are not meant to be. A published bundle has to work unzipped in a subfolder as well as at a domain root, so its links are relative — about.html, services/design.html. A preview is not a file tree: its pages are served by a preview route, so the same links come back with that route in front of them. The path is identical either way; only the base changes, and the base is the engine's business.
So do not compare a URL against a literal, strip a prefix off one, or append to one. If you find yourself needing to, say what you actually want — item.active already tells you which page is the current one.
A link field is resolved too, and always was meant to be. Whatever an editor types into one — get-in-touch.html — reaches your template already pointing at the right place from the page being rendered, exactly like item.url. Output it directly:
<a href="{{ block.cta_url }}">{{ block.cta_text }}</a>
If your theme prefixes link fields by hand, remove that now. Building a base out of
site.homeand appending it to a link field was a real workaround for a real bug, and it will now produce../../get-in-touch.htmlwhere it used to produce../get-in-touch.html. Only a link field is resolved — atextfield that happens to hold something path-shaped is still just text.
Left alone, because they already say where they point: a full URL, mailto:, tel:, a protocol-relative //host, anything starting /, and an anchor or a bare query, which are deliberately relative to the page they sit on.
Slot blocks
A slot block is an ordinary block. It gets the same drops, renders through the same pipeline, and there is no special mechanism to learn. The slot decides only two things:
1. Where it lands. menu renders above the page's content, hero next, footer last. Body blocks sit between hero and footer, in their own order. 2. That it can be shared. A slot block chosen for the site is stored once and rendered on every page, so a header is edited in one place. A page may override its own slot, or hide it.
So a menu block is written exactly like any other:
<nav class="pl-masthead"{% if block.appearance != '' %} style="{{ block.appearance }}"{% endif %}>
<a href="{{ site.home }}">{{ block.brand }}</a>
<ul>
{%- for item in nav.items %}
<li><a href="{{ item.url }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.label }}</a></li>
{%- endfor %}
</ul>
</nav>
nav.items is the page list, ready to render: label, url, active and children, with URLs already base-path aware. It is available to every block, not just menus — a footer with a sitemap column reads the same drop.
Pages go two levels deep. Every item has children, always an array, so a template can loop without a guard — and one nested loop is the whole of it, never recursion:
{%- for item in nav.items %}
<li>
<a href="{{ item.url }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.label }}</a>
{%- if item.children.size > 0 %}
<ul>
{%- for child in item.children %}
<li><a href="{{ child.url }}">{{ child.label }}</a></li>
{%- endfor %}
</ul>
{%- endif %}
</li>
{%- endfor %}
A parent is active when it or anything inside it is the current page, so a section stays lit while a reader is on one of its pages. A child publishes to {parent}/{child}.html, and its links climb out on their own — never write a leading slash.
A dropdown is best left to CSS even now that a theme may ship a script — it works before the script loads, and for anyone whose script never arrives: :hover and :focus-within to open it, and on narrow screens show it indented instead. A dropdown that needs a pointer does not exist on a phone. Starter's menu does exactly this.
A theme needs at least one block per slot it expects to fill, and each must declare the matching slot. A block with "slot": "menu" can only be chosen for the menu; a block with no slot can only go in the body.
Styling
assets/theme.css is linked on every page of a site using your theme. Put your whole stylesheet there — converting an existing site usually means pasting it in almost unchanged.
Anything it references goes beside it:
assets/theme.css
assets/fonts/inter.woff2 → url('fonts/inter.woff2')
assets/img/texture.png → url('img/texture.png')
Reference them relatively from the stylesheet. A published bundle copies the whole assets/ tree in beside your CSS, so relative URLs keep working unzipped anywhere; an absolute path would break the moment the site lived in a subfolder.
Allowed: .css, .js, .woff, .woff2, .ttf, .otf, .svg, .png, .jpg, .jpeg, .webp, .gif, .ico. Nothing is served from the docroot — assets are streamed by the engine with an explicit content type and nosniff.
JavaScript
A theme may ship one script, at assets/theme.js. The engine links it with defer, so it runs against a finished document, and it travels into the published bundle beside the stylesheet.
assets/theme.css
assets/theme.js ← one file, this name
Write it as enhancement, not as the page. The bundle is a standalone site on somebody else's host: if the script fails there, nothing of ours can intervene. So build markup that already works, and let the script improve it.
A collapsing menu is the worked example, because it is the thing most themes want first. The template renders a button and the links:
<nav class="t-menu">
<ul class="t-menu__links">
{%- for item in nav.items %}
<li><a href="{{ item.url }}">{{ item.label }}</a></li>
{%- endfor %}
</ul>
<button type="button" class="t-menu__toggle" data-menu-toggle aria-label="Menu">☰</button>
</nav>
The stylesheet hides the button wherever the links fit, and hides the links only where they do not — so a page with no script still shows every link at the width where there is room for them:
.t-menu__toggle { display: none; }
@media (max-width: 720px) {
.t-menu__toggle { display: inline-flex; }
.t-menu__links { display: none; }
.t-menu.is-open .t-menu__links { display: flex; flex-direction: column; }
}
And assets/theme.js gives the button its behaviour:
document.querySelectorAll('[data-menu-toggle]').forEach(function (button) {
var nav = button.closest('.t-menu');
// Set here, not in the template: there is no disclosure to announce until
// this file has run.
button.setAttribute('aria-expanded', 'false');
button.addEventListener('click', function () {
var open = !nav.classList.contains('is-open');
nav.classList.toggle('is-open', open);
button.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Escape closes it and puts the caret back where a keyboard reader left it.
nav.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && nav.classList.contains('is-open')) {
nav.classList.remove('is-open');
button.setAttribute('aria-expanded', 'false');
button.focus();
}
});
});
Note what the script does not do: it does not build the menu, fetch it, or decide what is in it. The document is complete before it runs. That is the whole contract, and it is what makes a script that fails to load a cosmetic problem rather than a site with no navigation.
Four rules, refused at install rather than discovered later:
| One file | assets/theme.js. Bundle anything else into it. |
| No external hosts | No CDN import, no fetch to another origin, no injected <script src="https://…">. Your own files are fine. A bundle that phones out is not standalone. |
| 48KB | A ceiling, not a target. Enough for a lot of hand-written behaviour; not enough for a framework. |
No document.write | It runs deferred, so document.write would replace the finished page. |
In a preview your script runs sandboxed. A preview is served from the same origin as the Studio, so the engine sends Content-Security-Policy: sandbox: an opaque origin, where document.cookie and localStorage throw and a credentialed request to the API is refused. Stylesheets, fonts and images load normally. Nothing you would write for a static site notices — it is there so that installing a theme can never mean handing someone your session.
One place your script does not run: the Studio's editing canvas. That view stays same-origin so the Studio can attach its editing chrome, which the sandbox would forbid — so it carries no theme script. Use Preview ↗ to check behaviour. Preview and the published bundle stay identical; this is a difference between two views inside the Studio, not between preview and publish.
Colours are yours, and so are the custom properties
Palette roles are yours to name.
/v1/capabilitiesreportsdefault_theme.paletteasprimary/accent/dark/light/white— those are the built-in theme's names, not a fixed vocabulary. Your theme declares whatever roles suit it:ink,paper,wash,brand, as many or as few as you like. For the roles a particular site can use, askGET /v1/content-types?site={site}and readtheme.palette.
The engine emits no CSS of its own. There is no site-wide colour picker: how a site looks is a property of the theme (D43). A theme built for one client can simply be opinionated, and one that wants options can offer them later.
That makes the custom properties a contract between your theme.json and your stylesheet. Declare palette roles:
"palette": {
"primary": { "label": "Primary", "default": "#14503A" },
"accent": { "label": "Accent", "default": "#C6F24E" }
}
…then define the matching pair for each in your CSS:
:root {
--c-primary: #14503A; --c-on-primary: #FFFFFF; /* background, and ink on it */
--c-accent: #C6F24E; --c-on-accent: #16240F;
}
Those role names become the options in each block's Background control, and {{ block.appearance }} resolves to background: var(--c-primary) and friends. Declare no palette roles and the control disappears, which is what a bespoke theme wants.
The full set block.appearance can reference:
| Property | Meaning |
|---|---|
--c-{role} | the background for that palette role |
--c-on-{role} | readable ink on that background |
--colour-bg, --colour-text | set by block.appearance on a tinted block — read them inside it |
--colour-primary, --c-on-primary | set by _accent, for buttons and links within a block |
Everything else — fonts, type scale, spacing — is ordinary CSS, unless you choose to offer it as a setting.
Offering settings
A theme built for one client should be opinionated: declare nothing, put your values in your stylesheet, done. Nobody is handed a colour picker that changes a design you were paid to decide.
A theme meant to build many sites is the opposite case — if its colours live only in CSS, every site made with it looks the same. So a theme can declare settings, and each site using it answers for itself:
"settings": {
"brand": { "type": "colour", "label": "Brand colour",
"group": "Palette", "default": "#14503A", "css": "--c-brand" },
"on_brand": { "type": "colour", "label": "Text on brand",
"group": "Palette", "default": "#FFFFFF", "css": "--c-on-brand" },
"font_body": { "type": "select", "label": "Body font", "group": "Fonts",
"options": { "Georgia, serif": "Georgia", "system-ui": "System" },
"default": "system-ui", "css": "--font-body" },
"base_size": { "type": "number", "label": "Body size", "group": "Text",
"min": 12, "max": 24, "default": "17",
"css": "--fs-base", "css_unit": "px" }
}
- Fields use the same vocabulary as block fields, so the editor that builds block forms builds this one, and the validator that checks block values checks these.
colouris a real colour;palette_colourpicks one of your roles. cssnames the custom property the answer sets. Omit it and the setting is still a setting — you can read it in a template — but emits no CSS.groupgroups the controls on screen, in the order you declare them.defaultis used until somebody answers. Define the same properties in your stylesheet too, so a site that has answered nothing still renders.
The engine emits only what you declared, and nothing at all if you declared nothing. Sandra Theme — the built-in one — declares its palette, fonts and type sizes, which is what makes it a site builder rather than a single design.
A theme with no assets/theme.css is refused at install — an unstyled site is a confusing way to discover a missing file. If you genuinely want no styles, ship an empty one.
Entry templates
entries/{profile}.liquid renders one entry as its own page — so a blog is entries/blog.liquid.
Named after the collection profile, not the collection key, because a theme cannot know that a site called its blog journal. And not after a block role: entry_detail is a block that shows an entry inside a page, and is never a filename here.
The profiles you can write a template for are listed at GET /v1/collection-profiles. There is one at launch, blog, with core fields title, excerpt, body, published_at and image.
<article class="pl-entry">
<h1>{{ entry.title }}</h1>
{%- if entry.published_at != '' %}
<time datetime="{{ entry.published_at }}">{{ entry.published_at | date_long }}</time>
{% endif %}
{%- if entry.body != '' %}<div class="pl-rich">{{ entry.body | rich }}</div>{% endif %}
</article>
The engine wraps this in the site's header, footer and document shell, so an entry page cannot drift into being a page from a different website.
A collection whose profile the theme has no template for produces no entry pages, and the publish says so rather than shipping empty documents.
Installing, and what a switch costs
Upload the zip in the Studio, or POST /v1/themes. A theme you install is yours: it appears on your sites and nobody else's.
Before switching, the picker reports what it would cost — which of your blocks have no counterpart, and which collections would produce no entry pages. You may switch anyway.
Nothing is ever deleted. A block whose type the new theme lacks renders through whatever fills its role; if nothing does, it renders as nothing. Its values are untouched, so switching back restores everything. A missing render is blank, never broken.
That is why roles matter more than they look: they are what lets a second theme render content the first one created.
Gotchas worth knowing
- Every field value arrives as a string, including
number. Arithmetic and comparison still work —{{ n | plus: 1 }}and{% if n > 10 %}behave the same whether the value is"50"or50— so the uniformity costs nothing and means a decimal keeps the trailing zero you typed. This page used to claim numbers arrived as real numbers and that the filters refused strings. Neither was true, and it sent at least one theme author looking for a bug that was not there. - A number field left blank arrives as
'', not0, so{% if x != '' %}still means "was it set". | url_param, not| url_encode. Values are escaped, andurl_encodewould encode the entities.- Field order is the order you write it. It is the order of the generated editor, so put the important fields first.