@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.
- package/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// `campaigns-os spec derive`: write the fields the target repo already states
|
|
2
|
+
// into the packet's local CampaignSpec.
|
|
3
|
+
//
|
|
4
|
+
// Every field in a CampaignSpec has a class (#432): authored (a human writes
|
|
5
|
+
// it), mirrored (pulled from the Campaigns API or the store) or derived (the
|
|
6
|
+
// repo or the store already states it). Derived fields are generated, never
|
|
7
|
+
// typed, and this command is the generator for the repo-derived ones: the
|
|
8
|
+
// SDK pin from `_data/campaigns.json[<route>].sdk_version`, each page's public
|
|
9
|
+
// route from the page tree under `src/<route>/`, and the analytics ids the
|
|
10
|
+
// entry carries (`gtm_id`, `fb_pixel_id`). Doctor then compares generated
|
|
11
|
+
// against generated instead of refereeing a human's typing against the repo.
|
|
12
|
+
//
|
|
13
|
+
// The repo is the authority for exactly those fields. A spec value that looks
|
|
14
|
+
// authored but sits in a derived field is overwritten, with the before -> after
|
|
15
|
+
// line showing it; nothing outside the derived fields is written, ever.
|
|
16
|
+
// The store-derived fields (the nine campaign.store_* Store Profile fields,
|
|
17
|
+
// authority: the store's Admin API) are planned by spec-derive-store.mjs
|
|
18
|
+
// behind --from-store; their rows are applied here under the same guard.
|
|
19
|
+
//
|
|
20
|
+
// This module is pure: the CLI reads the packet, the spec, the entry and the
|
|
21
|
+
// page tree, and does the writing and the printing.
|
|
22
|
+
import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
|
|
23
|
+
import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
|
|
24
|
+
import { PAGE_KIT_STORE_PROFILE_FIELDS } from "./page-kit-store-profile.mjs";
|
|
25
|
+
import { compareReleasedSdkVersions, entryInScaffoldState, resolveSpecSdkPin } from "./page-kit-sdk-version.mjs";
|
|
26
|
+
import { isAbsoluteHttpUrl, normalizePageKitRoute, runtimeRelativeRouteForSpecValue, stripPublicRoutePrefix } from "./route-identity.mjs";
|
|
27
|
+
import { publicRouteForPage } from "./source-html-intake.mjs";
|
|
28
|
+
|
|
29
|
+
// The spec fields this command may write, as path patterns. `applySpecDerive`
|
|
30
|
+
// refuses any change whose path matches none of them, so a plan row can never
|
|
31
|
+
// reach an authored field.
|
|
32
|
+
export const SPEC_DERIVE_FIELDS = Object.freeze([
|
|
33
|
+
"global_config.sdk_version",
|
|
34
|
+
"runtime.sdk_version",
|
|
35
|
+
"funnels[].pages[].page_url",
|
|
36
|
+
"funnel_pages[].page_url",
|
|
37
|
+
"analytics.providers.gtm.containerId",
|
|
38
|
+
"analytics.providers.facebook.pixelId",
|
|
39
|
+
...PAGE_KIT_STORE_PROFILE_FIELDS.map((field) => `campaign.${field}`),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// campaigns.json analytics keys and the spec provider field each one derives.
|
|
43
|
+
export const ANALYTICS_ID_FIELDS = Object.freeze([
|
|
44
|
+
Object.freeze({ key: "gtm_id", provider: "gtm", property: "containerId", shape: /^GTM-[A-Z0-9]{4,}$/, describe: "a GTM container id (GTM-XXXXXXX)" }),
|
|
45
|
+
Object.freeze({ key: "fb_pixel_id", provider: "facebook", property: "pixelId", shape: /^\d{5,20}$/, describe: "a Meta pixel id (digits only)" }),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// The SDK routing meta tags a page's `sdk_hints.meta_tags` may carry, and the
|
|
49
|
+
// declared routing field (a page id) each one is the route of. A derived
|
|
50
|
+
// route change on the referenced page leaves such a hint stale; the hint is a
|
|
51
|
+
// spec projection the editor regenerates, so it is reported, not rewritten.
|
|
52
|
+
const ROUTING_HINT_FIELDS = Object.freeze({
|
|
53
|
+
"next-success-url": "success_url",
|
|
54
|
+
"next-upsell-accept-url": "on_accept",
|
|
55
|
+
"next-upsell-decline-url": "on_decline",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
|
|
59
|
+
// everything else under the campaign root, `assets/` and `_data/` included,
|
|
60
|
+
// is rendered when it is .html), plus the two no build reads. The CLI walker
|
|
61
|
+
// prunes with this and pageRouteForFile filters with it, so the list lives
|
|
62
|
+
// in one place.
|
|
63
|
+
export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
|
|
64
|
+
export function isPageTreeIgnoredDir(name) {
|
|
65
|
+
return PAGE_TREE_IGNORED_DIRS.includes(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPlainObject(value) {
|
|
69
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isNonEmptyString(value) {
|
|
73
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hasControlCharacters(value) {
|
|
77
|
+
// eslint-disable-next-line no-control-regex
|
|
78
|
+
return /[\u0000-\u001f\u007f]/.test(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A repo value echoed in a reason is quoted short: a value mis-pasted into
|
|
82
|
+
// the wrong key (a token, a URL) must not travel whole into warnings.
|
|
83
|
+
export function quoteValue(value) {
|
|
84
|
+
const text = JSON.stringify(value);
|
|
85
|
+
return text.length > 44 ? `${text.slice(0, 40)}…"` : text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The placeholder ids the starter templates document (GTM-XXXXXXX, a run of
|
|
89
|
+
// one digit): well-formed, never a real container or pixel.
|
|
90
|
+
function isPlaceholderId(value) {
|
|
91
|
+
return /^GTM-X+$/i.test(value) || /^(\d)\1+$/.test(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Whether the containers along a write path can take the value: an existing
|
|
95
|
+
// container of the wrong type (analytics: "off", global_config: []) is a
|
|
96
|
+
// spec defect the plan reports, so a dry run and a real run agree. Returns
|
|
97
|
+
// null when the path is writable, else the offending label.
|
|
98
|
+
function containerProblem(spec, path) {
|
|
99
|
+
let node = spec;
|
|
100
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
101
|
+
const segment = path[index];
|
|
102
|
+
const nextIsIndex = Number.isInteger(path[index + 1]);
|
|
103
|
+
const child = node?.[segment];
|
|
104
|
+
if (child === undefined || child === null) return nextIsIndex ? pathLabel(path.slice(0, index + 1)) : null;
|
|
105
|
+
if (nextIsIndex ? !Array.isArray(child) : !isPlainObject(child)) return pathLabel(path.slice(0, index + 1));
|
|
106
|
+
node = child;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The public route a page-kit source file builds to without a permalink,
|
|
112
|
+
// relative to the campaign root. page-kit's resolveOutput routes by the
|
|
113
|
+
// FILENAME alone (`checkout.html` -> `checkout/`, `offers/upsell.html` ->
|
|
114
|
+
// `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
|
|
115
|
+
// warning), and `index.html` is the entry route "". A nested `index.html`
|
|
116
|
+
// would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
|
|
117
|
+
// is not a page this reads. Files under `_layouts/` or `_includes/` return
|
|
118
|
+
// null, as page-kit never renders them.
|
|
119
|
+
export function pageRouteForFile(relativePath) {
|
|
120
|
+
const path = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
121
|
+
if (!path || !/\.html$/i.test(path)) return null;
|
|
122
|
+
const segments = path.split("/");
|
|
123
|
+
if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) return null;
|
|
124
|
+
const basename = segments[segments.length - 1].replace(/\.html$/i, "");
|
|
125
|
+
if (basename === "index") return segments.length === 1 ? "" : null;
|
|
126
|
+
return `${basename}/`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The campaign-relative route a frontmatter permalink states. page-kit serves
|
|
130
|
+
// a permalink verbatim at `/<permalink>/`, and prepare-build only ever writes
|
|
131
|
+
// the `/<slug>/<route>/` form, so that is the only form derive accepts: any
|
|
132
|
+
// other spelling (no slug prefix, another prefix, `.html`, `..`, a control
|
|
133
|
+
// character) is a repo defect reported with the URL page-kit would serve.
|
|
134
|
+
// Returns `{ route }` or `{ problem }`; the problem text carries the served
|
|
135
|
+
// URL where one can be named.
|
|
136
|
+
export function permalinkRoute(permalink, publicRouteSlug = "") {
|
|
137
|
+
const raw = String(permalink ?? "").trim();
|
|
138
|
+
if (hasControlCharacters(raw)) return { problem: "contains control characters" };
|
|
139
|
+
const stripped = raw.replace(/^\/+|\/+$/g, "");
|
|
140
|
+
const served = `/${stripped}/`;
|
|
141
|
+
const segments = stripped.split("/");
|
|
142
|
+
const slug = String(publicRouteSlug || "").trim();
|
|
143
|
+
if (!stripped || segments[0] !== slug) return { problem: `is served at ${served}, outside the campaign root /${slug || "<slug>"}/` };
|
|
144
|
+
const rest = segments.slice(1);
|
|
145
|
+
if (rest.some((segment) => segment === "" || segment === "." || segment === ".." || /\.html$/i.test(segment) || /[?#]/.test(segment))) {
|
|
146
|
+
return { problem: `is served at ${served}, which is not a page-kit route under /${slug}/ (each segment a plain name, no .html, no query)` };
|
|
147
|
+
}
|
|
148
|
+
return { route: rest.length ? `${rest.join("/")}/` : "" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A route the page tree states must be a relative page-kit route before it
|
|
152
|
+
// becomes an authoritative spec value: a permalink that is an absolute URL,
|
|
153
|
+
// climbs with `..`, carries an empty segment or a control character is a
|
|
154
|
+
// repo defect, reported rather than written. Returns null when the route is
|
|
155
|
+
// usable, else the reason.
|
|
156
|
+
export function derivedRouteProblem(route) {
|
|
157
|
+
if (typeof route !== "string") return "not a string";
|
|
158
|
+
if (route === "") return null;
|
|
159
|
+
if (hasControlCharacters(route)) return "contains control characters";
|
|
160
|
+
if (isAbsoluteHttpUrl(route)) return "is an absolute URL, not a page-kit route";
|
|
161
|
+
if (!/\/$/.test(route) || route.startsWith("/")) return "is not a relative page-kit route";
|
|
162
|
+
const segments = route.slice(0, -1).split("/");
|
|
163
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return "contains an empty, `.` or `..` segment";
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The read side of the walker's permalink: YAML idioms page-kit's own
|
|
168
|
+
// frontmatter reader (gray-matter) understands. `false`, `null` and `~` mean
|
|
169
|
+
// no permalink; a trailing `# comment` on an unquoted value is not part of
|
|
170
|
+
// it; surrounding quotes are dropped.
|
|
171
|
+
export function normalizePermalinkValue(value) {
|
|
172
|
+
if (typeof value !== "string") return null;
|
|
173
|
+
const text = value.trim();
|
|
174
|
+
// A quoted scalar ends at its closing quote; whatever follows (a comment)
|
|
175
|
+
// is not part of it. An unquoted scalar ends at ` #`.
|
|
176
|
+
const quoted = text.match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
|
|
177
|
+
if (quoted) return quoted[2] || null;
|
|
178
|
+
const bare = text.replace(/\s+#.*$/, "").trim();
|
|
179
|
+
if (!bare || ["false", "null", "~"].includes(bare)) return null;
|
|
180
|
+
return bare;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The raw `permalink:` scalar of a page file's frontmatter (the first line
|
|
184
|
+
// of the block that declares it), quoting and comment intact, or null when
|
|
185
|
+
// the block or the key is absent. A BOM and CRLF are read as page-kit's
|
|
186
|
+
// frontmatter reader reads them.
|
|
187
|
+
export function frontmatterPermalink(text) {
|
|
188
|
+
const normalized = String(text ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
189
|
+
const block = normalized.match(/^---\n([\s\S]*?)\n---/);
|
|
190
|
+
if (!block) return null;
|
|
191
|
+
for (const line of block[1].split("\n")) {
|
|
192
|
+
const match = line.match(/^permalink:\s*(.*?)\s*$/);
|
|
193
|
+
if (match) return normalizePermalinkValue(match[1]);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function terminalSegment(route) {
|
|
199
|
+
const parts = normalizePageKitRoute(route).replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
200
|
+
return parts.length ? parts[parts.length - 1] : "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Bind one active spec page to one file in the page tree. The packet's own
|
|
204
|
+
// projection (`source_html.pages[].page_kit.target_path`, the file the build
|
|
205
|
+
// stage wrote for this page id) binds first; without it, a file whose derived
|
|
206
|
+
// route equals the page's current route, whose terminal segment equals the
|
|
207
|
+
// current route's, or whose filename is the page id. Exactly one candidate
|
|
208
|
+
// binds; none or several is reported and the page is left as it is.
|
|
209
|
+
function bindPageFile(page, pageFiles, packetBindings) {
|
|
210
|
+
const bound = packetBindings instanceof Map ? packetBindings.get(page.id) : undefined;
|
|
211
|
+
if (isNonEmptyString(bound)) {
|
|
212
|
+
const match = pageFiles.find((file) => file.path === bound.replace(/^\/+/, ""));
|
|
213
|
+
if (match) return { file: match, via: "packet" };
|
|
214
|
+
}
|
|
215
|
+
const currentRoute = publicRouteForPage(page);
|
|
216
|
+
const currentTerminal = terminalSegment(currentRoute);
|
|
217
|
+
const candidates = new Set();
|
|
218
|
+
for (const file of pageFiles) {
|
|
219
|
+
if (file.route !== null && file.route === currentRoute) candidates.add(file);
|
|
220
|
+
else if (currentTerminal && file.route !== null && terminalSegment(file.route) === currentTerminal) candidates.add(file);
|
|
221
|
+
else if (file.basename === page.id) candidates.add(file);
|
|
222
|
+
}
|
|
223
|
+
if (candidates.size === 1) return { file: [...candidates][0], via: "page_tree" };
|
|
224
|
+
return { file: null, candidates: [...candidates].map((file) => file.path).sort(), via: candidates.size ? "ambiguous" : "none" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Every active page with the JSON path it lives at. `funnels[]` is
|
|
228
|
+
// authoritative; `funnel_pages[]` is the legacy mirror and is only the source
|
|
229
|
+
// when `funnels[]` is absent (the same fallback doctor's normalizeFunnels
|
|
230
|
+
// applies).
|
|
231
|
+
function activePagesWithPaths(spec) {
|
|
232
|
+
const pages = [];
|
|
233
|
+
const visit = (page, path) => {
|
|
234
|
+
if (isPlainObject(page) && page.enabled !== false && isNonEmptyString(page.id)) pages.push({ page, path });
|
|
235
|
+
};
|
|
236
|
+
if (Array.isArray(spec?.funnels)) {
|
|
237
|
+
spec.funnels.forEach((funnel, funnelIndex) => {
|
|
238
|
+
(Array.isArray(funnel?.pages) ? funnel.pages : []).forEach((page, pageIndex) => visit(page, ["funnels", funnelIndex, "pages", pageIndex]));
|
|
239
|
+
});
|
|
240
|
+
} else if (Array.isArray(spec?.funnel_pages)) {
|
|
241
|
+
spec.funnel_pages.forEach((page, index) => visit(page, ["funnel_pages", index]));
|
|
242
|
+
}
|
|
243
|
+
return pages;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pathLabel(path) {
|
|
247
|
+
return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The field-by-field plan. `changes` are the derived fields whose value will
|
|
251
|
+
// move, `unchanged` already match, `not_derived` are derived fields the repo
|
|
252
|
+
// cannot state for this campaign right now (with the reason), `not_in_target`
|
|
253
|
+
// are derived fields the repo simply does not carry (left as they are), and
|
|
254
|
+
// `stale_hints` are routing hints on other pages that a derived route change
|
|
255
|
+
// leaves pointing at the old route.
|
|
256
|
+
//
|
|
257
|
+
// Inputs, all read by the CLI: the parsed spec; the campaigns.json `entry`
|
|
258
|
+
// for the route; `pageFiles` as `{ path, basename, route, permalink }` rows for every
|
|
259
|
+
// page file under the campaign's source directory (null when the directory
|
|
260
|
+
// does not exist); `packetBindings` as a Map of page id -> target file path
|
|
261
|
+
// from the packet's page-kit projection; `waivedGates` as the checkpoint
|
|
262
|
+
// gates an active named-human waiver currently covers.
|
|
263
|
+
export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "" } = {}) {
|
|
264
|
+
const target = isPlainObject(entry) ? entry : {};
|
|
265
|
+
const changes = [];
|
|
266
|
+
const unchanged = [];
|
|
267
|
+
const notDerived = [];
|
|
268
|
+
const notInTarget = [];
|
|
269
|
+
const staleHints = [];
|
|
270
|
+
const waivedBy = new Map(
|
|
271
|
+
(Array.isArray(waivedGates) ? waivedGates : [])
|
|
272
|
+
.filter((gate) => isPlainObject(gate) && typeof gate.scope === "string")
|
|
273
|
+
.map((gate) => [gate.scope, gate.waived_by || "a named human"]),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// SDK pin: the repo pin is what the funnel serves. It is written into the
|
|
277
|
+
// canonical field and, when the spec also declares the alias, into the alias
|
|
278
|
+
// too, so the two never conflict. It is not derived while the entry is still
|
|
279
|
+
// a scaffold (the pin is the starter's seed and page-kit sync's seeding rule
|
|
280
|
+
// owns that direction), when the repo pin is missing or not a released
|
|
281
|
+
// version, or while a named-human waiver covers the exact pair.
|
|
282
|
+
const observed = Object.hasOwn(target, "sdk_version") ? target.sdk_version : undefined;
|
|
283
|
+
const specPin = resolveSpecSdkPin(spec);
|
|
284
|
+
const sdkTargets = [
|
|
285
|
+
{ field: "global_config.sdk_version", path: ["global_config", "sdk_version"], before: spec?.global_config?.sdk_version },
|
|
286
|
+
...(spec?.runtime != null && Object.hasOwn(spec.runtime, "sdk_version")
|
|
287
|
+
? [{ field: "runtime.sdk_version", path: ["runtime", "sdk_version"], before: spec.runtime.sdk_version }]
|
|
288
|
+
: []),
|
|
289
|
+
];
|
|
290
|
+
const entrySource = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${publicRouteSlug || "<route>"}].${key}`;
|
|
291
|
+
const sdkSource = entrySource("sdk_version");
|
|
292
|
+
const sdkWaiver = waivedBy.get("page_kit.sdk_version") || null;
|
|
293
|
+
if (observed === undefined) {
|
|
294
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "target_missing", detail: `the target entry has no sdk_version; add the Campaign Cart pin the funnel serves to ${sdkSource}, then derive again.` });
|
|
295
|
+
} else if (!isReleasedSdkVersion(observed)) {
|
|
296
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "target_invalid", detail: `the target pin ${quoteValue(observed)} is not a released MAJOR.MINOR.PATCH version; correct ${sdkSource}, then derive again.` });
|
|
297
|
+
} else if (entryInScaffoldState(target)) {
|
|
298
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "scaffold_seed", detail: `the target entry still carries the starter demo store profile, so its pin ${observed} is the starter's seed, not a version anyone chose; the spec seeds the pin in that state (page-kit sync). Sync the scaffold from the spec first, then derive.` });
|
|
299
|
+
} else if (sdkWaiver) {
|
|
300
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "waived", detail: `the SDK pin is covered by an active page_kit.sdk_version waiver recorded by ${sdkWaiver}; spec derive leaves the spec as the waiver accepted it. Withdraw the waiver on the Assembly Report (waivers[]) to let derive write the repo pin.` });
|
|
301
|
+
} else if (waiversUnknown) {
|
|
302
|
+
// An unreadable Assembly Report means a named-human waiver on the pin
|
|
303
|
+
// may exist unseen; "unknown" is not "none", so the pin waits.
|
|
304
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "waivers_unknown", detail: "the Assembly Report could not be read, so an active page_kit.sdk_version waiver cannot be ruled out; spec derive leaves the pin alone rather than reverse a decision it cannot see. Repair or restore the report, then derive again." });
|
|
305
|
+
} else {
|
|
306
|
+
// A spec pin ahead of the repo pin is the state doctor blocks on with
|
|
307
|
+
// page-kit sync as the repair (#413: a bump the repo never received, or
|
|
308
|
+
// a lost one); the repo moves forward, the spec is not moved back. Only
|
|
309
|
+
// one command may own that state, so derive reports it and writes nothing.
|
|
310
|
+
// Every released pin the spec declares is checked, so a conflicting pair
|
|
311
|
+
// (canonical ahead, alias behind) cannot slip a lowering past the rule.
|
|
312
|
+
const declaredAhead = sdkTargets.map((row) => row.before).filter((value) => isReleasedSdkVersion(value) && compareReleasedSdkVersions(value, observed) > 0);
|
|
313
|
+
if (declaredAhead.length) {
|
|
314
|
+
notDerived.push({ field: "global_config.sdk_version", reason: "spec_ahead", detail: `the spec pin ${declaredAhead[0]} is ahead of the target pin ${observed}; doctor blocks on that state and page-kit sync is its repair (it moves the repo forward, and never moves a configured campaign's pin backwards). Run page-kit sync, or lower the spec pin by hand if ${observed} is what should ship, then derive again.` });
|
|
315
|
+
} else {
|
|
316
|
+
for (const row of sdkTargets) {
|
|
317
|
+
const change = { field: row.field, path: row.path, before: row.before, after: observed, source: sdkSource };
|
|
318
|
+
const problem = containerProblem(spec, row.path);
|
|
319
|
+
if (problem) notDerived.push({ field: row.field, reason: "spec_container_invalid", detail: `the spec's ${problem} is not an object, so ${row.field} cannot be written; repair the spec, then derive again.` });
|
|
320
|
+
else if (row.before === observed) unchanged.push(change);
|
|
321
|
+
else changes.push(change);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Page routes: page-kit routes by source filename (or permalink), so the
|
|
327
|
+
// tree is the authority for `page_url`.
|
|
328
|
+
const activePages = activePagesWithPaths(spec);
|
|
329
|
+
// A page id that appears twice cannot bind one route: doctor's
|
|
330
|
+
// PageIdUniqueness rule blocks the spec, and derive names it too rather
|
|
331
|
+
// than let the last binding win.
|
|
332
|
+
const idCounts = new Map();
|
|
333
|
+
for (const { page } of activePages) idCounts.set(page.id, (idCounts.get(page.id) || 0) + 1);
|
|
334
|
+
// The route every bound page derives to, changed or not: the standing
|
|
335
|
+
// check on routing hints reads it, so a hint left stale by an earlier run
|
|
336
|
+
// keeps surfacing until the Map is re-saved.
|
|
337
|
+
const derivedRouteByPageId = new Map();
|
|
338
|
+
if (pageFiles === null) {
|
|
339
|
+
for (const { page, path } of activePages) {
|
|
340
|
+
notDerived.push({ field: `${pathLabel(path)}.page_url`, page_id: page.id, reason: "page_tree_missing", detail: `the campaign's page tree does not exist in the target repo, so no route can be derived for page "${page.id}". Scaffold and build the campaign first, then derive.` });
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
const mirrorIndex = new Map();
|
|
344
|
+
if (Array.isArray(spec?.funnels) && Array.isArray(spec?.funnel_pages)) {
|
|
345
|
+
spec.funnel_pages.forEach((page, index) => {
|
|
346
|
+
if (isPlainObject(page) && isNonEmptyString(page.id) && !mirrorIndex.has(page.id)) mirrorIndex.set(page.id, index);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
for (const { page, path } of activePages) {
|
|
350
|
+
const field = `${pathLabel(path)}.page_url`;
|
|
351
|
+
if (idCounts.get(page.id) > 1) {
|
|
352
|
+
notDerived.push({ field, page_id: page.id, reason: "page_id_duplicate", detail: `page id "${page.id}" appears more than once in the spec, so no single route can be derived for it; make page ids unique, then derive again.` });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const binding = bindPageFile(page, pageFiles, packetBindings);
|
|
356
|
+
if (!binding.file) {
|
|
357
|
+
notDerived.push(binding.via === "ambiguous"
|
|
358
|
+
? { field, page_id: page.id, reason: "page_file_ambiguous", detail: `more than one page file could be page "${page.id}" (${binding.candidates.join(", ")}); add a permalink or rename so one file carries the route, then derive again.` }
|
|
359
|
+
: { field, page_id: page.id, reason: "page_file_not_found", detail: `no page file in the page tree binds to page "${page.id}" (by the packet's page-kit projection, the page's current route ${JSON.stringify(publicRouteForPage(page))}, or a file named after the page id); the page has not been built, or was renamed past recognition. Build it, or name the file after the page, then derive again.` });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const after = binding.file.route;
|
|
363
|
+
const before = Object.hasOwn(page, "page_url") ? page.page_url : undefined;
|
|
364
|
+
const source = `${binding.file.path}${binding.file.permalink ? " (permalink)" : ""}`;
|
|
365
|
+
const problem = binding.file.problem || derivedRouteProblem(after);
|
|
366
|
+
if (problem) {
|
|
367
|
+
notDerived.push({ field, page_id: page.id, reason: "target_invalid", detail: `the permalink ${source} states for page "${page.id}" (${quoteValue(binding.file.permalink ?? after)}) ${problem}; fix the file's permalink, then derive again.` });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const row = { field, page_id: page.id, path: [...path, "page_url"], before, after, source };
|
|
371
|
+
// The entry route is the empty string, and doctor honours an empty
|
|
372
|
+
// page_url only on a page flagged is_entry (publicRouteForPage falls
|
|
373
|
+
// back to the type's default route otherwise). Writing "" onto any
|
|
374
|
+
// other page would move it, in doctor's eyes, to a route that does not
|
|
375
|
+
// exist; the flag is authored in the Map, so it is asked for instead.
|
|
376
|
+
if (after === "" && page.is_entry !== true) {
|
|
377
|
+
notDerived.push({ field, page_id: page.id, reason: "entry_route_undeclared", detail: `page "${page.id}" binds to ${source}, the entry route, but the page is not flagged is_entry; doctor reads an empty page_url only on the entry page. Flag it in the Map (or give the file a permalink), then derive again.` });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
derivedRouteByPageId.set(page.id, after);
|
|
381
|
+
const containerIssue = containerProblem(spec, row.path);
|
|
382
|
+
if (containerIssue) {
|
|
383
|
+
notDerived.push({ field, page_id: page.id, reason: "spec_container_invalid", detail: `the spec's ${containerIssue} is not an object, so ${field} cannot be written; repair the spec, then derive again.` });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// Compared the way prepare-build projects a route (normalized, slug
|
|
387
|
+
// prefix stripped): a value that differs only in spelling
|
|
388
|
+
// ("/slug/checkout/", "checkout") is the same route and is not
|
|
389
|
+
// rewritten; a value nested differently from the tree is not.
|
|
390
|
+
const sameRoute = typeof before === "string"
|
|
391
|
+
&& stripPublicRoutePrefix(normalizePageKitRoute(before), publicRouteSlug) === after
|
|
392
|
+
&& (before.trim() !== "" || page.is_entry === true);
|
|
393
|
+
if (sameRoute) unchanged.push(row);
|
|
394
|
+
else changes.push(row);
|
|
395
|
+
// The legacy mirror is reconciled for every derived page, changed or
|
|
396
|
+
// not: a mirror left stale by an earlier edit would otherwise stay so.
|
|
397
|
+
const mirrorAt = mirrorIndex.get(page.id);
|
|
398
|
+
if (mirrorAt !== undefined) {
|
|
399
|
+
const mirror = spec.funnel_pages[mirrorAt];
|
|
400
|
+
const mirrorBefore = Object.hasOwn(mirror, "page_url") ? mirror.page_url : undefined;
|
|
401
|
+
const mirrorSame = typeof mirrorBefore === "string"
|
|
402
|
+
&& stripPublicRoutePrefix(normalizePageKitRoute(mirrorBefore), publicRouteSlug) === after
|
|
403
|
+
&& (mirrorBefore.trim() !== "" || page.is_entry === true);
|
|
404
|
+
if (!mirrorSame) {
|
|
405
|
+
changes.push({ field: `funnel_pages[${mirrorAt}].page_url`, page_id: page.id, path: ["funnel_pages", mirrorAt, "page_url"], before: mirrorBefore, after, source: `mirror of ${field}` });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// A routing hint that names a page whose derived route it does not match
|
|
410
|
+
// is stale, whether the route moved in this run or an earlier one. Hints
|
|
411
|
+
// are a spec projection the editor regenerates (doctor reads them as
|
|
412
|
+
// build expectations), so they are reported, never rewritten.
|
|
413
|
+
for (const { page } of activePages) {
|
|
414
|
+
const metaTags = page.sdk_hints?.meta_tags;
|
|
415
|
+
if (!isPlainObject(metaTags)) continue;
|
|
416
|
+
for (const [tag, routingField] of Object.entries(ROUTING_HINT_FIELDS)) {
|
|
417
|
+
const value = metaTags[tag];
|
|
418
|
+
const targetId = page[routingField];
|
|
419
|
+
if (!isNonEmptyString(value) || !isNonEmptyString(targetId) || !derivedRouteByPageId.has(targetId)) continue;
|
|
420
|
+
const derived = derivedRouteByPageId.get(targetId);
|
|
421
|
+
if (runtimeRelativeRouteForSpecValue(value, publicRouteSlug) === runtimeRelativeRouteForSpecValue(derived, publicRouteSlug)) continue;
|
|
422
|
+
staleHints.push({ page_id: page.id, tag, value, target_page_id: targetId, derived_route: derived });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Analytics ids the entry carries. A usable id is written into the provider
|
|
428
|
+
// block (created when the spec lacks it). An empty repo value never deletes
|
|
429
|
+
// a spec id: the mismatch is reported so someone decides which side is
|
|
430
|
+
// wrong.
|
|
431
|
+
for (const { key, provider, property, shape, describe } of ANALYTICS_ID_FIELDS) {
|
|
432
|
+
const field = `analytics.providers.${provider}.${property}`;
|
|
433
|
+
const raw = Object.hasOwn(target, key) ? target[key] : undefined;
|
|
434
|
+
const current = spec?.analytics?.providers?.[provider]?.[property];
|
|
435
|
+
const source = entrySource(key);
|
|
436
|
+
if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) {
|
|
437
|
+
if (isNonEmptyString(current)) {
|
|
438
|
+
notDerived.push({ field, reason: "target_empty", detail: `the target entry's ${key} is empty but the spec declares ${field} ${quoteValue(current)}; add the id to ${source} if the funnel should carry it, or remove it from the spec. Nothing was written.` });
|
|
439
|
+
} else notInTarget.push(field);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (typeof raw !== "string" || hasControlCharacters(raw) || !shape.test(raw.trim())) {
|
|
443
|
+
notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${typeof raw === "string" ? quoteValue(raw) : `is ${Array.isArray(raw) ? "an array" : `a ${typeof raw}`}`} is not ${describe}; correct ${source}, then derive again.` });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const after = raw.trim();
|
|
447
|
+
if (isPlaceholderId(after)) {
|
|
448
|
+
notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${quoteValue(after)} is a placeholder, not a real id; writing it would declare an analytics contract QA then blocks on. Replace it in ${source} (or clear it), then derive again.` });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const row = { field, path: ["analytics", "providers", provider, property], before: current, after, source };
|
|
452
|
+
const problem = containerProblem(spec, row.path);
|
|
453
|
+
if (problem) notDerived.push({ field, reason: "spec_container_invalid", detail: `the spec's ${problem} is not an object, so ${field} cannot be written; repair the spec, then derive again.` });
|
|
454
|
+
else if (current === after) unchanged.push(row);
|
|
455
|
+
else changes.push(row);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// A provider block derive will create is a new analytics contract: QA
|
|
459
|
+
// stops treating analytics as advisory and expects that tag to fire.
|
|
460
|
+
const createdBlocks = ANALYTICS_ID_FIELDS
|
|
461
|
+
.filter(({ provider, property }) => changes.some((row) => row.field === `analytics.providers.${provider}.${property}`) && !isPlainObject(spec?.analytics?.providers?.[provider]))
|
|
462
|
+
.map(({ provider }) => `analytics.providers.${provider}`);
|
|
463
|
+
|
|
464
|
+
return { changes, unchanged, not_derived: notDerived, not_in_target: notInTarget, stale_hints: staleHints, created_blocks: [...new Set(createdBlocks)] };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function pathMatchesPattern(path, pattern) {
|
|
468
|
+
const expected = pattern.split(".").flatMap((segment) => (segment.endsWith("[]") ? [segment.slice(0, -2), "[]"] : [segment]));
|
|
469
|
+
if (expected.length !== path.length) return false;
|
|
470
|
+
return expected.every((segment, index) => (segment === "[]" ? Number.isInteger(path[index]) : path[index] === segment));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Apply a plan to the parsed spec. Mutates ONLY the derived field each change
|
|
474
|
+
// names, in place, so key order and every other key survive; a missing
|
|
475
|
+
// container object is created (`global_config`, `analytics.providers.gtm`
|
|
476
|
+
// as `{ enabled: true }`), a missing array element is never invented. Returns
|
|
477
|
+
// the same spec object.
|
|
478
|
+
export function applySpecDerive(spec, plan) {
|
|
479
|
+
if (!isPlainObject(spec)) throw new Error("CampaignSpec must be a JSON object.");
|
|
480
|
+
for (const change of plan.changes) {
|
|
481
|
+
const path = Array.isArray(change.path) ? change.path : [];
|
|
482
|
+
if (!SPEC_DERIVE_FIELDS.some((pattern) => pathMatchesPattern(path, pattern))) {
|
|
483
|
+
throw new Error(`Refusing to write "${change.field}": spec derive governs only ${SPEC_DERIVE_FIELDS.join(", ")}.`);
|
|
484
|
+
}
|
|
485
|
+
let node = spec;
|
|
486
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
487
|
+
const segment = path[index];
|
|
488
|
+
if (Number.isInteger(segment)) {
|
|
489
|
+
if (!Array.isArray(node) || !isPlainObject(node[segment])) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an object in the spec.`);
|
|
490
|
+
node = node[segment];
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const nextIsIndex = Number.isInteger(path[index + 1]);
|
|
494
|
+
if (node[segment] === undefined || node[segment] === null) {
|
|
495
|
+
if (nextIsIndex) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an array in the spec.`);
|
|
496
|
+
// A provider block created by derive is enabled: an id the repo
|
|
497
|
+
// carries is a tag the funnel fires, which is what QA then expects.
|
|
498
|
+
node[segment] = path[index - 1] === "providers" ? { enabled: true } : {};
|
|
499
|
+
} else if (nextIsIndex ? !Array.isArray(node[segment]) : !isPlainObject(node[segment])) {
|
|
500
|
+
throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not ${nextIsIndex ? "an array" : "an object"} in the spec.`);
|
|
501
|
+
}
|
|
502
|
+
node = node[segment];
|
|
503
|
+
}
|
|
504
|
+
node[path[path.length - 1]] = change.after;
|
|
505
|
+
}
|
|
506
|
+
return spec;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// One line per field for the printed diff. `undefined` (the spec had no such
|
|
510
|
+
// key) prints as (absent); everything else is JSON so a string that merely
|
|
511
|
+
// looks empty is visibly quoted.
|
|
512
|
+
export function formatDeriveValue(value) {
|
|
513
|
+
return value === undefined ? "(absent)" : JSON.stringify(value);
|
|
514
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one fetch of a CampaignSpec by Map ID. `start`/`prepare-build --map-id`
|
|
2
|
+
// and packet-less `qa run --map-id` both read the same endpoint and must apply
|
|
3
|
+
// the same response rules; a second, more lenient reading once lived in the
|
|
4
|
+
// QA runner (which cannot import the CLI) and returned an `{ ok: false }`
|
|
5
|
+
// error body as the spec. A leaf: no imports.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PROXY_BASE = "https://campaign-map.nextcommerce.com";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch a CampaignSpec by Map ID from the proxy Worker.
|
|
11
|
+
*
|
|
12
|
+
* The Map Builder portal at campaign-map.nextcommerce.com is fronted by
|
|
13
|
+
* a backend service that persists saved specs and exposes them via
|
|
14
|
+
* GET /api/spec/<map-id>. Response shape is
|
|
15
|
+
* { ok: true, data: <spec> } or { ok: false, error: <message> } on a
|
|
16
|
+
* 200 with a logical failure.
|
|
17
|
+
*
|
|
18
|
+
* `fetchImpl` is parameterized for tests so a local mock server can
|
|
19
|
+
* stand in for the deployed Worker.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} mapId — saved Map Builder identity (e.g. "veyra-v1-knp4")
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {string} [opts.proxyBase] — proxy origin without trailing slash
|
|
24
|
+
* @param {Function} [opts.fetchImpl] — fetch shim for testing
|
|
25
|
+
* @returns {Promise<object>} parsed CampaignSpec
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchSpecByMapId(mapId, opts = {}) {
|
|
28
|
+
const trimmed = String(mapId || "").trim();
|
|
29
|
+
if (!trimmed) throw new Error("fetchSpecByMapId: mapId is required.");
|
|
30
|
+
const base = (opts.proxyBase || DEFAULT_PROXY_BASE).replace(/\/+$/, "");
|
|
31
|
+
const url = `${base}/api/spec/${encodeURIComponent(trimmed)}`;
|
|
32
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
33
|
+
if (typeof fetchImpl !== "function") {
|
|
34
|
+
throw new Error("Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
|
|
35
|
+
}
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetchImpl(url, { headers: { Accept: "application/json" } });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw specFetchError(`Spec fetch network error: ${error.message} (${url})`, { kind: "network" });
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw specFetchError(`Spec fetch failed: ${res.status} ${res.statusText} (${url})`, { kind: "http", status: res.status });
|
|
44
|
+
}
|
|
45
|
+
let body;
|
|
46
|
+
try {
|
|
47
|
+
body = await res.json();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw specFetchError(`Spec fetch returned invalid JSON: ${error.message} (${url})`, { kind: "invalid_json", status: res.status });
|
|
50
|
+
}
|
|
51
|
+
if (!body || body.ok === false || body.data == null) {
|
|
52
|
+
throw specFetchError(`Spec fetch returned ok=false: ${body?.error || "unknown error"} (${url})`, { kind: "not_ok", status: res.status });
|
|
53
|
+
}
|
|
54
|
+
return body.data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Every refusal carries what happened as data beside the prose: `kind`
|
|
58
|
+
// (network | http | invalid_json | not_ok) and, once a response arrived, its
|
|
59
|
+
// HTTP `status`. A caller that must route on a 404 reads the field, never the
|
|
60
|
+
// message.
|
|
61
|
+
function specFetchError(message, { kind, status = null }) {
|
|
62
|
+
const error = new Error(message);
|
|
63
|
+
error.kind = kind;
|
|
64
|
+
error.status = status;
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Spec-hash identity comparison.
|
|
3
|
+
//
|
|
4
|
+
// A spec hash reaches a comparison from several producers (the saved Map, a
|
|
5
|
+
// Build Context, an Assembly Report, a stored QA verdict, a pricing
|
|
6
|
+
// calculation envelope) and they do not all spell it the same way: some carry
|
|
7
|
+
// the `sha256:` prefix, some do not; some upper-case the hex; some carry
|
|
8
|
+
// surrounding whitespace. A raw string compare turns those spellings into a
|
|
9
|
+
// false "stale" or "mismatch", and a consumer that normalises differently can
|
|
10
|
+
// disagree with this toolkit about whether the same verdict matches the same
|
|
11
|
+
// spec. Every "is this the same spec?" check goes through these three
|
|
12
|
+
// functions so the answer is the same everywhere.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonical form of a spec hash: trimmed, lower-cased, with one leading
|
|
17
|
+
* `sha256:` removed. Returns null for any non-string, for empty/whitespace-only
|
|
18
|
+
* input and for a bare prefix with nothing after it.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeSpecHash(value) {
|
|
21
|
+
// Only a string can carry a hash. Numbers, booleans, NaN, objects and
|
|
22
|
+
// arrays all stringify to something (`"nan"`, `"[object Object]"`) that two
|
|
23
|
+
// unrelated malformed documents would share, so they never normalise.
|
|
24
|
+
if (typeof value !== "string") return null;
|
|
25
|
+
const text = value.trim().toLowerCase().replace(/^sha256:/, "").trim();
|
|
26
|
+
return text || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True only when both sides normalise to a non-null value and those values
|
|
31
|
+
* are equal. Two missing hashes are NOT a match: absence carries no identity,
|
|
32
|
+
* and treating it as agreement would let an unhashed artifact pass as the
|
|
33
|
+
* same spec.
|
|
34
|
+
*/
|
|
35
|
+
export function specHashesMatch(left, right) {
|
|
36
|
+
const a = normalizeSpecHash(left);
|
|
37
|
+
const b = normalizeSpecHash(right);
|
|
38
|
+
return a != null && b != null && a === b;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The spec hash a Map / CampaignSpec document carries, in either of the two
|
|
43
|
+
* places producers put it: a top-level `spec_hash`, or `spec_identity.spec_hash`.
|
|
44
|
+
* Returns the raw stored value (not normalised) or null.
|
|
45
|
+
*/
|
|
46
|
+
export function specHashOf(doc) {
|
|
47
|
+
return doc?.spec_hash ?? doc?.spec_identity?.spec_hash ?? null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const VOLATILE_TOP_LEVEL_FIELDS = new Set(["spec_identity", "slug", "map_id", "saved_at"]);
|
|
4
|
+
|
|
5
|
+
function canonicalJson(value) {
|
|
6
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
8
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function materialSpec(spec) {
|
|
12
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
|
|
13
|
+
return Object.fromEntries(
|
|
14
|
+
Object.entries(spec).filter(([key]) => !VOLATILE_TOP_LEVEL_FIELDS.has(key)),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function specMaterialHash(spec) {
|
|
19
|
+
return `sha256:${createHash("sha256").update(canonicalJson(materialSpec(spec))).digest("hex")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The three pure comparators live in `./spec-hash.mjs`, a dependency-free
|
|
23
|
+
// leaf, so that browser-targeted consumers of the public
|
|
24
|
+
// `./commercial-journey` export can reach them without pulling this module's
|
|
25
|
+
// `node:crypto` import into their bundle. They are re-exported here so every
|
|
26
|
+
// existing importer of `./spec-identity.mjs` keeps working unchanged.
|
|
27
|
+
export { normalizeSpecHash, specHashesMatch, specHashOf } from "./spec-hash.mjs";
|