@contentrain/wp-import 0.7.0 → 0.9.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
@@ -525,6 +525,7 @@ async function parseWxr(input, opts) {
525
525
  mime: guessMime(r.attachment_url ?? r.guid ?? ""),
526
526
  parent: r.parent,
527
527
  parent_resolved: r.parent ? byId.has(r.parent) : null,
528
+ link: !r.parent || byId.get(r.parent)?.status === "publish" && !byId.get(r.parent)?.password ? r.link || null : null,
528
529
  author: r.author,
529
530
  date: r.date,
530
531
  status: r.status,
@@ -675,6 +676,479 @@ function guessMime(u) {
675
676
  return MIME[(u.split("?")[0]?.split(".").pop() ?? "").toLowerCase()] ?? null;
676
677
  }
677
678
  //#endregion
679
+ //#region src/acf.ts
680
+ /** ACF types that never carry content (layout) or must never be read (secrets). */
681
+ const ACF_LAYOUT_TYPES = new Set([
682
+ "tab",
683
+ "accordion",
684
+ "message"
685
+ ]);
686
+ const ACF_SECRET_TYPES = new Set(["password"]);
687
+ /**
688
+ * ACF type → Contentrain type, for types whose value maps without a lookup.
689
+ * `select` with `multiple` and `checkbox` are arrays of their options; see `acfFieldDef`.
690
+ */
691
+ const ACF_SCALAR_TYPES = {
692
+ text: "string",
693
+ textarea: "text",
694
+ wysiwyg: "richtext",
695
+ email: "email",
696
+ url: "url",
697
+ oembed: "url",
698
+ number: "number",
699
+ range: "number",
700
+ true_false: "boolean",
701
+ date_picker: "date",
702
+ date_time_picker: "datetime",
703
+ time_picker: "string",
704
+ color_picker: "color",
705
+ icon_picker: "icon",
706
+ select: "select",
707
+ radio: "select",
708
+ button_group: "select",
709
+ image: "image",
710
+ file: "file"
711
+ };
712
+ const ACF_REFERENCE_TYPES = {
713
+ post_object: "post",
714
+ relationship: "post",
715
+ taxonomy: "term",
716
+ user: "user",
717
+ gallery: "media",
718
+ page_link: "address"
719
+ };
720
+ /**
721
+ * Names that mean a secret whatever the field's type, for sources that do not state the type: whole words
722
+ * of the name (`user_pass`, `apiKey`, `client-secret`), so `passage_text` or `compass` stay content.
723
+ */
724
+ const SECRET_NAME = /(?:^|_)(?:pass(?:word|wd)?|secret|token|api_?key|private_?key|credentials?)(?:_|$)/i;
725
+ const secretName = (name) => SECRET_NAME.test(name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_"));
726
+ /** Whether a field must never be read: a secret by type, or — when the type is unknown — by name. */
727
+ function acfIsSecret(name, type) {
728
+ if (type) return ACF_SECRET_TYPES.has(type);
729
+ return secretName(name);
730
+ }
731
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
732
+ const SOURCE = "_source";
733
+ /** The ACF type SCF states for a sub-field of a row or group (`<name>_source.type`). */
734
+ const statedType = (row, k) => {
735
+ const s = row[`${k}${SOURCE}`];
736
+ return isRecord(s) && typeof s.type === "string" ? s.type : void 0;
737
+ };
738
+ /** A sub-field that carries nothing to keep: a secret (stated, or by name) or a layout field. */
739
+ const droppedKey = (row, k) => {
740
+ const t = statedType(row, k);
741
+ return t !== void 0 && ACF_LAYOUT_TYPES.has(t) || acfIsSecret(k, t);
742
+ };
743
+ /**
744
+ * An ACF value with every secret sub-field removed, at any depth: repeater rows, groups, flexible layouts.
745
+ * A sub-field's `_source` keeps only its type and label — its `formatted_value` repeats the value.
746
+ */
747
+ function acfScrub(value, secret = /* @__PURE__ */ new Set()) {
748
+ if (Array.isArray(value)) {
749
+ const rows = value.filter(isRecord);
750
+ const shared = new Set([...secret, ...rows.flatMap((r) => Object.keys(r).filter((k) => !k.endsWith(SOURCE) && droppedKey(r, k)))]);
751
+ return value.map((v) => acfScrub(v, shared));
752
+ }
753
+ if (!isRecord(value)) return value;
754
+ const gone = (k) => secret.has(k) || droppedKey(value, k);
755
+ const out = {};
756
+ for (const [k, v] of Object.entries(value)) {
757
+ if (k.endsWith(SOURCE) && k.length > 7) {
758
+ if (gone(k.slice(0, -7)) || !isRecord(v)) continue;
759
+ out[k] = {
760
+ ...typeof v.type === "string" ? { type: v.type } : {},
761
+ ...typeof v.label === "string" ? { label: v.label } : {}
762
+ };
763
+ continue;
764
+ }
765
+ if (gone(k)) continue;
766
+ out[k] = acfScrub(v);
767
+ }
768
+ return out;
769
+ }
770
+ /** ACF's image/file array (`return_format: array`): `{ ID, url, filename, … }`. */
771
+ const isAttachment = (v) => isRecord(v) && typeof v.url === "string" && ("ID" in v || "id" in v) && ("filename" in v || "mime_type" in v);
772
+ const isLink = (v) => isRecord(v) && typeof v.url === "string" && "title" in v && "target" in v && Object.keys(v).length <= 3;
773
+ const isMap = (v) => isRecord(v) && typeof v.lat === "number" && typeof v.lng === "number";
774
+ /** A date picker's stored value (`Ymd`) → ISO date. */
775
+ const ymd = (v) => typeof v === "string" && /^\d{8}$/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : null;
776
+ /** `Y-m-d H:i:s` → ISO local datetime (site time: WordPress stores it without a zone). */
777
+ const dt = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v) ? v.replace(" ", "T") : null;
778
+ const pad = (n) => String(Math.floor(n)).padStart(2, "0");
779
+ /** Minutes east of UTC → `+03:00`. */
780
+ const offsetText = (minutes) => `${minutes < 0 ? "-" : "+"}${pad(Math.abs(minutes) / 60)}:${pad(Math.abs(minutes) % 60)}`;
781
+ /** The offset of an IANA zone at a wall-clock time there (DST included), in minutes east of UTC; `null` for an unknown zone. */
782
+ function zoneOffset(local, timeZone) {
783
+ let fmt;
784
+ try {
785
+ fmt = new Intl.DateTimeFormat("en-US", {
786
+ timeZone,
787
+ hourCycle: "h23",
788
+ year: "numeric",
789
+ month: "2-digit",
790
+ day: "2-digit",
791
+ hour: "2-digit",
792
+ minute: "2-digit",
793
+ second: "2-digit"
794
+ });
795
+ } catch {
796
+ return null;
797
+ }
798
+ const wall = Date.parse(`${local}Z`);
799
+ const at = (utc) => {
800
+ const p = Object.fromEntries(fmt.formatToParts(new Date(utc)).map((x) => [x.type, x.value]));
801
+ return (Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)) - utc) / 6e4;
802
+ };
803
+ return at(wall - at(wall) * 6e4);
804
+ }
805
+ /** A local ISO datetime with the site's offset for that moment; the local value when the site's zone is unknown. */
806
+ function zonedDateTime(local, zone = {}) {
807
+ const minutes = (zone.timeZone ? zoneOffset(local, zone.timeZone) : null) ?? (typeof zone.gmtOffset === "number" && Number.isFinite(zone.gmtOffset) ? Math.round(zone.gmtOffset * 60) : null);
808
+ return minutes === null ? {
809
+ value: local,
810
+ zoned: false
811
+ } : {
812
+ value: `${local}${offsetText(minutes)}`,
813
+ zoned: true
814
+ };
815
+ }
816
+ const LINK_FIELDS = {
817
+ url: { type: "url" },
818
+ title: { type: "string" },
819
+ target: { type: "string" }
820
+ };
821
+ const MAP_FIELDS = {
822
+ address: { type: "string" },
823
+ lat: { type: "decimal" },
824
+ lng: { type: "decimal" },
825
+ zoom: { type: "integer" }
826
+ };
827
+ /** Contentrain type of a sub-value whose ACF type is not stated (a repeater row, a group member). */
828
+ function inferDef(value) {
829
+ if (value === null || value === void 0 || value === "") return null;
830
+ if (isAttachment(value)) return { type: /\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i.test(value.url) ? "image" : "file" };
831
+ if (isLink(value)) return {
832
+ type: "object",
833
+ fields: LINK_FIELDS
834
+ };
835
+ if (isMap(value)) return {
836
+ type: "object",
837
+ fields: MAP_FIELDS
838
+ };
839
+ if (Array.isArray(value)) {
840
+ if (value.every(isRecord)) return {
841
+ type: "array",
842
+ items: {
843
+ type: "object",
844
+ fields: rowFields(value)
845
+ }
846
+ };
847
+ const item = value.map(inferDef).find(Boolean);
848
+ return {
849
+ type: "array",
850
+ items: item?.type === "object" ? item : item?.type ?? "string"
851
+ };
852
+ }
853
+ if (isRecord(value)) return {
854
+ type: "object",
855
+ fields: rowFields([value])
856
+ };
857
+ if (ymd(value)) return { type: "date" };
858
+ const t = byValue(value);
859
+ return t ? { type: t } : null;
860
+ }
861
+ /**
862
+ * The union of the fields every row states; a name typed two ways falls back to `string`. A sub-field whose ACF
863
+ * type the row states (SCF's `<name>_source`) is typed by the table like a top-level field; a reference inside a
864
+ * row is not resolved, so it — and any sub-field without a stated type — is typed from its value.
865
+ */
866
+ function rowFields(rows) {
867
+ const out = {};
868
+ for (const row of rows) for (const [k, v] of Object.entries(row)) {
869
+ if (k.endsWith(SOURCE) || droppedKey(row, k)) continue;
870
+ const stated = statedType(row, k);
871
+ const source = row[`${k}${SOURCE}`];
872
+ const label = isRecord(source) && typeof source.label === "string" ? source.label : void 0;
873
+ const d = stated && !ACF_REFERENCE_TYPES[stated] ? acfFieldDef(k, v, {
874
+ type: stated,
875
+ ...label ? { label } : {}
876
+ }) : inferDef(v);
877
+ if (!d) continue;
878
+ const seen = out[k];
879
+ if (!seen) out[k] = d;
880
+ else if (seen.type !== d.type) out[k] = { type: "string" };
881
+ }
882
+ return out;
883
+ }
884
+ /** A value in the shape its field definition promises; `undefined` = nothing to store. */
885
+ function acfValue(def, value, ctx = {}) {
886
+ if (value === null || value === void 0 || value === "" || Array.isArray(value) && !value.length) return void 0;
887
+ switch (def.type) {
888
+ case "image":
889
+ case "file":
890
+ case "video": return isAttachment(value) ? value.url : typeof value === "string" ? value : void 0;
891
+ case "date": return ymd(value) ?? (typeof value === "string" ? value : void 0);
892
+ case "datetime": {
893
+ const local = dt(value);
894
+ if (!local) return typeof value === "string" ? value : void 0;
895
+ const out = zonedDateTime(local, ctx);
896
+ if (!out.zoned) ctx.unzoned?.(out.value);
897
+ return out.value;
898
+ }
899
+ case "boolean": return value === true || value === 1 || value === "1";
900
+ case "number":
901
+ case "integer":
902
+ case "decimal": return typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)) ? Number(value) : void 0;
903
+ case "icon": return isRecord(value) && typeof value.value === "string" ? value.value : typeof value === "string" ? value : void 0;
904
+ case "object": {
905
+ if (!isRecord(value)) return void 0;
906
+ const out = {};
907
+ for (const [k, sub] of Object.entries(def.fields ?? {})) {
908
+ if (droppedKey(value, k)) continue;
909
+ const v = acfValue(sub, value[k], ctx);
910
+ if (v !== void 0) out[k] = v;
911
+ }
912
+ return Object.keys(out).length ? out : void 0;
913
+ }
914
+ case "array": {
915
+ if (!Array.isArray(value)) return void 0;
916
+ const item = typeof def.items === "string" ? { type: def.items } : def.items ?? { type: "string" };
917
+ const out = value.map((v) => acfValue(item, v, ctx)).filter((v) => v !== void 0);
918
+ return out.length ? out : void 0;
919
+ }
920
+ case "select": {
921
+ const v = typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
922
+ if (v !== void 0 && def.options?.length && !def.options.includes(v)) {
923
+ ctx.dropped?.(v, def.options);
924
+ return;
925
+ }
926
+ return v;
927
+ }
928
+ case "richtext":
929
+ case "text":
930
+ case "string":
931
+ case "url":
932
+ case "email":
933
+ case "color": return typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
934
+ default: return value;
935
+ }
936
+ }
937
+ /**
938
+ * Field definition for one ACF field value, or `null` when it carries no content, is a secret, or is a
939
+ * reference (the caller resolves those: see `ACF_REFERENCE_TYPES`).
940
+ */
941
+ function acfFieldDef(name, value, stated = {}) {
942
+ const t = stated.type;
943
+ if (t && (ACF_LAYOUT_TYPES.has(t) || ACF_REFERENCE_TYPES[t])) return null;
944
+ if (acfIsSecret(name, t)) return null;
945
+ const label = stated.label ? strip(stated.label) : void 0;
946
+ const withLabel = (d) => label ? {
947
+ ...d,
948
+ label
949
+ } : d;
950
+ if (!t) {
951
+ const inferred = inferDef(value);
952
+ return inferred ? withLabel({
953
+ ...inferred,
954
+ description: "ACF (type inferred from the value)"
955
+ }) : null;
956
+ }
957
+ const choices = stated.choices?.length ? stated.choices : void 0;
958
+ if (t === "checkbox" || t === "select" && (stated.multiple || Array.isArray(value))) return withLabel({
959
+ type: "array",
960
+ items: choices ? {
961
+ type: "select",
962
+ options: choices
963
+ } : "string"
964
+ });
965
+ if (ACF_SCALAR_TYPES[t]) {
966
+ const type = ACF_SCALAR_TYPES[t];
967
+ if (type === "select") return withLabel(choices ? {
968
+ type,
969
+ options: choices
970
+ } : { type: "string" });
971
+ return withLabel({ type });
972
+ }
973
+ if (t === "link") return withLabel({
974
+ type: "object",
975
+ fields: LINK_FIELDS
976
+ });
977
+ if (t === "google_map") return withLabel({
978
+ type: "object",
979
+ fields: MAP_FIELDS
980
+ });
981
+ if (t === "group") return withLabel({
982
+ type: "object",
983
+ fields: rowFields(isRecord(value) ? [value] : [])
984
+ });
985
+ if (t === "repeater") return withLabel({
986
+ type: "array",
987
+ items: {
988
+ type: "object",
989
+ fields: rowFields(Array.isArray(value) ? value.filter(isRecord) : [])
990
+ }
991
+ });
992
+ if (t === "flexible_content") {
993
+ const rows = Array.isArray(value) ? value.filter(isRecord) : [];
994
+ const fields = rowFields(rows.map(({ acf_fc_layout: _l, ...rest }) => rest));
995
+ return withLabel({
996
+ type: "array",
997
+ items: {
998
+ type: "object",
999
+ fields: {
1000
+ layout: {
1001
+ type: "select",
1002
+ options: [...new Set(rows.map((r) => String(r.acf_fc_layout ?? "")).filter(Boolean))].toSorted(),
1003
+ required: true
1004
+ },
1005
+ ...fields
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+ const inferred = inferDef(value);
1011
+ return inferred ? withLabel({
1012
+ ...inferred,
1013
+ description: `ACF ${t} (type inferred from the value)`
1014
+ }) : null;
1015
+ }
1016
+ /** Merge two definitions of the same field seen on different entries (union of object fields and options). */
1017
+ function mergeFieldDef(a, b) {
1018
+ if (a.type !== b.type) return a;
1019
+ const out = { ...a };
1020
+ if (a.options || b.options) out.options = [...new Set([...a.options ?? [], ...b.options ?? []])].toSorted();
1021
+ if (a.fields || b.fields) {
1022
+ const fields = { ...a.fields };
1023
+ for (const [k, d] of Object.entries(b.fields ?? {})) fields[k] = fields[k] ? mergeFieldDef(fields[k], d) : d;
1024
+ out.fields = fields;
1025
+ }
1026
+ if (typeof a.items === "object" && typeof b.items === "object") out.items = mergeFieldDef(a.items, b.items);
1027
+ return out;
1028
+ }
1029
+ /** Flexible content rows keep their layout name under `layout`. */
1030
+ function acfRows(value) {
1031
+ return Array.isArray(value) ? value.map((r) => isRecord(r) && "acf_fc_layout" in r ? (({ acf_fc_layout, ...rest }) => ({
1032
+ layout: acf_fc_layout,
1033
+ ...rest
1034
+ }))(r) : r) : value;
1035
+ }
1036
+ //#endregion
1037
+ //#region src/rest-redirects.ts
1038
+ /** Redirection's own defaults, under a site that states none. */
1039
+ const DEFAULTS = {
1040
+ flag_query: "exact",
1041
+ flag_case: false,
1042
+ flag_trailing: false
1043
+ };
1044
+ const MODULES = {
1045
+ 2: "apache",
1046
+ 3: "nginx"
1047
+ };
1048
+ /** Codes that say an address is gone: served as that answer, with no target. */
1049
+ const GONE = new Set([410, 451]);
1050
+ /** Redirection's query modes; `exactorder` (the same parameters in the same order) is read as `exact`. */
1051
+ const QUERY = {
1052
+ exact: "exact",
1053
+ exactorder: "exact",
1054
+ ignore: "ignore",
1055
+ pass: "pass"
1056
+ };
1057
+ /** The flags one layer states, as Redirection accepts them: a known query mode, a true boolean (not `"1"`). */
1058
+ const valid = (f) => ({
1059
+ ...typeof f?.flag_query === "string" && f.flag_query in QUERY ? { flag_query: f.flag_query } : {},
1060
+ ...typeof f?.flag_case === "boolean" ? { flag_case: f.flag_case } : {},
1061
+ ...typeof f?.flag_trailing === "boolean" ? { flag_trailing: f.flag_trailing } : {}
1062
+ });
1063
+ /** By id as bytes, as the Bridge sorts (`strcmp`). */
1064
+ const order = (a, b) => (a.id ?? "") < (b.id ?? "") ? -1 : (a.id ?? "") > (b.id ?? "") ? 1 : 0;
1065
+ const on = (x) => x.enabled ?? (x.status === void 0 || x.status === "enabled");
1066
+ /** Same-site targets become root-relative, like every `from`; other hosts stay absolute (as the Bridge does). */
1067
+ function relative(url, origin) {
1068
+ try {
1069
+ const home = new URL(origin);
1070
+ const u = new URL(url, `${origin}/`);
1071
+ if (u.host.toLowerCase() !== home.host.toLowerCase()) return url;
1072
+ return `${u.pathname}${u.search}${u.hash}`;
1073
+ } catch {
1074
+ return url;
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Redirection's rules and groups → the rules the site serves and the ones it does not, sorted by id.
1079
+ * A rule's query, case and trailing-slash modes are what Redirection applies to it: its own flags, else the site's
1080
+ * (`options`), else the plugin's defaults. Without `options` (not readable) only a rule's own flags are stated.
1081
+ */
1082
+ function redirectionRules(items, groups, origin, options) {
1083
+ const site = options ? {
1084
+ ...DEFAULTS,
1085
+ ...valid(options)
1086
+ } : {};
1087
+ const byId = new Map(groups.map((g) => [g.id, g]));
1088
+ const redirects = [];
1089
+ const excluded = [];
1090
+ for (const item of items) {
1091
+ const group = item.group_id === void 0 ? void 0 : byId.get(item.group_id);
1092
+ const code = Number(item.action_code ?? 301);
1093
+ const data = item.action_data;
1094
+ const target = typeof data === "string" ? data : data?.url ?? "";
1095
+ const own = item.match_data?.source ?? {};
1096
+ const flags = {
1097
+ ...site,
1098
+ ...valid(own)
1099
+ };
1100
+ const rule = {
1101
+ id: `redirection:${item.id}`,
1102
+ from: item.url ?? "",
1103
+ source: "redirection",
1104
+ match: "url",
1105
+ regex: item.regex === true || own.flag_regex === true
1106
+ };
1107
+ const served = group?.module_id !== void 0 ? MODULES[group.module_id] : void 0;
1108
+ if (served) rule.served_by = served;
1109
+ if (rule.regex) rule.query = "exact";
1110
+ else if (typeof flags.flag_query === "string") rule.query = QUERY[flags.flag_query];
1111
+ if (typeof flags.flag_case === "boolean") rule.case_insensitive = flags.flag_case;
1112
+ if (typeof flags.flag_trailing === "boolean") rule.trailing_slash = flags.flag_trailing ? "ignore" : "exact";
1113
+ const matchType = item.match_type ?? "url";
1114
+ const actionType = item.action_type ?? "url";
1115
+ if (!on(item) || group && !on(group)) excluded.push({
1116
+ ...rule,
1117
+ reason: "disabled"
1118
+ });
1119
+ else if (actionType === "error" && GONE.has(code) && matchType === "url") redirects.push({
1120
+ ...rule,
1121
+ to: "",
1122
+ status: code
1123
+ });
1124
+ else if (actionType !== "url") excluded.push({
1125
+ ...rule,
1126
+ status: code,
1127
+ reason: `not-a-redirect:${actionType}`
1128
+ });
1129
+ else if (matchType !== "url") excluded.push({
1130
+ ...rule,
1131
+ match: matchType,
1132
+ status: code,
1133
+ ...matchType === "login" ? { condition: data ?? null } : {},
1134
+ reason: `conditional-match:${matchType}`
1135
+ });
1136
+ else {
1137
+ const move = code >= 300 && code < 400;
1138
+ redirects.push({
1139
+ ...rule,
1140
+ to: relative(target, origin),
1141
+ status: move ? code : 301,
1142
+ ...move ? {} : { status_note: `source status ${code} is not a redirect code; 301 assumed` }
1143
+ });
1144
+ }
1145
+ }
1146
+ return {
1147
+ redirects: redirects.toSorted(order),
1148
+ excluded: excluded.toSorted(order)
1149
+ };
1150
+ }
1151
+ //#endregion
678
1152
  //#region src/rest-menus.ts
679
1153
  const targetOf = (kind, object, id, url, ctx) => {
680
1154
  const objectId = id && id > 0 ? id : null;
@@ -713,6 +1187,12 @@ const targetOf = (kind, object, id, url, ctx) => {
713
1187
  resolved: false
714
1188
  };
715
1189
  };
1190
+ /** The public address of the post or term a target names, when it is one this import proved public. */
1191
+ const publicLink = (target, ctx) => {
1192
+ if (!target.resolved || !("id" in target) || !target.id) return void 0;
1193
+ if (target.kind === "post") return ctx.postLink?.(target.id);
1194
+ if (target.kind === "term") return ctx.termLink?.(target.taxonomy, target.id);
1195
+ };
716
1196
  /**
717
1197
  * Items that are left out: a draft item, or one whose post target is not proven visible. Their children move up
718
1198
  * to the nearest kept ancestor, as WordPress's walker shows them.
@@ -733,12 +1213,35 @@ function hiddenItems(items, drop) {
733
1213
  lift
734
1214
  };
735
1215
  }
1216
+ /** A host without case or a leading `www.`: example.com and www.example.com are one site (as the Bridge reads it). */
1217
+ const bareHost = (u) => u.hostname.toLowerCase().replace(/^www\./, "");
1218
+ /**
1219
+ * The post a plain permalink names (`/?page_id=14`, `/?p=14`) on this site, `www.` or not: a custom link typed by
1220
+ * hand carries no post id, but it points at a post all the same, and its label is often that post's title.
1221
+ */
1222
+ function plainPostId(url, origin) {
1223
+ if (!url || !url.includes("?")) return void 0;
1224
+ try {
1225
+ const u = new URL(url, `${origin}/`);
1226
+ if (bareHost(u) !== bareHost(new URL(origin))) return void 0;
1227
+ const id = Number(u.searchParams.get("page_id") ?? u.searchParams.get("p"));
1228
+ return Number.isSafeInteger(id) && id > 0 ? id : void 0;
1229
+ } catch {
1230
+ return;
1231
+ }
1232
+ }
736
1233
  /** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
737
1234
  function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
738
1235
  const out = [];
739
1236
  for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
740
1237
  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)));
1238
+ const hidden = (i) => {
1239
+ if ((i.status ?? "publish") !== "publish") return true;
1240
+ if (i.type === "post_type") return !(i.object_id && ctx.postSlug(i.object_id));
1241
+ const id = plainPostId(i.url, ctx.origin);
1242
+ return id !== void 0 && !ctx.postSlug(id);
1243
+ };
1244
+ const { gone, lift } = hiddenItems(all, hidden);
742
1245
  dropped.count += gone.size;
743
1246
  const own = all.filter((i) => !gone.has(i.id));
744
1247
  const ids = new Set(own.map((i) => i.id));
@@ -747,13 +1250,15 @@ function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
747
1250
  slug: m.slug ?? "",
748
1251
  name: strip(m.name ?? m.slug ?? ""),
749
1252
  items: own.map((i) => {
1253
+ const target = targetOf(i.type, i.object, i.object_id, i.url, ctx);
1254
+ const typed = target.kind === "url" ? plainPostId(i.url, ctx.origin) : void 0;
750
1255
  const item = {
751
1256
  id: i.id,
752
1257
  title: i.title?.raw || i.title?.rendered || "",
753
1258
  order: i.menu_order ?? 0,
754
1259
  parent: lift(i.parent || null),
755
- url: i.url || null,
756
- target: targetOf(i.type, i.object, i.object_id, i.url, ctx),
1260
+ url: publicLink(target, ctx) ?? (typed !== void 0 ? ctx.postLink?.(typed) : void 0) ?? (i.url || null),
1261
+ target,
757
1262
  target_attr: i.target || null,
758
1263
  classes: (i.classes ?? []).filter(Boolean),
759
1264
  description: i.description ?? "",
@@ -805,6 +1310,7 @@ function parseBlocks(content) {
805
1310
  const str = (v) => typeof v === "string" && v ? v : void 0;
806
1311
  const absolute = (url, origin) => {
807
1312
  if (!url) return void 0;
1313
+ if (url.startsWith("#")) return url;
808
1314
  try {
809
1315
  return new URL(url, `${origin}/`).href;
810
1316
  } catch {
@@ -822,17 +1328,22 @@ const KIND = {
822
1328
  * published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
823
1329
  */
824
1330
  function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
1331
+ return blockItems(parseBlocks(content), ctx, nextId, dropped);
1332
+ }
1333
+ /** Menu items of navigation blocks already parsed (a `wp_navigation` body, or an inline navigation's links). */
1334
+ function blockItems(tree, ctx, nextId, dropped) {
825
1335
  const items = [];
826
1336
  const push = (b, parent) => {
827
1337
  const id = nextId();
828
1338
  const url = absolute(b.url, ctx.origin);
1339
+ const target = targetOf(b.kind, b.object, b.id, url, ctx);
829
1340
  items.push({
830
1341
  id,
831
1342
  title: b.title,
832
1343
  order: items.length + 1,
833
1344
  parent,
834
- url: url ?? null,
835
- target: targetOf(b.kind, b.object, b.id, url, ctx),
1345
+ url: publicLink(target, ctx) ?? (b.typed !== void 0 ? ctx.postLink?.(b.typed) : void 0) ?? url ?? null,
1346
+ target,
836
1347
  target_attr: b.newTab ? "_blank" : null,
837
1348
  classes: (b.className ?? "").split(/\s+/).filter(Boolean),
838
1349
  description: b.description ?? "",
@@ -859,7 +1370,8 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
859
1370
  if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
860
1371
  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
1372
  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)) {
1373
+ const postId = kind === "post_type" && typeof a.id === "number" ? a.id : kind === "custom" ? plainPostId(str(a.url), ctx.origin) : void 0;
1374
+ if (postId !== void 0 && !ctx.postSlug(postId)) {
863
1375
  dropped.count++;
864
1376
  walk(b.children, parent);
865
1377
  continue;
@@ -870,6 +1382,7 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
870
1382
  kind,
871
1383
  object,
872
1384
  id: typeof a.id === "number" ? a.id : void 0,
1385
+ typed: kind === "custom" ? postId : void 0,
873
1386
  newTab: a.opensInNewTab === true,
874
1387
  className: str(a.className),
875
1388
  description: str(a.description)
@@ -884,15 +1397,94 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
884
1397
  else walk(b.children, parent);
885
1398
  }
886
1399
  };
887
- walk(parseBlocks(content), null);
1400
+ walk(tree, null);
888
1401
  return items;
889
1402
  }
1403
+ /** Template-part slugs the templates use, at any depth (a part inside a group, a part inside a part is not followed). */
1404
+ function partSlugs(blocks, out = /* @__PURE__ */ new Set()) {
1405
+ for (const b of blocks) {
1406
+ if (b.name === "core/template-part" && typeof b.attrs.slug === "string") out.add(b.attrs.slug);
1407
+ partSlugs(b.children, out);
1408
+ }
1409
+ return out;
1410
+ }
1411
+ /**
1412
+ * The template parts a page can show: those a template references. Without the templates (not readable), the
1413
+ * part named after its area (`header`, `footer`) — what a theme's templates use unless they say otherwise.
1414
+ * Parts without a slug (older callers) all count.
1415
+ */
1416
+ function usedParts(parts, templates = []) {
1417
+ const used = /* @__PURE__ */ new Set();
1418
+ for (const t of templates) if (t.content?.raw) partSlugs(parseBlocks(t.content.raw), used);
1419
+ return parts.filter((p) => !p.slug || (used.size ? used.has(p.slug) : p.slug === p.area));
1420
+ }
1421
+ const AREA_ORDER = ["header", "footer"];
1422
+ const areaOf = (p) => p.area && p.area !== "uncategorized" ? p.area : void 0;
1423
+ /**
1424
+ * A part's blocks with the parts it holds in their place (a header holding a `navigation` part), each part once:
1425
+ * what the part shows, in its area.
1426
+ */
1427
+ function partTree(part, all) {
1428
+ const bySlug = new Map(all.filter((p) => p.slug).map((p) => [p.slug, p]));
1429
+ const seen = new Set(part.slug ? [part.slug] : []);
1430
+ const expand = (blocks) => blocks.flatMap((b) => {
1431
+ if (b.name === "core/template-part") {
1432
+ const inner = typeof b.attrs.slug === "string" ? bySlug.get(b.attrs.slug) : void 0;
1433
+ if (!inner?.content?.raw || seen.has(inner.slug)) return [];
1434
+ seen.add(inner.slug);
1435
+ return expand(parseBlocks(inner.content.raw));
1436
+ }
1437
+ return [{
1438
+ ...b,
1439
+ children: expand(b.children)
1440
+ }];
1441
+ });
1442
+ return expand(parseBlocks(part.content?.raw ?? ""));
1443
+ }
1444
+ const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1);
1445
+ const slugOf = (s) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1446
+ /**
1447
+ * Inline navigations of the used template parts → RawMenu[], in area order (header, footer, others) and document
1448
+ * order within a part. A part's single inline navigation is named after its area (`Footer navigation`), several
1449
+ * are numbered; a navigation's own `ariaLabel` wins. Links keep their order and nesting; a link to content not
1450
+ * proven public is left out, as in every other menu.
1451
+ */
1452
+ function inlineMenus(parts, ctx, taken, nextId, dropped = { count: 0 }, all = parts) {
1453
+ const out = [];
1454
+ 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));
1455
+ for (const p of ordered) {
1456
+ const area = areaOf(p);
1457
+ const navs = [];
1458
+ const find = (blocks) => {
1459
+ for (const b of blocks) if (b.name === "core/navigation") {
1460
+ if (typeof b.attrs.ref !== "number" && b.children.length) navs.push(b);
1461
+ } else find(b.children);
1462
+ };
1463
+ find(partTree(p, all));
1464
+ navs.forEach((nav, i) => {
1465
+ const name = strip(nav.attrs.ariaLabel) || `${titleCase(area)} navigation${navs.length > 1 ? ` ${i + 1}` : ""}`;
1466
+ const base = slugOf(name) || `${area}-navigation`;
1467
+ let slug = base;
1468
+ for (let k = 2; taken.has(slug); k++) slug = `${base}-${k}`;
1469
+ taken.add(slug);
1470
+ const items = blockItems(nav.children, ctx, nextId, dropped);
1471
+ if (items.length) out.push({
1472
+ id: nextId(),
1473
+ slug,
1474
+ name,
1475
+ items,
1476
+ locations: [area]
1477
+ });
1478
+ });
1479
+ }
1480
+ return out;
1481
+ }
890
1482
  /**
891
1483
  * Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
892
1484
  * refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
893
1485
  * one with links of its own shows those (an inline menu, not a `wp_navigation` post).
894
1486
  */
895
- function navigationLocations(parts, navigations) {
1487
+ function navigationLocations(parts, navigations, all = parts) {
896
1488
  const out = /* @__PURE__ */ new Map();
897
1489
  const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
898
1490
  const find = (blocks, area) => {
@@ -906,15 +1498,19 @@ function navigationLocations(parts, navigations) {
906
1498
  }
907
1499
  };
908
1500
  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);
1501
+ const area = areaOf(p);
1502
+ if (area && p.content?.raw) find(partTree(p, all), area);
911
1503
  }
912
1504
  return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
913
1505
  }
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 }) {
1506
+ /**
1507
+ * Published `wp_navigation` posts, then the inline navigations of the used template parts → RawMenu[], slugs kept
1508
+ * unique against the classic menus already read. `templates` says which parts are used (see `usedParts`).
1509
+ */
1510
+ function blockMenus(navigations, allParts, ctx, taken, dropped = { count: 0 }, templates = []) {
1511
+ const parts = usedParts(allParts, templates);
916
1512
  const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
917
- const locations = navigationLocations(parts, published);
1513
+ const locations = navigationLocations(parts, published, allParts);
918
1514
  let next = 0;
919
1515
  const out = [];
920
1516
  for (const n of published) {
@@ -931,6 +1527,7 @@ function blockMenus(navigations, parts, ctx, taken, dropped = { count: 0 }) {
931
1527
  if (where?.length) menu.locations = where;
932
1528
  out.push(menu);
933
1529
  }
1530
+ out.push(...inlineMenus(parts, ctx, taken, () => -++next, dropped, allParts));
934
1531
  return out;
935
1532
  }
936
1533
  //#endregion
@@ -952,8 +1549,41 @@ const DENIED = new Set([
952
1549
  401,
953
1550
  403
954
1551
  ]);
1552
+ /** REST taxonomies that hold no content: menus, block-pattern categories, multilingual bookkeeping. */
1553
+ const NOT_CONTENT_TAXONOMIES = new Set([
1554
+ "category",
1555
+ "post_tag",
1556
+ "nav_menu",
1557
+ "wp_pattern_category",
1558
+ "post_format",
1559
+ "language",
1560
+ "post_translations",
1561
+ "term_language",
1562
+ "term_translations"
1563
+ ]);
955
1564
  /** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
956
1565
  const commentKey = (status) => status === "approve" ? "comments" : `comments:${status}`;
1566
+ /**
1567
+ * ACF fields of one REST post. `<name>_source` (SCF) states each field's type and label; a secret — a
1568
+ * `password` field, or, when no type is stated, a field named like one — is never read. ACF answers `[]`
1569
+ * for a post with no field group in REST.
1570
+ */
1571
+ function acfOf(acf) {
1572
+ if (!acf || Array.isArray(acf) || typeof acf !== "object") return {};
1573
+ const out = {};
1574
+ for (const [name, value] of Object.entries(acf)) {
1575
+ if (name.endsWith("_source") && name.slice(0, -7) in acf) continue;
1576
+ const source = acf[`${name}_source`];
1577
+ const type = typeof source?.type === "string" ? source.type : void 0;
1578
+ if (acfIsSecret(name, type)) continue;
1579
+ out[name] = {
1580
+ value: acfScrub(value),
1581
+ ...type ? { type } : {},
1582
+ ...typeof source?.label === "string" && source.label ? { label: source.label } : {}
1583
+ };
1584
+ }
1585
+ return Object.keys(out).length ? { acf: out } : {};
1586
+ }
957
1587
  /** What a visitor can open: published, not behind a post password. */
958
1588
  const visible = (p) => p.status === "publish" && !p.password;
959
1589
  const positiveInteger = (name, value) => {
@@ -1063,10 +1693,15 @@ async function fetchRestRawIR(options) {
1063
1693
  return null;
1064
1694
  }
1065
1695
  });
1066
- const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
1696
+ let typesResp = await doFetch(`${origin}/wp-json/wp/v2/types${options.auth && !rejected ? "?context=edit" : ""}`, { headers });
1697
+ if (!typesResp.ok && options.auth && !rejected) {
1698
+ await typesResp.body?.cancel();
1699
+ typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers: anonymous });
1700
+ }
1067
1701
  const types = typesResp.ok ? await typesResp.json() : {};
1068
1702
  if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
1069
1703
  const postTypes = Object.values(types).filter((t) => t.rest_base && !SKIP_TYPES.test(t.slug) && t.slug !== "attachment");
1704
+ const notViewable = new Set(postTypes.filter((t) => t.viewable === false).map((t) => t.slug));
1070
1705
  const bases = postTypes.length ? postTypes.map((t) => ({
1071
1706
  slug: t.slug,
1072
1707
  base: t.rest_base
@@ -1090,24 +1725,52 @@ async function fetchRestRawIR(options) {
1090
1725
  for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
1091
1726
  return [...byId.values()];
1092
1727
  };
1093
- const [categories, tags, users, media, restComments, ...postLists] = await Promise.all([
1728
+ const taxResp = await schedule(async () => {
1729
+ try {
1730
+ const r = await doFetch(`${origin}/wp-json/wp/v2/taxonomies`, { headers });
1731
+ if (!r.ok) {
1732
+ await r.body?.cancel();
1733
+ return {};
1734
+ }
1735
+ return await r.json();
1736
+ } catch {
1737
+ return {};
1738
+ }
1739
+ });
1740
+ 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) => ({
1741
+ taxonomy: t.slug,
1742
+ base: t.rest_base
1743
+ })).toSorted((a, b) => a.taxonomy.localeCompare(b.taxonomy));
1744
+ const [categories, tags, users, media, restComments, ...lists] = await Promise.all([
1094
1745
  getAll("categories"),
1095
1746
  getAll("tags"),
1096
1747
  getAll("users"),
1097
1748
  getAll("media"),
1098
1749
  commentListing(),
1750
+ ...extraTaxonomies.map((t) => getAll(t.base)),
1099
1751
  ...bases.map((b) => postListing(b.base))
1100
1752
  ]);
1753
+ const extraTerms = lists.slice(0, extraTaxonomies.length);
1754
+ const postLists = lists.slice(extraTaxonomies.length);
1101
1755
  const termsById = /* @__PURE__ */ new Map();
1102
- for (const [tax, list] of [["category", categories], ["post_tag", tags]]) for (const t of list) termsById.set(t.id, {
1103
- id: t.id,
1104
- taxonomy: tax,
1105
- slug: t.slug,
1106
- name: strip(t.name),
1107
- parent: t.parent ? list.find((x) => x.id === t.parent)?.slug ?? null : null,
1108
- parent_resolved: t.parent ? list.some((x) => x.id === t.parent) : null,
1109
- description: t.description ?? ""
1110
- });
1756
+ /** A term's public address, for menu items that name it; RawTerm carries none. */
1757
+ const termLinks = /* @__PURE__ */ new Map();
1758
+ for (const [tax, list] of [
1759
+ ["category", categories],
1760
+ ["post_tag", tags],
1761
+ ...extraTaxonomies.map((t, i) => [t.taxonomy, extraTerms[i] ?? []])
1762
+ ]) for (const t of list) {
1763
+ if (t.link) termLinks.set(t.id, t.link);
1764
+ termsById.set(t.id, {
1765
+ id: t.id,
1766
+ taxonomy: tax,
1767
+ slug: t.slug,
1768
+ name: strip(t.name),
1769
+ parent: t.parent ? list.find((x) => x.id === t.parent)?.slug ?? null : null,
1770
+ parent_resolved: t.parent ? list.some((x) => x.id === t.parent) : null,
1771
+ description: t.description ?? ""
1772
+ });
1773
+ }
1111
1774
  const userById = new Map(users.map((u) => [u.id, u]));
1112
1775
  const posts = [];
1113
1776
  const languagePairs = [];
@@ -1131,7 +1794,15 @@ async function fetchRestRawIR(options) {
1131
1794
  });
1132
1795
  };
1133
1796
  for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
1134
- const termRefs = [...p.categories ?? [], ...p.tags ?? []].map((id) => {
1797
+ const custom = extraTaxonomies.flatMap((t) => {
1798
+ const ids = p[t.base];
1799
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "number") : [];
1800
+ });
1801
+ const termRefs = [
1802
+ ...p.categories ?? [],
1803
+ ...p.tags ?? [],
1804
+ ...custom
1805
+ ].map((id) => {
1135
1806
  const t = termsById.get(id);
1136
1807
  return t ? {
1137
1808
  taxonomy: t.taxonomy,
@@ -1157,7 +1828,7 @@ async function fetchRestRawIR(options) {
1157
1828
  status: p.status ?? "publish",
1158
1829
  slug: p.slug,
1159
1830
  title: p.title?.rendered ?? "",
1160
- link: p.link ?? null,
1831
+ link: notViewable.has(base.slug) ? null : p.link ?? null,
1161
1832
  guid: null,
1162
1833
  author,
1163
1834
  date: iso(p.date_gmt),
@@ -1171,7 +1842,8 @@ async function fetchRestRawIR(options) {
1171
1842
  comment_status: p.comment_status ?? null,
1172
1843
  ping_status: p.ping_status ?? null,
1173
1844
  terms: termRefs,
1174
- meta
1845
+ meta,
1846
+ ...acfOf(p.acf)
1175
1847
  });
1176
1848
  }
1177
1849
  const attachments = media.map((m) => ({
@@ -1186,9 +1858,11 @@ async function fetchRestRawIR(options) {
1186
1858
  mime: m.mime_type ?? null,
1187
1859
  parent: m.post ?? null,
1188
1860
  parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
1861
+ link: !m.post || posts.some((p) => p.id === m.post && visible(p)) ? m.link || null : null,
1189
1862
  date: iso(m.date_gmt)
1190
1863
  }));
1191
1864
  const gaps = [];
1865
+ if (postLists.some((list) => list.some((p) => p.acf !== void 0))) gaps.push("acf_partial");
1192
1866
  let menus = [];
1193
1867
  if (!authed) gaps.push("menus_require_auth");
1194
1868
  else {
@@ -1227,11 +1901,12 @@ async function fetchRestRawIR(options) {
1227
1901
  status: first.status
1228
1902
  };
1229
1903
  };
1230
- const [classic, items, navs, parts] = await Promise.all([
1904
+ const [classic, items, navs, parts, templates] = await Promise.all([
1231
1905
  listing("menus?context=edit"),
1232
1906
  listing("menu-items?context=edit"),
1233
1907
  listing("navigation?context=edit&status=publish"),
1234
- listing("template-parts?context=edit")
1908
+ listing("template-parts?context=edit"),
1909
+ listing("templates?context=edit&_fields=slug,content")
1235
1910
  ]);
1236
1911
  const denied = [
1237
1912
  classic,
@@ -1248,6 +1923,7 @@ async function fetchRestRawIR(options) {
1248
1923
  ["navigation", navs]
1249
1924
  ]) if (!l.status || l.status >= 400 && !DENIED.has(l.status) && l.status !== 404) warnings.push(`${name}: HTTP ${l.status} — skipped`);
1250
1925
  const slugOf = new Map(posts.filter(visible).map((p) => [p.id, p.slug]));
1926
+ const linkOf = new Map(posts.filter(visible).flatMap((p) => p.link ? [[p.id, p.link]] : []));
1251
1927
  const ctx = {
1252
1928
  origin,
1253
1929
  postSlug: (id) => slugOf.get(id),
@@ -1255,6 +1931,8 @@ async function fetchRestRawIR(options) {
1255
1931
  const t = termsById.get(id);
1256
1932
  return t && t.taxonomy === taxonomy ? t.slug : void 0;
1257
1933
  },
1934
+ postLink: (id) => linkOf.get(id),
1935
+ termLink: (taxonomy, id) => termsById.get(id)?.taxonomy === taxonomy ? termLinks.get(id) : void 0,
1258
1936
  pages: posts.filter((p) => p.type === "page" && p.status === "publish" && !p.password).map((p) => ({
1259
1937
  id: p.id,
1260
1938
  parent: p.parent ?? null,
@@ -1266,7 +1944,7 @@ async function fetchRestRawIR(options) {
1266
1944
  };
1267
1945
  const dropped = { count: 0 };
1268
1946
  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));
1947
+ 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 : []));
1270
1948
  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
1949
  }
1272
1950
  const postIds = new Set(posts.map((p) => p.id));
@@ -1288,6 +1966,62 @@ async function fetchRestRawIR(options) {
1288
1966
  user_id: c.author || null
1289
1967
  }));
1290
1968
  for (const c of comments) if (!postIds.has(c.post)) warnings.push(`comment ${c.id}: post ${c.post} not in fetched set`);
1969
+ let redirects = [];
1970
+ let redirectsExcluded = [];
1971
+ gaps.push("redirects_partial");
1972
+ const namespaces = Array.isArray(about?.namespaces) ? about.namespaces : void 0;
1973
+ const redirection = namespaces ? namespaces.includes("redirection/v1") : void 0;
1974
+ if (redirection && !authed) gaps.push("redirects_require_auth");
1975
+ else if (authed && redirection !== false) {
1976
+ const all = async (path) => {
1977
+ const items = [];
1978
+ const seen = /* @__PURE__ */ new Set();
1979
+ for (let page = 0; page < (maxPages ?? 500); page++) {
1980
+ const r = await schedule(() => doFetch(`${origin}/wp-json/redirection/v1/${path}?per_page=200&page=${page}`, { headers }));
1981
+ if (!r.ok) {
1982
+ await r.body?.cancel();
1983
+ return {
1984
+ items,
1985
+ status: r.status
1986
+ };
1987
+ }
1988
+ const body = await r.json();
1989
+ const fresh = (Array.isArray(body.items) ? body.items : []).filter((x) => !seen.has(x.id));
1990
+ for (const x of fresh) {
1991
+ seen.add(x.id);
1992
+ items.push(x);
1993
+ }
1994
+ if (!fresh.length || items.length >= Number(body.total ?? 0)) break;
1995
+ }
1996
+ return {
1997
+ items,
1998
+ status: 200
1999
+ };
2000
+ };
2001
+ const settings = async () => {
2002
+ try {
2003
+ const r = await schedule(() => doFetch(`${origin}/wp-json/redirection/v1/setting`, { headers }));
2004
+ if (!r.ok) {
2005
+ await r.body?.cancel();
2006
+ return;
2007
+ }
2008
+ const body = await r.json();
2009
+ return body?.settings && typeof body.settings === "object" ? body.settings : void 0;
2010
+ } catch {
2011
+ return;
2012
+ }
2013
+ };
2014
+ const [rules, groups, siteFlags] = await Promise.all([
2015
+ all("redirect"),
2016
+ all("group"),
2017
+ settings()
2018
+ ]);
2019
+ if (DENIED.has(rules.status)) {
2020
+ gaps.push("redirects_require_auth");
2021
+ warnings.push(`redirects: HTTP ${rules.status} with the credential — the user may not manage Redirection; its rules not read`);
2022
+ } else if (rules.status === 200) ({redirects, excluded: redirectsExcluded} = redirectionRules(rules.items, groups.status === 200 ? groups.items : [], origin, siteFlags));
2023
+ else if (redirection) warnings.push(`redirects: HTTP ${rules.status} — Redirection's rules not read`);
2024
+ }
1291
2025
  return {
1292
2026
  raw: {
1293
2027
  version: MIGRATION_CONTRACT_VERSION,
@@ -1300,7 +2034,9 @@ async function fetchRestRawIR(options) {
1300
2034
  ...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
1301
2035
  ...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
1302
2036
  ...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
1303
- ...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {}
2037
+ ...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {},
2038
+ ...typeof about?.timezone_string === "string" && about.timezone_string ? { timezone: about.timezone_string } : {},
2039
+ ...about?.gmt_offset !== void 0 && about.gmt_offset !== null && about.gmt_offset !== "" && Number.isFinite(Number(about.gmt_offset)) ? { gmt_offset: Number(about.gmt_offset) } : {}
1304
2040
  },
1305
2041
  authors: users.map((u) => ({
1306
2042
  id: u.id,
@@ -1313,6 +2049,8 @@ async function fetchRestRawIR(options) {
1313
2049
  attachments,
1314
2050
  comments,
1315
2051
  ...menus.length ? { menus } : {},
2052
+ ...redirects.length ? { redirects } : {},
2053
+ ...redirectsExcluded.length ? { redirects_excluded: redirectsExcluded } : {},
1316
2054
  ...languagePairs.length ? { language_pairs: languagePairs } : {}
1317
2055
  },
1318
2056
  warnings,
@@ -1355,6 +2093,8 @@ function relationOver(targets) {
1355
2093
  ref: (entry) => single ? entry.ref : entry
1356
2094
  };
1357
2095
  }
2096
+ /** Post, term, user or attachment ids of an ACF reference value: ids, numeric strings or `{ ID }` objects. */
2097
+ 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);
1358
2098
  function rawToContentrain(raw, opts) {
1359
2099
  const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
1360
2100
  const locale = normLocale(raw.site.language) || "en";
@@ -1386,12 +2126,15 @@ function rawToContentrain(raw, opts) {
1386
2126
  title_fallback: 0,
1387
2127
  meta_fields: {},
1388
2128
  acf_fields: {},
2129
+ acf_outside_choices: {},
2130
+ acf_datetime_unzoned: 0,
1389
2131
  skipped_types: [],
1390
2132
  dropped_relations: 0,
1391
2133
  models: {},
1392
2134
  locales,
1393
2135
  translation_groups: translationGroups,
1394
- password_protected_drafts: 0
2136
+ password_protected_drafts: 0,
2137
+ site_title_missing: false
1395
2138
  };
1396
2139
  const importMeta = (status, extra = {}) => ({
1397
2140
  status,
@@ -1449,6 +2192,100 @@ function rawToContentrain(raw, opts) {
1449
2192
  });
1450
2193
  }
1451
2194
  const mediaRef = (id) => raw.attachments.some((a) => a.id === id) ? hexId(`media:${id}`) : null;
2195
+ const termById = new Map(raw.terms.map((t) => [t.id, t]));
2196
+ const authorById = new Map(raw.authors.filter((a) => a.id !== null).map((a) => [a.id, a]));
2197
+ const refOf = (kind, id) => {
2198
+ if (kind === "post") return postEntry.get(id) ?? null;
2199
+ if (kind === "media") {
2200
+ const m = mediaRef(id);
2201
+ return m ? {
2202
+ model: "media",
2203
+ ref: m
2204
+ } : null;
2205
+ }
2206
+ if (kind === "user") {
2207
+ const a = authorById.get(id);
2208
+ return a && models.authors ? {
2209
+ model: "authors",
2210
+ ref: authorId(slugify(a.login))
2211
+ } : null;
2212
+ }
2213
+ if (kind === "term") {
2214
+ const t = termById.get(id);
2215
+ return t && models[taxModelId(t.taxonomy)] ? {
2216
+ model: taxModelId(t.taxonomy),
2217
+ ref: termId(t.taxonomy, t.slug)
2218
+ } : null;
2219
+ }
2220
+ return null;
2221
+ };
2222
+ /** Every ACF field on these posts: its definition, or — for a reference — its kind and target models. */
2223
+ const acfPlan = (items, taken) => {
2224
+ const out = /* @__PURE__ */ new Map();
2225
+ for (const p of items) for (const [k, acf] of Object.entries(p.acf ?? {})) {
2226
+ if (taken(k) || acf.value === null || acf.value === void 0 || acf.value === "" || acf.value === false && acf.type !== "true_false") continue;
2227
+ const kind = acf.type ? ACF_REFERENCE_TYPES[acf.type] : void 0;
2228
+ if (kind === "address") {
2229
+ const def = Array.isArray(acf.value) ? {
2230
+ type: "array",
2231
+ items: "url"
2232
+ } : { type: "url" };
2233
+ const cur = out.get(k);
2234
+ out.set(k, {
2235
+ def: cur?.def ?? {
2236
+ ...def,
2237
+ ...acf.label ? { label: strip(acf.label) } : {}
2238
+ },
2239
+ kind,
2240
+ multiple: false,
2241
+ models: /* @__PURE__ */ new Set()
2242
+ });
2243
+ continue;
2244
+ }
2245
+ if (kind) {
2246
+ const cur = out.get(k) ?? {
2247
+ kind,
2248
+ multiple: acf.type === "relationship" || acf.type === "gallery",
2249
+ models: /* @__PURE__ */ new Set()
2250
+ };
2251
+ if (Array.isArray(acf.value)) cur.multiple = true;
2252
+ for (const id of idsOf(acf.value)) {
2253
+ const r = refOf(kind, id);
2254
+ if (r) cur.models.add(r.model);
2255
+ }
2256
+ out.set(k, cur);
2257
+ continue;
2258
+ }
2259
+ const def = acfFieldDef(k, acfScrub(acf.value), {
2260
+ type: acf.type,
2261
+ label: acf.label
2262
+ });
2263
+ if (!def) continue;
2264
+ const cur = out.get(k);
2265
+ out.set(k, {
2266
+ def: cur?.def ? mergeFieldDef(cur.def, def) : def,
2267
+ multiple: false,
2268
+ models: /* @__PURE__ */ new Set()
2269
+ });
2270
+ }
2271
+ for (const [k, plan] of out) if (!plan.def && !plan.models.size) out.delete(k);
2272
+ return out;
2273
+ };
2274
+ const acfRefs = (plan, value) => {
2275
+ const refs = idsOf(value).map((id) => refOf(plan.kind, id)).filter((r) => !!r);
2276
+ const missing = idsOf(value).length - refs.length;
2277
+ if (missing > 0) report.dropped_relations += missing;
2278
+ if (!refs.length) return void 0;
2279
+ const poly = plan.models.size > 1;
2280
+ const one = (r) => poly ? r : r.ref;
2281
+ return plan.multiple ? refs.map(one) : one(refs[0]);
2282
+ };
2283
+ const addressOf = (v) => {
2284
+ if (Array.isArray(v)) return v.map(addressOf).filter(Boolean);
2285
+ if (typeof v !== "number") return v;
2286
+ const t = postById.get(v);
2287
+ return t && t.status === "publish" && !t.password ? t.link ?? void 0 : void 0;
2288
+ };
1452
2289
  const titleOf = (p) => {
1453
2290
  const t = strip(p.title);
1454
2291
  if (t) return t;
@@ -1788,31 +2625,32 @@ function rawToContentrain(raw, opts) {
1788
2625
  label: "Comments open",
1789
2626
  order: o += 10
1790
2627
  };
1791
- for (const p of typeItems) {
1792
- for (const [k, v] of Object.entries(p.meta)) {
1793
- if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
1794
- const t = byValue(v);
1795
- if (t && t !== "object") {
1796
- fields[k] = {
1797
- type: t,
1798
- label: k,
1799
- order: o += 10
1800
- };
1801
- report.meta_fields[k] = t;
1802
- }
1803
- }
1804
- for (const [k, acf] of Object.entries(p.acf ?? {})) {
1805
- if (fields[k]) continue;
1806
- const t = byValue(acf.value) ?? "string";
2628
+ for (const p of typeItems) for (const [k, v] of Object.entries(p.meta)) {
2629
+ if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
2630
+ const t = byValue(v);
2631
+ if (t && t !== "object") {
1807
2632
  fields[k] = {
1808
- type: t === "object" ? "object" : t,
2633
+ type: t,
1809
2634
  label: k,
1810
- description: "ACF",
1811
2635
  order: o += 10
1812
2636
  };
1813
- report.acf_fields[k] = t;
2637
+ report.meta_fields[k] = t;
1814
2638
  }
1815
2639
  }
2640
+ const acfFields = acfPlan(typeItems, (k) => !!fields[k]);
2641
+ for (const [k, plan] of acfFields) {
2642
+ const def = plan.def ?? {
2643
+ type: plan.multiple ? "relations" : "relation",
2644
+ model: plan.models.size > 1 ? [...plan.models].toSorted() : [...plan.models][0]
2645
+ };
2646
+ fields[k] = {
2647
+ ...def,
2648
+ ...def.label ? {} : { label: k },
2649
+ description: def.description ?? "ACF",
2650
+ order: o += 10
2651
+ };
2652
+ report.acf_fields[k] = plan.def ? plan.def.type : `${def.type}:${[...plan.models].toSorted().join("|")}`;
2653
+ }
1816
2654
  const covered = locales.filter((l) => l === locale || typeItems.some((p) => postLocale(p) === l));
1817
2655
  addModel({
1818
2656
  id: mid,
@@ -1861,7 +2699,22 @@ function rawToContentrain(raw, opts) {
1861
2699
  if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
1862
2700
  if (fields.comments_open) e.comments_open = p.comment_status === "open";
1863
2701
  for (const k of Object.keys(fields)) if (k in p.meta && !(k in e) && !k.startsWith("_")) e[k] = p.meta[k];
1864
- for (const [k, acf] of Object.entries(p.acf ?? {})) if (fields[k] && !(k in e)) e[k] = acf.value;
2702
+ for (const [k, acf] of Object.entries(p.acf ?? {})) {
2703
+ const plan = acfFields.get(k);
2704
+ if (!plan || k in e) continue;
2705
+ const ctx = {
2706
+ timeZone: raw.site.timezone,
2707
+ gmtOffset: raw.site.gmt_offset,
2708
+ dropped: () => {
2709
+ report.acf_outside_choices[k] = (report.acf_outside_choices[k] ?? 0) + 1;
2710
+ },
2711
+ unzoned: () => {
2712
+ report.acf_datetime_unzoned++;
2713
+ }
2714
+ };
2715
+ const v = plan.def ? acfValue(plan.def, plan.kind === "address" ? addressOf(acf.value) : acfRows(acfScrub(acf.value)), ctx) : acfRefs(plan, acf.value);
2716
+ if (v !== void 0) e[k] = v;
2717
+ }
1865
2718
  contentBucket[id] = e;
1866
2719
  metaBucket[id] = mapStatus(p);
1867
2720
  }
@@ -1993,7 +2846,7 @@ function rawToContentrain(raw, opts) {
1993
2846
  name: strip(m.name),
1994
2847
  slug: slugify(m.slug) || `menu-${m.id}`,
1995
2848
  items: m.items.map((i) => itemRef(i.id)),
1996
- wp_id: m.id,
2849
+ ...m.id !== null && m.id > 0 ? { wp_id: m.id } : {},
1997
2850
  ...m.locations?.length ? { locations: m.locations } : {}
1998
2851
  };
1999
2852
  metas.menus[mid] = importMeta("published");
@@ -2157,7 +3010,7 @@ function rawToContentrain(raw, opts) {
2157
3010
  }
2158
3011
  });
2159
3012
  siteEntry = pick({
2160
- title: strip(raw.site.title) || "Site",
3013
+ title: strip(raw.site.title),
2161
3014
  tagline: raw.site.description,
2162
3015
  url: raw.site.url,
2163
3016
  language: raw.site.language
@@ -2168,6 +3021,7 @@ function rawToContentrain(raw, opts) {
2168
3021
  "language"
2169
3022
  ]));
2170
3023
  siteMeta = importMeta("published");
3024
+ report.site_title_missing = !siteEntry.title;
2171
3025
  const vocabTerms = {};
2172
3026
  for (const m of menus) for (const i of m.items) {
2173
3027
  const k = slugify(strip(i.title));