From 1bdeb2fdaacb2b125ccd0192c73958089958522e Mon Sep 17 00:00:00 2001
From: Joshua Pozos <25085383+JoshuaPozos@users.noreply.github.com>
Date: Sun, 10 May 2026 14:40:56 -0600
Subject: [PATCH 1/8] refactor: promote schema-inference + locale-expander to
src/shared/
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
These two modules were named with a `sanity-` prefix in v0.6.0 because
the only caller at the time was `pull-sanity`. Their logic is fully
source-agnostic — `inferSchemas` walks Document samples by content type
and recognizes both Sanity-shaped (`_type: 'reference'`/`'image'`) and
Contentful Link-sys values, and `detectLocales` / `expandDocumentsByLocale`
only inspect field-value shape.
Promoting them to `src/shared/` (sibling of `src/sanity/`) unblocks
`pull-wordpress` reusing them without an awkward import path like
`../sanity/sanity-schema-inference.js` from WordPress code.
Renames (no behavior change):
src/sanity/sanity-schema-inference.ts → src/shared/schema-inference.ts
src/sanity/sanity-locale-expander.ts → src/shared/locale-expander.ts
+ their .test.ts companions
Import path updates in `pull-sanity.ts` and `index.ts`. The public
type alias `SanityInferenceWarning` (etc.) is removed in favor of the
unprefixed names — the `inferSchemas` export is now top-level.
601 tests pass (same count as v0.6.0 — no regressions). 5 examples
(`example-from-scratch`, `-sanity-migration`, `-wp-migration`,
`-pull-contentful`, `-sanity-pull`) all produce identical counters.
---
src/index.ts | 10 ++++++----
src/sanity/pull-sanity.ts | 6 +++---
.../locale-expander.test.ts} | 7 +++++--
.../locale-expander.ts} | 9 +++++++--
.../schema-inference.test.ts} | 11 ++++++++---
.../schema-inference.ts} | 12 +++++++++---
6 files changed, 38 insertions(+), 17 deletions(-)
rename src/{sanity/sanity-locale-expander.test.ts => shared/locale-expander.test.ts} (94%)
rename src/{sanity/sanity-locale-expander.ts => shared/locale-expander.ts} (91%)
rename src/{sanity/sanity-schema-inference.test.ts => shared/schema-inference.test.ts} (93%)
rename src/{sanity/sanity-schema-inference.ts => shared/schema-inference.ts} (95%)
diff --git a/src/index.ts b/src/index.ts
index b463f2b..22d808c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -110,6 +110,12 @@ export type { WXRReadOptions, WXRSite, WXRResult } from './wordpress/wxr-reader.
export { analyzeWXR, generateScaffold, scaffoldFromWXR } from './wordpress/wxr-scaffold.js';
export type { ScaffoldAnalysis, AnalyzedContentType, AnalyzedField, ScaffoldOptions, ScaffoldOutput } from './wordpress/wxr-scaffold.js';
+// ── Shared (source-agnostic pull plumbing) ──────────────────────
+export { inferSchemas } from './shared/schema-inference.js';
+export type { InferenceWarning, InferenceResult, InferenceOptions, DocumentSamplesByType } from './shared/schema-inference.js';
+export { detectLocales, expandDocumentsByLocale } from './shared/locale-expander.js';
+export type { LocaleDetectionResult, ExpandOptions as LocaleExpandOptions } from './shared/locale-expander.js';
+
// ── Sanity ───────────────────────────────────────────────────────
export { readSanity, parseSanity, parseSanityString, portableTextToHTML, isSanityNDJSON } from './sanity/sanity-reader.js';
export type { SanityReadOptions, SanityImageAsset, SanityResult } from './sanity/sanity-reader.js';
@@ -117,11 +123,7 @@ export { portableTextToRichText, isPortableText } from './sanity/portable-text-t
export type { RichTextWarning, RichTextResult } from './sanity/portable-text-to-richtext.js';
export { pullSanity } from './sanity/pull-sanity.js';
export type { PullSanityOptions, PullSanityResult } from './sanity/pull-sanity.js';
-export { inferSchemas as inferSanitySchemas } from './sanity/sanity-schema-inference.js';
-export type { InferenceWarning as SanityInferenceWarning, InferenceResult as SanityInferenceResult, InferenceOptions as SanityInferenceOptions } from './sanity/sanity-schema-inference.js';
export { mapRefsToContentful, mapDocumentRefs } from './sanity/sanity-ref-mapper.js';
export type { RefMapResult } from './sanity/sanity-ref-mapper.js';
export { buildAssetUrlMap, mapImageRefsToLinks, mapDocumentAssets, toAssetMetadata } from './sanity/sanity-asset-mapper.js';
export type { AssetMapResult, PullSanityAssetMeta } from './sanity/sanity-asset-mapper.js';
-export { detectLocales, expandDocumentsByLocale } from './sanity/sanity-locale-expander.js';
-export type { LocaleDetectionResult, ExpandOptions as LocaleExpandOptions } from './sanity/sanity-locale-expander.js';
diff --git a/src/sanity/pull-sanity.ts b/src/sanity/pull-sanity.ts
index 5496135..60383e9 100644
--- a/src/sanity/pull-sanity.ts
+++ b/src/sanity/pull-sanity.ts
@@ -18,11 +18,11 @@ import { join } from 'node:path';
import { parseSanity, parseSanityString } from './sanity-reader.js';
import type { SanityImageAsset } from './sanity-reader.js';
-import { inferSchemas } from './sanity-schema-inference.js';
-import type { InferenceWarning } from './sanity-schema-inference.js';
+import { inferSchemas } from '../shared/schema-inference.js';
+import type { InferenceWarning } from '../shared/schema-inference.js';
import { mapDocumentRefs } from './sanity-ref-mapper.js';
import { mapDocumentAssets, toAssetMetadata } from './sanity-asset-mapper.js';
-import { detectLocales, expandDocumentsByLocale } from './sanity-locale-expander.js';
+import { detectLocales, expandDocumentsByLocale } from '../shared/locale-expander.js';
import type { ContentTypeDefinition, Document } from '../types.js';
// ── Public API ───────────────────────────────────────────────────
diff --git a/src/sanity/sanity-locale-expander.test.ts b/src/shared/locale-expander.test.ts
similarity index 94%
rename from src/sanity/sanity-locale-expander.test.ts
rename to src/shared/locale-expander.test.ts
index 7c43a8a..acda2ad 100644
--- a/src/sanity/sanity-locale-expander.test.ts
+++ b/src/shared/locale-expander.test.ts
@@ -1,5 +1,8 @@
/**
- * Tests for src/sanity/sanity-locale-expander.js — v0.6.0 M7.
+ * Tests for src/shared/locale-expander.js — v0.6.0 M7 (originally
+ * sanity-only; promoted to shared/ in v0.6.1 alongside the WordPress
+ * adapter). The detector is source-agnostic — it only inspects field-
+ * value shape, not the surrounding document's content type.
*
* Locale detection rules + the expand-by-locale fan-out behaviour.
* Covers: positive detection (en, es, pt-BR), negative detection (Link
@@ -11,7 +14,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
-import { detectLocales, expandDocumentsByLocale } from './sanity-locale-expander.js';
+import { detectLocales, expandDocumentsByLocale } from './locale-expander.js';
import type { Document } from '../types.js';
function doc(id: string, ct: string, data: Record): Document {
diff --git a/src/sanity/sanity-locale-expander.ts b/src/shared/locale-expander.ts
similarity index 91%
rename from src/sanity/sanity-locale-expander.ts
rename to src/shared/locale-expander.ts
index d5965de..c2fc2c8 100644
--- a/src/sanity/sanity-locale-expander.ts
+++ b/src/shared/locale-expander.ts
@@ -1,11 +1,16 @@
/**
- * Content Model Simulator — Sanity Locale Detector + Expander (v0.6.0 M7)
+ * Content Model Simulator — Locale Detector + Expander (v0.6.0 M7 → shared in v0.6.1)
*
- * Detects locale-shaped values in normalized Sanity documents
+ * Detects locale-shaped values in normalized source documents
* (`{en: '…', es: '…'}` — a plain object whose keys all match a locale
* code pattern) and rewrites the corpus so each `Document` carries
* exactly one locale tag, mirroring what `cms-sim simulate` expects.
*
+ * Source-agnostic: only inspects the *value* shape, never the
+ * surrounding document's content type or origin. Works equally well
+ * for Sanity NDJSON exports and WordPress WXR-derived documents (when
+ * a plugin like Polylang stores translations on the same field).
+ *
* Without this pass the schema-inference step classifies localized
* fields as plain `Object` and the simulator can't merge entries across
* locale variants. With it, each Sanity doc fans out to N docs (one per
diff --git a/src/sanity/sanity-schema-inference.test.ts b/src/shared/schema-inference.test.ts
similarity index 93%
rename from src/sanity/sanity-schema-inference.test.ts
rename to src/shared/schema-inference.test.ts
index 858b654..0929b65 100644
--- a/src/sanity/sanity-schema-inference.test.ts
+++ b/src/shared/schema-inference.test.ts
@@ -1,5 +1,10 @@
/**
- * Tests for src/sanity/sanity-schema-inference.js — v0.6.0 M2.
+ * Tests for src/shared/schema-inference.js — v0.6.0 M2 (originally Sanity-only;
+ * promoted to shared/ in v0.6.1 so WordPress and future source adapters can
+ * reuse it. The detection logic is intentionally generic — it recognizes
+ * both Sanity-shaped (`_type: 'reference'`, `_type: 'image'`) and
+ * Contentful-shaped (`{sys: {type: 'Link', linkType: 'Entry'/'Asset'}}`)
+ * values without caring which source produced them).
*
* Field-type inference is what differentiates a useful pull from a
* placeholder. These tests cover the per-shape mapping (string sizes,
@@ -12,8 +17,8 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
-import { inferSchemas } from './sanity-schema-inference.js';
-import type { DocumentSamplesByType } from './sanity-schema-inference.js';
+import { inferSchemas } from './schema-inference.js';
+import type { DocumentSamplesByType } from './schema-inference.js';
function group(typeId: string, docs: Array>): DocumentSamplesByType {
return new Map([[typeId, docs]]);
diff --git a/src/sanity/sanity-schema-inference.ts b/src/shared/schema-inference.ts
similarity index 95%
rename from src/sanity/sanity-schema-inference.ts
rename to src/shared/schema-inference.ts
index 8996a09..bdf750e 100644
--- a/src/sanity/sanity-schema-inference.ts
+++ b/src/shared/schema-inference.ts
@@ -1,11 +1,17 @@
/**
- * Content Model Simulator — Sanity Schema Inference (v0.6.0 M2)
+ * Content Model Simulator — Schema Inference (v0.6.0 M2 → shared in v0.6.1)
*
- * Given a set of documents grouped by Sanity `_type`, infer a
+ * Given a set of documents grouped by content type, infer a
* Contentful-shaped `ContentTypeDefinition` for each: walks each
* field across all sampled docs, classifies the value shape, and
* maps it to a Contentful field type (Symbol / Text / Integer /
- * Number / Boolean / Date / Object / Link / Array<...>).
+ * Number / Boolean / Date / Object / RichText / Link / Array<...>).
+ *
+ * Source-agnostic: detection recognizes both Sanity-shaped values
+ * (`{_type: 'reference'}`, `{_type: 'image'}`) and Contentful-shaped
+ * sys-Link values (`{sys: {type: 'Link', linkType: 'Entry'/'Asset'}}`),
+ * so it works for both `pull-sanity` and `pull-wordpress` after the
+ * source-specific mappers have rewritten refs/assets.
*
* Zero-dependency. Read-only over the documents.
*
From 665fb63e464dd048a2f84c0823015d7a4d840c71 Mon Sep 17 00:00:00 2001
From: Joshua Pozos <25085383+JoshuaPozos@users.noreply.github.com>
Date: Sun, 10 May 2026 14:47:43 -0600
Subject: [PATCH 2/8] =?UTF-8?q?feat:=20pull-wordpress=20adapter=20?=
=?UTF-8?q?=E2=80=94=20WXR=20XML=20=E2=86=92=20Contentful-shape=20output?=
=?UTF-8?q?=20(skeleton)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `cms-sim pull-wordpress` and the public `pullWordpress()` API.
Reads a WordPress WXR XML export (offline, from wp-admin's
Tools → Export → All content) and writes the same on-disk shape as
`cms-sim pull` for Contentful spaces, so the rest of the pipeline
(simulate / to-import / diff) consumes the result unchanged.
Output mirrors pull-contentful + pull-sanity exactly:
schemas/.js one .js per surviving WP content type
data/entries.ndjson one JSON per line
contentful-space.json + WP site metadata (title / url / language)
Reuses the existing `parseWXR` (already shipped in v0.5.0) plus the
shared `inferSchemas` / `detectLocales` / `expandDocumentsByLocale`
modules promoted to `src/shared/` in the previous commit.
Stable id prefixes per content type so refs (next commit) have
predictable targets:
- post / page → wp_ e.g. wp_42
- author → wp_author_ e.g. wp_author_jdoe
- category → wp_category_ e.g. wp_category_tech
- tag → wp_tag_ e.g. wp_tag_javascript
Default skip set drops content WordPress emits but Contentful has no
equivalent for: nav_menu_item, wp_navigation, wp_template,
wp_template_part, wp_global_styles, wp_block, oembed_cache,
customize_changeset, custom_css, revision, and (for now) attachment.
Attachments will be reshaped into assets/assets.json in commit 4 —
`--include-attachments` opts back into emitting them as entries until
then.
Status: skeleton + output contract. The following land in follow-up
commits:
- ref rewriting: post.categories/tags (string slugs) → Array,
post.author (login string) → Link Entry + linkContentType validations
- asset linking: attachment posts → assets.json + featured_image / inline
images → Link Asset
- Gutenberg / Classic HTML → Contentful RichText (uses existing
htmlToRichText from transform/rich-text.ts)
- Polylang locale detection + fan-out
+10 unit tests (611 total). Pre-commit smoke against the five
`file:`-linked examples regression-free (5/25, 6/20, 5/18, 23/69, 7/21).
npm audit: 0 vulnerabilities.
---
CHANGELOG.md | 9 +
src/cli.ts | 160 ++++++++++++++++
src/index.ts | 2 +
src/wordpress/pull-wordpress.test.ts | 221 ++++++++++++++++++++++
src/wordpress/pull-wordpress.ts | 264 +++++++++++++++++++++++++++
5 files changed, 656 insertions(+)
create mode 100644 src/wordpress/pull-wordpress.test.ts
create mode 100644 src/wordpress/pull-wordpress.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa9411d..c64ff68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to `content-model-simulator` are documented here.
+## [Unreleased]
+
+### Added
+- **`cms-sim pull-wordpress`** — new CLI subcommand and public `pullWordpress()` API that convert a WordPress WXR XML export (from wp-admin → Tools → Export → All content) into the same on-disk shape as `cms-sim pull` for Contentful spaces: `contentful-space.json` + `schemas/.js` + `data/entries.ndjson`. Reuses `parseWXR` for parsing and the shared `inferSchemas` / `detectLocales` / `expandDocumentsByLocale` from `src/shared/`. Flags: `--input` (required), `--output` (default `./wordpress-export`), `--default-locale`, `--force-locale`, `--include-attachments`, `--skip-types=`, `--source`, `--verbose`. Stable id prefixes per content type: `wp_` for posts/pages, `wp_author_`, `wp_category_`, `wp_tag_` — so cross-document refs (commit 3) have predictable targets. Default skip set drops `nav_menu_item` / `wp_navigation` / `wp_template` / `wp_template_part` / `wp_global_styles` / `wp_block` / `oembed_cache` / `customize_changeset` / `custom_css` / `revision` / `attachment` (the last opts back in via `--include-attachments` and will be reshaped into `assets/assets.json` in a follow-up commit). **Status:** skeleton + output contract — ref → Link Entry rewriting, asset linking, Gutenberg/Classic HTML → RichText, and Polylang locale detection land in follow-up commits.
+- **10 unit tests** for `pullWordpress` covering: three pieces written, `contentful-space.json` shape with WP site metadata + `sourceFormat: 'wordpress-wxr'`, `--force-locale` precedence, one schema per surviving content type sorted, Contentful schema shape with `id` / `name` / `fields[]`, prefixed-id assignment across post / page / author / category / tag, default skip set (attachments + nav_menu_item filtered), `--include-attachments` opt-in, in-memory return shape, missing-input error.
+
+### Changed
+- **`inferSchemas` and locale detection promoted to `src/shared/`** (originally named `sanity-schema-inference` / `sanity-locale-expander` in v0.6.0 because Sanity was the only caller). Both modules are source-agnostic — `inferSchemas` recognizes both Sanity-shaped (`{_type: 'reference'}`, `{_type: 'image'}`) and Contentful-shaped (`{sys: Link Entry/Asset}`) values, and the locale detector inspects only field-value shape. Moved to `src/shared/schema-inference.{ts,test.ts}` and `src/shared/locale-expander.{ts,test.ts}`. Public API export `inferSanitySchemas` is replaced by the unprefixed `inferSchemas` (breaking, but pre-1.0.0 minor — see SemVer caveat in README). Imports in `pull-sanity.ts` updated. No runtime/behavior change; 601 tests still pass.
+
## [0.6.0] — 2026-05-10
### Added
diff --git a/src/cli.ts b/src/cli.ts
index 477b024..8dd4eab 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -184,6 +184,7 @@ ${c.cyan}COMMANDS:${c.reset}
cms-sim from-migrations [opts] ${c.dim}Convert contentful-migration scripts → cms-sim schemas${c.reset}
cms-sim pull [options] ${c.dim}Download content model from Contentful${c.reset}
cms-sim pull-sanity [options] ${c.dim}Convert a Sanity NDJSON export to the same shape as ${c.bold}pull${c.reset}${c.dim} (offline)${c.reset}
+ cms-sim pull-wordpress [opts] ${c.dim}Convert a WordPress WXR export to the same shape as ${c.bold}pull${c.reset}${c.dim} (offline)${c.reset}
cms-sim diff --old=A --new=B ${c.dim}Compare two schema directories${c.reset}
cms-sim validate [options] ${c.dim}Validate schemas + data (no HTML output)${c.reset}
@@ -1061,6 +1062,158 @@ async function pullSanityMain(argv: string[]): Promise {
console.log('');
}
+// ── pull-wordpress sub-command ───────────────────────────────────
+
+function showPullWordpressHelp(): void {
+ console.log(`
+${c.bold}Content Model Simulator — Pull WordPress${c.reset}
+Convert a WordPress WXR XML export into the same on-disk shape that
+${c.bold}cms-sim pull${c.reset} produces (Contentful-flavored). The result drops into
+${c.bold}cms-sim simulate${c.reset}, ${c.bold}cms-sim to-import${c.reset}, and ${c.bold}cms-sim diff${c.reset} unchanged.
+
+${c.cyan}USAGE:${c.reset}
+ cms-sim pull-wordpress --input= [options]
+
+${c.cyan}REQUIRED:${c.reset}
+ --input= Path to the WordPress WXR XML export
+ (Tools → Export → All content in wp-admin)
+
+${c.cyan}OPTIONS:${c.reset}
+ --output= Output directory (default: ./wordpress-export)
+ --default-locale= Override the language detected from the WXR tag
+ --force-locale= Force-tag every entry with this locale (ignore WXR)
+ --include-attachments Emit attachment posts as entries (default: filtered;
+ commit 4 will reshape them into assets/assets.json)
+ --skip-types= Additional post_types to skip (merged with defaults:
+ nav_menu_item, wp_navigation, wp_template, …)
+ --source=
]]>
+
+
+
+
+
+ About
+ 10
+ 2024-01-05 08:00:00
+ about
+ publish
+ page
+ About this site.]]>
+
+ jdoe
+
+
+ hero-image
+ 55
+ hero-image
+ inherit
+ attachment
+ https://example.com/wp-content/uploads/hero.jpg
+
+
+ Primary Menu Item
+ 200
+ menu-item
+ publish
+ nav_menu_item
+
+
+`;
+
+// ── Test scaffolding ─────────────────────────────────────────────
+
+let tmpDir: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pull-wp-test-'));
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function readSchemaFile(filePath: string): unknown {
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const match = raw.match(/export default ([\s\S]+);\s*$/);
+ if (!match) throw new Error(`Schema file missing 'export default' shape: ${filePath}`);
+ return JSON.parse(match[1]);
+}
+
+// ── M1-equivalent: output contract ───────────────────────────────
+
+describe('pullWordpress — output contract', () => {
+ it('writes the three expected pieces under the output directory', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ assert.ok(fs.existsSync(path.join(tmpDir, 'contentful-space.json')));
+ assert.ok(fs.existsSync(path.join(tmpDir, 'schemas')));
+ assert.ok(fs.existsSync(path.join(tmpDir, 'data', 'entries.ndjson')));
+ });
+
+ it('writes contentful-space.json with WP site metadata + sourceFormat', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR, source: 'test.xml' });
+ const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'contentful-space.json'), 'utf-8'));
+ assert.equal(cfg.baseLocale, 'en');
+ assert.deepEqual(cfg.locales, ['en']);
+ assert.equal(cfg.source, 'test.xml');
+ assert.equal(cfg.sourceFormat, 'wordpress-wxr');
+ assert.equal(cfg.site.title, 'Test Site');
+ assert.equal(cfg.site.url, 'https://example.com');
+ assert.equal(cfg.site.language, 'en');
+ assert.ok(!isNaN(Date.parse(cfg.pulledAt)));
+ });
+
+ it('honors --force-locale and --default-locale precedence', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR, forceLocale: 'es-MX' });
+ const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'contentful-space.json'), 'utf-8'));
+ assert.equal(cfg.baseLocale, 'es-MX');
+ const lines = fs.readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8').trim().split('\n');
+ for (const line of lines) assert.equal(JSON.parse(line).locale, 'es-MX');
+ });
+
+ it('emits one .js schema per surviving content type, sorted', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const schemaFiles = fs.readdirSync(path.join(tmpDir, 'schemas')).sort();
+ // After defaults: author + category + tag + post + page survive.
+ // Filtered: attachment, nav_menu_item.
+ assert.deepEqual(schemaFiles, ['author.js', 'category.js', 'page.js', 'post.js', 'tag.js']);
+ });
+
+ it('emits schemas with the Contentful shape (id, name, fields[])', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const postSchema = readSchemaFile(path.join(tmpDir, 'schemas', 'post.js')) as any;
+ assert.equal(postSchema.id, 'post');
+ assert.equal(postSchema.name, 'Post');
+ assert.ok(Array.isArray(postSchema.fields));
+ const ids = postSchema.fields.map((f: any) => f.id).sort();
+ // skeleton commit: refs are still slug arrays, body is HTML string,
+ // publishDate is ISO. Field set should at least include title/slug/body.
+ assert.ok(ids.includes('title'));
+ assert.ok(ids.includes('slug'));
+ assert.ok(ids.includes('body'));
+ });
+
+ it('writes data/entries.ndjson with prefixed ids per type', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const lines = fs.readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8').trim().split('\n');
+ const byId = Object.fromEntries(
+ lines.map(l => {
+ const e = JSON.parse(l);
+ return [e.id, e.contentType];
+ }),
+ );
+ // Posts/pages → wp_
+ assert.equal(byId.wp_42, 'post');
+ assert.equal(byId.wp_10, 'page');
+ // Author → wp_author_
+ assert.equal(byId.wp_author_jdoe, 'author');
+ // Category → wp_category_
+ assert.equal(byId.wp_category_tech, 'category');
+ // Tag → wp_tag_
+ assert.equal(byId.wp_tag_javascript, 'tag');
+ });
+
+ it('filters attachments and nav_menu_item / wp_navigation by default', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const lines = fs.readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8').trim().split('\n');
+ const types = lines.map(l => JSON.parse(l).contentType);
+ assert.ok(!types.includes('attachment'), 'attachment should be filtered by default');
+ assert.ok(!types.includes('nav_menu_item'), 'nav_menu_item should be filtered by default');
+ });
+
+ it('emits attachments as entries when includeAttachments is true', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR, includeAttachments: true });
+ const lines = fs.readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8').trim().split('\n');
+ const types = lines.map(l => JSON.parse(l).contentType);
+ assert.ok(types.includes('attachment'));
+ });
+
+ it('returns site, schemas, documents, and warnings in memory', async () => {
+ const result = await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ assert.equal(result.site.title, 'Test Site');
+ assert.ok(result.schemas.length >= 4); // author + category + tag + post + page
+ assert.ok(result.documents.length > 0);
+ assert.ok(Array.isArray(result.warnings));
+ });
+
+ it('throws when neither `input` nor `inputContent` is provided', async () => {
+ await assert.rejects(
+ () => pullWordpress({ output: tmpDir }),
+ /either `input` .+ or `inputContent` .+ is required/,
+ );
+ });
+});
diff --git a/src/wordpress/pull-wordpress.ts b/src/wordpress/pull-wordpress.ts
new file mode 100644
index 0000000..05a27fe
--- /dev/null
+++ b/src/wordpress/pull-wordpress.ts
@@ -0,0 +1,264 @@
+/**
+ * Content Model Simulator — WordPress Pull Adapter (v0.6.1)
+ *
+ * Reads a WordPress WXR XML export and writes the same on-disk shape
+ * that `cms-sim pull` produces for Contentful spaces, so the rest of
+ * the pipeline (`simulate`, `to-import`, `diff`) consumes the result
+ * unchanged.
+ *
+ * Zero-dependency. Reuses `parseWXR` from `wxr-reader.ts` for the XML
+ * parsing and the shared `inferSchemas` / `detectLocales` /
+ * `expandDocumentsByLocale` from `src/shared/` for the source-agnostic
+ * passes.
+ *
+ * Status (commit 2 / 8): skeleton + output contract. References between
+ * docs (`categories[]`, `tags[]`, `author`) are still emitted as raw
+ * slug/login strings — ref → Link Entry rewriting lands in commit 3.
+ * Attachments are filtered by default — proper asset linking lands in
+ * commit 4. Gutenberg / classic HTML → RichText is commit 5.
+ */
+
+import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+import { parseWXR } from './wxr-reader.js';
+import type { WXRSite } from './wxr-reader.js';
+import { inferSchemas } from '../shared/schema-inference.js';
+import type { InferenceWarning } from '../shared/schema-inference.js';
+import { detectLocales, expandDocumentsByLocale } from '../shared/locale-expander.js';
+import type { ContentTypeDefinition, Document } from '../types.js';
+
+// ── Public API ───────────────────────────────────────────────────
+
+export interface PullWordpressOptions {
+ /** Path to the WordPress WXR XML export. Required unless `inputContent` is provided. */
+ input?: string;
+ /** Output directory. Created recursively if missing. */
+ output: string;
+ /** Default locale tag used when the WXR doesn't carry a `` and no
+ * locale plugin is detected. Defaults to the WXR's own `` value, or
+ * `'en'` as final fallback. */
+ defaultLocale?: string;
+ /** Override the locale detected by `parseWXR`. */
+ forceLocale?: string;
+ /** WordPress post_types to include in addition to the defaults. */
+ extraIncludeTypes?: string[];
+ /** WordPress post_types to skip explicitly (merged with the default skip list). */
+ skipTypes?: string[];
+ /** Emit `attachment` documents as entries instead of filtering them. Commit 4
+ * will reshape attachments into the `assets/assets.json` index — until then,
+ * the default behavior is `false` (filter). */
+ includeAttachments?: boolean;
+ /** Label written to `contentful-space.json` describing the source. */
+ source?: string;
+ /** Print progress and inference warnings to stdout. */
+ verbose?: boolean;
+ /** Pass raw WXR XML content directly (used in tests). Takes precedence over `input`. */
+ inputContent?: string;
+}
+
+export interface PullWordpressResult {
+ schemas: ContentTypeDefinition[];
+ documents: Document[];
+ /** Site metadata as parsed from the WXR `` block. */
+ site: WXRSite;
+ /** Inference warnings (type conflicts, array-collapse fallbacks, etc.). */
+ warnings: InferenceWarning[];
+ output: string;
+}
+
+/** WordPress content types we never want as entries — Contentful has no
+ * equivalent. `attachment` is in here by default because commit 4 will
+ * handle assets separately (the user opts in via `--include-attachments`). */
+const DEFAULT_SKIP_TYPES = new Set([
+ 'attachment',
+ 'nav_menu_item',
+ 'wp_navigation',
+ 'wp_template',
+ 'wp_template_part',
+ 'wp_global_styles',
+ 'wp_block',
+ 'oembed_cache',
+ 'customize_changeset',
+ 'custom_css',
+ 'revision',
+]);
+
+/** Stable id prefixes per WP content type. Predictable so the ref mapper
+ * (commit 3) can target them without needing a lookup at write time. */
+function deriveId(doc: Document): string | undefined {
+ const data = (doc.data || {}) as Record;
+ switch (doc.contentType) {
+ case 'author': {
+ const login = typeof data.login === 'string' ? data.login : doc.id;
+ return login ? `wp_author_${sanitize(login)}` : undefined;
+ }
+ case 'category': {
+ const slug = typeof data.slug === 'string' ? data.slug : doc.id;
+ return slug ? `wp_category_${sanitize(slug)}` : undefined;
+ }
+ case 'tag': {
+ const slug = typeof data.slug === 'string' ? data.slug : doc.id;
+ return slug ? `wp_tag_${sanitize(slug)}` : undefined;
+ }
+ default:
+ return doc.id ? `wp_${sanitize(doc.id)}` : undefined;
+ }
+}
+
+function sanitize(s: string): string {
+ return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60);
+}
+
+export async function pullWordpress(options: PullWordpressOptions): Promise {
+ const {
+ input,
+ output,
+ defaultLocale,
+ forceLocale,
+ extraIncludeTypes = [],
+ skipTypes = [],
+ includeAttachments = false,
+ source,
+ verbose = false,
+ inputContent,
+ } = options;
+
+ if (inputContent === undefined && !input) {
+ throw new Error('pullWordpress: either `input` (file path) or `inputContent` (WXR XML string) is required.');
+ }
+
+ // 1. Parse WXR XML.
+ const xml = inputContent !== undefined
+ ? inputContent
+ : await readFileText(input as string);
+
+ // parseWXR expects a file path; parseWXRString handles raw content.
+ // We import parseWXR (file path) but re-route through string when needed.
+ const { parseWXRString } = await import('./wxr-reader.js');
+ const result = inputContent !== undefined
+ ? parseWXRString(xml, forceLocale ? { locale: forceLocale } : {})
+ : parseWXR(input as string, forceLocale ? { locale: forceLocale } : {});
+
+ const { site, documents } = result;
+ if (verbose) {
+ process.stdout.write(` Read ${documents.length} document(s) from WXR (site: "${site.title}", language: ${site.language})\n`);
+ }
+
+ // 2. Determine the effective base locale.
+ const wxrLocale = site.language ? site.language.split('-')[0] : null;
+ const effectiveBaseLocale = forceLocale || defaultLocale || wxrLocale || 'en';
+
+ // 3. Filter unwanted content types. The default skip set drops nav menus,
+ // templates, attachments (until commit 4 turns them into assets), etc.
+ const skipSet = new Set([
+ ...DEFAULT_SKIP_TYPES,
+ ...skipTypes,
+ ]);
+ if (includeAttachments) skipSet.delete('attachment');
+ const filteredDocs = documents.filter(d => !skipSet.has(d.contentType) || extraIncludeTypes.includes(d.contentType));
+
+ // 4. Assign stable, prefixed ids so the ref mapper (commit 3) can target
+ // cross-document refs without a runtime lookup.
+ const idAssignedDocs: Document[] = filteredDocs.map(d => {
+ const id = deriveId(d) || d.id;
+ return { ...d, id };
+ });
+
+ // 5. Tag every doc with a locale (the WXR doesn't carry per-doc locales by
+ // default; locale plugins like Polylang land in commit 6). For now,
+ // every doc gets `effectiveBaseLocale`.
+ const localizedDocs: Document[] = idAssignedDocs.map(d => ({
+ ...d,
+ locale: d.locale || effectiveBaseLocale,
+ }));
+
+ // 6. Multi-locale detection (no-op for plain WP — the shared detector only
+ // fires when field values are locale-shaped objects, which Polylang
+ // doesn't produce by default).
+ const { locales: detectedLocales, localizedFields } = detectLocales(localizedDocs);
+ const effectiveLocales = detectedLocales.length > 0 ? detectedLocales : [effectiveBaseLocale];
+ const expandedDocs = expandDocumentsByLocale(
+ localizedDocs,
+ effectiveLocales,
+ localizedFields,
+ { baseLocale: effectiveBaseLocale },
+ );
+
+ // 7. Group by content type and infer schemas.
+ const typeGroups = new Map();
+ for (const d of expandedDocs) {
+ if (!typeGroups.has(d.contentType)) typeGroups.set(d.contentType, []);
+ (typeGroups.get(d.contentType) as Document[]).push(d);
+ }
+ const samplesByType = new Map(
+ [...typeGroups.entries()].map(([typeId, docs]) => [
+ typeId,
+ docs.map(d => (d.data || {}) as Record),
+ ]),
+ );
+ const { schemas, warnings } = inferSchemas(samplesByType, { localizedFields });
+
+ if (verbose && warnings.length > 0) {
+ process.stdout.write(` ${warnings.length} inference warning(s):\n`);
+ for (const w of warnings) {
+ process.stdout.write(` • [${w.contentTypeId}.${w.fieldId}] ${w.message}\n`);
+ }
+ }
+
+ // 8. Write to disk.
+ mkdirSync(output, { recursive: true });
+
+ const sourceLabel = source || input || '';
+ const localeConfig = {
+ baseLocale: effectiveBaseLocale,
+ locales: effectiveLocales,
+ pulledAt: new Date().toISOString(),
+ source: sourceLabel,
+ sourceFormat: 'wordpress-wxr',
+ site: {
+ title: site.title,
+ url: site.url,
+ language: site.language,
+ },
+ };
+ writeFileSync(
+ join(output, 'contentful-space.json'),
+ JSON.stringify(localeConfig, null, 2) + '\n',
+ 'utf-8',
+ );
+
+ const schemasDir = join(output, 'schemas');
+ mkdirSync(schemasDir, { recursive: true });
+ for (const schema of schemas) {
+ const filePath = join(schemasDir, `${schema.id}.js`);
+ const content = `/**\n * ${schema.name}\n * Pulled from WordPress (${sourceLabel})\n */\nexport default ${JSON.stringify(schema, null, 2)};\n`;
+ writeFileSync(filePath, content, 'utf-8');
+ }
+
+ if (expandedDocs.length > 0) {
+ const dataDir = join(output, 'data');
+ mkdirSync(dataDir, { recursive: true });
+ const ndjson = expandedDocs.map(d => JSON.stringify(d)).join('\n') + '\n';
+ writeFileSync(join(dataDir, 'entries.ndjson'), ndjson, 'utf-8');
+ }
+
+ if (verbose) {
+ process.stdout.write(
+ ` Wrote ${schemas.length} schema(s) + ${expandedDocs.length} entry/entries (${effectiveLocales.length} locale${effectiveLocales.length === 1 ? '' : 's'}) to ${output}\n`,
+ );
+ }
+
+ return { schemas, documents: expandedDocs, site, warnings, output: resolve(output) };
+}
+
+// ── Helpers ──────────────────────────────────────────────────────
+
+async function readFileText(filePath: string): Promise {
+ const resolved = resolve(filePath);
+ if (!existsSync(resolved)) {
+ throw new Error(`pullWordpress: WXR file not found: ${resolved}`);
+ }
+ const fs = await import('node:fs/promises');
+ return fs.readFile(resolved, 'utf-8');
+}
From 12fe15a610460b681158c4045b9cc25a3a6b2bc8 Mon Sep 17 00:00:00 2001
From: Joshua Pozos <25085383+JoshuaPozos@users.noreply.github.com>
Date: Sun, 10 May 2026 14:55:32 -0600
Subject: [PATCH 3/8] feat(pull-wordpress): rewrite refs to Link Entry + infer
linkContentType
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`mapWordpressRefs` walks every WordPress document and rewrites
cross-document references into the Contentful Entry-link sys shape:
post.author (login) → Link Entry wp_author_
post.categories[] (slugs) → Array wp_category_
post.tags[] (slugs) → Array wp_tag_
category.parent (slug) → Link Entry wp_category_
The same pass harvests a `linkTargets` map keyed by
`contentTypeId.fieldId`, mirroring `pull-sanity`'s contract. The shared
`inferSchemas` consumes it to attach real `linkContentType` validations
to the inferred fields. Orphan refs (target id missing from the corpus —
e.g., a post tagged with a slug whose `` was filtered by the
skip set) stay rewritten as dangling links but don't pollute the
validation list.
`pullWordpress` runs the mapper between id assignment and schema
inference, so by the time `inferSchemas` sees the data, every ref is
already Contentful-shaped and the shared shape detector picks it up
without needing WordPress-specific knowledge.
### Breaking (pre-1.0.0)
`parseWXR` / `readWXR` now emit slugs (the `nicename` attribute) in
`data.categories` and `data.tags` instead of display names (CDATA
content). The slug is WordPress's canonical taxonomy identifier — what
any cross-reference resolution would expect. The display name remains
available on the matching `` / `` document.
Public test fixture in `wxr-reader.test.ts` updated to assert slugs.
+14 unit tests (625 total). 5 `file:`-linked examples regression-free
(5/25, 6/20, 5/18, 23/69, 7/21).
---
CHANGELOG.md | 6 +-
src/index.ts | 2 +
src/wordpress/pull-wordpress.test.ts | 47 ++++++++
src/wordpress/pull-wordpress.ts | 20 ++--
src/wordpress/wp-ref-mapper.test.ts | 154 +++++++++++++++++++++++++++
src/wordpress/wp-ref-mapper.ts | 129 ++++++++++++++++++++++
src/wordpress/wxr-reader.test.ts | 13 ++-
src/wordpress/wxr-reader.ts | 12 ++-
8 files changed, 370 insertions(+), 13 deletions(-)
create mode 100644 src/wordpress/wp-ref-mapper.test.ts
create mode 100644 src/wordpress/wp-ref-mapper.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c64ff68..1dacf9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,8 +5,12 @@ All notable changes to `content-model-simulator` are documented here.
## [Unreleased]
### Added
-- **`cms-sim pull-wordpress`** — new CLI subcommand and public `pullWordpress()` API that convert a WordPress WXR XML export (from wp-admin → Tools → Export → All content) into the same on-disk shape as `cms-sim pull` for Contentful spaces: `contentful-space.json` + `schemas/.js` + `data/entries.ndjson`. Reuses `parseWXR` for parsing and the shared `inferSchemas` / `detectLocales` / `expandDocumentsByLocale` from `src/shared/`. Flags: `--input` (required), `--output` (default `./wordpress-export`), `--default-locale`, `--force-locale`, `--include-attachments`, `--skip-types=`, `--source`, `--verbose`. Stable id prefixes per content type: `wp_` for posts/pages, `wp_author_`, `wp_category_`, `wp_tag_` — so cross-document refs (commit 3) have predictable targets. Default skip set drops `nav_menu_item` / `wp_navigation` / `wp_template` / `wp_template_part` / `wp_global_styles` / `wp_block` / `oembed_cache` / `customize_changeset` / `custom_css` / `revision` / `attachment` (the last opts back in via `--include-attachments` and will be reshaped into `assets/assets.json` in a follow-up commit). **Status:** skeleton + output contract — ref → Link Entry rewriting, asset linking, Gutenberg/Classic HTML → RichText, and Polylang locale detection land in follow-up commits.
+- **`cms-sim pull-wordpress`** — new CLI subcommand and public `pullWordpress()` API that convert a WordPress WXR XML export (from wp-admin → Tools → Export → All content) into the same on-disk shape as `cms-sim pull` for Contentful spaces: `contentful-space.json` + `schemas/.js` + `data/entries.ndjson`. Reuses `parseWXR` for parsing and the shared `inferSchemas` / `detectLocales` / `expandDocumentsByLocale` from `src/shared/`. Flags: `--input` (required), `--output` (default `./wordpress-export`), `--default-locale`, `--force-locale`, `--include-attachments`, `--skip-types=`, `--source`, `--verbose`. Stable id prefixes per content type: `wp_` for posts/pages, `wp_author_`, `wp_category_`, `wp_tag_` — so cross-document refs have predictable targets. Default skip set drops `nav_menu_item` / `wp_navigation` / `wp_template` / `wp_template_part` / `wp_global_styles` / `wp_block` / `oembed_cache` / `customize_changeset` / `custom_css` / `revision` / `attachment` (the last opts back in via `--include-attachments` and will be reshaped into `assets/assets.json` in a follow-up commit). **Status:** skeleton + ref rewriting shipped; asset linking, Gutenberg/Classic HTML → RichText, and Polylang locale detection land in follow-up commits.
- **10 unit tests** for `pullWordpress` covering: three pieces written, `contentful-space.json` shape with WP site metadata + `sourceFormat: 'wordpress-wxr'`, `--force-locale` precedence, one schema per surviving content type sorted, Contentful schema shape with `id` / `name` / `fields[]`, prefixed-id assignment across post / page / author / category / tag, default skip set (attachments + nav_menu_item filtered), `--include-attachments` opt-in, in-memory return shape, missing-input error.
+- **`mapWordpressRefs(documents)`** — pure transform in `src/wordpress/wp-ref-mapper.ts`. Rewrites every WordPress cross-document reference into a Contentful Entry-link sys shape: `post.author` (login) → `Link Entry pointing at wp_author_`; `post.categories[]` (slugs) → `Array` pointing at `wp_category_`; `post.tags[]` (slugs) → `Array` pointing at `wp_tag_`; `category.parent` (slug) → self-referential `Link Entry`. Returns the rewritten documents plus a `linkTargets` map (`contentTypeId.fieldId` → set of resolved target content types) which the shared `inferSchemas` consumes to attach real `linkContentType` validations to Link fields and `Array.items`. Orphan refs (target id missing from the corpus, e.g., a post tagged with a slug whose `` block was filtered) stay rewritten as dangling links — they just don't pollute the validation list. `pullWordpress` runs the mapper between id assignment and schema inference. **+14 unit tests** (3 in `pull-wordpress.test.ts` end-to-end + 11 in `wp-ref-mapper.test.ts`).
+
+### Changed (breaking, pre-1.0.0)
+- **`parseWXR` / `readWXR` now emit category and tag slugs in `data.categories` / `data.tags`, not display names.** Previously `extractInlineTerms` returned the CDATA value (e.g., `'Technology'`, `'JavaScript'`); now it returns the `nicename` attribute (e.g., `'tech'`, `'javascript'`). The slug is WordPress's canonical taxonomy identifier — it's what cross-references in `pull-wordpress` resolve against, and what any reasonable downstream tool would want. The display name is still available on the matching `` / `` document (which is parsed separately and ends up as `category.data.name` / `tag.data.name`). One inline test in `wxr-reader.test.ts` updated to assert slugs.
### Changed
- **`inferSchemas` and locale detection promoted to `src/shared/`** (originally named `sanity-schema-inference` / `sanity-locale-expander` in v0.6.0 because Sanity was the only caller). Both modules are source-agnostic — `inferSchemas` recognizes both Sanity-shaped (`{_type: 'reference'}`, `{_type: 'image'}`) and Contentful-shaped (`{sys: Link Entry/Asset}`) values, and the locale detector inspects only field-value shape. Moved to `src/shared/schema-inference.{ts,test.ts}` and `src/shared/locale-expander.{ts,test.ts}`. Public API export `inferSanitySchemas` is replaced by the unprefixed `inferSchemas` (breaking, but pre-1.0.0 minor — see SemVer caveat in README). Imports in `pull-sanity.ts` updated. No runtime/behavior change; 601 tests still pass.
diff --git a/src/index.ts b/src/index.ts
index 1804a26..ffcaa56 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -111,6 +111,8 @@ export { analyzeWXR, generateScaffold, scaffoldFromWXR } from './wordpress/wxr-s
export type { ScaffoldAnalysis, AnalyzedContentType, AnalyzedField, ScaffoldOptions, ScaffoldOutput } from './wordpress/wxr-scaffold.js';
export { pullWordpress } from './wordpress/pull-wordpress.js';
export type { PullWordpressOptions, PullWordpressResult } from './wordpress/pull-wordpress.js';
+export { mapWordpressRefs } from './wordpress/wp-ref-mapper.js';
+export type { WordpressRefMapResult } from './wordpress/wp-ref-mapper.js';
// ── Shared (source-agnostic pull plumbing) ──────────────────────
export { inferSchemas } from './shared/schema-inference.js';
diff --git a/src/wordpress/pull-wordpress.test.ts b/src/wordpress/pull-wordpress.test.ts
index 0c05d8f..1ace24d 100644
--- a/src/wordpress/pull-wordpress.test.ts
+++ b/src/wordpress/pull-wordpress.test.ts
@@ -219,3 +219,50 @@ describe('pullWordpress — output contract', () => {
);
});
});
+
+// ── commit 3: ref rewriting + linkContentType inference ──────────
+
+describe('pullWordpress — refs (commit 3)', () => {
+ it('rewrites post.author to a Link Entry pointing at wp_author_', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const lines = fs.readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8').trim().split('\n');
+ const post = lines.map(l => JSON.parse(l)).find(e => e.id === 'wp_42');
+ assert.deepEqual(post.data.author, {
+ sys: { type: 'Link', linkType: 'Entry', id: 'wp_author_jdoe' },
+ });
+ });
+
+ it('rewrites post.categories / post.tags to arrays of Link Entry sys', async () => {
+ await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const post = fs
+ .readFileSync(path.join(tmpDir, 'data', 'entries.ndjson'), 'utf-8')
+ .trim()
+ .split('\n')
+ .map(l => JSON.parse(l))
+ .find(e => e.id === 'wp_42');
+ assert.deepEqual(post.data.categories, [
+ { sys: { type: 'Link', linkType: 'Entry', id: 'wp_category_tech' } },
+ ]);
+ assert.deepEqual(post.data.tags, [
+ { sys: { type: 'Link', linkType: 'Entry', id: 'wp_tag_javascript' } },
+ ]);
+ });
+
+ it('adds linkContentType validations to post schema fields based on real refs', async () => {
+ const result = await pullWordpress({ output: tmpDir, inputContent: MINIMAL_WXR });
+ const postSchema = result.schemas.find(s => s.id === 'post')!;
+
+ const author = postSchema.fields.find(f => f.id === 'author')!;
+ assert.equal(author.type, 'Link');
+ assert.equal(author.linkType, 'Entry');
+ assert.deepEqual(author.validations?.[0]?.linkContentType, ['author']);
+
+ const categories = postSchema.fields.find(f => f.id === 'categories')!;
+ assert.equal(categories.type, 'Array');
+ assert.equal(categories.items?.linkType, 'Entry');
+ assert.deepEqual(categories.items?.validations?.[0]?.linkContentType, ['category']);
+
+ const tags = postSchema.fields.find(f => f.id === 'tags')!;
+ assert.deepEqual(tags.items?.validations?.[0]?.linkContentType, ['tag']);
+ });
+});
diff --git a/src/wordpress/pull-wordpress.ts b/src/wordpress/pull-wordpress.ts
index 05a27fe..c8ba5f3 100644
--- a/src/wordpress/pull-wordpress.ts
+++ b/src/wordpress/pull-wordpress.ts
@@ -23,6 +23,7 @@ import { join, resolve } from 'node:path';
import { parseWXR } from './wxr-reader.js';
import type { WXRSite } from './wxr-reader.js';
+import { mapWordpressRefs } from './wp-ref-mapper.js';
import { inferSchemas } from '../shared/schema-inference.js';
import type { InferenceWarning } from '../shared/schema-inference.js';
import { detectLocales, expandDocumentsByLocale } from '../shared/locale-expander.js';
@@ -158,17 +159,22 @@ export async function pullWordpress(options: PullWordpressOptions): Promise !skipSet.has(d.contentType) || extraIncludeTypes.includes(d.contentType));
- // 4. Assign stable, prefixed ids so the ref mapper (commit 3) can target
- // cross-document refs without a runtime lookup.
+ // 4. Assign stable, prefixed ids so the ref mapper has predictable
+ // targets for cross-document references.
const idAssignedDocs: Document[] = filteredDocs.map(d => {
const id = deriveId(d) || d.id;
return { ...d, id };
});
- // 5. Tag every doc with a locale (the WXR doesn't carry per-doc locales by
- // default; locale plugins like Polylang land in commit 6). For now,
- // every doc gets `effectiveBaseLocale`.
- const localizedDocs: Document[] = idAssignedDocs.map(d => ({
+ // 5. Rewrite cross-document references (post.author / categories[] / tags[] /
+ // category.parent) into Contentful Entry-link sys shapes, and harvest the
+ // per-(content type, field) set of target content types for the inference
+ // `linkContentType` derivation.
+ const { documents: refMappedDocs, linkTargets } = mapWordpressRefs(idAssignedDocs);
+
+ // 6. Tag every doc with a locale (the WXR doesn't carry per-doc locales by
+ // default; locale plugins like Polylang land in a follow-up commit).
+ const localizedDocs: Document[] = refMappedDocs.map(d => ({
...d,
locale: d.locale || effectiveBaseLocale,
}));
@@ -197,7 +203,7 @@ export async function pullWordpress(options: PullWordpressOptions): Promise (d.data || {}) as Record),
]),
);
- const { schemas, warnings } = inferSchemas(samplesByType, { localizedFields });
+ const { schemas, warnings } = inferSchemas(samplesByType, { linkTargets, localizedFields });
if (verbose && warnings.length > 0) {
process.stdout.write(` ${warnings.length} inference warning(s):\n`);
diff --git a/src/wordpress/wp-ref-mapper.test.ts b/src/wordpress/wp-ref-mapper.test.ts
new file mode 100644
index 0000000..6b05a9a
--- /dev/null
+++ b/src/wordpress/wp-ref-mapper.test.ts
@@ -0,0 +1,154 @@
+/**
+ * Tests for src/wordpress/wp-ref-mapper.js — v0.6.1 commit 3.
+ *
+ * Covers: single + array refs (categories, tags, author, category.parent)
+ * are rewritten to Link Entry sys, orphan refs (target id missing) stay
+ * dangling without polluting linkTargets, pass-through for non-ref
+ * fields, and no input mutation.
+ */
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { mapWordpressRefs } from './wp-ref-mapper.js';
+import type { Document } from '../types.js';
+
+function doc(id: string, ct: string, data: Record): Document {
+ return { id, contentType: ct, data };
+}
+
+const FIXTURE: Document[] = [
+ doc('wp_author_jdoe', 'author', { login: 'jdoe', displayName: 'Jane Doe' }),
+ doc('wp_category_tech', 'category', { name: 'Technology', slug: 'tech', parent: null }),
+ doc('wp_category_news', 'category', { name: 'News', slug: 'news', parent: 'tech' }),
+ doc('wp_tag_javascript', 'tag', { name: 'JavaScript', slug: 'javascript' }),
+ doc('wp_42', 'post', {
+ title: 'Hello',
+ slug: 'hello',
+ body: '