@nextcommerce/campaigns-os 1.61.0 → 1.63.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 +2 -1
- package/CHANGELOG.md +127 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +3 -0
- package/campaign-spec/dist/rules/index.js +9 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +59 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
- package/contracts/release-ledger.json +1260 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +59 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +7 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +34 -3
- package/schemas/campaign-spec.v5.schema.json +34 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
package/src/spec-derive.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// typed, and this command is the generator for the repo-derived ones: the
|
|
8
8
|
// SDK pin from `_data/campaigns.json[<route>].sdk_version`, each page's public
|
|
9
9
|
// route from the page tree under `src/<route>/`, and the analytics ids the
|
|
10
|
-
// entry carries (`gtm_id`, `fb_pixel_id
|
|
10
|
+
// entry carries (`gtm_id`, `fb_pixel_id`, and the starter-template vendor ids
|
|
11
|
+
// such as `ga4_id` or `triplewhale_name`). Doctor then compares generated
|
|
11
12
|
// against generated instead of refereeing a human's typing against the repo.
|
|
12
13
|
//
|
|
13
14
|
// The repo is the authority for exactly those fields. A spec value that looks
|
|
@@ -17,14 +18,23 @@
|
|
|
17
18
|
// authority: the store's Admin API) are planned by spec-derive-store.mjs
|
|
18
19
|
// behind --from-store; their rows are applied here under the same guard.
|
|
19
20
|
//
|
|
21
|
+
// After handoff (#721) the repo is also the authority for which pages exist:
|
|
22
|
+
// a page file the tree gains is added to the spec, a page whose built file is
|
|
23
|
+
// gone is removed, and the routing that links an added or removed page is read
|
|
24
|
+
// from the page files' frontmatter links. Pages are matched by id, never by
|
|
25
|
+
// position. The offer and package fields the spec mirrors from the Campaigns
|
|
26
|
+
// App are planned by spec-derive-campaign.mjs from the live campaign read and
|
|
27
|
+
// applied here under the same guard.
|
|
28
|
+
//
|
|
20
29
|
// This module is pure: the CLI reads the packet, the spec, the entry and the
|
|
21
30
|
// page tree, and does the writing and the printing.
|
|
22
|
-
import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
|
|
31
|
+
import { ANALYTICS_VENDORS, isPlaceholderVendorId, isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
|
|
23
32
|
import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
|
|
24
33
|
import { PAGE_KIT_STORE_PROFILE_FIELDS } from "./page-kit-store-profile.mjs";
|
|
25
34
|
import { compareReleasedSdkVersions, entryInScaffoldState, resolveSpecSdkPin } from "./page-kit-sdk-version.mjs";
|
|
26
35
|
import { isAbsoluteHttpUrl, normalizePageKitRoute, runtimeRelativeRouteForSpecValue, stripPublicRoutePrefix } from "./route-identity.mjs";
|
|
27
|
-
import { publicRouteForPage } from "./source-html-intake.mjs";
|
|
36
|
+
import { cpkPageTypeForSpecType, publicRouteForPage } from "./source-html-intake.mjs";
|
|
37
|
+
import { MIRRORED_OFFER_FIELDS, MIRRORED_PACKAGE_FIELDS, MIRRORED_PAGE_OFFER_FIELDS } from "./spec-derive-campaign.mjs";
|
|
28
38
|
|
|
29
39
|
// The spec fields this command may write, as path patterns. `applySpecDerive`
|
|
30
40
|
// refuses any change whose path matches none of them, so a plan row can never
|
|
@@ -36,25 +46,64 @@ export const SPEC_DERIVE_FIELDS = Object.freeze([
|
|
|
36
46
|
"funnel_pages[].page_url",
|
|
37
47
|
"analytics.providers.gtm.containerId",
|
|
38
48
|
"analytics.providers.facebook.pixelId",
|
|
49
|
+
...ANALYTICS_VENDORS.map(({ key }) => `analytics.vendors.${key}.id`),
|
|
39
50
|
...PAGE_KIT_STORE_PROFILE_FIELDS.map((field) => `campaign.${field}`),
|
|
40
51
|
]);
|
|
41
52
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
// The page routing fields derive writes after handoff, and only on an edge
|
|
54
|
+
// that names a page the tree added or removed (#721).
|
|
55
|
+
export const PAGE_ROUTING_FIELDS = Object.freeze(["next_page", "on_accept", "on_decline", "success_url"]);
|
|
56
|
+
|
|
57
|
+
// The starter-template vendor ids (analytics.vendors.<vendor>.id), one per
|
|
58
|
+
// vendor in the campaign-spec registry, keyed by the campaigns.json key that
|
|
59
|
+
// switches the vendor on. GA4 and Taboola have a known format (Taboola's id is
|
|
60
|
+
// injected unquoted, so anything but digits breaks the page); the others are
|
|
61
|
+
// one token with no whitespace or quotes, since the template writes the id
|
|
62
|
+
// into a script.
|
|
63
|
+
const VENDOR_ID_SHAPES = Object.freeze({
|
|
64
|
+
ga4: { shape: /^G-[A-Z0-9]{4,}$/, describe: "a GA4 measurement id (G-XXXXXXXXXX)" },
|
|
65
|
+
taboola: { shape: /^\d+$/, describe: "a Taboola account id (digits only)" },
|
|
66
|
+
});
|
|
67
|
+
const VENDOR_ID_TOKEN = { shape: /^[^\s"'<>\\`]+$/, describe: "one id token with no spaces or quotes" };
|
|
68
|
+
export const ANALYTICS_VENDOR_ID_FIELDS = Object.freeze(ANALYTICS_VENDORS.map(({ key, idKey, label }) => Object.freeze({
|
|
69
|
+
key: idKey,
|
|
70
|
+
vendor: key,
|
|
71
|
+
property: "id",
|
|
72
|
+
...(VENDOR_ID_SHAPES[key] || { ...VENDOR_ID_TOKEN, describe: `a ${label} id (${VENDOR_ID_TOKEN.describe})` }),
|
|
73
|
+
})));
|
|
47
74
|
|
|
48
75
|
// The SDK routing meta tags a page's `sdk_hints.meta_tags` may carry, and the
|
|
49
76
|
// 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
|
|
51
|
-
//
|
|
77
|
+
// route change on the referenced page leaves such a hint stale, and derive
|
|
78
|
+
// reports it. The one exception is an edge derive itself re-points after
|
|
79
|
+
// handoff (a page added or removed): there no editor will regenerate the
|
|
80
|
+
// hint, so derive writes it with the routing field it goes with.
|
|
52
81
|
const ROUTING_HINT_FIELDS = Object.freeze({
|
|
53
82
|
"next-success-url": "success_url",
|
|
54
83
|
"next-upsell-accept-url": "on_accept",
|
|
55
84
|
"next-upsell-decline-url": "on_decline",
|
|
56
85
|
});
|
|
57
86
|
|
|
87
|
+
// The further paths a handed-off derive may write: whole pages added to or
|
|
88
|
+
// removed from the funnel the tree states (an element op), the routing
|
|
89
|
+
// fields of an edge that names such a page, and the offer and package fields
|
|
90
|
+
// the spec mirrors from the live campaign (spec-derive-campaign.mjs).
|
|
91
|
+
export const SPEC_DERIVE_ELEMENTS = Object.freeze(["funnels[].pages[]", "funnel_pages[]", "offers[]"]);
|
|
92
|
+
export const SPEC_DERIVE_MIRROR_FIELDS = Object.freeze([
|
|
93
|
+
...PAGE_ROUTING_FIELDS.flatMap((field) => [`funnels[].pages[].${field}`, `funnel_pages[].${field}`]),
|
|
94
|
+
...Object.keys(ROUTING_HINT_FIELDS).flatMap((tag) => [`funnels[].pages[].sdk_hints.meta_tags.${tag}`, `funnel_pages[].sdk_hints.meta_tags.${tag}`]),
|
|
95
|
+
...MIRRORED_PACKAGE_FIELDS.flatMap((field) => [`funnels[].pages[].packages[].${field}`, `funnel_pages[].packages[].${field}`]),
|
|
96
|
+
"offers",
|
|
97
|
+
...MIRRORED_OFFER_FIELDS.map((field) => `offers[].${field}`),
|
|
98
|
+
...MIRRORED_PAGE_OFFER_FIELDS.flatMap((field) => [`funnels[].pages[].offers[].${field}`, `funnel_pages[].offers[].${field}`]),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// campaigns.json analytics keys and the spec provider field each one derives.
|
|
102
|
+
export const ANALYTICS_ID_FIELDS = Object.freeze([
|
|
103
|
+
Object.freeze({ key: "gtm_id", provider: "gtm", property: "containerId", shape: /^GTM-[A-Z0-9]{4,}$/, describe: "a GTM container id (GTM-XXXXXXX)" }),
|
|
104
|
+
Object.freeze({ key: "fb_pixel_id", provider: "facebook", property: "pixelId", shape: /^\d{5,20}$/, describe: "a Meta pixel id (digits only)" }),
|
|
105
|
+
]);
|
|
106
|
+
|
|
58
107
|
// The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
|
|
59
108
|
// on 0.2.x everything else under the campaign root, `assets/` and `_data/`
|
|
60
109
|
// included, is rendered when it is .html), plus the two no build reads. The
|
|
@@ -200,14 +249,80 @@ export function normalizePermalinkValue(value) {
|
|
|
200
249
|
// the block or the key is absent. A BOM and CRLF are read as page-kit's
|
|
201
250
|
// frontmatter reader reads them.
|
|
202
251
|
export function frontmatterPermalink(text) {
|
|
252
|
+
return frontmatterScalars(text, ["permalink"]).permalink;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The frontmatter keys a page file states that derive reads after handoff:
|
|
256
|
+
// the page-kit page_type (which makes a file a funnel page) and the links
|
|
257
|
+
// prepare-build projects between pages (next_url, decline_url, next_step).
|
|
258
|
+
export const PAGE_FILE_FRONTMATTER_KEYS = Object.freeze(["permalink", "page_type", "next_url", "decline_url", "next_step", "step_number"]);
|
|
259
|
+
|
|
260
|
+
// The raw scalar of each named key in a page file's frontmatter (the first
|
|
261
|
+
// line of the block that declares it), read as frontmatterPermalink reads a
|
|
262
|
+
// permalink; a key absent from the block, or a file with no block, is null.
|
|
263
|
+
export function frontmatterScalars(text, keys = PAGE_FILE_FRONTMATTER_KEYS) {
|
|
264
|
+
const values = Object.fromEntries(keys.map((key) => [key, null]));
|
|
203
265
|
const normalized = String(text ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
204
266
|
const block = normalized.match(/^---\n([\s\S]*?)\n---/);
|
|
205
|
-
if (!block) return
|
|
267
|
+
if (!block) return values;
|
|
206
268
|
for (const line of block[1].split("\n")) {
|
|
207
|
-
const match = line.match(/^
|
|
208
|
-
if (match
|
|
269
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
|
|
270
|
+
if (!match || !Object.hasOwn(values, match[1]) || values[match[1]] !== null) continue;
|
|
271
|
+
values[match[1]] = normalizePermalinkValue(match[2]);
|
|
209
272
|
}
|
|
210
|
-
return
|
|
273
|
+
return values;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The routing hints whose value no longer matches the route of the page they
|
|
278
|
+
* name (#531). A page's `sdk_hints.meta_tags` routing tag is the route of the
|
|
279
|
+
* page its declared routing field names (next-success-url: success_url, and
|
|
280
|
+
* so on); compared as the SDK composes it (slug prefix stripped, terminal
|
|
281
|
+
* segment), a hint that differs sends the shopper somewhere the funnel no
|
|
282
|
+
* longer serves. With `routeByPageId` (the routes derive is about to write)
|
|
283
|
+
* only the pages it names are compared; without it every target page's
|
|
284
|
+
* current route is. spec derive warns with this, doctor blocks on it, and
|
|
285
|
+
* record spec refuses a routes-only bind while any hint is stale: one rule,
|
|
286
|
+
* one owner.
|
|
287
|
+
*/
|
|
288
|
+
export function staleRoutingHints(spec, { publicRouteSlug = "", routeByPageId = null } = {}) {
|
|
289
|
+
const pages = activePagesWithPaths(spec).map(({ page }) => page);
|
|
290
|
+
const byId = new Map();
|
|
291
|
+
for (const page of pages) if (!byId.has(page.id)) byId.set(page.id, page);
|
|
292
|
+
const stale = [];
|
|
293
|
+
for (const page of pages) {
|
|
294
|
+
const metaTags = page.sdk_hints?.meta_tags;
|
|
295
|
+
if (!isPlainObject(metaTags)) continue;
|
|
296
|
+
for (const [tag, routingField] of Object.entries(ROUTING_HINT_FIELDS)) {
|
|
297
|
+
const value = metaTags[tag];
|
|
298
|
+
const targetId = page[routingField];
|
|
299
|
+
if (!isNonEmptyString(value) || !isNonEmptyString(targetId)) continue;
|
|
300
|
+
let route;
|
|
301
|
+
if (routeByPageId instanceof Map) {
|
|
302
|
+
if (!routeByPageId.has(targetId)) continue;
|
|
303
|
+
route = routeByPageId.get(targetId);
|
|
304
|
+
} else {
|
|
305
|
+
if (!byId.has(targetId)) continue;
|
|
306
|
+
route = publicRouteForPage(byId.get(targetId));
|
|
307
|
+
}
|
|
308
|
+
if (runtimeRelativeRouteForSpecValue(value, publicRouteSlug) === runtimeRelativeRouteForSpecValue(route, publicRouteSlug)) continue;
|
|
309
|
+
stale.push({ page_id: page.id, tag, value, target_page_id: targetId, derived_route: route });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return stale;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A routing hint rewritten to `route`, spelled the way the hint it replaces
|
|
316
|
+
// was: the prefix in front of its runtime-relative route ("/slug/" in
|
|
317
|
+
// "/slug/receipt/", nothing in "receipt/") is kept, so a rooted hint stays
|
|
318
|
+
// rooted.
|
|
319
|
+
function routingHintValue(previous, route, publicRouteSlug) {
|
|
320
|
+
const relative = runtimeRelativeRouteForSpecValue(route, publicRouteSlug);
|
|
321
|
+
const raw = previous.trim().replace(/[?#].*$/, "");
|
|
322
|
+
const withSlash = raw.endsWith("/") ? raw : `${raw}/`;
|
|
323
|
+
const old = runtimeRelativeRouteForSpecValue(previous, publicRouteSlug);
|
|
324
|
+
const prefix = old && withSlash.endsWith(old) ? withSlash.slice(0, withSlash.length - old.length) : "";
|
|
325
|
+
return `${prefix}${relative}`;
|
|
211
326
|
}
|
|
212
327
|
|
|
213
328
|
function terminalSegment(route) {
|
|
@@ -262,6 +377,221 @@ function pathLabel(path) {
|
|
|
262
377
|
return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
|
|
263
378
|
}
|
|
264
379
|
|
|
380
|
+
// The CampaignSpec page type a page-kit page_type stands for when the tree
|
|
381
|
+
// adds a page. page-kit's four runtime types are coarser than the spec's, so
|
|
382
|
+
// product reads as a landing page; a page that is something else (a
|
|
383
|
+
// presell, a downsell) has its type authored after the add. prepare-build
|
|
384
|
+
// writes next_step on every page before the Checkout and step_number only on a
|
|
385
|
+
// checkout_step, so a checkout file with next_step is a checkout_step when it
|
|
386
|
+
// has a step_number and a select page when it has none.
|
|
387
|
+
const SPEC_TYPE_FOR_PAGE_KIT_TYPE = Object.freeze({ product: "landing", checkout: "checkout", upsell: "upsell", receipt: "thankyou" });
|
|
388
|
+
function specTypeForPageFile(file) {
|
|
389
|
+
if (file.page_type === "checkout" && isNonEmptyString(file.next_step)) {
|
|
390
|
+
return file.step_number != null && String(file.step_number).trim() !== "" ? "checkout_step" : "select";
|
|
391
|
+
}
|
|
392
|
+
return Object.hasOwn(SPEC_TYPE_FOR_PAGE_KIT_TYPE, file.page_type ?? "") ? SPEC_TYPE_FOR_PAGE_KIT_TYPE[file.page_type] : undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// checkout_step is a CampaignSpec v5 type. A v4 spec states a step as a
|
|
396
|
+
// checkout that forwards to another checkout (upgradeCampaignSpec retypes it
|
|
397
|
+
// on read), so that is how a step is added to one.
|
|
398
|
+
function writtenPageType(type, spec) {
|
|
399
|
+
const version = typeof spec?.schema_version === "string" ? spec.schema_version.trim() : "";
|
|
400
|
+
return type === "checkout_step" && !/^5(\.|$)/.test(version) ? "checkout" : type;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The frontmatter link that states each routing field, by spec page type: the
|
|
404
|
+
// keys prepare-build projects (source-html-intake.mjs) read back the other
|
|
405
|
+
// way. A select or checkout_step page's next_url is the Checkout's
|
|
406
|
+
// destination, not its own forward link, so only next_step is read there.
|
|
407
|
+
function linkFieldsForType(type) {
|
|
408
|
+
if (type === "upsell" || type === "downsell") return [["next_url", "on_accept"], ["decline_url", "on_decline"]];
|
|
409
|
+
if (type === "checkout") return [["next_url", "success_url"]];
|
|
410
|
+
if (type === "select" || type === "checkout_step") return [["next_step", "next_page"]];
|
|
411
|
+
if (type === "thankyou" || type === "receipt") return [];
|
|
412
|
+
return [["next_url", "next_page"]];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const PAGE_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
416
|
+
|
|
417
|
+
// Which pages a handed-off tree adds and removes (#721), and the routing on
|
|
418
|
+
// the edges that name them. Matching is by page id, never by position:
|
|
419
|
+
// - added: a file with a page-kit page_type in its frontmatter that no spec
|
|
420
|
+
// page binds (and no page could be, ambiguously). Its id is the file's
|
|
421
|
+
// name, its type follows page_type, its route is the route the file
|
|
422
|
+
// builds to, and it is appended to the funnel's pages;
|
|
423
|
+
// - removed: a page whose file the packet's projection named
|
|
424
|
+
// (source_html.pages[].page_kit.target_path) and the tree no longer has.
|
|
425
|
+
// A page the build never wrote is still only "not found";
|
|
426
|
+
// - routing: the added page's own frontmatter links set its routing fields,
|
|
427
|
+
// and a kept page whose link now names an added page, or whose routing
|
|
428
|
+
// named a removed one, is re-pointed at the page its link names.
|
|
429
|
+
// Page order is not something the tree states: an added page is appended,
|
|
430
|
+
// and where the funnel's pages carry an `order` the new page's is reported
|
|
431
|
+
// as not derived. Before handoff nothing is added or removed; the files no
|
|
432
|
+
// page binds are listed as unclaimed.
|
|
433
|
+
function planPageScope({ spec, activePages, pageFiles, packetBindings, boundFiles, ambiguousFiles, notFound, derivedRouteByPageId, publicRouteSlug, handedOff }) {
|
|
434
|
+
const scope = { handed_off: Boolean(handedOff), added: [], removed: [], unclaimed: [], changes: [], not_derived: [] };
|
|
435
|
+
const unbound = pageFiles.filter((file) => !boundFiles.has(file.path) && !ambiguousFiles.has(file.path) && file.route !== null && !file.problem && isNonEmptyString(file.page_type));
|
|
436
|
+
if (!handedOff) {
|
|
437
|
+
scope.unclaimed = unbound.map((file) => file.path);
|
|
438
|
+
return scope;
|
|
439
|
+
}
|
|
440
|
+
const bindings = packetBindings instanceof Map ? packetBindings : new Map();
|
|
441
|
+
const gone = notFound.filter(({ page }) => isNonEmptyString(bindings.get(page.id)));
|
|
442
|
+
const funnels = Array.isArray(spec?.funnels) ? spec.funnels : null;
|
|
443
|
+
const container = funnels
|
|
444
|
+
? (funnels.length === 1 && Array.isArray(funnels[0]?.pages) ? ["funnels", 0, "pages"] : null)
|
|
445
|
+
: Array.isArray(spec?.funnel_pages) ? ["funnel_pages"] : null;
|
|
446
|
+
const containerPages = container ? (container.length === 1 ? spec.funnel_pages : spec.funnels[0].pages) : [];
|
|
447
|
+
const mirror = funnels && Array.isArray(spec?.funnel_pages) ? spec.funnel_pages : null;
|
|
448
|
+
const mirrorIndexOf = (id) => (mirror ? mirror.findIndex((page) => isPlainObject(page) && page.id === id) : -1);
|
|
449
|
+
|
|
450
|
+
const takenIds = new Set();
|
|
451
|
+
for (const list of [...(funnels || []).map((funnel) => funnel?.pages), spec?.funnel_pages]) {
|
|
452
|
+
for (const page of Array.isArray(list) ? list : []) if (isPlainObject(page) && isNonEmptyString(page.id)) takenIds.add(page.id);
|
|
453
|
+
}
|
|
454
|
+
const added = [];
|
|
455
|
+
for (const file of unbound) {
|
|
456
|
+
const id = file.basename;
|
|
457
|
+
const label = `page file ${file.path}`;
|
|
458
|
+
const type = specTypeForPageFile(file);
|
|
459
|
+
if (!type) {
|
|
460
|
+
scope.not_derived.push({ field: label, page_id: id, reason: "page_type_unknown", detail: `${file.path} declares page_type ${quoteValue(file.page_type)}, which is not one of page-kit's product, checkout, upsell or receipt; the page was not added. Correct the frontmatter, then derive again.` });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!PAGE_ID_SHAPE.test(id) || id === "index" || takenIds.has(id)) {
|
|
464
|
+
scope.not_derived.push({ field: label, page_id: id, reason: "page_id_unusable", detail: `${file.path} would add page "${id}", but ${takenIds.has(id) ? "the spec already has a page with that id" : "that is not a usable page id (letters, digits, - and _, not the entry file)"}; rename the file, then derive again.` });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (!container) {
|
|
468
|
+
scope.not_derived.push({ field: label, page_id: id, reason: "page_funnel_ambiguous", detail: `${file.path} is a page the spec does not have, but the spec has ${funnels ? `${funnels.length} funnels` : "no page list"}, so which funnel it joins is not something the tree states. Add the page to the right funnel by hand, then derive again.` });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
takenIds.add(id);
|
|
472
|
+
added.push({ file, id, type });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A page whose file is gone and a new file of the same page-kit page_type
|
|
476
|
+
// in one run is most likely a rename (upsell.html to upsell-1.html, or a
|
|
477
|
+
// downsell's downsell.html, which prepare-build writes as page_type upsell,
|
|
478
|
+
// to downsell-1.html). Applied as written, the remove would discard the
|
|
479
|
+
// page's authored record (its packages, offers, label and hints) and the
|
|
480
|
+
// add would start a bare one, so neither is applied: the pair is reported
|
|
481
|
+
// for a person to resolve. The comparison is in page-kit's types, the only
|
|
482
|
+
// type the new file states.
|
|
483
|
+
const goneKitTypes = new Set(gone.map(({ page }) => cpkPageTypeForSpecType(page.type)));
|
|
484
|
+
const renamedKitTypes = new Set(added.filter(({ file }) => goneKitTypes.has(file.page_type)).map(({ file }) => file.page_type));
|
|
485
|
+
const held = (kitType) => renamedKitTypes.has(kitType);
|
|
486
|
+
for (const { file, id } of added.filter((entry) => held(entry.file.page_type))) {
|
|
487
|
+
const was = gone.filter(({ page }) => cpkPageTypeForSpecType(page.type) === file.page_type).map(({ page }) => `"${page.id}"`).join(", ");
|
|
488
|
+
scope.not_derived.push({ field: `page file ${file.path}`, page_id: id, reason: "page_renamed", detail: `${file.path} is a new page_type ${file.page_type} page and the file of page ${was} (also page_type ${file.page_type}) is gone, which reads as a rename; derive applies neither, so the page's packages, offers and other authored fields are not lost. If it is a rename, set that page's page_url to ${JSON.stringify(file.route)} (it keeps its id), or rename the file back. If it is a new page, remove the old page from the spec by hand. Then derive again.` });
|
|
489
|
+
}
|
|
490
|
+
for (const { page, path } of gone) {
|
|
491
|
+
const kitType = cpkPageTypeForSpecType(page.type);
|
|
492
|
+
if (held(kitType)) {
|
|
493
|
+
scope.not_derived.push({ field: pathLabel(path), page_id: page.id, reason: "page_renamed", detail: `the file of page "${page.id}" (${bindings.get(page.id).replace(/^\/+/, "")}) is gone and the tree has a new page_type ${kitType} page file, which reads as a rename; derive did not remove the page. See the page_renamed row for the new file.` });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
scope.removed.push({ page_id: page.id, file: bindings.get(page.id).replace(/^\/+/, "") });
|
|
497
|
+
scope.changes.push({ field: pathLabel(path), page_id: page.id, path, op: "remove", before: page, after: undefined, source: `${bindings.get(page.id).replace(/^\/+/, "")} (no longer in the page tree)` });
|
|
498
|
+
const at = mirrorIndexOf(page.id);
|
|
499
|
+
if (at !== -1) scope.changes.push({ field: `funnel_pages[${at}]`, page_id: page.id, path: ["funnel_pages", at], op: "remove", before: mirror[at], after: undefined, source: `mirror of ${pathLabel(path)}` });
|
|
500
|
+
}
|
|
501
|
+
if (renamedKitTypes.size) added.splice(0, added.length, ...added.filter((entry) => !held(entry.file.page_type)));
|
|
502
|
+
|
|
503
|
+
// The routes of the pages the spec will hold, for resolving links.
|
|
504
|
+
const finalRoutes = new Map([...derivedRouteByPageId].filter(([id]) => !scope.removed.some((removed) => removed.page_id === id)));
|
|
505
|
+
for (const { id, file } of added) finalRoutes.set(id, file.route);
|
|
506
|
+
const resolveLink = (value) => {
|
|
507
|
+
if (!isNonEmptyString(value) || value.trim().startsWith("#") || isAbsoluteHttpUrl(value.trim())) return null;
|
|
508
|
+
const wanted = runtimeRelativeRouteForSpecValue(value, publicRouteSlug);
|
|
509
|
+
const matches = [...finalRoutes].filter(([, route]) => runtimeRelativeRouteForSpecValue(route, publicRouteSlug) === wanted).map(([id]) => id);
|
|
510
|
+
return matches.length === 1 ? matches[0] : null;
|
|
511
|
+
};
|
|
512
|
+
const addedIds = new Set(added.map((entry) => entry.id));
|
|
513
|
+
const removedIds = new Set(scope.removed.map((entry) => entry.page_id));
|
|
514
|
+
|
|
515
|
+
const hasOrder = containerPages.some((page) => isPlainObject(page) && typeof page.order === "number");
|
|
516
|
+
added.forEach(({ file, id, type }, offset) => {
|
|
517
|
+
const path = [...container, containerPages.length + offset];
|
|
518
|
+
const page = { id, type: writtenPageType(type, spec), page_url: file.route };
|
|
519
|
+
for (const [key, field] of linkFieldsForType(type)) {
|
|
520
|
+
const target = resolveLink(file[key]);
|
|
521
|
+
if (target && target !== id) page[field] = target;
|
|
522
|
+
else {
|
|
523
|
+
scope.not_derived.push({ field: `${pathLabel(path)}.${field}`, page_id: id, reason: "routing_not_derived", detail: file[key]
|
|
524
|
+
? `${file.path} links ${key} to ${quoteValue(file[key])}, which is not the route of exactly one page; set ${field} on page "${id}" by hand.`
|
|
525
|
+
: `${file.path} states no ${key}, so page "${id}"'s ${field} is not something the tree states; set it by hand.` });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
scope.added.push({ page_id: id, file: file.path, type: page.type, route: file.route });
|
|
529
|
+
if (type !== "thankyou") {
|
|
530
|
+
scope.not_derived.push({ field: `${pathLabel(path)}.packages`, page_id: id, reason: "page_packages_not_derived", detail: `page "${id}" was added from ${file.path}; which packages it sells is authored, so set them by hand. Until each path into the Checkout passes a page that sells a main-cart package, and each upsell or downsell has its offer package, doctor blocks (CheckoutPathHasPackages, UpsellHasPackages).` });
|
|
531
|
+
}
|
|
532
|
+
scope.changes.push({ field: pathLabel(path), page_id: id, path, op: "insert", before: undefined, after: page, source: `${file.path} (page_type ${file.page_type})` });
|
|
533
|
+
if (mirror) {
|
|
534
|
+
const mirrorPath = ["funnel_pages", mirror.length + offset];
|
|
535
|
+
scope.changes.push({ field: pathLabel(mirrorPath), page_id: id, path: mirrorPath, op: "insert", before: undefined, after: { ...page }, source: `mirror of ${pathLabel(path)}` });
|
|
536
|
+
}
|
|
537
|
+
if (hasOrder) {
|
|
538
|
+
scope.not_derived.push({ field: `${pathLabel(path)}.order`, page_id: id, reason: "page_order_not_derived", detail: `page "${id}" was appended to the funnel's pages; the repo states its links, not its position, so its order is left for you to set.` });
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// The kept pages' edges that name an added or a removed page. Re-pointing
|
|
543
|
+
// an edge also writes the routing hint that goes with it (when the page
|
|
544
|
+
// carries one) to the new target's route, and the legacy mirror's copy of
|
|
545
|
+
// both: after handoff no editor regenerates them, and a hint left on the
|
|
546
|
+
// old route is what doctor and record spec then refuse.
|
|
547
|
+
const fileByPageId = new Map();
|
|
548
|
+
for (const file of pageFiles) if (boundFiles.has(file.path)) fileByPageId.set(boundFiles.get(file.path), file);
|
|
549
|
+
const hintTagFor = new Map(Object.entries(ROUTING_HINT_FIELDS).map(([tag, field]) => [field, tag]));
|
|
550
|
+
const repoint = (page, path, field, before, after, source) => {
|
|
551
|
+
const rows = [{ field: `${pathLabel(path)}.${field}`, page_id: page.id, path: [...path, field], before, after, source }];
|
|
552
|
+
const tag = hintTagFor.get(field);
|
|
553
|
+
const hintRow = (record, recordPath) => {
|
|
554
|
+
const value = record?.sdk_hints?.meta_tags?.[tag];
|
|
555
|
+
if (!tag || !isNonEmptyString(value) || isAbsoluteHttpUrl(value.trim()) || !finalRoutes.has(after)) return null;
|
|
556
|
+
const next = routingHintValue(value, finalRoutes.get(after), publicRouteSlug);
|
|
557
|
+
if (next === value) return null;
|
|
558
|
+
return { field: `${pathLabel(recordPath)}.sdk_hints.meta_tags.${tag}`, page_id: page.id, path: [...recordPath, "sdk_hints", "meta_tags", tag], before: value, after: next, source: `route of page "${after}" (${source})` };
|
|
559
|
+
};
|
|
560
|
+
const own = hintRow(page, path);
|
|
561
|
+
if (own) rows.push(own);
|
|
562
|
+
const at = path[0] === "funnels" ? mirrorIndexOf(page.id) : -1;
|
|
563
|
+
if (at !== -1) {
|
|
564
|
+
const mirrorPath = ["funnel_pages", at];
|
|
565
|
+
if (isPlainObject(mirror[at]) && mirror[at][field] !== after) rows.push({ field: `${pathLabel(mirrorPath)}.${field}`, page_id: page.id, path: [...mirrorPath, field], before: mirror[at][field], after, source: `mirror of ${pathLabel(path)}.${field}` });
|
|
566
|
+
const mirrorHint = hintRow(mirror[at], mirrorPath);
|
|
567
|
+
if (mirrorHint) rows.push(mirrorHint);
|
|
568
|
+
}
|
|
569
|
+
scope.changes.push(...rows);
|
|
570
|
+
};
|
|
571
|
+
for (const { page, path } of activePages) {
|
|
572
|
+
if (removedIds.has(page.id)) continue;
|
|
573
|
+
const file = fileByPageId.get(page.id);
|
|
574
|
+
// A v4 spec states a step as a checkout; its file carries next_step,
|
|
575
|
+
// which only a page before the Checkout has, and that is its forward link.
|
|
576
|
+
const linkType = page.type === "checkout" && isNonEmptyString(file?.next_step) ? "checkout_step" : page.type;
|
|
577
|
+
for (const [key, linkField] of linkFieldsForType(linkType)) {
|
|
578
|
+
// A checkout with no success_url forwards through next_page (the
|
|
579
|
+
// generic fallback, campaign-spec/routing.ts), so that is the field
|
|
580
|
+
// its next_url re-points.
|
|
581
|
+
const field = linkField === "success_url" && !isNonEmptyString(page.success_url) && isNonEmptyString(page.next_page) ? "next_page" : linkField;
|
|
582
|
+
const current = isNonEmptyString(page[field]) ? page[field] : undefined;
|
|
583
|
+
const target = file ? resolveLink(file[key]) : null;
|
|
584
|
+
if (target && addedIds.has(target) && current !== target) {
|
|
585
|
+
repoint(page, path, field, current, target, `${file.path} frontmatter ${key}`);
|
|
586
|
+
} else if (current && removedIds.has(current)) {
|
|
587
|
+
if (target && target !== current) repoint(page, path, field, current, target, `${file.path} frontmatter ${key}`);
|
|
588
|
+
else scope.not_derived.push({ field: `${pathLabel(path)}.${field}`, page_id: page.id, reason: "routing_target_removed", detail: `page "${page.id}" routes ${field} to "${current}", which the tree removed, and ${file ? `${file.path}'s ${key} names no page the spec keeps` : "no page file states where it goes now"}; set ${field} by hand.` });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return scope;
|
|
593
|
+
}
|
|
594
|
+
|
|
265
595
|
// The field-by-field plan. `changes` are the derived fields whose value will
|
|
266
596
|
// move, `unchanged` already match, `not_derived` are derived fields the repo
|
|
267
597
|
// cannot state for this campaign right now (with the reason), `not_in_target`
|
|
@@ -275,7 +605,7 @@ function pathLabel(path) {
|
|
|
275
605
|
// does not exist); `packetBindings` as a Map of page id -> target file path
|
|
276
606
|
// from the packet's page-kit projection; `waivedGates` as the checkpoint
|
|
277
607
|
// gates an active named-human waiver currently covers.
|
|
278
|
-
export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "" } = {}) {
|
|
608
|
+
export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "", handedOff = false } = {}) {
|
|
279
609
|
const target = isPlainObject(entry) ? entry : {};
|
|
280
610
|
const changes = [];
|
|
281
611
|
const unchanged = [];
|
|
@@ -348,8 +678,14 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
|
|
|
348
678
|
for (const { page } of activePages) idCounts.set(page.id, (idCounts.get(page.id) || 0) + 1);
|
|
349
679
|
// The route every bound page derives to, changed or not: the standing
|
|
350
680
|
// check on routing hints reads it, so a hint left stale by an earlier run
|
|
351
|
-
// keeps surfacing until the
|
|
681
|
+
// keeps surfacing until the hint is fixed.
|
|
352
682
|
const derivedRouteByPageId = new Map();
|
|
683
|
+
// What the page-scope plan reads: the files a page binds, the files more
|
|
684
|
+
// than one page could be, and the pages no file binds.
|
|
685
|
+
const boundFiles = new Map();
|
|
686
|
+
const ambiguousFiles = new Set();
|
|
687
|
+
const notFound = [];
|
|
688
|
+
let pageScope = { handed_off: handedOff, added: [], removed: [], unclaimed: [], changes: [], not_derived: [] };
|
|
353
689
|
if (pageFiles === null) {
|
|
354
690
|
for (const { page, path } of activePages) {
|
|
355
691
|
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.` });
|
|
@@ -369,11 +705,17 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
|
|
|
369
705
|
}
|
|
370
706
|
const binding = bindPageFile(page, pageFiles, packetBindings);
|
|
371
707
|
if (!binding.file) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
708
|
+
if (binding.via === "ambiguous") {
|
|
709
|
+
for (const candidate of binding.candidates) ambiguousFiles.add(candidate);
|
|
710
|
+
notDerived.push({ 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.` });
|
|
711
|
+
} else {
|
|
712
|
+
const row = { 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.` };
|
|
713
|
+
notFound.push({ page, path, row });
|
|
714
|
+
notDerived.push(row);
|
|
715
|
+
}
|
|
375
716
|
continue;
|
|
376
717
|
}
|
|
718
|
+
boundFiles.set(binding.file.path, page.id);
|
|
377
719
|
const after = binding.file.route;
|
|
378
720
|
const before = Object.hasOwn(page, "page_url") ? page.page_url : undefined;
|
|
379
721
|
const source = `${binding.file.path}${binding.file.permalink ? " (permalink)" : ""}`;
|
|
@@ -421,37 +763,55 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
|
|
|
421
763
|
}
|
|
422
764
|
}
|
|
423
765
|
}
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
766
|
+
// The pages the tree adds or removes (#721), matched by id.
|
|
767
|
+
pageScope = planPageScope({ spec, activePages, pageFiles, packetBindings, boundFiles, ambiguousFiles, notFound, derivedRouteByPageId, publicRouteSlug, handedOff });
|
|
768
|
+
changes.push(...pageScope.changes);
|
|
769
|
+
notDerived.push(...pageScope.not_derived);
|
|
770
|
+
|
|
771
|
+
// A routing hint that names a page whose route it does not match is
|
|
772
|
+
// stale, whether the route moved in this run or an earlier one. It is
|
|
773
|
+
// read off the spec as this plan leaves it, so a hint the plan rewrites
|
|
774
|
+
// is not reported and one an added or removed page leaves behind is:
|
|
775
|
+
// the same list doctor blocks on and record spec refuses after the
|
|
776
|
+
// write. Hints are a spec projection (doctor reads them as build
|
|
777
|
+
// expectations); derive rewrites only the hint on an edge it re-points.
|
|
778
|
+
const routesAfter = new Map([...derivedRouteByPageId].filter(([id]) => !pageScope.removed.some((entry) => entry.page_id === id)));
|
|
779
|
+
for (const entry of pageScope.added) routesAfter.set(entry.page_id, entry.route);
|
|
780
|
+
let planned = null;
|
|
781
|
+
try {
|
|
782
|
+
planned = applySpecDerive(structuredClone(spec), { changes });
|
|
783
|
+
} catch {
|
|
784
|
+
// A plan the guard would refuse is reported by the write; the hints
|
|
785
|
+
// are then read off the spec as it is.
|
|
439
786
|
}
|
|
787
|
+
staleHints.push(...staleRoutingHints(planned || spec, { publicRouteSlug, routeByPageId: routesAfter }));
|
|
788
|
+
}
|
|
789
|
+
// A page the tree no longer builds is reported as not found unless this run
|
|
790
|
+
// removes it.
|
|
791
|
+
for (const removed of pageScope.removed) {
|
|
792
|
+
const at = notDerived.findIndex((row) => row.reason === "page_file_not_found" && row.page_id === removed.page_id);
|
|
793
|
+
if (at !== -1) notDerived.splice(at, 1);
|
|
440
794
|
}
|
|
441
795
|
|
|
442
796
|
// Analytics ids the entry carries. A usable id is written into the provider
|
|
443
|
-
// block (created when the spec lacks it). An empty repo value
|
|
444
|
-
// a spec id: the mismatch is reported so someone decides
|
|
445
|
-
// wrong.
|
|
446
|
-
|
|
447
|
-
|
|
797
|
+
// or vendor block (created when the spec lacks it). An empty repo value
|
|
798
|
+
// never deletes a spec id: the mismatch is reported so someone decides
|
|
799
|
+
// which side is wrong. A vendor neither side carries is not listed under
|
|
800
|
+
// not_in_target: the template vendors are opt-in (absent means off), so
|
|
801
|
+
// eight "not in target" lines on every run would say nothing.
|
|
802
|
+
const analyticsIdRows = [
|
|
803
|
+
...ANALYTICS_ID_FIELDS.map(({ key, provider, property, shape, describe }) => ({ key, block: "providers", name: provider, property, shape, describe, optIn: false })),
|
|
804
|
+
...ANALYTICS_VENDOR_ID_FIELDS.map(({ key, vendor, property, shape, describe }) => ({ key, block: "vendors", name: vendor, property, shape, describe, optIn: true })),
|
|
805
|
+
];
|
|
806
|
+
for (const { key, block, name, property, shape, describe, optIn } of analyticsIdRows) {
|
|
807
|
+
const field = `analytics.${block}.${name}.${property}`;
|
|
448
808
|
const raw = Object.hasOwn(target, key) ? target[key] : undefined;
|
|
449
|
-
const current = spec?.analytics?.
|
|
809
|
+
const current = spec?.analytics?.[block]?.[name]?.[property];
|
|
450
810
|
const source = entrySource(key);
|
|
451
811
|
if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) {
|
|
452
812
|
if (isNonEmptyString(current)) {
|
|
453
813
|
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.` });
|
|
454
|
-
} else notInTarget.push(field);
|
|
814
|
+
} else if (!optIn) notInTarget.push(field);
|
|
455
815
|
continue;
|
|
456
816
|
}
|
|
457
817
|
if (typeof raw !== "string" || hasControlCharacters(raw) || !shape.test(raw.trim())) {
|
|
@@ -459,11 +819,14 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
|
|
|
459
819
|
continue;
|
|
460
820
|
}
|
|
461
821
|
const after = raw.trim();
|
|
462
|
-
if (isPlaceholderId(after)) {
|
|
463
|
-
|
|
822
|
+
if (isPlaceholderId(after) || (block === "vendors" && isPlaceholderVendorId(after))) {
|
|
823
|
+
const consequence = block === "vendors"
|
|
824
|
+
? "the template loads the vendor for any non-empty value, so the campaign is sending to a vendor account that does not exist"
|
|
825
|
+
: "writing it would declare an analytics contract QA then blocks on";
|
|
826
|
+
notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${quoteValue(after)} is a placeholder, not a real id; ${consequence}. Replace it in ${source} (or clear it), then derive again.` });
|
|
464
827
|
continue;
|
|
465
828
|
}
|
|
466
|
-
const row = { field, path: ["analytics",
|
|
829
|
+
const row = { field, path: ["analytics", block, name, property], before: current, after, source };
|
|
467
830
|
const problem = containerProblem(spec, row.path);
|
|
468
831
|
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.` });
|
|
469
832
|
else if (current === after) unchanged.push(row);
|
|
@@ -475,8 +838,26 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
|
|
|
475
838
|
const createdBlocks = ANALYTICS_ID_FIELDS
|
|
476
839
|
.filter(({ provider, property }) => changes.some((row) => row.field === `analytics.providers.${provider}.${property}`) && !isPlainObject(spec?.analytics?.providers?.[provider]))
|
|
477
840
|
.map(({ provider }) => `analytics.providers.${provider}`);
|
|
841
|
+
// A vendor block derive will create declares the vendor: doctor then reads
|
|
842
|
+
// its campaigns.json key against the spec. When the spec had no analytics
|
|
843
|
+
// block at all, the new block also turns on QA's analytics leg.
|
|
844
|
+
const createdVendorBlocks = ANALYTICS_VENDOR_ID_FIELDS
|
|
845
|
+
.filter(({ vendor, property }) => changes.some((row) => row.field === `analytics.vendors.${vendor}.${property}`) && !isPlainObject(spec?.analytics?.vendors?.[vendor]))
|
|
846
|
+
.map(({ vendor }) => `analytics.vendors.${vendor}`);
|
|
847
|
+
const analyticsCreated = (createdBlocks.length > 0 || createdVendorBlocks.length > 0) && !isPlainObject(spec?.analytics);
|
|
478
848
|
|
|
479
|
-
|
|
849
|
+
const pageScopeSummary = { handed_off: pageScope.handed_off, added: pageScope.added, removed: pageScope.removed, unclaimed: pageScope.unclaimed };
|
|
850
|
+
return {
|
|
851
|
+
changes,
|
|
852
|
+
unchanged,
|
|
853
|
+
not_derived: notDerived,
|
|
854
|
+
not_in_target: notInTarget,
|
|
855
|
+
stale_hints: staleHints,
|
|
856
|
+
created_blocks: [...new Set(createdBlocks)],
|
|
857
|
+
created_vendor_blocks: createdVendorBlocks,
|
|
858
|
+
analytics_created: analyticsCreated,
|
|
859
|
+
page_scope: pageScopeSummary,
|
|
860
|
+
};
|
|
480
861
|
}
|
|
481
862
|
|
|
482
863
|
function pathMatchesPattern(path, pattern) {
|
|
@@ -487,16 +868,35 @@ function pathMatchesPattern(path, pattern) {
|
|
|
487
868
|
|
|
488
869
|
// Apply a plan to the parsed spec. Mutates ONLY the derived field each change
|
|
489
870
|
// names, in place, so key order and every other key survive; a missing
|
|
490
|
-
// container object is created (`global_config
|
|
491
|
-
// as `
|
|
492
|
-
//
|
|
871
|
+
// container object is created (`global_config`; a provider or vendor block
|
|
872
|
+
// such as `analytics.providers.gtm` or `analytics.vendors.ga4` as
|
|
873
|
+
// `{ enabled: true }`), a missing array element is never invented. A row
|
|
874
|
+
// with `op` adds ("insert", appended at its index) or removes ("remove") a
|
|
875
|
+
// whole element of a list derive governs (a page, an offer record): field
|
|
876
|
+
// writes go first, against the indices the plan read, then inserts, then
|
|
877
|
+
// removals from the highest index down, so no write lands on a shifted
|
|
878
|
+
// element. Returns the same spec object.
|
|
493
879
|
export function applySpecDerive(spec, plan) {
|
|
494
880
|
if (!isPlainObject(spec)) throw new Error("CampaignSpec must be a JSON object.");
|
|
881
|
+
const writable = [...SPEC_DERIVE_FIELDS, ...SPEC_DERIVE_MIRROR_FIELDS];
|
|
882
|
+
const fieldRows = [];
|
|
883
|
+
const inserts = [];
|
|
884
|
+
const removes = [];
|
|
495
885
|
for (const change of plan.changes) {
|
|
496
886
|
const path = Array.isArray(change.path) ? change.path : [];
|
|
497
|
-
if (
|
|
498
|
-
|
|
887
|
+
if (change.op === "insert" || change.op === "remove") {
|
|
888
|
+
if (!SPEC_DERIVE_ELEMENTS.some((pattern) => pathMatchesPattern(path, pattern))) {
|
|
889
|
+
throw new Error(`Refusing to ${change.op === "insert" ? "add" : "remove"} "${change.field}": spec derive adds and removes only ${SPEC_DERIVE_ELEMENTS.join(", ")}.`);
|
|
890
|
+
}
|
|
891
|
+
(change.op === "insert" ? inserts : removes).push({ change, path });
|
|
892
|
+
continue;
|
|
499
893
|
}
|
|
894
|
+
if (!writable.some((pattern) => pathMatchesPattern(path, pattern))) {
|
|
895
|
+
throw new Error(`Refusing to write "${change.field}": spec derive governs only ${SPEC_DERIVE_FIELDS.join(", ")} (and, after handoff or from the live campaign, ${SPEC_DERIVE_MIRROR_FIELDS.join(", ")}).`);
|
|
896
|
+
}
|
|
897
|
+
fieldRows.push({ change, path });
|
|
898
|
+
}
|
|
899
|
+
for (const { change, path } of fieldRows) {
|
|
500
900
|
let node = spec;
|
|
501
901
|
for (let index = 0; index < path.length - 1; index += 1) {
|
|
502
902
|
const segment = path[index];
|
|
@@ -508,9 +908,9 @@ export function applySpecDerive(spec, plan) {
|
|
|
508
908
|
const nextIsIndex = Number.isInteger(path[index + 1]);
|
|
509
909
|
if (node[segment] === undefined || node[segment] === null) {
|
|
510
910
|
if (nextIsIndex) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an array in the spec.`);
|
|
511
|
-
// A provider block created by derive is enabled: an id the
|
|
512
|
-
// carries is a tag the funnel fires, which is what QA then expects.
|
|
513
|
-
node[segment] = path[index - 1] === "providers" ? { enabled: true } : {};
|
|
911
|
+
// A provider or vendor block created by derive is enabled: an id the
|
|
912
|
+
// repo carries is a tag the funnel fires, which is what QA then expects.
|
|
913
|
+
node[segment] = path[index - 1] === "providers" || path[index - 1] === "vendors" ? { enabled: true } : {};
|
|
514
914
|
} else if (nextIsIndex ? !Array.isArray(node[segment]) : !isPlainObject(node[segment])) {
|
|
515
915
|
throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not ${nextIsIndex ? "an array" : "an object"} in the spec.`);
|
|
516
916
|
}
|
|
@@ -518,6 +918,26 @@ export function applySpecDerive(spec, plan) {
|
|
|
518
918
|
}
|
|
519
919
|
node[path[path.length - 1]] = change.after;
|
|
520
920
|
}
|
|
921
|
+
const listAt = (path, change) => {
|
|
922
|
+
let node = spec;
|
|
923
|
+
for (const segment of path.slice(0, -1)) {
|
|
924
|
+
node = node?.[segment];
|
|
925
|
+
if (node === undefined || node === null) break;
|
|
926
|
+
}
|
|
927
|
+
if (!Array.isArray(node)) throw new Error(`Refusing to change "${change.field}": ${pathLabel(path.slice(0, -1))} is not an array in the spec.`);
|
|
928
|
+
return node;
|
|
929
|
+
};
|
|
930
|
+
for (const { change, path } of inserts.sort((a, b) => a.path[a.path.length - 1] - b.path[b.path.length - 1])) {
|
|
931
|
+
const list = listAt(path, change);
|
|
932
|
+
if (path[path.length - 1] !== list.length || !isPlainObject(change.after)) throw new Error(`Refusing to add "${change.field}": it is not the next element of ${pathLabel(path.slice(0, -1))}.`);
|
|
933
|
+
list.push(change.after);
|
|
934
|
+
}
|
|
935
|
+
for (const { change, path } of removes.sort((a, b) => b.path[b.path.length - 1] - a.path[a.path.length - 1])) {
|
|
936
|
+
const list = listAt(path, change);
|
|
937
|
+
const index = path[path.length - 1];
|
|
938
|
+
if (!isPlainObject(list[index]) || (isNonEmptyString(change.page_id) && list[index].id !== change.page_id)) throw new Error(`Refusing to remove "${change.field}": it is not page "${change.page_id}" in the spec.`);
|
|
939
|
+
list.splice(index, 1);
|
|
940
|
+
}
|
|
521
941
|
return spec;
|
|
522
942
|
}
|
|
523
943
|
|