Meridian handbook
Content modelling

Blocks and rich text

Repeaters (a list of items with the same fields), blocks (a flexible page body of typed blocks, each with its own template), shared blocks, and Portable Text rich text with styles, lists, marks, link annotations and object blocks, and how each renders.

Three field types hold structured, ordered content:

TypeUse it forEach item is
repeaterA list of things of one shape: a walk’s stages, a venue’s rooms, questions and answers.The same child fields.
blocksA flexible page body an editor composes and reorders: text, a quote, a gallery, a grid of cards.One of the declared blocks, each with its own fields and its own template.
portable_textFormatted prose: headings, paragraphs, lists, links, bold and italic, with optional embedded objects.A Portable Text block.

repeater

fields:
  - name: stages
    type: repeater
    min: 1
    max: 20
    collapsed: true
    item_label: "{{ title }}"
    fields:
      - { name: title, type: text, required: true }
      - { name: distanceKm, type: number, min: 0, index: true }
      - { name: notes, type: portable_text, marks: [strong, em, link] }
ParameterTypeDefaultWhat it does
fieldslistrequiredThe child fields of every item, declared like any field. A child may itself be a repeater or blocks field.
minwhole numbernoneThe fewest items. Fewer than min but more than none is an error on save; none is an error at publish.
maxwhole numbernoneThe most items.
collapsedbooleanfalseStarts every item closed in the editor, so a long list reads as an outline.
item_labeltemplatethe first text-like valueThe summary line of a collapsed item, such as "{{ title }}". A rich text child gives its plain text.

A repeater must declare at least one child field (a repeater declares its item fields).

blocks

fields:
  - name: body
    type: blocks
    collapsed: true
    blocks:
      - key: richText
        label: { en-GB: Text }
        description: { en-GB: A passage of formatted text }
        item_label: "{{ content }}"
        fields:
          - name: content
            type: portable_text
            styles: [normal, h2, h3]
            lists: [bullet, number]
            marks: [strong, em, link]
      - key: faq
        label: { en-GB: Questions and answers }
        fields:
          - name: items
            type: repeater
            item_label: "{{ question }}"
            fields:
              - { name: question, type: text, required: true }
              - { name: answer, type: portable_text, styles: [normal], marks: [strong, em, link] }
      - use: quote
ParameterTypeDefaultWhat it does
blockslistrequiredThe block kinds an editor may add, in the order the block picker shows them. At least one.
min, maxwhole numbernoneThe fewest and most blocks, as for a repeater.
collapsedbooleanfalseStarts every block closed; a block just added opens regardless.
item_labeltemplateThe collapsed summary when a block has none of its own.

Each block declares:

KeyTypeWhat it does
keycamelCase stringThe block’s handle, and the name of its template. Unique within the field.
idstringWritten by check --fix (blk_...).
labellocale map or stringIts name in the block picker.
descriptionlocale map or stringOne line under the label in the block picker.
item_labeltemplateIts collapsed summary, such as "{{ heading }}".
fieldslistIts fields.
useshared block keyInstead of all the above: include a shared block.

Shared blocks

A block several blocks fields offer is declared once in config/blocks/_index.yaml, with key:, and included with - use: <key>:

# config/blocks/_index.yaml
blocks:
  - key: quote
    label: { en-GB: Quote }
    description: { en-GB: A pull quote with its source }
    item_label: "{{ text }}"
    fields:
      - { name: text, type: text, multiline: true, required: true }
      - { name: cite, type: text }

A use: entry declares nothing else. A shared block is declared with key:, never use:, and once only.

Block templates

Each block renders through blocks/<key>.html in the site’s template roots (for example templates/_shared/blocks/quote.html). The page template renders the whole field with the blocks tag:

<div class="blocks">{% blocks entry.body %}</div>

Inside a block template, the block’s fields are on block, with blockIndex (from zero) and blockCount, beside every global of the page:

{# templates/_shared/blocks/quote.html #}
<blockquote class="quote">
  <p>{{ block.text|linebreaksbr }}</p>
  {% if block.cite %}<footer>{{ block.cite }}</footer>{% endif %}
</blockquote>
{# templates/_shared/blocks/faq.html #}
<section class="faq">
  {% for item in block.items %}
  <details>
    <summary>{{ item.question }}</summary>
    {{ item.answer|ptext }}
  </details>
  {% endfor %}
</section>

engine-dmo check fails when a declared block has no template on a site: block "quote" of page.body has no template for site main, with the path to add. engine-dmo new block <key> writes a block template and the schema stub that declares it.

Validation

CodeMessage
min_itemsAdd at least N
max_itemsNo more than N
block_typeBlock type “x” is not declared (blocks only)
keyItem has no key / Duplicate item key

Child fields are validated like top-level fields, with paths such as body[2].heading and stages[0].title.

Stored value and template value

A repeater or blocks value is a list of items. Each item has a _key, unique within the field and stable across edits, which is how translations find their item; a block also has _type, its block key:

[
  { "_key": "hm01", "_type": "richText", "content": [ ... ] },
  { "_key": "hm02", "_type": "quote", "text": "The best view in the valley.", "cite": "A visitor" }
]

In a template, a repeater is a list of items and a blocks field a list of blocks. Each item carries its child fields as properties, key (and _key), and for a block type (and _type). Relations inside items resolve to entries as top-level ones do.

<ol>
  {% for stage in entry.stages %}
  <li id="stage-{{ stage.key }}"><h2>{{ stage.title }}</h2>{{ stage.notes|ptext }}</li>
  {% endfor %}
</ol>

With index: true on a child, its values are projected under a dotted key (stages.distanceKm), one row per item, so lists can filter documents by values inside items. In a document with several locales, which items exist and in which order is normally shared, and each locale writes its own text into them: see Localisation.

portable_text

Rich text is stored as Portable Text, a JSON array of blocks, never HTML. The schema says exactly which styles, lists and marks a field allows; the editor offers only those, and content pasted from Word, Google Docs or a web page is posted to the engine, converted and normalised to the field’s schema, with a list of what changed.

fields:
  - name: description
    type: portable_text
    styles: [normal, h2, h3, blockquote]
    lists: [bullet, number]
    marks:
      decorators: [strong, em, underline]
      annotations:
        - key: link
          kinds: [{ key: page, of: document, types: [page] }, { of: url }, { of: email }]
          attributes: [text, new_tab]
        - key: glossary
          label: { en-GB: Glossary term }
          fields:
            - { name: definition, type: text, required: true }
    blocks:
      - key: figure
        label: { en-GB: Image }
        fields:
          - { name: image, type: asset, required: true }
          - { name: caption, type: text }
    max: 5000

Parameters

ParameterTypeDefaultWhat it does
styleslistnormal, h2, h3, h4, blockquoteThe block styles allowed, from normal, h2 to h6 and blockquote. Leave h1 to the page template.
listslistbullet, numberThe list styles allowed.
markslist or mappingstrong, em and linkThe inline marks allowed. See below.
blockslistnoneObject blocks an editor can insert between paragraphs, declared like the blocks of a blocks field (key, label, description, fields).
maxwhole numbernoneThe most characters of text, counted as grapheme clusters.

Marks, short form. A list of names. strong, em, underline, code and strike-through are decorators; link, internalLink and mention are annotations:

marks: [strong, em, link]

Marks, long form. A mapping with decorators and annotations, where each annotation is { key, ... }:

AnnotationWhat it is
{ key: link }A link with the link field’s model. It takes kinds, attributes and rel and nothing else; a document target is stored by id, so the link survives a move. nofollow and ugc are always allowed for partners’ links.
{ key: internalLink, to: [page, article] }A reference to a document, picked from the given types (every type when to is left out).
{ key: mention }A staff user, picked by typing @; renders as <span class="mention">.
{ key: <yours>, label, fields }A custom annotation with fields of its own, validated like a block’s fields. A custom annotation with no fields is applied as it is.

Validation

Validation is against the field’s schema, so a value never holds what the field does not allow:

CodeMessage
styleStyle “h1” is not allowed here
listList style “number” is not allowed here
decoratorFormatting “underline” is not allowed here
annotationAnnotation “x” is not allowed here
link_marksText can carry one link at a time
block_type, inline_typeBlock type / Inline object “x” is not allowed here
max_lengthMust be N characters or fewer

A rich text field with translate: none gets a warning: it cannot then be translated at all.

Stored value

[
  { "_type": "block", "_key": "b1", "style": "normal", "markDefs": [],
    "children": [
      { "_type": "span", "_key": "b1s0", "text": "Open every day except ", "marks": [] },
      { "_type": "span", "_key": "b1s1", "text": "Mondays", "marks": ["strong"] }
    ] },
  { "_type": "figure", "_key": "f1", "image": { "asset": "3c1e..." }, "caption": "The tower" }
]

A list item is a block with listItem (bullet or number) and level. A link is a markDefs entry referenced from a span’s marks. Text is stored in Unicode NFC. An import may give a rich text value as { "html": "<p>...</p>" }, converted by the same converter the editor’s paste uses (Seeding and importing).

Rich text is translated whole: each locale holds its own complete value, so a translator can merge and split sentences.

Rendering

FilterWhat it gives
|ptextThe HTML. Every span is escaped. h2 to h4 headings carry ids.
|ptext:"compact"The HTML with the compact serialiser set for object blocks (below).
|tocA navigation list of the headings, linking to their ids.
|plainThe text alone, for a meta description or a card; follow it with |truncate:160 or |excerpt:160 to shorten it.
<div class="prose">{{ entry.description|ptext }}</div>
<nav aria-label="On this page">{{ entry.description|toc }}</nav>
<meta name="description" content="{{ entry.description|plain|excerpt:160 }}">

Object blocks render through ptext/<set>/<key>.html in the site’s template roots, where <set> is default unless the filter names one. The block’s fields are on node, its position on index, beside every global of the page:

{# templates/_shared/ptext/default/figure.html #}
{{ node.image.figure("card") }}

An object block with no template renders nothing on the public site (with serve --dev, a notice naming the template to add), and engine-dmo check warns: object block "figure" of listing.description has no serialiser template for site main, so |ptext renders it as nothing. Naming a serialiser set that has no templates at all is an error when the page renders.