# Components and Modules
> Source: /cms/components-and-modules
> The block-type registry, field kinds, owner scoping, the module compiler, saved sections, theme kits, and media assets

# Components and modules

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](/cms/grid).

`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 [#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 [#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).

<Note>
  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.
</Note>

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]

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 [#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 [#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 a `groupField`.
- No `{% include %}`, no macros, and **no module JavaScript**.
- Unknown field references fail at schema-write time.
- Field values auto-escape; `richTextField` compiles 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.

<Warning>
  Editing a type's template does not rewrite live pages. Pages already published
  keep their snapshot until they are republished.
</Warning>

`custom_html` remains instance-level chrome, not a reusable type.

## Saved sections [#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 [#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](/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/`.
