Meridian handbook
Integrating

Migration

How to move an existing site into Meridian without losing content, media or search rankings: the engine's three imports (media manifests, content files with the HTML path, redirect CSVs) and their formats, the meridian-migrate toolkit for WordPress and Sanity exports, crawling the old site and matching its addresses, and a rehearsed cutover runbook.

A migration moves a live site’s content, media and addresses into a Meridian install. The engine imports three kinds of file, each with a dry run, each safe to run again:

FileImported withHolds
A media manifest (meridian.media.v1)engine-dmo media importFiles, with the ids they are to have, alt text, captions, framing and rights
A content file (meridian.content.v1)engine-dmo content importDocuments and their locales’ values; rich text as Portable Text or as HTML
A redirect CSVengine-dmo redirects importThe old addresses and where each now goes

You can write these files with any tool. For WordPress and Sanity, the migration toolkit (meridian-migrate) writes them from the platform’s own export through a mapping file, and maps the old site’s addresses to the new documents. Organisations, people and memberships (on engine-dmo) come in through engine-dmo crm import, described in CRM.

engine-cms behaves the same for content, media and redirects.

The order

  1. Configure the project’s types, routes and locales, and apply them: engine-dmo schema apply.
  2. Import the media, so content can refer to it by id.
  3. Import the content, in an order where a document comes after those it refers to; forward references in a second pass.
  4. Review the drafts, then publish.
  5. Import the redirects, once the documents they point at exist.

Every import writes through the same validation, permissions and audit as the admin, and nothing is published by an import.

Media manifests

{
  "format": "meridian.media.v1",
  "assets": [
    {
      "id": "6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e",
      "file": "files/6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e.jpg",
      "filename": "harbour-at-dawn.jpg",
      "alt": { "en-GB": "Fishing boats in the harbour at dawn" },
      "caption": { "en-GB": "The harbour in October" },
      "focal": { "x": 0.5, "y": 0.4 },
      "rights": { "holder": "Jo Smith", "licence": "CC-BY-4.0", "credit": "Photo: Jo Smith" },
      "source": "https://old.example.org/wp-content/uploads/2024/10/harbour.jpg",
      "tags": ["harbour"],
      "source_id": "attachment:1042"
    }
  ]
}
KeyRequiredMeaning
formatYesmeridian.media.v1.
assets[].idYesThe UUID the asset will have, chosen by you so content can name it first. Unique in the file.
assets[].fileYesThe original, relative to the manifest; it may not leave the manifest’s directory.
filenameThe name the library shows; the file’s own name when absent.
alt, captionBy locale.
decorativeAn image with no alt text by intent.
focal{x, y}, each 0 to 1.
hotspot, crop{x, y, w, h} in fractions of the whole image from its top left; a rectangle outside the image is refused.
rightsholder, licence, uses, territory, from and until (YYYY-MM-DD), credit, source.
rights_reviewWhy the asset needs its rights established; counted in the report.
sourceWhere the original came from, recorded as the rights source when the record names none.
tagsTag keys of the vocabulary; a key it does not hold is left out.
source_idThe asset’s id in the old system, for the report.

A manifest holds at most 20,000 assets, and unknown keys are refused.

engine-dmo media import --manifest out/media/manifest.json --dry-run --project .
engine-dmo media import --manifest out/media/manifest.json --project .

Each file goes through the media pipeline as an upload does: its type sniffed, its size checked, its metadata (camera location included) stripped. An id the library already holds is reported exists and left alone, so a rehearsal never undoes work done in the library since. The report counts the assets without a licence; the library’s No rights recorded filter is the queue to work through. See Media.

Content files

{
  "format": "meridian.content.v1",
  "site": "main",
  "type": "article",
  "documents": [
    {
      "id": "1b4e28ba-2fa1-51d2-883f-0016d3cca427",
      "locales": {
        "en-GB": {
          "values": {
            "title": "The harbour in winter",
            "slug": "harbour-in-winter",
            "summary": "What to see when the boats are in.",
            "publishedAt": "2024-12-02T09:00:00Z",
            "body": { "html": "<p>The boats come in at dusk.</p><img src=\"asset:6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e\" alt=\"Boats at dusk\">" }
          }
        }
      }
    }
  ]
}
KeyMeaning
formatmeridian.content.v1.
siteThe site the file was written for.
typeThe type of every document that names none of its own.
documents[].idThe document’s UUID.
documents[].typeIts type, overriding the file’s.
documents[].variantIts entry variant, where the type declares variants.
documents[].localesBy locale code: {values}, each value by field name in the shape the admin saves. status (written by an export) is ignored.

At most 2,000 documents a file. engine-dmo content export writes the same shape, so an export is the best template for your own files.

engine-dmo content import --site main --file out/content/0001.json --dry-run --project .
engine-dmo content import --site main --file out/content/0001.json --project .
FlagMeaning
--fileThe file; - reads standard input.
--siteThe site; the default site when absent.
--matchid (the default): a document is matched by its id, which is then required, and created with that id when new. slug: matched by its slug, and created with a new id when none matches.
--dry-runSay what would happen to each locale and write nothing.

Each locale of each document is create, update, unchanged or reject, with a reason for a rejection: an unknown type, a type not on the site, a locale the site does not enable, a missing or malformed id, an id that belongs to another type or site or is in the trash, field validation errors, or a permission the person running it lacks. Values are validated as a save in the admin is, and a slug another document already holds is a validation error. An import never publishes; a second run of the same file reports every locale unchanged.

Rich text as HTML

A portable_text field’s value may be {"html": "..."} instead of Portable Text blocks. The import converts it with the same converter the editor uses for pasted HTML, then normalises the blocks to what the field allows (its styles, lists and marks); every adjustment is a note on the row, so nothing changes silently.

  • An <img> whose src is asset:<uuid> becomes the field’s image block pointing at that library asset, with the HTML’s alt text. Import the media first and write the ids into the HTML.
  • Any other <img> is left out and counted in a note. The import never fetches a file from an address in an import file.
  • Only top-level portable_text fields convert; a rich text field inside a blocks or repeater field must be given as Portable Text.

Publishing what you imported

engine-dmo content publish --ids-from out/ids.txt --all-locales --project .

--ids-from reads one document id a line. --all-locales publishes every locale with a working revision, the source locale first, through the publish checks; a locale whose checks fail is skipped and named.

Redirect CSVs

source,target,status,locale,note
/2019/05/harbour-walk,document:1b4e28ba-2fa1-51d2-883f-0016d3cca427,301,,from the old blog
/visit-us,/visit,301,,
/old-shop,,410,,the shop closed
ColumnMeaning
sourceThe old path, without the site’s locale prefix.
targetA path on the site, an absolute URL, document:<uuid> (the rule follows the document if its address changes), or empty with status 410.
status301 (the default), 302, 307, 308 or 410.
localeThe locale the rule applies to, or empty for every locale.
noteFree text, kept with the rule.

Other columns are ignored.

engine-dmo redirects import --site main --file redirects.csv --dry-run --project .
engine-dmo redirects import --site main --file redirects.csv --project .
engine-dmo redirects list --site main --origin imported --project .
engine-dmo redirects rollback --site main --batch <batch-id> --project .

Every row is checked: the source and status, the locale, a duplicate in the file or in the site’s rules, that a target document exists, that a target path is one the site answers, and loops and chains through every rule the site has. Any error refuses the whole import unless --skip-invalid. A document target not yet published is a warning (the rule serves once it is), and a row whose document is already published at that address is unneeded. The import is one transaction with origin imported and a batch id, which rollback --batch disables. The import writes exact rules only; prefix and wildcard rules belong in routes.yaml. See Redirects and URLs.

The migration toolkit

A separate program

meridian-migrate is a command of its own, not part of the engine binary or the container images. It runs on a workstation beside the export, never connects to the engine’s database, and writes the files above. It imports nothing of the engine, so its version need not match the engine’s. A release does not publish a build of it: today it is built from the Meridian source repository, so ask Cherwell Studio for it. Without it, write the three import formats above with any tool; they are the engine’s contract and need only the binary.
meridian-migrate run --mapping mapping.yaml --source <export> --out out [--dry-run] [--uploads dir]
meridian-migrate crawl --start https://old.example.org --out crawl.json [--delay 1s] [--max 10000]
meridian-migrate redirects --crawl crawl.json --urls out/urls.json --out redirects.csv --review redirects-review.csv

Every id it writes is a UUID derived from the mapping’s namespace and the source’s own id, so a second run writes the same files, a second media import finds every asset present, and a second content import reports every locale unchanged. A rehearsal can run any number of times against the same staging database.

The mapping file

format: meridian.migrate.v1
source: wordpress              # or sanity
namespace: example-org-2026    # seeds every id; keep it for every rehearsal and the cutover
site: main                     # the Meridian site the import files name
locales:
  default: en-GB               # a record with no locale, and the first locale a document is created in
  map: { en: en-GB, fr: fr-FR } # source code to Meridian code; a code not named is rejected
media:                         # dotted paths into each asset's metadata
  credit: creditLine
  holder: source.name
  licence: license
  source_url: source.url
  review_tag: rights-review    # optional: a tag key for every asset without a licence
types:
  - from: post                 # the source type: a WordPress post_type or taxonomy, a Sanity _type
    to: article                # the Meridian type
    statuses: [publish]        # the states imported; empty imports the published state only
    dedupe: [slug]             # slug, title or both: later duplicates are rejected
    fields:                    # a field of the Meridian type: a source field (dotted path), or { from, as }
      title: title
      slug: slug
      summary: excerpt
      publishedAt: { from: date, as: value }
      hero: { from: featured_image, as: asset }
      topics: { from: categories, as: reference }
      body: { from: content, as: html }
  - from: category
    to: topic
    fields:
      title: title
      slug: slug

The whole file is checked before anything runs, and every problem is listed together.

KeyMeaning
formatmeridian.migrate.v1.
sourcewordpress or sanity.
namespaceRequired; seeds every derived id. Never change it between rehearsals.
siteThe site key the import files name.
locales.default, locales.mapAs above. With an empty map, a source code that is already a locale code is kept.
i18n.document_field, i18n.metadata_typeSanity’s document-level internationalisation: the field holding the language (language is the plugin’s default) and the type of the documents linking translations (translation.metadata).
media.*Dotted paths into each asset’s metadata for its rights record, and an optional review tag.
types[].from, types[].toThe source type and the Meridian type. Each source type is mapped once.
types[].urlThe old address pattern with {slug} and {locale}, for a source that does not record addresses (Sanity).
types[].statusesThe source states imported; others are rejected with the reason.
types[].dedupeslug, title or both. A later duplicate is rejected, and references to it follow the first.
types[].fieldsMeridian field handle to a source field. as is text (the default: a string, a slug’s current, a number), portable_text (Portable Text passed through and mapped, or HTML sent down the HTML path), html (always the HTML path), asset (an image object or a WordPress featured image, one or a list), reference (to the document the source id became) or value (any JSON, with references and assets inside it resolved).
types[].styles, types[].decoratorsRename Portable Text block styles and decorators; a decorator mapped to "" is dropped.
types[].annotationsPortable Text mark definitions by source _type: link or drop. A mark that holds a reference becomes a document link.
types[].blocksPortable Text object blocks by source _type: {to, asset_field, fields} (fields is Meridian field to source field), or drop.

What a run writes

FileWhat it is
content/0001.json, 0002.json, …Content files, at most 2,000 documents each, ordered so a document comes after those it refers to
content/NNNN-references.jsonReferences that would point forward (a cycle, or a document naming itself), set as updates once every document exists. Import it last.
media/manifest.json, media/files/The media manifest and each original under its Meridian id
urls.jsonEvery imported document’s old addresses per locale, with its id, title and slug: the input for address matching
ids.txtEvery document id in import order, for content publish --ids-from
rejects.csvlevel, source_id, type, reason: each record or asset left out (reject) or kept with something lost (warning), rejects first
summary.jsonCounts: records, documents and locales by type, duplicates, assets, duplicate files, rights review, deferred references, addresses

--dry-run writes only rejects.csv and summary.json and downloads nothing. Start every migration with it, and treat each reject as a decision: map the type, change a status, or accept the loss.

Records of one translation group become the locales of one document, the default locale first. Within a Meridian type, a document whose first locale has the same slug (or normalised title) as an earlier one is rejected as a duplicate. Media are deduplicated by content: two files with the same SHA-256 import once.

WordPress

source: wordpress reads a WXR export (Tools, Export, All content), versions 1.0 to 1.2.

  • Posts, pages and custom post types are records of their post_type, with title, slug, content (HTML), excerpt, author (display name, as text), date and modified (UTC), parent, featured_image, categories, tags, each custom taxonomy by its name, and meta (post meta without a leading underscore). WordPress’s own types (menu items, revisions, templates) are skipped.
  • Categories, tags and terms are records of type category, post_tag or the taxonomy, with title, slug, description and parent. An unmapped type is rejected, which is a decision: map it, or leave it.
  • Attachments are assets, with alt text from _wp_attachment_image_alt and the caption from the excerpt. Originals are read from --uploads (a copy of wp-content/uploads) when given, else downloaded politely (one request at a time, --delay apart, default 1s, with --user-agent; add a contact address to it).
  • HTML is cleaned for the engine’s HTML path: classic editor paragraphs restored, [caption] turned into the image and a paragraph, [embed] into a link, any other shortcode reported once per post and left as text. Each <img> of an imported attachment, including a resized copy (photo-300x200.jpg is photo.jpg), is rewritten to asset:<id>; any other image is reported.
  • Multilingual plugins (WPML, Polylang) are not read: every record takes the default locale.

Sanity

source: sanity reads the output of sanity dataset export, the .tar.gz or its unpacked directory (data.ndjson, assets.json, images/, files/).

  • _id is the source id (a drafts. prefix removed) and _type the source type; system documents are skipped. A draft of a published document is ignored in favour of the published version; a draft with no published version has status draft.
  • slug.current is read where a text or slug field names a slug object; {_ref} anywhere is a reference by source id.
  • Both asset forms are read, with originalFilename, altText, description (or title) as the caption, creditLine, source {name, id, url} and any custom metadata the mapping’s media paths name. The CDN address is recorded, never fetched.
  • Portable Text passes through with its keys, styles, lists and marks; what is Sanity’s is mapped by the mapping, and anything it does not name is left out with a warning.
  • Document-level internationalisation (@sanity/document-internationalization) and the two common field-level shapes (sanity-plugin-internationalized-array, and locale objects such as localeString) become locales.

Crawling the old site and matching addresses

meridian-migrate crawl --start https://old.example.org --out crawl.json --user-agent "Example migration; ops@example.org"
meridian-migrate redirects --crawl crawl.json --urls out/urls.json --out redirects.csv --review redirects-review.csv

The crawl keeps to robots.txt (its Crawl-delay wins when longer than --delay), reads the sitemaps it names, /sitemap.xml and any --sitemap, and follows same-host links one request at a time: no nofollow links, no files, fragments and tracking parameters removed, query strings dropped unless --keep-query. It records each page’s status, title and canonical address, and each redirect with its target. --max (default 10,000) stops it and says so.

redirects maps every old address (the source’s own from urls.json, and each crawled page that answered 200) to a document:

  • an address the source recorded for a document matches it outright, with confidence 1;
  • any other is scored against every document of its locale by the similarity of its last path segment to the slug and of its page title to the title;
  • two candidates within 0.05 of each other always go to review;
  • the home page is left alone.

A match at or above --threshold (default 0.8) goes to redirects.csv with a document:<id> target and a confidence column; the rest go to the review file with the best candidate, the others and an empty decision column for a person to fill: redirect (copy the row into the redirects file), recreate, or 410. --locale-prefix /fr=fr-FR (repeatable) removes an old locale prefix from the source and sets the locale column.

The runbook

Do every step against staging first, and rehearse the whole cutover at least twice.

Export and assess. Take the export (sanity dataset export production export.tar.gz; or WordPress’s All content export with a copy of wp-content/uploads). Record the counts by type.

Write the mapping against the project’s schema, already applied. Choose the namespace once.

Dry run the toolkit and work through rejects.csv until it holds only what is meant to be left. Then run it for real.

Crawl the live site, record the ranking baseline of its top pages, run meridian-migrate redirects, and work through the review file.

Import into staging, dry runs first: the media manifest; each content file in order, the references file last; on engine-dmo, the CRM; review the drafts in the admin; content publish --ids-from out/ids.txt --all-locales; then the redirects until the dry run is clean.

Work the rights queue (the library’s No rights recorded filter) until every image in published content has a rights record.

Rehearse again from a fresh export and a reset staging database, timing each step. Because ids derive from source ids, every rehearsal and the cutover write the same ids.

Cut over. Freeze editing at the source, take the final export, run the toolkit and the imports against production, check a sample of old addresses answer 301 to the right page (engine-dmo redirects list --origin imported), switch DNS, then watch the 404 report daily for two weeks and compare rankings after 30 days.

Before DNS moves, a rollback is a database restore; a redirect batch alone is engine-dmo redirects rollback --batch <id>.

Not handled

  • Links inside migrated rich text that point at the old site stay as addresses; the redirect set serves them.
  • Old media addresses (/wp-content/uploads/..., the Sanity CDN) get no redirects to the imported assets.
  • Multilingual WordPress (WPML, Polylang).
  • Sources other than WordPress and Sanity need their files written by your own tool, in the formats above.