Meridian handbook
Content modelling

Validation

When a document is checked (save and publish), required fields and required_in, visible_when and required_when, the expression language, cross-field validation rules with severity and timing, and the publish checks.

Every document is checked against its type on each save and on each publish. A problem is an error, which stops the action, or a warning, which is shown as advice and stops nothing. Each problem has a path (stages[2].title, meta.description) so the admin, the content API and the MCP point at the exact control, inside collapsed repeater items too.

Save and publish

A draft must be able to stop mid-work, so a save checks less than a publish.

CheckOn saveOn publish
Each field type’s own rules (a number is a number, a URL parses, a phone is valid)errorerror
required, required_in, required_whennot checkederror
A repeater’s or blocks field’s min, when it has fewer items but not noneerrorerror
A repeater’s or blocks field’s min, when it is emptynot checkederror
uniquewarningerror
Validation rules without on:as their severityas their severity
Validation rules with on: publishnot runas their severity
A link’s target type and site, an asset’s kind and constraintserrorerror
Asset rights, alt text and missing assets (Asset)error or warning
A tag relation names a vocabulary tag, not a candidateerror
A nesting document is not placed under itselferror
A relation’s target is not published in this locale or its fallbackswarning
The reserved slug __home__ where it cannot applyerrorerror

Publishing in one locale checks that locale’s view of the document.

Required fields

fields:
  - { name: title, type: text, required: true }
  - { name: summary, type: text, required: true, required_in: all }
  - { name: legalNotice, type: portable_text, required: true, required_in: [en-GB, cy-GB] }

required: true makes a field necessary to publish. required_in says in which locales, and applies only with required: true:

required_inThe field is required when publishing
left out, or sourcein the source locale only (the default)
allin every locale
a locale code or a listin the source locale if source is listed, and in each locale listed

The default is deliberate: requiring every locale blocks publishing until everything is translated.

A value is empty when it is missing, a string of only spaces, an empty list, or an empty object. false is a value, so required on a boolean does not force it to be true. The message is This field is required with code required.

Conditional fields

fields:
  - { name: bookingRequired, type: boolean }
  - name: bookingUrl
    type: link
    kinds: [{ of: url }]
    visible_when: "bookingRequired == true"
    required_when: "bookingRequired == true"
KeyWhat it does
visible_whenThe editor shows the field only while the expression is true, with no round trip to the server. On every save the server evaluates it too, and a hidden field’s value is removed: a crafted post cannot keep a value for a field that should not be there.
required_whenThe field is required to publish while the expression is true.

They are separate on purpose: a field can be visible and optional, or required by a condition while visible for another. Both read sibling fields only: a top-level field reads other top-level fields, and a field inside a repeater item or a block reads the other fields of that item.

The expression language

visible_when, required_when and validation rules share one small language, parsed and type-checked when the configuration loads.

FormExample
Compare a field with a literaltier == 'gold', capacity > 50, dogFriendly == true
Compare two fieldsendDate >= startDate
EmptinessheroImage == empty, accessibility != empty
Membershipdifficulty in ['moderate', 'hard'], kind not in ['online']
Length (rules)summary|length >= 60
Combineand, or, not, and parentheses: not (tier in ['gold', 'platinum']) or heroImage != empty
  • Operators: ==, !=, <, >, <=, >=, in, not in.
  • Literals: strings in single or double quotes, numbers, true, false, and empty (with == and != only). A list after in holds literals only.
  • |length is the only filter: a string’s length in characters, or a list’s length. It compares with a number.
  • No function calls, no arithmetic, no paths into other documents.

engine-dmo check type-checks every expression once all schema files are read:

MessageCause
unknown field "x" (with a suggestion)The name is not a sibling.
"tlak" is not an option of kind (with the options listed)A select compared with a value it does not offer.
price is a number and is compared with a number, not "ten"The literal does not suit the field: a number needs a number, a boolean true or false, a text, slug, phone, date or datetime a quoted string.
summary|length compares with a numberA length compared with something other than a number.
heroImage is a asset field and can only be tested with == empty or != emptyStructured fields (asset, link, video, geo, geo_shape, address, opening_hours, recurrence, seo, portable_text, repeater, blocks) are only tested for emptiness.
x (number) is compared with y (text), which never matchTwo fields of different kinds.
empty only works with == and !=, unterminated string, expected a list after inSyntax.

Comparisons between two values are numeric when both are numbers and textual otherwise. Two date fields compare correctly as text (YYYY-MM-DD). A datetime is a structured value, so compare datetime fields only with == empty or != empty, and use date fields where a rule must order two moments.

Validation rules

Rules check several fields together. They sit in the type’s validation: list:

validation:
  - rule: "endDate == empty or endDate >= startDate"
    message: { en-GB: The end date must be on or after the start date. }
    fields: [endDate]

  - rule: "isAccessible == false or accessibility != empty"
    message: { en-GB: Describe the surfaces and gradients before marking the walk accessible. }
    fields: [accessibility]
    on: publish

  - rule: "summary == empty or summary|length >= 60"
    message: { en-GB: A summary under 60 characters reads poorly in search results. }
    fields: [summary]
    severity: warning
KeyTypeDefaultWhat it does
ruleexpressionrequiredWhat must be true. A rule that is false produces its message.
messagelocale map or stringWhat the editor reads, in the locale being saved (else the source locale’s).
fieldslist of field namesthe fields the rule readsWhere the message appears. Each must be a field of the type.
severityerror or warningerrorAn error stops the save (or the publish, with on: publish); a warning is advice.
onsave or publishevery save and publishpublish runs the rule only when publishing, so a draft can be incomplete.

Rules read the document’s top-level fields. A type with variants runs a rule only in the variants that have every field it names. The code of a rule’s problem is cross_field. Rules may be spread across the files of a type split over several files.

What a problem looks like

Through the content API and the content MCP, each problem is reported with:

PropertyExample
pathstages[2].title, address.postal_code, cta.fields.icon
coderequired, max_length, cross_field, money_currency_fixed
severityerror or warning
messageLocalised text, such as Must be 280 characters or fewer

Each field type’s codes are on its page under Fields.

Checking without saving

engine-dmo content create --type walk --data '{...}' validates as the admin’s save does and refuses on an error; add --publish to run the publish checks too. Both need a database. The content MCP can report a document’s publish checks without publishing (Content MCP).