Appearance
Rendering & themes
Chapter 5. In Chapter 4 a request reached the origin and "rendered." This chapter opens that word: how a theme is modelled, stored, and turned into HTML — and how merchants change it.
How a theme is modelled, stored, and turned into HTML — and how merchants edit it.
Two render paths — bundle vs page-builder
Ratio-3.0 has two storefront rendering systems, and it helps to know that up front:
- The bundle theme path (primary). A merchant's live theme is a Liquid bundle (
base ⊕ overrides, below). When a tenant has a live bundle theme (tenants.live_theme_idis set), the origin renders through it — this is the path the rest of this chapter describes, and the one all four shipped themes use. - The page-builder path (legacy fallback). An older JSON
PageDocsystem (thepagestable'sdraft_doc/live_doc), rendered bycomposePagefrom the section registry. A store falls back to it only if it has no live bundle theme. It's kept for back-compat and is being phased out.
They share the same section registry, resolver, islands, and cache-tag model, so most of this book applies to both. Where they differ — chiefly how resolved data reaches a section (a flat merge in the bundle path vs per-binding namespacing in the page-builder path) — the resolver internals chapter lays them side by side. Unless a page says otherwise, "render" in this book means the bundle path.
A theme is base ⊕ overrides
An editable theme is an immutable base (a published library theme at a specific version) plus a small bundle of overrides — only the files the merchant changed, plus a _deletes manifest.
composeTheme(base, overrides)flattens the two into the effective theme;diffFromBaseis the inverse (what the merchant actually changed). —packages/builder-core/src/theme/theme-compose.ts- Improving the base is a version bump rolled into stores via
rebaseToBase; because the merge is file-level, a merchant's edits survive a base upgrade. —theme/base-propagation.ts - The shared base library is itself a tenant:
_library.
How bundles are stored
- A theme's text files are packed into one gzip blob, content-addressed by the SHA-256 of the canonical
[path, content]pairs (an array, so keys like__proto__round-trip safely). Binary assets are separate content-addressed objects. A 32 MB cap guards against zip bombs. —theme/bundle.ts,theme/assets.ts - Draft = a mutable source bundle. Publish freezes an immutable
published/source+ a minifiedpublished/compiledbundle and flips the live pointer in Postgres. —theme/compile.ts,theme/theme-store.ts - Postgres holds metadata (the file index
theme_file, version recordstheme_bundle_version= the hashes, the live pointer ontenants); S3 holds the bytes, content-addressed, read by the origin through CloudFront. See data model.
The two Liquid engines (and why two)
Rendering is LiquidJS. There are two engines because an engine alone can't bound CPU on untrusted code:
| In-process engine | Worker-thread isolate | |
|---|---|---|
| File | packages/builder-render/src/engine.ts | worker.mjs + isolate.ts |
| Runs | trusted first-party section markup | untrusted merchant / theme Liquid |
| Safety | cooperative limits, curated filters, edge-safe (no Node built-ins) | hard wall-clock kill — a runaway worker is terminated and evicted (self-healing) |
| Why | speed + edge-portability | co-tenant isolation — one store can't starve another |
The untrusted engine is a hand-written second copy kept in lock-step with engine.ts (a parity test guards against drift — e.g. the money, sanitize_html, and safe_url filters must behave identically). It's exposed as @ratio/builder-render/isolate and kept out of the edge-safe barrel (index.ts) on purpose.
Editing a filter? Edit both copies.
A Liquid filter change must land in both engine.ts and worker.mjs, plus a new case in the parity test — otherwise the two engines drift silently. This is exactly how the "prices 100× too high" bug shipped. See Render engine & isolate and the guardrails.
No autoescape
LiquidJS does not auto-escape its output tags. Merchant-controlled values must go through | escape (text), | asset_url (asset paths), or | safe_url (href/link — blocks javascript:/data: schemes), because the storefront CSP is script-src 'unsafe-inline' on some (GoKwik) stores. This is enforced by review + the filter allowlist, not by the engine.
The render cycle (inside the origin)
What happens between the edge proxying a cache miss and the origin returning 200 HTML + surrogate tags — apps/origin/src/handlers/storefront.ts. (For the line-by-line mechanics — how a template is parsed, sections and blocks attach, data binds, and the layout/SEO compose — see Render internals.)
A few invariants keep this safe, cacheable, and non-surprising:
- Fail loud, never a partial page. A missing bundle, a broken layout, or an untrusted section that times out is a 500 — so the edge serves the last-good page, never a silently-cached broken one.
- The shell is provably cacheable. Per-user content is not rendered into the page — it's an island placeholder (below). So the cached HTML holds only shared bytes.
- A forgotten
s-maxageis silently uncached. Thecache-control: … s-maxage=300 …stamped here is what makes the page cacheable — drop it and every request hits the origin (no error, just cost). - Sections read data flat. A theme section reads top-level
products/product, not Shopify'sproduct.*; a Shopify-styleproduct.titlereference resolves to empty (undefined →'', no error). See resolver internals.
Trusted vs untrusted render
| Trusted (first-party sections) | Untrusted (merchant / theme Liquid) | |
|---|---|---|
| Where | in-process (engine.ts) | worker-thread isolate (isolate.ts) |
| Limits | cooperative (render / mem / parse) | cooperative + wall-clock kill |
| Filters | full set | curated allowlist only |
| Failure | RenderFailed → 500 | RenderTimeout / Failed → 500, host safe |
Cacheability tiers — what the edge does with each
The registry (not the section author) assigns a tier; the shell's tier = the max of its non-island sections, so it's provably below per-user:
| Tier | Example | In the cached shell? | Cache-Control |
|---|---|---|---|
static | title, rich text | yes | long TTL |
shared-volatile | price, stock | yes (shared) | long TTL + tag-purge |
per-segment | locale / currency | yes (per segment) | keyed by segment |
per-user | cart, account | no — island only | never in shell; island no-store |
The island path (per-user, after the shell paints)
Islands are the only per-user path — the cached shell carries an inert placeholder, hydrated client-side:
Sections, tiers, and islands
- Sections come in two flavours: theme sections (Liquid in the bundle, rendered by the isolate) and first-party / platform sections (markup in the registry, rendered in-process). —
packages/builder-registry - Cacheability tier is decided by the registry's
BINDING_CATALOG, not by the section author (static/shared-volatile/per-segment/per-user). Registration is the enforcement gate: a per-user section must be an island or it's rejected. - Islands are the only per-user path: the cached shell carries an inert placeholder, hydrated client-side from
/api/island/*(alwaysno-store). The header account corner is the one live island today.
Blocks (Shopify-style)
A theme section can declare a {% schema %} (settings + accepted block types + presets). The template carries block instances; the render loop exposes them to the Liquid as section.blocks ({ type, id, settings }), strips the {% schema %} before render, and validates blocks against the schema on save.
- Block types + settings are edited in the admin (see below). Data-bound sections (product rows, category grids) carry a
dataSourceKeyand are not freely addable (they'd render empty without a data source). - Files:
builder-core/src/theme/theme-render.ts(render),section-schema.ts(extract/strip/validate), the 4 shipped themes underbuilder-core/src/theme/library/{nova,forma,aura,atelier}.
Editing: two surfaces, one draft
Both admin editing surfaces write the same ThemeStore draft, so they stay in lock-step (apps/admin-web/src/features/theme/):
- Code editor — a Monaco file tree over the theme bundle (
theme-editor.tsx,code-editor.tsx), with live preview + version history. - Visual customizer — a Shopify-style surface (
theme-customizer.tsx,customizer-frame.tsx): a live-preview iframe with in-canvas selection (click a section/block to edit it), add / remove / reorder sections and blocks, an image asset picker, device toggle, and Save / Publish. The selection bridge injectsdata-rt-*markers into the preview in editor mode only — they never reach a live storefront.
Validation — what's checked, and when
Two gates protect the live storefront: a light one on every save, a strict one on publish.
| Check | Save (draft) | Publish (owner) |
|---|---|---|
| Optimistic concurrency (revision / CAS) | ✓ 409 on conflict | — |
Block instances vs {% schema %} (accepted types + settings) | ✓ | ✓ |
| Compile + minify | — | ✓ |
Full-document layout — layout/theme.liquid renders a complete document | — | ✓ |
Required templates present (header, footer, cart-drawer, account, order-confirmation, main-*) | — | ✓ |
| Section references resolve | — | ✓ |
- Save writes a mutable draft bundle — it may be imperfect; that's fine, it isn't live.
- Publish is atomic: if any publish check fails it's a 422 and nothing changes — the old version stays live. Only a fully valid theme freezes new immutable bundles and flips the live pointer.
File protection
Default/structural files (the layout, the required templates) are non-deletable — a server-side hard gate rejects deleting or breaking them, so a theme can't be published into an unrenderable state. Only a few optional page templates are UI-removable.
Theme lifecycle & versions (multi-theme)
A store can hold many themes (create from a base, or duplicate an existing one); one is live at a time, the rest are drafts/experiments. A theme's state lives across three places — the mutable draft, immutable published versions (theme_bundle_version), and the tenant's single live pointer.
makeLivehas three modes:true(always flip the pointer),false(cut a version, never flip), andif-already-live— the merchant-publish rule: flip only if this theme is already the live one (or none is). The decision is made under a row lock on the tenant, so concurrent publishes can't race the pointer.- Publishing is content-addressed — an identical
compiled_hashreuses the existing version instead of churning a new one. - activate / rollback are the same primitive —
setLive(theme, version?)repoints the live pointer to any published version (omit the version → latest). Every move enqueues a tag purge in the same transaction, so the edge drops the old cached pages. - Delete refuses the live theme (and the last one) — switch away first.
The storefront CSP (and why it's strict)
A storefront page ships a deliberately tight Content-Security-Policy. Several chapters touch a piece of it — here's the whole picture in one place:
- Default:
script-src 'none'. A cacheable storefront page carries no first-party JavaScript, so blocking all script is the injection backstop. Every exception below is opt-in. - Islands widen it to
'self'. If a page has an island (the account corner), the islands runtime is added andscript-srcwidens to'self'(same-origin only). A page with no island keeps'none'and ships zero JS. - GoKwik stores loosen to
'unsafe-inline'. The GoKwik cart/checkout/login widgets need inline bootstrap scripts, so those stores' CSP includesscript-src 'unsafe-inline', unioned in by the provider seam. - The PWA hash makes
'unsafe-inline'inert — and that's handled deliberately. The default-on service-worker registration is authorized by a hash inscript-src; per CSP3, the moment a hash appears,'unsafe-inline'is ignored. So the CSP builder drops the hash from any directive that also carries'unsafe-inline'(keeping inline working for GoKwik) and keeps it where there's none. This is exactly why the KwikPass bootstrap is served as a same-origin asset, not inline (see Commerce internals). - Because inline can be allowed, output is not a safe XSS backstop — which is why merchant values must go through
| escape,| safe_url, and| sanitize_html(the No autoescape warning above). The CSP and the filters are two independent guards.
Deeper reading
docs/page-builder-end-to-end.md walks the page-builder path in detail. Confluence: ADR-013 (page builder decision), the theme-ownership + base-propagation epics. See the ADR index.