A CMS where the schema is a TypeScript definition. @spfn/cms declares
labels in code — nested, multi-language, template-ready — syncs them to the database on server
startup, and serves reads from a published cache. Section names and properties are checked at
compile time; editors work in drafts and publish when ready.
Content is the work that quietly turns into an admin panel. Declared in code, it stays inside the application your agent already reads.
Labels are declared in code with defineLabels — nested,
per-locale, with {placeholder} template variables. That definition is the source
of every default and of the types: section names and property paths autocomplete in the IDE,
and a typo is a compile error, not a blank string in production. On server startup
syncLabels writes the definition to the database — new labels created, changed
ones updated by deep-equality check, unchanged ones skipped. The database holds only what
editors change on top: a draft, then a published version, resolved as
Draft > Published > Default.
Label content lives in two tables — cms_labels for metadata,
cms_label_values for versioned per-locale values — but reads touch neither.
Publishing a section rebuilds cms_published_cache: one JSONB row of content per
section and locale, stamped with a version. getLabel hits that cache in a single
query — 5 ms where the JOIN path takes 87 ms — merges it over the code-defined
defaults bound to the current locale, and returns a typed nested object. Only the sections you
request are processed, so a 1,000-label app reading one 100-label section does 10% of the
work, and the version column drives client-side cache invalidation.
Section names and properties validated at compile time — typos are compile errors.
syncLabels on startup — creates, updates by deep equality, skips unchanged.
Single-query reads at 5ms vs 87ms with JOINs — 17x faster.
Admin routes per section — save drafts, publish, reset. Versioned.
Type-checked locales, resolved cookie → defaultLocale → 'en'.
Labels organized hierarchically, read with direct property access.
{placeholder} substitution via format() — strings and numbers.
One section direct, many by name — only requested sections processed.