Field types reference

{{ field('name', 'type') }} renders an editable content block. Available types are listed in cms-core/src/Twig/FieldExtension.php::ALLOWED_TYPES and validated by cms-core/src/Field/FieldSanitizer.php.

For each type below: payload shape (what's stored on Page.fields[name]), the partial markup (what renders), and the styling hooks themes can use.

Choosing a type

Match the type to what the editor needs to do, not to what the visual result looks like. Wrong type = wrong editor UI, sometimes also = no inline editing at all.

You want Type Wrapper rendered Inline edit?
Long-form rich body (paragraphs, H2/H3, lists, links) html <div class="field-html"> Yes (rich)
Editable text inside a tag the theme controls (<h1 class="…">, <span>, etc.) text None Yes (single line)
One image with layout knobs (alt, width, alignment, fit) image <figure class="field-image"> Cog dialog only
List of images or video (grid, carousel, lightbox, video slider) gallery <div class="field-gallery"> Cog dialog only
Hero rotating banner with overlay text + CTAs slideshow <div class="cms-slideshow"> Cog dialog only
Two images compared with a draggable divider (before/after) compare <figure class="cms-compare"> Cog dialog only
One uploaded video (inline, or a header background loop) video <div class="field-video"> Cog dialog only
Opaque third-party HTML (map, YouTube/Vimeo embed) embed <div class="field-embed"> Cog dialog only
Visual separator divider <hr class="field-divider"> n/a

video is for a file in the media library; embed is for someone else's player. Use embed only for YouTube, Vimeo or a map, where the markup isn't yours. A video you uploaded goes in a video field — you get the poster, the smaller renditions and no third-party branding.

A theme can also render a video without a field, by passing a source to cms_video() directly. Use the field when an editor should choose the video; use the helper when the theme owns it.

The most common mistake is reaching past field() to read page.getFields()['x'].content|raw directly because the theme wants control of the outer tag. That works on the public site but breaks the editor — the inline editor binds to the wrapper that field() emits in editor mode; without it, the field is invisible to the editor. Use text instead — see its section below.

How editors discover fields

Two surfaces work together to make sure editors can find and fill everything the template renders:

  • Side panel — the admin's inline page editor shows a list of every field the template calls field() for, with a filled/empty dot. Click a row → the iframe scrolls to that field and focuses it. The list is derived from a static scan of the page template (see cms-core/src/Website/PageSchemaExtractor.php), so a designer who adds a new {{ field('foo', 'text') }} to a template sees foo appear in the side panel the moment the page reloads — no schema file to keep in sync.

  • Inline placeholders — empty fields aren't 0-height invisible boxes. image/gallery/slideshow/embed show a labelled "Click ⚙ to choose an image" zone; text shows the field name in italics as a placeholder that disappears the moment the editor types. Both are CSS-only and don't pollute the saved content.

Dynamic field calls inside loops (field(slot ~ '_value', 'html')) can only be expanded by reading the page's stored fields, since the static scan doesn't know what slot will be. The side panel merges template- declared names with stored names so loop fields show up too — see PageSchemaExtractor::merge.

html

Rich text with TipTap-allowlisted inline + block marks. The HTML is sanitised on save.

No element keeps a class or a style, with three exceptions for HTML written outside the editor (the docs.ceems.nl handbook is Markdown turned into this field):

  • headings keep their id, so a /page#section link lands;
  • <code> keeps a language-* class (<pre><code class="language-twig">) and drops every other class, so the highlight module can colour the block;
  • <ol> keeps start, so a numbered list that resumes after a code block keeps counting.

Tables, <pre>, <div> and <span> survive too, but the editor has no table support: opening such a field in the admin and saving it loses them.

Stored payload

{ "type": "html", "content": "<p>Hello <strong>world</strong>.</p>" }

Renders

<div class="field-html">…sanitised HTML…</div>

Editor: contentEditable in place. The full-mode TipTap menu offers bold, italic, underline, strike, paragraph, H2 + H3, bullet list, ordered list, blockquote, link.

Realistic example

{# In the template: #}
<article class="prose mx-auto py-12">
    {{ field('intro', 'html') }}
</article>

What an editor will type — multi-paragraph body with a heading, a list, and a link:

{
    "type": "html",
    "content": "<p>PMU staat voor <strong>Permanente Make-Up</strong>. Tijdens een behandeling brengen we natuurlijke pigmenten aan in de huid.</p><h2>Wat we bieden</h2><ul><li>Wenkbrauwen — powder, combi, ombré, nano</li><li>Lippen — pigmentatie en contouring</li></ul><p>Lees meer over <a href=\"/pmu\">de procedure</a>.</p>"
}

The same template, on a fresh page where the field is empty, renders the empty <div class="field-html"> so the editor still has a click target.

Default value. Pass a third argument to seed fallback markup when the field has no content (key absent or stored-but-empty):

{{ field('contact_email', 'html', '<a href="mailto:x@y.nl">x@y.nl</a>') }}

This replaces the old field(...)|default('x') idiom, which silently broke in the editor. See twig-helpers.md → field.

text

Editable inline-rich content with no wrapper element. The theme owns the surrounding tag — typically the page's <h1>, a <span> caption, a custom <dt> label, etc.

This is the right primitive when you'd otherwise be tempted to read page.getFields()['x'].content|raw by hand to keep control of the outer markup. The bare-content render makes the result clean, and field() still emits the editor's <div data-cms-field-name="…"> wrapper in editor mode so the SPA finds and edits the field.

Stored payload

{ "type": "text", "content": "Pure <em>fragrance</em>" }

Content sanitisation is the same surface as html — TipTap's full allowlist, so inline <em>, <strong>, links, etc. survive. There's no wrapper-shape concern at the data level; only the partial differs.

Renders

{{- field.content|default('')|raw -}}

That is — the partial emits content only, no element. In editor mode FieldExtension wraps it in <div class="cms-field" data-cms-field-name="…" data-cms-field-type="text">…</div> so the editor binds; on the public site there is no wrapper at all.

Use it like this — keeping the theme's outer tag and classes:

{# Hero <h1>: theme picks the tag and the styling, editor still
   types the words and any inline <em> accents. #}
<h1 class="font-display font-light text-[clamp(3rem,7vw,6.5rem)] leading-[0.96]
           [&_em]:text-brass-deep [&_em]:italic">
    {{ field('hero_title', 'text') }}
</h1>

{# Sidebar label inside a span — same pattern. #}
<span class="block font-sans text-[11px] uppercase tracking-[0.32em] text-brass-deep">
    {{ field('side_title', 'text') }}
</span>

{# Definition-list label inside a <dt>. #}
<dt class="font-sans text-[10.5px] uppercase tracking-[0.32em] text-brass-deep">
    {{ field('contact_label', 'text') }}
</dt>

Editor: contentEditable on the wrapper <div> itself (no inner partial to bind to). Inline TipTap surface; line breaks aren't stripped, so an editor pressing Enter inside an <h1> will insert a <br>. If your design assumes single-line use, the surrounding tag's CSS (e.g. white-space: nowrap) is your guard.

Default value. Pass a third argument for fallback copy shown until an editor sets the field — handy for meta rows, labels, and statement headlines that should never render blank:

<span class="v">{{ field('hero_meta_location', 'text', 'Zaandam, NL') }}</span>

Prefer this over field(...)|default('x'), which doesn't survive editor mode. See twig-helpers.md → field.

image

Single image with layout knobs.

Stored payload

{
    "type": "image",
    "src": "/media/uploads/foo.jpg",
    "alt": "Description for screen readers",
    "caption": "Optional visible caption",
    "width":  "small | medium | large | full",
    "align":  "left | center | right",
    "radius": "none | small | large | circle",
    "fit":    "auto | cover"
}

After server-side enrichment (enrichImage) the payload also carries intrinsicWidth, intrinsicHeight (from the Media row, for CLS-safe sizing), srcsetWidths (the resize ladder 320/480/640/960/1280/1920, cut off at the original's width) and loading: "lazy" (default). An SVG or a GIF gets no srcsetWidths and renders with its bare src: an animated GIF is served as its original whatever the params. A WebP or an AVIF keeps its ladder, since most of them are stills; an animated one is served as its original at every width, so it still moves.

Renders

<figure class="field-image"
        data-width="medium" data-align="center" data-radius="none" data-fit="auto">
    <img src="/media/uploads/foo.jpg?w=1600"
         srcset="/media/uploads/foo.jpg?w=320 320w, …, /media/uploads/foo.jpg?w=1600 1600w"
         sizes="(max-width: 600px) 100vw, 600px"
         alt="…" width="…" height="…" loading="lazy" decoding="async">
    <figcaption>Optional visible caption</figcaption>
</figure>

The browser picks the smallest candidate that covers the rendered size, so a 2000px upload in a 600px column no longer downloads at full size. The default sizes follows the data-width presets in blocks.css (small 280px, medium 600px, large 880px, full 100vw). An SVG has no stored size and is served verbatim, so it keeps a bare src.

Template options: field() takes an options hash, passed by name. For image fields it reads loading (eager|lazy), fetchpriority (high|low|auto) and sizes; anything else is ignored.

{# The page header is the LCP image: load it now, ahead of everything else. #}
{{ field('header_foto', 'image', options: {loading: 'eager', fetchpriority: 'high'}) }}

{# A theme that changes the width presets, or puts the field in a grid, says how wide it really is. #}
{{ field('team_photo', 'image', options: {sizes: '(min-width: 1024px) 33vw, 100vw'}) }}

Use eager + high for at most one image per page, the one above the fold. Everything else should stay lazy.

Theme hooks: [data-width], [data-align], [data-radius], [data-fit] give CSS handles. Defaults live in cms-public/public/assets/blocks.css.

Editor: media picker for src; alt + caption text inputs; selects for the four enums. No inline edit — open via cog button.

Realistic example

{# Template — that's literally all you need: #}
<section class="container py-12">
    {{ field('about_portrait', 'image') }}
</section>

Stored payload after an editor uploads a portrait, sets a width, and adds an alt:

{
    "type": "image",
    "src": "/media/uploads/portrait-melanie.jpeg",
    "alt": "Portret van Melanie, eigenaresse van de studio",
    "caption": "",
    "width":  "medium",
    "align":  "center",
    "radius": "large",
    "fit":    "cover",
    "intrinsicWidth":  1600,
    "intrinsicHeight": 2000,
    "loading": "lazy"
}

The four enums map to CSS via the data-* attributes — see cms-public/public/assets/blocks.css for the default sizes that data-width="medium" and friends resolve to. A theme can override:

.field-image[data-width="medium"] { max-width: 32rem; }
.field-image[data-radius="large"]  { border-radius: 1.5rem; }

video

One uploaded video, with playback and layout knobs.

{{ field('hero_video', 'video') }}

Stored payload

{
    "type": "video",
    "src": "/media/80/80fb58aa4cb63022.mp4",
    "alt": "Onze werkplaats",
    "caption": "",
    "autoplay": true,
    "loop": true,
    "controls": false,
    "mobile": "poster",
    "expandable": true,
    "width": "full",
    "align": "center",
    "radius": "large",
    "radius_tl": "inherit",
    "radius_tr": "none",
    "radius_br": "none",
    "radius_bl": "inherit"
}

Rendered markup

<div class="field-video" data-width="full" data-align="center" data-radius="none">
    <div class="cms-video-frame cms-video-frame--cover">…</div>
</div>

The player is cms_video() — the field only adds the [data-width]/[data-align]/[data-radius] wrapper, the same three knobs image exposes, so a theme already styling one handles both. Everything about playback (rendition choice, poster, forced mute under autoplay, the mobile still) lives in the helper.

Template options: the same loading, fetchpriority and sizes an image field takes, applied to the mobile still. A header video's still is the LCP element on a phone:

{{ field('hero_video', 'video', options: {fetchpriority: 'high'}) }}

Editor: media picker restricted to video; toggles for play-automatically, loop and controls; a choice of what phones get; alt, caption; width, alignment, corner radius (individually if needed), and whether visitors can enlarge it.

Dependent defaults. Ticking play automatically means a background loop, so loop defaults on and controls off. Leaving it off means a video the visitor starts, so controls defaults on and loop off. Both stay explicitly overridable — they are defaults, not rules.

Autoplay is always silent. Browsers refuse to autoplay a video with sound; the renderer forces muted regardless of the payload. The editor toggle says "play automatically" rather than "mute" because muting is a consequence, not a choice.

Corners can be set individually. radius sets all four; each radius_tl/tr/br/bl either follows it ("inherit") or states its own step, so "top-left round, top-right square" is expressible. The partial composes them into one border-radius shorthand built from named CSS steps (--cms-video-radius-none|small|large), so the actual scale stays the theme's and stored content carries intent rather than pixel values.

Enlarging is not fullscreen. "expandable": true (the default for a video that doesn't autoplay) adds a control that grows the video over the page; clicking beside it or pressing Escape restores it. The browser's fullscreen is deliberately avoided — it hides the site and on iOS hands the clip to the system player.

On phones, "mobile": "poster" shows the generated poster frame as a still instead of the video — the largest file on the smallest screen over the worst connection is the trade worth avoiding. The video is then not downloaded at all at that width. A different photo is a theme-level call: cms_video()'s mobile option also takes a /media/... path.

A list of images — and video. An item is whatever the editor picked; the renderer resolves each media row and emits an <img> or a player accordingly, so a gallery can be all images, all video, or mixed.

That is also why there is no separate video slider: cms_gallery(name, 'carousel') becomes one as soon as the field holds video.

Video slides behave differently from a standalone video field in two ways that matter on a page with several of them:

  • Nothing but the poster loads until a visitor plays one. A slider is the one place where several videos share a page, and preloading metadata for all of them is how a single component eats the page's whole media budget.
  • A slide that scrolls out of view is paused, so swiping on never leaves audio playing behind you.

They are enlargeable by default, since a slide is small by nature.

Stored payload

{
    "type": "gallery",
    "items": [{ "src": "/media/...", "alt": "…" }, …],
    "columns": "auto | 2 | 3 | 4",
    "width":   "medium | large | full"
}

After enrichment items carry loading: "eager" for the first gallery_eager_count slots (passed by the partial — see cms_gallery('name', 'carousel') which sets eager=1).

Renders

<div class="field-gallery" data-columns="auto" data-width="full">
    <figure class="field-gallery__item">
        <img src="…?w=520&h=650&fit=cover" alt="…" width="520" height="650"
             loading="lazy" decoding="async">
    </figure>
    …
</div>

The on-the-fly transform (?w=…&h=…) is honoured by cms-public/src/Controller/MediaController.php.

Theme hooks: [data-columns], [data-width]. The carousel + lightbox partials reuse this same field — see cms_gallery() and per-page-modules.md.

Editor: list with thumbnail previews, reorder + delete + add per row; columns + width selects.

Realistic example — voor/na gallery

{# Plain grid, theme decides columns via the data-attribute. #}
{{ field('voor_na', 'gallery') }}

{# Or wrap with cms_gallery to get the carousel/lightbox modes,
   no extra Twig data wiring needed: #}
{{ cms_gallery('voor_na', 'lightbox') }}

Stored payload after the editor uploads four before/after photos and picks 4-column layout:

{
    "type": "gallery",
    "items": [
        { "src": "/media/uploads/voor-na-1.jpg", "alt": "Voor en na — wenkbrauwen klant 1" },
        { "src": "/media/uploads/voor-na-2.jpg", "alt": "Voor en na — wenkbrauwen klant 2" },
        { "src": "/media/uploads/voor-na-3.jpg", "alt": "Voor en na — lippen klant 3" },
        { "src": "/media/uploads/voor-na-4.jpg", "alt": "Voor en na — combi-brow klant 4" }
    ],
    "columns": "4",
    "width":   "large"
}

Tip: columns: "auto" lets the gallery flow into whatever the parent container can fit — useful for reusable partials. Pick a fixed number (2, 3, 4) when the design is locked.

slideshow

Hero-style auto-rotating banner with text overlays + per-slide CTA.

Stored payload

{
    "type": "slideshow",
    "slides": [
        {
            "src":           "/media/uploads/hero1.jpg",
            "alt":           "…",
            "heading":       "By Melanie",
            "cta_kind":          "none | url | page | category | product",
            "cta_label":         "Contact opnemen",
            "cta_url":           "/contact",              // when cta_kind = url
            "cta_page_slug":     "contact",               // when cta_kind = page
            "cta_category_slug": "winterjassen",          // when cta_kind = category
            "cta_product_slug":  "winterjas-dames"        // when cta_kind = product
        },
        …
    ],
    "interval":       5000,        // ms between slides (1000–60000)
    "pause_on_hover": true,
    "dots":           false,       // dot indicators
    "arrows":         false,       // prev/next arrows
    "height":         "small | medium | large | full"
}

Every internal CTA kind (page, category, product) stores a slug, never a URL, and the slug stored is always the CANONICAL one — the slug in the website's default language. The href is resolved server-side when the slide renders, against the language being rendered, so a single stored link emits /winterjassen in Dutch and /de/winterjacken in German without the editor picking a target per language. A target that no longer resolves (deleted, unpublished, renamed) renders no button at all, rather than a button onto a 404.

Only top-level categories can be linked: /categories/{slug} serves exactly those.

Per-language text. A slideshow is the one field type whose translation override is partial. A language tab edits the slide's heading, alt and cta_label; the image, the CTA target and the timing knobs stay shared, so swapping a photo or re-pointing a button in the default language reaches every language at once. The admin disables the shared controls on a language tab, and the server keeps only those three keys from a translation.

Renders

<div class="cms-slideshow"
     data-cms-slideshow data-cms-slideshow-interval="5000"
     data-cms-slideshow-pause-on-hover
     data-height="large"
     role="region" aria-roledescription="slideshow" aria-label="…">

    <div class="cms-slideshow__track" data-cms-slideshow-track>
        <div class="cms-slideshow__slide cms-slideshow__slide--active" data-cms-slideshow-slide>
            <img class="cms-slideshow__image" src="…?w=1920&h=1080&fit=cover" alt="…" loading="eager">
            <div class="cms-slideshow__overlay">
                <h2 class="cms-slideshow__heading">By Melanie</h2>
                <a class="cms-slideshow__cta" href="/contact">Contact opnemen</a>
            </div>
        </div>
        <div class="cms-slideshow__slide" data-cms-slideshow-slide>…</div>
    </div>
</div>

The partial calls cms_module('slideshow') itself, so cms-public/public/assets/slideshow.{js,css} auto-load. Slide 1 is loading="eager" (LCP), the rest are lazy.

Theme hooks: CSS variables on .cms-slideshow —

--cms-slideshow-fade            (700ms)
--cms-slideshow-overlay-bg      (rgba(0,0,0,0.25))
--cms-slideshow-overlay-fg      (#fff)
--cms-slideshow-heading-size    (clamp(1.75rem, 4vw, 3.25rem))
--cms-slideshow-heading-weight  (500)
--cms-slideshow-cta-bg          (#111)
--cms-slideshow-cta-fg          (#fff)
--cms-slideshow-arrow-bg        (rgba(0,0,0,0.4))
--cms-slideshow-arrow-fg        (#fff)
--cms-slideshow-dot-color       (rgba(255,255,255,0.5))
--cms-slideshow-dot-active      (#fff)

Override per-website by setting them on .cms-slideshow itself, with a selector more specific than the module's own and outside any @layer. A value set on an ancestor never reaches it, because slideshow.css declares every default on .cms-slideshow. See tailwind-and-styles.md for why, and cms-public/public/assets/slideshow.css for full defaults. Heights map to viewport units: small → 40vh, medium → 60vh, large → 80vh, full → 100vh.

Behaviour: crossfade transition, auto-rotate at interval, pause on hover (when enabled) + on visibilitychange, no auto-rotate when the visitor has prefers-reduced-motion: reduce or there's only one slide.

Editor: per-slide row with image picker + heading + alt + CTA radio (None / URL / Page slug); top-level controls for height, interval (ms), pause-on-hover, dots, arrows.

Realistic example — three-slide hero

{# That's it. The slideshow partial registers its own JS/CSS via
   cms_module('slideshow') so no additional setup is needed. #}
{{ field('hero', 'slideshow') }}

Stored payload after the editor adds three slides, sets large height, 4-second interval, and links each slide to a page:

{
    "type": "slideshow",
    "slides": [
        {
            "src":           "/media/uploads/hero-pmu.jpg",
            "alt":           "Permanente make-up behandeling — wenkbrauwen",
            "heading":       "Permanente make-up",
            "cta_kind":      "page",
            "cta_label":     "Bekijk de behandelingen",
            "cta_page_slug": "pmu"
        },
        {
            "src":           "/media/uploads/hero-aftercare.jpg",
            "alt":           "Nazorg-instructies — kalme studio-sfeer",
            "heading":       "Nazorg & advies",
            "cta_kind":      "page",
            "cta_label":     "Lees meer",
            "cta_page_slug": "nazorg"
        },
        {
            "src":           "/media/uploads/hero-contact.jpg",
            "alt":           "Studio Melanie — interieur",
            "heading":       "Maak een afspraak",
            "cta_kind":      "page",
            "cta_label":     "Contact",
            "cta_page_slug": "contact"
        }
    ],
    "interval":       4000,
    "pause_on_hover": true,
    "dots":           true,
    "arrows":         false,
    "height":         "large"
}

To re-skin the slideshow per-website without touching the partial, set the CSS variables in the theme's stylesheet, keyed on a class you put on a wrapper:

<section class="hero">
    {{ field('hero', 'slideshow') }}
</section>
/* end of styles.src.css, outside any @layer */
.hero .cms-slideshow {
    --cms-slideshow-cta-bg: var(--color-gold-deep);
    --cms-slideshow-overlay-bg: rgba(20,15,11,0.4);
    --cms-slideshow-heading-weight: 600;
}

An inline style="--cms-slideshow-…" on the wrapper looks equivalent and does nothing: the wrapper's value is inherited, and the module's own declaration on .cms-slideshow beats it.

compare

Two images in one box, revealed against each other by a divider the visitor drags. The classic before/after: a mossy roof and a cleaned one, a room before and after a renovation.

Stored payload

{
    "type": "compare",
    "before_src": "/media/uploads/roof-before.jpg",
    "before_alt": "Dak met mos voor de reiniging",
    "before_label": "Voor",
    "after_src": "/media/uploads/roof-after.jpg",
    "after_alt": "Hetzelfde dak na de reiniging",
    "after_label": "Na",
    "orientation": "horizontal | vertical",
    "start": 50,
    "width":  "small | medium | large | full",
    "align":  "left | center | right",
    "radius": "none | small | large"
}

start is 0-100: where the divider sits before anyone touches it. Off-centre is a real editorial choice, 65 shows mostly the "after" with enough of the "before" to invite the drag.

Both images are required. A pair with one image is not a degraded comparison, it is a confusing image with a line over it, so the sanitizer drops the whole field rather than storing half of one. The admin dialog says which half is still missing.

After enrichment (enrichCompare) the payload carries intrinsicWidth and intrinsicHeight taken from the before image, which is the one in normal flow and therefore the one that gives the figure its height. A pair with different aspect ratios crops rather than jumping.

Renders

<figure class="cms-compare" data-cms-compare
        data-orientation="horizontal" data-width="large"
        data-align="center" data-radius="none"
        style="--cms-compare-position: 50%;">
    <img class="cms-compare__image cms-compare__image--before" src="…" alt="…">
    <div class="cms-compare__after" aria-hidden="true">
        <img class="cms-compare__image cms-compare__image--after" src="…" alt="">
    </div>
    <span class="cms-compare__label cms-compare__label--before">Voor</span>
    <span class="cms-compare__label cms-compare__label--after">Na</span>
    <div class="cms-compare__divider" aria-hidden="true">…</div>
    <input type="range" class="cms-compare__range" data-cms-compare-range value="50">
</figure>

Works without JavaScript. The reveal is one custom property (--cms-compare-position) written inline by the partial, and the control is a real <input type="range">, so the divider starts where the editor left it and remains keyboard-operable with compare.js missing. All the script adds is the gesture everyone expects: press and drag anywhere on the image.

Theme hooks: [data-orientation], [data-width], [data-align], [data-radius], plus the --cms-compare-* custom properties in cms-public/public/assets/compare.css (divider colour and width, handle size and shape, label styling, focus ring).

.cms-compare {
    --cms-compare-divider-color: #22d3ee;
    --cms-compare-handle-size: 52px;
    --cms-compare-label-bg: rgba(0, 0, 0, 0.6);
}

Editor: two media picker slots with their own alt and label, a live preview that clips exactly as the public page does, a slider for the starting position, and selects for direction, width, alignment and corner radius. No inline edit, open via the cog button.

Realistic example

<section class="container py-12">
    <h2 class="text-2xl font-semibold">Het verschil</h2>
    {{ field('roof_before_after', 'compare') }}
</section>

Labels are not per-language yet. before_label / after_label are text living inside a media-typed field, the same gap image.alt has: the translation overlay leaves them in the default language. On a multilingual site either leave them empty and let the surrounding copy carry the meaning, or accept one language's wording on every locale.

embed

Opaque HTML for third-party embeds (maps, video, calendar widgets).

Stored payload

{ "type": "embed", "content": "<iframe src='…' …></iframe>" }

Renders

<div class="field-embed">…content as-is…</div>

The content is not sanitised on save (it's literally for embeds); the saver tightens the surface later. For now, treat embed as an admin escape hatch and don't use it for editor-shaped content.

Editor: monospace textarea via cog dialog.

Realistic example — Google Maps embed

{# Theme: just the field. Wrap with whatever framing you need. #}
<div class="aspect-[16/9] overflow-hidden rounded-lg">
    {{ field('location_map', 'embed') }}
</div>

Stored payload (paste from Google Maps' "Embed a map" dialog):

{
    "type": "embed",
    "content": "<iframe src=\"https://www.google.com/maps/embed?pb=…\" width=\"100%\" height=\"100%\" style=\"border:0\" allowfullscreen=\"\" loading=\"lazy\" referrerpolicy=\"no-referrer-when-downgrade\"></iframe>"
}

Operators paste the entire <iframe> snippet — including any width / height the source service includes. Wrap in a CSS container if you need responsive sizing.

divider

Visual separator with no payload.

Renders

<hr class="field-divider">

Editor: no settings; cog button is informational.


Adding a new field type

This is a cms-core change, not a theme change. Three files:

  1. cms-core/src/Twig/FieldExtension.php
    • Add to ALLOWED_TYPES.
    • Optionally add an enrich<Type>() method called from renderField() for server-side payload prep (intrinsic dims, link resolution, etc.).
  2. cms-core/src/Field/FieldSanitizer.php
    • Add to KNOWN_TYPES.
    • Add a case '<type>' block that produces a stable, validated payload shape from raw editor input.
  3. cms-core/templates/_partials/fields/<type>.html.twig
    • Render the payload. If the field needs a JS+CSS module to be interactive, call {% do cms_module('<name>') %} near the top.

Then on the admin side (cms-admin):

  1. cms-admin/src/types.ts — add a variant to the FieldPayload union.
  2. cms-admin/src/lib/pages.ts — add '<type>' to PageSchema.fields[].type.
  3. cms-admin/src/components/FieldSettingsDialog.vue — add a template v-else-if="type === '<type>'" block with the editor UI.

Tag a cms-core release; bump consumers; redeploy. Themes see the new type immediately — no theme change required.