WebSite (single store) and WebPreviewServer (multi-tenant) turn a URL path
into a (collection, docId) and render its page. WebPreviewServer resolves
the store id from the request in one of two ways:
- path prefix —
pathPrefix: '/preview/demo'serves/preview/demo/{sid}/**;matchPath(req)returns{sid, path}and the store-relativepathis passed tohandle(req, res, sid, path). Pages are rendered withbasePath = '{pathPrefix}/{sid}', which rebases root-absolute links (href/src/actionstarting with/, plus{{menu}}hrefs) onto the prefix (rebaseHtmlinsrc/web/web_renderer.ts) and exposes it to page JS aswindow.DROP_BASE. A bare{pathPrefix}/{sid}301s to the slashed form. (The admin console used this for its/preview/{env}/{sid}store previews until 2026-09-04; its previews now render in the browser — see "Page composition (compose.ts)" below.) - wildcard hostname —
baseDomains: ['example.com']serves{sid}.example.comviamatchHost(req)(matchProjectHostinsrc/web/host_match.ts); needs wildcard DNS.WebSiteOptions.basePathstays unset, links stay root-absolute.
The homepage has one address. / renders the homepage: a
staticRoutes['/'] entry, else setup/web.index (homeRefOf in
compose.ts). A slug hit on that same document answers 301 to the
locale's root (/, or /en in path mode), keeping the query. The hit can be
a flat {slug}, a routed {segment}/{slug}, or a routed collection's bare
segment whose route.index is the homepage. resolveSiteTarget decides it,
so the fleet and the admin's Studio renderer behave the same. The POST routes
(forms, load-more, variant swaps) treat that redirect as "no page", like any
other redirect.
Tokens and the read seam#
WebSiteOptions.accessToken is the token the site's SDK session runs on
(masterToken survives as a deprecated alias). What that token must grant
depends on reads:
reads: 'admin'(default) — site metadata (setup docs, elements, schemas, templates, slugs, files metadata, data-doc fallbacks) is read through/admin/read|list, so the token must be a master or store-scoped admin token.reads: 'public'— the same reads go throughPOST /public/web(the server-side allowlisted site-code surface), so an ordinary{public: true, process: true}token renders the whole site. This is the tenant hosting server's mode (drop-core-tenant); pair a store-scoped token withheaderStore: sid.
The seam is SiteReader (src/web/site_reader.ts): AdminSiteReader /
PublicSiteReader behind one get/list contract (the public reader maps
every registry the router reads — slug/{key}, routes/{c}/slugs/{key},
routes/{c}/routes/{cat}/slugs/{key} and their listings — onto
/public/web kind: slug | slugs, and a path redirect redirects/{id} onto
kind: redirect). Data documents
themselves always read through the public content API regardless of reads.
WebPreviewServerOptions.siteOptions injects per-store WebSiteOptions
overrides when a site is (re)built (per-store credentials, reads,
hookKey, …) — with it set, the shared masterToken becomes optional.
WebPreviewServer.site(sid) / .evict(sid) are public so wrappers (the
tenant server's hook ingress and credential-drift handling) can reach and
rebuild a store's WebSite.
Three route kinds
(matchRoute in src/web/routes.ts, shared with the static-site example):
| kind | path | resolves to |
|---|---|---|
| static | / and any routes entry |
a predefined document — / is the setup doc's index reference; extra routes are developer-defined in code (WebSiteOptions.routes: {'/about': {collection, document}}) |
| reference | /ref/{collection}/{id} |
that data document directly (reserved /ref/ prefix) |
| slug | any other path | the store's slug registry (store/{sid}/slug/{key}) → its document. One document per URL key holding a per-locale map (.dev/plans/slug-registry-locale-map.md): locales[l] = the slot {path, moved?, alias?} of locale l, redirect (locale-neutral) / redirects[l] (per locale). One read, one rule — core-lib lookupSlug, shared with Studio and the backend: redirect → redirects[serve] → locales[serve] → same-language locales (en_GB → en_US: slot or redirect) → default / other locales / default (first slot, then first redirect). The top-level compat mirror (path / locale / moved / alias) is never read — it exists for pre-map vendored builds. Collection routes: a two-segment /{segment}/{slug} serves a document of a collection whose setup/data row has route.enabled — segment is the serve locale's collection slug (else the key), slug an entry of that collection's route registry (store/{sid}/routes/{collection}/slugs/{id}, same id shape and lookup — never the root registry, so the same slug may exist in several collections and a document's seo.scope decides whether it lives at the root, under its collection route, or both); the bare /{segment} serves the row's route.index page when no document owns that key (documents win the flat key space). The flat /{slug} keeps serving; the routed URL is emitted as <link rel="canonical">. Category routes (.dev/plans/nested-category-routes.md): /{segment}/{category}/{slug} reads the leaf category's registry store/{sid}/routes/{collection}/routes/{category}/slugs/{key} (planRouteTail — the chain must name routable categories of the row in the serve locale); on a document miss /{segment}/{category} serves the category's index page (a document owning that key at that level wins). Aliases (slugs[l].aliases of the collection or a category) answer 301 to the same path with the primary segments, keeping the language prefix (url-admin.md U13). A segment of another store locale resolves there: path mode 301s to the right-locale URL, cookie mode serves the document in the serve locale (map §4.3). More than 2 + MAX_CATEGORY_DEPTH segments never route. Rule + helpers: collection_routes.ts in core-lib. Old addresses (U14): whatever none of the above answers is looked up ONCE in store/{sid}/redirects/{pathRedirectId(path)} — the full request path (before the locale split; any shape) canonicalized and encoded (core-lib web/path_redirect.ts) — and answers 301 / 302 to the stored target; a live page, a registry redirect or a routed address always wins |
new WebSite({ /* … */, routes: {'/about': {collection: 'pages', document: 'about'}} });All three feed the same render pipeline and the same render-cache key
(cacheKey(locale, collection, docId)), so a /ref/… URL and a slug that point
at the same document share one cached page and one hook-invalidation entry.
Per-visitor locale (cookie mode, the default). Pages render in the
store's default (first) locale unless the request carries a drop_locale
cookie naming another configured store locale (renderPath(path, locale?);
the server layer parses the cookie, strict xx_XX shape only, and stamps
Vary: Cookie on page HTML and slug redirects). A store's language-switch UI
writes the cookie and reloads (a language-switcher element does exactly
that). A slug owned by another locale (a cross-locale lookup hit)
still serves its document in the serve locale, and the locale-keyed render
cache keeps the variants apart. Unknown or unconfigured values fall back to
the default locale. An external server only has to pass the cookie value as
renderPath's second argument — the same call serves path mode too.
Language in the URL (path mode). With setup/web.localeUrl = {mode: 'path', codes?} (core-lib web/locale_url.ts: localeUrlContextOf,
localeUrlCodes, splitLocalePath, localePrefixOf, withLocalePrefix)
the first URL segment names the locale: /o-nas is the default locale,
/en/about another (codes derived cs_CZ → cs, a later same-language locale
takes the region form en-gb; the admin persists the full codes map). The
store needs ≥ 2 locales that all derive a code, else it silently stays in
cookie mode. renderPath reads the prefix itself — no separate API; the
cookie is ignored — and answers:
/{defaultCode}/x→ 301/x; an upper-case prefix (/EN/x) → 301 to the lower-case one (mount prefix included in preview);- a cross-locale slug or collection segment → 301 to the document's URL in
the right locale: a prefixed request tries its own locale first, then the
owner; an unprefixed request (typically an old cookie-era link,
/about= an en slug) tries the owner first (/en/about). A redirect that would land on the requested URL serves the page instead; - otherwise the page, with
WebPageResult.localeMode: 'path'— the server sends novary: cookieon pages or redirects.
Every generator emits prefixed links: linkPathOf (LinkSiteContext.localePrefix),
link fields and stringifyLinks, {{route}} (in fragments too),
composeMenu, the canonical, the consent policy link, redirect Locations
and the /variant reply's href. localizeHtml (web_renderer.ts) is
the safety net for links typed literally into templates: it prefixes
root-absolute href / action / formaction / data-href / data-ref-base
/ data-form-redirect / data-page values before rebaseHtml, skipping
router platform paths (/form, /m, … — /ref/{c}/{id} is a page and IS
prefixed), the asset prefix, values already under a code or the mount prefix,
and any tag carrying hreflang or data-drop-raw-href. POST routes stay
unprefixed (/form/{locale}/…); /content, /variant and form placement
strip the prefix from the browser path and resolve in the locale it names.
Path-mode pages also get the bootstrap globals DROP_LOCALE_BASE ('' or
/en — build page links as DROP_BASE + DROP_LOCALE_BASE + path, the kit's
DropElements.pageHref), DROP_LOCALE_MODE = "path" and DROP_ALTERNATES;
a cookie-mode page emits none of them and renders byte-identically to before
(src/web/test_snapshots/cookie_mode.json guards that).
Alternates. Every template (and fragment) gets {{#each alternates}} —
{locale, code?, hreflang, label, href, current} per store locale
(alternatesOf in compose). In path mode an entry is the page's URL in that
locale (a document's own slug in that slice; a locale without a slug is
omitted) and the sibling documents are render deps, so a rename in one locale
re-renders the others; the head gets the hreflang cluster + x-default,
og:locale / og:locale:alternate, og:url and a canonical on every page,
absolute over siteOriginOf (siteBaseUrl, else the first
setup/web.domains host; no origin → no hreflang/og:url). In cookie mode
the list is the locales on the page's one URL (a switcher sets the cookie) and
the head is unchanged.
Reference routing is confined to the data/ namespace readDoc already reads,
behind the publish gate + a renderable template — it can't reach
orders/customers/etc. Not yet routed: template-rendered collection
listings (a routed collection's bare segment needs a route.index page —
404 with a TODO otherwise).
Redirects. A registry entry (root or route) that carries redirect answers
301 (302 with temporary: true) instead of rendering — and the redirect WINS over a stored path. The
target is either an absolute http(s):// URL (sent verbatim) or a path on this
site (about, /about, news/item?x=1), normalized to root-absolute, rebased
on basePath, and carrying the request's query when it names none. Anything
else — protocol-relative //host, another scheme, control characters, a target
that points back at the requested path — resolves to nothing, i.e. the store's
404: one validator, parseSlugRedirect in core-lib, used by the fleet, the
backend and the admin editor alike. Redirects are never render-cached (each hit
is one TTL-cached slug read), never listed in a sitemap, and the interactive
routes (/form, /content, /variant) treat them as "no page" (404).
In path mode a site-path redirect owned by a locale — redirects[l] — is a
path within that locale and gets l's prefix (SiteRoutingContext.prefixOf), not the
visitor's: an en_GB visitor falling back to en_US's redirect lands on
/en/…. The string redirect stays root-absolute. Former slugs (moved). When a document's slug is renamed,
the content sync keeps the old key's slot as {path, moved: true}; it
answers 301 to the document's CURRENT URL (movedPathOf — one cached
readDoc + linkPathOf), so rename chains collapse and a later segment
rename can't stale it; no slug any more → 404. moved entries are never
listed in a sitemap. renderPath returns {status: 301 | 302, redirect};
WebPreviewServer sends it as a bodyless Location response.
Old addresses (path redirects). A migrated site's former URLs — any shape
(/cz/kampus/urbanity-kampus-tachov/, /produkt.php?id=12, /en/about-us.html)
— live in store/{sid}/redirects/{id} (PathRedirectEntity; .dev/plans/url-admin.md
U14). renderPath consults them last (step 4), only when nothing routes:
the full request path (before the locale split) is canonicalized and encoded
into the document id (core-lib web/path_redirect.ts: normalizeRedirectPath
pathRedirectId), read ONCE through the TTL-cached meta read (misses cached 60 s), compared with the storedpath(an id collision is a miss) and answered 301 (302 withtemporary) to the stored target throughparseSlugRedirect— rebased onbasePath, the request's query riding along, never locale-prefixed (the target is written as the visitor lands on it,/cs/kampus/x). A live page, a registry redirect or a routed address always wins. Theslughook flaggedx-drop-redirects: 1(labelredirects/{id}) evicts the one meta key.
Reserved paths#
Beyond rendered pages, the server answers a few well-known paths (all
publish-gated; <loc> entries and the Sitemap: line use the request's origin
so they match the host/env that served the file):
| path | method | notes |
|---|---|---|
/sitemap.xml |
sitemapXml(baseUrl?) |
The pages urlset (homepage + static routes + the pages documents). It becomes a <sitemapindex> over the sub-sitemaps below when another collection is configured in setup.sitemap.collections, and always in path mode (one entry per locale file). |
/sitemap-pages.xml |
sitemapPagesXml(baseUrl?, locale?) |
The pages sub-sitemap — always on; single documents are opted out with setup.sitemap.exclude. |
/sitemap-{name}.{code}.xml |
sitemapFileXml(path, baseUrl?) |
Path mode: one file per locale URL code (/sitemap-recipes.cs.xml), holding only that locale's URLs — what the index lists. sitemapFileXml dispatches every sub-sitemap path (isSitemapFilePath). |
/sitemap-{collection}.xml |
sitemapCollectionXml(name, baseUrl?) |
That collection's slug URLs — so dynamic pages are in the sitemap once the collection is configured. One URL per live slot of a registry document; moved slots, redirected slots and redirect entries are skipped. A routed collection adds one listing per routable category registry (its URLs /{segment}/{category}/{key}, each locale with its own category segment). Path mode: this file and /sitemap-pages.xml list each page once per locale under its prefix (localizedUrlsetXml), as plain <loc>s — the language versions are declared by the pages' <head> hreflang links only (Google: one method is enough); cookie mode lists the default language. |
/robots.txt |
robotsTxt(baseUrl?) |
From setup.robots, with the Sitemap: line appended unless the sitemap is disabled. |
/favicon.ico |
faviconUrl() |
302 to the store's icon (the raster's 32px rendition, else the SVG as a data URI); 204 when the store sets none or is unpublished. |
POST /content/:locale |
listContent(...) |
Page-driven "load more" — lists a page's collection-reference field, contract derived from the placement envelope. See "Page-driven load more" below. |
POST /variant/:locale |
variantContent(...) |
In-place product-variant swap — body {path, variant}; the page is routed like a render, variant must be a key of that (products) page's propagated variants registry, and the response document carries the read-time parent fallback (same envelope-security idea as /content). The reply's href is the variant page's public URL (scope + locale prefix) — what history.replaceState should show; slug stays for older kit scripts. |
POST /cart/:locale |
cartContent(...) |
Storefront cart — body {action: 'set'|'get'|'delete', id?, items?, email?, promo?, origin?, note?} forwarded to process/cart/* over the site's own serving token (which carries the process node). A browser uid is dropped — the fleet authenticates no customer. No caching, served no-store; validation caps live in the core API. |
POST /delivery/:locale |
delivery(...) |
The cart's delivery methods ({country?, total?, origin?} → {methods}, priced by the core's POST /public/shipping/options) or, with {points: {carrier, q?, type?, country?}}, the pickup points of an offered carrier. Served no-store; the Studio bridge answers the same route. |
POST /checkout/:locale |
checkout(...) |
Checkout — a whitelisted OrderInitEntity (typically {cart}) forwarded to process/order/make; the payment-init payload passes through. delivery_method + pickup_point ride along (plans/carriers §3.6 — validated and priced by the core), and so does payment_method (the chosen row of the store's payment catalog). finalize/status/number and user are never forwarded (the fleet authenticates no customer — a visitor must not attribute the order to one). Served no-store. |
POST /checkout/:locale/payment |
payment(...) |
The payment side of the checkout (.dev/plans/invoice-providers §G6). {country?, currency?, total?} → {methods: [{id, service, method?, name}]} — the methods the cart may choose (send the chosen id to /checkout as payment_method). {order} → the return step: a gateway's return URL carries no trusted outcome, so the page the buyer lands on (?o={order}) asks here — {provider, state: {status, …}} for a module gateway (the gateway is queried and the answer applied to the order), {status} for a Stripe payment (the provider's own object never reaches the browser; paymentReturnBody in compose.ts). For a module gateway the order answers with payment.service_intent.session — navigate the top-level window there from the store's own origin. Served no-store. |
GET|POST|PUT|DELETE /m/{moduleId}/** |
moduleCall(...) |
An installed module's storefront leg — the request (path after /m/{id}, query, JSON body ≤ 256 KiB, the request locale, ip / origin / referer, the visitor's identity-token) is forwarded to POST /api/public/module over the site's serving token, and the module's answer is re-emitted: its status and body verbatim, of its headers only content-type, cache-control (default no-store) and location. HEAD → 405. A GET answer that says cache-control: public, max-age=N (N ≤ 3600) is kept in the store's edge cache (see "The edge response cache" below) — never for a guarded path, a request carrying an identity-token or a non-2xx; everything else is never cached. An unknown / disabled module is the core's 404. Guarded paths (the manifest's routes.guarded): the server's formGuard chain runs first — a GET answers {fields} (no-store, the /form contract, guard form key module:{id}:/{path}), a write failing verify() is 403 {error, reason}, a passing one is forwarded with the guard fields stripped and guard: 'passed'. The list comes from the core's envelopes (ModuleWebResponse.guarded, cached per store + module for 5 min); the core refuses a guarded path without guard: 'passed' (428 guard_required, the module not called), so a stale list costs one retry, never an unguarded call. m is a reserved first segment: matchRoute never treats /m/** as a slug or a collection route, and the core refuses such slugs on write (core-lib slug_rules.ts). Locale (WebSite.requestLocaleHint): path mode = the Referer's locale prefix (none = the default locale); else an x-drop-locale header naming a store locale; else the drop_locale cookie (cookie mode only). /m/** answers keep vary: cookie. |
POST /_drop/v |
— | The stats render beacon (stats_beacon.ts): answers 204 no-store, nothing else — the host's stats recorder files the call as a view event. With WebPreviewServerOptions.statsBeacon on (the tenant server sets it while it records traffic; ignored in live mode) every served 200 page carries the inline script that sends it (?p={location.pathname}&r={document.referrer}, skipped under navigator.webdriver, deferred for a prerendered page). _drop is a reserved first segment. |
GET|POST|PUT|DELETE|OPTIONS /fn/{serviceId}/** |
serviceCall(...) |
A store service (.dev/plans/modules/services.md) — the /m relay (same request envelope, body ≤ 256 KiB of any JSON, same header filter, HEAD → 405, the same guard flow with form key service:{id}:/{path}, the same edge-cache rules) run by the injected WebSiteOptions.services host, else forwarded to POST /api/public/service. CORS for the service's listed origins only; vary: cookie, origin on every answer. See "Store services" below. fn is a reserved first segment like m. |
Non-indexable hosts. WebPreviewServer.handle(req, res, sid, path?, serve?)
takes a per-request serve object whose indexable: false marks a host that
must never be indexed. That host gets a constant User-agent: * / Disallow: /
robots.txt (the store's setup.robots is not consulted), 404s on every
sitemap, and x-robots-tag: noindex, nofollow, noarchive on every response
— pages, assets, code URLs and errors alike — with a store-configured
x-robots-tag in httpHeaders dropped so it can't opt back in. It is a
header and never markup, because one render cache serves every host a store
answers on. The tenant server passes it for the platform subdomains; the
default is true, so a self-hosted WebPreviewServer is unaffected.
404s. A path that resolves to nothing (or whose target doc/template is
missing) renders the store's custom notFound doc (setup/web.notFound) at a
404 status; without one, a minimal built-in body is sent.
WebPageResult.reason says why: 'unpublished' (publish gate),
'unavailable' (the publish gate could not be read at all — a core
transport/auth failure, retry-worthy unlike the others), 'no_route'
(nothing matched the path) or 'no_template' (routed, but the target's
doc/template is missing or blank).
Maintenance page. An unpublished store whose setup/web.maintenance
names a document serves that document — through its own template, with the
shell / SEO / head tags but no canonical or hreflang — for every page URL
(redirect slugs included), at 503 with retry-after: 3600,
x-robots-tag: noindex and x-drop-maintenance: 1 (MAINTENANCE_HEADER;
the tenant fleet reads it to tell this deliberate 503 from a dying store
token). Path mode takes the locale from the URL prefix without redirecting.
No page configured, or its doc/template missing → the built-in 404 as before
(reason: 'unpublished' either way). /favicon.ico follows the page; the
interactive POST endpoints, robots.txt and the sitemaps stay publish-gated.
Options worth knowing#
| option | default | notes |
|---|---|---|
live |
false |
Bypass every cache and force the repository into remote mode. A dev/preview switch, not a production mode (the admin's own previews stopped using WebSite on 2026-09-04 — they render in the browser through compose.ts). |
ignorePublish |
false |
Serve the site even when setup/web.published !== true — the admin-preview switch, so editors see a store before it goes live. Applies to page rendering, the interactive POST endpoints and faviconUrl; robots.txt and the sitemaps stay publish-gated. Public hosting (the tenant server) never sets it. |
feedTtlMs |
4 h (FEED_TTL_MS) |
The one default for every hook-covered cache — meta, data, render + deps, templates, assets, the sitemap slug index. Applies only when hookKey is set (a site that can verify pushes); without it each cache keeps its legacy default (last column of the rows below). 'legacy' forces the legacy defaults, a number replaces 4 h. A per-cache option below always wins. Why 4 h is safe: every render input is covered by a push — docs/hooks-and-caching.md / the docs site's Caching & freshness. |
renderTtlMs |
feedTtlMs (legacy 30 min) |
Rendered-HTML backstop. Hooks invalidate eagerly, but they are best-effort (above) — this is what catches a lost push. The deps index always takes the same TTL. |
caches |
in-memory | Bring-your-own backends: data / meta / metaFallback (last-good meta copies, read only when a core read fails — no TTL) / template / render / deps / assets. See below. |
assets |
on | Same-origin asset serving ({serve, pathPrefix, preload, maxBytes, maxObjectBytes, ttlMs, cacheControl, warmOnHook, redirectBase} — redirectBase is where the LIVE-mode 302 points, a same-origin mirror like the admin's /asset/{env} so self-hosted fonts pass CORS) — pass {serve: false} to opt out. See below. |
setupTtlMs |
feedTtlMs (legacy 60 s) |
Meta docs found — public setup docs, the store doc's locales/name, elements, slug entries, the favicon doc. The setup / slug / elements events evict them. |
negativeMetaTtlMs |
60 s | Meta lookups that found NOTHING (an unknown slug, a missing setup doc) — stays short whatever feedTtlMs says, so a lost push for a NEW slug or doc never hides it for hours. |
dataTtlMs |
feedTtlMs (legacy 60 min) |
Data documents; data pushes keep them fresh in between. A missing doc is never cached. |
templateTtlMs |
feedTtlMs (legacy 30 min) |
Template backstop — the templates hook event evicts eagerly (in-place edits included); a version bump still forces an immediate fetch. 0 restores the historical no-expiry behaviour. |
slugIndexTtlMs |
feedTtlMs (legacy 60 min) |
The sitemap slug index (a miss lists the whole registry); the slug event evicts the affected tier. |
assets.ttlMs |
feedTtlMs (legacy 60 min) |
Cached asset bytes; the files event evicts. |
hookMaxAgeMs |
5 min | Replay window for access-seed verification — a push whose seed timestamp is older is rejected. Only meaningful with hookKey. |
moduleGate |
— | Removed 2026-10-27: a module with no runtime doc is not installed and its elements render nothing (the rollout's 'open' mode is gone). The rest of the old row for history: 'open' renders it without a runtime (the rollout default — installs not restamped yet have no doc), 'strict' renders nothing (an uninstalled module). A runtime doc with disabled: true gates in both. See "Element rendering". |
siteBaseUrl |
— | Fallback base for sitemap/robots when no request origin is available. |
coreTimeoutMs |
— (untimed) | Timeout of the site's core reads (CORE_READ_PATH: /public/{web,content,slug,setup,locale,store,proforma}, /admin/{read,list,count,aggregate}), in ms — a hung core read fails the request in seconds instead of holding it (plans/tenthousand W9 E-D7). Writes and provider calls (checkout, cart, forms, /fn, payments, shipping) are never timed: a checkout cut short may already have created the order. The tenant sets 10 s. |
routes, collection, homeDoc, storageBase, hookKey, headerStore |
— | Route table, default collection/home document, asset base, hook signing key, store header. |
Template freshness.
templateCacheis keyed by{schemaId}/{version}. In-place template edits (no version bump — what the admin agent writes) propagate through thetemplateshook event when the site receives the multi-hook feed (receiveEventHook), and are TTL-bounded otherwise.WebSite.flushTemplates()is the narrow manual lever — it clears the template cache AND render+deps (cached pages embed the template).WebSite.flushAll()clears every cache of the site (meta, slug index, data, render, deps, templates, assets) — the lever that ends any staleness after a lost push;WebPreviewServerand the tenant (/-/mount {flush: true}) expose it.
Collection routes in templates ({{route}}, window.DROP_ROUTES)#
A listing card that links to an item's own page should use the {{route}}
helper instead of hand-building /{{this.seo.slug}}:
{{route "recipes" this.seo.slug}} → /recepty/tomato-soup when the
recipes collection is routed for the serve locale, else /tomato-soup —
so the helper is safe to adopt before a store flips routing on. The routed
segment map ({recipes: 'recepty'}, from routedSegments in core-lib)
rides the render context like translations — the page template, the
shell fragments, AND every element placement / render:true component
(fragmentExtras; a listing card built inside an element links to the
routed URL too) — and is also exposed to page
JS as window.DROP_ROUTES for cards built client-side (load-more):
/${DROP_ROUTES[c] ? DROP_ROUTES[c] + '/' : ''}${slug}. Per-collection
sitemaps list the routed URLs.
Categories (.dev/plans/nested-category-routes.md): a routed collection's
documents can live one level deeper, under a category declared on the row
(route.categories) and picked by the document's seo.category. A third
argument builds that URL — {{route "support" this.seo.slug this.seo.category}}
→ /podpora/fakturace/vraceni-penez; a missing or unknown category keeps the
2-segment path. The category-path map ({support: {billing: 'fakturace'}},
routedCategorySegments in core-lib — the value is the full joined path, so
it stays valid at any depth) rides the same render context and is exposed as
window.DROP_ROUTE_CATEGORIES — emitted only when the store categorizes
something, so other pages render byte-identically:
DROP_ROUTE_CATEGORIES[c]?.[cat] ? `/${DROP_ROUTES[c]}/${DROP_ROUTE_CATEGORIES[c][cat]}/${slug}` : ….
A category carrying the reserved parent is not routable in v1 (absent from
the map; its documents keep /{segment}/{slug}).
Store translations ({{t}})#
Several positional arguments join with a dot — {{t "tag" this}} resolves
tag.sale — the form for a key held in data (a product's tags /
categories); there is no concat helper. Two groups are system groups
(SYSTEM_TRANSLATION_GROUPS: tag, category): the admin's Tags /
Categories pages own them (a category path such as recipe:summer is one
flat leaf key whose value is the leaf's label), the Localizations editor
hides them, and to this package they are ordinary groups. The key rules
(TAXONOMY_KEY_RX, isValidTaxonomyKey, isValidCategoryPath,
categoryAncestors …) are exported next to flattenGroup.
Rendered templates (pages, shell, fragments, elements alike) can reference
shared per-store strings with the {{t "group.key"}} Handlebars helper —
{{t "cta.buy"}}, with optional {name} placeholder interpolation from hash
args: {{t "cart.count" count=items.length}}. The returned string is
HTML-escaped like any mustache.
Storage is one document per group at
store/{sid}/data/{locale}/translations/{group} — everything before the first
dot of the public key is the doc id, everything after is the field path inside
it (nested objects resolve as deeper dotted paths: product.badge.sale). Only
string leaves count; underscore-prefixed keys and doc bookkeeping fields
(id, ref, schema, …) are skipped; _meta is a reserved group id.
The Translations accessor (src/web/translations.ts) keeps one merged flat
map per locale in memory:
- Warm once per locale per process — a single
repo.list('translations'), deliberately off the per-request render path. A failed warm serves what was known and retries on the next call. - Lookup fallback chain: the page's locale → the store's first locale →
the key itself (a visible
cta.buyin the page is a bug report; an empty string would be silent data loss). - Freshness via data hooks: a
translationspush patches/evicts the group in the in-memory map and then drops the whole render + deps cache — any rendered page may embed a translated string. livemode (the admin preview) memoizes nothing — every call re-lists, consistent with live mode bypassing every cache.
The admin edits these under /store/{sid}/translations; agents learn the
helper from sdk-generative-js/rules/handlebars-binding.md.