WordPress Import
@contentrain/wp-import reads a WordPress site and produces two things: RawIR — what the source actually said, before anyone interpreted it — and, from that, a .contentrain/ content store.
It was ported from a corpus-measured import chain rather than written fresh. The identity formulas, the PHP-serialized meta decoding, and the reference-resolution passes carried over intact, which is why a WXR import and a REST import of the same site agree on every entry id.
WXR file or REST API ──► RawIR (source-faithful, provenance-stamped)
RawIR ──► .contentrain content store + EntrySourceMap
RawIR + EntrySourceMap ─► CommentsExport (live-service intake payload)Install
pnpm add @contentrain/wp-importThe access ladder
You cannot always reach all of a WordPress site, and pretending otherwise is how migrations lose content silently. Every RawIR records which rung produced it, because two documents for the same site are only comparable when their rungs are.
| Rung | How it reads the site | Field coverage |
|---|---|---|
rest_public | The public REST API, no credentials | ~34% (measured) |
rest_auth | REST with an Application Password | ~57% (measured) |
wxr | A WXR export file | ~82% (measured) |
bridge | The WordPress Bridge plugin | All fields by design — it reads WordPress from inside; not a sampled figure |
The measured figures date from August 2026, before migration contract version 1; a WordPress or plugin release can move them.
The Bridge rung is not in this package
bridge is a provenance value this package understands, not an importer it ships. The Bridge plugin is GPL-licensed and lives in a separate repository — a WordPress plugin cannot be MIT and link WordPress core. This package covers the three rungs you can reach from outside the site.
Absence at a low rung is information, not an error. A field missing from a rest_public import means the public API does not expose it, and the report says so rather than inventing a value.
Usage
import { parseWxr, fetchRestRawIR, rawToContentrain, buildCommentsExport } from '@contentrain/wp-import'
// Highest offline rung: a WXR export file
const { raw, stats } = await parseWxr(createReadStream('export.xml'))
// Or the REST rungs — public, or an Application Password for rest_auth
const { raw: viaRest } = await fetchRestRawIR({
origin: 'https://site.example',
auth: { user, appPassword },
})
// RawIR → .contentrain — pure: returns { files, entry_source_map, report }
const { files, entry_source_map, report } = rawToContentrain(raw)
// Comments intake payload for a live comments service
const commentsExport = buildCommentsExport(raw, entry_source_map)rawToContentrain is a pure function returning a file map — canonical-serialized (sorted keys, 2-space indent, trailing newline), exactly as the rest of Contentrain writes JSON. Putting those bytes on disk is the caller's one line. Nothing in this package touches your filesystem or your git repo.
WXR parsing is streaming (sax): a 100 MB export holds only its records in memory, not the document.
The comments export carries only public discussion: comments on published, unprotected entries that are approved or pending (pending ones land in the receiving service's moderation queue). Comments on drafts, private, scheduled or password-protected entries, any status other than approved or pending (spam, trash, post-trashed, a plugin's), and comments on entries the import does not hold, stay behind; excluded counts them per reason.
What it guarantees
| Guarantee | Why it matters |
|---|---|
| Provenance on every document | rest_public / rest_auth / wxr / bridge — you can always tell how complete an import could have been |
| Unresolved references are marked, never dropped | resolved flags, ghost terms joining the pool, dropped-relation counts in the report. Deciding what a broken reference means is not an importer's call |
| Shared identity formulas | hexId('posts:' + slug) — a WXR import and a REST import of the same site produce the same entry ids, which is what makes delta imports possible |
| UTC dates | GMT columns preferred, stamped ISO 8601 Z |
EntrySourceMap produced where it can be known | The WP-id → entry-address mapping that comments intake requires exists only at conversion time |
Multilingual sites
A post's language is read from the plugin that set it — Polylang's lang (REST) or its language taxonomy term (WXR), WPML's wpml_current_locale — and lands on RawPost.lang verbatim. Translation groups become RawIR.language_pairs, one pair per group, from Polylang's translations / post_translations or WPML's wpml_translations.
More than one locale turns the output into an i18n store:
- post-type models get
i18n: true - content and meta are written per locale —
content/{domain}/{model}/{locale}.json - every translation in a group shares one entry id. The canonical member is the default-locale post, else the lowest id, so
entry_source_map[wp_id]addresses each post by that shared id plus its own locale - locales are primary subtags (
tr_TR→tr) - the
language/post_translationsbookkeeping taxonomies never become models
report.locales and report.translation_groups say what happened. A monolingual site is unchanged: i18n: false, data.json.
Regional locales that collapse
Primary-subtag normalization means en_US and en_GB both become en. A translation group whose regional translations collapse onto the same locale is rejected rather than silently overwritten — one of the two would otherwise disappear with no trace.
REST limits and completeness
fetchRestRawIR takes concurrency (default 4), perPage (1–100, default 100), and an optional maxPages cap per collection. Limits must be positive safe integers. The concurrency slot covers response body consumption, not just the request.
Unpublished content needs a credential. Anonymous REST returns what WordPress shows the public: published posts and approved comments. With an Application Password, fetchRestRawIR also lists future, draft, pending and private posts (in the edit context, so password-protected posts are marked too, and come in as drafts) and held comments. Trash is never imported. If the site refuses the credential those listings, the import falls back to the public one and says so in warnings. A credential the site rejects outright (401 on every route, as WordPress answers a wrong Application Password) is dropped after one check, and the whole import reads the public listings. Act on the result's credential rather than the warning text: { status: 'none' | 'accepted' | 'rejected', fell_back }, where fell_back names each listing that did not use the credential (posts, pages, a custom type's REST base, comments, comments:hold). The WordPress status is kept as-is in RawIR, and becomes entry metadata on conversion.
Truncated collections and failed pages are named in warnings. Inspect them before claiming a complete migration — a capped or partially failed REST import looks exactly like a small site otherwise.
ACF field and group records and their cross-model parents stay in the content store; deciding which records become public pages is route discovery's job, further down the pipeline. Importing configuration does not execute a plugin.
Post status
| WordPress | Entry metadata |
|---|---|
publish | published |
future | published + publish_at (see below) |
pending | in_review |
draft | draft |
private | draft, with visibility: private |
comment approved / hold | published / in_review |
any status with a post password (except trash) | draft, with visibility: password |
Password-protected posts always come in as drafts. Their body is the protected text, and nothing downstream (the query SDK, the Astro emitter) enforces visibility: password, so publishing one would put that text on a public site. import-report.json counts them in password_protected_drafts. The password itself is never read: RawPost.password holds the marker [protected].
Scheduled posts
A WordPress future post becomes status: published metadata carrying publish_at, which preserves the scheduled-publication intent instead of flattening it to a draft. A scheduled post without a valid date fails the import rather than being guessed at.
Publication windows are the consumer's job
status alone does not tell a public build whether a post should be visible — a published entry with a future publish_at must not ship. Use the query generator's public-build mode before deployment. Posts imported before this behaviour existed need re-importing or an explicit metadata update.
Source deltas
After the first delivery, the site keeps changing. A bridge reports each change since the last delivery as a SourceDeltaPlan. planSourceDelta({ delta, store, incoming }) places every record in the repository's store:
- Post-type records are placed through the
EntrySourceMap. Terms and media are placed bywp_idin their own model, never through the post map. updatedandmovedrecords list the fields that differ from the incoming export. A changed address becomes a 301 redirect.deletedrecords become tombstones, andtrashed(can come back) is kept apart frompurged(gone).- A record that cannot be placed says why (
unmapped). - A record the repository also edited since the import (in Studio, by an agent, by hand) is a conflict. It carries the edit's
updated_byandupdated_at, and it is never silently overwritten.
The function only plans. contentrain delta --dry-run prints the same plan from the command line, and applying it goes through review.
Where this sits in the pipeline
This package covers the first leg only. The full chain — and the part of it that is not open source — is laid out in the WordPress Migration guide.
wp-import ──► RawIR ──► [migration engine: ProjectIR] ──► emitter-astro ──► verify
MIT closed source MIT MITRelated Pages
- WordPress Migration — the end-to-end chain, and which parts are open
- Astro Emitter — renders the other end of the pipeline
- Verify — the gates that say whether the migrated site is correct
- Types —
RawIR,EntrySourceMap,CapabilityManifestand the rest of the migration contracts