@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,627 @@
1
+ import { basename, extname, join, resolve } from "node:path";
2
+ import {
3
+ readSourceHtmlManifestFile,
4
+ } from "./source-html-manifest.mjs";
5
+ // Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
6
+ // so build-time wiring and spec-time analysis share one edge resolver.
7
+ import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
8
+ import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
9
+
10
+ const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
11
+
12
+ export function createSourceHtmlIntake({
13
+ sourceRoot,
14
+ specPages,
15
+ htmlFiles,
16
+ publicRouteSlug,
17
+ outputDir,
18
+ buildScope = null,
19
+ manifestPath = null,
20
+ templateFamily = null,
21
+ }) {
22
+ const manifestResult = readSourceHtmlManifestFile(sourceRoot, { manifestPath });
23
+ const scopeOptions = { buildScope, templateFamily };
24
+ const matched = manifestResult.manifest
25
+ ? applyManifestToPages(specPages, manifestResult.manifest, manifestResult.path, scopeOptions)
26
+ : matchSourcePages(specPages, htmlFiles, scopeOptions);
27
+ const pageById = new Map((specPages || []).map((page) => [page.id, page]));
28
+ const projectionDecisions = [];
29
+
30
+ const mappings = matched.mappings.map((mapping) => {
31
+ const specPage = pageById.get(mapping.page_id);
32
+ if (!specPage || !mapping.path) return mapping;
33
+ const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
34
+ projectionDecisions.push({
35
+ id: `dec_page_kit_target_${specPage.id}`,
36
+ stage: "prepare_build",
37
+ decision_type: "deterministic_derivation",
38
+ decision: `projected CampaignSpec page "${specPage.id}" to Page Kit target "${pageKit.target_path}" with CPK page_type "${pageKit.page_type}"`,
39
+ confidence: "high",
40
+ evidence: [
41
+ `source path "${mapping.path}" remains producer provenance`,
42
+ `target path "${pageKit.output_path}" derives from CampaignSpec route "${pageKit.spec_route || "(entry route)"}" and public slug "${publicRouteSlug}"`,
43
+ ],
44
+ });
45
+ return { ...mapping, page_kit: pageKit };
46
+ });
47
+
48
+ const targetPrompts = pageKitTargetPrompts(mappings);
49
+
50
+ return {
51
+ manifestResult,
52
+ manifestWarnings: [
53
+ ...(manifestResult.warning ? [manifestResult.warning] : []),
54
+ ...(manifestResult.warnings || []),
55
+ ],
56
+ mappings,
57
+ prompts: [...matched.prompts, ...targetPrompts],
58
+ decisions: [...matched.decisions, ...projectionDecisions],
59
+ ambiguousCandidates: matched.ambiguousCandidates || [],
60
+ manifestDraft: matched.manifestDraft || null,
61
+ declaredSkips: matched.declaredSkips || [],
62
+ };
63
+ }
64
+
65
+ // A partial-source build is the ordinary shape of a template-family campaign:
66
+ // a few designed pages carry prepared source HTML, the rest assemble from the
67
+ // certified family. Two explicit declarations turn a missing source file from
68
+ // a blocker into a recorded out-of-scope page:
69
+ // 1. a source-html manifest entry with skip_reason instead of path (per-page), or
70
+ // 2. CampaignSpec build_scope.mode "partial" (blanket, for pages with no
71
+ // manifest entry and no design_source).
72
+ // A page that declares design_source still blocks without a per-page skip
73
+ // entry — it was produced to have source HTML, so its absence stays an error.
74
+ // Full/undeclared scope keeps today's blocking behavior exactly.
75
+ function declaredPartialScope(buildScope) {
76
+ return buildScope?.mode === "partial";
77
+ }
78
+
79
+ function buildScopeSkipReason(buildScope) {
80
+ const reasons = Array.isArray(buildScope?.reasons) ? buildScope.reasons.filter(isNonEmptyString).map((reason) => reason.trim()) : [];
81
+ const base = 'Declared out of source scope by CampaignSpec build_scope (mode "partial"); the page assembles from the selected template family.';
82
+ return reasons.length ? `${base} Reasons: ${reasons.join(" ")}` : base;
83
+ }
84
+
85
+ // A declared out-of-scope page is template stock: the family's own page is
86
+ // the design, so the decision carries `template_stock: true` and the family
87
+ // the page assembles from. The Design Source Package reads the same marker
88
+ // (through prepare-build's templateStockPageIds) so intake never asks for a
89
+ // design source the page cannot have, and the build stage materialises the
90
+ // page from that family's stock page.
91
+ function declaredScopeSkip(page, { skipEntry = null, buildScope = null, manifestPath = null, templateFamily = null }) {
92
+ // Mirror the validator's contract (skip_reason is a non-empty string) rather
93
+ // than assuming it: a malformed entry falls back to the build_scope text
94
+ // instead of throwing mid-intake.
95
+ const skipReason = optionalString(skipEntry?.skip_reason) || buildScopeSkipReason(buildScope);
96
+ const family = optionalString(templateFamily);
97
+ const familyLabel = family ? `the locked ${family} family` : "the selected template family";
98
+ return {
99
+ mapping: { page_id: page.id, skip_reason: skipReason },
100
+ declaredSkip: {
101
+ page_id: page.id,
102
+ skip_reason: skipReason,
103
+ declared_by: skipEntry ? "source_html_manifest" : "campaign_spec_build_scope",
104
+ },
105
+ decision: {
106
+ id: `dec_page_scope_${page.id}`,
107
+ stage: "prepare_build",
108
+ decision_type: "deterministic_derivation",
109
+ decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); the build stage materialises the page from ${familyLabel}'s stock page, and intake demands no design source for it`,
110
+ confidence: "high",
111
+ template_stock: true,
112
+ template_family: family,
113
+ evidence: [
114
+ skipEntry
115
+ ? `source-html manifest entry for "${page.id}" at ${manifestPath} declares skip_reason without a path`
116
+ : `CampaignSpec build_scope.mode is "partial" and page "${page.id}" has no bound source HTML and no design_source`,
117
+ ],
118
+ },
119
+ };
120
+ }
121
+
122
+ export function publicRouteForPage(page) {
123
+ if (isNonEmptyString(page?.page_url)) return pageRouteForPageKit(page.page_url);
124
+ if (isNonEmptyString(page?.url)) return pageRouteForPageKit(page.url);
125
+ if (page?.is_entry) return "";
126
+ return defaultRouteForSpecType(page?.type);
127
+ }
128
+
129
+ function pageRouteForPageKit(value) {
130
+ const raw = String(value || "").trim();
131
+ if (!raw) return "";
132
+ if (!isAbsoluteHttpUrl(raw)) return normalizePageKitRoute(raw);
133
+ try {
134
+ const url = new URL(raw);
135
+ return normalizePageKitRoute(url.pathname);
136
+ } catch {
137
+ return normalizePageKitRoute(raw);
138
+ }
139
+ }
140
+
141
+ function applyManifestToPages(specPages, manifest, manifestPath, { buildScope = null, templateFamily = null } = {}) {
142
+ const mappings = [];
143
+ const prompts = [];
144
+ const decisions = [];
145
+ const declaredSkips = [];
146
+ const manifestPages = Array.isArray(manifest.pages) ? manifest.pages : [];
147
+ const byPageId = new Map();
148
+ for (const entry of manifestPages) {
149
+ if (!entry || !isNonEmptyString(entry.page_id)) continue;
150
+ if (byPageId.has(entry.page_id)) {
151
+ const firstEntry = byPageId.get(entry.page_id);
152
+ prompts.push({
153
+ code: "MANIFEST_DUPLICATE_PAGE",
154
+ stage: "prepare_build",
155
+ message: `Source-html manifest has more than one entry for page_id "${entry.page_id}" (first path "${firstEntry.path || ""}", duplicate path "${entry.path || ""}"). Only the first entry is used. Deduplicate the manifest before build.`,
156
+ page_id: entry.page_id,
157
+ });
158
+ continue;
159
+ }
160
+ byPageId.set(entry.page_id, entry);
161
+ }
162
+
163
+ const byPageUrl = new Map();
164
+ const typeBuckets = new Map();
165
+ for (const entry of manifestPages) {
166
+ if (!entry || !isNonEmptyString(entry.path)) continue;
167
+ const url = optionalString(entry.page_url);
168
+ if (url !== null) {
169
+ const norm = pageRouteForPageKit(url);
170
+ const firstEntry = byPageUrl.get(norm);
171
+ if (firstEntry) {
172
+ prompts.push({
173
+ code: "MANIFEST_DUPLICATE_PAGE_URL",
174
+ stage: "prepare_build",
175
+ message: `Source-html manifest has more than one entry for page_url "${url}" (first path "${firstEntry.path || ""}", duplicate path "${entry.path || ""}"). Only the first entry is used for page_url matching. Deduplicate page_url values before build.`,
176
+ page_url: norm,
177
+ });
178
+ } else {
179
+ byPageUrl.set(norm, entry);
180
+ }
181
+ }
182
+ const t = optionalString(entry.page_type);
183
+ if (t) {
184
+ if (!typeBuckets.has(t)) typeBuckets.set(t, []);
185
+ typeBuckets.get(t).push(entry);
186
+ }
187
+ }
188
+
189
+ const typeOrdinals = new Map();
190
+ const typeSeen = new Map();
191
+ for (const page of specPages) {
192
+ const t = page.type || "page";
193
+ const n = (typeSeen.get(t) || 0) + 1;
194
+ typeSeen.set(t, n);
195
+ typeOrdinals.set(page.id, n);
196
+ }
197
+ const usedEntries = new Set();
198
+ const matchedIds = new Set();
199
+
200
+ for (const page of specPages) {
201
+ let entry = byPageId.get(page.id);
202
+ let matchVia = "page_id";
203
+ if (!(entry && isNonEmptyString(entry.path))) {
204
+ const norm = pageRouteForPageKit(optionalString(page.page_url) || optionalString(page.url) || "");
205
+ const urlEntry = byPageUrl.get(norm);
206
+ if (urlEntry && isNonEmptyString(urlEntry.path) && !usedEntries.has(urlEntry)) {
207
+ entry = urlEntry;
208
+ matchVia = "page_url";
209
+ } else {
210
+ const bucket = typeBuckets.get(page.type) || [];
211
+ const ordinal = typeOrdinals.get(page.id) || 1;
212
+ const typeEntry = bucket[ordinal - 1];
213
+ if (typeEntry && isNonEmptyString(typeEntry.path) && !usedEntries.has(typeEntry)) {
214
+ entry = typeEntry;
215
+ matchVia = "page_type+ordinal";
216
+ }
217
+ }
218
+ }
219
+ if (entry && isNonEmptyString(entry.path)) {
220
+ usedEntries.add(entry);
221
+ const mapping = { page_id: page.id, path: entry.path };
222
+ if (isNonEmptyString(entry.page_type)) mapping.page_type = entry.page_type;
223
+ addSpecHints(mapping, page);
224
+ const sourceHash = optionalString(entry.source_hash);
225
+ if (sourceHash) mapping.source_hash = sourceHash;
226
+ mappings.push(mapping);
227
+ matchedIds.add(page.id);
228
+ decisions.push({
229
+ id: `dec_page_map_${page.id}`,
230
+ stage: "prepare_build",
231
+ decision_type: "deterministic_derivation",
232
+ decision: `mapped CampaignSpec page "${page.id}" to source file "${entry.path}" via source-html manifest (matched by ${matchVia})`,
233
+ confidence: matchVia === "page_id" ? "high" : "medium",
234
+ evidence: [`source-html manifest entry matched page "${page.id}" by ${matchVia}; path="${entry.path}" from ${manifestPath}`],
235
+ });
236
+ } else {
237
+ const skipEntry = entry && !isNonEmptyString(entry.path) && isNonEmptyString(entry.skip_reason) ? entry : null;
238
+ if (skipEntry || (declaredPartialScope(buildScope) && !isObject(page.design_source))) {
239
+ const declared = declaredScopeSkip(page, { skipEntry, buildScope, manifestPath, templateFamily });
240
+ if (skipEntry) usedEntries.add(skipEntry);
241
+ matchedIds.add(page.id);
242
+ mappings.push(declared.mapping);
243
+ declaredSkips.push(declared.declaredSkip);
244
+ decisions.push(declared.decision);
245
+ continue;
246
+ }
247
+ mappings.push({
248
+ page_id: page.id,
249
+ skip_reason: `No entry for "${page.id}" in source-html manifest at ${manifestPath}; add this page_id to the manifest (with a path, or a skip_reason to declare it out of source scope), set CampaignSpec build_scope.mode to "partial" for template-derived pages, or remove the manifest to fall back to filesystem matching.`,
250
+ });
251
+ prompts.push({
252
+ code: "MISSING_SOURCE_PAGE",
253
+ stage: "prepare_build",
254
+ message: `Active CampaignSpec page "${page.id}" has no matching HTML file (source-html manifest did not list it).`,
255
+ page_id: page.id,
256
+ });
257
+ }
258
+ }
259
+
260
+ for (const entry of manifestPages) {
261
+ if (!entry || !isNonEmptyString(entry.page_id)) continue;
262
+ if (matchedIds.has(entry.page_id)) continue;
263
+ if (specPages.some((page) => page.id === entry.page_id)) continue;
264
+ if (usedEntries.has(entry)) continue;
265
+ prompts.push({
266
+ code: "MANIFEST_EXTRA_PAGE",
267
+ stage: "prepare_build",
268
+ message: `Source-html manifest lists page_id "${entry.page_id}" (path "${entry.path || ""}") which is not an active CampaignSpec page. Reconcile the manifest or the spec before build.`,
269
+ page_id: entry.page_id,
270
+ });
271
+ }
272
+
273
+ return { mappings, prompts, decisions, declaredSkips };
274
+ }
275
+
276
+ function matchSourcePages(specPages, htmlFiles, { buildScope = null, templateFamily = null } = {}) {
277
+ const usedByPageId = new Map();
278
+ const mappings = [];
279
+ const prompts = [];
280
+ const decisions = [];
281
+ const declaredSkips = [];
282
+ const ambiguousCandidates = [];
283
+ const counts = new Map();
284
+ const ordinals = new Map();
285
+
286
+ for (const page of specPages) {
287
+ const key = page.type || "page";
288
+ const next = (counts.get(key) || 0) + 1;
289
+ counts.set(key, next);
290
+ ordinals.set(page.id, next);
291
+ }
292
+
293
+ for (const page of specPages) {
294
+ const keys = pageMatchKeys(page, ordinals.get(page.id));
295
+ const candidates = htmlFiles
296
+ .map((file) => ({
297
+ ...file,
298
+ matched_keys: keys.filter((key) => key && key === slugify(file.basename)),
299
+ }))
300
+ .filter((file) => file.matched_keys.length > 0);
301
+ const unused = candidates.filter((file) => !usedByPageId.has(file.path));
302
+ const match = unused.length === 1 ? unused[0] : null;
303
+ if (unused.length > 1) {
304
+ const candidateEntries = candidateEntriesForPrompt(unused);
305
+ ambiguousCandidates.push({
306
+ page_id: page.id,
307
+ page_type: page.type || null,
308
+ match_keys: keys,
309
+ candidates: candidateEntries,
310
+ });
311
+ mappings.push({
312
+ page_id: page.id,
313
+ skip_reason: `Ambiguous source HTML candidates found: ${candidateEntries.map((candidate) => candidate.path).join(", ")}. Add .campaigns-os/source-html-manifest.json to bind this page explicitly before build.`,
314
+ });
315
+ prompts.push({
316
+ code: "AMBIGUOUS_SOURCE_PAGE",
317
+ stage: "prepare_build",
318
+ message: `Active CampaignSpec page "${page.id}" has multiple matching HTML files. Add a source-html manifest entry for this page before build.`,
319
+ page_id: page.id,
320
+ detail: {
321
+ match_keys: keys,
322
+ candidates: candidateEntries,
323
+ manifest_entry: manifestEntryDraft(page, candidateEntries[0]?.path || ""),
324
+ },
325
+ });
326
+ } else if (match) {
327
+ usedByPageId.set(match.path, page.id);
328
+ const mapping = { page_id: page.id, path: match.path };
329
+ addSpecHints(mapping, page);
330
+ mappings.push(mapping);
331
+ decisions.push({
332
+ id: `dec_page_map_${page.id}`,
333
+ stage: "prepare_build",
334
+ decision_type: "deterministic_derivation",
335
+ decision: `mapped CampaignSpec page "${page.id}" to source file "${match.path}"`,
336
+ confidence: candidates.length === 1 ? "high" : "medium",
337
+ evidence: [`matched source filename against page keys: ${keys.join(", ")}`],
338
+ });
339
+ } else if (candidates.length > 0) {
340
+ const candidateEntries = candidateEntriesForPrompt(candidates, usedByPageId);
341
+ ambiguousCandidates.push({
342
+ page_id: page.id,
343
+ page_type: page.type || null,
344
+ match_keys: keys,
345
+ candidates: candidateEntries,
346
+ });
347
+ mappings.push({
348
+ page_id: page.id,
349
+ skip_reason: `Matching source HTML candidates were already assigned to other pages: ${candidateEntries.map((candidate) => `${candidate.path}${candidate.used_by_page_id ? ` used by ${candidate.used_by_page_id}` : ""}`).join(", ")}. Add .campaigns-os/source-html-manifest.json to bind this page explicitly before build.`,
350
+ });
351
+ prompts.push({
352
+ code: "AMBIGUOUS_SOURCE_PAGE",
353
+ stage: "prepare_build",
354
+ message: `Active CampaignSpec page "${page.id}" only matched HTML files already assigned to another page. Add a source-html manifest entry for this page before build.`,
355
+ page_id: page.id,
356
+ detail: {
357
+ match_keys: keys,
358
+ candidates: candidateEntries,
359
+ manifest_entry: { ...manifestEntryDraft(page, ""), path_conflicts: true },
360
+ },
361
+ });
362
+ } else {
363
+ const hasDesignSource = isObject(page.design_source);
364
+ if (declaredPartialScope(buildScope) && !hasDesignSource) {
365
+ const declared = declaredScopeSkip(page, { buildScope, templateFamily });
366
+ mappings.push(declared.mapping);
367
+ declaredSkips.push(declared.declaredSkip);
368
+ decisions.push(declared.decision);
369
+ continue;
370
+ }
371
+ if (!hasDesignSource) {
372
+ mappings.push({ page_id: page.id, skip_reason: "No matching source HTML file found; provide a source file or an explicit skip reason before build." });
373
+ }
374
+ prompts.push({
375
+ code: "MISSING_SOURCE_PAGE",
376
+ stage: "prepare_build",
377
+ message: `Active CampaignSpec page "${page.id}" has no matching HTML file.`,
378
+ page_id: page.id,
379
+ detail: {
380
+ match_keys: keys,
381
+ manifest_entry: manifestEntryDraft(page, ""),
382
+ },
383
+ });
384
+ }
385
+ }
386
+
387
+ return {
388
+ mappings,
389
+ prompts,
390
+ decisions,
391
+ declaredSkips,
392
+ ambiguousCandidates,
393
+ manifestDraft: ambiguousCandidates.length > 0
394
+ ? createManifestDraft(specPages, mappings, ambiguousCandidates)
395
+ : null,
396
+ };
397
+ }
398
+
399
+ function candidateEntriesForPrompt(candidates, usedByPageId = null) {
400
+ return candidates.map((candidate) => ({
401
+ path: candidate.path,
402
+ bytes: candidate.bytes ?? null,
403
+ sha256: candidate.sha256 || null,
404
+ matched_keys: candidate.matched_keys || [],
405
+ ...(usedByPageId?.has(candidate.path) ? { used_by_page_id: usedByPageId.get(candidate.path) } : {}),
406
+ }));
407
+ }
408
+
409
+ function manifestEntryDraft(page, path) {
410
+ const entry = {
411
+ page_id: page.id,
412
+ path,
413
+ };
414
+ if (isNonEmptyString(page.type)) entry.page_type = page.type;
415
+ const pageUrl = optionalString(page.page_url) || optionalString(page.url);
416
+ if (pageUrl) entry.page_url = pageUrl;
417
+ return entry;
418
+ }
419
+
420
+ function createManifestDraft(specPages, mappings, ambiguousCandidates) {
421
+ const mappingByPageId = new Map(mappings.map((mapping) => [mapping.page_id, mapping]));
422
+ const ambiguousByPageId = new Map(ambiguousCandidates.map((entry) => [entry.page_id, entry]));
423
+ return {
424
+ schema_version: "source-html-manifest/v0",
425
+ generator: "campaigns-os@prepare-build-draft",
426
+ pages: specPages
427
+ .map((page) => {
428
+ const mapping = mappingByPageId.get(page.id);
429
+ const ambiguous = ambiguousByPageId.get(page.id);
430
+ const path = mapping?.path || ambiguous?.candidates?.[0]?.path || "";
431
+ return path ? manifestEntryDraft(page, path) : null;
432
+ })
433
+ .filter(Boolean),
434
+ };
435
+ }
436
+
437
+ function addSpecHints(mapping, page) {
438
+ const upsellPattern = optionalString(page.upsell_template_pattern);
439
+ if (upsellPattern) mapping.upsell_template_pattern = upsellPattern;
440
+ const mvTiers = normalizedMvTiers(page.upsell_mv_tiers);
441
+ if (mvTiers) mapping.upsell_mv_tiers = mvTiers;
442
+ const variantLabels = normalizedVariantLabels(page.variant_labels);
443
+ if (variantLabels) mapping.variant_labels = variantLabels;
444
+ }
445
+
446
+ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
447
+ const specRoute = publicRouteForPage(page);
448
+ const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
449
+ const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
450
+ const targetPath = targetPagePathForRoute(relativeRoute, page);
451
+ const defaultRoute = defaultPublicRouteForTargetPath(targetPath, publicRouteSlug);
452
+ const permalinkRequired = normalizeRootedRoute(publicRoute) !== normalizeRootedRoute(defaultRoute);
453
+ const pageType = cpkPageTypeForSpecType(page.type);
454
+ const frontmatter = { page_type: pageType };
455
+ if (permalinkRequired) frontmatter.permalink = publicRoute;
456
+
457
+ const nextUrl = nextUrlForPage(page, pageById, publicRouteSlug);
458
+ if (nextUrl) frontmatter.next_url = nextUrl;
459
+ const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
460
+ if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
461
+
462
+ return {
463
+ target_path: targetPath,
464
+ output_path: join(outputDir, targetPath),
465
+ public_route: publicRoute,
466
+ spec_route: relativeRoute,
467
+ page_type: pageType,
468
+ permalink_required: permalinkRequired,
469
+ frontmatter,
470
+ };
471
+ }
472
+
473
+ function pageKitTargetPrompts(mappings) {
474
+ const prompts = [];
475
+ const byOutputPath = new Map();
476
+ for (const mapping of mappings) {
477
+ const outputPath = mapping.page_kit?.output_path;
478
+ if (!outputPath) continue;
479
+ const existing = byOutputPath.get(outputPath);
480
+ if (existing) {
481
+ prompts.push({
482
+ code: "PAGE_KIT_TARGET_CONFLICT",
483
+ stage: "prepare_build",
484
+ message: `CampaignSpec pages "${existing.page_id}" and "${mapping.page_id}" both project to Page Kit target "${outputPath}". Give one page a distinct route before build.`,
485
+ page_id: mapping.page_id,
486
+ detail: {
487
+ output_path: outputPath,
488
+ existing_page_id: existing.page_id,
489
+ conflicting_page_id: mapping.page_id,
490
+ },
491
+ });
492
+ } else {
493
+ byOutputPath.set(outputPath, mapping);
494
+ }
495
+ }
496
+ return prompts;
497
+ }
498
+
499
+ function targetPagePathForRoute(relativeRoute, page) {
500
+ const route = normalizePageKitRoute(relativeRoute || defaultRouteForSpecType(page.type));
501
+ const segments = route.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
502
+ if (segments.length === 0) return "index.html";
503
+ return `${segments[segments.length - 1]}.html`;
504
+ }
505
+
506
+ function defaultPublicRouteForTargetPath(targetPath, publicRouteSlug) {
507
+ const filename = basename(targetPath, extname(targetPath));
508
+ if (filename === "index") return `/${normalizePublicRouteSlug(publicRouteSlug)}/`;
509
+ return `/${normalizePublicRouteSlug(publicRouteSlug)}/${filename}/`;
510
+ }
511
+
512
+ // Forward and decline links come from campaign-spec/routing.ts, the single
513
+ // source of truth every consumer shares — see that module for why the forward
514
+ // link is a first-match precedence while cycle detection takes the full edge
515
+ // set. This used to be a page-type switch that silently discarded any edge
516
+ // declared outside its table: twelve checkout-typed pages across ten
517
+ // certified fixtures route through next_page, and every one of them built with
518
+ // no next_url at all.
519
+ function nextUrlForPage(page, pageById, publicRouteSlug) {
520
+ return pageKitFlowUrl(forwardRouteTarget(page), pageById, publicRouteSlug);
521
+ }
522
+
523
+ function declineUrlForPage(page, pageById, publicRouteSlug) {
524
+ return pageKitFlowUrl(declineRouteTarget(page), pageById, publicRouteSlug);
525
+ }
526
+
527
+ function pageKitFlowUrl(value, pageById, publicRouteSlug) {
528
+ if (!isNonEmptyString(value)) return null;
529
+ const raw = value.trim();
530
+ if (raw.startsWith("#") || isAbsoluteHttpUrl(raw)) return raw;
531
+ const referencedPage = pageById.get(raw);
532
+ const route = referencedPage ? publicRouteForPage(referencedPage) : raw;
533
+ return rootedCampaignRoute(stripPublicRoutePrefix(route, publicRouteSlug), publicRouteSlug);
534
+ }
535
+
536
+ function rootedCampaignRoute(route, publicRouteSlug) {
537
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
538
+ const relativeRoute = stripPublicRoutePrefix(route, slug);
539
+ return relativeRoute ? `/${slug}/${relativeRoute}` : `/${slug}/`;
540
+ }
541
+
542
+ function normalizeRootedRoute(value) {
543
+ const normalized = normalizePageKitRoute(value);
544
+ return normalized ? `/${normalized}` : "/";
545
+ }
546
+
547
+ function cpkPageTypeForSpecType(type) {
548
+ if (type === "presell" || type === "landing") return "product";
549
+ if (type === "thankyou" || type === "receipt") return "receipt";
550
+ if (type === "upsell" || type === "downsell") return "upsell";
551
+ if (type === "checkout" || type === "select") return "checkout";
552
+ return CPK_PAGE_TYPES.has(type) ? type : "product";
553
+ }
554
+
555
+ function defaultRouteForSpecType(type) {
556
+ if (type === "thankyou" || type === "receipt") return "receipt/";
557
+ if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
558
+ return `${type || "page"}/`;
559
+ }
560
+
561
+ function pageMatchKeys(page, ordinal) {
562
+ const keys = new Set([
563
+ slugify(page.id),
564
+ slugify(page.label),
565
+ slugify(page.type),
566
+ ].filter(Boolean));
567
+ const sourceUrl = optionalString(page.page_url) || optionalString(page.url);
568
+ if (sourceUrl) {
569
+ const clean = sourceUrl.replace(/[?#].*$/, "").replace(/^\/+|\/+$/g, "");
570
+ if (clean) {
571
+ keys.add(slugify(clean));
572
+ keys.add(slugify(basename(clean, extname(clean))));
573
+ }
574
+ }
575
+ if (ordinal && page.type) keys.add(slugify(`${page.type}-${ordinal}`));
576
+ if (page.type === "thankyou") {
577
+ keys.add("receipt");
578
+ keys.add("thank-you");
579
+ keys.add("thankyou");
580
+ }
581
+ if (page.type === "landing" || page.type === "presell") keys.add("index");
582
+ if (page.type === "checkout") keys.add("checkout");
583
+ if (page.type === "upsell") keys.add("upsell");
584
+ if (page.type === "downsell") keys.add("downsell");
585
+ return [...keys];
586
+ }
587
+
588
+ function normalizedMvTiers(value) {
589
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
590
+ const { min, max } = value;
591
+ if (!Number.isInteger(min) || !Number.isInteger(max)) return null;
592
+ if (min < 1 || max < 1) return null;
593
+ if (min > max) return null;
594
+ return { min, max };
595
+ }
596
+
597
+ function normalizedVariantLabels(value) {
598
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
599
+ const primary = isNonEmptyString(value.primary) ? value.primary.trim() : null;
600
+ if (!primary) return null;
601
+ const out = { primary };
602
+ if (isNonEmptyString(value.secondary)) {
603
+ out.secondary = value.secondary.trim();
604
+ }
605
+ return out;
606
+ }
607
+
608
+ function slugify(value) {
609
+ return String(value || "")
610
+ .trim()
611
+ .toLowerCase()
612
+ .replace(/['"]/g, "")
613
+ .replace(/[^a-z0-9]+/g, "-")
614
+ .replace(/^-+|-+$/g, "");
615
+ }
616
+
617
+ function isObject(value) {
618
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
619
+ }
620
+
621
+ function isNonEmptyString(value) {
622
+ return typeof value === "string" && value.trim().length > 0;
623
+ }
624
+
625
+ function optionalString(value, fallback = null) {
626
+ return isNonEmptyString(value) ? value.trim() : fallback;
627
+ }