Meridian handbook
Content modelling

Localisation

How fields are translated: the translate modes (none, locale, language, group), the shared skeleton and per-locale leaves of repeaters, blocks, links and assets, structure per_locale, and field fallback on the public site.

A document exists once and holds a version of its content per locale. The schema decides, field by field, what is translated and what is shared, so a translator never retypes a phone number and an editor never rebuilds a page body eleven times.

Locales, the source locale, fallback chains and the locales each site enables are configured in config/locales.yaml and each site’s site.yaml (Sites, locales and routes).

Translate modes

translate: sets how many values a field has across locales:

ModeOne value perTypical fields
nonedocumentCoordinates, relations, numbers, dates, opening hours, prices, select choices
localelocaleTitles, prose, summaries, slugs, SEO copy, link text, alt text
languagelanguage, ignoring regionCopy shared by en-GB and en-IE, by fr-FR and fr-CA
grouplocale groupA market-level fact: a regional phone line, a disclaimer per market

Each field type has a default: text, slug, portable_text, asset, link, video, seo, repeater and blocks are locale; every other type is none (see the table on Fields). translatable: true and translatable: false are shorthand for locale and none.

fields:
  - { name: title, type: text }                                  # locale, the default
  - { name: phone, type: phone }                                 # none, the default
  - { name: legalNotice, type: portable_text, translate: language }
  - { name: helpline, type: phone, translate: group }

engine-dmo check warns when a field whose type defaults to none is given another mode (a number field is rarely translated), and when a rich text field is given translate: none (cannot be translated at all).

Language takes the language subtag of the locale code: en-GB and en-IE share the en value.

Group takes the locale’s group from config/locales.yaml. A locale may be in one group only:

# config/locales.yaml
locales:
  available:
    - { code: en-GB, label: English (UK), source: true, default: true, timezone: Europe/London, currency: GBP }
    - { code: en-IE, label: English (Ireland), timezone: Europe/Dublin, currency: EUR }
    - { code: fr-FR, label: Français, timezone: Europe/Paris, currency: EUR }
  groups:
    - { key: uk-ie, locales: [en-GB, en-IE] }

Values in none, language and group modes are held in the document’s shared revision chain, so they change in every locale at once and an editor can change them from any locale.

The shared skeleton and per-locale leaves

A repeater, a blocks field, a link, an asset and a video are split in two:

  • The skeleton is shared by every locale: which items exist, in what order, of which block type, and their untranslated values (a relation, a number, an image choice, a link’s target, a video’s id).
  • The leaves are each locale’s own: the translated values inside each item (headings, text, captions, alt text, link text, a video’s title and transcript), keyed by the item’s _key.

Here is one walk in two locales, with a repeater of stages and a hero image:

flowchart TB
  subgraph shared["Skeleton, shared by every locale"]
    sk1["stages: s1, s2 in this order"]
    sk2["heroImage: asset red-kite.jpg<br/>with its focal point"]
    sk3["difficulty: moderate<br/>(translate: none)"]
  end
  subgraph en["en leaves"]
    en1["s1.title, s2.title"]
    en2["heroImage alt, caption"]
  end
  subgraph cy["cy leaves"]
    cy1["s1.title, s2.title"]
    cy2["heroImage alt, caption"]
  end
  shared --> enpage["the en page"]
  en --> enpage
  shared --> cypage["the cy page"]
  cy --> cypage

So adding a block in the source locale adds it to every locale’s draft at once, reordering reorders everywhere, and removing removes everywhere. A translator fills in the leaves; they never rebuild the structure. The skeleton and each locale’s leaves have their own revision history.

fields:
  - name: body
    type: blocks
    blocks:
      - key: imageBanner
        fields:
          - { name: image, type: asset }     # the image is skeleton; its alt and caption are leaves
          - { name: caption, type: text }    # a leaf
      - key: listingGrid
        fields:
          - { name: heading, type: text }    # a leaf
          - { name: listings, type: relation, to: [listing], multiple: true }   # skeleton

engine-dmo describe type shows a link, asset or video field as translate locale, its default mode. That means the field has per-locale leaves, not that each locale picks its own target:

FieldShared by every locale (skeleton)Each locale’s own (leaves)
assetthe chosen asset, its focal point, hotspot and cropalt, caption
linkthe kind, the target, new tab, rel, the suffix, the styletext, title, aria_label
videothe source, its id, the start, the poster, captions confirmation, durationtitle, transcript

A value written in any locale carries both parts, and its skeleton part replaces the shared one:

  • A locale that writes a different asset (or link target, or video) does not get its own: every locale shows the new one. A seed file whose cy block gives heroImage a different asset changes the English page too.
  • A locale that writes only the leaves, heroImage: { alt: Llun }, writes an empty asset into the skeleton, and the document then fails to publish in every locale (Choose a file from the library). A locale that writes the field at all repeats the same asset: heroImage: { asset: red-kite, alt: Barcud coch }.

For images the simplest rule is to put alt text and captions in the asset’s own library record, per locale (a seed asset’s alt: { en: ..., cy: ... }), and write only heroImage: { asset: red-kite } in the source locale. Every locale then shows the library’s text in its language. In a seed file a locale that does not write the field starts from the source locale’s value, per-use alt included, so an English per-use alt would also show on the Welsh page. To let each locale choose its own image, declare the field with structure: per_locale, below.

structure: per_locale

fields:
  - name: marketNotes
    type: repeater
    structure: per_locale
    fields:
      - { name: note, type: text }

structure: per_locale makes the whole value each locale’s own: each locale has its own list of items, in its own order. Use it where the content genuinely differs per market. On a link, asset or video it makes the whole choice each locale’s (a different video per language). It contradicts translate: none, which check warns about.

A single document can hold a field the other way from its schema, for one market-specific page, through an audited per-document override:

engine-dmo content structure --id <document id> --field body --mode per_locale --reason "Japanese edition has its own programme"

--mode shared returns it. The override applies to the working revisions and takes effect at the next publish.

Rich text is whole

A portable_text field is translated whole: each locale holds its complete value, because a translator must be free to merge and split sentences. It has no skeleton.

What the public site shows when a translation is missing

fallback: on a field sets what a locale with no value of its own shows on the public site:

fallbackA plain field with no value in this localeA skeleton item whose leaves are untranslated
empty (the default)Nothing: the field is left out.The item is left out.
inheritThe source locale’s value.The item is shown with the source locale’s leaves.
fields:
  - name: items
    type: repeater
    fallback: inherit      # show every item, in the source language where untranslated
    fields:
      - { name: document, type: relation, to: [listing] }
      - { name: note, type: text }

An item with no translatable leaves (only skeleton values) is never “untranslated”. The admin and previews always show every item and mark what is untranslated, whatever fallback says. The core guide type’s items uses fallback: inherit, so a translated guide shows every item with the source note until its own is written.

Field fallback is separate from the locale fallback chain, which decides which locale’s published document a relation, a link or a navigation item shows when the target is not published in the request locale (Relations).

Slugs and the home page

slug is translated by default, so each locale has its own address: /walks/cherwell-valley and /cy/teithiau/taith-dyffryn-cherwell. The home page is the exception. When a site’s routes.yaml names it by slug (home: { type: page, slug: home }), a document is the home page in a locale only where its slug in that locale is home. Give the home document the slug home in every locale; a Welsh slug such as hafan makes it an ordinary page at /cy/hafan, and /cy/ answers 404. A home page that is a singleton (home: { type: homepage }) is the home page whatever its slug.

Required in which locales

required: true is checked when publishing in the source locale only, unless required_in says otherwise (all, or a list of locale codes). See Validation.

Labels

Every label, help text, option label, block label and validation message in the schema may be a locale map, and engine-dmo check reports the enabled locales that have none (Content types). These are what editors and, for option labels, visitors read.

AI drafting

When an AI provider is configured, an editor can ask for a draft translation. machine_translate: forbid on a field stops drafting for that field in every locale; a locale’s own machine_translation: forbid in config/locales.yaml stops it for every field in that locale.

Changing a field’s mode later

Changing translate: on a field documents already hold moves values between chains and is a destructive change in the schema plan (Schema changes). Choose deliberately at the start.

Tools

CommandWhat it shows
engine-dmo locale listCodes, the source, groups, fallbacks and coverage.
engine-dmo locale coverage --site <key>Translation coverage by type and locale, as data.
engine-dmo locale staleWhat has drifted in a locale since its source changed, by document and field.
engine-dmo locale add <code>Seeds drafts of a new locale for the chosen types and creates a translation job. --dry-run counts first.

All need a database.