@contentrain/wp-import 0.6.0 → 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/README.md CHANGED
@@ -102,6 +102,36 @@ these before claiming a complete migration. ACF field/group records and their
102
102
  cross-model parents remain in the content store; route discovery decides which
103
103
  records become public pages. Importing configuration does not execute a plugin.
104
104
 
105
+ ### Site name over REST
106
+
107
+ `fetchRestRawIR` reads the `/wp-json/` index for the site's name and tagline
108
+ (`RawIR.site.title` / `description`, the store's `site` singleton) and its
109
+ install and public addresses (`base_site_url` / `base_blog_url`). An index that
110
+ does not answer leaves them out.
111
+
112
+ ### Menus over REST
113
+
114
+ With an Application Password, `fetchRestRawIR` reads the site's menus into
115
+ `RawIR.menus`: classic menus (`/wp/v2/menus` + `/wp/v2/menu-items`, with the
116
+ theme locations each is assigned to) and a block theme's published
117
+ `wp_navigation` posts (links, submenus, page lists, home links). A block menu's
118
+ `locations` are the template-part areas (`header`, `footer`) whose navigation
119
+ block refers to it; an empty navigation block without a `ref` shows the most
120
+ recent published one, as WordPress does. Block menu items have no WordPress id
121
+ and get negative ids; the store claims no `wp_id` for them.
122
+
123
+ A menu item that is itself a draft, or whose post target is not proven public
124
+ — a draft, pending, private, scheduled or password-protected post, or one this
125
+ import never read (a type outside REST, a listing past a page cap) — is left
126
+ out, fail-closed: its label is often that post's title. Its children move up to the
127
+ nearest kept ancestor; `warnings` gives only the count.
128
+
129
+ Both need `edit_theme_options`. Without a credential, with a rejected one, or
130
+ with a user who lacks that right, no menus are read and the result's `gaps`
131
+ contains `menus_require_auth` — an empty menu list is never presented as the
132
+ site having none. The `menus` model gains a `locations` field only when the
133
+ source named them.
134
+
105
135
 
106
136
  ### Publication and translation identity
107
137
 
package/dist/index.d.mts CHANGED
@@ -67,6 +67,12 @@ interface RestImportResult {
67
67
  status: 'none' | 'accepted' | 'rejected';
68
68
  fell_back: string[];
69
69
  };
70
+ /**
71
+ * What this rung could not read, as codes a caller can act on (the text is in `warnings`).
72
+ * `menus_require_auth`: menus and block navigation are only readable with an application password
73
+ * of a user who may edit theme options — none was given, the site rejected it, or it lacks that right.
74
+ */
75
+ gaps: string[];
70
76
  }
71
77
  declare function fetchRestRawIR(options: RestImportOptions): Promise<RestImportResult>;
72
78
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/wxr.ts","../src/rest.ts","../src/contentrain.ts","../src/comments.ts","../src/php-unserialize.ts","../src/core.ts","../src/delta.ts"],"mappings":";;;;UA2BiB,QAAA;EACf,UAAA;IAAc,QAAA;IAAkB,OAAA;IAAiB,MAAA;IAAgB,IAAA;EAAA;EACjE,UAAA;EACA,gBAAA;EACA,MAAA,EAAQ,MAAA;AAAA;AAAA,UAGO,cAAA;EACf,GAAA,EAAK,KAAA;EACL,KAAA,EAAO,QAAA;AAAA;AAAA,iBAiBa,QAAA,CAAS,KAAA,WAAgB,QAAA,EAAU,IAAA;EAAS,IAAA;AAAA,IAAkB,OAAA,CAAQ,cAAA;;;UCtC3E,iBAAA;EACf,MAAA;;EAEA,SAAA,UAAmB,KAAA;EDSI;;;;;;;;ECAvB,IAAA;IAAS,IAAA;IAAc,WAAA;EAAA;EACvB,OAAA;EDGc;AAGhB;;;;ECAE,WAAA;EDCK;;;;ECIL,QAAA;EACA,IAAA;AAAA;AAAA,UAGe,gBAAA;EACf,GAAA,EAAK,KAAA;EACL,QAAA;EDQkF;;;;;;;;ECClF,UAAA;IAAc,MAAA;IAA0C,SAAA;EAAA;AAAA;AAAA,iBA+CpC,cAAA,CAAe,OAAA,EAAS,iBAAA,GAAoB,OAAA,CAAQ,gBAAA;;;UC1EzD,YAAA;EACf,cAAA;EACA,aAAA;EACA,cAAA;EACA,WAAA,EAAa,MAAA;EACb,UAAA,EAAY,MAAA;EACZ,aAAA;EACA,iBAAA;EACA,MAAA,EAAQ,MAAA;IAAiB,IAAA;IAAc,MAAA;IAAgB,MAAA;IAAgB,OAAA;EAAA;EFJvE;EEMA,OAAA;EFNc;EEQd,kBAAA;EFLe;;;;;;EEYf,yBAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;EAClB,MAAA,EAAQ,YAAA;AAAA;AAAA,iBAoCM,gBAAA,CAAiB,GAAA,EAAK,KAAA,EAAO,IAAA;EAAS,SAAA;AAAA,IAAuB,iBAAA;;;;;;AF7D7E;;;iBGbgB,cAAA,CAAe,GAAA,EAAK,KAAA;EAAU,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,WAAA,CAAY,cAAA;AAAA;AAAA,iBAgB5E,mBAAA,CAAoB,GAAA,EAAK,KAAA,EAAO,OAAA,EAAS,cAAA,EAAgB,IAAA;EAAS,YAAA;AAAA,IAA0B,cAAA;;iBAiB5F,iBAAA,CAAkB,GAAA,EAAK,cAAA,GAAiB,eAAA;;;UC3CvC,iBAAA;EACf,EAAA;EACA,UAAA;EACA,KAAA;EACA,KAAA;AAAA;AAAA,iBAGc,eAAA,CAAgB,CAAA,YAAa,CAAA;AAAA,iBAI7B,cAAA,CAAe,GAAA;;iBAyFf,cAAA,CAAe,CAAA,WAAY,iBAAA;;;cC9F9B,KAAA,GAAS,CAAA;;cAST,KAAA,GAAS,CAAA;;cAcT,KAAA,GAAS,CAAA;;;;;;;;;ALCtB;cK8Ba,UAAA,EAAU,MAAA;;cAIV,WAAA,EAAW,MAAA;;cAOX,UAAA,GAAc,CAAA;AAAA,cAGd,OAAA,GAAW,CAAA;;;;UC3DP,UAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;AAAA;AAAA,UAGH,oBAAA;ENOD;EMLd,KAAA,EAAO,eAAA;ENEO;EMAd,KAAA,EAAO,UAAA;ENA0C;EMEjD,QAAA,GAAW,UAAA;ENDX;;;;;EMOA,UAAA;ENFe;;;;EMOf,SAAA;AAAA;AAAA,cAGW,kBAAA;AAAA,cACA,iBAAA;;;ANQb;;;;;;;;;;iBM8NgB,eAAA,CAAgB,KAAA,EAAO,oBAAA,GAAuB,eAAA;;iBAkC9C,uBAAA,CAAwB,IAAA,EAAM,eAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/wxr.ts","../src/rest.ts","../src/contentrain.ts","../src/comments.ts","../src/php-unserialize.ts","../src/core.ts","../src/delta.ts"],"mappings":";;;;UA2BiB,QAAA;EACf,UAAA;IAAc,QAAA;IAAkB,OAAA;IAAiB,MAAA;IAAgB,IAAA;EAAA;EACjE,UAAA;EACA,gBAAA;EACA,MAAA,EAAQ,MAAA;AAAA;AAAA,UAGO,cAAA;EACf,GAAA,EAAK,KAAA;EACL,KAAA,EAAO,QAAA;AAAA;AAAA,iBAiBa,QAAA,CAAS,KAAA,WAAgB,QAAA,EAAU,IAAA;EAAS,IAAA;AAAA,IAAkB,OAAA,CAAQ,cAAA;;;UCrC3E,iBAAA;EACf,MAAA;;EAEA,SAAA,UAAmB,KAAA;EDQI;;;;;;;;ECCvB,IAAA;IAAS,IAAA;IAAc,WAAA;EAAA;EACvB,OAAA;EDEc;AAGhB;;;;ECCE,WAAA;EDAK;;;;ECKL,QAAA;EACA,IAAA;AAAA;AAAA,UAGe,gBAAA;EACf,GAAA,EAAK,KAAA;EACL,QAAA;EDOkF;;;;;;;;ECElF,UAAA;IAAc,MAAA;IAA0C,SAAA;EAAA;;;AAvC1D;;;EA6CE,IAAA;AAAA;AAAA,iBAkDoB,cAAA,CAAe,OAAA,EAAS,iBAAA,GAAoB,OAAA,CAAQ,gBAAA;;;UCpFzD,YAAA;EACf,cAAA;EACA,aAAA;EACA,cAAA;EACA,WAAA,EAAa,MAAA;EACb,UAAA,EAAY,MAAA;EACZ,aAAA;EACA,iBAAA;EACA,MAAA,EAAQ,MAAA;IAAiB,IAAA;IAAc,MAAA;IAAgB,MAAA;IAAgB,OAAA;EAAA;EFJvE;EEMA,OAAA;EFNc;EEQd,kBAAA;EFLe;;;;;;EEYf,yBAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;EAClB,MAAA,EAAQ,YAAA;AAAA;AAAA,iBAoCM,gBAAA,CAAiB,GAAA,EAAK,KAAA,EAAO,IAAA;EAAS,SAAA;AAAA,IAAuB,iBAAA;;;;;;AF7D7E;;;iBGbgB,cAAA,CAAe,GAAA,EAAK,KAAA;EAAU,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,WAAA,CAAY,cAAA;AAAA;AAAA,iBAgB5E,mBAAA,CAAoB,GAAA,EAAK,KAAA,EAAO,OAAA,EAAS,cAAA,EAAgB,IAAA;EAAS,YAAA;AAAA,IAA0B,cAAA;;iBAiB5F,iBAAA,CAAkB,GAAA,EAAK,cAAA,GAAiB,eAAA;;;UC3CvC,iBAAA;EACf,EAAA;EACA,UAAA;EACA,KAAA;EACA,KAAA;AAAA;AAAA,iBAGc,eAAA,CAAgB,CAAA,YAAa,CAAA;AAAA,iBAI7B,cAAA,CAAe,GAAA;;iBAyFf,cAAA,CAAe,CAAA,WAAY,iBAAA;;;cC9F9B,KAAA,GAAS,CAAA;;cAST,KAAA,GAAS,CAAA;;cAcT,KAAA,GAAS,CAAA;;;;;;;;;ALCtB;cK8Ba,UAAA,EAAU,MAAA;;cAIV,WAAA,EAAW,MAAA;;cAOX,UAAA,GAAc,CAAA;AAAA,cAGd,OAAA,GAAW,CAAA;;;;UC3DP,UAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;AAAA;AAAA,UAGH,oBAAA;ENOD;EMLd,KAAA,EAAO,eAAA;ENEO;EMAd,KAAA,EAAO,UAAA;ENA0C;EMEjD,QAAA,GAAW,UAAA;ENDX;;;;;EMOA,UAAA;ENFe;;;;EMOf,SAAA;AAAA;AAAA,cAGW,kBAAA;AAAA,cACA,iBAAA;;;ANQb;;;;;;;;;;iBM8NgB,eAAA,CAAgB,KAAA,EAAO,oBAAA,GAAuB,eAAA;;iBAkC9C,uBAAA,CAAwB,IAAA,EAAM,eAAA"}
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";
@@ -695,6 +954,8 @@ const DENIED = new Set([
695
954
  ]);
696
955
  /** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
697
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;
698
959
  const positiveInteger = (name, value) => {
699
960
  if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`);
700
961
  return value;
@@ -790,6 +1051,18 @@ async function fetchRestRawIR(options) {
790
1051
  const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => pageData(i + 2).then((r) => r.items)));
791
1052
  return firstPage.concat(...rest);
792
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
+ });
793
1066
  const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
794
1067
  const types = typesResp.ok ? await typesResp.json() : {};
795
1068
  if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
@@ -915,6 +1188,87 @@ async function fetchRestRawIR(options) {
915
1188
  parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
916
1189
  date: iso(m.date_gmt)
917
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
+ }
918
1272
  const postIds = new Set(posts.map((p) => p.id));
919
1273
  const commentIds = new Set(restComments.map((c) => c.id));
920
1274
  const comments = restComments.map((c) => ({
@@ -941,7 +1295,13 @@ async function fetchRestRawIR(options) {
941
1295
  kind: authed ? "rest_auth" : "rest_public",
942
1296
  tool: options.tool ?? "@contentrain/wp-import"
943
1297
  },
944
- 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
+ },
945
1305
  authors: users.map((u) => ({
946
1306
  id: u.id,
947
1307
  login: u.slug,
@@ -952,13 +1312,15 @@ async function fetchRestRawIR(options) {
952
1312
  posts,
953
1313
  attachments,
954
1314
  comments,
1315
+ ...menus.length ? { menus } : {},
955
1316
  ...languagePairs.length ? { language_pairs: languagePairs } : {}
956
1317
  },
957
1318
  warnings,
958
1319
  credential: {
959
1320
  status: !options.auth ? "none" : fellBack.size ? "rejected" : "accepted",
960
1321
  fell_back: [...fellBack].toSorted()
961
- }
1322
+ },
1323
+ gaps
962
1324
  };
963
1325
  }
964
1326
  //#endregion
@@ -1537,7 +1899,13 @@ function rawToContentrain(raw, opts) {
1537
1899
  type: "integer",
1538
1900
  label: "WP term ID",
1539
1901
  order: 40
1540
- }
1902
+ },
1903
+ ...menus.some((m) => m.locations?.length) ? { locations: {
1904
+ type: "array",
1905
+ items: "string",
1906
+ label: "Locations",
1907
+ order: 50
1908
+ } } : {}
1541
1909
  }
1542
1910
  });
1543
1911
  const toTarget = relationOver([...contentModelIds, ...taxonomies.map(taxModelId)]);
@@ -1625,7 +1993,8 @@ function rawToContentrain(raw, opts) {
1625
1993
  name: strip(m.name),
1626
1994
  slug: slugify(m.slug) || `menu-${m.id}`,
1627
1995
  items: m.items.map((i) => itemRef(i.id)),
1628
- wp_id: m.id
1996
+ wp_id: m.id,
1997
+ ...m.locations?.length ? { locations: m.locations } : {}
1629
1998
  };
1630
1999
  metas.menus[mid] = importMeta("published");
1631
2000
  for (const i of m.items) {
@@ -1634,7 +2003,7 @@ function rawToContentrain(raw, opts) {
1634
2003
  menu: mid,
1635
2004
  order: i.order,
1636
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",
1637
- wp_id: i.id,
2006
+ ...i.id > 0 ? { wp_id: i.id } : {},
1638
2007
  open_in_new_tab: i.target_attr === "_blank"
1639
2008
  };
1640
2009
  if (i.parent && !i.parent_unresolved) e.parent = itemRef(i.parent);