Free, local-first WordPress bridge: models site content and interface text as Contentrain JSON/Markdown, delivered as a ZIP or to a branch in your own GitHub repository.
- License: GPL-2.0-or-later
- No account or paid service required
- No telemetry, and no network request until you start a GitHub delivery yourself
- Explicit, nonce-protected administrator export
- Comment export off by default and privacy-minimized
- ACF groups and repeaters modelled as real collections rather than one anonymous value bag
- Supported local media copied into
media/and content relinked; missing or oversized files remain source URLs with warnings - Output verified against the published Contentrain toolchain, not only the plugin's own validator (
tests/verify-store.mjs)
Source coverage is incomplete, and the export says so. Every manifest carries
complete_source_coverage: false. This exports the content layer and the interface text it can model; it does not reproduce a WordPress site. Advanced page-builder runtime, widget and theme settings, rendered-state output and source-code reuse are outside what it reads today — see Current coverage boundaries. A store that validates is not a claim that a site has been migrated.
The JSON contract is defined by the MIT-licensed @contentrain/types package in Contentrain/ai; the plugin does not import or embed private Migrate or proprietary Studio code.
Download contentrain-bridge.zip from the
latest release
and install it in Plugins > Add New > Upload Plugin. Updates are manual while
the plugin is distributed outside WordPress.org: upload the new release ZIP the
same way and WordPress replaces the installed version. Each release is the exact
archive CI built and tested (RELEASING.md).
This repository intentionally has no runtime dependency or build step. Run PHP syntax checks with:
find . -name '*.php' -print0 | xargs -0 -n1 php -lThe acceptance suite runs the plugin inside a throwaway WordPress and leaves the finished store on the host, which the external verification then reads with the published Contentrain packages:
tests/run.sh # docker compose, integration checks, writes tests/.out/store
node tests/verify-store.mjs # contentrain validate + canonical byte parityPoint CONTENTRAIN_TYPES at a local @contentrain/types build to verify
against an unreleased serializer.
Before a WordPress.org submission, run the official Plugin Check plugin and validate readme.txt against the current directory rules.
Run npm ci --ignore-scripts to install the pinned development toolchain. Node is
not needed on the WordPress server. npm run test:wordpress generates the fixture,
npm run test:store runs the pinned Contentrain CLI and canonical serializer, and
npm test verifies the handoff and tamper rejection. ACF installation is required;
a missing dependency fails acceptance instead of skipping its assertions.
npm run package writes dist/contentrain-bridge.zip. Development dependencies,
tests, Git metadata and internal tools are excluded from the WordPress archive.
After unpacking a reviewed export:
node tools/prepare-migrate.mjs /path/to/export /path/to/new-intakeThe adapter verifies every manifest hash, preserves Bridge entry identities,
reconstructs RawIR and comments export, and produces store/.contentrain plus
Migrate's intake summary/source map. Transferred media is under public/media.
The destination must not exist; tampered exports are rejected. This adapter does
not contact WordPress or GitHub. The Migrate consumer must copy that public media
into the emitted project's public directory. Hosted Migrate onboarding and an
Astro end-to-end acceptance are separate integration gates, not proven by this
adapter test.
An export started over REST (below) also carries its RawIR v1 as
bridge/rawir.json (listed in the manifest with its sha256): the same document
this adapter builds from the raw files, so its reader needs no assembler of its
own. The admin screen's export, its ZIP and GitHub delivery never carry it: the
site's repository holds the store, not a raw copy of every record.
An administrator's application password is enough to start, advance and read an
export; every route needs export + manage_options, like the read API. Where
the host strips the Authorization header, the Contentrain Migrate connection key
does the same job (below).
| Route | Answer |
|---|---|
GET /wp-json/contentrain-bridge/v1/about |
No sign-in: { version, auth: ["app_password", "key"] }. A 404 is a Bridge older than 0.4.0 (application password only). |
POST /wp-json/contentrain-bridge/v1/exports { types?, private?, comments?, media_files?, max_age?, fresh? } |
201 { export, reused: false }; the caller's live export with the same scope instead: 200 { export, reused: true }. With max_age (seconds) only one started within it is reused; an older one is removed and replaced (fresh=1: always). 409 bridge_export_busy while the one to replace is running, or was read in the last 10 minutes (a download in progress). At most three live remote exports per user (429). |
GET /wp-json/contentrain-bridge/v1/exports |
{ exports: [export] }, the caller's remote exports, oldest first. |
POST /wp-json/contentrain-bridge/v1/exports/{id}/advance |
Runs steps for about 20 seconds: { export }. While another request holds it: { export, busy: true }, nothing run. |
GET /wp-json/contentrain-bridge/v1/exports/{id} |
Once ready: the file list with sha256 and bytes. ?file= one file up to 8 MiB; ?file=&offset=N&length=M any file in base64 chunks of up to 8 MiB, with the whole file's sha256 and bytes. |
POST /wp-json/contentrain-bridge/v1/exports/{id}/read { file?, offset?, length? } |
The same answers as GET /exports/{id}, for a caller whose key travels in the body. |
DELETE /wp-json/contentrain-bridge/v1/key |
With the key only (and its pairing): { revoked: true }. Migrate closes the key when the order is done. |
media_files: false keeps every attachment record but copies no file: nothing
under media/, and the store keeps the WordPress upload URLs (a reader that
localizes media itself). It is part of the scope a reused export must match.
export is { id, phase, step, cursor, counts, files, scope, created_at, expires_at }. Poll advance until phase is terminal: ready, or failed
with error: { code, message }: content_changed (WordPress changed under the
snapshot; start again), too_large, review_required or export_failed. A
remote export scans no theme or plugin source, so it has no interface text to
review and never waits on a person. Exports expire 24 hours after they start.
An administrator creates it under Tools > Contentrain Bridge ("Connect to Contentrain Migrate") and pastes it into Migrate. It is shown once; only its SHA-256 is stored.
- Send it as the
X-Contentrain-Keyheader and, in every POST, as thecontentrain_keybody field (either may be stripped on the way; both are compared when both arrive). It is never read from the query string. - Every call also carries
contentrain_pairing(Migrate's order id,[A-Za-z0-9_-]{8,64}): in the JSON body of a POST or DELETE, in the query of a GET. The first accepted call of any route pairs the key with it — an access check that reads a non-existent export (404 bridge_not_found) included; another pairing is refused. - It acts as the administrator who created it, while they still have
exportmanage_options, and opens these routes only. It sees only the exports it started, and runs one at a time: another scope while one runs is409 bridge_export_live; once that one isreadyorfailed, a rerun starts.
- Unpaired, it stops working an hour after it was created. Paired, it has no
idle limit (reruns can be days apart) and works until 14 days after it was
created, or until it is revoked (the Bridge screen, or
DELETE /key) or replaced. HTTPS only (orlocalsites), like WordPress's application passwords.
| Code | Status | Meaning |
|---|---|---|
bridge_key_invalid |
401 | Unknown key. |
bridge_key_expired |
401 | Unpaired an hour after creation, or 14 days old. |
bridge_key_required |
401 | DELETE /key without the key. |
bridge_key_revoked |
401 | Revoked, or replaced by a newer key. |
bridge_key_mismatch |
400 | Header and body carry different keys. |
bridge_key_pairing_required |
400 | No valid contentrain_pairing. |
bridge_key_forbidden |
403 | Its administrator can no longer export. |
bridge_key_paired_elsewhere |
403 | The key belongs to another order. |
bridge_key_insecure |
403 | The site is not served over HTTPS. |
bridge_key_rate_limited |
429 | A wrong key after ten from this address in 15 minutes; data.retry_after seconds. The right key is compared first and never held back. |
bridge_not_found |
404 | Not this key's export. |
Every export writes bridge/inventory.json (contentrain-bridge-inventory@2):
one row per post, page, CPT record, attachment, menu item and public term in
scope, every status, with a fingerprint of the mapped record. WordPress keeps no
delta state; the inventory travels with the content. When a GitHub delivery finds
an inventory in the repository, it writes bridge/delta.json, a
SourceDeltaPlan (@contentrain/types) with created, updated, moved and
deleted (trashed/purged) entries.
Deletions are proven by comparing inventories, never by modified_after: that
feed misses deletions and meta-only (ACF) edits, and tests/delta.php proves it.
An inventory edited in Git, a legacy inventory, a type that left the scope (its
plugin was deactivated) or a truncated walk makes deletions_detectable false
rather than reporting records as deleted. In a public-scope export, drafts and
trashed records keep their id, status and fingerprint but not their slug or
address. model, entry_id, conflict and redirects are left to the planner,
which reads the store.
KEEP=1 npm run test:wordpress && npm run test:delta && npm run test:secretsEvery export writes what the site tells search engines and how it builds addresses:
bridge/seo.json/bridge/seo-entries.json: Yoast SEO, Rank Math, AIOSEO and SEOPress. Per post and term: title, description, canonical, robots, Open Graph, Twitter, focus keyword and the JSON-LD graph. Site-wide: separator, title and description templates, social defaults, verification codes. With Yoast active, the values are the ones the page renders (resolved: true), and the robots directives are the oneswp_robots()prints. A deactivated plugin's stored data is still exported, marked unresolved. With no SEO plugin the file saysstatus: "none". Where a plugin is not running to resolve its values, Bridge renders its templates (%title% %sep% %sitename%,#post_title #separator_sa #site_title,%%post_title%%,%%title%%) from the record:renderedholds the title, description, canonical, robots, Open Graph and Twitter text,template_sourcesays whether each came from the record, its type's template or the plugin's default, andunresolvednames any variable left out (never guessed). URLs stay at the source origin. Entries are keyedpost:<ID>(thebridge/entry-source-map.jsonkey) andterm:<taxonomy>:<term_id>; a home page that lists posts has its head underhomeinbridge/seo.json. Integration tokens (SEMrush, Wincher, MyYoast) never leave.bridge/redirects.json: Redirection, Yoast Premium, Rank Math, Safe Redirect Manager and WordPress's own old-slug redirects, as oneRawRedirectlist. Every stored rule is either listed as served or inexcludedwith its reason (disabled, conditional, 410, plugin inactive).bridge/routing.json: permalink structure, category/tag bases, trailing slash, static front and posts page, and each post type's and taxonomy's rewrite rules.
tests/seo.sh checks every exported head value against the page WordPress serves,
requests every exported redirect, and runs @contentrain/verify with the served
pages as its baseline.
A delivery is always a new branch; the default branch is never written. It starts from the repository's default branch, or from a base branch you name (read and compared against, never written). An empty repository is refused with what to do: give it a first commit, such as a README, and deliver again. On a repository that already holds a Bridge export:
- A record the delta proves was deleted in WordPress (trashed or purged) has its document, metadata and media removed on the branch, and the commit lists each one. A file that is missing from the new export for any other reason stops the delivery with its name: nothing is removed without that proof.
- A managed file someone edited in the repository since the last delivery stops the delivery with the file, the person and the date. Deliver again choosing "keep the repository version" or "use the WordPress version"; either choice is listed in the commit.
npm run test:delivery-git proves both against a repository with history and
replays every state into real Git. npm run test:e2e runs the whole chain,
WordPress to Astro, including a second delivery.
With "Find interface text" on, an export inventories the text the site shows
that is not content: gettext calls, literal HTML and echoed strings in theme
templates, JavaScript strings, widget titles and text, menu labels, Customizer
settings, and — opt-in, fetched from the site as a visitor — what the home,
single, page, search and not-found pages actually render.
bridge/hardcoded-text.json lists every candidate with all its occurrences and
exactly one outcome: transfer (to the ui-strings dictionary, the
theme-settings singleton, or content already exported), exclude with its
reason (code, URL, number, placeholder-only, dynamic gettext argument, secret,
content, or rendered from a source string it links to), or error (a file too
large or unreadable, a page that could not be fetched). Occurrences merge only
when text, locale and context are all the same. Keys depend on the text and its
context only, so moving a string to another file does not change its key.
Every export writes bridge/coverage.json, also shown on the Contentrain Bridge
screen when the export is ready. Each place WordPress keeps content — every post
type and status (REST-hidden types included), taxonomies, term and post meta,
attachments, comments, users, options, widgets, Customizer, sticky posts, page
templates, shortcodes, embeds, reusable blocks, patterns, network sites and every
database table — is counted in the database and split into exported,
excluded:<reason> or unsupported:<reason>. A source whose outcomes do not add
up to its count is marked balanced: false; a table the export does not read is
listed with its row count.
bridge/integrations.json lists the outside services the site is connected to —
analytics, CRM, newsletter, ads, comments, captcha, CDN — with the evidence for
each: an active plugin, a settings option by name, a script host in the theme or
on the rendered home page, a form wired to the service, or embedded content. Each
says whether an account must be reconnected and whether a credential is set on
WordPress. No key, token or password is exported: a credential is only checked
for being set, in memory, and the export names the setting, never its value. The
list is shown on the Bridge screen and reaches Migrate's intake as an
integration_reconnect_required issue.
ACF shapes that cannot be fully represented use a reported structured fallback; partial named models are not accepted. Same-name groups use a stable field-key suffix. Nested sensitive ACF values are removed before RawIR is written.
Page-builder layout is exported as data, not rendered: Elementor's element tree
(_elementor_data, decoded) and page settings, Divi's builder switches, the raw
Gutenberg/Divi body, and in options the site's design system — the active
theme, block-theme global settings and styles, block templates and template
parts, the Elementor kit and Divi's theme options. Keys that look like secrets
are removed at every depth (a form widget's recipient, an integration's API
key), and a password-protected post carries no builder tree. Builder runtime
(widget PHP, dynamic tags), rendered-state coverage and source-code reuse are
not yet complete.
Every manifest carries complete_source_coverage: false. That flag is the
honest answer to "is this everything?", and it is set on every export rather
than only on the ones that noticed a gap. A successful content-store validation
means the store is well-formed and the published toolchain reads it — not that
every WordPress behaviour came across.
Document metadata carrying quotes, backslashes, newlines or tabs is written as
JSON escapes and read back unchanged. That was a release blocker until
@contentrain/types@1.14.0: the published reader stripped a scalar's quotes
without decoding its escapes, so Bridge refused to finalize such an export rather
than publish content the reader would alter.
It is no longer taken on faith. npm run test:reader reads this writer's own
output back with the pinned reader and compares it to the values that went in, and
it runs in CI — so the compatibility cannot regress silently. Metadata that is not
valid UTF-8 still stops the export.