◆ SandraCMS
Theme authors, human or otherwise. Read as Markdown →

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:

FileShows
theme.jsonpalette roles, a declared collection
blocks/starter_menua slot block, and nav.items
blocks/starter_heroescaping, `\rich`, guarding unset fields
blocks/starter_texta repeater, and why it is not an entry
blocks/starter_postslisting a collection, and the entries drop
blocks/starter_footersite.fields, the custom-field bag
entries/blog.liquidone entry as its own page
assets/theme.cssthe --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 size8 MB, compressed and expanded
Files in the package400
Block types200
assets/theme.js48 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:

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 isVocabularyExample
Block rolewhat a block is forSCHEMA_ROLES, 18 of themhero, navigation, entry_list
Collection profilewhich shape a collection followsCOLLECTION_PROFILESblog

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" }
  }
}
RoleWhat it is for
navigationSite navigation
heroOpening statement at the top of a page
page_titleA page heading, where a full hero would be too much
footerSite footer
textProse, with or without an image
ctaA call to action
feature_listSeveral short selling points
galleryImages shown together
faqQuestions and answers
teamPeople
testimonialA quote or endorsement
pricingWhat things cost
contactHow to make contact
hoursWhen you are open
product_listSeveral products or menu items
product_detailOne product in full
entry_listA list of entries from a collection
entry_detailOne 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.

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:

Keptp br div h2 h3 strong b em i u a blockquote ul ol li pre code img
Removed entirelyscript, style, template — contents and all, because their contents are code, not writing
Unwrappedanything else — figure, table and the rest lose the tag, and their text is kept
Attributeshref on a, src and alt on img. Everything else goes, on every tag — class, style, width, and every on* handler
Rewrittenevery <a> gains rel="noopener nofollow"; entities normalise to characters, so &mdash; 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:

DropWhat it holds
block.*this block's fields, plus appearance and type
site.*name, home, fields.* (custom fields)
page.*title, slug, fields.*
nav.itemslabel, 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.home and appending it to a link field was a real workaround for a real bug, and it will now produce ../../get-in-touch.html where it used to produce ../get-in-touch.html. Only a link field is resolved — a text field 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">&#9776;</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 fileassets/theme.js. Bundle anything else into it.
No external hostsNo 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.
48KBA ceiling, not a target. Enough for a lot of hand-written behaviour; not enough for a framework.
No document.writeIt 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/capabilities reports default_theme.palette as primary/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, ask GET /v1/content-types?site={site} and read theme.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:

PropertyMeaning
--c-{role}the background for that palette role
--c-on-{role}readable ink on that background
--colour-bg, --colour-textset by block.appearance on a tinted block — read them inside it
--colour-primary, --c-on-primaryset 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" }
}

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