wp-migrate-core 0.1.0-demo → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-09-03
4
+
5
+ This release makes inspection safer when an export or output path is not quite what the CLI expects.
6
+
7
+ - Writes the inspection plan and repair report as one staged handoff, then moves them into place together.
8
+ - Refuses an existing inspection output path without changing its files.
9
+ - Skips missing, invalid, and duplicate WordPress post IDs and explains each skipped item in the repair queue.
10
+ - Keeps malformed numeric XML entities intact instead of letting them crash WXR parsing.
11
+ - Adds regression coverage for the parser edge cases and the CLI's no-clobber behavior.
12
+
13
+ ## [0.1.0-demo] - 2026-09-02
14
+
15
+ The first public demo: inspect a WordPress WXR export, surface unsupported migration work, and generate a deliberately private Astro handoff for human review.
16
+
17
+ [0.1.1]: https://github.com/lame13/wp-migrate-core/compare/v0.1.0-demo...v0.1.1
18
+ [0.1.0-demo]: https://github.com/lame13/wp-migrate-core/releases/tag/v0.1.0-demo
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # wp-migrate-core
2
2
 
3
- `wp-migrate-core` is an intentionally limited, local CLI prototype for inspecting a WordPress WXR export and creating an Astro migration handoff. Version `0.1.0-demo` is published under npm's `demo` tag.
3
+ `wp-migrate-core` is an intentionally limited, local CLI prototype for inspecting a WordPress WXR export and creating an Astro migration handoff. Version `0.1.1` uses npm's `demo` tag.
4
4
 
5
5
  It can help expose what must be rebuilt. It does not produce a finished or production-ready WordPress replacement.
6
6
 
@@ -29,9 +29,9 @@ wp-migrate-core report <export.xml> [--out migration-report.html]
29
29
  wp-migrate-core demo [--out wp-migrate-core-demo]
30
30
  ```
31
31
 
32
- `inspect` writes `migration-plan.json` and a local repair report. `convert` writes an Astro-shaped project plus its migration manifest and repair report. `report` writes only the repair report. `demo` runs both inspection and conversion against the bundled fixture.
32
+ `inspect` validates the WXR file before writing. It creates `migration-plan.json` and a local repair report as a pair in a new output directory: if either output cannot be created, it leaves neither newly created file behind and never overwrites an existing `--out` path. `convert` writes an Astro-shaped project plus its migration manifest and repair report. `report` writes only the repair report. `demo` runs both inspection and conversion against the bundled fixture.
33
33
 
34
- The `astro` target is the only enabled target in `0.1.0-demo`. `next` and `nuxt` are registered as planned targets and deliberately fail when selected.
34
+ The `astro` target is the only enabled target. `next` and `nuxt` are registered as planned targets and deliberately fail when selected.
35
35
 
36
36
  ## What the demo handles
37
37
 
@@ -40,6 +40,7 @@ The `astro` target is the only enabled target in `0.1.0-demo`. `next` and `nuxt`
40
40
  - A bounded subset of serialized Gutenberg core blocks.
41
41
  - Elementor `_elementor_data` in WXR post metadata, including simple containers, headings, text, images, buttons, dividers, and spacers.
42
42
  - An explicit repair queue for dynamic Gutenberg blocks, unsupported shortcodes, Elementor forms and queries, and unknown Elementor widgets.
43
+ - Items with missing, invalid, or duplicate `wp:post_id` values are skipped and added to the repair report. Correct the export before relying on the handoff.
43
44
 
44
45
  Each detected construct is marked as `native`, `legacy-html`, `manual`, or `blocked`. Unsupported behavior stays visible in the generated output and repair queue; it is not counted as a successful conversion.
45
46
 
@@ -6,6 +6,6 @@ export const targetAvailability = {
6
6
  export function assertTargetEnabled(target) {
7
7
  const availability = targetAvailability[target];
8
8
  if (!availability.enabled) {
9
- throw new Error(`${availability.label} is planned but disabled in 0.1.0-demo. Use --target astro.`);
9
+ throw new Error(`${availability.label} is planned but not available in this release. Use --target astro.`);
10
10
  }
11
11
  }
package/dist/src/cli.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
2
+ import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertTargetEnabled, targetAvailability } from "./adapters.js";
6
6
  import { parseWxr } from "./core.js";
7
7
  import { generateAstroProject } from "./generate.js";
8
8
  import { writeReport } from "./report.js";
9
9
  function usage() {
10
- return `WP Migrate Core 0.1.0-demo
10
+ return `WP Migrate Core 0.1.1
11
11
 
12
12
  Usage:
13
13
  wp-migrate-core inspect <export.xml> [--out migration-plan] [--target astro]
@@ -16,9 +16,9 @@ Usage:
16
16
  wp-migrate-core demo [--out wp-migrate-core-demo]
17
17
 
18
18
  Targets:
19
- astro implemented in 0.1.0-demo
20
- next not implemented in 0.1.0-demo
21
- nuxt not implemented in 0.1.0-demo
19
+ astro implemented
20
+ next planned, not implemented
21
+ nuxt planned, not implemented
22
22
 
23
23
  This is a deliberately incomplete demonstration. It does not modify WordPress.`;
24
24
  }
@@ -131,20 +131,11 @@ function isNodeErrorCode(error, code) {
131
131
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
132
132
  }
133
133
  function outputExistsError(outputPath) {
134
- return new Error(`Refusing to overwrite existing output: ${outputPath}. Choose a different --out path or remove the existing file intentionally.`);
134
+ return new Error(`Refusing to overwrite existing output: ${outputPath}. Choose a different --out path or remove the existing path intentionally.`);
135
135
  }
136
- async function ensureInspectionDirectory(directory) {
137
- try {
138
- const details = await stat(directory);
139
- if (!details.isDirectory()) {
140
- throw new Error(`Inspect output path exists and is not a directory: ${directory}`);
141
- }
142
- }
143
- catch (error) {
144
- if (!isNodeErrorCode(error, "ENOENT"))
145
- throw error;
146
- await mkdir(directory, { recursive: true });
147
- }
136
+ async function prepareInspectionOutput(directory) {
137
+ await mkdir(dirname(directory), { recursive: true });
138
+ await assertOutputDoesNotExist(directory);
148
139
  }
149
140
  async function assertOutputDoesNotExist(outputPath) {
150
141
  try {
@@ -186,13 +177,23 @@ function printSummary(project) {
186
177
  }
187
178
  async function inspect(project, outputDirectory) {
188
179
  const directory = resolve(outputDirectory);
180
+ await prepareInspectionOutput(directory);
181
+ // A sibling keeps the final rename on the same filesystem.
182
+ const stagingDirectory = await mkdtemp(resolve(dirname(directory), `.${basename(directory)}.staging-`));
183
+ try {
184
+ const stagingPlanPath = resolve(stagingDirectory, "migration-plan.json");
185
+ const stagingReportPath = resolve(stagingDirectory, "report.html");
186
+ await writeNewFile(stagingPlanPath, `${JSON.stringify(createMigrationPlan(project), null, 2)}\n`);
187
+ await writeReport(project, stagingReportPath, { noClobber: true });
188
+ await assertOutputDoesNotExist(directory);
189
+ await rename(stagingDirectory, directory);
190
+ }
191
+ catch (error) {
192
+ await rm(stagingDirectory, { recursive: true, force: true });
193
+ throw error;
194
+ }
189
195
  const planPath = resolve(directory, "migration-plan.json");
190
196
  const reportPath = resolve(directory, "report.html");
191
- await ensureInspectionDirectory(directory);
192
- await assertOutputDoesNotExist(planPath);
193
- await assertOutputDoesNotExist(reportPath);
194
- await writeNewFile(planPath, `${JSON.stringify(createMigrationPlan(project), null, 2)}\n`);
195
- await writeReport(project, reportPath, { noClobber: true });
196
197
  printSummary(project);
197
198
  console.log(`\nPlan: ${planPath}`);
198
199
  console.log(`Report: ${reportPath}`);
@@ -3,7 +3,7 @@ import type { InspectOptions, MigrationProject } from "./types.js";
3
3
  * Parse a bounded WXR export into a target-neutral migration model.
4
4
  *
5
5
  * This deliberately does not claim to be a complete XML or WordPress parser. It
6
- * is dependency-free so 0.1.0-demo can make the conversion boundary visible.
6
+ * is dependency-free so the conversion boundary stays visible.
7
7
  */
8
8
  export declare function parseWxr(xml: string, options?: InspectOptions): MigrationProject;
9
9
  export declare const inspectWxr: typeof parseWxr;
package/dist/src/core.js CHANGED
@@ -82,11 +82,12 @@ const SAFE_ELEMENTOR_HREF_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
82
82
  * Parse a bounded WXR export into a target-neutral migration model.
83
83
  *
84
84
  * This deliberately does not claim to be a complete XML or WordPress parser. It
85
- * is dependency-free so 0.1.0-demo can make the conversion boundary visible.
85
+ * is dependency-free so the conversion boundary stays visible.
86
86
  */
87
87
  export function parseWxr(xml, options = {}) {
88
88
  const records = [];
89
89
  const projectIssues = [];
90
+ const seenWordPressIds = new Set();
90
91
  const channelHeader = xml.slice(0, xml.search(/<item\b/i) === -1 ? xml.length : xml.search(/<item\b/i));
91
92
  const itemPattern = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
92
93
  let itemMatch;
@@ -101,19 +102,37 @@ export function parseWxr(xml, options = {}) {
101
102
  continue;
102
103
  }
103
104
  const rawId = cleanField(readTag(itemXml, "wp:post_id"));
104
- const wordpressId = Number.parseInt(rawId, 10);
105
- if (!Number.isFinite(wordpressId)) {
105
+ const wordpressId = parseWordPressId(rawId);
106
+ if (wordpressId === undefined) {
107
+ const missingId = rawId === "";
106
108
  projectIssues.push({
107
- id: `project:WXR_ITEM_MISSING_ID:${projectIssues.length + 1}`,
109
+ id: `project:${missingId ? "WXR_ITEM_MISSING_ID" : "WXR_ITEM_INVALID_ID"}:${projectIssues.length + 1}`,
108
110
  severity: "warning",
109
- code: "WXR_ITEM_MISSING_ID",
111
+ code: missingId ? "WXR_ITEM_MISSING_ID" : "WXR_ITEM_INVALID_ID",
110
112
  sourceId: "wp:unknown",
111
- title: "WordPress item is missing its ID",
112
- message: `Skipped a ${postType} without a numeric wp:post_id.`,
113
- requiredAction: "Inspect the WXR export and restore the missing post identifier."
113
+ title: missingId ? "WordPress item is missing its ID" : "WordPress item has an invalid ID",
114
+ message: missingId
115
+ ? `Skipped a ${postType} without a wp:post_id.`
116
+ : `Skipped a ${postType} because wp:post_id must be a positive integer.`,
117
+ requiredAction: missingId
118
+ ? "Inspect the WXR export and restore the missing post identifier."
119
+ : "Inspect the WXR export and restore a positive integer post identifier."
114
120
  });
115
121
  continue;
116
122
  }
123
+ if (seenWordPressIds.has(wordpressId)) {
124
+ projectIssues.push({
125
+ id: `project:WXR_ITEM_DUPLICATE_ID:${projectIssues.length + 1}`,
126
+ severity: "warning",
127
+ code: "WXR_ITEM_DUPLICATE_ID",
128
+ sourceId: `wp:${postType}:${wordpressId}`,
129
+ title: "WordPress item repeats an existing ID",
130
+ message: `Skipped a duplicate ${postType} with wp:post_id ${wordpressId}.`,
131
+ requiredAction: "Inspect the WXR export and resolve the duplicate post identifier before migration."
132
+ });
133
+ continue;
134
+ }
135
+ seenWordPressIds.add(wordpressId);
117
136
  records.push(parseItem(itemXml, postType, status, wordpressId));
118
137
  }
119
138
  if (records.length === 0) {
@@ -330,7 +349,7 @@ function convertElementorElement(value, sourceId, collector, nextOrdinal) {
330
349
  return [node];
331
350
  }
332
351
  const node = createMutableNode(nodeId, "elementor", widgetType, "unknown", "manual", settings, children);
333
- collector.add("warning", "ELEMENTOR_WIDGET_UNKNOWN", `Elementor widget ${widgetType} has no 0.1.0-demo adapter.`, "Replace it with an Astro component or preserve its rendered HTML.", { nodeId, evidence: widgetType });
352
+ collector.add("warning", "ELEMENTOR_WIDGET_UNKNOWN", `Elementor widget ${widgetType} has no supported adapter in this release.`, "Replace it with an Astro component or preserve its rendered HTML.", { nodeId, evidence: widgetType });
334
353
  return [node];
335
354
  }
336
355
  function scanShortcodes(content, sourceId, collector) {
@@ -376,7 +395,7 @@ function reportGutenbergCompatibility(node, collector) {
376
395
  return;
377
396
  }
378
397
  if (!NATIVE_GUTENBERG_BLOCKS.has(node.sourceType)) {
379
- collector.add("warning", "GUTENBERG_UNKNOWN_BLOCK", `Gutenberg block ${node.sourceType} has no 0.1.0-demo adapter.`, "Add an adapter or preserve the block's rendered HTML.", { nodeId: node.id, evidence: node.sourceType });
398
+ collector.add("warning", "GUTENBERG_UNKNOWN_BLOCK", `Gutenberg block ${node.sourceType} has no supported adapter in this release.`, "Add an adapter or preserve the block's rendered HTML.", { nodeId: node.id, evidence: node.sourceType });
380
399
  }
381
400
  }
382
401
  function parseBlockAttributes(serialized, blockName, nodeId, collector) {
@@ -506,16 +525,36 @@ function cleanOptionalField(value) {
506
525
  const cleaned = cleanField(value);
507
526
  return cleaned === "" ? undefined : cleaned;
508
527
  }
528
+ function parseWordPressId(value) {
529
+ if (!/^[0-9]+$/.test(value)) {
530
+ return undefined;
531
+ }
532
+ const wordpressId = Number(value);
533
+ return Number.isSafeInteger(wordpressId) && wordpressId > 0 ? wordpressId : undefined;
534
+ }
509
535
  function decodeXmlEntities(value) {
510
536
  return value
511
537
  .replace(/&lt;/g, "<")
512
538
  .replace(/&gt;/g, ">")
513
539
  .replace(/&quot;/g, '"')
514
540
  .replace(/&apos;/g, "'")
515
- .replace(/&#(\d+);/g, (_match, decimal) => String.fromCodePoint(Number.parseInt(decimal, 10)))
516
- .replace(/&#x([0-9a-f]+);/gi, (_match, hexadecimal) => String.fromCodePoint(Number.parseInt(hexadecimal, 16)))
541
+ .replace(/&#(\d+);/g, (entity, decimal) => decodeNumericXmlEntity(entity, decimal, 10))
542
+ .replace(/&#x([0-9a-f]+);/gi, (entity, hexadecimal) => decodeNumericXmlEntity(entity, hexadecimal, 16))
517
543
  .replace(/&amp;/g, "&");
518
544
  }
545
+ function decodeNumericXmlEntity(entity, value, radix) {
546
+ const codePoint = Number.parseInt(value, radix);
547
+ return isValidXmlCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
548
+ }
549
+ function isValidXmlCodePoint(value) {
550
+ return (Number.isSafeInteger(value) &&
551
+ (value === 0x9 ||
552
+ value === 0xa ||
553
+ value === 0xd ||
554
+ (value >= 0x20 && value <= 0xd7ff) ||
555
+ (value >= 0xe000 && value <= 0xfffd) ||
556
+ (value >= 0x10000 && value <= 0x10ffff)));
557
+ }
519
558
  function htmlToText(value) {
520
559
  return decodeXmlEntities(value.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
521
560
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, parse, resolve } from "node:path";
3
3
  const GENERATOR_NAME = "wp-migrate-core";
4
- const GENERATOR_VERSION = "0.1.0-demo";
4
+ const GENERATOR_VERSION = "0.1.1";
5
5
  const SAFE_ELEMENTOR_HREF_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
6
6
  export async function generateAstroProject(project, outDir) {
7
7
  const outputDirectory = await prepareOutputDirectory(outDir);
@@ -293,7 +293,7 @@ npm run dev
293
293
 
294
294
  Generated content lives in \`src/content/pages\` and \`src/content/posts\`. Route mappings and source IDs live in \`migration/manifest.json\`.
295
295
 
296
- Astro is the only enabled renderer in 0.1.0-demo. Next.js and Nuxt appear in the migration manifest as planned, disabled targets; this output contains no fake compatibility layer for either framework.
296
+ Astro is the only enabled renderer in this handoff. Next.js and Nuxt appear in the migration manifest as planned, disabled targets; this output contains no fake compatibility layer for either framework.
297
297
  `;
298
298
  }
299
299
  function renderContentRecord(generated) {
@@ -86,7 +86,7 @@ function renderTargetCard(project, target) {
86
86
  <span class="target-state target-state--${enabled ? "available" : "unavailable"}">${state}</span>
87
87
  </div>
88
88
  <p>${escapeHtml(targetDescriptions[target])}</p>
89
- ${enabled ? "" : '<span class="target-card__notice">Not implemented in 0.1.0-demo</span>'}
89
+ ${enabled ? "" : '<span class="target-card__notice">Not implemented in this release</span>'}
90
90
  </article>`;
91
91
  }
92
92
  function renderSourceBreakdown(project) {
@@ -646,7 +646,7 @@ export function renderReport(project) {
646
646
  <header class="topbar">
647
647
  <div class="topbar__inner">
648
648
  <div class="product">WP Migrate Core</div>
649
- <div class="report-meta">Local migration report · Version 0.1.0-demo</div>
649
+ <div class="report-meta">Local migration report · Version 0.1.1</div>
650
650
  </div>
651
651
  </header>
652
652
 
@@ -16,7 +16,7 @@ export interface MigrationNode {
16
16
  readonly rawHtml?: string;
17
17
  }
18
18
  export type MigrationIssueSeverity = "warning" | "blocker";
19
- export type MigrationIssueCode = "WXR_NO_ITEMS" | "WXR_ITEM_MISSING_ID" | "GUTENBERG_UNCLOSED_BLOCK" | "GUTENBERG_UNMATCHED_CLOSE" | "GUTENBERG_INVALID_ATTRIBUTES" | "GUTENBERG_DYNAMIC_BLOCK" | "GUTENBERG_MEDIA_UNSUPPORTED" | "GUTENBERG_UNKNOWN_BLOCK" | "SHORTCODE_UNSUPPORTED" | "ELEMENTOR_INVALID_DATA" | "ELEMENTOR_FORM_UNSUPPORTED" | "ELEMENTOR_QUERY_UNSUPPORTED" | "ELEMENTOR_IMAGE_REMOTE_MEDIA" | "ELEMENTOR_BUTTON_UNSAFE_URL" | "ELEMENTOR_WIDGET_UNKNOWN";
19
+ export type MigrationIssueCode = "WXR_NO_ITEMS" | "WXR_ITEM_MISSING_ID" | "WXR_ITEM_INVALID_ID" | "WXR_ITEM_DUPLICATE_ID" | "GUTENBERG_UNCLOSED_BLOCK" | "GUTENBERG_UNMATCHED_CLOSE" | "GUTENBERG_INVALID_ATTRIBUTES" | "GUTENBERG_DYNAMIC_BLOCK" | "GUTENBERG_MEDIA_UNSUPPORTED" | "GUTENBERG_UNKNOWN_BLOCK" | "SHORTCODE_UNSUPPORTED" | "ELEMENTOR_INVALID_DATA" | "ELEMENTOR_FORM_UNSUPPORTED" | "ELEMENTOR_QUERY_UNSUPPORTED" | "ELEMENTOR_IMAGE_REMOTE_MEDIA" | "ELEMENTOR_BUTTON_UNSAFE_URL" | "ELEMENTOR_WIDGET_UNKNOWN";
20
20
  export interface MigrationIssue {
21
21
  readonly id: string;
22
22
  readonly severity: MigrationIssueSeverity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wp-migrate-core",
3
- "version": "0.1.0-demo",
3
+ "version": "0.1.1",
4
4
  "description": "Turn a WordPress WXR export into an Astro handoff, with a repair queue for the parts that still need a human.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -25,6 +25,7 @@
25
25
  "wp-migrate-core": "dist/src/cli.js"
26
26
  },
27
27
  "files": [
28
+ "CHANGELOG.md",
28
29
  "dist/src",
29
30
  "fixtures/demo-wordpress.xml"
30
31
  ],