Meridian handbook
Content modelling

Content modelling

How a content type is declared in YAML under config/schema, with a complete worked example from an empty file to a rendered page.

A content type is a YAML file under config/schema/ that names the type, lists its fields and, optionally, its validation rules and editor layout. The engine reads every type file when it starts and when you run engine-dmo check, builds the admin’s editor from it, stores each document’s values against it, and gives templates a typed entry for each document of the type.

The model is configuration, not code: you never write a migration for a new field. You change the YAML, run engine-dmo check, then engine-dmo schema plan and engine-dmo schema apply to bring the database in line.

One type file feeds every part of the engine that touches the type’s content:

flowchart LR
  file["config/schema/walk.yaml"] --> check["engine-dmo check<br/>ids, fields, rules, templates"]
  check --> plan["schema plan / apply"]
  plan --> db[("database:<br/>the applied model")]
  db --> admin["admin editor<br/>groups, labels, validation"]
  db --> store["stored values<br/>by field id"]
  store --> tpl["templates:<br/>a typed entry"]
  store --> api["content API, MCP, export"]

Both bundles

Everything in this section works the same in engine-cms and engine-dmo. The DMO bundle adds two core types, listing and area, which are extended exactly as the other core types are.

A worked example

This example adds a walk type to a project: a route with a summary, a difficulty, a distance, a start point, stages and an accessibility rule.

Create the type file

Either write the file by hand or let the engine scaffold it with a template:

engine-dmo new type walk

new type writes config/schema/walk.yaml with a title, a slug and a summary (ids included), and templates/_shared/walk/entry.html. It reads the project’s configuration first, so every ${VAR} in engine.yaml without a default must be set, even with no database: DATABASE_URL=postgres://unused engine-dmo new type walk is enough when only DATABASE_URL is missing. engine-dmo dev type add walk writes the same type file without that requirement, but no template. Edit the type file so it reads:

# config/schema/walk.yaml
key: walk
label: Walk
plural: Walks
ordering: { field: title, direction: asc }

fields:
  - { name: title, type: text, required: true, index: true }
  - { name: slug, type: slug, source: title, required: true }
  - { name: summary, type: text, multiline: true, max: 280, required: true }
  - name: difficulty
    type: select
    required: true
    index: true
    options:
      - { value: easy, label: { en: Easy } }
      - { value: moderate, label: { en: Moderate } }
      - { value: hard, label: { en: Hard } }
  - { name: distanceKm, type: number, min: 0.1, max: 200, unit: km, index: true }
  - { name: startPoint, type: geo, required: true }
  - { name: heroImage, type: asset }
  - name: stages
    type: repeater
    min: 1
    item_label: "{{ title }}"
    fields:
      - { name: title, type: text, required: true }
      - { name: directions, type: portable_text, styles: [normal], lists: [bullet], marks: [strong, em] }
  - { name: isAccessible, type: boolean, index: true }
  - { name: accessibility, type: portable_text, styles: [normal], marks: [strong, em, link] }

validation:
  - rule: "isAccessible == false or accessibility != empty"
    message: Describe the surfaces and gradients before marking the walk accessible.
    fields: [accessibility]

Give it an address

A type has pages only where a site routes it. Add a route to the site’s routes.yaml:

# config/sites/main/routes.yaml
routes:
  - { type: walk, uri: "/walks/{slug}" }

See Sites, locales and routes for every route option.

Write the ids

Every type, field, block, select option and group carries a stable id. You never write one by hand: check --fix writes an id for every element that has none, keeping your comments and layout, then checks the project.

engine-dmo check --fix

After it runs, each element in the file has an id: such as typ_vydpz07cy9 or fld_0c6c697g4f. Commit them with the file. An element without an id is an error (field "stages" has no id), so a project never reaches the database half identified. Ids are what let you rename a field later without losing its values: see Schema changes.

Write the template

templates/_shared/walk/entry.html renders one walk. Each field is a property of entry:

{% extends "base.html" %}
{% block body %}
<article>
  <h1>{{ entry.title }}</h1>
  {% if entry.heroImage %}{{ entry.heroImage.figure("hero", priority=true) }}{% endif %}
  <p>{{ entry.summary }}</p>
  <dl>
    <dt>Difficulty</dt><dd>{{ entry.difficulty.label }}</dd>
    {% if entry.distanceKm %}<dt>Distance</dt><dd>{{ entry.distanceKm }} km</dd>{% endif %}
  </dl>
  <ol>
    {% for stage in entry.stages %}
    <li>
      <h2>{{ stage.title }}</h2>
      {{ stage.directions|ptext }}
    </li>
    {% endfor %}
  </ol>
  {% if entry.isAccessible %}<section><h2>Accessibility</h2>{{ entry.accessibility|ptext }}</section>{% endif %}
</article>
{% endblock %}

hero is a transform declared in config/images.yaml (Images). engine-dmo check type-checks the template against the schema, so {{ entry.difficultee }} fails with entry has no field "difficultee" in this project's schema and a suggestion.

Check, plan and apply

engine-dmo check
engine-dmo schema plan
engine-dmo schema apply

check validates the configuration and templates and needs no database with --no-db. Read every line it prints: its closing summary (0 errors, 0 warnings) counts configuration problems only, so a template warning printed above it, such as the starter layout’s this head has no head(), is not in the count and does not change the exit code. schema plan prints what will change in the database (+ type walk, + field walk.title, and so on) and exits with code 2 while changes are pending. schema apply applies them in one transaction. Both need a reachable database in DATABASE_URL; Schema changes says what apply does with each kind of change. Under engine-dmo dev up, the development server does the check, plan and safe apply on every save.

Add content

Editors now see Walks in the admin. To create content from the command line or a seed set, see Seeding and importing.

Where type files live

LocationWhat it holds
config/schema/*.yamlShared types, on every site unless the type says sites:. Files are read at any depth, so config/schema/walk/fields.yaml and config/schema/walk/validation.yaml can together declare one type.
config/schema/_shared.yamlField groups that several types include with - use: <name> (Fields).
config/blocks/_index.yamlBlocks that several blocks fields include with - use: <key> (Blocks and rich text).
config/sites/<site>/schema/*.yamlA site’s own types, and fields a site adds to a shared type (Content types).

Files whose names start with _ or . are not type files. Each file declares exactly one of key: (a custom type) or extends: (a core type, or from a site’s directory a shared custom type).

Core types

Some types are built in. Their core fields cannot be removed or retyped, but you add fields to them with extends:.

TypeBundleCore fields (required ones in bold)
pagebothtitle, slug
articlebothtitle, slug, summary (max 280), publishedAt, body
eventbothtitle, slug, summary (max 280), occurrences (recurrence), location (geo)
guidebothtitle, slug, summary (max 280), items (a repeater of a document relation and a note)
collectionbothtitle, slug, summary (max 280), smartList (a relation to a public smart list)
listingengine-dmotitle, slug, summary (max 280), location (geo), primaryTag (a tag relation, two levels deep)
areaengine-dmotitle, slug, summary (max 280), boundary (geo_shape), centre (geo)

The core page type has no summary and no body: a page you can write an About page in needs fields added with extends: page (Extending page). Until then head() has no description to print for a page.

Run engine-dmo describe type <key> to see any type’s fields with their ids (for a core type it lists the core fields above plus the ones your files add), and engine-dmo describe project to list every type.

Tools for working on the model

CommandWhat it does
engine-dmo describe fieldtypesEvery field type, what it stores, its parameters and its default translate mode. --json for a machine-readable copy.
engine-dmo describe type <key>One type’s fields at every depth, with ids, parameters, blocks, options and groups.
engine-dmo config schema --write .meridian/schemaJSON Schemas of every configuration file, so an editor completes and checks type files as you type. engine-dmo init writes them for you.
engine-dmo new type <key>A type file and its entry template. Needs every ${VAR} of engine.yaml set.
engine-dmo dev type add <key>A type file with a title, slug and summary, ids included, and no template; --sites or --site places it. Runs without a database or DATABASE_URL.
engine-dmo dev field add|set|rename|removeEdit a field in place with its id, keeping comments; --dry-run prints the diff (Schema changes).
engine-dmo check --fixWrite missing ids, then check.

Next