@contentrain/wp-import 0.5.7 → 0.7.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 CHANGED
@@ -675,6 +675,265 @@ function guessMime(u) {
675
675
  return MIME[(u.split("?")[0]?.split(".").pop() ?? "").toLowerCase()] ?? null;
676
676
  }
677
677
  //#endregion
678
+ //#region src/rest-menus.ts
679
+ const targetOf = (kind, object, id, url, ctx) => {
680
+ const objectId = id && id > 0 ? id : null;
681
+ if (kind === "post_type" && object) {
682
+ const slug = objectId ? ctx.postSlug(objectId) : void 0;
683
+ return {
684
+ kind: "post",
685
+ post_type: object,
686
+ id: objectId,
687
+ slug: slug ?? null,
688
+ resolved: slug !== void 0
689
+ };
690
+ }
691
+ if (kind === "taxonomy" && object) {
692
+ const slug = objectId ? ctx.termSlug(object, objectId) : void 0;
693
+ return {
694
+ kind: "term",
695
+ taxonomy: object,
696
+ id: objectId,
697
+ slug: slug ?? null,
698
+ resolved: slug !== void 0
699
+ };
700
+ }
701
+ if (kind === "post_type_archive" && object) return {
702
+ kind: "archive",
703
+ post_type: object,
704
+ resolved: true
705
+ };
706
+ if (url) return {
707
+ kind: "url",
708
+ url,
709
+ resolved: true
710
+ };
711
+ return {
712
+ kind: "unknown",
713
+ resolved: false
714
+ };
715
+ };
716
+ /**
717
+ * Items that are left out: a draft item, or one whose post target is not proven visible. Their children move up
718
+ * to the nearest kept ancestor, as WordPress's walker shows them.
719
+ */
720
+ function hiddenItems(items, drop) {
721
+ const parentOf = new Map(items.filter(drop).map((i) => [i.id, i.parent || null]));
722
+ const lift = (parent) => {
723
+ const seen = /* @__PURE__ */ new Set();
724
+ let p = parent;
725
+ while (p !== null && parentOf.has(p) && !seen.has(p)) {
726
+ seen.add(p);
727
+ p = parentOf.get(p) ?? null;
728
+ }
729
+ return p;
730
+ };
731
+ return {
732
+ gone: new Set(parentOf.keys()),
733
+ lift
734
+ };
735
+ }
736
+ /** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
737
+ function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
738
+ const out = [];
739
+ for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
740
+ const all = items.filter((i) => i.menus === m.id).toSorted((a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || a.id - b.id);
741
+ const { gone, lift } = hiddenItems(all, (i) => (i.status ?? "publish") !== "publish" || i.type === "post_type" && !(i.object_id && ctx.postSlug(i.object_id)));
742
+ dropped.count += gone.size;
743
+ const own = all.filter((i) => !gone.has(i.id));
744
+ const ids = new Set(own.map((i) => i.id));
745
+ const menu = {
746
+ id: m.id,
747
+ slug: m.slug ?? "",
748
+ name: strip(m.name ?? m.slug ?? ""),
749
+ items: own.map((i) => {
750
+ const item = {
751
+ id: i.id,
752
+ title: i.title?.raw || i.title?.rendered || "",
753
+ order: i.menu_order ?? 0,
754
+ parent: lift(i.parent || null),
755
+ url: i.url || null,
756
+ target: targetOf(i.type, i.object, i.object_id, i.url, ctx),
757
+ target_attr: i.target || null,
758
+ classes: (i.classes ?? []).filter(Boolean),
759
+ description: i.description ?? "",
760
+ status: i.status ?? "publish"
761
+ };
762
+ if (item.parent && !ids.has(item.parent)) item.parent_unresolved = true;
763
+ return item;
764
+ })
765
+ };
766
+ const locations = (m.locations ?? []).filter(Boolean).toSorted();
767
+ if (locations.length) menu.locations = locations;
768
+ out.push(menu);
769
+ }
770
+ return out;
771
+ }
772
+ /** Serialized block comments (`<!-- wp:name {attrs} /-->`, `<!-- /wp:name -->`). Same grammar as the facts builder parser. */
773
+ const BLOCK_TOKEN = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:(?!}\s+\/?-->)[\s\S])*?}\s+)?(\/)?-->/g;
774
+ function parseBlocks(content) {
775
+ const root = {
776
+ name: "root",
777
+ attrs: {},
778
+ children: []
779
+ };
780
+ const stack = [root];
781
+ const token = new RegExp(BLOCK_TOKEN.source, "g");
782
+ let m;
783
+ while (m = token.exec(content)) {
784
+ const [, closer, ns, local, json, selfClose] = m;
785
+ if (closer) {
786
+ if (stack.length > 1) stack.pop();
787
+ continue;
788
+ }
789
+ let attrs = {};
790
+ if (json) try {
791
+ attrs = JSON.parse(json);
792
+ } catch {
793
+ attrs = {};
794
+ }
795
+ const block = {
796
+ name: `${ns ? ns.slice(0, -1) : "core"}/${local}`,
797
+ attrs,
798
+ children: []
799
+ };
800
+ stack[stack.length - 1].children.push(block);
801
+ if (!selfClose) stack.push(block);
802
+ }
803
+ return root.children;
804
+ }
805
+ const str = (v) => typeof v === "string" && v ? v : void 0;
806
+ const absolute = (url, origin) => {
807
+ if (!url) return void 0;
808
+ try {
809
+ return new URL(url, `${origin}/`).href;
810
+ } catch {
811
+ return url;
812
+ }
813
+ };
814
+ /** The block editor's link kinds → the classic menu item types. */
815
+ const KIND = {
816
+ "post-type": "post_type",
817
+ taxonomy: "taxonomy",
818
+ custom: "custom"
819
+ };
820
+ /**
821
+ * Menu items of one `wp_navigation` body. Links and submenus become items; a page list becomes the
822
+ * published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
823
+ */
824
+ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
825
+ const items = [];
826
+ const push = (b, parent) => {
827
+ const id = nextId();
828
+ const url = absolute(b.url, ctx.origin);
829
+ items.push({
830
+ id,
831
+ title: b.title,
832
+ order: items.length + 1,
833
+ parent,
834
+ url: url ?? null,
835
+ target: targetOf(b.kind, b.object, b.id, url, ctx),
836
+ target_attr: b.newTab ? "_blank" : null,
837
+ classes: (b.className ?? "").split(/\s+/).filter(Boolean),
838
+ description: b.description ?? "",
839
+ status: "publish"
840
+ });
841
+ return id;
842
+ };
843
+ const pageList = (parentPage, parent) => {
844
+ const kids = ctx.pages.filter((p) => (p.parent ?? 0) === parentPage).toSorted((a, b) => a.menu_order - b.menu_order || a.title.localeCompare(b.title));
845
+ for (const p of kids) {
846
+ const id = push({
847
+ title: p.title,
848
+ url: p.link ?? void 0,
849
+ kind: "post_type",
850
+ object: "page",
851
+ id: p.id
852
+ }, parent);
853
+ pageList(p.id, id);
854
+ }
855
+ };
856
+ const walk = (blocks, parent) => {
857
+ for (const b of blocks) {
858
+ const a = b.attrs;
859
+ if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
860
+ const kind = KIND[str(a.kind) ?? ""] ?? (str(a.type) === "category" || str(a.type) === "tag" || str(a.type) === "post_tag" ? "taxonomy" : a.id ? "post_type" : "custom");
861
+ const object = kind === "taxonomy" && str(a.type) === "tag" ? "post_tag" : str(a.type);
862
+ if (kind === "post_type" && typeof a.id === "number" && !ctx.postSlug(a.id)) {
863
+ dropped.count++;
864
+ walk(b.children, parent);
865
+ continue;
866
+ }
867
+ const id = push({
868
+ title: strip(a.label),
869
+ url: str(a.url),
870
+ kind,
871
+ object,
872
+ id: typeof a.id === "number" ? a.id : void 0,
873
+ newTab: a.opensInNewTab === true,
874
+ className: str(a.className),
875
+ description: str(a.description)
876
+ }, parent);
877
+ walk(b.children, id);
878
+ } else if (b.name === "core/home-link") push({
879
+ title: strip(a.label) || "Home",
880
+ url: "/",
881
+ className: str(a.className)
882
+ }, parent);
883
+ else if (b.name === "core/page-list") pageList(typeof a.parentPageID === "number" ? a.parentPageID : 0, parent);
884
+ else walk(b.children, parent);
885
+ }
886
+ };
887
+ walk(parseBlocks(content), null);
888
+ return items;
889
+ }
890
+ /**
891
+ * Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
892
+ * refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
893
+ * one with links of its own shows those (an inline menu, not a `wp_navigation` post).
894
+ */
895
+ function navigationLocations(parts, navigations) {
896
+ const out = /* @__PURE__ */ new Map();
897
+ const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
898
+ const find = (blocks, area) => {
899
+ for (const b of blocks) {
900
+ if (b.name === "core/navigation") {
901
+ const ref = typeof b.attrs.ref === "number" ? b.attrs.ref : b.children.length ? void 0 : fallback;
902
+ if (ref !== void 0) (out.get(ref) ?? out.set(ref, /* @__PURE__ */ new Set()).get(ref)).add(area);
903
+ continue;
904
+ }
905
+ find(b.children, area);
906
+ }
907
+ };
908
+ for (const p of parts) {
909
+ const area = p.area && p.area !== "uncategorized" ? p.area : void 0;
910
+ if (area && p.content?.raw) find(parseBlocks(p.content.raw), area);
911
+ }
912
+ return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
913
+ }
914
+ /** Published `wp_navigation` posts → RawMenu[], slugs kept unique against the classic menus already read. */
915
+ function blockMenus(navigations, parts, ctx, taken, dropped = { count: 0 }) {
916
+ const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
917
+ const locations = navigationLocations(parts, published);
918
+ let next = 0;
919
+ const out = [];
920
+ for (const n of published) {
921
+ let slug = n.slug || `navigation-${n.id}`;
922
+ while (taken.has(slug)) slug = `${slug}-nav`;
923
+ taken.add(slug);
924
+ const menu = {
925
+ id: n.id,
926
+ slug,
927
+ name: strip(n.title?.raw || n.title?.rendered || "") || slug,
928
+ items: navigationItems(n.content?.raw ?? "", ctx, () => -++next, dropped)
929
+ };
930
+ const where = locations.get(n.id);
931
+ if (where?.length) menu.locations = where;
932
+ out.push(menu);
933
+ }
934
+ return out;
935
+ }
936
+ //#endregion
678
937
  //#region src/rest.ts
679
938
  const iso = (gmt) => gmt ? `${gmt}Z` : null;
680
939
  const approvedOf = (status) => status === "approved" ? "1" : status === "hold" ? "0" : status === "spam" ? "spam" : status === "trash" ? "trash" : status ?? "1";
@@ -693,6 +952,10 @@ const DENIED = new Set([
693
952
  401,
694
953
  403
695
954
  ]);
955
+ /** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
956
+ const commentKey = (status) => status === "approve" ? "comments" : `comments:${status}`;
957
+ /** What a visitor can open: published, not behind a post password. */
958
+ const visible = (p) => p.status === "publish" && !p.password;
696
959
  const positiveInteger = (name, value) => {
697
960
  if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`);
698
961
  return value;
@@ -721,20 +984,36 @@ async function fetchRestRawIR(options) {
721
984
  });
722
985
  }
723
986
  };
724
- const headers = { accept: "application/json" };
725
- if (options.auth) headers.authorization = `Basic ${Buffer.from(`${options.auth.user}:${options.auth.appPassword}`).toString("base64")}`;
987
+ const anonymous = { accept: "application/json" };
988
+ let headers = anonymous;
989
+ if (options.auth) headers = {
990
+ ...anonymous,
991
+ authorization: `Basic ${Buffer.from(`${options.auth.user}:${options.auth.appPassword}`).toString("base64")}`
992
+ };
993
+ const fellBack = /* @__PURE__ */ new Set();
994
+ let rejected = false;
995
+ if (options.auth) {
996
+ const me = await doFetch(`${origin}/wp-json/wp/v2/users/me`, { headers });
997
+ await me.body?.cancel();
998
+ if (me.status === 401 || me.status === 403) {
999
+ rejected = true;
1000
+ headers = anonymous;
1001
+ warnings.push(`credential: HTTP ${me.status} on users/me — the site rejected it; imported the public listings only`);
1002
+ }
1003
+ }
726
1004
  /**
727
1005
  * Every page of `path`. With `fallback`, a first page the site refuses
728
1006
  * (400/401/403 — the credential may not list those statuses or the edit
729
1007
  * context) is retried as `fallback` instead of dropping the collection;
730
1008
  * an empty `fallback` means there is nothing public to retry, so it is
731
- * skipped with that warning.
1009
+ * skipped with that warning. The retry is anonymous, and `key` goes into
1010
+ * `credential.fell_back`.
732
1011
  */
733
- const getAll = async (path, fallback) => {
1012
+ const getAll = async (path, fallback, key, send = headers) => {
734
1013
  const label = path.split("?")[0];
735
1014
  const url = (page) => `${origin}/wp-json/wp/v2/${path}${path.includes("?") ? "&" : "?"}per_page=${perPage}&page=${page}`;
736
1015
  const pageData = (page) => schedule(async () => {
737
- const response = await doFetch(url(page), { headers });
1016
+ const response = await doFetch(url(page), { headers: send });
738
1017
  if (!response.ok) {
739
1018
  await response.body?.cancel();
740
1019
  if (page === 1 && fallback !== void 0 && DENIED.has(response.status)) return {
@@ -761,7 +1040,8 @@ async function fetchRestRawIR(options) {
761
1040
  const first = await pageData(1);
762
1041
  if (first.denied !== void 0) {
763
1042
  warnings.push(`${label}: HTTP ${first.denied} for ${path.split("?")[1]} with the credential — ${fallback ? "fell back to the public listing" : "skipped (no public listing)"}`);
764
- return fallback ? getAll(fallback) : [];
1043
+ fellBack.add(key ?? label);
1044
+ return fallback ? getAll(fallback, void 0, void 0, anonymous) : [];
765
1045
  }
766
1046
  const totalPages = first.pages;
767
1047
  const firstPage = first.items;
@@ -771,6 +1051,18 @@ async function fetchRestRawIR(options) {
771
1051
  const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => pageData(i + 2).then((r) => r.items)));
772
1052
  return firstPage.concat(...rest);
773
1053
  };
1054
+ const about = await schedule(async () => {
1055
+ try {
1056
+ const index = await doFetch(`${origin}/wp-json/`, { headers });
1057
+ if (!index.ok) {
1058
+ await index.body?.cancel();
1059
+ return null;
1060
+ }
1061
+ return await index.json();
1062
+ } catch {
1063
+ return null;
1064
+ }
1065
+ });
774
1066
  const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
775
1067
  const types = typesResp.ok ? await typesResp.json() : {};
776
1068
  if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
@@ -785,10 +1077,15 @@ async function fetchRestRawIR(options) {
785
1077
  slug: "page",
786
1078
  base: "pages"
787
1079
  }];
788
- const postListing = (base) => options.auth ? getAll(`${base}?status=${AUTH_POST_STATUSES.join(",")}&context=edit`, base) : getAll(base);
1080
+ if (rejected) {
1081
+ for (const b of bases) fellBack.add(b.base);
1082
+ for (const status of AUTH_COMMENT_STATUSES) fellBack.add(commentKey(status));
1083
+ }
1084
+ const authed = options.auth !== void 0 && !rejected;
1085
+ const postListing = (base) => authed ? getAll(`${base}?status=${AUTH_POST_STATUSES.join(",")}&context=edit`, base, base) : getAll(base);
789
1086
  const commentListing = async () => {
790
- if (!options.auth) return getAll("comments");
791
- const lists = await Promise.all(AUTH_COMMENT_STATUSES.map((status) => getAll(`comments?status=${status}&context=edit`, status === "approve" ? "comments" : "")));
1087
+ if (!authed) return getAll("comments");
1088
+ const lists = await Promise.all(AUTH_COMMENT_STATUSES.map((status) => getAll(`comments?status=${status}&context=edit`, status === "approve" ? "comments" : "", commentKey(status))));
792
1089
  const byId = /* @__PURE__ */ new Map();
793
1090
  for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
794
1091
  return [...byId.values()];
@@ -891,6 +1188,87 @@ async function fetchRestRawIR(options) {
891
1188
  parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
892
1189
  date: iso(m.date_gmt)
893
1190
  }));
1191
+ const gaps = [];
1192
+ let menus = [];
1193
+ if (!authed) gaps.push("menus_require_auth");
1194
+ else {
1195
+ const listing = async (path) => {
1196
+ const url = (page) => `${origin}/wp-json/wp/v2/${path}${path.includes("?") ? "&" : "?"}per_page=${perPage}&page=${page}`;
1197
+ const first = await schedule(async () => {
1198
+ const r = await doFetch(url(1), { headers });
1199
+ if (!r.ok) {
1200
+ await r.body?.cancel();
1201
+ return {
1202
+ items: [],
1203
+ status: r.status,
1204
+ pages: 1
1205
+ };
1206
+ }
1207
+ const items = await r.json();
1208
+ return {
1209
+ items: Array.isArray(items) ? items : [],
1210
+ status: r.status,
1211
+ pages: Math.max(1, Number(r.headers.get("x-wp-totalpages") ?? "1") || 1)
1212
+ };
1213
+ });
1214
+ const wanted = maxPages !== void 0 ? Math.min(first.pages, maxPages) : first.pages;
1215
+ const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => schedule(async () => {
1216
+ const r = await doFetch(url(i + 2), { headers });
1217
+ if (!r.ok) {
1218
+ await r.body?.cancel();
1219
+ warnings.push(`${path.split("?")[0]}: page ${i + 2} HTTP ${r.status} — skipped`);
1220
+ return [];
1221
+ }
1222
+ const items = await r.json();
1223
+ return Array.isArray(items) ? items : [];
1224
+ })));
1225
+ return {
1226
+ items: first.items.concat(...rest),
1227
+ status: first.status
1228
+ };
1229
+ };
1230
+ const [classic, items, navs, parts] = await Promise.all([
1231
+ listing("menus?context=edit"),
1232
+ listing("menu-items?context=edit"),
1233
+ listing("navigation?context=edit&status=publish"),
1234
+ listing("template-parts?context=edit")
1235
+ ]);
1236
+ const denied = [
1237
+ classic,
1238
+ items,
1239
+ navs
1240
+ ].filter((l) => DENIED.has(l.status));
1241
+ if (denied.length) {
1242
+ gaps.push("menus_require_auth");
1243
+ warnings.push(`menus: HTTP ${denied[0].status} with the credential — the user may not edit theme options; menus not read`);
1244
+ }
1245
+ for (const [name, l] of [
1246
+ ["menus", classic],
1247
+ ["menu-items", items],
1248
+ ["navigation", navs]
1249
+ ]) if (!l.status || l.status >= 400 && !DENIED.has(l.status) && l.status !== 404) warnings.push(`${name}: HTTP ${l.status} — skipped`);
1250
+ const slugOf = new Map(posts.filter(visible).map((p) => [p.id, p.slug]));
1251
+ const ctx = {
1252
+ origin,
1253
+ postSlug: (id) => slugOf.get(id),
1254
+ termSlug: (taxonomy, id) => {
1255
+ const t = termsById.get(id);
1256
+ return t && t.taxonomy === taxonomy ? t.slug : void 0;
1257
+ },
1258
+ pages: posts.filter((p) => p.type === "page" && p.status === "publish" && !p.password).map((p) => ({
1259
+ id: p.id,
1260
+ parent: p.parent ?? null,
1261
+ menu_order: p.menu_order ?? 0,
1262
+ title: strip(p.title),
1263
+ link: p.link ?? null,
1264
+ slug: p.slug
1265
+ }))
1266
+ };
1267
+ const dropped = { count: 0 };
1268
+ menus = classicMenus(DENIED.has(classic.status) ? [] : classic.items, DENIED.has(items.status) ? [] : items.items, ctx, dropped);
1269
+ menus.push(...blockMenus(DENIED.has(navs.status) ? [] : navs.items, parts.status < 400 ? parts.items : [], ctx, new Set(menus.map((m) => m.slug)), dropped));
1270
+ if (dropped.count) warnings.push(`menus: ${dropped.count} item(s) are drafts or point at content not proven public (unpublished, password-protected, or not read by this import) — left out`);
1271
+ }
894
1272
  const postIds = new Set(posts.map((p) => p.id));
895
1273
  const commentIds = new Set(restComments.map((c) => c.id));
896
1274
  const comments = restComments.map((c) => ({
@@ -914,10 +1292,16 @@ async function fetchRestRawIR(options) {
914
1292
  raw: {
915
1293
  version: MIGRATION_CONTRACT_VERSION,
916
1294
  provenance: {
917
- kind: options.auth ? "rest_auth" : "rest_public",
1295
+ kind: authed ? "rest_auth" : "rest_public",
918
1296
  tool: options.tool ?? "@contentrain/wp-import"
919
1297
  },
920
- site: { url: origin },
1298
+ site: {
1299
+ url: origin,
1300
+ ...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
1301
+ ...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
1302
+ ...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
1303
+ ...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {}
1304
+ },
921
1305
  authors: users.map((u) => ({
922
1306
  id: u.id,
923
1307
  login: u.slug,
@@ -928,9 +1312,15 @@ async function fetchRestRawIR(options) {
928
1312
  posts,
929
1313
  attachments,
930
1314
  comments,
1315
+ ...menus.length ? { menus } : {},
931
1316
  ...languagePairs.length ? { language_pairs: languagePairs } : {}
932
1317
  },
933
- warnings
1318
+ warnings,
1319
+ credential: {
1320
+ status: !options.auth ? "none" : fellBack.size ? "rejected" : "accepted",
1321
+ fell_back: [...fellBack].toSorted()
1322
+ },
1323
+ gaps
934
1324
  };
935
1325
  }
936
1326
  //#endregion
@@ -1509,7 +1899,13 @@ function rawToContentrain(raw, opts) {
1509
1899
  type: "integer",
1510
1900
  label: "WP term ID",
1511
1901
  order: 40
1512
- }
1902
+ },
1903
+ ...menus.some((m) => m.locations?.length) ? { locations: {
1904
+ type: "array",
1905
+ items: "string",
1906
+ label: "Locations",
1907
+ order: 50
1908
+ } } : {}
1513
1909
  }
1514
1910
  });
1515
1911
  const toTarget = relationOver([...contentModelIds, ...taxonomies.map(taxModelId)]);
@@ -1597,7 +1993,8 @@ function rawToContentrain(raw, opts) {
1597
1993
  name: strip(m.name),
1598
1994
  slug: slugify(m.slug) || `menu-${m.id}`,
1599
1995
  items: m.items.map((i) => itemRef(i.id)),
1600
- wp_id: m.id
1996
+ wp_id: m.id,
1997
+ ...m.locations?.length ? { locations: m.locations } : {}
1601
1998
  };
1602
1999
  metas.menus[mid] = importMeta("published");
1603
2000
  for (const i of m.items) {
@@ -1606,7 +2003,7 @@ function rawToContentrain(raw, opts) {
1606
2003
  menu: mid,
1607
2004
  order: i.order,
1608
2005
  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",
1609
- wp_id: i.id,
2006
+ ...i.id > 0 ? { wp_id: i.id } : {},
1610
2007
  open_in_new_tab: i.target_attr === "_blank"
1611
2008
  };
1612
2009
  if (i.parent && !i.parent_unresolved) e.parent = itemRef(i.parent);
@@ -1837,8 +2234,44 @@ function rawToContentrain(raw, opts) {
1837
2234
  }
1838
2235
  //#endregion
1839
2236
  //#region src/comments.ts
2237
+ /**
2238
+ * The comments that may leave the source site (see `CommentsExport.comments`). An allowlist, so it
2239
+ * fails closed: a comment travels only when its entry is in this RawIR and public, and its status is
2240
+ * approved (`'1'`, or none) or pending (`'0'`). Everything else is counted in `excluded`: spam, trash,
2241
+ * WordPress's `post-trashed`, a plugin's own status, a post this RawIR does not hold.
2242
+ */
2243
+ function selectComments(raw) {
2244
+ const posts = new Map(raw.posts.map((p) => [p.id, p]));
2245
+ const excluded = {};
2246
+ const count = (key) => {
2247
+ excluded[key] = (excluded[key] ?? 0) + 1;
2248
+ };
2249
+ const comments = [];
2250
+ for (const c of raw.comments ?? []) {
2251
+ const post = posts.get(c.post);
2252
+ if (!post) {
2253
+ count("unknown_entry");
2254
+ continue;
2255
+ }
2256
+ if (post.status !== "publish" || post.password !== null && post.password !== void 0 && post.password !== "") {
2257
+ count("non_public_entry");
2258
+ continue;
2259
+ }
2260
+ const status = c.approved ?? "1";
2261
+ if (status === "1" || status === "0") {
2262
+ comments.push(c);
2263
+ continue;
2264
+ }
2265
+ count(status === "spam" ? "spam" : status === "trash" ? "trash" : "other_status");
2266
+ }
2267
+ return {
2268
+ comments,
2269
+ excluded
2270
+ };
2271
+ }
1840
2272
  function buildCommentsExport(raw, entries, opts) {
1841
2273
  const threadsClosed = raw.posts.filter((p) => p.comment_status && p.comment_status !== "open").map((p) => p.id);
2274
+ const { comments, excluded } = selectComments(raw);
1842
2275
  return {
1843
2276
  version: MIGRATION_CONTRACT_VERSION,
1844
2277
  format: COMMENTS_EXPORT_FORMAT,
@@ -1847,7 +2280,8 @@ function buildCommentsExport(raw, entries, opts) {
1847
2280
  generated_at: opts?.generated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1848
2281
  entries,
1849
2282
  threads_closed: threadsClosed.length ? threadsClosed : void 0,
1850
- comments: raw.comments ?? []
2283
+ comments,
2284
+ excluded: Object.keys(excluded).length ? excluded : void 0
1851
2285
  };
1852
2286
  }
1853
2287
  /** Handoff summary for the export: counts + closed threads + what could not be addressed. */
@@ -1870,7 +2304,8 @@ function summarizeComments(exp) {
1870
2304
  types: byType,
1871
2305
  export: { format: exp.format },
1872
2306
  threads_closed: exp.threads_closed,
1873
- unresolved: unresolved.length ? unresolved : void 0
2307
+ unresolved: unresolved.length ? unresolved : void 0,
2308
+ excluded: exp.excluded
1874
2309
  };
1875
2310
  }
1876
2311
  //#endregion
@@ -2124,6 +2559,6 @@ function formatSourceDeltaReport(plan) {
2124
2559
  return `${lines.join("\n")}\n`;
2125
2560
  }
2126
2561
  //#endregion
2127
- export { DEFAULT_IMPORTERS, DEFAULT_TAXONOMIES, PLUGIN_META, SKIP_TYPES, buildCommentsExport, canon, fetchRestRawIR, formatSourceDeltaReport, hexId, looksSerialized, parseWxr, phpUnserialize, planSourceDelta, rawToContentrain, slugify, strip, summarizeComments, taxModelId, tryUnserialize };
2562
+ export { DEFAULT_IMPORTERS, DEFAULT_TAXONOMIES, PLUGIN_META, SKIP_TYPES, buildCommentsExport, canon, fetchRestRawIR, formatSourceDeltaReport, hexId, looksSerialized, parseWxr, phpUnserialize, planSourceDelta, rawToContentrain, selectComments, slugify, strip, summarizeComments, taxModelId, tryUnserialize };
2128
2563
 
2129
2564
  //# sourceMappingURL=index.mjs.map