Skip to content

feat(import): complete WordPress migrations via the exporter plugin (taxonomies, ACF, SEO, menus, comments, site identity, link rewriting)#1830

Merged
ascorbic merged 5 commits into
emdash-cms:mainfrom
swissky:feat/wp-migration-overhaul
Jul 6, 2026
Merged

Conversation

@swissky

@swissky swissky commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes migrating from WordPress dramatically easier and more complete. Today, the plugin import path silently drops most of a site: taxonomy assignments, ACF/custom fields, SEO metadata, menus, comments, site identity, and everything past the first 500 media files. A user who migrates discovers the gaps only after switching. This PR closes those gaps end-to-end, together with its companion plugin PR (emdash-cms/wp-emdash#8) — the plugin exports a complete site, and this PR makes EmDash import all of it.

What now migrates (plugin import path):

  • Taxonomies: terms are pre-created and per-post assignments written through the shared WXR taxonomy machinery. Custom post type taxonomies (e.g. a company taxonomy on a company CPT) get their EmDash taxonomy definitions auto-created, scoped to the collections the post types map onto — previously they were dropped as "missing".
  • ACF & custom meta fields: suggested as collection fields during analysis (with well-known plugin bookkeeping meta like rank_math_*/wpil_* filtered out) and populated on import. Hyphenated meta keys (event-date) are sanitized into valid field slugs consistently on both the create and the match side. Meta values are coerced to the target field type (WP meta is stringly-typed and inconsistent across posts); incoercible values are dropped instead of failing the item.
  • SEO metadata: Yoast/Rank Math titles and descriptions land in auto-created seo_title/seo_description fields — ensured per (collection, field) so items later in the stream still get them.
  • Menus: navigation menus with hierarchy, resolved through the WP-ID → EmDash-ID map.
  • Comments: imported into EmDash's native comments with authors, dates, threading, and approval status.
  • Site identity: title, tagline, logo, and favicon are taken over from the source site, replacing starter-template placeholders.
  • Internal links: rewritten to root-relative URLs in imported content (both plugin and WXR sources), so migrated posts stop sending readers back to the old WordPress domain. Media URLs stay absolute for the existing media-pass URL map.
  • Media: full library instead of the first 500 files (pagination), fixing Importer maxes out on media at 500 wp-emdash#1.
  • UX: the import screen accepts a one-paste migration key generated by the plugin wizard; plain-permalink sites work via a ?rest_route= fallback (no routes mapping in lokal installations wp-emdash#5, Doesn't seem to detect sites managed with Local wp-emdash#7 territory).

Also fixes a bug where created content IDs were read from the wrong response field, which broke the WP-ID map and silently skipped all comments and taxonomy assignments.

Verified end-to-end against two live WordPress sites: a small business site (CPTs + ACF + Yoast) and a ~1.8k-post blog with 3.5k media files, 300+ comments, Rank Math, and custom taxonomies — imported with zero content errors.

Related: emdash-cms/wp-emdash#2 (partially — CPT taxonomies now migrate), emdash-cms/wp-emdash#3 (hyphenated slugs), #1691 (this was developed against exactly such realistic live migrations).

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — full import suites green (229 tests); known local Node 24.1.0 vite-config.test.ts failures are unrelated (admin build ESM issue), deferring to CI
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: A better path to easier WordPress migration: guided wizard + complete-site import #1831 (opened alongside this PR — it grew directly out of the open WordPress-migration issues above and live-migration testing; happy to split the PR up if preferred)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude. All changes were reviewed and verified against live WordPress migrations by a human.

Screenshots / test output

Import summary after the large-blog stress test: 1768 content items, 309 comments, 1689 taxonomy assignments, menus, and site identity imported with 0 errors.

swissky added 3 commits July 5, 2026 20:52
…, menus, media pagination, rest_route fallback, migration key)

- execute.ts now pre-creates taxonomy terms via the shared WXR taxonomy
  machinery and attaches category/tag/custom-taxonomy assignments per post
- Yoast/Rank Math per-post titles and descriptions land in seo_title /
  seo_description fields (auto-created like other import fields)
- ACF values and custom meta are written to schema fields with matching
  slugs (created by the prepare step); no fields are invented
- Navigation menus are imported after content via the plugin's /menus
  endpoint, resolving item references through a WP-ID -> EmDash-ID map
- analyze() paginates the media list instead of stopping at 500 items
- All plugin API calls fall back to ?rest_route= when /wp-json/ 404s
  (plain permalinks)
- Admin import screen accepts an em1. migration key generated by the
  EmDash Exporter plugin wizard (URL + credentials in one paste)
… WordPress plugin

- Import approved/pending comments with authors, original dates, threading,
  and status into _emdash_comments (idempotent re-runs).
- Take over site identity (title, tagline, logo, favicon) from /options,
  writing the real site:* settings keys and side-loading logo/favicon media.
- Surface per-post-type custom fields (ACF and plain meta) from the plugin
  analysis as suggested collection fields, with sanitized slugs and inferred
  types; match incoming meta keys with the same sanitization on execute.
- Fix field auto-creation to run per field and item instead of once per
  collection, so fields needed only by a later item (e.g. a lone Yoast SEO
  override) are still created.
…internal link rewriting

- Map plugin custom_fields into requiredFields during analysis (with
  sanitizeFieldSlug so hyphenated meta keys like event-date become valid
  field slugs) and sanitize incoming meta keys on execute so values land
- Filter well-known plugin bookkeeping meta (rank_math_*, wpil_*, ...)
  out of field suggestions
- Ensure import fields per (collection, field) instead of once per
  collection, so items later in the stream still get their SEO fields
- Coerce meta values to the target field type; drop incoercible values
  instead of failing the whole item
- Read created content IDs from data.item.id (was data.id, which broke
  the WP-ID map and skipped all comments and taxonomy assignments)
- Auto-create taxonomy defs for custom CPT taxonomies during plugin
  import, scoped to the mapped collections (plugin now exports
  post_types per taxonomy)
- Rewrite internal links in imported content to root-relative URLs in
  both plugin and WXR sources, so posts stop linking back to the old
  WordPress domain
@changeset-bot

changeset-bot Bot commented Jul 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 687bab1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,412 lines across 16 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 5, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1830

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1830

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1830

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1830

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1830

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1830

emdash

npm i https://pkg.pr.new/emdash@1830

create-emdash

npm i https://pkg.pr.new/create-emdash@1830

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1830

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1830

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1830

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1830

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1830

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1830

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1830

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1830

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1830

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1830

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1830

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1830

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1830

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1830

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1830

commit: 687bab1

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a substantial, well-scoped improvement to WordPress plugin imports that closes real migration gaps (CPT taxonomies, ACF/meta, SEO, menus, comments, site identity, link rewriting, media pagination, plain-permalink fallback, and the migration-key UX). The approach reuses existing EmDash machinery (WXR taxonomy helpers, handleContentCreate, importMediaWithProgress) rather than inventing parallel code, which is good architecture.

The main caveat is process: AGENTS.md requires an approved Discussion for new features, and the PR description acknowledges it doesn't have one. The author is transparent about this and offers to open one, so I won't block on process alone, but the maintainer should confirm whether this needs a Discussion before merge.

Code-wise I found two concrete issues that should be fixed:

  1. Migration key decoding ignores base64url padding. The EmDash Exporter key is described as em1. + base64url of a JSON blob. Standard base64url is unpadded, but atob requires padding. Without adding padding, many legitimate keys will decode-fail and the UI will tell the user the key is invalid.

  2. Raw-HTML link relativization only handles double-quoted hrefs. The href="..." regex in relativizeContentLinks misses single-quoted and unquoted attributes, so HTML blocks containing internal links with non-double-quoted hrefs will keep pointing at the old WordPress domain after import, partially defeating the stated goal.

Tests, changeset, Lingui wrapping, and RTL-safe classes all look correct.

Comment on lines +77 to +81
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
const base64 = key.slice(MIGRATION_KEY_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
const parsed: unknown = JSON.parse(atob(base64));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] decodeMigrationKey converts base64url characters back to base64 but does not restore padding. atob requires the input length to be a multiple of four, so a standard unpadded base64url payload (e.g. a realistic JSON migration key) will throw and the UI will reject a valid key.

Suggested change
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
const base64 = key.slice(MIGRATION_KEY_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
const parsed: unknown = JSON.parse(atob(base64));
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
let base64 = key
.slice(MIGRATION_KEY_PREFIX.length)
.replace(/-/g, "+")
.replace(/_/g, "/");
base64 = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const parsed: unknown = JSON.parse(atob(base64));
// ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Padding restored in 5464331. One factual note for the record: atob implements WHATWG forgiving-base64, which accepts unpadded input — only a remainder-of-1 length fails, and that's impossible for valid base64. That's why every live-tested key (all generated unpadded by the plugin's rtrim(base64_encode(...), '=')) decoded fine. Added the padEnd anyway so strict polyfills or non-browser environments can't trip on it — it costs one line.

Comment thread packages/core/src/import/utils.ts Outdated
Comment on lines +335 to +369
const hrefPattern = new RegExp(
`href="https?://(?:www\\.)?${sourceHost.replace(REGEX_SPECIALS, "\\$&")}(/[^"]*)?"`,
"g",
);

for (const block of blocks) {
switch (block._type) {
case "block":
relativizeMarkDefs(block.markDefs, sourceHost);
break;
case "image":
// asset.url stays absolute (media pass), only the click-through link
if (block.link) block.link = relativizeUrl(block.link, sourceHost) ?? block.link;
break;
case "table":
for (const row of block.rows) {
for (const cell of row.cells) relativizeMarkDefs(cell.markDefs, sourceHost);
}
break;
case "columns":
for (const column of block.columns) relativizeContentLinks(column.content, siteUrl);
break;
case "cover":
relativizeContentLinks(block.content, siteUrl);
break;
case "button":
if (block.url) block.url = relativizeUrl(block.url, sourceHost) ?? block.url;
break;
case "buttons":
for (const button of block.buttons) {
if (button.url) button.url = relativizeUrl(button.url, sourceHost) ?? button.url;
}
break;
case "htmlBlock":
block.html = block.html.replace(hrefPattern, (_m, path: string | undefined) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The htmlBlock handler only rewrites href="..." links. HTML blocks in WordPress commonly use single-quoted or unquoted attributes, so those internal links will remain absolute and keep sending readers to the old domain.

Use a more permissive pattern (or parse and walk the HTML) to catch href='...' and href=... forms as well. For example:

Suggested change
const hrefPattern = new RegExp(
`href="https?://(?:www\\.)?${sourceHost.replace(REGEX_SPECIALS, "\\$&")}(/[^"]*)?"`,
"g",
);
for (const block of blocks) {
switch (block._type) {
case "block":
relativizeMarkDefs(block.markDefs, sourceHost);
break;
case "image":
// asset.url stays absolute (media pass), only the click-through link
if (block.link) block.link = relativizeUrl(block.link, sourceHost) ?? block.link;
break;
case "table":
for (const row of block.rows) {
for (const cell of row.cells) relativizeMarkDefs(cell.markDefs, sourceHost);
}
break;
case "columns":
for (const column of block.columns) relativizeContentLinks(column.content, siteUrl);
break;
case "cover":
relativizeContentLinks(block.content, siteUrl);
break;
case "button":
if (block.url) block.url = relativizeUrl(block.url, sourceHost) ?? block.url;
break;
case "buttons":
for (const button of block.buttons) {
if (button.url) button.url = relativizeUrl(button.url, sourceHost) ?? button.url;
}
break;
case "htmlBlock":
block.html = block.html.replace(hrefPattern, (_m, path: string | undefined) => {
const hrefPattern = new RegExp(
`href=["']?https?://(?:www\\.)?${sourceHost.replace(REGEX_SPECIALS, "\\$&")}(/[^"'\\s>]*)?["']?`,
"gi",
);
// ...
case "htmlBlock":
block.html = block.html.replace(hrefPattern, (match, path: string | undefined) => {
return match.replace(/href=["']?[^"'\\s>]+/i, `href="${path || "/"}"`);
});
break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 5464331. The pattern now matches double-quoted, single-quoted, and unquoted href values (via a backreference for the closing quote) and is case-insensitive for HREF=. Rewritten links are normalized to href="...". Went with a single regex with a backreference instead of the suggested two-step replace — same coverage, no nested replace. Test extended with all three quote styles plus an external link that must stay untouched.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 6, 2026
…ef quote styles

- Restore base64 padding in decodeMigrationKey before atob. Browsers'
  forgiving-base64 accepts unpadded input (verified: remainder-1 lengths
  are impossible for valid base64), but strict polyfills may not.
- relativizeContentLinks htmlBlock handler now matches single-quoted,
  unquoted, and uppercase href attributes, not just href="...".
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 6, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@ascorbic
ascorbic enabled auto-merge (squash) July 6, 2026 18:51
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 6, 2026
@ascorbic
ascorbic merged commit 15b4d2d into emdash-cms:main Jul 6, 2026
43 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants