Subchapter 6.9
references/wordpress.mdMarkdown13 KBView on GitHub
Before writing migration code, determine:
Choose the extraction route by available access:
/wp-json/wp/v2, per_page=100, and response headers such as x-wp-total and x-wp-totalpages for counts.context=edit, raw Gutenberg content, drafts, private fields, and some plugin data. Use application passwords or another approved auth flow; store credentials in environment variables.If a WXR/XML file is present and complete, default to parsing it rather than requiring WP-CLI access.
REST URLs:
https://<domain>/wp-json/wp/v2.https://<domain>/<site-name>/wp-json/wp/v2.curl -sI "<base>/posts?per_page=100".Authenticated raw content example:
curl -u "$WP_USER:$WP_APP_PASSWORD" \
"<base>/posts?context=edit&per_page=100"If REST is blocked by Cloudflare or security plugins, use one of these before scraping rendered HTML: ask for a WXR export, run WordPress locally with a production database copy, have the customer save JSON from an authenticated browser session, or pass a valid cf_clearance cookie only with explicit approval.
With WordPress access, export schema signals before modeling:
wp post-type list --format=json > post-types.json
wp taxonomy list --format=json > taxonomies.json
wp acf export --format=json > acf-fields.jsonIf WP-CLI is unavailable, inspect:
register_post_type() calls in themes/plugins.register_taxonomy() calls.get_field() usage and exported field groups.single-*.php, archive-*.php, and page templates.wp_posts, wp_postmeta, and wp_term_taxonomy when database access exists.ACF defaults:
string or text.image.post, page, or more semantic document types such as article, person, location, event, or caseStudy.postType field.author or person documents. Treat them separately from Sanity project members.seo object if the target project uses one.Use page templates and custom post types as signals for content trapped in presentational structures.
post-123, page-123, author-41, category-news, or a project-specific semantic prefix._thumbnail_id postmeta points to an attachment post._sanityAsset or uploaded asset references.publishedAt and modifiedAt; do not rely on _createdAt / _updatedAt for source publication dates.null.When parsing WXR/XML, always unwrap parser output before writing Sanity documents. Prefer fast-xml-parser so CDATA is captured consistently:
import {XMLParser} from 'fast-xml-parser'
import {readFile} from 'node:fs/promises'
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
cdataPropName: '_cdata',
textNodeName: '_text',
})
const xml = await readFile('wordpress-export.xml', 'utf8')
const parsed = parser.parse(xml)
function text(value: unknown): string {
if (value == null) return ''
if (typeof value === 'string' || typeof value === 'number') return String(value)
if (Array.isArray(value)) return text(value[0])
if (typeof value === 'object') {
const record = value as Record<string, unknown>
return text(record._cdata ?? record._text)
}
return ''
}Run every XML field through a helper like text() before writing Sanity documents. Never store raw parser objects.
Key WXR fields:
title -> title.wp:post_name -> slug.current.content:encoded -> body or content source, converted to Portable Text.excerpt:encoded -> excerpt; omit when empty.wp:post_date_gmt -> publishedAt or equivalent source publication field.dc:creator -> author lookup key; prefer mapping to WordPress author ID when possible.wp:post_type -> content type routing.wp:post_id -> source ID for deterministic Sanity IDs.wp:postmeta[] -> ACF, _thumbnail_id, Yoast, Elementor, and other plugin data.category[] with domain attributes -> categories, tags, or custom taxonomies.Build a flat postmeta map:
const meta: Record<string, string> = {}
for (const item of post['wp:postmeta'] || []) {
const key = text(item['wp:meta_key'])
const value = text(item['wp:meta_value'])
if (key) meta[key] = value
}Featured image is not in the main post body. Build an attachment map from wp:post_type === "attachment" records, then resolve meta["_thumbnail_id"] to {url, alt} and set an image field with _sanityAsset.
Map common Yoast fields only when non-empty:
_yoast_wpseo_title -> seo.metaTitle._yoast_wpseo_metadesc -> seo.metaDescription._yoast_wpseo_focuskw -> seo.focusKeyword._yoast_wpseo_meta-robots-noindex === "1" -> seo.noIndex.Also inspect Rank Math or other SEO plugin postmeta if present; do not assume Yoast is the only SEO source.
WordPress body content is the highest-risk part of most migrations.
content.rendered): convert to Portable Text with HTML tooling, but expect loss of block/editor structure.content.raw with context=edit): parse with @wordpress/block-serialization-default-parser, then map each block type to Portable Text blocks or custom objects.@portabletext/block-tools is acceptable only after testing on real posts and confirming markDefs and custom blocks are correct._sanityAsset from the discovered URL and log unresolved metadata.For links, remember that Portable Text annotations live in a block’s markDefs; spans reference annotation keys through marks. The link mark definition belongs on the block, not the span.
Before running full conversion, test 3-5 real posts and confirm:
markDefs on the block.Common Gutenberg mappings:
core/paragraph, core/heading, core/list, core/quote: Portable Text blocks.core/image: image block with resolved attachment or _sanityAsset.core/embed: custom embed object or logged external embed.core/columns and core/column: custom Portable Text object or page builder object if layout matters.core/button / core/buttons: CTA object, link annotation, or page-level CTA based on reuse.core/table: custom table object; do not flatten unless table semantics are not needed.core/code / core/preformatted: code block object when code content matters.Elementor stores structured page data in _elementor_data, not in normal REST content.rendered.
_elementor_data.content array of sections, columns, containers, and widgets.widgetType to identify content fields, but avoid creating one Sanity type per low-level widget._sanityAsset.Common Elementor widget fields:
heading: settings.title, settings.header_size.text-editor: settings.editor HTML.image: settings.image as object or JSON string with URL/ID/alt.button: settings.text, settings.link, settings._css_classes.counter: settings.ending_number, settings.prefix, settings.suffix, settings.title.icon-list: settings.icon_list[].html: settings.html; inspect for forms, carousels, or embeds.template: settings.template_id; resolve global Elementor templates separately.Other page builders such as Divi and Beaver Builder have similar risks: rendered HTML loses source structure and usually requires custom handling.
_cdata; unwrap every field before writing Sanity documents.content.rendered has shortcodes and filters already applied; content.raw requires auth but preserves Gutenberg block comments.wp:post_type or REST endpoint and compare to Sanity document counts.attachment, nav_menu_item, wp_block, or ACF definitions.<p, _cdata, and [object Object]._thumbnail_id or another source-specific image field._sanityAsset directive or uploaded asset reference, not a TODO/null placeholder.