@nextcommerce/campaigns-os 1.54.0 → 1.55.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 +84 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -12
- package/compatibility.json +1 -1
- package/contracts/release-ledger.json +441 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +35 -19
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +55 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- 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 +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +36 -59
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +16 -6
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +521 -92
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +144 -39
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-markup.mjs +41 -1
- package/src/stage-ledger.mjs +14 -2
- package/src/stage-record.mjs +50 -13
package/skills.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "campaigns-os-skills",
|
|
3
3
|
"description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
|
|
4
|
-
"bundle_revision": "1.
|
|
4
|
+
"bundle_revision": "1.55.0+skills.1",
|
|
5
5
|
"homepage": "https://github.com/NextCommerceCo/campaigns-os",
|
|
6
6
|
"retired_skills": [
|
|
7
7
|
{
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"id": "next-campaigns-os",
|
|
18
18
|
"name": "Campaigns OS Lifecycle",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.52",
|
|
20
20
|
"path": "skills/next-campaigns-os/SKILL.md",
|
|
21
21
|
"domain": "campaigns",
|
|
22
22
|
"description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"id": "next-campaigns-os-setup",
|
|
26
26
|
"name": "Campaigns OS Setup",
|
|
27
|
-
"version": "2.0.
|
|
27
|
+
"version": "2.0.35",
|
|
28
28
|
"path": "skills/next-campaigns-os-setup/SKILL.md",
|
|
29
29
|
"domain": "campaigns",
|
|
30
30
|
"description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
{
|
|
33
33
|
"id": "next-campaigns-build",
|
|
34
34
|
"name": "Campaign Build",
|
|
35
|
-
"version": "1.0.
|
|
35
|
+
"version": "1.0.37",
|
|
36
36
|
"path": "skills/next-campaigns-build/SKILL.md",
|
|
37
37
|
"domain": "campaigns",
|
|
38
38
|
"description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
{
|
|
41
41
|
"id": "next-campaigns-polish",
|
|
42
42
|
"name": "Campaign Polish",
|
|
43
|
-
"version": "1.1.
|
|
43
|
+
"version": "1.1.36",
|
|
44
44
|
"path": "skills/next-campaigns-polish/SKILL.md",
|
|
45
45
|
"domain": "campaigns",
|
|
46
46
|
"description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
{
|
|
49
49
|
"id": "next-campaigns-qa",
|
|
50
50
|
"name": "Campaign QA",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.36",
|
|
52
52
|
"path": "skills/next-campaigns-qa/SKILL.md",
|
|
53
53
|
"domain": "campaigns",
|
|
54
54
|
"description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
{
|
|
57
57
|
"id": "campaign-lifecycle-orientation",
|
|
58
58
|
"name": "Campaign Lifecycle Orientation",
|
|
59
|
-
"version": "1.0.
|
|
59
|
+
"version": "1.0.32",
|
|
60
60
|
"path": "skills/campaign-lifecycle-orientation/SKILL.md",
|
|
61
61
|
"domain": "campaigns",
|
|
62
62
|
"description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
{
|
|
65
65
|
"id": "campaign-run-evidence",
|
|
66
66
|
"name": "Campaign Run Evidence",
|
|
67
|
-
"version": "1.0.
|
|
67
|
+
"version": "1.0.32",
|
|
68
68
|
"path": "skills/campaign-run-evidence/SKILL.md",
|
|
69
69
|
"domain": "campaigns",
|
|
70
70
|
"description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
{
|
|
73
73
|
"id": "campaign-readback-classification",
|
|
74
74
|
"name": "Campaign Readback Classification",
|
|
75
|
-
"version": "1.0.
|
|
75
|
+
"version": "1.0.32",
|
|
76
76
|
"path": "skills/campaign-readback-classification/SKILL.md",
|
|
77
77
|
"domain": "campaigns",
|
|
78
78
|
"description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
{
|
|
81
81
|
"id": "contribution-intake",
|
|
82
82
|
"name": "Contribution Intake",
|
|
83
|
-
"version": "1.0.
|
|
83
|
+
"version": "1.0.32",
|
|
84
84
|
"path": "skills/contribution-intake/SKILL.md",
|
|
85
85
|
"domain": "campaigns",
|
|
86
86
|
"description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
|
|
@@ -66,6 +66,20 @@ const SCRIPT_STYLE_REFERENCE_POLICIES = new Set(["frontmatter_or_campaign_asset"
|
|
|
66
66
|
const CTA_REWRITE_POLICIES = ROUTE_REWRITE_POLICIES;
|
|
67
67
|
const LAYOUT_CHOICES = new Set(["campaign_layout", "page_layout", "raw_passthrough", "not_applicable", "unknown"]);
|
|
68
68
|
|
|
69
|
+
// The scalar choices record build can set; the object-valued template slice
|
|
70
|
+
// stays with its existing proof fields and doctor checks.
|
|
71
|
+
export const ADAPTER_DECISION_SCALAR_VALUES = Object.freeze(Object.assign(Object.create(null), {
|
|
72
|
+
raw_html_conversion_status: [...RAW_HTML_CONVERSION_STATUSES],
|
|
73
|
+
source_asset_strategy: [...SOURCE_ASSET_STRATEGIES],
|
|
74
|
+
commerce_shell_adoption: [...COMMERCE_SHELL_ADOPTIONS],
|
|
75
|
+
route_rewrite_policy: [...ROUTE_REWRITE_POLICIES],
|
|
76
|
+
config_script_strategy: [...CONFIG_SCRIPT_STRATEGIES],
|
|
77
|
+
frontmatter_policy: [...FRONTMATTER_POLICIES],
|
|
78
|
+
script_style_reference_policy: [...SCRIPT_STYLE_REFERENCE_POLICIES],
|
|
79
|
+
cta_rewrite_policy: [...CTA_REWRITE_POLICIES],
|
|
80
|
+
layout_choice: [...LAYOUT_CHOICES],
|
|
81
|
+
}));
|
|
82
|
+
|
|
69
83
|
/**
|
|
70
84
|
* Seeds the adapter decision record written onto a new Build Packet.
|
|
71
85
|
*
|
|
@@ -170,7 +184,7 @@ export function validateAdapterSourceFiles({ decisions, sourceRoot, pages = [],
|
|
|
170
184
|
addIssue(
|
|
171
185
|
warnings,
|
|
172
186
|
"source_html.raw_html_wrappers",
|
|
173
|
-
`
|
|
187
|
+
`Effective raw_html_conversion_status is "${status}", but mapped source HTML still contains document wrapper tags: ${sample}${more}. Strip <!doctype>, <html>, <head>, and <body> before treating the page as page-kit-ready source.`
|
|
174
188
|
);
|
|
175
189
|
}
|
|
176
190
|
|
|
@@ -184,32 +198,32 @@ export function validateAdapterDecisionGates({ decisions, location, specPages =
|
|
|
184
198
|
addIssue(
|
|
185
199
|
warnings,
|
|
186
200
|
"adapter.raw_html_conversion_status",
|
|
187
|
-
`Assembly is recorded complete, but ${location}.raw_html_conversion_status is "${decisions.raw_html_conversion_status}".
|
|
201
|
+
`Assembly is recorded complete, but ${location}.raw_html_conversion_status is "${decisions.raw_html_conversion_status}". Use record build --adapter-decision raw_html_conversion_status=<value> to record completed/not_required after wrapper stripping, frontmatter, asset moves, script/style refs, CTA rewrites, route policy, and layout choice are settled.`
|
|
188
202
|
);
|
|
189
203
|
}
|
|
190
204
|
if (["raw_passthrough", "unknown"].includes(decisions.source_asset_strategy)) {
|
|
191
|
-
addIssue(warnings, "adapter.source_asset_strategy", `Assembly is recorded complete with ${location}.source_asset_strategy="${decisions.source_asset_strategy}".
|
|
205
|
+
addIssue(warnings, "adapter.source_asset_strategy", `Assembly is recorded complete with ${location}.source_asset_strategy="${decisions.source_asset_strategy}". Use record build --adapter-decision source_asset_strategy=<value> to record pagekit_campaign_asset_root when src/<slug>/assets/* publishes at /<slug>/*.`);
|
|
192
206
|
}
|
|
193
207
|
if (["raw_passthrough", "unknown"].includes(decisions.route_rewrite_policy)) {
|
|
194
|
-
addIssue(warnings, "adapter.route_rewrite_policy", `Assembly is recorded complete with ${location}.route_rewrite_policy="${decisions.route_rewrite_policy}".
|
|
208
|
+
addIssue(warnings, "adapter.route_rewrite_policy", `Assembly is recorded complete with ${location}.route_rewrite_policy="${decisions.route_rewrite_policy}". Use record build --adapter-decision route_rewrite_policy=<value> to record how CampaignSpec routes and CTA destinations were rewritten before QA.`);
|
|
195
209
|
}
|
|
196
210
|
if (decisions.config_script_strategy === "unknown") {
|
|
197
|
-
addIssue(warnings, "adapter.config_script_strategy", `Assembly is recorded complete but ${location}.config_script_strategy is unknown.
|
|
211
|
+
addIssue(warnings, "adapter.config_script_strategy", `Assembly is recorded complete but ${location}.config_script_strategy is unknown. Use record build --adapter-decision config_script_strategy=<value> to record whether config scripts load via campaign assets, frontmatter scripts, inline config, or not_required.`);
|
|
198
212
|
}
|
|
199
213
|
if (["preserve_document_wrappers", "unknown"].includes(decisions.wrapper_policy)) {
|
|
200
|
-
addIssue(warnings, "adapter.wrapper_policy", `Assembly is recorded complete with ${location}.wrapper_policy="${decisions.wrapper_policy}".
|
|
214
|
+
addIssue(warnings, "adapter.wrapper_policy", `Assembly is recorded complete with ${location}.wrapper_policy="${decisions.wrapper_policy}". Select wrapper_policy at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; page-kit source should normally strip document wrappers unless preserving them is explicitly not_required.`);
|
|
201
215
|
}
|
|
202
216
|
if (["raw_passthrough", "unknown"].includes(decisions.frontmatter_policy)) {
|
|
203
|
-
addIssue(warnings, "adapter.frontmatter_policy", `Assembly is recorded complete with ${location}.frontmatter_policy="${decisions.frontmatter_policy}".
|
|
217
|
+
addIssue(warnings, "adapter.frontmatter_policy", `Assembly is recorded complete with ${location}.frontmatter_policy="${decisions.frontmatter_policy}". Use record build --adapter-decision frontmatter_policy=<value> to record how Page Kit YAML frontmatter was created or why it is not_required.`);
|
|
204
218
|
}
|
|
205
219
|
if (["raw_passthrough", "unknown"].includes(decisions.script_style_reference_policy)) {
|
|
206
|
-
addIssue(warnings, "adapter.script_style_reference_policy", `Assembly is recorded complete with ${location}.script_style_reference_policy="${decisions.script_style_reference_policy}".
|
|
220
|
+
addIssue(warnings, "adapter.script_style_reference_policy", `Assembly is recorded complete with ${location}.script_style_reference_policy="${decisions.script_style_reference_policy}". Use record build --adapter-decision script_style_reference_policy=<value> to record how scripts/styles load via frontmatter, campaign assets, inline blocks, or not_required.`);
|
|
207
221
|
}
|
|
208
222
|
if (["raw_passthrough", "unknown"].includes(decisions.cta_rewrite_policy)) {
|
|
209
|
-
addIssue(warnings, "adapter.cta_rewrite_policy", `Assembly is recorded complete with ${location}.cta_rewrite_policy="${decisions.cta_rewrite_policy}".
|
|
223
|
+
addIssue(warnings, "adapter.cta_rewrite_policy", `Assembly is recorded complete with ${location}.cta_rewrite_policy="${decisions.cta_rewrite_policy}". Use record build --adapter-decision cta_rewrite_policy=<value> to record how CTA destinations were rewritten from CampaignSpec routes before QA.`);
|
|
210
224
|
}
|
|
211
225
|
if (["raw_passthrough", "unknown"].includes(decisions.layout_choice)) {
|
|
212
|
-
addIssue(warnings, "adapter.layout_choice", `Assembly is recorded complete with ${location}.layout_choice="${decisions.layout_choice}".
|
|
226
|
+
addIssue(warnings, "adapter.layout_choice", `Assembly is recorded complete with ${location}.layout_choice="${decisions.layout_choice}". Use record build --adapter-decision layout_choice=<value> to record the Page Kit layout strategy before handoff.`);
|
|
213
227
|
}
|
|
214
228
|
}
|
|
215
229
|
|
|
@@ -219,13 +233,13 @@ export function validateAdapterDecisionGates({ decisions, location, specPages =
|
|
|
219
233
|
addIssue(
|
|
220
234
|
errors,
|
|
221
235
|
"adapter.commerce_shell_adoption",
|
|
222
|
-
`Runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}) are marked custom_html_experimental for template family "${family}". Use template_clone_first_verified or sdk_surfaces_preserved before treating checkout/upsell/downsell/receipt as build-ready.`
|
|
236
|
+
`Runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}) are marked custom_html_experimental for template family "${family}". Use record build --adapter-decision commerce_shell_adoption=<value> to record template_clone_first_verified or sdk_surfaces_preserved before treating checkout/upsell/downsell/receipt as build-ready.`
|
|
223
237
|
);
|
|
224
238
|
} else if (assemblyComplete && !["template_clone_first_verified", "sdk_surfaces_preserved"].includes(adoption)) {
|
|
225
239
|
addIssue(
|
|
226
240
|
warnings,
|
|
227
241
|
"adapter.commerce_shell_adoption",
|
|
228
|
-
`Assembly is recorded complete, but ${location}.commerce_shell_adoption is "${adoption || "missing"}" for runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}).
|
|
242
|
+
`Assembly is recorded complete, but ${location}.commerce_shell_adoption is "${adoption || "missing"}" for runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}). Use record build --adapter-decision commerce_shell_adoption=<value> after commerce pages are template-clone-first, then styled, with SDK-owned surfaces preserved.`
|
|
229
243
|
);
|
|
230
244
|
}
|
|
231
245
|
}
|
package/src/brand-theme.mjs
CHANGED
|
@@ -256,8 +256,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
256
256
|
const lower = name.toLowerCase();
|
|
257
257
|
if (isTargetContractToken(lower)) continue;
|
|
258
258
|
const parts = tokenNameParts(lower);
|
|
259
|
+
const decorativeName = hasTokenPart(parts, DECORATIVE_NAME_PARTS);
|
|
260
|
+
const namedRole = hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
|
|
259
261
|
const ctaName = hasTokenPart(parts, ["cta", "button", "btn"]);
|
|
260
|
-
if (!ctaName && (
|
|
262
|
+
if (!ctaName && (!decorativeName || namedRole)
|
|
263
|
+
&& (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
|
|
261
264
|
addToken("--brand-primary", color);
|
|
262
265
|
}
|
|
263
266
|
if (ctaName) {
|
|
@@ -281,7 +284,7 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
281
284
|
if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
|
|
282
285
|
}
|
|
283
286
|
|
|
284
|
-
const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
|
|
287
|
+
const rulePattern = /([^{};]+)\{([^{}]+)\}/g;
|
|
285
288
|
for (const match of stripCssComments(content).matchAll(rulePattern)) {
|
|
286
289
|
const selector = match[1] || "";
|
|
287
290
|
for (const declaration of match[2].split(";")) {
|
|
@@ -291,9 +294,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
291
294
|
const color = resolveDeclarationColor(rawValue, rootTokens);
|
|
292
295
|
if (!color) continue;
|
|
293
296
|
if (["background", "background-color"].includes(name)) {
|
|
294
|
-
if (
|
|
297
|
+
if (hasPrimarySelectorRole(selector)
|
|
298
|
+
&& isStrongBrandColor(color)) {
|
|
295
299
|
addToken("--brand-primary", color);
|
|
296
|
-
}
|
|
300
|
+
}
|
|
301
|
+
if (isCtaLikeSelector(eligibleSelectorText(selector)) && isStrongBrandColor(color)) {
|
|
297
302
|
addToken("--button-primary-bg", color);
|
|
298
303
|
addToken("--brand-cta", color);
|
|
299
304
|
addToken("--brand-accent", color);
|
|
@@ -319,6 +324,32 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
319
324
|
return tokens;
|
|
320
325
|
}
|
|
321
326
|
|
|
327
|
+
const DECORATIVE_NAME_PARTS = ["sale", "discount", "badge", "rating", "star", "strike", "border", "announcement"];
|
|
328
|
+
const EXPLICIT_BRAND_PARTS = ["brand", "primary", "cta", "button", "btn"];
|
|
329
|
+
|
|
330
|
+
function selectorSubjectParts(compound) {
|
|
331
|
+
return [...compound.classes, ...compound.ids].flatMap(tokenNameParts);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function subjectHasDecorativeRole(compound) {
|
|
335
|
+
// A button or submit input is a control whatever its classes say.
|
|
336
|
+
if (compound.type === "button") return false;
|
|
337
|
+
if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return false;
|
|
338
|
+
const parts = selectorSubjectParts(compound);
|
|
339
|
+
return hasTokenPart(parts, DECORATIVE_NAME_PARTS) && !hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function eligibleSelectorText(selector) {
|
|
343
|
+
const compounds = rightmostCompounds(selector);
|
|
344
|
+
if (!compounds) return selector;
|
|
345
|
+
return compounds.filter((compound) => !subjectHasDecorativeRole(compound))
|
|
346
|
+
.map((compound) => compound.selectorText).join(", ");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hasPrimarySelectorRole(selector) {
|
|
350
|
+
return /(header|nav|announcement|brand|hero)/i.test(eligibleSelectorText(selector));
|
|
351
|
+
}
|
|
352
|
+
|
|
322
353
|
function isCtaLikeSelector(selector) {
|
|
323
354
|
return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
|
|
324
355
|
}
|
|
@@ -337,11 +368,12 @@ const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
|
|
|
337
368
|
function rightmostCompounds(selectorList) {
|
|
338
369
|
const text = String(selectorList || "");
|
|
339
370
|
const compounds = [];
|
|
340
|
-
const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
|
|
341
|
-
const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
|
|
371
|
+
const fresh = () => ({ type: null, classes: [], ids: [], attributes: [], pseudos: [] });
|
|
372
|
+
const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.ids.length && !compound.attributes.length && !compound.pseudos.length;
|
|
342
373
|
let current = fresh();
|
|
343
374
|
let afterCombinator = false;
|
|
344
375
|
let i = 0;
|
|
376
|
+
let selectorStart = 0;
|
|
345
377
|
const readName = () => {
|
|
346
378
|
const start = i;
|
|
347
379
|
while (i < text.length && (/[A-Za-z0-9_\- -]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
|
|
@@ -371,10 +403,12 @@ function rightmostCompounds(selectorList) {
|
|
|
371
403
|
if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
|
|
372
404
|
if (ch === ",") {
|
|
373
405
|
if (isEmpty(current)) return null;
|
|
406
|
+
current.selectorText = text.slice(selectorStart, i);
|
|
374
407
|
compounds.push(current);
|
|
375
408
|
current = fresh();
|
|
376
409
|
afterCombinator = false;
|
|
377
410
|
i += 1;
|
|
411
|
+
selectorStart = i;
|
|
378
412
|
continue;
|
|
379
413
|
}
|
|
380
414
|
if (afterCombinator) { current = fresh(); afterCombinator = false; }
|
|
@@ -383,6 +417,7 @@ function rightmostCompounds(selectorList) {
|
|
|
383
417
|
const name = readName();
|
|
384
418
|
if (!name) return null;
|
|
385
419
|
if (ch === ".") current.classes.push(name);
|
|
420
|
+
else current.ids.push(name);
|
|
386
421
|
} else if (ch === "[") {
|
|
387
422
|
const attribute = readGroup("[", "]");
|
|
388
423
|
if (attribute === null) return null;
|
|
@@ -404,6 +439,7 @@ function rightmostCompounds(selectorList) {
|
|
|
404
439
|
}
|
|
405
440
|
}
|
|
406
441
|
if (isEmpty(current)) return null;
|
|
442
|
+
current.selectorText = text.slice(selectorStart);
|
|
407
443
|
compounds.push(current);
|
|
408
444
|
return compounds;
|
|
409
445
|
}
|
|
@@ -469,7 +505,7 @@ function splitDeclarations(body) {
|
|
|
469
505
|
// declaredCtaForegroundValue.
|
|
470
506
|
function collectCtaLabelRules(content, rootTokens = {}) {
|
|
471
507
|
const rules = [];
|
|
472
|
-
for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
|
|
508
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
473
509
|
if (!isButtonSelector(match[1])) continue;
|
|
474
510
|
let color = null;
|
|
475
511
|
let background = null;
|
|
@@ -975,7 +1011,7 @@ function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] }
|
|
|
975
1011
|
const hasCta = mappings.some((mapping) => mapping.target === "--brand--color--cta-primary");
|
|
976
1012
|
if (!hasCta && isNonEmptyString(tokens["--brand-accent"])) {
|
|
977
1013
|
addMapping("--brand-accent", "--brand--color--cta-primary", tokens["--brand-accent"].trim(), "medium", { method: "cta-fallback-from-brand-accent" });
|
|
978
|
-
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-accent as --brand--color--cta-primary."));
|
|
1014
|
+
warnings.push(issue("theme.cta.fallback", "No explicit CTA token or button background was found; using --brand-accent as --brand--color--cta-primary. This may be a rating or decorative accent; confirm the CTA colour against the design."));
|
|
979
1015
|
} else if (!hasCta && isNonEmptyString(tokens["--brand-primary"])) {
|
|
980
1016
|
addMapping("--brand-primary", "--brand--color--cta-primary", tokens["--brand-primary"].trim(), "medium", { method: "cta-fallback-from-brand-primary" });
|
|
981
1017
|
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-primary as --brand--color--cta-primary."));
|
|
@@ -45,6 +45,7 @@ export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
|
|
|
45
45
|
// Finding kinds, each with its own issue code under the gate id.
|
|
46
46
|
export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
|
|
47
47
|
api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
|
|
48
|
+
api_key_mismatch: `${CAMPAIGN_IDENTITY}.api_key_mismatch`,
|
|
48
49
|
funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
|
|
49
50
|
funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
|
|
50
51
|
attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
|
|
@@ -270,7 +271,7 @@ function gateBase(subject) {
|
|
|
270
271
|
* file?: string|null, content: string, spec_page?: boolean,
|
|
271
272
|
* scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
|
|
272
273
|
*/
|
|
273
|
-
export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
274
|
+
export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey = null } = {}) {
|
|
274
275
|
const all = Array.isArray(pages) ? pages : [];
|
|
275
276
|
const skipped = all
|
|
276
277
|
.filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
|
|
@@ -298,7 +299,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
298
299
|
// value. Meta and config are compared together on purpose: a page whose
|
|
299
300
|
// meta names one campaign while its config.js names another is drift
|
|
300
301
|
// inside a single page, and the SDK's precedence rule only hides it.
|
|
301
|
-
const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
|
|
302
|
+
const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, page_file: identity.file || identity.page_id, ...key })));
|
|
302
303
|
const firstKey = keyObservations[0] || null;
|
|
303
304
|
for (const observation of keyObservations.slice(1)) {
|
|
304
305
|
if (observation.value === firstKey.value) continue;
|
|
@@ -312,6 +313,32 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
312
313
|
});
|
|
313
314
|
break; // one finding per kind names the first pair; the rest follow from the same edit
|
|
314
315
|
}
|
|
316
|
+
const wrongKeys = new Map();
|
|
317
|
+
if (expectedApiKey?.key) {
|
|
318
|
+
for (const observation of keyObservations) {
|
|
319
|
+
if (observation.value === expectedApiKey.key) continue;
|
|
320
|
+
if (!wrongKeys.has(observation.value)) wrongKeys.set(observation.value, new Map());
|
|
321
|
+
const sources = wrongKeys.get(observation.value);
|
|
322
|
+
const sourceId = `${observation.where}\u0000${observation.source}`;
|
|
323
|
+
if (!sources.has(sourceId)) sources.set(sourceId, { file: observation.where, source: observation.source, pages: new Set() });
|
|
324
|
+
sources.get(sourceId).pages.add(observation.page_file);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const wrongSourceFiles = [...new Set([...wrongKeys.values()].flatMap((sources) => [...sources.values()].map((source) => source.file)))];
|
|
328
|
+
if (wrongKeys.size) {
|
|
329
|
+
const firstWrong = keyObservations.find((observation) => observation.value !== expectedApiKey.key);
|
|
330
|
+
const wrongDetails = [...wrongKeys].map(([value, sources]) =>
|
|
331
|
+
`${[...sources.values()].map(({ file, source, pages }) => `${file} (${source}; used by ${[...pages].join(", ")})`).join(" and ")} has ${quote(value)}`,
|
|
332
|
+
).join("; ");
|
|
333
|
+
findings.push({
|
|
334
|
+
kind: "api_key_mismatch",
|
|
335
|
+
code: CAMPAIGN_IDENTITY_KINDS.api_key_mismatch,
|
|
336
|
+
a: { page_id: null, file: expectedApiKey.source, value: expectedApiKey.key },
|
|
337
|
+
b: { page_id: firstWrong.page_id, file: firstWrong.where, source: firstWrong.source, value: firstWrong.value },
|
|
338
|
+
evidence: `Built API key in ${wrongDetails} differs from the Campaigns API key in ${expectedApiKey.source}.`,
|
|
339
|
+
message: `Built API key in ${wrongDetails} differs from the Campaigns API key ${quote(expectedApiKey.key)} in ${expectedApiKey.source}. Replace the built key in the named source with the campaign's own key, then rebuild.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
315
342
|
|
|
316
343
|
// 2. next-funnel: one value across pages, and present wherever the SDK is
|
|
317
344
|
// bootstrapped (a page that declares next-page-type is SDK-bound).
|
|
@@ -422,7 +449,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
422
449
|
// Short on purpose; the per-edit prose is findings[].message, which is
|
|
423
450
|
// what each doctor error carries. A reason that concatenates them is
|
|
424
451
|
// truncated by every single-line renderer.
|
|
425
|
-
reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the
|
|
452
|
+
reason: `${findings.length} campaign identity ${findings.every((finding) => finding.kind === "api_key_mismatch") ? "API key mismatch" : "drift"} finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the sources and values to repair.`,
|
|
426
453
|
findings,
|
|
427
454
|
pages_scanned: scanned.length,
|
|
428
455
|
pages_skipped: skipped,
|
|
@@ -432,7 +459,9 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
432
459
|
id: "repair_identity",
|
|
433
460
|
kind: "edit",
|
|
434
461
|
command: null,
|
|
435
|
-
description:
|
|
462
|
+
description: findings.map((finding) => finding.kind === "api_key_mismatch"
|
|
463
|
+
? `Replace the built key in ${wrongSourceFiles.join(", ")} with the campaign's key, then rebuild.`
|
|
464
|
+
: `Make every page name the same campaign: ${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")}).`).join(" "),
|
|
436
465
|
},
|
|
437
466
|
],
|
|
438
467
|
};
|