The theme builder is a small command-line tool in theme-builder/ for
writing a theme, seeing it in a browser, and getting it into a store.
It has no dependencies and no build step. You need Node and this repository — nothing to install.
cd theme-builder
npm run new -- my-theme # start from a theme that already works
npm run preview -- my-theme # open preview/index.html
npm run check -- my-theme # what the platform would refuse, and why
npm run export -- my-theme # a file to import in the console
A theme is still just a JSON file — see Building a theme for what every field means. The builder exists because three things about that file are hard to get right by hand: it is long, most mistakes in it produce no error anywhere, and there is no way to look at it without running the whole stack.
Commands
| Command | What it does |
|---|---|
npm run new -- <key> |
Writes a complete, valid theme to themes/<key>.theme.json. --design vstore starts from the tech-store design instead of the default fashion one. |
npm run preview -- <key> |
Renders every page to preview/*.html. No server, no database, no API. |
npm run check -- <key> |
Validates the file and explains anything wrong, at the path in the file where it is wrong. |
npm run export -- <key> |
Writes dist/<key>.theme.json — the file the console's Import button takes. --zip wraps it; --out <name> names it. |
npm run install -- <key> |
Copies it into apps/api/themes/, the platform catalogue. --bump raises the revision. |
npm run list |
Your themes, and what the platform currently has. |
check runs inside export and install too. Neither will write a file the
platform would reject.
Two ways into a store
They are different things and people confuse them.
Import puts the theme into one store. You send someone a file; they load it. Nothing is deployed.
npm run export -- my-theme
# then, in that store: Themes -> Import -> choose dist/my-theme.theme.json
Install puts it in the platform catalogue, where every store sees it and installs it themselves. This is a deploy.
theme-builder/themes/my-theme.theme.json you write this
| npm run install
v
apps/api/themes/my-theme.theme.json the catalogue source
| syncThemeCatalog(), on API boot
v
control DB - catalog_themes one row, all stores
| console -> Themes -> Install
v
the store's own themes table a copy, theirs to edit
Two consequences, both of which surprise people:
Installing is a copy. Once a shop has installed a theme it owns that copy. Editing your file afterwards does nothing to it. That is deliberate — a merchant's edits cannot be destroyed by someone else's deploy.
The catalogue is only read at boot. npm run install writes the file;
nothing changes until the API restarts.
About the zip
--zip produces an archive holding the .theme.json and a note.
The console cannot import a .zip. It reads the file you choose with
JSON.parse, so an archive fails with "That file is not valid JSON".
Unzip it and choose the .theme.json inside. The zip is for sending a theme
to someone, not for importing.
Writing a section
The interesting part of a theme is a custom block: your markup, your CSS,
and a list of fields the merchant can edit.
{
"id": "hero",
"type": "custom",
"data": {
"name": "Hero",
"template": "<h1>{{ fields.headline }}</h1>",
"css": ".hero { padding: 80px 20px; }",
"fields": [
{ "key": "headline", "label": "Headline", "type": "text", "default": "New season" }
],
"values": {}
}
}
template and css are code — yours, replaced when the theme updates.
values is settings — the merchant's, never overwritten. That split is
what makes a theme updatable.
The template language
It is deliberately small, because it is a contract with people outside the codebase. Nothing in a template is ever executed.
{{ fields.headline }} what the merchant edits
{% if fields.body %} ... {% endif %}
{% products category="Chairs" limit="8" %} ... {% endproducts %}
{% categories limit="4" %} ... {% endcategories %}
Inside a products loop: {{ product.name }}, .price, .wasPrice,
.badge, .category, .excerpt, .image, .url, .id.
Inside a categories loop: {{ category.name }}, .count, .image,
.url.
Field types the console can edit: text, richText, image, link,
color. Every value is escaped on the way out, except richText, which was
cleaned when it was saved.
Your CSS is scoped to the section before it is served, so a rule you write
cannot restyle the rest of the shop. Write plain selectors — .hero h1, not
#my-theme .hero h1.
Two traps worth knowing
{% if product.x %} inside a loop does not work. The storefront expands
loops first and applies conditions afterwards, with only fields in scope —
so a condition on a product or a category is always false by then, and
silently deletes whatever it wraps on every row. It looks completely
reasonable in the file.
Render the value and hide it with CSS instead:
<span class="badge">{{ product.badge }}</span>
.badge:empty { display: none; }
Header and page blocks need an id. Blocks in theme.layout do not, but
the console's importer requires one on every header block and every page
block. A theme without them installs from the catalogue and then fails to
import, with an error naming a property you never wrote.
check catches both.
What check actually checks
It reads the platform's own lists — block types, header block types, colour keys — out of the API source at startup rather than keeping a copy, so it cannot fall behind when someone adds a block type.
On top of that it catches the mistakes that produce no error anywhere and simply render as nothing:
{{ fields.headlne }}where the field isheadline- a field the merchant can edit that the template never uses
{{ product.name }}outside a{% products %}loop{% if %}without{% endif %}, or a tag that is not in the language- a condition on a product or category inside a loop
- two sections sharing an
id, whose CSS would merge - a
<script>that will be stripped when the section is saved - a missing or non-hex colour
- a header or page block with no
id pagesor aheaderin a file still marked"version": 1
The three themes the platform ships all pass, which is the check on the checker.
What the preview really shows
preview/index.html is a real browser page with your CSS, your markup and
your colour tokens on :root, drawn against a sample catalogue. It works
offline.
Only your custom sections are really rendered. Built-in types —
productGrid, hero, newsletter and the rest — appear as labelled
placeholders at the right position. A faked productRails would teach you
the wrong thing about a component you do not control.
The preview is a second implementation of the storefront's renderer,
because the original is a React server component that reads the live
catalogue. Two implementations drift, so check compares its grammar against
the storefront's regexes and fails if they have moved. That is a tripwire,
not a proof: it catches a construct being added, and would not catch a subtle
change in behaviour.
Updating a theme shops already have
Raise revision and shops below it are offered the difference:
npm run install -- my-theme --bump
revision is the design. version is the file format. A version 2 file
carries a header and pages; a version 1 file does not. Raising version by
mistake offers every shop on the platform an update to a design nobody
touched, so check warns if you look like you meant revision.
When a shop takes an update:
| The shop's section | What happens |
|---|---|
| Untouched since install | Updated to your new code |
| Edited by the merchant | Kept, and reported as kept |
| New in your revision | Reported, not inserted |
| Installed before tracking existed | Left alone, reported as not tracked |
Merchant values always survive. New fields arrive at their defaults;
removed fields take their values with them. Section order is never
rearranged, and dropping a section from your theme never deletes a shop's
content.
The last row is the one to understand: a copy installed before sections carried a code hash cannot be updated automatically, because an edit and an un-taken improvement are indistinguishable without one. Those copies are reported rather than overwritten.
Getting to production
The API image copies themes/ (apps/api/Dockerfile), so a theme reaches
the catalogue by being committed in the api repository and the stack being
rebuilt. The catalogue syncs on boot.
deploy/api/build.sh — the non-Docker packing path for managed hosting —
copies dist, scripts and package.json but not themes/. A theme
shipped that way will not reach the catalogue.
theme-builder/ is not an npm workspace, so it is never part of a
production build.
Reference: the file
{
"format": "my-store.theme",
"version": 2,
"revision": 1,
"key": "my-theme",
"description": "One line, shown on the card.",
"isPublished": true,
"isDefault": false,
"sortOrder": 50,
"theme": {
"name": "My Theme",
"fontFamily": "Inter",
"khmerFont": "Battambang",
"colors": { "...": "all nine, hex" },
"style": { "...": "shape and spacing tokens" },
"topBar": { "enabled": true, "location": "", "phone": "" },
"layout": [ { "id": "hero", "type": "custom", "data": {} } ],
"menu": [ { "label": "Shop", "href": "/shop" } ]
},
"header": { "name": "", "settings": {}, "blocks": [] },
"pages": [ { "slug": "shop", "title": "Shop", "blocks": [] } ]
}
The nine colour keys are ink, sand, cream, clay, accent, gold,
dune, rust and parchment. They are named for the original fashion
design; in another kind of shop you map them — in the bundled vstore
design ink is the dark chrome, accent the green and rust the sale
badge. What matters is that all nine are present and that you use them
consistently, since the storefront paints with them everywhere you do not.