@contentrain/wp-import 0.1.0

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/dist/index.mjs ADDED
@@ -0,0 +1,1654 @@
1
+ import sax from "sax";
2
+ import { COMMENTS_EXPORT_FORMAT, MIGRATION_CONTRACT_VERSION } from "@contentrain/types";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/php-unserialize.ts
5
+ function looksSerialized(s) {
6
+ return typeof s === "string" && /^(a|O):\d+:[{"]|^(s|i|d|b):[^;]+;?$|^N;$/.test(s);
7
+ }
8
+ function phpUnserialize(str) {
9
+ const buf = Buffer.from(str, "utf8");
10
+ let p = 0;
11
+ const fail = (m) => {
12
+ throw new Error(`unserialize: ${m} at byte ${p}`);
13
+ };
14
+ const readUntil = (ch) => {
15
+ const i = buf.indexOf(ch, p);
16
+ if (i < 0) fail(`expected ${ch}`);
17
+ const s = buf.toString("utf8", p, i);
18
+ p = i + 1;
19
+ return s;
20
+ };
21
+ const expect = (ch) => {
22
+ if (buf[p] !== ch.charCodeAt(0)) fail(`expected '${ch}' got '${String.fromCharCode(buf[p] ?? 0)}'`);
23
+ p++;
24
+ };
25
+ const value = () => {
26
+ const t = String.fromCharCode(buf[p] ?? 0);
27
+ p++;
28
+ switch (t) {
29
+ case "N":
30
+ expect(";");
31
+ return null;
32
+ case "b":
33
+ expect(":");
34
+ return readUntil(";") === "1";
35
+ case "i":
36
+ expect(":");
37
+ return Number(readUntil(";"));
38
+ case "d": {
39
+ expect(":");
40
+ const v = readUntil(";");
41
+ return v === "INF" ? Infinity : v === "-INF" ? -Infinity : v === "NAN" ? NaN : Number(v);
42
+ }
43
+ case "s": {
44
+ expect(":");
45
+ const n = Number(readUntil(":"));
46
+ expect("\"");
47
+ const s = buf.toString("utf8", p, p + n);
48
+ p += n;
49
+ expect("\"");
50
+ expect(";");
51
+ return s;
52
+ }
53
+ case "a": {
54
+ expect(":");
55
+ const n = Number(readUntil(":"));
56
+ expect("{");
57
+ const entries = [];
58
+ for (let i = 0; i < n; i++) {
59
+ const k = value();
60
+ const v = value();
61
+ entries.push([k, v]);
62
+ }
63
+ expect("}");
64
+ return entries.every(([k], i) => k === i) ? entries.map(([, v]) => v) : Object.fromEntries(entries.map(([k, v]) => [String(k), v]));
65
+ }
66
+ case "O": {
67
+ expect(":");
68
+ readUntil(":");
69
+ expect("\"");
70
+ const cls = readUntil("\"");
71
+ expect(":");
72
+ const n = Number(readUntil(":"));
73
+ expect("{");
74
+ const o = { __class: cls };
75
+ for (let i = 0; i < n; i++) {
76
+ const k = value();
77
+ const v = value();
78
+ o[String(k).split("\0").at(-1) ?? String(k)] = v;
79
+ }
80
+ expect("}");
81
+ return o;
82
+ }
83
+ default: return fail(`unknown type '${t}'`);
84
+ }
85
+ };
86
+ const v = value();
87
+ if (p !== buf.length) fail(`trailing ${buf.length - p} bytes`);
88
+ return v;
89
+ }
90
+ /** Attempt a decode; on failure the value stays exactly as it was. */
91
+ function tryUnserialize(s) {
92
+ if (!looksSerialized(s)) return {
93
+ ok: false,
94
+ serialized: false,
95
+ value: s
96
+ };
97
+ try {
98
+ return {
99
+ ok: true,
100
+ serialized: true,
101
+ value: phpUnserialize(s)
102
+ };
103
+ } catch (e) {
104
+ return {
105
+ ok: false,
106
+ serialized: true,
107
+ value: s,
108
+ error: e instanceof Error ? e.message : String(e)
109
+ };
110
+ }
111
+ }
112
+ //#endregion
113
+ //#region src/core.ts
114
+ const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
115
+ const strip = (s) => norm(String(s ?? "").replace(/<[^>]*>/g, " ").replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d))).replace(/&amp;/g, "&"));
116
+ /** Entry id: deterministic hex from a stable key (model:slug style). */
117
+ const hexId = (s) => createHash("sha1").update(s).digest("hex").slice(0, 24);
118
+ const sortDeep = (v) => Array.isArray(v) ? v.map(sortDeep) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).toSorted().map((k) => [k, sortDeep(v[k])])) : v;
119
+ /** Canonical serialization: sorted keys, 2-space indent, trailing newline. */
120
+ const canon = (o) => `${JSON.stringify(sortDeep(o), null, 2)}\n`;
121
+ /** Field-type inference from a value (open meta / ACF fields). */
122
+ const byValue = (v) => {
123
+ if (typeof v === "boolean") return "boolean";
124
+ if (typeof v === "number") return Number.isInteger(v) ? "integer" : "decimal";
125
+ if (Array.isArray(v)) return "array";
126
+ if (v && typeof v === "object") return "object";
127
+ const s = String(v ?? "").trim();
128
+ if (!s) return null;
129
+ if (/^https?:\/\/\S+\.(mp3|m4a|wav|pdf|zip|docx?)(\?|$)/i.test(s)) return "file";
130
+ if (/^https?:\/\/\S+\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i.test(s)) return "image";
131
+ if (/^https?:\/\//.test(s)) return "url";
132
+ if (/^[^@\s]+@[^@\s]+\.[a-z]{2,}$/i.test(s)) return "email";
133
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})/.test(s)) return "datetime";
134
+ if (/<[a-z][\s\S]*>/i.test(s)) return "richtext";
135
+ return s.length > 200 ? "text" : "string";
136
+ };
137
+ /** WP post types that never become content models. */
138
+ const SKIP_TYPES = /^(attachment|nav_menu_item|wp_|jp_|pattern|revision|oembed_cache|customize_changeset|user_request|custom_css)/;
139
+ /** Open meta keys that are plugin plumbing, not editorial fields. */
140
+ const PLUGIN_META = /^(jetpack|wpdc|discourse|footnotes|inline_featured|spay_|advanced_seo|rank_math|yoast)/i;
141
+ /** Taxonomy → model id. */
142
+ const taxModelId = (t) => t === "post_tag" ? "tags" : t === "category" ? "categories" : t.replace(/_/g, "-");
143
+ const slugify = (s) => String(s ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
144
+ const decodeSlug = (s) => {
145
+ try {
146
+ return decodeURIComponent(s);
147
+ } catch {
148
+ return s;
149
+ }
150
+ };
151
+ /** The measured core field skeleton for content models (same order/labels). */
152
+ function coreFields(opts) {
153
+ const fields = {
154
+ title: {
155
+ type: "string",
156
+ required: true,
157
+ label: "Title",
158
+ order: 10
159
+ },
160
+ slug: {
161
+ type: "slug",
162
+ required: true,
163
+ unique: true,
164
+ label: "Slug",
165
+ order: 20
166
+ }
167
+ };
168
+ let o = 30;
169
+ if (opts.excerpt) fields.excerpt = {
170
+ type: "text",
171
+ label: "Excerpt",
172
+ order: o += 10
173
+ };
174
+ if (opts.body) fields.body = {
175
+ type: "richtext",
176
+ label: "Body",
177
+ order: o += 10
178
+ };
179
+ if (opts.date) fields.published_at = {
180
+ type: "datetime",
181
+ label: "Published at",
182
+ order: o += 10
183
+ };
184
+ if (opts.author) fields.author = {
185
+ type: "relation",
186
+ model: "authors",
187
+ label: "Author",
188
+ order: o += 10
189
+ };
190
+ for (const t of opts.taxes) fields[taxModelId(t)] = {
191
+ type: "relations",
192
+ model: taxModelId(t),
193
+ label: t,
194
+ order: o += 10
195
+ };
196
+ if (opts.cover) fields.cover = {
197
+ type: "relation",
198
+ model: "media",
199
+ label: "Cover",
200
+ order: o += 10
201
+ };
202
+ return {
203
+ fields,
204
+ order: o
205
+ };
206
+ }
207
+ //#endregion
208
+ //#region src/wxr.ts
209
+ const ts = (s) => !s || s.startsWith("0000-00-00") ? null : s.replace(" ", "T");
210
+ const iso$1 = (local, gmt) => gmt && !gmt.startsWith("0000") ? `${ts(gmt)}Z` : ts(local);
211
+ const num = (s) => s === "" || s == null ? 0 : Number(s);
212
+ const termKey = (tax, slug) => `${tax}:${slug}`;
213
+ async function parseWxr(input, opts) {
214
+ const site = {};
215
+ const authors = [];
216
+ const terms = [];
217
+ const items = [];
218
+ const stats = {
219
+ serialized: {
220
+ detected: 0,
221
+ decoded: 0,
222
+ failed: 0,
223
+ keys: []
224
+ },
225
+ acf_fields: 0,
226
+ unresolved_terms: 0,
227
+ counts: {}
228
+ };
229
+ const serializedKeySet = /* @__PURE__ */ new Set();
230
+ const parser = sax.createStream(true, { trim: false });
231
+ const path = [];
232
+ let text = "";
233
+ let item = null;
234
+ let meta = null;
235
+ let comment = null;
236
+ let cmeta = null;
237
+ let author = null;
238
+ let term = null;
239
+ const channelText = {};
240
+ const leaf = (name, into, map) => {
241
+ const target = map[name];
242
+ if (target) into[target] = text;
243
+ };
244
+ parser.on("opentag", (node) => {
245
+ path.push(node.name);
246
+ text = "";
247
+ const n = node.name;
248
+ if (n === "item") item = {
249
+ terms: [],
250
+ postmeta: [],
251
+ comments: []
252
+ };
253
+ else if (n === "wp:postmeta") meta = {};
254
+ else if (n === "wp:comment") comment = { meta: [] };
255
+ else if (n === "wp:commentmeta") cmeta = {};
256
+ else if (n === "wp:author") author = {};
257
+ else if (n === "wp:category" || n === "wp:tag" || n === "wp:term") term = { _tag: n };
258
+ else if (n === "category" && item) {
259
+ const attrs = node.attributes;
260
+ item.terms.push({
261
+ taxonomy: attrs.domain ?? "",
262
+ slug: attrs.nicename ?? ""
263
+ });
264
+ }
265
+ });
266
+ parser.on("text", (t) => {
267
+ text += t;
268
+ });
269
+ parser.on("cdata", (t) => {
270
+ text += t;
271
+ });
272
+ parser.on("comment", (c) => {
273
+ const m = /generator="([^"]*)"\s+created="([^"]*)"/.exec(c);
274
+ if (m) {
275
+ site.generator = m[1];
276
+ site.export_date = m[2];
277
+ }
278
+ });
279
+ parser.on("closetag", (n) => {
280
+ const parent = path[path.length - 2];
281
+ if (cmeta) {
282
+ if (n === "wp:meta_key") cmeta.key = text;
283
+ else if (n === "wp:meta_value") cmeta.value = text;
284
+ else if (n === "wp:commentmeta") {
285
+ comment.meta.push(cmeta);
286
+ cmeta = null;
287
+ }
288
+ } else if (comment) {
289
+ leaf(n, comment, {
290
+ "wp:comment_id": "id",
291
+ "wp:comment_author": "author",
292
+ "wp:comment_author_email": "email",
293
+ "wp:comment_author_url": "url",
294
+ "wp:comment_date": "date",
295
+ "wp:comment_date_gmt": "date_gmt",
296
+ "wp:comment_content": "content",
297
+ "wp:comment_approved": "approved",
298
+ "wp:comment_type": "type",
299
+ "wp:comment_parent": "parent",
300
+ "wp:comment_user_id": "user_id"
301
+ });
302
+ if (n === "wp:comment") {
303
+ item.comments.push(comment);
304
+ comment = null;
305
+ }
306
+ } else if (meta) {
307
+ if (n === "wp:meta_key") meta.key = text;
308
+ else if (n === "wp:meta_value") meta.value = text;
309
+ else if (n === "wp:postmeta") {
310
+ item.postmeta.push(meta);
311
+ meta = null;
312
+ }
313
+ } else if (item) if (n === "category") {
314
+ const t = item.terms.at(-1);
315
+ if (t) t.name = text;
316
+ } else {
317
+ leaf(n, item, {
318
+ title: "title",
319
+ link: "link",
320
+ "dc:creator": "author",
321
+ guid: "guid",
322
+ description: "description",
323
+ "content:encoded": "content",
324
+ "excerpt:encoded": "excerpt",
325
+ "wp:post_id": "id",
326
+ "wp:post_date": "date",
327
+ "wp:post_date_gmt": "date_gmt",
328
+ "wp:post_modified": "modified",
329
+ "wp:post_modified_gmt": "modified_gmt",
330
+ "wp:comment_status": "comment_status",
331
+ "wp:ping_status": "ping_status",
332
+ "wp:post_name": "slug",
333
+ "wp:status": "status",
334
+ "wp:post_parent": "parent",
335
+ "wp:menu_order": "menu_order",
336
+ "wp:post_type": "type",
337
+ "wp:post_password": "password",
338
+ "wp:is_sticky": "sticky",
339
+ "wp:attachment_url": "attachment_url"
340
+ });
341
+ if (n === "item") {
342
+ items.push(item);
343
+ item = null;
344
+ }
345
+ }
346
+ else if (author) {
347
+ leaf(n, author, {
348
+ "wp:author_id": "id",
349
+ "wp:author_login": "login",
350
+ "wp:author_email": "email",
351
+ "wp:author_display_name": "display_name",
352
+ "wp:author_first_name": "first_name",
353
+ "wp:author_last_name": "last_name"
354
+ });
355
+ if (n === "wp:author") {
356
+ authors.push(author);
357
+ author = null;
358
+ }
359
+ } else if (term) {
360
+ leaf(n, term, {
361
+ "wp:term_id": "id",
362
+ "wp:category_nicename": "slug",
363
+ "wp:category_parent": "parent",
364
+ "wp:cat_name": "name",
365
+ "wp:category_description": "description",
366
+ "wp:tag_slug": "slug",
367
+ "wp:tag_name": "name",
368
+ "wp:tag_description": "description",
369
+ "wp:term_taxonomy": "taxonomy",
370
+ "wp:term_slug": "slug",
371
+ "wp:term_parent": "parent",
372
+ "wp:term_name": "name",
373
+ "wp:term_description": "description"
374
+ });
375
+ if (n === term._tag) {
376
+ term.taxonomy = term._tag === "wp:category" ? "category" : term._tag === "wp:tag" ? "post_tag" : term.taxonomy ?? "";
377
+ delete term._tag;
378
+ terms.push(term);
379
+ term = null;
380
+ }
381
+ } else if (parent === "channel") leaf(n, channelText, {
382
+ title: "title",
383
+ link: "link",
384
+ description: "description",
385
+ language: "language",
386
+ "wp:wxr_version": "wxr_version",
387
+ "wp:base_site_url": "base_site_url",
388
+ "wp:base_blog_url": "base_blog_url"
389
+ });
390
+ path.pop();
391
+ text = "";
392
+ });
393
+ await new Promise((resolve, reject) => {
394
+ parser.on("end", () => resolve());
395
+ parser.on("error", reject);
396
+ if (typeof input === "string") parser.end(input);
397
+ else input.pipe(parser);
398
+ });
399
+ Object.assign(site, channelText);
400
+ const decodeMeta = (list) => {
401
+ const out = {};
402
+ const serializedKeys = [];
403
+ for (const { key, value } of list) {
404
+ if (!key) continue;
405
+ const r = tryUnserialize(value ?? "");
406
+ if (r.serialized) {
407
+ stats.serialized.detected++;
408
+ serializedKeySet.add(key);
409
+ if (r.ok) {
410
+ stats.serialized.decoded++;
411
+ serializedKeys.push(key);
412
+ } else stats.serialized.failed++;
413
+ }
414
+ const v = r.ok ? r.value : value ?? "";
415
+ out[key] = key in out ? [].concat(out[key], [v]) : v;
416
+ }
417
+ return {
418
+ meta: out,
419
+ serializedKeys
420
+ };
421
+ };
422
+ const extractAcf = (metaObj) => {
423
+ const acf = {};
424
+ for (const [k, v] of Object.entries(metaObj)) if (k.startsWith("_") && typeof v === "string" && /^field_[0-9a-f]+$/.test(v) && k.slice(1) in metaObj) acf[k.slice(1)] = {
425
+ value: metaObj[k.slice(1)],
426
+ field_key: v
427
+ };
428
+ return acf;
429
+ };
430
+ const termSet = new Set(terms.map((t) => termKey(t.taxonomy ?? "", t.slug ?? "")));
431
+ const records = items.map((it) => {
432
+ const { meta: m, serializedKeys } = decodeMeta(it.postmeta);
433
+ const acf = extractAcf(m);
434
+ stats.acf_fields += Object.keys(acf).length;
435
+ const id = num(it.id);
436
+ const termRefs = it.terms.map((t) => ({
437
+ taxonomy: t.taxonomy,
438
+ slug: t.slug,
439
+ name: t.name ?? "",
440
+ resolved: termSet.has(termKey(t.taxonomy, t.slug))
441
+ }));
442
+ const comments = it.comments.map((c) => ({
443
+ id: num(c.id),
444
+ post: id,
445
+ post_type: it.type || "post",
446
+ parent: num(c.parent) || null,
447
+ parent_resolved: null,
448
+ author: c.author ?? "",
449
+ email: c.email || null,
450
+ url: c.url || null,
451
+ date: iso$1(c.date, c.date_gmt),
452
+ date_gmt: ts(c.date_gmt),
453
+ content: c.content ?? "",
454
+ approved: c.approved ?? "1",
455
+ type: c.type || "comment",
456
+ user_id: num(c.user_id) || null,
457
+ meta: decodeMeta(c.meta ?? []).meta
458
+ }));
459
+ return {
460
+ id,
461
+ type: it.type || "post",
462
+ status: it.status || "publish",
463
+ slug: it.slug || "",
464
+ title: it.title ?? "",
465
+ link: it.link || null,
466
+ guid: it.guid || null,
467
+ author: it.author || null,
468
+ date: iso$1(it.date, it.date_gmt),
469
+ modified: iso$1(it.modified, it.modified_gmt),
470
+ content: it.content ?? "",
471
+ excerpt: it.excerpt ?? "",
472
+ parent: num(it.parent) || null,
473
+ menu_order: num(it.menu_order),
474
+ sticky: it.sticky === "1",
475
+ password: it.password || null,
476
+ comment_status: it.comment_status || null,
477
+ ping_status: it.ping_status || null,
478
+ terms: termRefs,
479
+ meta: m,
480
+ serialized_keys: serializedKeys,
481
+ acf,
482
+ attachment_url: it.attachment_url || null,
483
+ _comments: comments
484
+ };
485
+ });
486
+ for (const r of records) for (const t of r.terms) if (!t.resolved && r.type !== "nav_menu_item") stats.unresolved_terms++;
487
+ const byId = new Map(records.map((r) => [r.id, r]));
488
+ const posts = records.filter((r) => r.type !== "attachment" && r.type !== "nav_menu_item").map(({ attachment_url: _a, _comments: _c, ...p }) => p);
489
+ const attachments = records.filter((r) => r.type === "attachment").map((r) => ({
490
+ id: r.id,
491
+ title: r.title,
492
+ slug: r.slug,
493
+ url: r.attachment_url ?? r.guid ?? null,
494
+ alt: typeof r.meta._wp_attachment_image_alt === "string" ? r.meta._wp_attachment_image_alt : "",
495
+ caption: r.excerpt,
496
+ description: r.content,
497
+ file: typeof r.meta._wp_attached_file === "string" ? r.meta._wp_attached_file : null,
498
+ image_meta: r.meta._wp_attachment_metadata ?? null,
499
+ mime: guessMime(r.attachment_url ?? r.guid ?? ""),
500
+ parent: r.parent,
501
+ parent_resolved: r.parent ? byId.has(r.parent) : null,
502
+ author: r.author,
503
+ date: r.date,
504
+ status: r.status,
505
+ meta: r.meta
506
+ }));
507
+ const postById = new Map(posts.map((p) => [p.id, p]));
508
+ const termByIdNum = new Map(terms.map((t) => [num(t.id), t]));
509
+ const menus = terms.filter((t) => t.taxonomy === "nav_menu").map((mt) => ({
510
+ id: num(mt.id) || null,
511
+ slug: mt.slug ?? "",
512
+ name: mt.name ?? "",
513
+ items: []
514
+ }));
515
+ const menuBySlug = new Map(menus.map((m) => [m.slug, m]));
516
+ for (const r of records.filter((x) => x.type === "nav_menu_item")) {
517
+ const m = r.meta;
518
+ const type = m._menu_item_type;
519
+ const object = m._menu_item_object;
520
+ const objectId = num(m._menu_item_object_id);
521
+ let target;
522
+ if (type === "custom") target = {
523
+ kind: "url",
524
+ url: String(m._menu_item_url ?? ""),
525
+ resolved: true
526
+ };
527
+ else if (type === "post_type") {
528
+ const p = postById.get(objectId);
529
+ target = {
530
+ kind: "post",
531
+ post_type: object ?? "",
532
+ id: objectId || null,
533
+ slug: p?.slug ?? null,
534
+ resolved: !!p
535
+ };
536
+ } else if (type === "taxonomy") {
537
+ const t = termByIdNum.get(objectId);
538
+ target = {
539
+ kind: "term",
540
+ taxonomy: object ?? "",
541
+ id: objectId || null,
542
+ slug: t?.slug ?? null,
543
+ resolved: !!t
544
+ };
545
+ } else if (type === "post_type_archive") target = {
546
+ kind: "archive",
547
+ post_type: object ?? "",
548
+ resolved: true
549
+ };
550
+ else target = {
551
+ kind: "unknown",
552
+ resolved: false
553
+ };
554
+ const parent = num(m._menu_item_menu_item_parent) || null;
555
+ const itemRec = {
556
+ id: r.id,
557
+ title: r.title || "slug" in target && target.slug || "",
558
+ order: r.menu_order,
559
+ parent,
560
+ url: m._menu_item_url || null,
561
+ target,
562
+ target_attr: m._menu_item_target || null,
563
+ classes: [].concat(m._menu_item_classes ?? []).filter(Boolean),
564
+ description: r.content,
565
+ status: r.status
566
+ };
567
+ const menuSlug = r.terms.find((t) => t.taxonomy === "nav_menu")?.slug;
568
+ const menu = menuSlug ? menuBySlug.get(menuSlug) : void 0;
569
+ if (menu) menu.items.push(itemRec);
570
+ }
571
+ const allMenuItemIds = new Set(menus.flatMap((m) => m.items.map((i) => i.id)));
572
+ for (const m of menus) for (const i of m.items) if (i.parent && !allMenuItemIds.has(i.parent)) i.parent_unresolved = true;
573
+ const comments = [];
574
+ for (const r of records) {
575
+ const ids = new Set(r._comments.map((c) => c.id));
576
+ for (const c of r._comments) comments.push({
577
+ ...c,
578
+ parent_resolved: c.parent ? ids.has(c.parent) : null
579
+ });
580
+ }
581
+ const rawAuthors = authors.map((a) => ({
582
+ id: num(a.id) || null,
583
+ login: a.login ?? "",
584
+ email: a.email || null,
585
+ display_name: a.display_name || a.login || "",
586
+ first_name: a.first_name || null,
587
+ last_name: a.last_name || null
588
+ }));
589
+ const rawTerms = terms.filter((t) => t.taxonomy !== "nav_menu").map((t) => ({
590
+ id: num(t.id) || null,
591
+ taxonomy: t.taxonomy ?? "",
592
+ slug: t.slug ?? "",
593
+ name: t.name ?? "",
594
+ parent: t.parent || null,
595
+ parent_resolved: t.parent ? termSet.has(termKey(t.taxonomy ?? "", t.parent)) : null,
596
+ description: t.description || ""
597
+ }));
598
+ stats.serialized.keys = [...serializedKeySet].toSorted();
599
+ stats.counts = {
600
+ items: records.length,
601
+ posts: posts.length,
602
+ attachments: attachments.length,
603
+ terms: rawTerms.length,
604
+ authors: rawAuthors.length,
605
+ menus: menus.length,
606
+ comments: comments.length
607
+ };
608
+ return {
609
+ raw: {
610
+ version: MIGRATION_CONTRACT_VERSION,
611
+ provenance: {
612
+ kind: "wxr",
613
+ tool: opts?.tool ?? "@contentrain/wp-import"
614
+ },
615
+ site: {
616
+ url: String(site.link ?? channelText.link ?? ""),
617
+ title: strip(channelText.title),
618
+ description: strip(channelText.description) || void 0,
619
+ base_site_url: channelText.base_site_url,
620
+ base_blog_url: channelText.base_blog_url,
621
+ language: channelText.language ?? null,
622
+ generator: site.generator ?? null,
623
+ export_date: site.export_date ?? null,
624
+ wxr_version: channelText.wxr_version ?? null
625
+ },
626
+ authors: rawAuthors,
627
+ terms: rawTerms,
628
+ posts,
629
+ attachments,
630
+ menus,
631
+ comments
632
+ },
633
+ stats
634
+ };
635
+ }
636
+ const MIME = {
637
+ jpg: "image/jpeg",
638
+ jpeg: "image/jpeg",
639
+ png: "image/png",
640
+ gif: "image/gif",
641
+ webp: "image/webp",
642
+ svg: "image/svg+xml",
643
+ mp3: "audio/mpeg",
644
+ mp4: "video/mp4",
645
+ pdf: "application/pdf"
646
+ };
647
+ function guessMime(u) {
648
+ return MIME[(u.split("?")[0]?.split(".").pop() ?? "").toLowerCase()] ?? null;
649
+ }
650
+ //#endregion
651
+ //#region src/rest.ts
652
+ const iso = (gmt) => gmt ? `${gmt}Z` : null;
653
+ const approvedOf = (status) => status === "approved" ? "1" : status === "hold" ? "0" : status === "spam" ? "spam" : status === "trash" ? "trash" : status ?? "1";
654
+ async function fetchRestRawIR(options) {
655
+ const origin = options.origin.replace(/\/$/, "");
656
+ const doFetch = options.fetchImpl ?? fetch;
657
+ const perPage = options.perPage ?? 100;
658
+ const warnings = [];
659
+ const headers = { accept: "application/json" };
660
+ if (options.auth) headers.authorization = `Basic ${Buffer.from(`${options.auth.user}:${options.auth.appPassword}`).toString("base64")}`;
661
+ const getAll = async (path) => {
662
+ const url = (page) => `${origin}/wp-json/wp/v2/${path}${path.includes("?") ? "&" : "?"}per_page=${perPage}&page=${page}`;
663
+ const first = await doFetch(url(1), { headers });
664
+ if (!first.ok) {
665
+ warnings.push(`${path}: HTTP ${first.status} — skipped`);
666
+ return [];
667
+ }
668
+ const totalPages = Number(first.headers.get("x-wp-totalpages") ?? "1") || 1;
669
+ const firstPage = await first.json();
670
+ if (totalPages <= 1) return firstPage;
671
+ const rest = await Promise.all(Array.from({ length: totalPages - 1 }, (_v, i) => doFetch(url(i + 2), { headers }).then(async (r) => r.ok ? await r.json() : [])));
672
+ return firstPage.concat(...rest);
673
+ };
674
+ const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
675
+ const types = typesResp.ok ? await typesResp.json() : {};
676
+ if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
677
+ const postTypes = Object.values(types).filter((t) => t.rest_base && !SKIP_TYPES.test(t.slug) && t.slug !== "attachment");
678
+ const bases = postTypes.length ? postTypes.map((t) => ({
679
+ slug: t.slug,
680
+ base: t.rest_base
681
+ })) : [{
682
+ slug: "post",
683
+ base: "posts"
684
+ }, {
685
+ slug: "page",
686
+ base: "pages"
687
+ }];
688
+ const [categories, tags, users, media, restComments, ...postLists] = await Promise.all([
689
+ getAll("categories"),
690
+ getAll("tags"),
691
+ getAll("users"),
692
+ getAll("media"),
693
+ getAll("comments"),
694
+ ...bases.map((b) => getAll(b.base))
695
+ ]);
696
+ const termsById = /* @__PURE__ */ new Map();
697
+ for (const [tax, list] of [["category", categories], ["post_tag", tags]]) for (const t of list) termsById.set(t.id, {
698
+ id: t.id,
699
+ taxonomy: tax,
700
+ slug: t.slug,
701
+ name: strip(t.name),
702
+ parent: t.parent ? list.find((x) => x.id === t.parent)?.slug ?? null : null,
703
+ parent_resolved: t.parent ? list.some((x) => x.id === t.parent) : null,
704
+ description: t.description ?? ""
705
+ });
706
+ const userById = new Map(users.map((u) => [u.id, u]));
707
+ const posts = [];
708
+ for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
709
+ const termRefs = [...p.categories ?? [], ...p.tags ?? []].map((id) => {
710
+ const t = termsById.get(id);
711
+ return t ? {
712
+ taxonomy: t.taxonomy,
713
+ slug: t.slug,
714
+ name: t.name,
715
+ resolved: true
716
+ } : {
717
+ taxonomy: "category",
718
+ slug: String(id),
719
+ name: String(id),
720
+ resolved: false
721
+ };
722
+ });
723
+ const author = p.author ? userById.get(p.author)?.slug ?? null : null;
724
+ const meta = { ...p.meta && typeof p.meta === "object" ? p.meta : {} };
725
+ if (p.featured_media) meta._thumbnail_id = String(p.featured_media);
726
+ posts.push({
727
+ id: p.id,
728
+ type: base.slug,
729
+ status: p.status ?? "publish",
730
+ slug: p.slug,
731
+ title: p.title?.rendered ?? "",
732
+ link: p.link ?? null,
733
+ guid: null,
734
+ author,
735
+ date: iso(p.date_gmt),
736
+ modified: iso(p.modified_gmt),
737
+ content: p.content?.rendered ?? "",
738
+ excerpt: p.excerpt?.rendered ?? "",
739
+ parent: p.parent || null,
740
+ menu_order: p.menu_order ?? 0,
741
+ sticky: p.sticky ?? false,
742
+ password: null,
743
+ comment_status: p.comment_status ?? null,
744
+ ping_status: p.ping_status ?? null,
745
+ terms: termRefs,
746
+ meta
747
+ });
748
+ }
749
+ const attachments = media.map((m) => ({
750
+ id: m.id,
751
+ title: strip(m.title?.rendered) || m.slug,
752
+ slug: m.slug,
753
+ url: m.source_url ?? null,
754
+ alt: m.alt_text ?? "",
755
+ caption: strip(m.caption?.rendered),
756
+ file: m.media_details?.file ?? null,
757
+ image_meta: m.media_details ?? null,
758
+ mime: m.mime_type ?? null,
759
+ parent: m.post ?? null,
760
+ parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
761
+ date: iso(m.date_gmt)
762
+ }));
763
+ const postIds = new Set(posts.map((p) => p.id));
764
+ const commentIds = new Set(restComments.map((c) => c.id));
765
+ const comments = restComments.map((c) => ({
766
+ id: c.id,
767
+ post: c.post,
768
+ post_type: posts.find((p) => p.id === c.post)?.type,
769
+ parent: c.parent || null,
770
+ parent_resolved: c.parent ? commentIds.has(c.parent) : null,
771
+ author: strip(c.author_name) || "anonymous",
772
+ email: null,
773
+ url: c.author_url || null,
774
+ date: iso(c.date_gmt),
775
+ date_gmt: c.date_gmt ?? null,
776
+ content: c.content?.rendered ?? "",
777
+ approved: approvedOf(c.status),
778
+ type: c.type ?? "comment",
779
+ user_id: c.author || null
780
+ }));
781
+ for (const c of comments) if (!postIds.has(c.post)) warnings.push(`comment ${c.id}: post ${c.post} not in fetched set`);
782
+ return {
783
+ raw: {
784
+ version: MIGRATION_CONTRACT_VERSION,
785
+ provenance: {
786
+ kind: options.auth ? "rest_auth" : "rest_public",
787
+ tool: options.tool ?? "@contentrain/wp-import"
788
+ },
789
+ site: { url: origin },
790
+ authors: users.map((u) => ({
791
+ id: u.id,
792
+ login: u.slug,
793
+ display_name: strip(u.name) || u.slug,
794
+ email: null
795
+ })),
796
+ terms: [...termsById.values()],
797
+ posts,
798
+ attachments,
799
+ comments
800
+ },
801
+ warnings
802
+ };
803
+ }
804
+ //#endregion
805
+ //#region src/contentrain.ts
806
+ const pick = (o, keys) => Object.fromEntries(Object.entries(o).filter(([k, v]) => keys.has(k) && v !== void 0 && v !== null && v !== ""));
807
+ const typeModelId = (t) => t === "post" ? "posts" : t === "page" ? "pages" : t.replace(/_/g, "-");
808
+ const domainOf = (t) => t === "post" ? "blog" : t === "page" ? "site" : "custom";
809
+ function rawToContentrain(raw, opts) {
810
+ const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
811
+ const locale = (raw.site.language ?? "en").split(/[-_]/)[0]?.toLowerCase() || "en";
812
+ const models = {};
813
+ const contents = {};
814
+ const metas = {};
815
+ let siteEntry = {};
816
+ let siteMeta = null;
817
+ const report = {
818
+ slug_rewritten: 0,
819
+ slug_fallback: 0,
820
+ title_fallback: 0,
821
+ meta_fields: {},
822
+ acf_fields: {},
823
+ skipped_types: [],
824
+ dropped_relations: 0,
825
+ models: {}
826
+ };
827
+ const importMeta = (status, extra = {}) => ({
828
+ status,
829
+ source: "import",
830
+ updated_by: updatedBy,
831
+ ...extra
832
+ });
833
+ const mapStatus = (p) => p.status === "publish" || p.status === "inherit" ? importMeta("published") : p.status === "future" ? importMeta("draft", p.date ? { publish_at: p.date } : {}) : p.status === "pending" ? importMeta("in_review") : p.status === "trash" ? importMeta("archived") : importMeta("draft");
834
+ const addModel = (m) => {
835
+ models[m.id] = m;
836
+ contents[m.id] ??= {};
837
+ metas[m.id] ??= {};
838
+ };
839
+ const authorId = (login) => hexId(`authors:${login}`);
840
+ const termId = (tax, slug) => hexId(`${tax}:${slug}`);
841
+ const usedSlugs = /* @__PURE__ */ new Map();
842
+ const postSlug = (p) => {
843
+ let s = slugify(decodeSlug(p.slug));
844
+ if (!s) {
845
+ s = `${p.type}-${p.id}`;
846
+ report.slug_fallback++;
847
+ } else if (s !== p.slug) report.slug_rewritten++;
848
+ const key = `${p.type}:${s}`;
849
+ const n = usedSlugs.get(key) ?? 0;
850
+ usedSlugs.set(key, n + 1);
851
+ return n ? `${s}-${p.id}` : s;
852
+ };
853
+ const slugOf = /* @__PURE__ */ new Map();
854
+ const postEntry = /* @__PURE__ */ new Map();
855
+ for (const p of raw.posts) {
856
+ const s = postSlug(p);
857
+ slugOf.set(p.id, s);
858
+ postEntry.set(p.id, {
859
+ model: typeModelId(p.type),
860
+ ref: hexId(`${typeModelId(p.type)}:${s}`)
861
+ });
862
+ }
863
+ const mediaRef = (id) => raw.attachments.some((a) => a.id === id) ? hexId(`media:${id}`) : null;
864
+ const titleOf = (p) => {
865
+ const t = strip(p.title);
866
+ if (t) return t;
867
+ report.title_fallback++;
868
+ return slugOf.get(p.id) ?? `#${p.id}`;
869
+ };
870
+ addModel({
871
+ id: "authors",
872
+ name: "Authors",
873
+ kind: "collection",
874
+ domain: "blog",
875
+ i18n: false,
876
+ title_field: "name",
877
+ fields: {
878
+ name: {
879
+ type: "string",
880
+ required: true,
881
+ label: "Name",
882
+ order: 10
883
+ },
884
+ slug: {
885
+ type: "slug",
886
+ required: true,
887
+ unique: true,
888
+ label: "Slug",
889
+ order: 20
890
+ },
891
+ email: {
892
+ type: "email",
893
+ label: "Email",
894
+ order: 30
895
+ },
896
+ wp_id: {
897
+ type: "integer",
898
+ label: "WP user ID",
899
+ order: 40
900
+ }
901
+ }
902
+ });
903
+ for (const a of raw.authors) {
904
+ const id = authorId(slugify(a.login));
905
+ contents.authors[id] = pick({
906
+ name: strip(a.display_name),
907
+ slug: slugify(a.login),
908
+ email: a.email,
909
+ wp_id: a.id
910
+ }, new Set([
911
+ "name",
912
+ "slug",
913
+ "email",
914
+ "wp_id"
915
+ ]));
916
+ metas.authors[id] = importMeta("published");
917
+ }
918
+ for (const p of raw.posts) if (p.author && !contents.authors[authorId(slugify(p.author))]) {
919
+ contents.authors[authorId(slugify(p.author))] = {
920
+ name: p.author,
921
+ slug: slugify(p.author)
922
+ };
923
+ metas.authors[authorId(slugify(p.author))] = importMeta("published");
924
+ }
925
+ const taxonomies = [...new Set(raw.terms.map((t) => t.taxonomy))].filter((t) => t !== "nav_menu" && t !== "post_format");
926
+ for (const tax of taxonomies) {
927
+ const mid = taxModelId(tax);
928
+ addModel({
929
+ id: mid,
930
+ name: mid,
931
+ kind: "collection",
932
+ domain: "blog",
933
+ i18n: false,
934
+ title_field: "name",
935
+ fields: {
936
+ name: {
937
+ type: "string",
938
+ required: true,
939
+ label: "Name",
940
+ order: 10
941
+ },
942
+ slug: {
943
+ type: "slug",
944
+ required: true,
945
+ unique: true,
946
+ label: "Slug",
947
+ order: 20
948
+ },
949
+ description: {
950
+ type: "text",
951
+ label: "Description",
952
+ order: 30
953
+ },
954
+ parent: {
955
+ type: "relation",
956
+ model: mid,
957
+ label: "Parent",
958
+ order: 40
959
+ },
960
+ wp_id: {
961
+ type: "integer",
962
+ label: "WP term ID",
963
+ order: 50
964
+ }
965
+ }
966
+ });
967
+ for (const t of raw.terms.filter((x) => x.taxonomy === tax)) {
968
+ const id = termId(tax, t.slug);
969
+ contents[mid][id] = pick({
970
+ name: strip(t.name),
971
+ slug: slugify(decodeSlug(t.slug)) || `term-${t.id}`,
972
+ description: strip(t.description),
973
+ parent: t.parent && t.parent_resolved ? termId(tax, t.parent) : null,
974
+ wp_id: t.id
975
+ }, new Set([
976
+ "name",
977
+ "slug",
978
+ "description",
979
+ "parent",
980
+ "wp_id"
981
+ ]));
982
+ metas[mid][id] = importMeta("published");
983
+ }
984
+ }
985
+ for (const p of raw.posts) for (const t of p.terms) {
986
+ if (t.taxonomy === "nav_menu" || t.taxonomy === "post_format" || t.resolved) continue;
987
+ const mid = taxModelId(t.taxonomy);
988
+ if (!models[mid]) continue;
989
+ const id = termId(t.taxonomy, t.slug);
990
+ contents[mid][id] ??= {
991
+ name: strip(t.name),
992
+ slug: slugify(t.slug)
993
+ };
994
+ metas[mid][id] ??= importMeta("published");
995
+ }
996
+ addModel({
997
+ id: "media",
998
+ name: "Media",
999
+ kind: "collection",
1000
+ domain: "assets",
1001
+ i18n: false,
1002
+ title_field: "title",
1003
+ fields: {
1004
+ title: {
1005
+ type: "string",
1006
+ required: true,
1007
+ label: "Title",
1008
+ order: 10
1009
+ },
1010
+ slug: {
1011
+ type: "slug",
1012
+ required: true,
1013
+ unique: true,
1014
+ label: "Slug",
1015
+ order: 20
1016
+ },
1017
+ url: {
1018
+ type: "url",
1019
+ required: true,
1020
+ label: "Source URL",
1021
+ order: 30
1022
+ },
1023
+ alt: {
1024
+ type: "string",
1025
+ label: "Alt text",
1026
+ order: 40
1027
+ },
1028
+ caption: {
1029
+ type: "text",
1030
+ label: "Caption",
1031
+ order: 50
1032
+ },
1033
+ description: {
1034
+ type: "text",
1035
+ label: "Description",
1036
+ order: 60
1037
+ },
1038
+ mime: {
1039
+ type: "string",
1040
+ label: "MIME type",
1041
+ order: 70
1042
+ },
1043
+ width: {
1044
+ type: "integer",
1045
+ label: "Width",
1046
+ order: 80
1047
+ },
1048
+ height: {
1049
+ type: "integer",
1050
+ label: "Height",
1051
+ order: 90
1052
+ },
1053
+ file: {
1054
+ type: "string",
1055
+ label: "Upload path",
1056
+ order: 100
1057
+ },
1058
+ parent: {
1059
+ type: "relation",
1060
+ model: [],
1061
+ label: "Attached to",
1062
+ order: 110
1063
+ },
1064
+ wp_id: {
1065
+ type: "integer",
1066
+ label: "WP attachment ID",
1067
+ order: 120
1068
+ },
1069
+ uploaded_at: {
1070
+ type: "datetime",
1071
+ label: "Uploaded at",
1072
+ order: 130
1073
+ }
1074
+ }
1075
+ });
1076
+ const mediaSlugs = /* @__PURE__ */ new Set();
1077
+ for (const a of raw.attachments) {
1078
+ const id = hexId(`media:${a.id}`);
1079
+ let slug = slugify(decodeSlug(a.slug)) || `media-${a.id}`;
1080
+ if (mediaSlugs.has(slug)) slug = `${slug}-${a.id}`;
1081
+ mediaSlugs.add(slug);
1082
+ const parent = a.parent ? postEntry.get(a.parent) ?? null : null;
1083
+ if (a.parent && !parent) report.dropped_relations++;
1084
+ const im = a.image_meta ?? null;
1085
+ contents.media[id] = pick({
1086
+ title: strip(a.title) || slug,
1087
+ slug,
1088
+ url: a.url,
1089
+ alt: strip(a.alt),
1090
+ caption: strip(a.caption),
1091
+ description: strip(a.description),
1092
+ mime: a.mime,
1093
+ width: im?.width,
1094
+ height: im?.height,
1095
+ file: a.file,
1096
+ parent,
1097
+ wp_id: a.id,
1098
+ uploaded_at: a.date
1099
+ }, new Set([
1100
+ "title",
1101
+ "slug",
1102
+ "url",
1103
+ "alt",
1104
+ "caption",
1105
+ "description",
1106
+ "mime",
1107
+ "width",
1108
+ "height",
1109
+ "file",
1110
+ "parent",
1111
+ "wp_id",
1112
+ "uploaded_at"
1113
+ ]));
1114
+ metas.media[id] = importMeta("published");
1115
+ }
1116
+ const postTypes = [...new Set(raw.posts.map((p) => p.type))].filter((t) => {
1117
+ if (SKIP_TYPES.test(t)) {
1118
+ report.skipped_types.push(t);
1119
+ return false;
1120
+ }
1121
+ return true;
1122
+ });
1123
+ const contentModelIds = postTypes.map(typeModelId);
1124
+ models.media.fields.parent.model = contentModelIds;
1125
+ for (const type of postTypes) {
1126
+ const typeItems = raw.posts.filter((p) => p.type === type);
1127
+ const mid = typeModelId(type);
1128
+ const taxes = [...new Set(typeItems.flatMap((p) => p.terms.map((t) => t.taxonomy)))].filter((t) => models[taxModelId(t)]);
1129
+ const { fields, order } = coreFields({
1130
+ excerpt: typeItems.some((p) => p.excerpt),
1131
+ body: typeItems.some((p) => p.content),
1132
+ date: typeItems.some((p) => p.date),
1133
+ author: typeItems.some((p) => p.author),
1134
+ taxes,
1135
+ cover: typeItems.some((p) => p.meta._thumbnail_id)
1136
+ });
1137
+ let o = order;
1138
+ fields.wp_id = {
1139
+ type: "integer",
1140
+ required: true,
1141
+ unique: true,
1142
+ label: "WP post ID",
1143
+ order: o += 10
1144
+ };
1145
+ fields.modified_at = {
1146
+ type: "datetime",
1147
+ label: "Modified at",
1148
+ order: o += 10
1149
+ };
1150
+ fields.link = {
1151
+ type: "url",
1152
+ label: "Original permalink",
1153
+ order: o += 10
1154
+ };
1155
+ if (typeItems.some((p) => p.parent)) fields.parent = {
1156
+ type: "relation",
1157
+ model: mid,
1158
+ label: "Parent",
1159
+ order: o += 10
1160
+ };
1161
+ if (typeItems.some((p) => p.menu_order)) fields.menu_order = {
1162
+ type: "integer",
1163
+ label: "Menu order",
1164
+ order: o += 10
1165
+ };
1166
+ if (typeItems.some((p) => p.sticky)) fields.sticky = {
1167
+ type: "boolean",
1168
+ label: "Sticky",
1169
+ order: o += 10
1170
+ };
1171
+ if (typeItems.some((p) => typeof p.meta._wp_page_template === "string" && p.meta._wp_page_template !== "default")) fields.template = {
1172
+ type: "string",
1173
+ label: "Page template",
1174
+ order: o += 10
1175
+ };
1176
+ const formats = [...new Set(typeItems.flatMap((p) => p.terms.filter((t) => t.taxonomy === "post_format").map((t) => t.slug.replace(/^post-format-/, ""))))].toSorted();
1177
+ if (formats.length) fields.format = {
1178
+ type: "select",
1179
+ options: formats,
1180
+ label: "Post format",
1181
+ order: o += 10
1182
+ };
1183
+ if (typeItems.some((p) => p.status === "private" || p.password)) fields.visibility = {
1184
+ type: "select",
1185
+ options: [
1186
+ "public",
1187
+ "private",
1188
+ "password"
1189
+ ],
1190
+ label: "Visibility",
1191
+ order: o += 10
1192
+ };
1193
+ if (typeItems.some((p) => p.comment_status)) fields.comments_open = {
1194
+ type: "boolean",
1195
+ label: "Comments open",
1196
+ order: o += 10
1197
+ };
1198
+ for (const p of typeItems) {
1199
+ for (const [k, v] of Object.entries(p.meta)) {
1200
+ if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
1201
+ const t = byValue(v);
1202
+ if (t && t !== "object") {
1203
+ fields[k] = {
1204
+ type: t,
1205
+ label: k,
1206
+ order: o += 10
1207
+ };
1208
+ report.meta_fields[k] = t;
1209
+ }
1210
+ }
1211
+ for (const [k, acf] of Object.entries(p.acf ?? {})) {
1212
+ if (fields[k]) continue;
1213
+ const t = byValue(acf.value) ?? "string";
1214
+ fields[k] = {
1215
+ type: t === "object" ? "object" : t,
1216
+ label: k,
1217
+ description: "ACF",
1218
+ order: o += 10
1219
+ };
1220
+ report.acf_fields[k] = t;
1221
+ }
1222
+ }
1223
+ addModel({
1224
+ id: mid,
1225
+ name: type === "post" ? "Posts" : type === "page" ? "Pages" : mid,
1226
+ kind: "collection",
1227
+ domain: domainOf(type),
1228
+ i18n: false,
1229
+ title_field: "title",
1230
+ fields
1231
+ });
1232
+ for (const p of typeItems) {
1233
+ const id = postEntry.get(p.id).ref;
1234
+ const e = {
1235
+ title: titleOf(p),
1236
+ slug: slugOf.get(p.id),
1237
+ wp_id: p.id
1238
+ };
1239
+ if (fields.excerpt) e.excerpt = strip(p.excerpt);
1240
+ if (fields.body) e.body = p.content;
1241
+ if (fields.published_at && p.date) e.published_at = p.date;
1242
+ if (p.modified) e.modified_at = p.modified;
1243
+ if (p.link) e.link = p.link;
1244
+ if (fields.author && p.author) e.author = authorId(slugify(p.author));
1245
+ for (const t of taxes) e[taxModelId(t)] = p.terms.filter((x) => x.taxonomy === t && contents[taxModelId(t)][termId(t, x.slug)]).map((x) => termId(t, x.slug));
1246
+ if (fields.cover && p.meta._thumbnail_id) {
1247
+ const m = mediaRef(Number(p.meta._thumbnail_id));
1248
+ if (m) e.cover = m;
1249
+ else report.dropped_relations++;
1250
+ }
1251
+ if (fields.parent && p.parent) {
1252
+ const pe = postEntry.get(p.parent);
1253
+ if (pe && pe.model === mid) e.parent = pe.ref;
1254
+ else report.dropped_relations++;
1255
+ }
1256
+ if (fields.menu_order && p.menu_order) e.menu_order = p.menu_order;
1257
+ if (fields.sticky) e.sticky = p.sticky ?? false;
1258
+ if (fields.template && typeof p.meta._wp_page_template === "string" && p.meta._wp_page_template !== "default") e.template = p.meta._wp_page_template;
1259
+ if (fields.format) {
1260
+ const f = p.terms.find((t) => t.taxonomy === "post_format");
1261
+ if (f) e.format = f.slug.replace(/^post-format-/, "");
1262
+ }
1263
+ if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
1264
+ if (fields.comments_open) e.comments_open = p.comment_status === "open";
1265
+ for (const k of Object.keys(fields)) if (k in p.meta && !(k in e) && !k.startsWith("_")) e[k] = p.meta[k];
1266
+ for (const [k, acf] of Object.entries(p.acf ?? {})) if (fields[k] && !(k in e)) e[k] = acf.value;
1267
+ contents[mid][id] = e;
1268
+ metas[mid][id] = mapStatus(p);
1269
+ }
1270
+ }
1271
+ const menus = raw.menus ?? [];
1272
+ if (menus.length) {
1273
+ addModel({
1274
+ id: "menus",
1275
+ name: "Menus",
1276
+ kind: "collection",
1277
+ domain: "site",
1278
+ i18n: false,
1279
+ title_field: "name",
1280
+ fields: {
1281
+ name: {
1282
+ type: "string",
1283
+ required: true,
1284
+ label: "Name",
1285
+ order: 10
1286
+ },
1287
+ slug: {
1288
+ type: "slug",
1289
+ required: true,
1290
+ unique: true,
1291
+ label: "Slug",
1292
+ order: 20
1293
+ },
1294
+ items: {
1295
+ type: "relations",
1296
+ model: "menu-items",
1297
+ label: "Items",
1298
+ order: 30
1299
+ },
1300
+ wp_id: {
1301
+ type: "integer",
1302
+ label: "WP term ID",
1303
+ order: 40
1304
+ }
1305
+ }
1306
+ });
1307
+ addModel({
1308
+ id: "menu-items",
1309
+ name: "Menu items",
1310
+ kind: "collection",
1311
+ domain: "site",
1312
+ i18n: false,
1313
+ title_field: "title",
1314
+ fields: {
1315
+ title: {
1316
+ type: "string",
1317
+ required: true,
1318
+ label: "Label",
1319
+ order: 10
1320
+ },
1321
+ menu: {
1322
+ type: "relation",
1323
+ model: "menus",
1324
+ required: true,
1325
+ label: "Menu",
1326
+ order: 20
1327
+ },
1328
+ order: {
1329
+ type: "integer",
1330
+ label: "Order",
1331
+ order: 30
1332
+ },
1333
+ parent: {
1334
+ type: "relation",
1335
+ model: "menu-items",
1336
+ label: "Parent item",
1337
+ order: 40
1338
+ },
1339
+ type: {
1340
+ type: "select",
1341
+ options: [
1342
+ "custom",
1343
+ "post_type",
1344
+ "taxonomy",
1345
+ "post_type_archive"
1346
+ ],
1347
+ label: "Type",
1348
+ order: 50
1349
+ },
1350
+ url: {
1351
+ type: "url",
1352
+ label: "URL",
1353
+ order: 60
1354
+ },
1355
+ target: {
1356
+ type: "relation",
1357
+ model: [...contentModelIds, ...taxonomies.map(taxModelId)],
1358
+ label: "Target",
1359
+ order: 70
1360
+ },
1361
+ open_in_new_tab: {
1362
+ type: "boolean",
1363
+ label: "Open in new tab",
1364
+ order: 80
1365
+ },
1366
+ classes: {
1367
+ type: "array",
1368
+ items: "string",
1369
+ label: "CSS classes",
1370
+ order: 90
1371
+ },
1372
+ description: {
1373
+ type: "text",
1374
+ label: "Description",
1375
+ order: 100
1376
+ },
1377
+ wp_id: {
1378
+ type: "integer",
1379
+ label: "WP post ID",
1380
+ order: 110
1381
+ }
1382
+ }
1383
+ });
1384
+ const itemRef = (id) => hexId(`menu-items:${id}`);
1385
+ for (const m of menus) {
1386
+ const mid = hexId(`menus:${m.slug}`);
1387
+ contents.menus[mid] = {
1388
+ name: strip(m.name),
1389
+ slug: slugify(m.slug) || `menu-${m.id}`,
1390
+ items: m.items.map((i) => itemRef(i.id)),
1391
+ wp_id: m.id
1392
+ };
1393
+ metas.menus[mid] = importMeta("published");
1394
+ for (const i of m.items) {
1395
+ const e = {
1396
+ title: strip(i.title) || i.url || `item-${i.id}`,
1397
+ menu: mid,
1398
+ order: i.order,
1399
+ type: i.target.kind === "url" ? "custom" : i.target.kind === "post" ? "post_type" : i.target.kind === "term" ? "taxonomy" : i.target.kind === "archive" ? "post_type_archive" : "custom",
1400
+ wp_id: i.id,
1401
+ open_in_new_tab: i.target_attr === "_blank"
1402
+ };
1403
+ if (i.parent && !i.parent_unresolved) e.parent = itemRef(i.parent);
1404
+ if (i.target.kind === "url" && i.url) e.url = i.url;
1405
+ if (i.target.kind === "post" && i.target.resolved && i.target.id != null) {
1406
+ const pe = postEntry.get(i.target.id);
1407
+ if (pe) e.target = pe;
1408
+ else report.dropped_relations++;
1409
+ }
1410
+ if (i.target.kind === "term" && i.target.resolved && i.target.slug && models[taxModelId(i.target.taxonomy)]) e.target = {
1411
+ model: taxModelId(i.target.taxonomy),
1412
+ ref: termId(i.target.taxonomy, i.target.slug)
1413
+ };
1414
+ if (i.classes?.length) e.classes = i.classes;
1415
+ if (i.description) e.description = strip(i.description);
1416
+ contents["menu-items"][itemRef(i.id)] = e;
1417
+ metas["menu-items"][itemRef(i.id)] = importMeta(i.status === "publish" ? "published" : "draft");
1418
+ }
1419
+ }
1420
+ }
1421
+ const comments = raw.comments ?? [];
1422
+ if (comments.length) {
1423
+ addModel({
1424
+ id: "comments",
1425
+ name: "Comments",
1426
+ kind: "collection",
1427
+ domain: "blog",
1428
+ i18n: false,
1429
+ title_field: "author",
1430
+ fields: {
1431
+ author: {
1432
+ type: "string",
1433
+ required: true,
1434
+ label: "Author",
1435
+ order: 10
1436
+ },
1437
+ email: {
1438
+ type: "email",
1439
+ label: "Email",
1440
+ order: 20
1441
+ },
1442
+ url: {
1443
+ type: "url",
1444
+ label: "Website",
1445
+ order: 30
1446
+ },
1447
+ body: {
1448
+ type: "richtext",
1449
+ label: "Body",
1450
+ order: 40
1451
+ },
1452
+ published_at: {
1453
+ type: "datetime",
1454
+ label: "Date",
1455
+ order: 50
1456
+ },
1457
+ post: {
1458
+ type: "relation",
1459
+ model: contentModelIds,
1460
+ required: true,
1461
+ label: "Post",
1462
+ order: 60
1463
+ },
1464
+ parent: {
1465
+ type: "relation",
1466
+ model: "comments",
1467
+ label: "In reply to",
1468
+ order: 70
1469
+ },
1470
+ type: {
1471
+ type: "string",
1472
+ label: "Type",
1473
+ order: 80
1474
+ },
1475
+ user: {
1476
+ type: "relation",
1477
+ model: "authors",
1478
+ label: "User",
1479
+ order: 90
1480
+ },
1481
+ wp_id: {
1482
+ type: "integer",
1483
+ label: "WP comment ID",
1484
+ order: 100
1485
+ }
1486
+ }
1487
+ });
1488
+ const cRef = (id) => hexId(`comments:${id}`);
1489
+ const userById = new Map(raw.authors.filter((a) => a.id != null).map((a) => [a.id, authorId(slugify(a.login))]));
1490
+ for (const c of comments) {
1491
+ const post = postEntry.get(c.post);
1492
+ if (!post) {
1493
+ report.dropped_relations++;
1494
+ continue;
1495
+ }
1496
+ contents.comments[cRef(c.id)] = pick({
1497
+ author: strip(c.author) || "anonymous",
1498
+ email: c.email,
1499
+ url: c.url,
1500
+ body: c.content,
1501
+ published_at: c.date,
1502
+ post,
1503
+ parent: c.parent && c.parent_resolved ? cRef(c.parent) : null,
1504
+ type: c.type,
1505
+ user: c.user_id ? userById.get(c.user_id) : null,
1506
+ wp_id: c.id
1507
+ }, new Set([
1508
+ "author",
1509
+ "email",
1510
+ "url",
1511
+ "body",
1512
+ "published_at",
1513
+ "post",
1514
+ "parent",
1515
+ "type",
1516
+ "user",
1517
+ "wp_id"
1518
+ ]));
1519
+ metas.comments[cRef(c.id)] = importMeta(c.approved === "1" ? "published" : c.approved === "spam" || c.approved === "trash" ? "archived" : "in_review");
1520
+ }
1521
+ }
1522
+ addModel({
1523
+ id: "site",
1524
+ name: "Site",
1525
+ kind: "singleton",
1526
+ domain: "site",
1527
+ i18n: false,
1528
+ title_field: "title",
1529
+ fields: {
1530
+ title: {
1531
+ type: "string",
1532
+ required: true,
1533
+ label: "Site title",
1534
+ order: 10
1535
+ },
1536
+ tagline: {
1537
+ type: "string",
1538
+ label: "Tagline",
1539
+ order: 20
1540
+ },
1541
+ url: {
1542
+ type: "url",
1543
+ label: "URL",
1544
+ order: 30
1545
+ },
1546
+ language: {
1547
+ type: "string",
1548
+ label: "Language",
1549
+ order: 40
1550
+ }
1551
+ }
1552
+ });
1553
+ siteEntry = pick({
1554
+ title: strip(raw.site.title) || "Site",
1555
+ tagline: raw.site.description,
1556
+ url: raw.site.url,
1557
+ language: raw.site.language
1558
+ }, new Set([
1559
+ "title",
1560
+ "tagline",
1561
+ "url",
1562
+ "language"
1563
+ ]));
1564
+ siteMeta = importMeta("published");
1565
+ const vocabTerms = {};
1566
+ for (const m of menus) for (const i of m.items) {
1567
+ const k = slugify(strip(i.title));
1568
+ if (k && !vocabTerms[k]) vocabTerms[k] = { [locale]: strip(i.title) };
1569
+ }
1570
+ const files = {};
1571
+ const domains = [...new Set(Object.values(models).map((m) => m.domain))].toSorted();
1572
+ for (const m of Object.values(models)) {
1573
+ files[`.contentrain/models/${m.id}.json`] = canon(m);
1574
+ const content = m.kind === "singleton" ? siteEntry : contents[m.id];
1575
+ const meta = m.kind === "singleton" ? siteMeta : metas[m.id];
1576
+ files[`.contentrain/content/${m.domain}/${m.id}/data.json`] = canon(content);
1577
+ files[`.contentrain/meta/${m.id}/${locale}.json`] = canon(meta);
1578
+ report.models[m.id] = {
1579
+ kind: m.kind,
1580
+ domain: m.domain,
1581
+ fields: Object.keys(m.fields ?? {}).length,
1582
+ entries: m.kind === "singleton" ? 1 : Object.keys(contents[m.id]).length
1583
+ };
1584
+ }
1585
+ files[".contentrain/config.json"] = canon({
1586
+ version: 1,
1587
+ stack: "astro",
1588
+ platform: "web",
1589
+ workflow: "review",
1590
+ locales: {
1591
+ default: locale,
1592
+ supported: [locale]
1593
+ },
1594
+ domains
1595
+ });
1596
+ files[".contentrain/vocabulary.json"] = canon({
1597
+ version: 1,
1598
+ terms: vocabTerms
1599
+ });
1600
+ const entrySourceMap = {};
1601
+ for (const [wpId, ref] of postEntry) entrySourceMap[String(wpId)] = {
1602
+ model_id: ref.model,
1603
+ entry_id: ref.ref,
1604
+ locale
1605
+ };
1606
+ files["import-report.json"] = canon(report);
1607
+ return {
1608
+ files,
1609
+ entry_source_map: entrySourceMap,
1610
+ report
1611
+ };
1612
+ }
1613
+ //#endregion
1614
+ //#region src/comments.ts
1615
+ function buildCommentsExport(raw, entries, opts) {
1616
+ const threadsClosed = raw.posts.filter((p) => p.comment_status && p.comment_status !== "open").map((p) => p.id);
1617
+ return {
1618
+ version: MIGRATION_CONTRACT_VERSION,
1619
+ format: COMMENTS_EXPORT_FORMAT,
1620
+ source: raw.provenance,
1621
+ site_url: raw.site.url || void 0,
1622
+ generated_at: opts?.generated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1623
+ entries,
1624
+ threads_closed: threadsClosed.length ? threadsClosed : void 0,
1625
+ comments: raw.comments ?? []
1626
+ };
1627
+ }
1628
+ /** Handoff summary for the export: counts + closed threads + what could not be addressed. */
1629
+ function summarizeComments(exp) {
1630
+ const byStatus = {};
1631
+ const byType = {};
1632
+ const unresolved = [];
1633
+ for (const c of exp.comments) {
1634
+ byStatus[c.approved ?? "1"] = (byStatus[c.approved ?? "1"] ?? 0) + 1;
1635
+ byType[c.type ?? "comment"] = (byType[c.type ?? "comment"] ?? 0) + 1;
1636
+ if (!exp.entries[String(c.post)]) unresolved.push({
1637
+ comment_id: c.id,
1638
+ post: c.post,
1639
+ reason: "post has no entry mapping"
1640
+ });
1641
+ }
1642
+ return {
1643
+ total: exp.comments.length,
1644
+ by_status: byStatus,
1645
+ types: byType,
1646
+ export: { format: exp.format },
1647
+ threads_closed: exp.threads_closed,
1648
+ unresolved: unresolved.length ? unresolved : void 0
1649
+ };
1650
+ }
1651
+ //#endregion
1652
+ export { buildCommentsExport, canon, fetchRestRawIR, hexId, looksSerialized, parseWxr, phpUnserialize, rawToContentrain, slugify, strip, summarizeComments, taxModelId, tryUnserialize };
1653
+
1654
+ //# sourceMappingURL=index.mjs.map