Components and Modules
The block-type registry, field kinds, owner scoping, the module compiler, saved sections, theme kits, and media assets
A module is one placed instance of a block type. Block types live in a
registry (core.cms_block_type_schemas); instances live in core.cms_page_blocks
and are positioned by the layout tree.
block_type is an open string backed by the registry rather than a closed union
in code, so new modules are a data operation, not a deploy — with the important
exception of field kinds, which are code-curated.
The registry
Each block type declares an ordered set of fields. A field name must be a
stable, identifier-safe JSON content key — lowercase letters and underscores
only, matching /^[a-z][a-z_]*$/. Display names go in label instead, so
renaming a label never rewrites stored content.
Platform block types use the same semantic-slug pattern (hero, rich_text,
pricing_table), capped at 50 characters. ULIDs and legacy org_13__… keys
contain digits, so they cannot collide with a new platform type.
Writes are gated by admin:block-schemas:manage. There is no partner
self-serve surface.
Owner scoping
Block types are optionally owned by an organization, brand, or property via
owner_entity_type / owner_entity_id. A NULL owner means platform-wide.
For an owned module the client supplies a plain slug plus an owner, and the
server stores a derived, globally unique block_type, returning the plain slug
and owner alongside it. Two organizations can each hold a pricing_table
without colliding.
A page's available palette is the union of platform modules plus every module owned by the page's own entity and each of its ancestors (property → brand → organization).
This deliberately differs from theme-kit resolution, which short-circuits on the first match. A theme is a single value to override; a module palette is a set to accumulate.
Placing a block whose type is not available to its page's owner is rejected at both resolution points — the flat block endpoint and inline layout modules — so an owned module cannot reach another partner's page by either route.
The two readers of "which block types exist" are split:
| Endpoint | Question | Permission |
|---|---|---|
GET /admin/pages/:pageGroupId/block-schemas | page-scoped palette, owner read off cms_page_ownership | admin:pages:read |
GET /admin/cms/block-schemas | registry management, with owner filters | admin:block-schemas:read |
Requesting another owner's palette is unexpressible on the page-scoped route rather than merely refused.
Field kinds
Field kinds are a fixed, code-curated set mirrored by a database CHECK
constraint. Growing the set is an engineering change — a migration and a
deploy — not an admin API operation.
textField | richTextField | imageField |
linkField | urlField | iconField |
booleanField | numberField | choiceField |
colorField | dateField | dateTimeField |
groupField | objectField | propertyField |
programField |
groupField and objectField are group-like and may nest further field
definitions. propertyField and programField resolve against Journey
property and loyalty-program records rather than storing free values.
Conditional visibility
A field may be shown or hidden based on another field's value. Operators are deliberately narrow:
| Operator | Operand |
|---|---|
EQUAL, NOT_EQUAL | a literal value |
EMPTY, NOT_EMPTY | none |
Only field kinds with a closed, schema-known value domain may control visibility, so a criterion's operand can be validated at schema-write time. Regex and numeric comparators are excluded. Hidden fields are skipped during content validation, so a hidden required field does not block a save.
The module compiler
Registering fields alone gives a type structure but no presentation. The
compiler adds type-level html and css on cms_block_type_schemas, with a
small Liquid-shaped dialect that injects that type's own fields:
{{ module.field_name }},{% if %}, and{% for %}over agroupField.- No
{% include %}, no macros, and no module JavaScript. - Unknown field references fail at schema-write time.
- Field values auto-escape;
richTextFieldcompiles Lexical to HTML.
Interactivity is HTML-native only: <details>/<summary> for disclosure, and
<dialog popover> with the Popover API for modals. The sanitizer strips
scripts, on* handlers, and javascript: URLs. Accordion and modal behavior
therefore need neither author JS nor a website helper.
New owner-scoped creates require html (css optional). Platform-wide
creates stay fields-only, and platform modules such as hero, signup, and
perks remain website components. Existing owner-scoped rows without a template
keep the generic CmsCustomBlock rendering until someone PATCHes html onto them.
Compilation happens in the API — live for drafts and assistant or iframe
preview, then snapshotted as compiledHtml / compiledCss onto
cms_page_blocks on publish. A public read of a published page serves the
snapshot and does not recompile.
Editing a type's template does not rewrite live pages. Pages already published keep their snapshot until they are republished.
custom_html remains instance-level chrome, not a reusable type.
Saved sections
A saved section is an owner-scoped, named library item holding a full root
section tree plus its module content.
- Inserting one creates a live-linked instance. Editing and publishing the library item updates every page still referencing it. Unlink clones the current tree onto the page and drops the link.
- Saved sections have their own draft and publish. A library edit does not reach live partner sites until the saved section is published, and publishing it invalidates cached public reads for every referencing page without republishing those pages.
- Owner isolation matches the module palette: the picker lists the page owner's items plus ancestors. Sibling brands and unrelated organizations are unexpressible from the picker and rejected on insert.
- Saving converts the source section on that draft into a linked instance, so the original page stays on the live link rather than drifting as a disconnected copy.
No new permission was added — list, save, insert, and unlink use
admin:pages:read / admin:pages:manage, like the rest of the page palette.
Supporting modules
| Module | Path | Role |
|---|---|---|
| Theme kits | src/core/cms/theme-kits/ | owner-scoped brand values with property → brand → organization inheritance; resolution short-circuits on first match |
| Media assets | src/core/cms/media-assets/ | CMS-hosted images served from the media CDN; includes import-from-URL and research capture |
| Assistant | src/core/cms/assistant/ | the CMS Assistant |
| Pages | src/core/cms/pages/ | pages, blocks, layout, compiler, lexical, theme, promotion |
Source: api/src/core/cms/, api/src/core/cms/pages/blocks/.