@contentrain/wp-import 0.7.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,364 @@ 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
678
1036
  //#region src/rest-menus.ts
679
1037
  const targetOf = (kind, object, id, url, ctx) => {
680
1038
  const objectId = id && id > 0 ? id : null;
@@ -733,12 +1091,33 @@ function hiddenItems(items, drop) {
733
1091
  lift
734
1092
  };
735
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
+ }
736
1109
  /** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
737
1110
  function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
738
1111
  const out = [];
739
1112
  for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
740
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);
741
- const { gone, lift } = hiddenItems(all, (i) => (i.status ?? "publish") !== "publish" || i.type === "post_type" && !(i.object_id && ctx.postSlug(i.object_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);
742
1121
  dropped.count += gone.size;
743
1122
  const own = all.filter((i) => !gone.has(i.id));
744
1123
  const ids = new Set(own.map((i) => i.id));
@@ -805,6 +1184,7 @@ function parseBlocks(content) {
805
1184
  const str = (v) => typeof v === "string" && v ? v : void 0;
806
1185
  const absolute = (url, origin) => {
807
1186
  if (!url) return void 0;
1187
+ if (url.startsWith("#")) return url;
808
1188
  try {
809
1189
  return new URL(url, `${origin}/`).href;
810
1190
  } catch {
@@ -822,6 +1202,10 @@ const KIND = {
822
1202
  * published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
823
1203
  */
824
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) {
825
1209
  const items = [];
826
1210
  const push = (b, parent) => {
827
1211
  const id = nextId();
@@ -859,7 +1243,8 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
859
1243
  if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
860
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");
861
1245
  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)) {
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)) {
863
1248
  dropped.count++;
864
1249
  walk(b.children, parent);
865
1250
  continue;
@@ -884,15 +1269,94 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
884
1269
  else walk(b.children, parent);
885
1270
  }
886
1271
  };
887
- walk(parseBlocks(content), null);
1272
+ walk(tree, null);
888
1273
  return items;
889
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
+ }
890
1354
  /**
891
1355
  * Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
892
1356
  * refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
893
1357
  * one with links of its own shows those (an inline menu, not a `wp_navigation` post).
894
1358
  */
895
- function navigationLocations(parts, navigations) {
1359
+ function navigationLocations(parts, navigations, all = parts) {
896
1360
  const out = /* @__PURE__ */ new Map();
897
1361
  const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
898
1362
  const find = (blocks, area) => {
@@ -906,15 +1370,19 @@ function navigationLocations(parts, navigations) {
906
1370
  }
907
1371
  };
908
1372
  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);
1373
+ const area = areaOf(p);
1374
+ if (area && p.content?.raw) find(partTree(p, all), area);
911
1375
  }
912
1376
  return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
913
1377
  }
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 }) {
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);
916
1384
  const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
917
- const locations = navigationLocations(parts, published);
1385
+ const locations = navigationLocations(parts, published, allParts);
918
1386
  let next = 0;
919
1387
  const out = [];
920
1388
  for (const n of published) {
@@ -931,6 +1399,7 @@ function blockMenus(navigations, parts, ctx, taken, dropped = { count: 0 }) {
931
1399
  if (where?.length) menu.locations = where;
932
1400
  out.push(menu);
933
1401
  }
1402
+ out.push(...inlineMenus(parts, ctx, taken, () => -++next, dropped, allParts));
934
1403
  return out;
935
1404
  }
936
1405
  //#endregion
@@ -952,8 +1421,41 @@ const DENIED = new Set([
952
1421
  401,
953
1422
  403
954
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
+ ]);
955
1436
  /** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
956
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
+ }
957
1459
  /** What a visitor can open: published, not behind a post password. */
958
1460
  const visible = (p) => p.status === "publish" && !p.password;
959
1461
  const positiveInteger = (name, value) => {
@@ -1063,10 +1565,15 @@ async function fetchRestRawIR(options) {
1063
1565
  return null;
1064
1566
  }
1065
1567
  });
1066
- const typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers });
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
+ }
1067
1573
  const types = typesResp.ok ? await typesResp.json() : {};
1068
1574
  if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
1069
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));
1070
1577
  const bases = postTypes.length ? postTypes.map((t) => ({
1071
1578
  slug: t.slug,
1072
1579
  base: t.rest_base
@@ -1090,16 +1597,39 @@ async function fetchRestRawIR(options) {
1090
1597
  for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
1091
1598
  return [...byId.values()];
1092
1599
  };
1093
- 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([
1094
1617
  getAll("categories"),
1095
1618
  getAll("tags"),
1096
1619
  getAll("users"),
1097
1620
  getAll("media"),
1098
1621
  commentListing(),
1622
+ ...extraTaxonomies.map((t) => getAll(t.base)),
1099
1623
  ...bases.map((b) => postListing(b.base))
1100
1624
  ]);
1625
+ const extraTerms = lists.slice(0, extraTaxonomies.length);
1626
+ const postLists = lists.slice(extraTaxonomies.length);
1101
1627
  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, {
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, {
1103
1633
  id: t.id,
1104
1634
  taxonomy: tax,
1105
1635
  slug: t.slug,
@@ -1131,7 +1661,15 @@ async function fetchRestRawIR(options) {
1131
1661
  });
1132
1662
  };
1133
1663
  for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
1134
- 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) => {
1135
1673
  const t = termsById.get(id);
1136
1674
  return t ? {
1137
1675
  taxonomy: t.taxonomy,
@@ -1157,7 +1695,7 @@ async function fetchRestRawIR(options) {
1157
1695
  status: p.status ?? "publish",
1158
1696
  slug: p.slug,
1159
1697
  title: p.title?.rendered ?? "",
1160
- link: p.link ?? null,
1698
+ link: notViewable.has(base.slug) ? null : p.link ?? null,
1161
1699
  guid: null,
1162
1700
  author,
1163
1701
  date: iso(p.date_gmt),
@@ -1171,7 +1709,8 @@ async function fetchRestRawIR(options) {
1171
1709
  comment_status: p.comment_status ?? null,
1172
1710
  ping_status: p.ping_status ?? null,
1173
1711
  terms: termRefs,
1174
- meta
1712
+ meta,
1713
+ ...acfOf(p.acf)
1175
1714
  });
1176
1715
  }
1177
1716
  const attachments = media.map((m) => ({
@@ -1189,6 +1728,7 @@ async function fetchRestRawIR(options) {
1189
1728
  date: iso(m.date_gmt)
1190
1729
  }));
1191
1730
  const gaps = [];
1731
+ if (postLists.some((list) => list.some((p) => p.acf !== void 0))) gaps.push("acf_partial");
1192
1732
  let menus = [];
1193
1733
  if (!authed) gaps.push("menus_require_auth");
1194
1734
  else {
@@ -1227,11 +1767,12 @@ async function fetchRestRawIR(options) {
1227
1767
  status: first.status
1228
1768
  };
1229
1769
  };
1230
- const [classic, items, navs, parts] = await Promise.all([
1770
+ const [classic, items, navs, parts, templates] = await Promise.all([
1231
1771
  listing("menus?context=edit"),
1232
1772
  listing("menu-items?context=edit"),
1233
1773
  listing("navigation?context=edit&status=publish"),
1234
- listing("template-parts?context=edit")
1774
+ listing("template-parts?context=edit"),
1775
+ listing("templates?context=edit&_fields=slug,content")
1235
1776
  ]);
1236
1777
  const denied = [
1237
1778
  classic,
@@ -1266,7 +1807,7 @@ async function fetchRestRawIR(options) {
1266
1807
  };
1267
1808
  const dropped = { count: 0 };
1268
1809
  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));
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 : []));
1270
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`);
1271
1812
  }
1272
1813
  const postIds = new Set(posts.map((p) => p.id));
@@ -1300,7 +1841,9 @@ async function fetchRestRawIR(options) {
1300
1841
  ...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
1301
1842
  ...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
1302
1843
  ...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
1303
- ...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {}
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) } : {}
1304
1847
  },
1305
1848
  authors: users.map((u) => ({
1306
1849
  id: u.id,
@@ -1355,6 +1898,8 @@ function relationOver(targets) {
1355
1898
  ref: (entry) => single ? entry.ref : entry
1356
1899
  };
1357
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);
1358
1903
  function rawToContentrain(raw, opts) {
1359
1904
  const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
1360
1905
  const locale = normLocale(raw.site.language) || "en";
@@ -1386,12 +1931,15 @@ function rawToContentrain(raw, opts) {
1386
1931
  title_fallback: 0,
1387
1932
  meta_fields: {},
1388
1933
  acf_fields: {},
1934
+ acf_outside_choices: {},
1935
+ acf_datetime_unzoned: 0,
1389
1936
  skipped_types: [],
1390
1937
  dropped_relations: 0,
1391
1938
  models: {},
1392
1939
  locales,
1393
1940
  translation_groups: translationGroups,
1394
- password_protected_drafts: 0
1941
+ password_protected_drafts: 0,
1942
+ site_title_missing: false
1395
1943
  };
1396
1944
  const importMeta = (status, extra = {}) => ({
1397
1945
  status,
@@ -1449,6 +1997,100 @@ function rawToContentrain(raw, opts) {
1449
1997
  });
1450
1998
  }
1451
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
+ };
1452
2094
  const titleOf = (p) => {
1453
2095
  const t = strip(p.title);
1454
2096
  if (t) return t;
@@ -1788,31 +2430,32 @@ function rawToContentrain(raw, opts) {
1788
2430
  label: "Comments open",
1789
2431
  order: o += 10
1790
2432
  };
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";
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") {
1807
2437
  fields[k] = {
1808
- type: t === "object" ? "object" : t,
2438
+ type: t,
1809
2439
  label: k,
1810
- description: "ACF",
1811
2440
  order: o += 10
1812
2441
  };
1813
- report.acf_fields[k] = t;
2442
+ report.meta_fields[k] = t;
1814
2443
  }
1815
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
+ }
1816
2459
  const covered = locales.filter((l) => l === locale || typeItems.some((p) => postLocale(p) === l));
1817
2460
  addModel({
1818
2461
  id: mid,
@@ -1861,7 +2504,22 @@ function rawToContentrain(raw, opts) {
1861
2504
  if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
1862
2505
  if (fields.comments_open) e.comments_open = p.comment_status === "open";
1863
2506
  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;
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
+ }
1865
2523
  contentBucket[id] = e;
1866
2524
  metaBucket[id] = mapStatus(p);
1867
2525
  }
@@ -1993,7 +2651,7 @@ function rawToContentrain(raw, opts) {
1993
2651
  name: strip(m.name),
1994
2652
  slug: slugify(m.slug) || `menu-${m.id}`,
1995
2653
  items: m.items.map((i) => itemRef(i.id)),
1996
- wp_id: m.id,
2654
+ ...m.id !== null && m.id > 0 ? { wp_id: m.id } : {},
1997
2655
  ...m.locations?.length ? { locations: m.locations } : {}
1998
2656
  };
1999
2657
  metas.menus[mid] = importMeta("published");
@@ -2157,7 +2815,7 @@ function rawToContentrain(raw, opts) {
2157
2815
  }
2158
2816
  });
2159
2817
  siteEntry = pick({
2160
- title: strip(raw.site.title) || "Site",
2818
+ title: strip(raw.site.title),
2161
2819
  tagline: raw.site.description,
2162
2820
  url: raw.site.url,
2163
2821
  language: raw.site.language
@@ -2168,6 +2826,7 @@ function rawToContentrain(raw, opts) {
2168
2826
  "language"
2169
2827
  ]));
2170
2828
  siteMeta = importMeta("published");
2829
+ report.site_title_missing = !siteEntry.title;
2171
2830
  const vocabTerms = {};
2172
2831
  for (const m of menus) for (const i of m.items) {
2173
2832
  const k = slugify(strip(i.title));