@contentrain/wp-import 0.6.0 → 0.8.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,734 @@ function guessMime(u) {
675
675
  return MIME[(u.split("?")[0]?.split(".").pop() ?? "").toLowerCase()] ?? null;
676
676
  }
677
677
  //#endregion
678
+ //#region src/acf.ts
679
+ /** ACF types that never carry content (layout) or must never be read (secrets). */
680
+ const ACF_LAYOUT_TYPES = new Set([
681
+ "tab",
682
+ "accordion",
683
+ "message"
684
+ ]);
685
+ const ACF_SECRET_TYPES = new Set(["password"]);
686
+ /**
687
+ * ACF type → Contentrain type, for types whose value maps without a lookup.
688
+ * `select` with `multiple` and `checkbox` are arrays of their options; see `acfFieldDef`.
689
+ */
690
+ const ACF_SCALAR_TYPES = {
691
+ text: "string",
692
+ textarea: "text",
693
+ wysiwyg: "richtext",
694
+ email: "email",
695
+ url: "url",
696
+ oembed: "url",
697
+ number: "number",
698
+ range: "number",
699
+ true_false: "boolean",
700
+ date_picker: "date",
701
+ date_time_picker: "datetime",
702
+ time_picker: "string",
703
+ color_picker: "color",
704
+ icon_picker: "icon",
705
+ select: "select",
706
+ radio: "select",
707
+ button_group: "select",
708
+ image: "image",
709
+ file: "file"
710
+ };
711
+ const ACF_REFERENCE_TYPES = {
712
+ post_object: "post",
713
+ relationship: "post",
714
+ taxonomy: "term",
715
+ user: "user",
716
+ gallery: "media",
717
+ page_link: "address"
718
+ };
719
+ /**
720
+ * Names that mean a secret whatever the field's type, for sources that do not state the type: whole words
721
+ * of the name (`user_pass`, `apiKey`, `client-secret`), so `passage_text` or `compass` stay content.
722
+ */
723
+ const SECRET_NAME = /(?:^|_)(?:pass(?:word|wd)?|secret|token|api_?key|private_?key|credentials?)(?:_|$)/i;
724
+ const secretName = (name) => SECRET_NAME.test(name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_"));
725
+ /** Whether a field must never be read: a secret by type, or — when the type is unknown — by name. */
726
+ function acfIsSecret(name, type) {
727
+ if (type) return ACF_SECRET_TYPES.has(type);
728
+ return secretName(name);
729
+ }
730
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
731
+ const SOURCE = "_source";
732
+ /** The ACF type SCF states for a sub-field of a row or group (`<name>_source.type`). */
733
+ const statedType = (row, k) => {
734
+ const s = row[`${k}${SOURCE}`];
735
+ return isRecord(s) && typeof s.type === "string" ? s.type : void 0;
736
+ };
737
+ /** A sub-field that carries nothing to keep: a secret (stated, or by name) or a layout field. */
738
+ const droppedKey = (row, k) => {
739
+ const t = statedType(row, k);
740
+ return t !== void 0 && ACF_LAYOUT_TYPES.has(t) || acfIsSecret(k, t);
741
+ };
742
+ /**
743
+ * An ACF value with every secret sub-field removed, at any depth: repeater rows, groups, flexible layouts.
744
+ * A sub-field's `_source` keeps only its type and label — its `formatted_value` repeats the value.
745
+ */
746
+ function acfScrub(value, secret = /* @__PURE__ */ new Set()) {
747
+ if (Array.isArray(value)) {
748
+ const rows = value.filter(isRecord);
749
+ const shared = new Set([...secret, ...rows.flatMap((r) => Object.keys(r).filter((k) => !k.endsWith(SOURCE) && droppedKey(r, k)))]);
750
+ return value.map((v) => acfScrub(v, shared));
751
+ }
752
+ if (!isRecord(value)) return value;
753
+ const gone = (k) => secret.has(k) || droppedKey(value, k);
754
+ const out = {};
755
+ for (const [k, v] of Object.entries(value)) {
756
+ if (k.endsWith(SOURCE) && k.length > 7) {
757
+ if (gone(k.slice(0, -7)) || !isRecord(v)) continue;
758
+ out[k] = {
759
+ ...typeof v.type === "string" ? { type: v.type } : {},
760
+ ...typeof v.label === "string" ? { label: v.label } : {}
761
+ };
762
+ continue;
763
+ }
764
+ if (gone(k)) continue;
765
+ out[k] = acfScrub(v);
766
+ }
767
+ return out;
768
+ }
769
+ /** ACF's image/file array (`return_format: array`): `{ ID, url, filename, … }`. */
770
+ const isAttachment = (v) => isRecord(v) && typeof v.url === "string" && ("ID" in v || "id" in v) && ("filename" in v || "mime_type" in v);
771
+ const isLink = (v) => isRecord(v) && typeof v.url === "string" && "title" in v && "target" in v && Object.keys(v).length <= 3;
772
+ const isMap = (v) => isRecord(v) && typeof v.lat === "number" && typeof v.lng === "number";
773
+ /** A date picker's stored value (`Ymd`) → ISO date. */
774
+ const ymd = (v) => typeof v === "string" && /^\d{8}$/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : null;
775
+ /** `Y-m-d H:i:s` → ISO local datetime (site time: WordPress stores it without a zone). */
776
+ const dt = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v) ? v.replace(" ", "T") : null;
777
+ const pad = (n) => String(Math.floor(n)).padStart(2, "0");
778
+ /** Minutes east of UTC → `+03:00`. */
779
+ const offsetText = (minutes) => `${minutes < 0 ? "-" : "+"}${pad(Math.abs(minutes) / 60)}:${pad(Math.abs(minutes) % 60)}`;
780
+ /** The offset of an IANA zone at a wall-clock time there (DST included), in minutes east of UTC; `null` for an unknown zone. */
781
+ function zoneOffset(local, timeZone) {
782
+ let fmt;
783
+ try {
784
+ fmt = new Intl.DateTimeFormat("en-US", {
785
+ timeZone,
786
+ hourCycle: "h23",
787
+ year: "numeric",
788
+ month: "2-digit",
789
+ day: "2-digit",
790
+ hour: "2-digit",
791
+ minute: "2-digit",
792
+ second: "2-digit"
793
+ });
794
+ } catch {
795
+ return null;
796
+ }
797
+ const wall = Date.parse(`${local}Z`);
798
+ const at = (utc) => {
799
+ const p = Object.fromEntries(fmt.formatToParts(new Date(utc)).map((x) => [x.type, x.value]));
800
+ return (Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)) - utc) / 6e4;
801
+ };
802
+ return at(wall - at(wall) * 6e4);
803
+ }
804
+ /** A local ISO datetime with the site's offset for that moment; the local value when the site's zone is unknown. */
805
+ function zonedDateTime(local, zone = {}) {
806
+ const minutes = (zone.timeZone ? zoneOffset(local, zone.timeZone) : null) ?? (typeof zone.gmtOffset === "number" && Number.isFinite(zone.gmtOffset) ? Math.round(zone.gmtOffset * 60) : null);
807
+ return minutes === null ? {
808
+ value: local,
809
+ zoned: false
810
+ } : {
811
+ value: `${local}${offsetText(minutes)}`,
812
+ zoned: true
813
+ };
814
+ }
815
+ const LINK_FIELDS = {
816
+ url: { type: "url" },
817
+ title: { type: "string" },
818
+ target: { type: "string" }
819
+ };
820
+ const MAP_FIELDS = {
821
+ address: { type: "string" },
822
+ lat: { type: "decimal" },
823
+ lng: { type: "decimal" },
824
+ zoom: { type: "integer" }
825
+ };
826
+ /** Contentrain type of a sub-value whose ACF type is not stated (a repeater row, a group member). */
827
+ function inferDef(value) {
828
+ if (value === null || value === void 0 || value === "") return null;
829
+ if (isAttachment(value)) return { type: /\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i.test(value.url) ? "image" : "file" };
830
+ if (isLink(value)) return {
831
+ type: "object",
832
+ fields: LINK_FIELDS
833
+ };
834
+ if (isMap(value)) return {
835
+ type: "object",
836
+ fields: MAP_FIELDS
837
+ };
838
+ if (Array.isArray(value)) {
839
+ if (value.every(isRecord)) return {
840
+ type: "array",
841
+ items: {
842
+ type: "object",
843
+ fields: rowFields(value)
844
+ }
845
+ };
846
+ const item = value.map(inferDef).find(Boolean);
847
+ return {
848
+ type: "array",
849
+ items: item?.type === "object" ? item : item?.type ?? "string"
850
+ };
851
+ }
852
+ if (isRecord(value)) return {
853
+ type: "object",
854
+ fields: rowFields([value])
855
+ };
856
+ if (ymd(value)) return { type: "date" };
857
+ const t = byValue(value);
858
+ return t ? { type: t } : null;
859
+ }
860
+ /**
861
+ * The union of the fields every row states; a name typed two ways falls back to `string`. A sub-field whose ACF
862
+ * type the row states (SCF's `<name>_source`) is typed by the table like a top-level field; a reference inside a
863
+ * row is not resolved, so it — and any sub-field without a stated type — is typed from its value.
864
+ */
865
+ function rowFields(rows) {
866
+ const out = {};
867
+ for (const row of rows) for (const [k, v] of Object.entries(row)) {
868
+ if (k.endsWith(SOURCE) || droppedKey(row, k)) continue;
869
+ const stated = statedType(row, k);
870
+ const source = row[`${k}${SOURCE}`];
871
+ const label = isRecord(source) && typeof source.label === "string" ? source.label : void 0;
872
+ const d = stated && !ACF_REFERENCE_TYPES[stated] ? acfFieldDef(k, v, {
873
+ type: stated,
874
+ ...label ? { label } : {}
875
+ }) : inferDef(v);
876
+ if (!d) continue;
877
+ const seen = out[k];
878
+ if (!seen) out[k] = d;
879
+ else if (seen.type !== d.type) out[k] = { type: "string" };
880
+ }
881
+ return out;
882
+ }
883
+ /** A value in the shape its field definition promises; `undefined` = nothing to store. */
884
+ function acfValue(def, value, ctx = {}) {
885
+ if (value === null || value === void 0 || value === "" || Array.isArray(value) && !value.length) return void 0;
886
+ switch (def.type) {
887
+ case "image":
888
+ case "file":
889
+ case "video": return isAttachment(value) ? value.url : typeof value === "string" ? value : void 0;
890
+ case "date": return ymd(value) ?? (typeof value === "string" ? value : void 0);
891
+ case "datetime": {
892
+ const local = dt(value);
893
+ if (!local) return typeof value === "string" ? value : void 0;
894
+ const out = zonedDateTime(local, ctx);
895
+ if (!out.zoned) ctx.unzoned?.(out.value);
896
+ return out.value;
897
+ }
898
+ case "boolean": return value === true || value === 1 || value === "1";
899
+ case "number":
900
+ case "integer":
901
+ case "decimal": return typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)) ? Number(value) : void 0;
902
+ case "icon": return isRecord(value) && typeof value.value === "string" ? value.value : typeof value === "string" ? value : void 0;
903
+ case "object": {
904
+ if (!isRecord(value)) return void 0;
905
+ const out = {};
906
+ for (const [k, sub] of Object.entries(def.fields ?? {})) {
907
+ if (droppedKey(value, k)) continue;
908
+ const v = acfValue(sub, value[k], ctx);
909
+ if (v !== void 0) out[k] = v;
910
+ }
911
+ return Object.keys(out).length ? out : void 0;
912
+ }
913
+ case "array": {
914
+ if (!Array.isArray(value)) return void 0;
915
+ const item = typeof def.items === "string" ? { type: def.items } : def.items ?? { type: "string" };
916
+ const out = value.map((v) => acfValue(item, v, ctx)).filter((v) => v !== void 0);
917
+ return out.length ? out : void 0;
918
+ }
919
+ case "select": {
920
+ const v = typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
921
+ if (v !== void 0 && def.options?.length && !def.options.includes(v)) {
922
+ ctx.dropped?.(v, def.options);
923
+ return;
924
+ }
925
+ return v;
926
+ }
927
+ case "richtext":
928
+ case "text":
929
+ case "string":
930
+ case "url":
931
+ case "email":
932
+ case "color": return typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
933
+ default: return value;
934
+ }
935
+ }
936
+ /**
937
+ * Field definition for one ACF field value, or `null` when it carries no content, is a secret, or is a
938
+ * reference (the caller resolves those: see `ACF_REFERENCE_TYPES`).
939
+ */
940
+ function acfFieldDef(name, value, stated = {}) {
941
+ const t = stated.type;
942
+ if (t && (ACF_LAYOUT_TYPES.has(t) || ACF_REFERENCE_TYPES[t])) return null;
943
+ if (acfIsSecret(name, t)) return null;
944
+ const label = stated.label ? strip(stated.label) : void 0;
945
+ const withLabel = (d) => label ? {
946
+ ...d,
947
+ label
948
+ } : d;
949
+ if (!t) {
950
+ const inferred = inferDef(value);
951
+ return inferred ? withLabel({
952
+ ...inferred,
953
+ description: "ACF (type inferred from the value)"
954
+ }) : null;
955
+ }
956
+ const choices = stated.choices?.length ? stated.choices : void 0;
957
+ if (t === "checkbox" || t === "select" && (stated.multiple || Array.isArray(value))) return withLabel({
958
+ type: "array",
959
+ items: choices ? {
960
+ type: "select",
961
+ options: choices
962
+ } : "string"
963
+ });
964
+ if (ACF_SCALAR_TYPES[t]) {
965
+ const type = ACF_SCALAR_TYPES[t];
966
+ if (type === "select") return withLabel(choices ? {
967
+ type,
968
+ options: choices
969
+ } : { type: "string" });
970
+ return withLabel({ type });
971
+ }
972
+ if (t === "link") return withLabel({
973
+ type: "object",
974
+ fields: LINK_FIELDS
975
+ });
976
+ if (t === "google_map") return withLabel({
977
+ type: "object",
978
+ fields: MAP_FIELDS
979
+ });
980
+ if (t === "group") return withLabel({
981
+ type: "object",
982
+ fields: rowFields(isRecord(value) ? [value] : [])
983
+ });
984
+ if (t === "repeater") return withLabel({
985
+ type: "array",
986
+ items: {
987
+ type: "object",
988
+ fields: rowFields(Array.isArray(value) ? value.filter(isRecord) : [])
989
+ }
990
+ });
991
+ if (t === "flexible_content") {
992
+ const rows = Array.isArray(value) ? value.filter(isRecord) : [];
993
+ const fields = rowFields(rows.map(({ acf_fc_layout: _l, ...rest }) => rest));
994
+ return withLabel({
995
+ type: "array",
996
+ items: {
997
+ type: "object",
998
+ fields: {
999
+ layout: {
1000
+ type: "select",
1001
+ options: [...new Set(rows.map((r) => String(r.acf_fc_layout ?? "")).filter(Boolean))].toSorted(),
1002
+ required: true
1003
+ },
1004
+ ...fields
1005
+ }
1006
+ }
1007
+ });
1008
+ }
1009
+ const inferred = inferDef(value);
1010
+ return inferred ? withLabel({
1011
+ ...inferred,
1012
+ description: `ACF ${t} (type inferred from the value)`
1013
+ }) : null;
1014
+ }
1015
+ /** Merge two definitions of the same field seen on different entries (union of object fields and options). */
1016
+ function mergeFieldDef(a, b) {
1017
+ if (a.type !== b.type) return a;
1018
+ const out = { ...a };
1019
+ if (a.options || b.options) out.options = [...new Set([...a.options ?? [], ...b.options ?? []])].toSorted();
1020
+ if (a.fields || b.fields) {
1021
+ const fields = { ...a.fields };
1022
+ for (const [k, d] of Object.entries(b.fields ?? {})) fields[k] = fields[k] ? mergeFieldDef(fields[k], d) : d;
1023
+ out.fields = fields;
1024
+ }
1025
+ if (typeof a.items === "object" && typeof b.items === "object") out.items = mergeFieldDef(a.items, b.items);
1026
+ return out;
1027
+ }
1028
+ /** Flexible content rows keep their layout name under `layout`. */
1029
+ function acfRows(value) {
1030
+ return Array.isArray(value) ? value.map((r) => isRecord(r) && "acf_fc_layout" in r ? (({ acf_fc_layout, ...rest }) => ({
1031
+ layout: acf_fc_layout,
1032
+ ...rest
1033
+ }))(r) : r) : value;
1034
+ }
1035
+ //#endregion
1036
+ //#region src/rest-menus.ts
1037
+ const targetOf = (kind, object, id, url, ctx) => {
1038
+ const objectId = id && id > 0 ? id : null;
1039
+ if (kind === "post_type" && object) {
1040
+ const slug = objectId ? ctx.postSlug(objectId) : void 0;
1041
+ return {
1042
+ kind: "post",
1043
+ post_type: object,
1044
+ id: objectId,
1045
+ slug: slug ?? null,
1046
+ resolved: slug !== void 0
1047
+ };
1048
+ }
1049
+ if (kind === "taxonomy" && object) {
1050
+ const slug = objectId ? ctx.termSlug(object, objectId) : void 0;
1051
+ return {
1052
+ kind: "term",
1053
+ taxonomy: object,
1054
+ id: objectId,
1055
+ slug: slug ?? null,
1056
+ resolved: slug !== void 0
1057
+ };
1058
+ }
1059
+ if (kind === "post_type_archive" && object) return {
1060
+ kind: "archive",
1061
+ post_type: object,
1062
+ resolved: true
1063
+ };
1064
+ if (url) return {
1065
+ kind: "url",
1066
+ url,
1067
+ resolved: true
1068
+ };
1069
+ return {
1070
+ kind: "unknown",
1071
+ resolved: false
1072
+ };
1073
+ };
1074
+ /**
1075
+ * Items that are left out: a draft item, or one whose post target is not proven visible. Their children move up
1076
+ * to the nearest kept ancestor, as WordPress's walker shows them.
1077
+ */
1078
+ function hiddenItems(items, drop) {
1079
+ const parentOf = new Map(items.filter(drop).map((i) => [i.id, i.parent || null]));
1080
+ const lift = (parent) => {
1081
+ const seen = /* @__PURE__ */ new Set();
1082
+ let p = parent;
1083
+ while (p !== null && parentOf.has(p) && !seen.has(p)) {
1084
+ seen.add(p);
1085
+ p = parentOf.get(p) ?? null;
1086
+ }
1087
+ return p;
1088
+ };
1089
+ return {
1090
+ gone: new Set(parentOf.keys()),
1091
+ lift
1092
+ };
1093
+ }
1094
+ /**
1095
+ * The post a plain permalink names (`/?page_id=14`, `/?p=14`) on this site: a custom link typed by hand carries no
1096
+ * post id, but it points at a post all the same, and its label is often that post's title.
1097
+ */
1098
+ function plainPostId(url, origin) {
1099
+ if (!url || !url.includes("?")) return void 0;
1100
+ try {
1101
+ const u = new URL(url, `${origin}/`);
1102
+ if (u.origin !== new URL(origin).origin) return void 0;
1103
+ const id = Number(u.searchParams.get("page_id") ?? u.searchParams.get("p"));
1104
+ return Number.isSafeInteger(id) && id > 0 ? id : void 0;
1105
+ } catch {
1106
+ return;
1107
+ }
1108
+ }
1109
+ /** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
1110
+ function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
1111
+ const out = [];
1112
+ for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
1113
+ const all = items.filter((i) => i.menus === m.id).toSorted((a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || a.id - b.id);
1114
+ const hidden = (i) => {
1115
+ if ((i.status ?? "publish") !== "publish") return true;
1116
+ if (i.type === "post_type") return !(i.object_id && ctx.postSlug(i.object_id));
1117
+ const id = plainPostId(i.url, ctx.origin);
1118
+ return id !== void 0 && !ctx.postSlug(id);
1119
+ };
1120
+ const { gone, lift } = hiddenItems(all, hidden);
1121
+ dropped.count += gone.size;
1122
+ const own = all.filter((i) => !gone.has(i.id));
1123
+ const ids = new Set(own.map((i) => i.id));
1124
+ const menu = {
1125
+ id: m.id,
1126
+ slug: m.slug ?? "",
1127
+ name: strip(m.name ?? m.slug ?? ""),
1128
+ items: own.map((i) => {
1129
+ const item = {
1130
+ id: i.id,
1131
+ title: i.title?.raw || i.title?.rendered || "",
1132
+ order: i.menu_order ?? 0,
1133
+ parent: lift(i.parent || null),
1134
+ url: i.url || null,
1135
+ target: targetOf(i.type, i.object, i.object_id, i.url, ctx),
1136
+ target_attr: i.target || null,
1137
+ classes: (i.classes ?? []).filter(Boolean),
1138
+ description: i.description ?? "",
1139
+ status: i.status ?? "publish"
1140
+ };
1141
+ if (item.parent && !ids.has(item.parent)) item.parent_unresolved = true;
1142
+ return item;
1143
+ })
1144
+ };
1145
+ const locations = (m.locations ?? []).filter(Boolean).toSorted();
1146
+ if (locations.length) menu.locations = locations;
1147
+ out.push(menu);
1148
+ }
1149
+ return out;
1150
+ }
1151
+ /** Serialized block comments (`<!-- wp:name {attrs} /-->`, `<!-- /wp:name -->`). Same grammar as the facts builder parser. */
1152
+ const BLOCK_TOKEN = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:(?!}\s+\/?-->)[\s\S])*?}\s+)?(\/)?-->/g;
1153
+ function parseBlocks(content) {
1154
+ const root = {
1155
+ name: "root",
1156
+ attrs: {},
1157
+ children: []
1158
+ };
1159
+ const stack = [root];
1160
+ const token = new RegExp(BLOCK_TOKEN.source, "g");
1161
+ let m;
1162
+ while (m = token.exec(content)) {
1163
+ const [, closer, ns, local, json, selfClose] = m;
1164
+ if (closer) {
1165
+ if (stack.length > 1) stack.pop();
1166
+ continue;
1167
+ }
1168
+ let attrs = {};
1169
+ if (json) try {
1170
+ attrs = JSON.parse(json);
1171
+ } catch {
1172
+ attrs = {};
1173
+ }
1174
+ const block = {
1175
+ name: `${ns ? ns.slice(0, -1) : "core"}/${local}`,
1176
+ attrs,
1177
+ children: []
1178
+ };
1179
+ stack[stack.length - 1].children.push(block);
1180
+ if (!selfClose) stack.push(block);
1181
+ }
1182
+ return root.children;
1183
+ }
1184
+ const str = (v) => typeof v === "string" && v ? v : void 0;
1185
+ const absolute = (url, origin) => {
1186
+ if (!url) return void 0;
1187
+ if (url.startsWith("#")) return url;
1188
+ try {
1189
+ return new URL(url, `${origin}/`).href;
1190
+ } catch {
1191
+ return url;
1192
+ }
1193
+ };
1194
+ /** The block editor's link kinds → the classic menu item types. */
1195
+ const KIND = {
1196
+ "post-type": "post_type",
1197
+ taxonomy: "taxonomy",
1198
+ custom: "custom"
1199
+ };
1200
+ /**
1201
+ * Menu items of one `wp_navigation` body. Links and submenus become items; a page list becomes the
1202
+ * published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
1203
+ */
1204
+ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
1205
+ return blockItems(parseBlocks(content), ctx, nextId, dropped);
1206
+ }
1207
+ /** Menu items of navigation blocks already parsed (a `wp_navigation` body, or an inline navigation's links). */
1208
+ function blockItems(tree, ctx, nextId, dropped) {
1209
+ const items = [];
1210
+ const push = (b, parent) => {
1211
+ const id = nextId();
1212
+ const url = absolute(b.url, ctx.origin);
1213
+ items.push({
1214
+ id,
1215
+ title: b.title,
1216
+ order: items.length + 1,
1217
+ parent,
1218
+ url: url ?? null,
1219
+ target: targetOf(b.kind, b.object, b.id, url, ctx),
1220
+ target_attr: b.newTab ? "_blank" : null,
1221
+ classes: (b.className ?? "").split(/\s+/).filter(Boolean),
1222
+ description: b.description ?? "",
1223
+ status: "publish"
1224
+ });
1225
+ return id;
1226
+ };
1227
+ const pageList = (parentPage, parent) => {
1228
+ const kids = ctx.pages.filter((p) => (p.parent ?? 0) === parentPage).toSorted((a, b) => a.menu_order - b.menu_order || a.title.localeCompare(b.title));
1229
+ for (const p of kids) {
1230
+ const id = push({
1231
+ title: p.title,
1232
+ url: p.link ?? void 0,
1233
+ kind: "post_type",
1234
+ object: "page",
1235
+ id: p.id
1236
+ }, parent);
1237
+ pageList(p.id, id);
1238
+ }
1239
+ };
1240
+ const walk = (blocks, parent) => {
1241
+ for (const b of blocks) {
1242
+ const a = b.attrs;
1243
+ if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
1244
+ 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");
1245
+ const object = kind === "taxonomy" && str(a.type) === "tag" ? "post_tag" : str(a.type);
1246
+ const postId = kind === "post_type" && typeof a.id === "number" ? a.id : kind === "custom" ? plainPostId(str(a.url), ctx.origin) : void 0;
1247
+ if (postId !== void 0 && !ctx.postSlug(postId)) {
1248
+ dropped.count++;
1249
+ walk(b.children, parent);
1250
+ continue;
1251
+ }
1252
+ const id = push({
1253
+ title: strip(a.label),
1254
+ url: str(a.url),
1255
+ kind,
1256
+ object,
1257
+ id: typeof a.id === "number" ? a.id : void 0,
1258
+ newTab: a.opensInNewTab === true,
1259
+ className: str(a.className),
1260
+ description: str(a.description)
1261
+ }, parent);
1262
+ walk(b.children, id);
1263
+ } else if (b.name === "core/home-link") push({
1264
+ title: strip(a.label) || "Home",
1265
+ url: "/",
1266
+ className: str(a.className)
1267
+ }, parent);
1268
+ else if (b.name === "core/page-list") pageList(typeof a.parentPageID === "number" ? a.parentPageID : 0, parent);
1269
+ else walk(b.children, parent);
1270
+ }
1271
+ };
1272
+ walk(tree, null);
1273
+ return items;
1274
+ }
1275
+ /** Template-part slugs the templates use, at any depth (a part inside a group, a part inside a part is not followed). */
1276
+ function partSlugs(blocks, out = /* @__PURE__ */ new Set()) {
1277
+ for (const b of blocks) {
1278
+ if (b.name === "core/template-part" && typeof b.attrs.slug === "string") out.add(b.attrs.slug);
1279
+ partSlugs(b.children, out);
1280
+ }
1281
+ return out;
1282
+ }
1283
+ /**
1284
+ * The template parts a page can show: those a template references. Without the templates (not readable), the
1285
+ * part named after its area (`header`, `footer`) — what a theme's templates use unless they say otherwise.
1286
+ * Parts without a slug (older callers) all count.
1287
+ */
1288
+ function usedParts(parts, templates = []) {
1289
+ const used = /* @__PURE__ */ new Set();
1290
+ for (const t of templates) if (t.content?.raw) partSlugs(parseBlocks(t.content.raw), used);
1291
+ return parts.filter((p) => !p.slug || (used.size ? used.has(p.slug) : p.slug === p.area));
1292
+ }
1293
+ const AREA_ORDER = ["header", "footer"];
1294
+ const areaOf = (p) => p.area && p.area !== "uncategorized" ? p.area : void 0;
1295
+ /**
1296
+ * A part's blocks with the parts it holds in their place (a header holding a `navigation` part), each part once:
1297
+ * what the part shows, in its area.
1298
+ */
1299
+ function partTree(part, all) {
1300
+ const bySlug = new Map(all.filter((p) => p.slug).map((p) => [p.slug, p]));
1301
+ const seen = new Set(part.slug ? [part.slug] : []);
1302
+ const expand = (blocks) => blocks.flatMap((b) => {
1303
+ if (b.name === "core/template-part") {
1304
+ const inner = typeof b.attrs.slug === "string" ? bySlug.get(b.attrs.slug) : void 0;
1305
+ if (!inner?.content?.raw || seen.has(inner.slug)) return [];
1306
+ seen.add(inner.slug);
1307
+ return expand(parseBlocks(inner.content.raw));
1308
+ }
1309
+ return [{
1310
+ ...b,
1311
+ children: expand(b.children)
1312
+ }];
1313
+ });
1314
+ return expand(parseBlocks(part.content?.raw ?? ""));
1315
+ }
1316
+ const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1);
1317
+ const slugOf = (s) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1318
+ /**
1319
+ * Inline navigations of the used template parts → RawMenu[], in area order (header, footer, others) and document
1320
+ * order within a part. A part's single inline navigation is named after its area (`Footer navigation`), several
1321
+ * are numbered; a navigation's own `ariaLabel` wins. Links keep their order and nesting; a link to content not
1322
+ * proven public is left out, as in every other menu.
1323
+ */
1324
+ function inlineMenus(parts, ctx, taken, nextId, dropped = { count: 0 }, all = parts) {
1325
+ const out = [];
1326
+ const ordered = parts.filter((p) => areaOf(p) && p.content?.raw).toSorted((a, b) => (AREA_ORDER.indexOf(areaOf(a)) + 1 || 99) - (AREA_ORDER.indexOf(areaOf(b)) + 1 || 99));
1327
+ for (const p of ordered) {
1328
+ const area = areaOf(p);
1329
+ const navs = [];
1330
+ const find = (blocks) => {
1331
+ for (const b of blocks) if (b.name === "core/navigation") {
1332
+ if (typeof b.attrs.ref !== "number" && b.children.length) navs.push(b);
1333
+ } else find(b.children);
1334
+ };
1335
+ find(partTree(p, all));
1336
+ navs.forEach((nav, i) => {
1337
+ const name = strip(nav.attrs.ariaLabel) || `${titleCase(area)} navigation${navs.length > 1 ? ` ${i + 1}` : ""}`;
1338
+ const base = slugOf(name) || `${area}-navigation`;
1339
+ let slug = base;
1340
+ for (let k = 2; taken.has(slug); k++) slug = `${base}-${k}`;
1341
+ taken.add(slug);
1342
+ const items = blockItems(nav.children, ctx, nextId, dropped);
1343
+ if (items.length) out.push({
1344
+ id: nextId(),
1345
+ slug,
1346
+ name,
1347
+ items,
1348
+ locations: [area]
1349
+ });
1350
+ });
1351
+ }
1352
+ return out;
1353
+ }
1354
+ /**
1355
+ * Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
1356
+ * refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
1357
+ * one with links of its own shows those (an inline menu, not a `wp_navigation` post).
1358
+ */
1359
+ function navigationLocations(parts, navigations, all = parts) {
1360
+ const out = /* @__PURE__ */ new Map();
1361
+ const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
1362
+ const find = (blocks, area) => {
1363
+ for (const b of blocks) {
1364
+ if (b.name === "core/navigation") {
1365
+ const ref = typeof b.attrs.ref === "number" ? b.attrs.ref : b.children.length ? void 0 : fallback;
1366
+ if (ref !== void 0) (out.get(ref) ?? out.set(ref, /* @__PURE__ */ new Set()).get(ref)).add(area);
1367
+ continue;
1368
+ }
1369
+ find(b.children, area);
1370
+ }
1371
+ };
1372
+ for (const p of parts) {
1373
+ const area = areaOf(p);
1374
+ if (area && p.content?.raw) find(partTree(p, all), area);
1375
+ }
1376
+ return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
1377
+ }
1378
+ /**
1379
+ * Published `wp_navigation` posts, then the inline navigations of the used template parts → RawMenu[], slugs kept
1380
+ * unique against the classic menus already read. `templates` says which parts are used (see `usedParts`).
1381
+ */
1382
+ function blockMenus(navigations, allParts, ctx, taken, dropped = { count: 0 }, templates = []) {
1383
+ const parts = usedParts(allParts, templates);
1384
+ const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
1385
+ const locations = navigationLocations(parts, published, allParts);
1386
+ let next = 0;
1387
+ const out = [];
1388
+ for (const n of published) {
1389
+ let slug = n.slug || `navigation-${n.id}`;
1390
+ while (taken.has(slug)) slug = `${slug}-nav`;
1391
+ taken.add(slug);
1392
+ const menu = {
1393
+ id: n.id,
1394
+ slug,
1395
+ name: strip(n.title?.raw || n.title?.rendered || "") || slug,
1396
+ items: navigationItems(n.content?.raw ?? "", ctx, () => -++next, dropped)
1397
+ };
1398
+ const where = locations.get(n.id);
1399
+ if (where?.length) menu.locations = where;
1400
+ out.push(menu);
1401
+ }
1402
+ out.push(...inlineMenus(parts, ctx, taken, () => -++next, dropped, allParts));
1403
+ return out;
1404
+ }
1405
+ //#endregion
678
1406
  //#region src/rest.ts
679
1407
  const iso = (gmt) => gmt ? `${gmt}Z` : null;
680
1408
  const approvedOf = (status) => status === "approved" ? "1" : status === "hold" ? "0" : status === "spam" ? "spam" : status === "trash" ? "trash" : status ?? "1";
@@ -693,8 +1421,43 @@ const DENIED = new Set([
693
1421
  401,
694
1422
  403
695
1423
  ]);
1424
+ /** REST taxonomies that hold no content: menus, block-pattern categories, multilingual bookkeeping. */
1425
+ const NOT_CONTENT_TAXONOMIES = new Set([
1426
+ "category",
1427
+ "post_tag",
1428
+ "nav_menu",
1429
+ "wp_pattern_category",
1430
+ "post_format",
1431
+ "language",
1432
+ "post_translations",
1433
+ "term_language",
1434
+ "term_translations"
1435
+ ]);
696
1436
  /** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
697
1437
  const commentKey = (status) => status === "approve" ? "comments" : `comments:${status}`;
1438
+ /**
1439
+ * ACF fields of one REST post. `<name>_source` (SCF) states each field's type and label; a secret — a
1440
+ * `password` field, or, when no type is stated, a field named like one — is never read. ACF answers `[]`
1441
+ * for a post with no field group in REST.
1442
+ */
1443
+ function acfOf(acf) {
1444
+ if (!acf || Array.isArray(acf) || typeof acf !== "object") return {};
1445
+ const out = {};
1446
+ for (const [name, value] of Object.entries(acf)) {
1447
+ if (name.endsWith("_source") && name.slice(0, -7) in acf) continue;
1448
+ const source = acf[`${name}_source`];
1449
+ const type = typeof source?.type === "string" ? source.type : void 0;
1450
+ if (acfIsSecret(name, type)) continue;
1451
+ out[name] = {
1452
+ value: acfScrub(value),
1453
+ ...type ? { type } : {},
1454
+ ...typeof source?.label === "string" && source.label ? { label: source.label } : {}
1455
+ };
1456
+ }
1457
+ return Object.keys(out).length ? { acf: out } : {};
1458
+ }
1459
+ /** What a visitor can open: published, not behind a post password. */
1460
+ const visible = (p) => p.status === "publish" && !p.password;
698
1461
  const positiveInteger = (name, value) => {
699
1462
  if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`);
700
1463
  return value;
@@ -790,10 +1553,27 @@ async function fetchRestRawIR(options) {
790
1553
  const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => pageData(i + 2).then((r) => r.items)));
791
1554
  return firstPage.concat(...rest);
792
1555
  };
793
- const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
1556
+ const about = await schedule(async () => {
1557
+ try {
1558
+ const index = await doFetch(`${origin}/wp-json/`, { headers });
1559
+ if (!index.ok) {
1560
+ await index.body?.cancel();
1561
+ return null;
1562
+ }
1563
+ return await index.json();
1564
+ } catch {
1565
+ return null;
1566
+ }
1567
+ });
1568
+ let typesResp = await doFetch(`${origin}/wp-json/wp/v2/types${options.auth && !rejected ? "?context=edit" : ""}`, { headers });
1569
+ if (!typesResp.ok && options.auth && !rejected) {
1570
+ await typesResp.body?.cancel();
1571
+ typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers: anonymous });
1572
+ }
794
1573
  const types = typesResp.ok ? await typesResp.json() : {};
795
1574
  if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
796
1575
  const postTypes = Object.values(types).filter((t) => t.rest_base && !SKIP_TYPES.test(t.slug) && t.slug !== "attachment");
1576
+ const notViewable = new Set(postTypes.filter((t) => t.viewable === false).map((t) => t.slug));
797
1577
  const bases = postTypes.length ? postTypes.map((t) => ({
798
1578
  slug: t.slug,
799
1579
  base: t.rest_base
@@ -817,16 +1597,39 @@ async function fetchRestRawIR(options) {
817
1597
  for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
818
1598
  return [...byId.values()];
819
1599
  };
820
- const [categories, tags, users, media, restComments, ...postLists] = await Promise.all([
1600
+ const taxResp = await schedule(async () => {
1601
+ try {
1602
+ const r = await doFetch(`${origin}/wp-json/wp/v2/taxonomies`, { headers });
1603
+ if (!r.ok) {
1604
+ await r.body?.cancel();
1605
+ return {};
1606
+ }
1607
+ return await r.json();
1608
+ } catch {
1609
+ return {};
1610
+ }
1611
+ });
1612
+ const extraTaxonomies = Object.values(taxResp && typeof taxResp === "object" ? taxResp : {}).filter((t) => t.rest_base && /^[\w-]+$/.test(t.rest_base) && !NOT_CONTENT_TAXONOMIES.has(t.slug)).map((t) => ({
1613
+ taxonomy: t.slug,
1614
+ base: t.rest_base
1615
+ })).toSorted((a, b) => a.taxonomy.localeCompare(b.taxonomy));
1616
+ const [categories, tags, users, media, restComments, ...lists] = await Promise.all([
821
1617
  getAll("categories"),
822
1618
  getAll("tags"),
823
1619
  getAll("users"),
824
1620
  getAll("media"),
825
1621
  commentListing(),
1622
+ ...extraTaxonomies.map((t) => getAll(t.base)),
826
1623
  ...bases.map((b) => postListing(b.base))
827
1624
  ]);
1625
+ const extraTerms = lists.slice(0, extraTaxonomies.length);
1626
+ const postLists = lists.slice(extraTaxonomies.length);
828
1627
  const termsById = /* @__PURE__ */ new Map();
829
- for (const [tax, list] of [["category", categories], ["post_tag", tags]]) for (const t of list) termsById.set(t.id, {
1628
+ for (const [tax, list] of [
1629
+ ["category", categories],
1630
+ ["post_tag", tags],
1631
+ ...extraTaxonomies.map((t, i) => [t.taxonomy, extraTerms[i] ?? []])
1632
+ ]) for (const t of list) termsById.set(t.id, {
830
1633
  id: t.id,
831
1634
  taxonomy: tax,
832
1635
  slug: t.slug,
@@ -858,7 +1661,15 @@ async function fetchRestRawIR(options) {
858
1661
  });
859
1662
  };
860
1663
  for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
861
- const termRefs = [...p.categories ?? [], ...p.tags ?? []].map((id) => {
1664
+ const custom = extraTaxonomies.flatMap((t) => {
1665
+ const ids = p[t.base];
1666
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "number") : [];
1667
+ });
1668
+ const termRefs = [
1669
+ ...p.categories ?? [],
1670
+ ...p.tags ?? [],
1671
+ ...custom
1672
+ ].map((id) => {
862
1673
  const t = termsById.get(id);
863
1674
  return t ? {
864
1675
  taxonomy: t.taxonomy,
@@ -884,7 +1695,7 @@ async function fetchRestRawIR(options) {
884
1695
  status: p.status ?? "publish",
885
1696
  slug: p.slug,
886
1697
  title: p.title?.rendered ?? "",
887
- link: p.link ?? null,
1698
+ link: notViewable.has(base.slug) ? null : p.link ?? null,
888
1699
  guid: null,
889
1700
  author,
890
1701
  date: iso(p.date_gmt),
@@ -898,7 +1709,8 @@ async function fetchRestRawIR(options) {
898
1709
  comment_status: p.comment_status ?? null,
899
1710
  ping_status: p.ping_status ?? null,
900
1711
  terms: termRefs,
901
- meta
1712
+ meta,
1713
+ ...acfOf(p.acf)
902
1714
  });
903
1715
  }
904
1716
  const attachments = media.map((m) => ({
@@ -915,6 +1727,89 @@ async function fetchRestRawIR(options) {
915
1727
  parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
916
1728
  date: iso(m.date_gmt)
917
1729
  }));
1730
+ const gaps = [];
1731
+ if (postLists.some((list) => list.some((p) => p.acf !== void 0))) gaps.push("acf_partial");
1732
+ let menus = [];
1733
+ if (!authed) gaps.push("menus_require_auth");
1734
+ else {
1735
+ const listing = async (path) => {
1736
+ const url = (page) => `${origin}/wp-json/wp/v2/${path}${path.includes("?") ? "&" : "?"}per_page=${perPage}&page=${page}`;
1737
+ const first = await schedule(async () => {
1738
+ const r = await doFetch(url(1), { headers });
1739
+ if (!r.ok) {
1740
+ await r.body?.cancel();
1741
+ return {
1742
+ items: [],
1743
+ status: r.status,
1744
+ pages: 1
1745
+ };
1746
+ }
1747
+ const items = await r.json();
1748
+ return {
1749
+ items: Array.isArray(items) ? items : [],
1750
+ status: r.status,
1751
+ pages: Math.max(1, Number(r.headers.get("x-wp-totalpages") ?? "1") || 1)
1752
+ };
1753
+ });
1754
+ const wanted = maxPages !== void 0 ? Math.min(first.pages, maxPages) : first.pages;
1755
+ const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => schedule(async () => {
1756
+ const r = await doFetch(url(i + 2), { headers });
1757
+ if (!r.ok) {
1758
+ await r.body?.cancel();
1759
+ warnings.push(`${path.split("?")[0]}: page ${i + 2} HTTP ${r.status} — skipped`);
1760
+ return [];
1761
+ }
1762
+ const items = await r.json();
1763
+ return Array.isArray(items) ? items : [];
1764
+ })));
1765
+ return {
1766
+ items: first.items.concat(...rest),
1767
+ status: first.status
1768
+ };
1769
+ };
1770
+ const [classic, items, navs, parts, templates] = await Promise.all([
1771
+ listing("menus?context=edit"),
1772
+ listing("menu-items?context=edit"),
1773
+ listing("navigation?context=edit&status=publish"),
1774
+ listing("template-parts?context=edit"),
1775
+ listing("templates?context=edit&_fields=slug,content")
1776
+ ]);
1777
+ const denied = [
1778
+ classic,
1779
+ items,
1780
+ navs
1781
+ ].filter((l) => DENIED.has(l.status));
1782
+ if (denied.length) {
1783
+ gaps.push("menus_require_auth");
1784
+ warnings.push(`menus: HTTP ${denied[0].status} with the credential — the user may not edit theme options; menus not read`);
1785
+ }
1786
+ for (const [name, l] of [
1787
+ ["menus", classic],
1788
+ ["menu-items", items],
1789
+ ["navigation", navs]
1790
+ ]) if (!l.status || l.status >= 400 && !DENIED.has(l.status) && l.status !== 404) warnings.push(`${name}: HTTP ${l.status} — skipped`);
1791
+ const slugOf = new Map(posts.filter(visible).map((p) => [p.id, p.slug]));
1792
+ const ctx = {
1793
+ origin,
1794
+ postSlug: (id) => slugOf.get(id),
1795
+ termSlug: (taxonomy, id) => {
1796
+ const t = termsById.get(id);
1797
+ return t && t.taxonomy === taxonomy ? t.slug : void 0;
1798
+ },
1799
+ pages: posts.filter((p) => p.type === "page" && p.status === "publish" && !p.password).map((p) => ({
1800
+ id: p.id,
1801
+ parent: p.parent ?? null,
1802
+ menu_order: p.menu_order ?? 0,
1803
+ title: strip(p.title),
1804
+ link: p.link ?? null,
1805
+ slug: p.slug
1806
+ }))
1807
+ };
1808
+ const dropped = { count: 0 };
1809
+ menus = classicMenus(DENIED.has(classic.status) ? [] : classic.items, DENIED.has(items.status) ? [] : items.items, ctx, dropped);
1810
+ menus.push(...blockMenus(DENIED.has(navs.status) ? [] : navs.items, parts.status < 400 ? parts.items : [], ctx, new Set(menus.map((m) => m.slug)), dropped, templates.status < 400 ? templates.items : []));
1811
+ 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`);
1812
+ }
918
1813
  const postIds = new Set(posts.map((p) => p.id));
919
1814
  const commentIds = new Set(restComments.map((c) => c.id));
920
1815
  const comments = restComments.map((c) => ({
@@ -941,7 +1836,15 @@ async function fetchRestRawIR(options) {
941
1836
  kind: authed ? "rest_auth" : "rest_public",
942
1837
  tool: options.tool ?? "@contentrain/wp-import"
943
1838
  },
944
- site: { url: origin },
1839
+ site: {
1840
+ url: origin,
1841
+ ...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
1842
+ ...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
1843
+ ...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
1844
+ ...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {},
1845
+ ...typeof about?.timezone_string === "string" && about.timezone_string ? { timezone: about.timezone_string } : {},
1846
+ ...about?.gmt_offset !== void 0 && about.gmt_offset !== null && about.gmt_offset !== "" && Number.isFinite(Number(about.gmt_offset)) ? { gmt_offset: Number(about.gmt_offset) } : {}
1847
+ },
945
1848
  authors: users.map((u) => ({
946
1849
  id: u.id,
947
1850
  login: u.slug,
@@ -952,13 +1855,15 @@ async function fetchRestRawIR(options) {
952
1855
  posts,
953
1856
  attachments,
954
1857
  comments,
1858
+ ...menus.length ? { menus } : {},
955
1859
  ...languagePairs.length ? { language_pairs: languagePairs } : {}
956
1860
  },
957
1861
  warnings,
958
1862
  credential: {
959
1863
  status: !options.auth ? "none" : fellBack.size ? "rejected" : "accepted",
960
1864
  fell_back: [...fellBack].toSorted()
961
- }
1865
+ },
1866
+ gaps
962
1867
  };
963
1868
  }
964
1869
  //#endregion
@@ -993,6 +1898,8 @@ function relationOver(targets) {
993
1898
  ref: (entry) => single ? entry.ref : entry
994
1899
  };
995
1900
  }
1901
+ /** Post, term, user or attachment ids of an ACF reference value: ids, numeric strings or `{ ID }` objects. */
1902
+ const idsOf = (v) => (Array.isArray(v) ? v : [v]).map((x) => typeof x === "number" ? x : typeof x === "string" && /^\d+$/.test(x) ? Number(x) : x && typeof x === "object" ? Number(x.ID ?? x.id ?? x.term_id) : NaN).filter((n) => Number.isSafeInteger(n) && n > 0);
996
1903
  function rawToContentrain(raw, opts) {
997
1904
  const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
998
1905
  const locale = normLocale(raw.site.language) || "en";
@@ -1024,12 +1931,15 @@ function rawToContentrain(raw, opts) {
1024
1931
  title_fallback: 0,
1025
1932
  meta_fields: {},
1026
1933
  acf_fields: {},
1934
+ acf_outside_choices: {},
1935
+ acf_datetime_unzoned: 0,
1027
1936
  skipped_types: [],
1028
1937
  dropped_relations: 0,
1029
1938
  models: {},
1030
1939
  locales,
1031
1940
  translation_groups: translationGroups,
1032
- password_protected_drafts: 0
1941
+ password_protected_drafts: 0,
1942
+ site_title_missing: false
1033
1943
  };
1034
1944
  const importMeta = (status, extra = {}) => ({
1035
1945
  status,
@@ -1087,6 +1997,100 @@ function rawToContentrain(raw, opts) {
1087
1997
  });
1088
1998
  }
1089
1999
  const mediaRef = (id) => raw.attachments.some((a) => a.id === id) ? hexId(`media:${id}`) : null;
2000
+ const termById = new Map(raw.terms.map((t) => [t.id, t]));
2001
+ const authorById = new Map(raw.authors.filter((a) => a.id !== null).map((a) => [a.id, a]));
2002
+ const refOf = (kind, id) => {
2003
+ if (kind === "post") return postEntry.get(id) ?? null;
2004
+ if (kind === "media") {
2005
+ const m = mediaRef(id);
2006
+ return m ? {
2007
+ model: "media",
2008
+ ref: m
2009
+ } : null;
2010
+ }
2011
+ if (kind === "user") {
2012
+ const a = authorById.get(id);
2013
+ return a && models.authors ? {
2014
+ model: "authors",
2015
+ ref: authorId(slugify(a.login))
2016
+ } : null;
2017
+ }
2018
+ if (kind === "term") {
2019
+ const t = termById.get(id);
2020
+ return t && models[taxModelId(t.taxonomy)] ? {
2021
+ model: taxModelId(t.taxonomy),
2022
+ ref: termId(t.taxonomy, t.slug)
2023
+ } : null;
2024
+ }
2025
+ return null;
2026
+ };
2027
+ /** Every ACF field on these posts: its definition, or — for a reference — its kind and target models. */
2028
+ const acfPlan = (items, taken) => {
2029
+ const out = /* @__PURE__ */ new Map();
2030
+ for (const p of items) for (const [k, acf] of Object.entries(p.acf ?? {})) {
2031
+ if (taken(k) || acf.value === null || acf.value === void 0 || acf.value === "" || acf.value === false && acf.type !== "true_false") continue;
2032
+ const kind = acf.type ? ACF_REFERENCE_TYPES[acf.type] : void 0;
2033
+ if (kind === "address") {
2034
+ const def = Array.isArray(acf.value) ? {
2035
+ type: "array",
2036
+ items: "url"
2037
+ } : { type: "url" };
2038
+ const cur = out.get(k);
2039
+ out.set(k, {
2040
+ def: cur?.def ?? {
2041
+ ...def,
2042
+ ...acf.label ? { label: strip(acf.label) } : {}
2043
+ },
2044
+ kind,
2045
+ multiple: false,
2046
+ models: /* @__PURE__ */ new Set()
2047
+ });
2048
+ continue;
2049
+ }
2050
+ if (kind) {
2051
+ const cur = out.get(k) ?? {
2052
+ kind,
2053
+ multiple: acf.type === "relationship" || acf.type === "gallery",
2054
+ models: /* @__PURE__ */ new Set()
2055
+ };
2056
+ if (Array.isArray(acf.value)) cur.multiple = true;
2057
+ for (const id of idsOf(acf.value)) {
2058
+ const r = refOf(kind, id);
2059
+ if (r) cur.models.add(r.model);
2060
+ }
2061
+ out.set(k, cur);
2062
+ continue;
2063
+ }
2064
+ const def = acfFieldDef(k, acfScrub(acf.value), {
2065
+ type: acf.type,
2066
+ label: acf.label
2067
+ });
2068
+ if (!def) continue;
2069
+ const cur = out.get(k);
2070
+ out.set(k, {
2071
+ def: cur?.def ? mergeFieldDef(cur.def, def) : def,
2072
+ multiple: false,
2073
+ models: /* @__PURE__ */ new Set()
2074
+ });
2075
+ }
2076
+ for (const [k, plan] of out) if (!plan.def && !plan.models.size) out.delete(k);
2077
+ return out;
2078
+ };
2079
+ const acfRefs = (plan, value) => {
2080
+ const refs = idsOf(value).map((id) => refOf(plan.kind, id)).filter((r) => !!r);
2081
+ const missing = idsOf(value).length - refs.length;
2082
+ if (missing > 0) report.dropped_relations += missing;
2083
+ if (!refs.length) return void 0;
2084
+ const poly = plan.models.size > 1;
2085
+ const one = (r) => poly ? r : r.ref;
2086
+ return plan.multiple ? refs.map(one) : one(refs[0]);
2087
+ };
2088
+ const addressOf = (v) => {
2089
+ if (Array.isArray(v)) return v.map(addressOf).filter(Boolean);
2090
+ if (typeof v !== "number") return v;
2091
+ const t = postById.get(v);
2092
+ return t && t.status === "publish" && !t.password ? t.link ?? void 0 : void 0;
2093
+ };
1090
2094
  const titleOf = (p) => {
1091
2095
  const t = strip(p.title);
1092
2096
  if (t) return t;
@@ -1426,31 +2430,32 @@ function rawToContentrain(raw, opts) {
1426
2430
  label: "Comments open",
1427
2431
  order: o += 10
1428
2432
  };
1429
- for (const p of typeItems) {
1430
- for (const [k, v] of Object.entries(p.meta)) {
1431
- if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
1432
- const t = byValue(v);
1433
- if (t && t !== "object") {
1434
- fields[k] = {
1435
- type: t,
1436
- label: k,
1437
- order: o += 10
1438
- };
1439
- report.meta_fields[k] = t;
1440
- }
1441
- }
1442
- for (const [k, acf] of Object.entries(p.acf ?? {})) {
1443
- if (fields[k]) continue;
1444
- const t = byValue(acf.value) ?? "string";
2433
+ for (const p of typeItems) for (const [k, v] of Object.entries(p.meta)) {
2434
+ if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
2435
+ const t = byValue(v);
2436
+ if (t && t !== "object") {
1445
2437
  fields[k] = {
1446
- type: t === "object" ? "object" : t,
2438
+ type: t,
1447
2439
  label: k,
1448
- description: "ACF",
1449
2440
  order: o += 10
1450
2441
  };
1451
- report.acf_fields[k] = t;
2442
+ report.meta_fields[k] = t;
1452
2443
  }
1453
2444
  }
2445
+ const acfFields = acfPlan(typeItems, (k) => !!fields[k]);
2446
+ for (const [k, plan] of acfFields) {
2447
+ const def = plan.def ?? {
2448
+ type: plan.multiple ? "relations" : "relation",
2449
+ model: plan.models.size > 1 ? [...plan.models].toSorted() : [...plan.models][0]
2450
+ };
2451
+ fields[k] = {
2452
+ ...def,
2453
+ ...def.label ? {} : { label: k },
2454
+ description: def.description ?? "ACF",
2455
+ order: o += 10
2456
+ };
2457
+ report.acf_fields[k] = plan.def ? plan.def.type : `${def.type}:${[...plan.models].toSorted().join("|")}`;
2458
+ }
1454
2459
  const covered = locales.filter((l) => l === locale || typeItems.some((p) => postLocale(p) === l));
1455
2460
  addModel({
1456
2461
  id: mid,
@@ -1499,7 +2504,22 @@ function rawToContentrain(raw, opts) {
1499
2504
  if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
1500
2505
  if (fields.comments_open) e.comments_open = p.comment_status === "open";
1501
2506
  for (const k of Object.keys(fields)) if (k in p.meta && !(k in e) && !k.startsWith("_")) e[k] = p.meta[k];
1502
- for (const [k, acf] of Object.entries(p.acf ?? {})) if (fields[k] && !(k in e)) e[k] = acf.value;
2507
+ for (const [k, acf] of Object.entries(p.acf ?? {})) {
2508
+ const plan = acfFields.get(k);
2509
+ if (!plan || k in e) continue;
2510
+ const ctx = {
2511
+ timeZone: raw.site.timezone,
2512
+ gmtOffset: raw.site.gmt_offset,
2513
+ dropped: () => {
2514
+ report.acf_outside_choices[k] = (report.acf_outside_choices[k] ?? 0) + 1;
2515
+ },
2516
+ unzoned: () => {
2517
+ report.acf_datetime_unzoned++;
2518
+ }
2519
+ };
2520
+ const v = plan.def ? acfValue(plan.def, plan.kind === "address" ? addressOf(acf.value) : acfRows(acfScrub(acf.value)), ctx) : acfRefs(plan, acf.value);
2521
+ if (v !== void 0) e[k] = v;
2522
+ }
1503
2523
  contentBucket[id] = e;
1504
2524
  metaBucket[id] = mapStatus(p);
1505
2525
  }
@@ -1537,7 +2557,13 @@ function rawToContentrain(raw, opts) {
1537
2557
  type: "integer",
1538
2558
  label: "WP term ID",
1539
2559
  order: 40
1540
- }
2560
+ },
2561
+ ...menus.some((m) => m.locations?.length) ? { locations: {
2562
+ type: "array",
2563
+ items: "string",
2564
+ label: "Locations",
2565
+ order: 50
2566
+ } } : {}
1541
2567
  }
1542
2568
  });
1543
2569
  const toTarget = relationOver([...contentModelIds, ...taxonomies.map(taxModelId)]);
@@ -1625,7 +2651,8 @@ function rawToContentrain(raw, opts) {
1625
2651
  name: strip(m.name),
1626
2652
  slug: slugify(m.slug) || `menu-${m.id}`,
1627
2653
  items: m.items.map((i) => itemRef(i.id)),
1628
- wp_id: m.id
2654
+ ...m.id !== null && m.id > 0 ? { wp_id: m.id } : {},
2655
+ ...m.locations?.length ? { locations: m.locations } : {}
1629
2656
  };
1630
2657
  metas.menus[mid] = importMeta("published");
1631
2658
  for (const i of m.items) {
@@ -1634,7 +2661,7 @@ function rawToContentrain(raw, opts) {
1634
2661
  menu: mid,
1635
2662
  order: i.order,
1636
2663
  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,
2664
+ ...i.id > 0 ? { wp_id: i.id } : {},
1638
2665
  open_in_new_tab: i.target_attr === "_blank"
1639
2666
  };
1640
2667
  if (i.parent && !i.parent_unresolved) e.parent = itemRef(i.parent);
@@ -1788,7 +2815,7 @@ function rawToContentrain(raw, opts) {
1788
2815
  }
1789
2816
  });
1790
2817
  siteEntry = pick({
1791
- title: strip(raw.site.title) || "Site",
2818
+ title: strip(raw.site.title),
1792
2819
  tagline: raw.site.description,
1793
2820
  url: raw.site.url,
1794
2821
  language: raw.site.language
@@ -1799,6 +2826,7 @@ function rawToContentrain(raw, opts) {
1799
2826
  "language"
1800
2827
  ]));
1801
2828
  siteMeta = importMeta("published");
2829
+ report.site_title_missing = !siteEntry.title;
1802
2830
  const vocabTerms = {};
1803
2831
  for (const m of menus) for (const i of m.items) {
1804
2832
  const k = slugify(strip(i.title));