Skip to content

WordPress Import ​

npm version

@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 ​

bash
pnpm add @contentrain/wp-import

The 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.

RungHow it reads the siteField coverage
rest_publicThe public REST API, no credentials~34% (measured)
rest_authREST with an Application Password~57% (measured)
wxrA WXR export file~82% (measured)
bridgeThe WordPress Bridge pluginAll 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 ​

ts
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 ​

GuaranteeWhy it matters
Provenance on every documentrest_public / rest_auth / wxr / bridge — you can always tell how complete an import could have been
Unresolved references are marked, never droppedresolved 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 formulashexId('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 datesGMT columns preferred, stamped ISO 8601 Z
EntrySourceMap produced where it can be knownThe 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_translations bookkeeping 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 ​

WordPressEntry metadata
publishpublished
futurepublished + publish_at (see below)
pendingin_review
draftdraft
privatedraft, with visibility: private
comment approved / holdpublished / 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 by wp_id in their own model, never through the post map.
  • updated and moved records list the fields that differ from the incoming export. A changed address becomes a 301 redirect.
  • deleted records become tombstones, and trashed (can come back) is kept apart from purged (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_by and updated_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            MIT
  • 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, CapabilityManifest and the rest of the migration contracts