@nextcommerce/campaigns-os 1.50.0 → 1.53.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/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -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 +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/build-brief.mjs
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { basename, extname, join, resolve } from "node:path";
|
|
3
4
|
import { parse as parseYaml } from "yaml";
|
|
4
5
|
|
|
6
|
+
import { cmd } from "./install-invocation.mjs";
|
|
7
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
5
8
|
import { escapeRegExp } from "./repo-scan.mjs";
|
|
6
9
|
|
|
7
10
|
export const BUILD_BRIEF_SCHEMA = "campaigns-os-build-brief/v1";
|
|
11
|
+
// The brief value of a packet that carries no Campaign Build Brief, for both
|
|
12
|
+
// material partitions and for every stage stamp made against it.
|
|
13
|
+
export const BRIEF_ABSENT = "absent";
|
|
14
|
+
// The fields the campaign intent summary prints, in its order. `<page>` stands
|
|
15
|
+
// for each page id present in design_authority; _meta.field_sources keys use
|
|
16
|
+
// the concrete id (design_authority.checkout.source).
|
|
17
|
+
export const SUMMARY_FIELDS = Object.freeze(["campaign_intent.audience", "campaign_intent.conversion_goal", "campaign_intent.tone", "brand.commerce_palette_source", "brand.primary_accent", "brand.cta_style", "brand.avoid", "design_authority.<page>.source", "template_residue_policy.block_placeholders"]);
|
|
18
|
+
const BRIEF_MODE_SOURCES = Object.freeze({ FIELD: "field", LEGACY_REPORT: "legacy_report", GENERATED: "generated" });
|
|
19
|
+
// Codes of the errors normalization itself raises. They block in either mode;
|
|
20
|
+
// evaluation gates block only a prepared brief.
|
|
21
|
+
const NORMALIZATION_ERROR_CODES = Object.freeze(new Set(["build_brief.type", "build_brief.schema_version", "build_brief.brief_mode"]));
|
|
22
|
+
// Top-level keys that never count as material: normalization and evaluation
|
|
23
|
+
// output, and the declared mode. qa_policy.enforcement is fixed package text.
|
|
24
|
+
const NON_MATERIAL_BRIEF_KEYS = Object.freeze(new Set(["_meta", "status", "questions", "gates", "confidence", "schema_version", "brief_mode"]));
|
|
8
25
|
export const BUILD_BRIEF_NORMALIZED_REL_PATH = ".campaign-runtime/input/campaign-build-brief.normalized.json";
|
|
9
26
|
export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
|
|
10
27
|
"campaign-build-brief.yaml",
|
|
@@ -12,11 +29,15 @@ export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
|
|
|
12
29
|
"campaign-build-brief.json",
|
|
13
30
|
]);
|
|
14
31
|
|
|
32
|
+
// answer_fields: the brief fields whose values close the question, as
|
|
33
|
+
// evaluateCampaignBuildBrief reads them. The guided-questions warning names
|
|
34
|
+
// them so an answer given in conversation can be written where it counts.
|
|
15
35
|
const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
16
36
|
{
|
|
17
37
|
id: "page_design_authority",
|
|
18
38
|
priority: 1,
|
|
19
39
|
field: "design_authority",
|
|
40
|
+
answer_fields: ["design_authority.<page_id>.source"],
|
|
20
41
|
question: "Which source controls each campaign page: the provided design export, the selected template, or a template adapted to another page?",
|
|
21
42
|
reason: "Page-by-page authority prevents checkout, OTO, and receipt pages from drifting into unrelated starter-template composition.",
|
|
22
43
|
},
|
|
@@ -24,6 +45,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
24
45
|
id: "brand_palette_cta",
|
|
25
46
|
priority: 2,
|
|
26
47
|
field: "brand",
|
|
48
|
+
answer_fields: ["brand.commerce_palette_source", "brand.cta_style"],
|
|
27
49
|
question: "Which palette and CTA style should commerce pages use?",
|
|
28
50
|
reason: "Commerce pages need a business-approved brand layer instead of silent template defaults.",
|
|
29
51
|
},
|
|
@@ -31,6 +53,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
31
53
|
id: "variant_media_rules",
|
|
32
54
|
priority: 3,
|
|
33
55
|
field: "media",
|
|
56
|
+
answer_fields: ["media.sold_variants", "media.allow_other_variant_colors"],
|
|
34
57
|
question: "Which product variants or colors are actually sold, and may media show other variants?",
|
|
35
58
|
reason: "Variant ambiguity often creates wrong-color carousels and unavailable-product claims.",
|
|
36
59
|
},
|
|
@@ -38,6 +61,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
38
61
|
id: "bundle_pricing_presentation",
|
|
39
62
|
priority: 4,
|
|
40
63
|
field: "offer_presentation.bundle_cards",
|
|
64
|
+
answer_fields: ["offer_presentation.bundle_cards.primary_price"],
|
|
41
65
|
question: "How should bundle cards present pricing: simple unit price, savings-led, or full accounting?",
|
|
42
66
|
reason: "CampaignSpec owns prices; the brief owns which shopper-facing price story is appropriate.",
|
|
43
67
|
},
|
|
@@ -45,13 +69,16 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
45
69
|
id: "promo_urgency_copy",
|
|
46
70
|
priority: 5,
|
|
47
71
|
field: "promo_urgency",
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
answer_fields: ["promo_urgency.header_claim_source", "promo_urgency.timer_label"],
|
|
73
|
+
answer_hint: 'promo_urgency.header_claim_source "campaign_offers" and promo_urgency.timer_label the template timer\'s label to fill them, or both "none" to remove them',
|
|
74
|
+
question: "Should the starter template's own promo placeholders (demo countdown timers, promo banners, placeholder voucher codes, exit-pop offers) be filled from this campaign's promo codes and offers, or removed?",
|
|
75
|
+
reason: "Template promo placeholders must not go live with demo values. The source design's own promo, proof and urgency copy is the merchant's content: it is built as designed and is not part of this question.",
|
|
50
76
|
},
|
|
51
77
|
{
|
|
52
78
|
id: "payment_methods_trust",
|
|
53
79
|
priority: 6,
|
|
54
80
|
field: "commerce_surfaces.payment_methods_allowed",
|
|
81
|
+
answer_fields: ["commerce_surfaces.payment_methods_allowed"],
|
|
55
82
|
question: "Which payment methods and trust badges may appear?",
|
|
56
83
|
reason: "Templates often carry demo wallets or badges that must not survive without approval.",
|
|
57
84
|
},
|
|
@@ -59,6 +86,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
59
86
|
id: "canonical_display_names",
|
|
60
87
|
priority: 7,
|
|
61
88
|
field: "canonical_display.product_name_source",
|
|
89
|
+
answer_fields: ["canonical_display.product_name_source"],
|
|
62
90
|
question: "Should CampaignSpec display names win, or may runtime/catalog names override them?",
|
|
63
91
|
reason: "Name drift across source, spec, and runtime data is hard to spot after assembly.",
|
|
64
92
|
},
|
|
@@ -66,6 +94,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
66
94
|
id: "regulated_claims",
|
|
67
95
|
priority: 8,
|
|
68
96
|
field: "campaign_intent.compliance",
|
|
97
|
+
answer_fields: ["campaign_intent.compliance.approved_benefit_language", "campaign_intent.compliance.forbidden_claims"],
|
|
69
98
|
question: "Are there regulated claims, forbidden phrases, or approved benefit statements the build must follow?",
|
|
70
99
|
reason: "Health, financial, and other regulated offers need explicit copy boundaries.",
|
|
71
100
|
conditional: "regulated",
|
|
@@ -157,6 +186,32 @@ export function loadCampaignBuildBriefFile(path) {
|
|
|
157
186
|
return { path: resolvedPath, format, value: parsed };
|
|
158
187
|
}
|
|
159
188
|
|
|
189
|
+
// The mode a brief file declares, in rule order: brief_mode "prepared" or
|
|
190
|
+
// "guided"; without brief_mode, prepared only when the report this write
|
|
191
|
+
// replaces already recorded a prepared brief for the campaign (so it keeps the
|
|
192
|
+
// blocking it was built under); otherwise guided. Any other brief_mode value
|
|
193
|
+
// is a normalization error. The file's own _meta is never read.
|
|
194
|
+
function briefModeFor(loaded, boundReportMode, errors) {
|
|
195
|
+
if (!loaded) return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
|
|
196
|
+
const declared = isObject(loaded.value) && Object.hasOwn(loaded.value, "brief_mode");
|
|
197
|
+
if (declared && loaded.value.brief_mode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.FIELD };
|
|
198
|
+
if (declared && loaded.value.brief_mode === "guided") return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
|
|
199
|
+
if (declared) {
|
|
200
|
+
errors.push({
|
|
201
|
+
code: "build_brief.brief_mode",
|
|
202
|
+
field: "brief_mode",
|
|
203
|
+
message: `Campaign Build Brief brief_mode must be "prepared" or "guided" (got ${JSON.stringify(loaded.value.brief_mode)}); omit it for a guided brief.`,
|
|
204
|
+
});
|
|
205
|
+
return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
|
|
206
|
+
}
|
|
207
|
+
if (boundReportMode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.LEGACY_REPORT };
|
|
208
|
+
return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `boundReportMode`: build_brief.mode on the Assembly Report this write
|
|
212
|
+
// replaces, when that report belongs to the same campaign (mode rule 3).
|
|
213
|
+
// `previousNormalizedBrief`: the normalized brief on disk before this write,
|
|
214
|
+
// read for field provenance only.
|
|
160
215
|
export function createCampaignBuildBriefArtifact({
|
|
161
216
|
inputPath = null,
|
|
162
217
|
inputSource = null,
|
|
@@ -166,19 +221,24 @@ export function createCampaignBuildBriefArtifact({
|
|
|
166
221
|
templateFamily = null,
|
|
167
222
|
sourceAssetCrawl = null,
|
|
168
223
|
commerceZoneFindings = [],
|
|
224
|
+
boundReportMode = null,
|
|
225
|
+
previousNormalizedBrief = null,
|
|
169
226
|
} = {}) {
|
|
170
227
|
const loaded = inputPath ? loadCampaignBuildBriefFile(inputPath) : null;
|
|
171
|
-
const
|
|
228
|
+
const errors = [];
|
|
229
|
+
const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
|
|
172
230
|
const baseBrief = loaded
|
|
173
231
|
? cloneJsonOrEmpty(loaded.value)
|
|
174
232
|
: draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
|
|
175
233
|
|
|
176
|
-
const errors = [];
|
|
177
234
|
const normalized = normalizeCampaignBuildBrief(baseBrief, {
|
|
178
235
|
mode,
|
|
236
|
+
modeSource,
|
|
179
237
|
inputPath: loaded?.path || null,
|
|
180
238
|
inputFormat: loaded?.format || "generated",
|
|
181
239
|
inputSource,
|
|
240
|
+
fileValue: loaded ? loaded.value : null,
|
|
241
|
+
previousNormalizedBrief,
|
|
182
242
|
spec,
|
|
183
243
|
activePages,
|
|
184
244
|
pageMappings,
|
|
@@ -210,9 +270,11 @@ export function createCampaignBuildBriefArtifact({
|
|
|
210
270
|
normalized.questions = evaluation.questions;
|
|
211
271
|
normalized.gates = gates;
|
|
212
272
|
|
|
273
|
+
// A prepared brief blocks on every blocker gate; a guided one only on the
|
|
274
|
+
// gates its normalization errors produced (the first errors.length gates).
|
|
213
275
|
const blocking = mode === "prepared"
|
|
214
276
|
? blockerGates
|
|
215
|
-
:
|
|
277
|
+
: gates.slice(0, errors.length);
|
|
216
278
|
|
|
217
279
|
return {
|
|
218
280
|
mode,
|
|
@@ -227,7 +289,18 @@ export function createCampaignBuildBriefArtifact({
|
|
|
227
289
|
};
|
|
228
290
|
}
|
|
229
291
|
|
|
230
|
-
|
|
292
|
+
// "brand_palette_cta (brand.commerce_palette_source, brand.cta_style)": each
|
|
293
|
+
// open question with the brief fields that close it.
|
|
294
|
+
function describeOpenQuestions(questions) {
|
|
295
|
+
return questions.map((question) => {
|
|
296
|
+
const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
|
|
297
|
+
if (isNonEmptyString(entry?.answer_hint)) return `${question?.id} (${entry.answer_hint})`;
|
|
298
|
+
const fields = entry?.answer_fields || (isNonEmptyString(question?.field) ? [question.field] : []);
|
|
299
|
+
return fields.length ? `${question?.id} (${fields.join(", ")})` : String(question?.id);
|
|
300
|
+
}).join(", ");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function validateCampaignBuildBriefArtifact(brief, { spec = null, normalizedPath = BUILD_BRIEF_NORMALIZED_REL_PATH } = {}) {
|
|
231
304
|
const errors = [];
|
|
232
305
|
const warnings = [];
|
|
233
306
|
const ready = [];
|
|
@@ -255,18 +328,26 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {})
|
|
|
255
328
|
if (questions.length) {
|
|
256
329
|
errors.push({
|
|
257
330
|
code: "build_brief.questions_unanswered",
|
|
258
|
-
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${questions.
|
|
331
|
+
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and save it with ${cmd("record")} brief --packet <packet>.`,
|
|
259
332
|
});
|
|
260
333
|
}
|
|
261
334
|
} else {
|
|
262
335
|
if (questions.length) {
|
|
336
|
+
// An answer given in conversation is not recorded until it is in a
|
|
337
|
+
// brief file saved with record brief: the guided draft is regenerated
|
|
338
|
+
// on every intake, and a brief file replaces it whole.
|
|
263
339
|
warnings.push({
|
|
264
340
|
code: "build_brief.guided_questions",
|
|
265
|
-
message: `
|
|
341
|
+
message: `Guided Campaign Build Brief has ${questions.length} high-impact business question(s) to confirm: ${describeOpenQuestions(questions)}. `
|
|
342
|
+
+ `An answer counts only once it is saved: copy ${normalizedPath} to campaign-build-brief.json in the target repo, set those fields, and run ${cmd("record")} brief --packet <packet> `
|
|
343
|
+
+ "(the file is found there automatically, or pass --brief <file>). The file replaces the draft, so start from the copy to keep the fields the draft already filled. "
|
|
344
|
+
+ "Saving keeps every stage's recorded evidence unless the brief's content changes.",
|
|
266
345
|
});
|
|
267
346
|
}
|
|
347
|
+
// Input errors block in both modes; evaluation gates stay warnings here.
|
|
268
348
|
for (const gate of blockerGates) {
|
|
269
|
-
|
|
349
|
+
const target = brief.status === "invalid" && NORMALIZATION_ERROR_CODES.has(gate.code) ? errors : warnings;
|
|
350
|
+
target.push({ code: gate.code || "build_brief.blocker", message: gate.message || "Guided Campaign Build Brief has an unresolved blocker." });
|
|
270
351
|
}
|
|
271
352
|
}
|
|
272
353
|
|
|
@@ -322,6 +403,7 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
|
|
|
322
403
|
brief._meta = {
|
|
323
404
|
generated_at: new Date().toISOString(),
|
|
324
405
|
mode: meta.mode,
|
|
406
|
+
mode_source: meta.modeSource,
|
|
325
407
|
input_path: meta.inputPath || null,
|
|
326
408
|
input_format: meta.inputFormat || null,
|
|
327
409
|
input_source: meta.inputSource || null,
|
|
@@ -339,25 +421,127 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
|
|
|
339
421
|
brief.template_residue_policy = normalizeResiduePolicy(brief.template_residue_policy);
|
|
340
422
|
brief.qa_policy = normalizeQaPolicy(brief.qa_policy);
|
|
341
423
|
brief.confidence = objectOrEmpty(brief.confidence);
|
|
424
|
+
brief._meta.field_sources = summaryFieldSources(brief, meta);
|
|
342
425
|
|
|
343
426
|
return brief;
|
|
344
427
|
}
|
|
345
428
|
|
|
429
|
+
// The concrete summary field paths of a normalized brief: one
|
|
430
|
+
// design_authority.<page>.source per page the brief lists.
|
|
431
|
+
function summaryFieldPaths(brief) {
|
|
432
|
+
return SUMMARY_FIELDS.flatMap((path) => (path === "design_authority.<page>.source"
|
|
433
|
+
? Object.keys(objectOrEmpty(brief.design_authority)).map((page) => `design_authority.${page}.source`)
|
|
434
|
+
: [path]));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The value at a dotted path, reading the segment after `design_authority`
|
|
438
|
+
// as one page id (a page id may itself contain a dot).
|
|
439
|
+
function valueAtPath(value, path) {
|
|
440
|
+
const parts = path.startsWith("design_authority.") && path.endsWith(".source")
|
|
441
|
+
? ["design_authority", path.slice("design_authority.".length, -".source".length), "source"]
|
|
442
|
+
: path.split(".");
|
|
443
|
+
let node = value;
|
|
444
|
+
for (const part of parts) {
|
|
445
|
+
if (!isObject(node) || !Object.hasOwn(node, part)) return undefined;
|
|
446
|
+
node = node[part];
|
|
447
|
+
}
|
|
448
|
+
return node;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function valueFingerprint(value) {
|
|
452
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Whether `path` is one of the answer_fields of a question still open in the
|
|
456
|
+
// previous normalized brief (design_authority.<page_id>.source matches any page).
|
|
457
|
+
function answersOpenQuestion(path, previous) {
|
|
458
|
+
const questions = Array.isArray(previous?.questions) ? previous.questions : [];
|
|
459
|
+
return questions.some((question) => {
|
|
460
|
+
const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
|
|
461
|
+
return (entry?.answer_fields || []).some((field) => field === path
|
|
462
|
+
|| (field === "design_authority.<page_id>.source" && path.startsWith("design_authority.") && path.endsWith(".source")));
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// _meta.field_sources: {kind, value_fingerprint} per summary field. A
|
|
467
|
+
// generated draft stamps every non-null value `default` (no draft value is
|
|
468
|
+
// copied verbatim from a structured source field). From a file: a value absent
|
|
469
|
+
// or null in the file is `default` when normalization filled one; a value the
|
|
470
|
+
// file sets is `stated` when it answers an open question of the previous
|
|
471
|
+
// brief or differs from the previous value (or there is no previous brief),
|
|
472
|
+
// and otherwise keeps the previous entry. A null value gets no entry.
|
|
473
|
+
function summaryFieldSources(brief, meta) {
|
|
474
|
+
const previous = isObject(meta.previousNormalizedBrief) ? meta.previousNormalizedBrief : null;
|
|
475
|
+
const previousSources = isObject(previous?._meta?.field_sources) ? previous._meta.field_sources : {};
|
|
476
|
+
const fromFile = isObject(meta.fileValue);
|
|
477
|
+
const sources = {};
|
|
478
|
+
for (const path of summaryFieldPaths(brief)) {
|
|
479
|
+
const value = valueAtPath(brief, path);
|
|
480
|
+
if (value === undefined || value === null) continue;
|
|
481
|
+
const entry = (kind) => ({ kind, value_fingerprint: valueFingerprint(value) });
|
|
482
|
+
const inFile = fromFile ? valueAtPath(meta.fileValue, path) : undefined;
|
|
483
|
+
if (!fromFile || inFile === undefined || inFile === null) {
|
|
484
|
+
sources[path] = entry("default");
|
|
485
|
+
} else if (answersOpenQuestion(path, previous) || !previous || canonicalJson(valueAtPath(previous, path) ?? null) !== canonicalJson(value)) {
|
|
486
|
+
sources[path] = entry("stated");
|
|
487
|
+
} else if (Object.hasOwn(previousSources, path) && fieldSourceEntryMatches(previousSources[path], value)) {
|
|
488
|
+
sources[path] = { kind: previousSources[path].kind, value_fingerprint: previousSources[path].value_fingerprint };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return sources;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const FIELD_SOURCE_KINDS = Object.freeze(["stated", "source", "default"]);
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Whether a _meta.field_sources entry can be read for `value`: exactly
|
|
498
|
+
* {kind, value_fingerprint}, a known kind, and the fingerprint of `value`.
|
|
499
|
+
* Any other entry is ignored.
|
|
500
|
+
*/
|
|
501
|
+
export function fieldSourceEntryMatches(entry, value) {
|
|
502
|
+
return isObject(entry)
|
|
503
|
+
&& Object.keys(entry).length === 2
|
|
504
|
+
&& FIELD_SOURCE_KINDS.includes(entry.kind)
|
|
505
|
+
&& entry.value_fingerprint === valueFingerprint(value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The brief's material content, from the normalized artifact: `qa_policy`
|
|
509
|
+
// (without its fixed enforcement note) and `presentation`, every other
|
|
510
|
+
// top-level key that is not normalization output or the declared mode,
|
|
511
|
+
// including keys the operator added.
|
|
512
|
+
export function briefMaterialProjection(normalized) {
|
|
513
|
+
const brief = isObject(normalized) ? normalized : {};
|
|
514
|
+
// Entry-wise copies, so an operator key named __proto__ stays data.
|
|
515
|
+
const presentation = Object.fromEntries(Object.entries(brief).filter(([key]) => !NON_MATERIAL_BRIEF_KEYS.has(key) && key !== "qa_policy"));
|
|
516
|
+
const qaPolicy = Object.fromEntries(Object.entries(objectOrEmpty(brief.qa_policy)).filter(([key]) => key !== "enforcement"));
|
|
517
|
+
return { presentation, qa_policy: qaPolicy };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Each partition as "sha256:<hex>" of its canonical JSON: key order,
|
|
521
|
+
// whitespace, an explicit default and _meta never change it.
|
|
522
|
+
export function briefMaterialFingerprint(normalized) {
|
|
523
|
+
const projection = briefMaterialProjection(normalized);
|
|
524
|
+
return { presentation: valueFingerprint(projection.presentation), qa_policy: valueFingerprint(projection.qa_policy) };
|
|
525
|
+
}
|
|
526
|
+
|
|
346
527
|
function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
|
|
347
528
|
const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
|
|
348
|
-
|
|
349
|
-
|
|
529
|
+
// Entry-wise, so a page id named __proto__ stays a page.
|
|
530
|
+
const designAuthority = Object.fromEntries((activePages || []).map((page) => {
|
|
350
531
|
const mapped = pageMap.get(page.id);
|
|
351
|
-
|
|
532
|
+
return [page.id, {
|
|
352
533
|
source: mapped?.path ? "provided_design_export" : "template",
|
|
353
534
|
reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
|
|
354
|
-
};
|
|
355
|
-
}
|
|
535
|
+
}];
|
|
536
|
+
}));
|
|
356
537
|
|
|
357
538
|
const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
|
|
358
539
|
const paymentMethods = collectSpecPaymentMethods(spec);
|
|
359
540
|
const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
|
|
360
541
|
const hasOrderBump = hasOrderBumpSignals(spec);
|
|
542
|
+
// With no CampaignSpec surface to fill them, the template's promo
|
|
543
|
+
// placeholders are removed: "none" for both.
|
|
544
|
+
const fillsTemplatePromo = templatePromoSurfaces({ spec, activePages }).length > 0;
|
|
361
545
|
|
|
362
546
|
return {
|
|
363
547
|
schema_version: BUILD_BRIEF_SCHEMA,
|
|
@@ -394,8 +578,8 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
394
578
|
},
|
|
395
579
|
},
|
|
396
580
|
promo_urgency: {
|
|
397
|
-
header_claim_source:
|
|
398
|
-
timer_label:
|
|
581
|
+
header_claim_source: fillsTemplatePromo ? "campaign_offers" : "none",
|
|
582
|
+
timer_label: fillsTemplatePromo ? null : "none",
|
|
399
583
|
show_promo_code_in_timer: false,
|
|
400
584
|
exit_pop: {
|
|
401
585
|
enabled: hasExitPop,
|
|
@@ -427,7 +611,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
427
611
|
brand: "low",
|
|
428
612
|
media: variantSignals.length === 1 ? "medium" : "low",
|
|
429
613
|
offer_presentation: "low",
|
|
430
|
-
promo_urgency:
|
|
614
|
+
promo_urgency: fillsTemplatePromo ? "low" : "medium",
|
|
431
615
|
commerce_surfaces: paymentMethods.length ? "medium" : "low",
|
|
432
616
|
template_family: templateFamily || null,
|
|
433
617
|
},
|
|
@@ -466,10 +650,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
|
|
|
466
650
|
options: ["discounted unit price", "savings-led", "full accounting"],
|
|
467
651
|
});
|
|
468
652
|
}
|
|
469
|
-
|
|
653
|
+
const promoSurfaces = templatePromoSurfaces({ spec, activePages });
|
|
654
|
+
if (promoSurfaces.length && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
|
|
470
655
|
addQuestion(questions, "promo_urgency_copy", {
|
|
471
|
-
detail:
|
|
472
|
-
options: ["
|
|
656
|
+
detail: `CampaignSpec maps ${promoSurfaces.join(" and ")}, which would fill the template's promo placeholders. Missing promo_urgency.header_claim_source ("campaign_offers" or "none") or promo_urgency.timer_label (the template timer's label, or "none").`,
|
|
657
|
+
options: ["fill from the campaign's promo codes and offers", "remove the template's promo placeholders"],
|
|
473
658
|
});
|
|
474
659
|
}
|
|
475
660
|
if (!normalizePaymentList(brief.commerce_surfaces?.payment_methods_allowed).length) {
|
|
@@ -638,16 +823,30 @@ export function collectSpecPaymentMethods(spec) {
|
|
|
638
823
|
return [...methods].sort();
|
|
639
824
|
}
|
|
640
825
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
826
|
+
// The CampaignSpec surfaces that fill a starter template's own promo
|
|
827
|
+
// placeholders: the template's promo banner and countdown timer read
|
|
828
|
+
// funnels[].promo_codes, and its exit-pop offer reads a page's exit_intent or
|
|
829
|
+
// promo_code_input. The campaign's offer catalog and discount pricing are not
|
|
830
|
+
// among them (bundle_pricing_presentation covers those), and neither is any
|
|
831
|
+
// promo, proof or urgency copy of the source design: that is the merchant's
|
|
832
|
+
// content, built as designed.
|
|
833
|
+
function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
|
|
834
|
+
const surfaces = [];
|
|
835
|
+
const funnels = Array.isArray(spec?.funnels) ? spec.funnels : [];
|
|
836
|
+
if (funnels.some((funnel) => Array.isArray(funnel?.promo_codes) && funnel.promo_codes.length > 0)) {
|
|
837
|
+
surfaces.push("promo codes (funnels[].promo_codes)");
|
|
838
|
+
}
|
|
839
|
+
const pages = Array.isArray(activePages) ? activePages : [];
|
|
840
|
+
// Checkout-only and enabled: true, the rule hasExitPop, doctor's exit-pop
|
|
841
|
+
// contract and QA's coupon orders use for these surfaces.
|
|
842
|
+
const checkoutSurface = (page, key) => page?.type === "checkout" && page?.[key]?.enabled === true;
|
|
843
|
+
if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
|
|
844
|
+
surfaces.push("an exit-intent offer (exit_intent)");
|
|
845
|
+
}
|
|
846
|
+
if (pages.some((page) => checkoutSurface(page, "promo_code_input"))) {
|
|
847
|
+
surfaces.push("a promo-code input (promo_code_input)");
|
|
848
|
+
}
|
|
849
|
+
return surfaces;
|
|
651
850
|
}
|
|
652
851
|
|
|
653
852
|
function hasOrderBumpSignals(value, keyPath = []) {
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, join, relative, sep } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { isFileReadFailure } from "./cart-placeholders.mjs";
|
|
20
|
+
|
|
19
21
|
const HTML_EXT = ".html";
|
|
20
22
|
|
|
21
23
|
// The route tokens inferPageType reads for the funnel roles, exported so a
|
|
@@ -56,7 +58,26 @@ export function inferPageType(routeOrName) {
|
|
|
56
58
|
return "page";
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// Whether a symbolic link may stand for a built page: its name ends in .html,
|
|
62
|
+
// or its target is a directory, or its target cannot be inspected (missing,
|
|
63
|
+
// EACCES, ELOOP, any file-system error). A link to a regular file (or any
|
|
64
|
+
// other non-directory) not named .html is no page. Only file-system errors
|
|
65
|
+
// are caught; anything else throws.
|
|
66
|
+
function linkMayBePage(full, name) {
|
|
67
|
+
if (name.toLowerCase().endsWith(HTML_EXT)) return true;
|
|
68
|
+
try {
|
|
69
|
+
return statSync(full).isDirectory();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!isFileReadFailure(error)) throw error;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `links`, when given, collects every symbolic link that may stand for a page
|
|
77
|
+
// (see linkMayBePage). They are never pages themselves (build output holds no
|
|
78
|
+
// links, and a link is not followed), but a caller that must account for every
|
|
79
|
+
// built page can name them.
|
|
80
|
+
function listHtmlFiles(root, links = null) {
|
|
60
81
|
const files = [];
|
|
61
82
|
if (!existsSync(root) || !statSync(root).isDirectory()) return files;
|
|
62
83
|
const walk = (dir) => {
|
|
@@ -67,9 +88,11 @@ function listHtmlFiles(root) {
|
|
|
67
88
|
const full = join(dir, entry.name);
|
|
68
89
|
if (entry.isDirectory()) walk(full);
|
|
69
90
|
else if (entry.isFile() && entry.name.toLowerCase().endsWith(HTML_EXT)) files.push(full);
|
|
91
|
+
else if (links && entry.isSymbolicLink() && linkMayBePage(full, entry.name)) links.push(full);
|
|
70
92
|
}
|
|
71
93
|
};
|
|
72
94
|
walk(root);
|
|
95
|
+
links?.sort();
|
|
73
96
|
return files.sort();
|
|
74
97
|
}
|
|
75
98
|
|
|
@@ -101,7 +124,13 @@ function resolveSiteRoot(targetRepo) {
|
|
|
101
124
|
*
|
|
102
125
|
* @param {string} targetRepo Absolute path to the page-kit target repo (or a
|
|
103
126
|
* `_site/` directory, or a campaign directory).
|
|
104
|
-
* @param {{ slug?: string|null }} [options]
|
|
127
|
+
* @param {{ slug?: string|null, includeLinkedPages?: boolean }} [options]
|
|
128
|
+
* `includeLinkedPages` adds `linked_pages`: every symbolic link under the
|
|
129
|
+
* campaign directory that may stand for a page (named .html, or its target
|
|
130
|
+
* a directory, or its target not inspectable; each one entry, its
|
|
131
|
+
* `built_path` the link itself; skipped as pages, not followed), in the
|
|
132
|
+
* same shape as `pages`. A link to a regular file not named .html is
|
|
133
|
+
* dropped. Off by default; without it the result is unchanged.
|
|
105
134
|
* @returns {{
|
|
106
135
|
* ok: boolean,
|
|
107
136
|
* error?: string,
|
|
@@ -114,7 +143,7 @@ function resolveSiteRoot(targetRepo) {
|
|
|
114
143
|
* slug_candidates?: string[],
|
|
115
144
|
* }}
|
|
116
145
|
*/
|
|
117
|
-
export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
146
|
+
export function resolveBuiltSiteScope(targetRepo, { slug = null, includeLinkedPages = false } = {}) {
|
|
118
147
|
const base = { ok: false, target_repo: targetRepo, site_root: null, slug: "", campaign_dir: null, pages: [], html_count: 0 };
|
|
119
148
|
if (!targetRepo || !existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
120
149
|
return { ...base, error: `Built campaign directory does not exist: ${targetRepo}` };
|
|
@@ -149,7 +178,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
149
178
|
return { ...base, site_root: siteRoot, slug: resolvedSlug, error: `Campaign directory does not exist: ${campaignDir}` };
|
|
150
179
|
}
|
|
151
180
|
|
|
152
|
-
const
|
|
181
|
+
const toPage = (file) => {
|
|
153
182
|
const route = routeForFile(campaignDir, file);
|
|
154
183
|
return {
|
|
155
184
|
page_id: pageIdForRoute(route),
|
|
@@ -157,10 +186,13 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
157
186
|
route,
|
|
158
187
|
built_path: file,
|
|
159
188
|
};
|
|
160
|
-
}
|
|
189
|
+
};
|
|
190
|
+
const links = includeLinkedPages ? [] : null;
|
|
191
|
+
const pages = listHtmlFiles(campaignDir, links).map(toPage);
|
|
192
|
+
const linked = links ? { linked_pages: links.map(toPage) } : {};
|
|
161
193
|
|
|
162
194
|
if (!pages.length) {
|
|
163
|
-
return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, error: `No built HTML pages found under ${campaignDir}.` };
|
|
195
|
+
return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, ...linked, error: `No built HTML pages found under ${campaignDir}.` };
|
|
164
196
|
}
|
|
165
197
|
|
|
166
198
|
return {
|
|
@@ -171,6 +203,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
171
203
|
campaign_dir: campaignDir,
|
|
172
204
|
pages,
|
|
173
205
|
html_count: pages.length,
|
|
206
|
+
...linked,
|
|
174
207
|
};
|
|
175
208
|
}
|
|
176
209
|
|