@nextcommerce/campaigns-os 1.61.0 → 1.62.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.
Files changed (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -0,0 +1,284 @@
1
+ // Change scope: what a change to built output owes.
2
+ //
3
+ // A change that touches only some pages' copy, images or styling owes QA on
4
+ // those pages; Polish is advisory for it. A change that touches commerce owes
5
+ // the full Polish and QA. Commerce here is what a page sends to the Campaign
6
+ // Cart SDK or the funnel: the campaign's Page Kit config entry (SDK pin,
7
+ // analytics ids, store fields), package, offer and shipping refs, the upsell
8
+ // order and routes, and scripts. A file that cannot be mapped to one page
9
+ // counts as commerce, and so does any change this module cannot classify.
10
+ //
11
+ // `record build` keeps a commerce signature beside the build manifest: one
12
+ // hash per built page over the page's commerce-bearing markup, plus one hash
13
+ // of the campaign's config entry. A later change compares the pages that
14
+ // differ against those hashes. It also keeps `build_changes`, one entry per
15
+ // recorded rebuild, so a stage that reviewed an older build can still read
16
+ // which pages changed since then after the manifest has moved on.
17
+ import { createHash } from "node:crypto";
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ import { parse } from "parse5";
22
+
23
+ import { inferPageType, pageIdForRoute, routeForFile } from "./built-site-scope.mjs";
24
+ import { loadPageKitCampaignEntry } from "./page-kit-campaign-config.mjs";
25
+
26
+ export const COMMERCE_SIGNATURE_ALGORITHM = "commerce-signature/v1";
27
+ export const CHANGE_KINDS = Object.freeze(["none", "page_only", "commerce_affecting", "unknown"]);
28
+ // The reasons a change counts as commerce, closed so readers can switch on them.
29
+ export const COMMERCE_REASONS = Object.freeze([
30
+ "unmapped_file",
31
+ "page_added",
32
+ "page_removed",
33
+ "checkout_page",
34
+ "commerce_markup_changed",
35
+ "commerce_signature_unavailable",
36
+ "campaign_config_changed",
37
+ "campaign_config_unavailable",
38
+ "change_history_unavailable",
39
+ ]);
40
+ // How many rebuilds `record build` keeps in build_changes, and how many paths
41
+ // one entry lists per list. A stage that reviewed a build older than the
42
+ // kept history reads its change as unknown, and so owes the full stages.
43
+ export const BUILD_CHANGES_LIMIT = 25;
44
+ export const CHANGE_PATH_LIMIT = 20;
45
+ // CPK page types that take payment or render the checkout surface.
46
+ const CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", "select"]);
47
+ // Attribute names that carry package, offer, shipping, upsell or checkout
48
+ // identity on built pages: data-next-*, the older os-checkout-* bindings and
49
+ // other data-* names for those refs. Inline event handlers (onclick and the
50
+ // like) can call the SDK, so they count too.
51
+ const COMMERCE_ATTRIBUTE = /^data-next-|^data-[a-z0-9-]*(?:package|offer|shipping|upsell|voucher|coupon)[a-z0-9-]*$|^data-(?:next-)?[a-z0-9-]*template-id$|^os-checkout(?:-|$)|^on[a-z]+$/;
52
+ // Attributes that render the checkout surface or take payment. A page that
53
+ // carries one is a checkout page whatever its type or route says.
54
+ const CHECKOUT_ATTRIBUTE = /^data-next-(?:checkout|payment|express-checkout)(?:-|$)|^os-checkout(?:-|$)/;
55
+
56
+ const sha256 = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
57
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
58
+
59
+ function textOf(node) {
60
+ const children = node.content ? node.content.childNodes : node.childNodes;
61
+ return (children || []).map((child) => (child.nodeName === "#text" ? child.value || "" : "")).join("");
62
+ }
63
+
64
+ // The commerce-bearing facts of one built page, in document order: every
65
+ // SDK attribute and inline event handler, every next-* meta tag, every
66
+ // script (src, or the inline body), every link target and form action. Copy,
67
+ // images and styling are left out, so a copy edit keeps the signature.
68
+ // `checkout` is true when the page carries checkout or payment markup.
69
+ export function pageCommerceFacts(html) {
70
+ const facts = [];
71
+ let pageType = null;
72
+ let checkout = false;
73
+ const walk = (node) => {
74
+ const children = node.content ? node.content.childNodes : node.childNodes;
75
+ for (const child of children || []) {
76
+ if (child.nodeName && !child.nodeName.startsWith("#")) {
77
+ const tag = child.nodeName.toLowerCase();
78
+ const attrs = new Map((child.attrs || []).map((attr) => [attr.name.toLowerCase(), attr.value]));
79
+ for (const [name, value] of attrs) {
80
+ if (COMMERCE_ATTRIBUTE.test(name)) facts.push(`${tag}[${name}]=${value}`);
81
+ if (CHECKOUT_ATTRIBUTE.test(name)) checkout = true;
82
+ }
83
+ const metaName = String(attrs.get("name") || "").trim().toLowerCase();
84
+ if (tag === "meta" && metaName.startsWith("next-")) {
85
+ facts.push(`meta[${metaName}]=${attrs.get("content") ?? ""}`);
86
+ if (metaName === "next-page-type") pageType = String(attrs.get("content") || "").trim().toLowerCase() || null;
87
+ }
88
+ if (tag === "script") facts.push(attrs.has("src") ? `script[src]=${attrs.get("src")}` : `script=${sha256(textOf(child))}`);
89
+ if (tag === "a" && attrs.has("href")) facts.push(`a[href]=${attrs.get("href")}`);
90
+ if (tag === "form" && attrs.has("action")) facts.push(`form[action]=${attrs.get("action")}`);
91
+ }
92
+ walk(child);
93
+ }
94
+ };
95
+ walk(parse(String(html)));
96
+ return { signature: sha256(facts.join("\n")), page_type: pageType, checkout };
97
+ }
98
+
99
+ // The campaign's own Page Kit config entry, hashed; a missing or unreadable
100
+ // entry is its status, so a change from present to absent still differs.
101
+ export function campaignConfigFingerprint(targetRepo, publicRouteSlug) {
102
+ const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
103
+ if (load.status !== "ok") return `unavailable:${load.status}`;
104
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical)
105
+ : isObject(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
106
+ return sha256(JSON.stringify(canonical(load.entry)));
107
+ }
108
+
109
+ /**
110
+ * The signature `record build` keeps on stages.assembly.commerce_signature:
111
+ * `{ algorithm, campaign_config, pages: { <built path>: sha256 } }` for the
112
+ * pages of a resolved built-site scope (resolveBuiltSiteScope). Null when the
113
+ * scope did not resolve; a page that cannot be read is left out, so a later
114
+ * change to it reads as unclassifiable.
115
+ */
116
+ export function buildCommerceSignature({ scope, outputRoot, targetRepo, publicRouteSlug }) {
117
+ if (!scope?.ok) return null;
118
+ const pages = {};
119
+ for (const page of scope.pages) {
120
+ const path = builtPath(outputRoot, page.built_path);
121
+ try {
122
+ pages[path] = pageCommerceFacts(readFileSync(page.built_path, "utf8")).signature;
123
+ } catch { /* left out: an unreadable page cannot be classified later */ }
124
+ }
125
+ return { algorithm: COMMERCE_SIGNATURE_ALGORITHM, campaign_config: campaignConfigFingerprint(targetRepo, publicRouteSlug), pages };
126
+ }
127
+
128
+ function builtPath(outputRoot, file) {
129
+ const relativePath = file.startsWith(outputRoot) ? file.slice(outputRoot.length).replace(/^[\\/]+/, "") : file;
130
+ return relativePath.split(/[\\/]/).join("/");
131
+ }
132
+
133
+ function recordedSignature(assembly) {
134
+ const signature = assembly?.commerce_signature;
135
+ return isObject(signature) && signature.algorithm === COMMERCE_SIGNATURE_ALGORITHM && isObject(signature.pages)
136
+ && typeof signature.campaign_config === "string" ? signature : null;
137
+ }
138
+
139
+ const capped = (items) => items.slice(0, CHANGE_PATH_LIMIT);
140
+
141
+ /**
142
+ * Classify the drift between a recorded build and the output on disk.
143
+ * `changes` is assessEvidenceDebt's known change list (changed_pages,
144
+ * unmapped_files, extra, missing). `baseline` is the recorded
145
+ * stages.assembly. Returns `{ kind, reasons }`, `kind` one of CHANGE_KINDS
146
+ * and `reasons` the commerce reasons found, each `{ code, path? }`.
147
+ */
148
+ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publicRouteSlug }) {
149
+ if (changes?.status !== "known") return { kind: "unknown", reasons: [] };
150
+ if (!changes.changed_pages.length && !changes.unmapped_files.length) return { kind: "none", reasons: [] };
151
+ const reasons = [];
152
+ for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
153
+ const signature = recordedSignature(baseline);
154
+ if (!signature) reasons.push({ code: "commerce_signature_unavailable" });
155
+ else if (signature.campaign_config.startsWith("unavailable:")
156
+ || campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config) {
157
+ reasons.push({ code: signature.campaign_config.startsWith("unavailable:") ? "campaign_config_unavailable" : "campaign_config_changed" });
158
+ }
159
+ for (const page of changes.changed_pages) {
160
+ if (changes.extra.includes(page.built_path)) { reasons.push({ code: "page_added", path: page.built_path }); continue; }
161
+ if (changes.missing.includes(page.built_path)) { reasons.push({ code: "page_removed", path: page.built_path }); continue; }
162
+ let current;
163
+ try {
164
+ current = pageCommerceFacts(readFileSync(join(outputRoot, page.built_path), "utf8"));
165
+ } catch {
166
+ reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
167
+ continue;
168
+ }
169
+ const type = current.page_type || inferPageType(routeForFile(outputRoot, join(outputRoot, page.built_path)));
170
+ if (CHECKOUT_PAGE_TYPES.has(type) || current.checkout) reasons.push({ code: "checkout_page", path: page.built_path });
171
+ else if (!signature || !Object.hasOwn(signature.pages, page.built_path)) {
172
+ if (signature) reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
173
+ } else if (signature.pages[page.built_path] !== current.signature) {
174
+ reasons.push({ code: "commerce_markup_changed", path: page.built_path });
175
+ }
176
+ }
177
+ return { kind: reasons.length ? "commerce_affecting" : "page_only", reasons: capped(reasons) };
178
+ }
179
+
180
+ /**
181
+ * One build_changes entry for a rebuild from `from` to `to`, from the debt
182
+ * assessment of the new output against the previous record.
183
+ */
184
+ export function buildChangeEntry({ from, to, at, changes, classification }) {
185
+ return {
186
+ from_build_fingerprint: from,
187
+ build_fingerprint: to,
188
+ recorded_at: at,
189
+ kind: classification.kind,
190
+ changed_pages: changes.status === "known" ? changes.changed_pages.map((page) => ({ page_id: page.page_id, built_path: page.built_path })) : [],
191
+ unmapped_files: changes.status === "known" ? capped(changes.unmapped_files) : [],
192
+ unmapped_file_count: changes.status === "known" ? changes.unmapped_files.length : 0,
193
+ reasons: classification.reasons,
194
+ };
195
+ }
196
+
197
+ function validEntry(entry) {
198
+ return isObject(entry) && typeof entry.from_build_fingerprint === "string" && typeof entry.build_fingerprint === "string"
199
+ && CHANGE_KINDS.includes(entry.kind) && Array.isArray(entry.changed_pages) && Array.isArray(entry.reasons);
200
+ }
201
+
202
+ /**
203
+ * The kept rebuild history after recording `entry`: the previous history
204
+ * with this rebuild appended, trimmed to the entries a stage anchored at one
205
+ * of `anchors` can still walk, and to BUILD_CHANGES_LIMIT.
206
+ */
207
+ export function nextBuildChanges(previous, entry, anchors = []) {
208
+ const history = [...(Array.isArray(previous) ? previous.filter(validEntry) : []), entry];
209
+ const wanted = new Set(anchors.filter((anchor) => typeof anchor === "string" && anchor));
210
+ const kept = [];
211
+ let at = entry.build_fingerprint;
212
+ for (let index = history.length - 1; index >= 0 && wanted.size; index -= 1) {
213
+ if (history[index].build_fingerprint !== at) break;
214
+ kept.unshift(history[index]);
215
+ at = history[index].from_build_fingerprint;
216
+ wanted.delete(at);
217
+ }
218
+ return kept.slice(-BUILD_CHANGES_LIMIT);
219
+ }
220
+
221
+ const KIND_RANK = Object.freeze({ none: 0, page_only: 1, commerce_affecting: 2, unknown: 3 });
222
+ // The worse of two change kinds; an unrecognized kind reads as unknown.
223
+ const rankOf = (kind) => KIND_RANK[kind] ?? KIND_RANK.unknown;
224
+ export function worseChangeKind(a, b) {
225
+ const kind = rankOf(a) >= rankOf(b) ? a : b;
226
+ return Object.hasOwn(KIND_RANK, kind) ? kind : "unknown";
227
+ }
228
+
229
+ /**
230
+ * The change a stage that reviewed `anchor` owes for: the recorded rebuilds
231
+ * from `anchor` to the recorded build, then the drift from the recorded build
232
+ * to the output on disk. Returns `{ kind, since_build_fingerprint,
233
+ * changed_pages, unmapped_files, reasons }`; `kind` is unknown when the
234
+ * history does not reach `anchor` or the drift cannot be read.
235
+ */
236
+ export function changeSince(anchor, { assembly, drift, driftClassification }) {
237
+ const recorded = typeof assembly?.build_fingerprint === "string" ? assembly.build_fingerprint : null;
238
+ const result = { kind: "none", since_build_fingerprint: anchor || null, changed_pages: [], unmapped_files: [], reasons: [] };
239
+ if (!anchor || !recorded) return { ...result, kind: "unknown" };
240
+ const segments = [];
241
+ if (anchor !== recorded) {
242
+ const history = Array.isArray(assembly.build_changes) ? assembly.build_changes.filter(validEntry) : [];
243
+ let at = recorded;
244
+ for (let index = history.length - 1; index >= 0 && at !== anchor; index -= 1) {
245
+ if (history[index].build_fingerprint !== at) break;
246
+ segments.unshift(history[index]);
247
+ at = history[index].from_build_fingerprint;
248
+ }
249
+ if (at !== anchor) return { ...result, kind: "unknown", reasons: [{ code: "change_history_unavailable" }] };
250
+ }
251
+ const pages = new Map();
252
+ const unmapped = new Set();
253
+ let kind = "none";
254
+ const add = (part, partKind, reasons) => {
255
+ kind = worseChangeKind(kind, partKind);
256
+ for (const page of part.changed_pages || []) pages.set(page.built_path, { page_id: page.page_id, built_path: page.built_path });
257
+ for (const path of part.unmapped_files || []) unmapped.add(path);
258
+ result.reasons.push(...(reasons || []));
259
+ };
260
+ for (const segment of segments) add(segment, segment.kind, segment.reasons);
261
+ if (drift?.status !== "known") kind = "unknown";
262
+ else add(drift, driftClassification?.kind || "unknown", driftClassification?.reasons);
263
+ return {
264
+ ...result,
265
+ kind,
266
+ changed_pages: [...pages.values()].sort((a, b) => a.built_path.localeCompare(b.built_path)),
267
+ unmapped_files: capped([...unmapped].sort()),
268
+ reasons: capped(result.reasons),
269
+ };
270
+ }
271
+
272
+ // The page id a QA topology page has in the change lists: the id of its
273
+ // route under the campaign base, as built-site scope derives it.
274
+ export function routePageIdForUrl(url, baseUrl) {
275
+ try {
276
+ const base = new URL(baseUrl).pathname.replace(/\/+$/, "");
277
+ const path = new URL(url).pathname;
278
+ const route = (path.startsWith(`${base}/`) || path === base ? path.slice(base.length) : path)
279
+ .replace(/^\/+/, "").replace(/(?:^|\/)index\.html$/, "").replace(/\.html$/i, "").replace(/\/+$/, "");
280
+ return pageIdForRoute(route);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }