Skip to content

feat: v0.6.1 — pull-wordpress adapter#13

Merged
JoshuaPozos merged 8 commits into
mainfrom
feat/v0.6.1-pull-wordpress
May 10, 2026
Merged

feat: v0.6.1 — pull-wordpress adapter#13
JoshuaPozos merged 8 commits into
mainfrom
feat/v0.6.1-pull-wordpress

Conversation

@JoshuaPozos

Copy link
Copy Markdown
Owner

Summary

  • New cms-sim pull-wordpress subcommand: single-command path from a WordPress WXR XML export to the same Contentful-shape output that cms-sim pull and cms-sim pull-sanity produce.
  • Promotes inferSchemas + locale detection to src/shared/ (source-agnostic) so future adapters can reuse them without an awkward sanity- import path.
  • 659 tests (+149 vs v0.5.0). Zero new runtime dependencies. 6 file:-linked examples regression-free.

What's inside

Commit Topic
1bdeb2f refactor: promote schema-inference + locale-expander to src/shared/
665fb63 feat: pull-wordpress skeleton + output contract + 10 tests
12fe15a feat: WP refs → Link Entry + linkContentType inference + 14 tests
2f3ff58 feat: attachments → assets.json + featured_image → Link Asset + 19 tests
9cc5516 feat: Gutenberg / Classic HTML body → Contentful RichText + 13 tests
013b519 feat: Polylang multi-locale detection + per-doc-vs-per-field fan-out fix
e7abdea docs: README workflow + capability row + tests badge bump (510 → 659)
149d630 chore(release): v0.6.1 bump + CHANGELOG date

Breaking (pre-1.0.0)

parseWXR / readWXR now emit category and tag slugs in data.categories / data.tags (the nicename attribute), not display names (the CDATA content). The slug is WordPress's canonical taxonomy identifier — what cross-references resolve against. Display names remain on the matching <wp:category> / <wp:term> documents. Public API export inferSanitySchemas is replaced by the unprefixed inferSchemas.

Validation

  • 659 unit + e2e tests passing locally (was 510 in v0.5.0). Single CI run will re-verify.
  • Pre-merge regression sweep against 6 file:-linked examples: from-scratch (5/25), sanity-migration (6/20/17W), wp-migration (5/18/24W), pull-contentful cached (23/69), sanity-pull (7/21/2L), wp-pull (5/11/2W against the new synthetic WXR fixture). Warning counts identical to v0.6.0 baseline.
  • npm audit: 0 vulnerabilities (prod + dev).
  • New runnable example at example-wp-pull/ (workspace level, file:-linked, mirrors example-sanity-pull/) with a synthetic 12-document WXR fixture exercising authors, nested categories, tags, Gutenberg + Classic bodies, attachments, featured images, and the nav-menu filter.

Test plan

  • npm test — 659 passing locally
  • npm run build — clean
  • 6 examples regression-free vs v0.6.0
  • npm audit clean
  • CI ("Test (Node 22)", "Test (Node 24)") to confirm

Merge note

Please merge commit rather than squash so the v0.6.1 tag (already on remote, pointing at 149d630) stays reachable from main without retagging. Same pattern as v0.5.0 (222aabf) and v0.6.0 (281a6d8).

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.
…leton)

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/<id>.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_<postId>             e.g. wp_42
  - author       → wp_author_<login>       e.g. wp_author_jdoe
  - category     → wp_category_<slug>      e.g. wp_category_tech
  - tag          → wp_tag_<slug>           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<Link Entry>,
    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.
`mapWordpressRefs` walks every WordPress document and rewrites
cross-document references into the Contentful Entry-link sys shape:

  post.author       (login)    →  Link Entry  wp_author_<login>
  post.categories[] (slugs)    →  Array<Link Entry> wp_category_<slug>
  post.tags[]       (slugs)    →  Array<Link Entry> wp_tag_<slug>
  category.parent   (slug)     →  Link Entry  wp_category_<slug>

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 `<wp:term>` 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 `<wp:category>` / `<wp:term>` 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).
…nk Asset

`mapWordpressAssets` extracts every WordPress `attachment` document into
the Contentful-shaped asset metadata that pull-contentful writes to
`assets/assets.json`:

  { id, title, fileName, contentType, url, size }

Featured-image references on posts (the `_thumbnail_id` post-meta key
that WordPress uses to point at an attachment by post_id) are rewritten
into `featuredImage: { sys: Link Asset }` on the parent document, with
the asset id matching the attachment's prefixed entry id (`wp_<postId>`)
so `assets/assets.json` and the in-entry Link sys share one identifier.

The shared `inferSchemas` shape detector picks up `featuredImage` as
`Link Asset` automatically — no WP-specific knowledge added to inference.
Dangling featured-image refs (target attachment missing from the corpus)
leave the original meta string in place rather than emit a bogus link.

`pullWordpress`:
- runs asset extraction BEFORE filtering (so attachments always reach
  the assets array even though they're dropped from `entries.ndjson`)
- writes `<output>/assets/assets.json` only when at least one
  attachment was present
- still honors `--include-attachments` (attachments are re-added to
  `entries.ndjson` in addition to the assets index)

WXR reader change: extractPostMeta gains an `INCLUDED_INTERNAL_META_KEYS`
whitelist so `_thumbnail_id`, `_wp_attached_file`, `_wp_attachment_metadata`,
and `_wp_page_template` flow through. Non-breaking — only adds keys.

+19 unit tests (644 total, was 625). 5 `file:`-linked examples
regression-free (5/25, 6/20, 5/18, 23/69, 7/21). npm audit: 0
vulnerabilities.
`mapWordpressBodies` walks every WordPress document and converts the
`body` and `excerpt` fields (raw HTML strings, already Gutenberg-stripped
by `parseWXR`) into Contentful RichText documents via the existing
`htmlToRichText` walker from `transform/rich-text.ts`.

Block-element mapping:
  <p>             →  paragraph
  <h1>..<h6>      →  heading-1..heading-6
  <blockquote>    →  blockquote
  <ul>/<ol>/<li>  →  unordered-list / ordered-list / list-item
  <pre>           →  paragraph (preserved as plain text)
  <table>/<tr>/<td>/<th>  →  table / table-row / table-cell / table-header-cell

Inline marks:
  <strong>/<b>    →  bold
  <em>/<i>        →  italic
  <u>             →  underline
  <code>          →  code
  <s>/<del>/<strike>  →  strikethrough
  <sup>/<sub>     →  superscript / subscript
  <a href>        →  hyperlink (data.uri preserved)
  <img>           →  embedded-asset-block placeholder

Empty / whitespace-only body/excerpt fields are dropped entirely instead
of being emitted as empty RichText documents — keeps schema inference
from carrying a stale `Text` field that every entry leaves null. After
conversion, the shared shape detector classifies the field as
`RichText` on the inferred schema.

Why convert at pull-time (not let the simulator do it):
  (a) the inferred schema gets a typed `RichText` field
  (b) `cms-sim to-import` emits a valid RichText payload for
      `contentful-import` directly
  (c) the pull-* adapters' output shape stays uniform across
      Sanity (Portable Text → RichText) and WordPress (HTML → RichText)

+13 unit tests (656 total, was 644). 5 `file:`-linked examples
regression-free (5/25, 6/20, 5/18, 23/69, 7/21).
`parseWXR` now extracts the Polylang `language` taxonomy alongside
categories and tags. When a WordPress `<item>` carries
`<category domain="language" nicename="fr">`, the document's `locale`
field is set to that slug (overriding the channel-level fallback).
Multiple posts in different languages each get their own per-doc locale.

`pullWordpress` collects all distinct per-doc locales into
`contentful-space.json` (`locales[]`), so a bilingual / trilingual
WordPress site exports as multi-locale automatically — no transforms
needed.

`--force-locale` overrides Polylang (and the channel language) for
callers that want to flatten everything to one locale.

### Shared expand-by-locale fix

The shared `expandDocumentsByLocale` previously fanned every doc out
to every detected locale, regardless of whether the doc actually
carried locale-shaped field values. That's the right behavior for
Sanity (where one doc with `{en, es}` field values represents N
locale variants), but wrong for WordPress+Polylang (where each post
already represents one locale and shouldn't be duplicated).

Fix: per-doc decision. If at least one of the doc's fields has a
`{en, es}` shape, fan out across all locales picking each locale's
value. Otherwise emit a single variant with the doc's own `locale`
tag. Sanity behavior unchanged; WP+Polylang no longer duplicates
content.

+3 unit tests covering: per-item language tagging, all-locales
captured in `contentful-space.json`, and `--force-locale` overriding
Polylang. 659 tests total (was 656).

5 `file:`-linked examples regression-free (5/25, 6/20, 5/18, 23/69,
7/21 — all locales 1 or 2 as expected). npm audit: 0 vulnerabilities.
Adds a "Pull from WordPress" row to the Core capabilities table and a
`cms-sim pull-wordpress — zero-config WordPress → Contentful` subsection
under Real workflows § 3, alongside the existing `pull-sanity`
documentation. Lists what the adapter handles automatically:
default content-type filtering, stable prefixed ids, reference rewriting,
attachments → assets.json, featured_image → Link Asset, Gutenberg /
Classic HTML body → RichText, and Polylang locale detection. Points at
the runnable walk-through in `example-wp-pull/`.

Tests badge bumped from 510 → 659 (was stale since v0.5.0; the
CLAUDE.md release process now lists this as a "Siempre" check).

A companion runnable example lives at the workspace level in
`example-wp-pull/` (file:-linked, not part of this repo) with a
synthetic 12-document WXR fixture exercising authors, nested
categories, tags, Gutenberg + Classic bodies, attachments, featured
images, and the nav-menu filter. End-to-end against the sample:
5 CTs / 11 entries / 2 assets / 0 errors / 2 NULL_FIELD warnings (on
optional fields like the About page's author and root-category parent).
Bumps version to 0.6.1 and closes the [Unreleased] CHANGELOG section
with the 2026-05-10 date.

v0.6.1 ships `cms-sim pull-wordpress`: a single-command path from a
WordPress WXR XML export to the same on-disk shape that `cms-sim pull`
produces for Contentful spaces, so simulate / to-import / diff consume
it unchanged. Reference rewriting (taxonomies + author + parent),
attachments → assets.json, featured-image → Link Asset, Gutenberg /
Classic HTML body → Contentful RichText, and Polylang locale detection
are all automatic.

Also promotes `inferSchemas` and `detectLocales` / `expandDocumentsByLocale`
from `src/sanity/` to `src/shared/` (source-agnostic), fixes the
expand-by-locale fan-out so Sanity-style (per-field locales) and
WordPress-style (per-doc locale tag) coexist correctly, and changes
`parseWXR` to emit category/tag slugs (the `nicename` attribute) instead
of display names — the canonical taxonomy identifier WordPress uses.

659 tests passing (was 510 in v0.5.0; +149 net). Zero new runtime
dependencies. 6 file:-linked examples regression-free.
@JoshuaPozos
JoshuaPozos merged commit 8dfeefb into main May 10, 2026
9 checks passed
@JoshuaPozos
JoshuaPozos deleted the feat/v0.6.1-pull-wordpress branch May 10, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant