@contentrain/wp-import 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,6 +140,40 @@ contains `menus_require_auth` — an empty menu list is never presented as the
140
140
  site having none. The `menus` model gains a `locations` field only when the
141
141
  source named them.
142
142
 
143
+ A menu item that names a public post or term links to that post's or term's
144
+ public address (its REST `link`). The address the item stored is not used: a
145
+ block keeps the URL it had when it was saved, and a hand-typed `?page_id=` is a
146
+ form the migrated site cannot serve. A hand-typed `?page_id=` / `?p=` counts
147
+ as this site's with or without a leading `www.`.
148
+
149
+ ### Redirects over REST
150
+
151
+ REST reads one source of redirects: the Redirection plugin (`redirection/v1`).
152
+ This needs an application password of a user who may manage the plugin. Its
153
+ rules are shaped as the Bridge shapes them. Each rule lands in exactly one of
154
+ two places:
155
+
156
+ - `redirects`: the rules the site serves, with the plugin's query, case and
157
+ trailing-slash modes. A "gone" rule (410, 451) is served with an empty `to`.
158
+ - `redirects_excluded`: every other rule, with its reason (disabled,
159
+ conditional, a 404 or pass-through action).
160
+
161
+ Yoast Premium, Rank Math, Safe Redirect Manager and `.htaccess` keep their
162
+ rules where only the Bridge export reads them. So `gaps` always contains
163
+ `redirects_partial`. It also contains `redirects_require_auth` when the site
164
+ runs Redirection but its rules could not be read with the credential given.
165
+
166
+ A conditional rule (login, cookie, header, IP, role…) is excluded. Only a login
167
+ rule keeps its `condition`, which is its two addresses. Any other condition
168
+ holds the value a visitor must present, often a secret, so it is never
169
+ exported; the reason names its type.
170
+
171
+ Each attachment carries its attachment page as `link`, so the migrated site
172
+ can redirect it. The link is kept only when the attachment has no parent or
173
+ its parent is proven public, because the page sits under the parent's address
174
+ and would otherwise carry a draft's, a private post's or a scheduled post's
175
+ slug.
176
+
143
177
  ### ACF fields, custom post types and taxonomies over REST
144
178
 
145
179
  ACF / Secure Custom Fields values arrive on a post's `acf` key — only for field
package/dist/index.d.mts CHANGED
@@ -71,6 +71,10 @@ interface RestImportResult {
71
71
  * What this rung could not read, as codes a caller can act on (the text is in `warnings`).
72
72
  * `menus_require_auth`: menus and block navigation are only readable with an application password
73
73
  * of a user who may edit theme options — none was given, the site rejected it, or it lacks that right.
74
+ * `redirects_partial`: always, over REST — it reads only the Redirection plugin's rules; Yoast Premium, Rank Math,
75
+ * Safe Redirect Manager and `.htaccess` keep theirs where only the Bridge export reads them.
76
+ * `redirects_require_auth`: the site runs Redirection, and its rules need an application password of a user who
77
+ * may manage it — none was given, the site rejected it, or it lacks that right.
74
78
  * `acf_partial`: the site runs ACF / Secure Custom Fields; REST shows only the field groups set to
75
79
  * `show_in_rest` (off by default) and no options page — the Bridge export reads the rest. Where the site
76
80
  * states no field types (plain ACF, no `<name>_source`), a secret field is recognised by its name only.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/wxr.ts","../src/rest.ts","../src/contentrain.ts","../src/comments.ts","../src/php-unserialize.ts","../src/core.ts","../src/delta.ts"],"mappings":";;;;UA2BiB,QAAA;EACf,UAAA;IAAc,QAAA;IAAkB,OAAA;IAAiB,MAAA;IAAgB,IAAA;EAAA;EACjE,UAAA;EACA,gBAAA;EACA,MAAA,EAAQ,MAAA;AAAA;AAAA,UAGO,cAAA;EACf,GAAA,EAAK,KAAA;EACL,KAAA,EAAO,QAAA;AAAA;AAAA,iBAiBa,QAAA,CAAS,KAAA,WAAgB,QAAA,EAAU,IAAA;EAAS,IAAA;AAAA,IAAkB,OAAA,CAAQ,cAAA;;;UCpC3E,iBAAA;EACf,MAAA;;EAEA,SAAA,UAAmB,KAAA;EDOI;;;;;;;;ECEvB,IAAA;IAAS,IAAA;IAAc,WAAA;EAAA;EACvB,OAAA;EDCc;AAGhB;;;;ECEE,WAAA;EDDK;;;;ECML,QAAA;EACA,IAAA;AAAA;AAAA,UAGe,gBAAA;EACf,GAAA,EAAK,KAAA;EACL,QAAA;EDMkF;;;;;;;;ECGlF,UAAA;IAAc,MAAA;IAA0C,SAAA;EAAA;;;AAvC1D;;;;;;EAgDE,IAAA;AAAA;AAAA,iBA2EoB,cAAA,CAAe,OAAA,EAAS,iBAAA,GAAoB,OAAA,CAAQ,gBAAA;;;UChHzD,YAAA;EACf,cAAA;EACA,aAAA;EACA,cAAA;EACA,WAAA,EAAa,MAAA;EACb,UAAA,EAAY,MAAA;EFFE;EEId,mBAAA,EAAqB,MAAA;EFPP;EESd,oBAAA;EACA,aAAA;EACA,iBAAA;EACA,MAAA,EAAQ,MAAA;IAAiB,IAAA;IAAc,MAAA;IAAgB,MAAA;IAAgB,OAAA;EAAA;EFNxD;EEQf,OAAA;;EAEA,kBAAA;EFTA;;;;;;EEgBA,yBAAA;EFE4B;;;;EEG5B,kBAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;EAClB,MAAA,EAAQ,YAAA;AAAA;AAAA,iBA0CM,gBAAA,CAAiB,GAAA,EAAK,KAAA,EAAO,IAAA;EAAS,SAAA;AAAA,IAAuB,iBAAA;;;;;;AF7E7E;;;iBGbgB,cAAA,CAAe,GAAA,EAAK,KAAA;EAAU,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,WAAA,CAAY,cAAA;AAAA;AAAA,iBAgB5E,mBAAA,CAAoB,GAAA,EAAK,KAAA,EAAO,OAAA,EAAS,cAAA,EAAgB,IAAA;EAAS,YAAA;AAAA,IAA0B,cAAA;;iBAiB5F,iBAAA,CAAkB,GAAA,EAAK,cAAA,GAAiB,eAAA;;;UC3CvC,iBAAA;EACf,EAAA;EACA,UAAA;EACA,KAAA;EACA,KAAA;AAAA;AAAA,iBAGc,eAAA,CAAgB,CAAA,YAAa,CAAA;AAAA,iBAI7B,cAAA,CAAe,GAAA;;iBAyFf,cAAA,CAAe,CAAA,WAAY,iBAAA;;;cC9F9B,KAAA,GAAS,CAAA;;cAST,KAAA,GAAS,CAAA;;cAcT,KAAA,GAAS,CAAA;;;;;;;;;ALCtB;cK8Ba,UAAA,EAAU,MAAA;;cAIV,WAAA,EAAW,MAAA;;cAOX,UAAA,GAAc,CAAA;AAAA,cAGd,OAAA,GAAW,CAAA;;;;UC3DP,UAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;AAAA;AAAA,UAGH,oBAAA;ENOD;EMLd,KAAA,EAAO,eAAA;ENEO;EMAd,KAAA,EAAO,UAAA;ENA0C;EMEjD,QAAA,GAAW,UAAA;ENDX;;;;;EMOA,UAAA;ENFe;;;;EMOf,SAAA;AAAA;AAAA,cAGW,kBAAA;AAAA,cACA,iBAAA;;;ANQb;;;;;;;;;;iBM8NgB,eAAA,CAAgB,KAAA,EAAO,oBAAA,GAAuB,eAAA;;iBAkC9C,uBAAA,CAAwB,IAAA,EAAM,eAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/wxr.ts","../src/rest.ts","../src/contentrain.ts","../src/comments.ts","../src/php-unserialize.ts","../src/core.ts","../src/delta.ts"],"mappings":";;;;UA2BiB,QAAA;EACf,UAAA;IAAc,QAAA;IAAkB,OAAA;IAAiB,MAAA;IAAgB,IAAA;EAAA;EACjE,UAAA;EACA,gBAAA;EACA,MAAA,EAAQ,MAAA;AAAA;AAAA,UAGO,cAAA;EACf,GAAA,EAAK,KAAA;EACL,KAAA,EAAO,QAAA;AAAA;AAAA,iBAiBa,QAAA,CAAS,KAAA,WAAgB,QAAA,EAAU,IAAA;EAAS,IAAA;AAAA,IAAkB,OAAA,CAAQ,cAAA;;;UCnC3E,iBAAA;EACf,MAAA;;EAEA,SAAA,UAAmB,KAAA;EDMI;;;;;;;;ECGvB,IAAA;IAAS,IAAA;IAAc,WAAA;EAAA;EACvB,OAAA;EDAc;AAGhB;;;;ECGE,WAAA;EDFK;;;;ECOL,QAAA;EACA,IAAA;AAAA;AAAA,UAGe,gBAAA;EACf,GAAA,EAAK,KAAA;EACL,QAAA;EDKkF;;;;;;;;ECIlF,UAAA;IAAc,MAAA;IAA0C,SAAA;EAAA;;;AAvC1D;;;;;;;;;;EAoDE,IAAA;AAAA;AAAA,iBA2EoB,cAAA,CAAe,OAAA,EAAS,iBAAA,GAAoB,OAAA,CAAQ,gBAAA;;;UCrHzD,YAAA;EACf,cAAA;EACA,aAAA;EACA,cAAA;EACA,WAAA,EAAa,MAAA;EACb,UAAA,EAAY,MAAA;EFFE;EEId,mBAAA,EAAqB,MAAA;EFPP;EESd,oBAAA;EACA,aAAA;EACA,iBAAA;EACA,MAAA,EAAQ,MAAA;IAAiB,IAAA;IAAc,MAAA;IAAgB,MAAA;IAAgB,OAAA;EAAA;EFNxD;EEQf,OAAA;;EAEA,kBAAA;EFTA;;;;;;EEgBA,yBAAA;EFE4B;;;;EEG5B,kBAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;EAClB,MAAA,EAAQ,YAAA;AAAA;AAAA,iBA0CM,gBAAA,CAAiB,GAAA,EAAK,KAAA,EAAO,IAAA;EAAS,SAAA;AAAA,IAAuB,iBAAA;;;;;;AF7E7E;;;iBGbgB,cAAA,CAAe,GAAA,EAAK,KAAA;EAAU,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,WAAA,CAAY,cAAA;AAAA;AAAA,iBAgB5E,mBAAA,CAAoB,GAAA,EAAK,KAAA,EAAO,OAAA,EAAS,cAAA,EAAgB,IAAA;EAAS,YAAA;AAAA,IAA0B,cAAA;;iBAiB5F,iBAAA,CAAkB,GAAA,EAAK,cAAA,GAAiB,eAAA;;;UC3CvC,iBAAA;EACf,EAAA;EACA,UAAA;EACA,KAAA;EACA,KAAA;AAAA;AAAA,iBAGc,eAAA,CAAgB,CAAA,YAAa,CAAA;AAAA,iBAI7B,cAAA,CAAe,GAAA;;iBAyFf,cAAA,CAAe,CAAA,WAAY,iBAAA;;;cC9F9B,KAAA,GAAS,CAAA;;cAST,KAAA,GAAS,CAAA;;cAcT,KAAA,GAAS,CAAA;;;;;;;;;ALCtB;cK8Ba,UAAA,EAAU,MAAA;;cAIV,WAAA,EAAW,MAAA;;cAOX,UAAA,GAAc,CAAA;AAAA,cAGd,OAAA,GAAW,CAAA;;;;UC3DP,UAAA;EACf,KAAA,EAAO,MAAA;EACP,gBAAA,EAAkB,cAAA;AAAA;AAAA,UAGH,oBAAA;ENOD;EMLd,KAAA,EAAO,eAAA;ENEO;EMAd,KAAA,EAAO,UAAA;ENA0C;EMEjD,QAAA,GAAW,UAAA;ENDX;;;;;EMOA,UAAA;ENFe;;;;EMOf,SAAA;AAAA;AAAA,cAGW,kBAAA;AAAA,cACA,iBAAA;;;ANQb;;;;;;;;;;iBM8NgB,eAAA,CAAgB,KAAA,EAAO,oBAAA,GAAuB,eAAA;;iBAkC9C,uBAAA,CAAwB,IAAA,EAAM,eAAA"}
package/dist/index.mjs CHANGED
@@ -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,
@@ -1033,6 +1034,130 @@ function acfRows(value) {
1033
1034
  }))(r) : r) : value;
1034
1035
  }
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
+ /** A login rule's two addresses, and nothing else its data may hold. */
1064
+ const loginCondition = (data) => {
1065
+ if (!data || typeof data !== "object") return null;
1066
+ const d = data;
1067
+ return {
1068
+ logged_in: typeof d.logged_in === "string" ? d.logged_in : "",
1069
+ logged_out: typeof d.logged_out === "string" ? d.logged_out : ""
1070
+ };
1071
+ };
1072
+ /** By id as bytes, as the Bridge sorts (`strcmp`). */
1073
+ const order = (a, b) => (a.id ?? "") < (b.id ?? "") ? -1 : (a.id ?? "") > (b.id ?? "") ? 1 : 0;
1074
+ const on = (x) => x.enabled ?? (x.status === void 0 || x.status === "enabled");
1075
+ /** Same-site targets become root-relative, like every `from`; other hosts stay absolute (as the Bridge does). */
1076
+ function relative(url, origin) {
1077
+ try {
1078
+ const home = new URL(origin);
1079
+ const u = new URL(url, `${origin}/`);
1080
+ if (u.host.toLowerCase() !== home.host.toLowerCase()) return url;
1081
+ return `${u.pathname}${u.search}${u.hash}`;
1082
+ } catch {
1083
+ return url;
1084
+ }
1085
+ }
1086
+ /**
1087
+ * Redirection's rules and groups → the rules the site serves and the ones it does not, sorted by id.
1088
+ * A rule's query, case and trailing-slash modes are what Redirection applies to it: its own flags, else the site's
1089
+ * (`options`), else the plugin's defaults. Without `options` (not readable) only a rule's own flags are stated.
1090
+ */
1091
+ function redirectionRules(items, groups, origin, options) {
1092
+ const site = options ? {
1093
+ ...DEFAULTS,
1094
+ ...valid(options)
1095
+ } : {};
1096
+ const byId = new Map(groups.map((g) => [g.id, g]));
1097
+ const redirects = [];
1098
+ const excluded = [];
1099
+ for (const item of items) {
1100
+ const group = item.group_id === void 0 ? void 0 : byId.get(item.group_id);
1101
+ const code = Number(item.action_code ?? 301);
1102
+ const data = item.action_data;
1103
+ const target = typeof data === "string" ? data : data?.url ?? "";
1104
+ const own = item.match_data?.source ?? {};
1105
+ const flags = {
1106
+ ...site,
1107
+ ...valid(own)
1108
+ };
1109
+ const rule = {
1110
+ id: `redirection:${item.id}`,
1111
+ from: item.url ?? "",
1112
+ source: "redirection",
1113
+ match: "url",
1114
+ regex: item.regex === true || own.flag_regex === true
1115
+ };
1116
+ const served = group?.module_id !== void 0 ? MODULES[group.module_id] : void 0;
1117
+ if (served) rule.served_by = served;
1118
+ if (rule.regex) rule.query = "exact";
1119
+ else if (typeof flags.flag_query === "string") rule.query = QUERY[flags.flag_query];
1120
+ if (typeof flags.flag_case === "boolean") rule.case_insensitive = flags.flag_case;
1121
+ if (typeof flags.flag_trailing === "boolean") rule.trailing_slash = flags.flag_trailing ? "ignore" : "exact";
1122
+ const matchType = item.match_type ?? "url";
1123
+ const actionType = item.action_type ?? "url";
1124
+ if (!on(item) || group && !on(group)) excluded.push({
1125
+ ...rule,
1126
+ reason: "disabled"
1127
+ });
1128
+ else if (actionType === "error" && GONE.has(code) && matchType === "url") redirects.push({
1129
+ ...rule,
1130
+ to: "",
1131
+ status: code
1132
+ });
1133
+ else if (actionType !== "url") excluded.push({
1134
+ ...rule,
1135
+ status: code,
1136
+ reason: `not-a-redirect:${actionType}`
1137
+ });
1138
+ else if (matchType !== "url") excluded.push({
1139
+ ...rule,
1140
+ match: matchType,
1141
+ status: code,
1142
+ ...matchType === "login" ? { condition: loginCondition(data) } : {},
1143
+ reason: `conditional-match:${matchType}`
1144
+ });
1145
+ else {
1146
+ const move = code >= 300 && code < 400;
1147
+ redirects.push({
1148
+ ...rule,
1149
+ to: relative(target, origin),
1150
+ status: move ? code : 301,
1151
+ ...move ? {} : { status_note: `source status ${code} is not a redirect code; 301 assumed` }
1152
+ });
1153
+ }
1154
+ }
1155
+ return {
1156
+ redirects: redirects.toSorted(order),
1157
+ excluded: excluded.toSorted(order)
1158
+ };
1159
+ }
1160
+ //#endregion
1036
1161
  //#region src/rest-menus.ts
1037
1162
  const targetOf = (kind, object, id, url, ctx) => {
1038
1163
  const objectId = id && id > 0 ? id : null;
@@ -1071,6 +1196,12 @@ const targetOf = (kind, object, id, url, ctx) => {
1071
1196
  resolved: false
1072
1197
  };
1073
1198
  };
1199
+ /** The public address of the post or term a target names, when it is one this import proved public. */
1200
+ const publicLink = (target, ctx) => {
1201
+ if (!target.resolved || !("id" in target) || !target.id) return void 0;
1202
+ if (target.kind === "post") return ctx.postLink?.(target.id);
1203
+ if (target.kind === "term") return ctx.termLink?.(target.taxonomy, target.id);
1204
+ };
1074
1205
  /**
1075
1206
  * Items that are left out: a draft item, or one whose post target is not proven visible. Their children move up
1076
1207
  * to the nearest kept ancestor, as WordPress's walker shows them.
@@ -1091,15 +1222,17 @@ function hiddenItems(items, drop) {
1091
1222
  lift
1092
1223
  };
1093
1224
  }
1225
+ /** A host without case or a leading `www.`: example.com and www.example.com are one site (as the Bridge reads it). */
1226
+ const bareHost = (u) => u.hostname.toLowerCase().replace(/^www\./, "");
1094
1227
  /**
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.
1228
+ * The post a plain permalink names (`/?page_id=14`, `/?p=14`) on this site, `www.` or not: a custom link typed by
1229
+ * hand carries no post id, but it points at a post all the same, and its label is often that post's title.
1097
1230
  */
1098
1231
  function plainPostId(url, origin) {
1099
1232
  if (!url || !url.includes("?")) return void 0;
1100
1233
  try {
1101
1234
  const u = new URL(url, `${origin}/`);
1102
- if (u.origin !== new URL(origin).origin) return void 0;
1235
+ if (bareHost(u) !== bareHost(new URL(origin))) return void 0;
1103
1236
  const id = Number(u.searchParams.get("page_id") ?? u.searchParams.get("p"));
1104
1237
  return Number.isSafeInteger(id) && id > 0 ? id : void 0;
1105
1238
  } catch {
@@ -1126,13 +1259,15 @@ function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
1126
1259
  slug: m.slug ?? "",
1127
1260
  name: strip(m.name ?? m.slug ?? ""),
1128
1261
  items: own.map((i) => {
1262
+ const target = targetOf(i.type, i.object, i.object_id, i.url, ctx);
1263
+ const typed = target.kind === "url" ? plainPostId(i.url, ctx.origin) : void 0;
1129
1264
  const item = {
1130
1265
  id: i.id,
1131
1266
  title: i.title?.raw || i.title?.rendered || "",
1132
1267
  order: i.menu_order ?? 0,
1133
1268
  parent: lift(i.parent || null),
1134
- url: i.url || null,
1135
- target: targetOf(i.type, i.object, i.object_id, i.url, ctx),
1269
+ url: publicLink(target, ctx) ?? (typed !== void 0 ? ctx.postLink?.(typed) : void 0) ?? (i.url || null),
1270
+ target,
1136
1271
  target_attr: i.target || null,
1137
1272
  classes: (i.classes ?? []).filter(Boolean),
1138
1273
  description: i.description ?? "",
@@ -1210,13 +1345,14 @@ function blockItems(tree, ctx, nextId, dropped) {
1210
1345
  const push = (b, parent) => {
1211
1346
  const id = nextId();
1212
1347
  const url = absolute(b.url, ctx.origin);
1348
+ const target = targetOf(b.kind, b.object, b.id, url, ctx);
1213
1349
  items.push({
1214
1350
  id,
1215
1351
  title: b.title,
1216
1352
  order: items.length + 1,
1217
1353
  parent,
1218
- url: url ?? null,
1219
- target: targetOf(b.kind, b.object, b.id, url, ctx),
1354
+ url: publicLink(target, ctx) ?? (b.typed !== void 0 ? ctx.postLink?.(b.typed) : void 0) ?? url ?? null,
1355
+ target,
1220
1356
  target_attr: b.newTab ? "_blank" : null,
1221
1357
  classes: (b.className ?? "").split(/\s+/).filter(Boolean),
1222
1358
  description: b.description ?? "",
@@ -1255,6 +1391,7 @@ function blockItems(tree, ctx, nextId, dropped) {
1255
1391
  kind,
1256
1392
  object,
1257
1393
  id: typeof a.id === "number" ? a.id : void 0,
1394
+ typed: kind === "custom" ? postId : void 0,
1258
1395
  newTab: a.opensInNewTab === true,
1259
1396
  className: str(a.className),
1260
1397
  description: str(a.description)
@@ -1625,19 +1762,24 @@ async function fetchRestRawIR(options) {
1625
1762
  const extraTerms = lists.slice(0, extraTaxonomies.length);
1626
1763
  const postLists = lists.slice(extraTaxonomies.length);
1627
1764
  const termsById = /* @__PURE__ */ new Map();
1765
+ /** A term's public address, for menu items that name it; RawTerm carries none. */
1766
+ const termLinks = /* @__PURE__ */ new Map();
1628
1767
  for (const [tax, list] of [
1629
1768
  ["category", categories],
1630
1769
  ["post_tag", tags],
1631
1770
  ...extraTaxonomies.map((t, i) => [t.taxonomy, extraTerms[i] ?? []])
1632
- ]) for (const t of list) termsById.set(t.id, {
1633
- id: t.id,
1634
- taxonomy: tax,
1635
- slug: t.slug,
1636
- name: strip(t.name),
1637
- parent: t.parent ? list.find((x) => x.id === t.parent)?.slug ?? null : null,
1638
- parent_resolved: t.parent ? list.some((x) => x.id === t.parent) : null,
1639
- description: t.description ?? ""
1640
- });
1771
+ ]) for (const t of list) {
1772
+ if (t.link) termLinks.set(t.id, t.link);
1773
+ termsById.set(t.id, {
1774
+ id: t.id,
1775
+ taxonomy: tax,
1776
+ slug: t.slug,
1777
+ name: strip(t.name),
1778
+ parent: t.parent ? list.find((x) => x.id === t.parent)?.slug ?? null : null,
1779
+ parent_resolved: t.parent ? list.some((x) => x.id === t.parent) : null,
1780
+ description: t.description ?? ""
1781
+ });
1782
+ }
1641
1783
  const userById = new Map(users.map((u) => [u.id, u]));
1642
1784
  const posts = [];
1643
1785
  const languagePairs = [];
@@ -1725,6 +1867,7 @@ async function fetchRestRawIR(options) {
1725
1867
  mime: m.mime_type ?? null,
1726
1868
  parent: m.post ?? null,
1727
1869
  parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
1870
+ link: !m.post || posts.some((p) => p.id === m.post && visible(p)) ? m.link || null : null,
1728
1871
  date: iso(m.date_gmt)
1729
1872
  }));
1730
1873
  const gaps = [];
@@ -1789,6 +1932,7 @@ async function fetchRestRawIR(options) {
1789
1932
  ["navigation", navs]
1790
1933
  ]) if (!l.status || l.status >= 400 && !DENIED.has(l.status) && l.status !== 404) warnings.push(`${name}: HTTP ${l.status} — skipped`);
1791
1934
  const slugOf = new Map(posts.filter(visible).map((p) => [p.id, p.slug]));
1935
+ const linkOf = new Map(posts.filter(visible).flatMap((p) => p.link ? [[p.id, p.link]] : []));
1792
1936
  const ctx = {
1793
1937
  origin,
1794
1938
  postSlug: (id) => slugOf.get(id),
@@ -1796,6 +1940,8 @@ async function fetchRestRawIR(options) {
1796
1940
  const t = termsById.get(id);
1797
1941
  return t && t.taxonomy === taxonomy ? t.slug : void 0;
1798
1942
  },
1943
+ postLink: (id) => linkOf.get(id),
1944
+ termLink: (taxonomy, id) => termsById.get(id)?.taxonomy === taxonomy ? termLinks.get(id) : void 0,
1799
1945
  pages: posts.filter((p) => p.type === "page" && p.status === "publish" && !p.password).map((p) => ({
1800
1946
  id: p.id,
1801
1947
  parent: p.parent ?? null,
@@ -1829,6 +1975,62 @@ async function fetchRestRawIR(options) {
1829
1975
  user_id: c.author || null
1830
1976
  }));
1831
1977
  for (const c of comments) if (!postIds.has(c.post)) warnings.push(`comment ${c.id}: post ${c.post} not in fetched set`);
1978
+ let redirects = [];
1979
+ let redirectsExcluded = [];
1980
+ gaps.push("redirects_partial");
1981
+ const namespaces = Array.isArray(about?.namespaces) ? about.namespaces : void 0;
1982
+ const redirection = namespaces ? namespaces.includes("redirection/v1") : void 0;
1983
+ if (redirection && !authed) gaps.push("redirects_require_auth");
1984
+ else if (authed && redirection !== false) {
1985
+ const all = async (path) => {
1986
+ const items = [];
1987
+ const seen = /* @__PURE__ */ new Set();
1988
+ for (let page = 0; page < (maxPages ?? 500); page++) {
1989
+ const r = await schedule(() => doFetch(`${origin}/wp-json/redirection/v1/${path}?per_page=200&page=${page}`, { headers }));
1990
+ if (!r.ok) {
1991
+ await r.body?.cancel();
1992
+ return {
1993
+ items,
1994
+ status: r.status
1995
+ };
1996
+ }
1997
+ const body = await r.json();
1998
+ const fresh = (Array.isArray(body.items) ? body.items : []).filter((x) => !seen.has(x.id));
1999
+ for (const x of fresh) {
2000
+ seen.add(x.id);
2001
+ items.push(x);
2002
+ }
2003
+ if (!fresh.length || items.length >= Number(body.total ?? 0)) break;
2004
+ }
2005
+ return {
2006
+ items,
2007
+ status: 200
2008
+ };
2009
+ };
2010
+ const settings = async () => {
2011
+ try {
2012
+ const r = await schedule(() => doFetch(`${origin}/wp-json/redirection/v1/setting`, { headers }));
2013
+ if (!r.ok) {
2014
+ await r.body?.cancel();
2015
+ return;
2016
+ }
2017
+ const body = await r.json();
2018
+ return body?.settings && typeof body.settings === "object" ? body.settings : void 0;
2019
+ } catch {
2020
+ return;
2021
+ }
2022
+ };
2023
+ const [rules, groups, siteFlags] = await Promise.all([
2024
+ all("redirect"),
2025
+ all("group"),
2026
+ settings()
2027
+ ]);
2028
+ if (DENIED.has(rules.status)) {
2029
+ gaps.push("redirects_require_auth");
2030
+ warnings.push(`redirects: HTTP ${rules.status} with the credential — the user may not manage Redirection; its rules not read`);
2031
+ } else if (rules.status === 200) ({redirects, excluded: redirectsExcluded} = redirectionRules(rules.items, groups.status === 200 ? groups.items : [], origin, siteFlags));
2032
+ else if (redirection) warnings.push(`redirects: HTTP ${rules.status} — Redirection's rules not read`);
2033
+ }
1832
2034
  return {
1833
2035
  raw: {
1834
2036
  version: MIGRATION_CONTRACT_VERSION,
@@ -1856,6 +2058,8 @@ async function fetchRestRawIR(options) {
1856
2058
  attachments,
1857
2059
  comments,
1858
2060
  ...menus.length ? { menus } : {},
2061
+ ...redirects.length ? { redirects } : {},
2062
+ ...redirectsExcluded.length ? { redirects_excluded: redirectsExcluded } : {},
1859
2063
  ...languagePairs.length ? { language_pairs: languagePairs } : {}
1860
2064
  },
1861
2065
  warnings,