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

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:

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/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
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 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 profileentries/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" }
  }
}

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:

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 — 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:

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. There are no --font-* or --fs-* properties any more; write the type you want.

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