@contentrain/wp-import
v0.10.0
Published
WordPress importers for Contentrain — WXR and REST to RawIR, RawIR to a .contentrain content store
Maintainers
Readme
@contentrain/wp-import
WordPress importers for the Contentrain migration pipeline — ported from a corpus-measured import chain, not written fresh: the identity formulas, PHP-serialized meta decoding, and reference-resolution passes carried over intact.
WXR file or REST API ──► RawIR (source-faithful, provenance-stamped)
RawIR ──► .contentrain content store (pure file map) + EntrySourceMap
RawIR + EntrySourceMap ──► CommentsExport (live-service intake payload)
SourceDeltaPlan + store (+ incoming export) ──► placed SourceDeltaPlanUsage
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 Application Password → rest_auth).
// With a credential, drafts, scheduled, pending and private posts and held
// comments are listed too; anonymous REST sees only what is published.
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 } = rawToContentrain(raw)
// Comments intake payload for a live comments service
const commentsExport = buildCommentsExport(raw, entry_source_map)Guarantees
- Provenance on every document — which access rung produced it (
rest_public/rest_auth/wxr/bridge); absence at a low rung is information, not an error. - Unresolved references are marked, never dropped (
resolvedflags, ghost terms joining the pool, dropped-relation counts in the report). - Shared identity formulas —
hexId('posts:' + slug)— so WXR and REST imports of the same site agree on every entry id (delta imports depend on this). - UTC dates — GMT columns preferred, stamped ISO 8601
Z. - Pure conversion —
rawToContentrainreturns a canonical-serialized file map (sorted keys, 2-space indent, trailing newline); writing to disk is the caller's one line. - EntrySourceMap produced at the only place that can know it — the WP-id → entry-address mapping the comments intake requires.
- Only public discussion leaves the site —
buildCommentsExportcarries comments on published, unprotected entries that are approved or pending (pending lands in the receiver's moderation queue). Comments on drafts, private, scheduled or password-protected entries, any other status (spam, trash,post-trashed, a plugin's) and entries the import does not hold, stay behind;excludedcounts them.
Streaming WXR parse (sax): a 100 MB export holds only its records in memory.
Source deltas
A bridge reports what changed at the origin since the last delivery as a SourceDeltaPlan: created, updated, moved and deleted records by WordPress id. planSourceDelta works out what each change means for the store:
import { planSourceDelta, formatSourceDeltaReport } from '@contentrain/wp-import'
const plan = planSourceDelta({
delta, // the bridge's SourceDeltaPlan
store: { files, entry_source_map }, // the repository's .contentrain, as it is now
incoming: { files: next, entry_source_map: nextMap }, // the new export
})
console.log(formatSourceDeltaReport(plan))- Placement. A post-type record is looked up in the
EntrySourceMap. A term or media record is found by itswp_idin its own model (wp-tax-<taxonomy>/categories/tags,wp-media/media). The map lists post ids only, and post 6 and category 6 are different records. A record that cannot be placed getsunmappedwith a reason:not-in-source-map,no-model-for-typeorentry-not-found. updated/movedlistfields_changed, comparing the stored entry with the incoming one (frontmatter keys andbodyfor a document). Amovedrecord whose address changed adds a 301 toredirects. If the store derives entry ids from slugs, the new id is inentry_id_after.deletedis a tombstone.trashed(can come back) andpurged(gone) stay distinct.- Conflicts. If the entry's meta says something other than the importer wrote it last (
sourceis notimport, orupdated_byis not an importer), the record getsconflict: trueandrepo_edit: { updated_by, updated_at?, source }. Nothing is overwritten, and a person decides.
The planner is pure and plan-only. Applying a plan is a governed write, and it goes through review. Pass taxonomies for custom taxonomies the store has no model of its own for; otherwise their term ids would be read as post ids.
Multilingual sites
A post's language is read from the plugin — 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
(Polylang translations / post_translations, WPML wpml_translations), one
pair per group.
rawToContentrain turns more than one locale into an i18n store: post-type
models get i18n: true, content and meta are written per locale
(content/{domain}/{model}/{locale}.json), and 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 id
and its own locale. Locales are primary subtags (tr_TR → tr). Taxonomies
stay per-store; 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).
Few sites are translated evenly. A post type whose content exists in some of the
site's languages gets model.locales — the locales it actually has posts in,
with the default locale always included, since a post that carries no language
tag belongs to it and its absence would be the importer's uncertainty rather
than a fact about the site. A type translated into every locale gets no
locales key at all: absent already means "all", and a redundant list would
have to be maintained as locales are added. contentrain validate checks
parity against that subset, so pages that only ever existed in one language
stop failing as missing translations. See
Locale Coverage.
REST limits and completeness
fetchRestRawIR accepts concurrency (default 4), maxPages (optional cap per
collection), and perPage (1–100, default 100). Limits must be positive safe
integers. The concurrency slot includes response body consumption. Truncated
collections and failed pages are named in warnings; callers must inspect
these before claiming a complete migration. ACF field/group records and their
cross-model parents remain in the content store; route discovery decides which
records become public pages. Importing configuration does not execute a plugin.
Site name over REST
fetchRestRawIR reads the /wp-json/ index for the site's name and tagline
(RawIR.site.title / description, the store's site singleton) and its
install and public addresses (base_site_url / base_blog_url). An index that
does not answer leaves them out.
Menus over REST
With an Application Password, fetchRestRawIR reads the site's menus into
RawIR.menus: classic menus (/wp/v2/menus + /wp/v2/menu-items, with the
theme locations each is assigned to) and a block theme's published
wp_navigation posts (links, submenus, page lists, home links). A block menu's
locations are the template-part areas (header, footer) whose navigation
block refers to it; an empty navigation block without a ref shows the most
recent published one, as WordPress does. Block menu items have no WordPress id
and get negative ids; the store claims no wp_id for them.
A navigation block in a template part that carries its own links (an inline
navigation, as in Twenty Twenty-Five's footer columns) is a menu of that part's
area: Footer navigation 1, 2, … (its ariaLabel names it when set), links
in order and nested. Only template parts a template uses count — a theme ships
alternatives (footer-columns, header-large-title) that no page shows; without
the templates, the part named after its area (header, footer). A # link
stays #. Inline menus have no WordPress record: negative ids, no wp_id.
A menu item that is itself a draft, or whose post target is not proven public
— a draft, pending, private, scheduled or password-protected post, or one this
import never read (a type outside REST, a listing past a page cap) — is left
out, fail-closed: its label is often that post's title. Its children move up to the
nearest kept ancestor; warnings gives only the count.
Both need edit_theme_options. Without a credential, with a rejected one, or
with a user who lacks that right, no menus are read and the result's gaps
contains menus_require_auth — an empty menu list is never presented as the
site having none. The menus model gains a locations field only when the
source named them.
A menu item that names a public post or term links to that post's or term's
public address (its REST link). The address the item stored is not used: a
block keeps the URL it had when it was saved, and a hand-typed ?page_id= is a
form the migrated site cannot serve. A hand-typed ?page_id= / ?p= counts
as this site's with or without a leading www..
Redirects over REST
REST reads one source of redirects: the Redirection plugin (redirection/v1).
This needs an application password of a user who may manage the plugin. Its
rules are shaped as the Bridge shapes them. Each rule lands in exactly one of
two places:
redirects: the rules the site serves, with the plugin's query, case and trailing-slash modes. A "gone" rule (410, 451) is served with an emptyto.redirects_excluded: every other rule, with its reason (disabled, conditional, a 404 or pass-through action).
Yoast Premium, Rank Math, Safe Redirect Manager and .htaccess keep their
rules where only the Bridge export reads them. So gaps always contains
redirects_partial. It also contains redirects_require_auth when the site
runs Redirection but its rules could not be read with the credential given.
A conditional rule (login, cookie, header, IP, role…) is excluded. Only a login
rule keeps its condition, which is its two addresses. Any other condition
holds the value a visitor must present, often a secret, so it is never
exported; the reason names its type.
Each attachment carries its attachment page as link, so the migrated site
can redirect it. The link is kept only when the attachment has no parent or
its parent is proven public, because the page sits under the parent's address
and would otherwise carry a draft's, a private post's or a scheduled post's
slug.
ACF fields, custom post types and taxonomies over REST
ACF / Secure Custom Fields values arrive on a post's acf key — only for field
groups set to "Show in REST API" (off by default). Each field is typed by one
versioned table (ACF_MAPPING_VERSION, exported with ACF_SCALAR_TYPES and
ACF_REFERENCE_TYPES): the ACF type is read from SCF's <name>_source.type
where the site states it, and inferred from the value's shape only when nothing
states it (the field description then says so).
| ACF | Contentrain |
| --- | --- |
| text, time picker | string |
| textarea | text |
| wysiwyg | richtext |
| email / url, oembed / number, range / true false | email / url / number / boolean |
| date picker / date time picker | date / datetime (ISO, with the site's offset) |
| color picker / icon picker | color / icon |
| select, radio, button group | select (its choices) |
| checkbox, multiple select | array of select |
| image / file | image / file (the URL) |
| link | object { url, title, target } |
| google map | object { address, lat, lng, zoom } |
| group | object of its fields |
| repeater | array of object (nested repeaters nest) |
| flexible content | array of object: a required layout select plus the union of the layouts' fields |
| post object, relationship | relation / relations to the entries' models |
| taxonomy / user / gallery | relations to the term model / authors / media |
| page link | url: the target's address, only when the target is published and unprotected |
| tab, accordion, message | nothing (layout only) |
| password | nothing, ever |
Sub-fields of a repeater, group or flexible layout are typed by the same table
when the site states their type (SCF's _source inside each row); a reference
inside a row, and a sub-field with no stated type, are typed from the value. A
select or checkbox value outside the field's stated choices is left out and
counted in the report's acf_outside_choices. A date time picker's value is written
with the site's UTC offset for that moment, DST included (the /wp-json/
index's timezone_string, else gmt_offset); with neither it stays local and
acf_datetime_unzoned counts it. src/fixtures/acf-parity.json
holds the cases the Contentrain Bridge must map the same way.
A password field is never read — at any depth: a password sub-field of a
repeater row, a group or a flexible layout is removed with its _source before
the value reaches RawIR. Where the source states the type, the type decides;
where it does not (plain ACF, no <name>_source), a field whose name has a
secret word in it (password, user_pass, apiKey, access_token, …; whole
words, so passage stays) is skipped instead. It never reaches RawIR, the
store, or a report.
ACF values follow their post: a draft's fields land in the store with the
draft (meta status: draft), like its body. A reference to an entry the import
did not read is dropped and counted in dropped_relations. The report's
acf_fields lists each field's type. When any post carries an acf key, the
result's gaps contains acf_partial: groups without REST exposure, options
pages and fields on post types outside REST are not visible to REST — the
Bridge reads those.
Options pages arrive from the Bridge as RawIR.acf_options. Their fields go to
the site entry, typed and resolved like a post's ACF fields. A name that is a
core site field (title, tagline, url, language) or sits on more than
one options page takes the page's slug as a prefix, without ACF's default
acf-options- (acf-options-footer gives footer_phone), so no page overwrites
another. A name that is still taken, say a field literally called footer_phone
on another page, gets a counter (footer_phone_2) rather than being dropped; the
report's acf_options_renamed lists each one with its page and field. The
report's acf_options lists each field written.
With an Application Password, post types are read with context=edit so their
viewable flag is known: a type that is in REST but not publicly queryable (a
testimonial post type, say) keeps its content but gets no address (link is
null), so no route or redirect claims a page that does not exist. Custom
taxonomies in REST are read with their terms, and a post's terms in them become
its relations.
Publication and translation identity
WordPress future posts become status: published metadata with publish_at,
preserving scheduled publication intent. A scheduled post without a valid date
fails import. Public consumers must filter the publication window, not status
alone; use the query generator/loader's public-build mode before deployment.
Previously imported future posts need re-importing or an explicit metadata update.
Translation groups share one entry identity across locales. Distinct groups with the same canonical slug receive distinct deterministic identities; existing non-colliding identities are unchanged. Rebuild the entry map and comments export from the same corrected import. Locale normalization currently uses primary language codes: a group whose regional translations collapse to the same locale is rejected rather than silently overwritten.
