Writing a Sandra theme
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. Nothing in a theme ever runs as code (D33), so there is nothing to compile: you write files, zip the folder, and upload it.
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/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 |
| 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 console, 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).
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._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.
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 — already base-path aware |
entries.* | published entries, by collection key and by profile |
appearance.* | palette roles, fonts, type scale |
Never hand-write a leading slash. Use {{ item.url }} and {{ site.home }}: a published bundle has to work unzipped in a subfolder as well as at a domain root (D20).
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 and active, 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.
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, .woff, .woff2, .ttf, .otf, .svg, .png, .jpg, .jpeg, .webp, .gif, .ico. Nothing executable, and nothing is served from the docroot — assets are streamed by the engine with an explicit content type.
Colours are yours, and so are the custom properties
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 console, 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
plus,minusand friends refuse strings. Unlike Shopify's Liquid, they raise rather than coerce, and a template error renders as nothing. Use anumberfield, which arrives as a real number.- 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.