@nextcommerce/campaigns-os 1.43.1 → 1.46.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 +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Built-output script syntax gate (#480).
|
|
2
|
+
//
|
|
3
|
+
// A campaign-owned script that does not parse throws a SyntaxError on every
|
|
4
|
+
// load of every page that references it, and nothing it defines runs. The
|
|
5
|
+
// shape that shipped: a template-family checkout script copied and
|
|
6
|
+
// hand-edited, left with one closing `});` too many. Page Kit built it, every
|
|
7
|
+
// other doctor gate read the HTML and passed, and doctor reported the build
|
|
8
|
+
// ready.
|
|
9
|
+
//
|
|
10
|
+
// This gate parses every campaign-owned `.js` file a built page loads by a
|
|
11
|
+
// LOCAL `<script src>`, with acorn at the latest ecmaVersion: `sourceType:
|
|
12
|
+
// 'script'` for classic scripts and `'module'` for `type="module"`, which is
|
|
13
|
+
// how the browser reads each. A parse failure is an error naming the file, the
|
|
14
|
+
// line and the column. Remote scripts (CDN URLs, protocol-relative, data:)
|
|
15
|
+
// are not campaign-owned and are not read. A referenced local file that does
|
|
16
|
+
// not exist on disk is a warning (#502): the browser gets a 404 for it and
|
|
17
|
+
// nothing it would define runs, but whether the page needs it is not known
|
|
18
|
+
// here, so it does not block.
|
|
19
|
+
//
|
|
20
|
+
// Not waivable: a script that cannot be parsed cannot be intended to ship.
|
|
21
|
+
// Both doctor entry points drive it, like the other static built-output gates.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { basename, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { parse as parseJs } from "acorn";
|
|
27
|
+
import { parse as parseHtml } from "parse5";
|
|
28
|
+
|
|
29
|
+
export const SCRIPT_SYNTAX = "built_output.script_syntax";
|
|
30
|
+
export const SCRIPT_SYNTAX_PARSE_FAILURE = `${SCRIPT_SYNTAX}.parse_failure`;
|
|
31
|
+
export const SCRIPT_SYNTAX_MISSING_SCRIPT = `${SCRIPT_SYNTAX}.missing_script`;
|
|
32
|
+
|
|
33
|
+
// Classic script MIME types the browser executes. Anything else with a type
|
|
34
|
+
// attribute (JSON-LD, text/template, importmap) is a data block, not script.
|
|
35
|
+
const CLASSIC_SCRIPT_TYPE = /^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^text\/(?:javascript1\.[0-5]|jscript|livescript)$/i;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How a module-capable browser treats a `<script>` element, from its
|
|
39
|
+
* attributes: "classic", "module", or null when it never runs it. Follows the
|
|
40
|
+
* HTML "prepare the script element" steps: an absent or empty type (or, with
|
|
41
|
+
* no type, an absent or empty language) is classic; otherwise the type has
|
|
42
|
+
* leading and trailing ASCII whitespace stripped and is compared
|
|
43
|
+
* ASCII-case-insensitively. `nomodule` stops only classic scripts; a module
|
|
44
|
+
* script ignores it.
|
|
45
|
+
*
|
|
46
|
+
* @param {Record<string, string>} attrs
|
|
47
|
+
* @returns {"classic" | "module" | null}
|
|
48
|
+
*/
|
|
49
|
+
export function scriptKind(attrs) {
|
|
50
|
+
// The script block's type string, as the HTML steps build it: an empty
|
|
51
|
+
// type, or no type with an empty or absent language, is text/javascript; a
|
|
52
|
+
// type attribute is stripped of ASCII whitespace; otherwise "text/" plus the
|
|
53
|
+
// language attribute, unstripped.
|
|
54
|
+
const hasType = typeof attrs.type === "string";
|
|
55
|
+
const hasLanguage = typeof attrs.language === "string";
|
|
56
|
+
let type;
|
|
57
|
+
if (hasType ? attrs.type === "" : !hasLanguage || attrs.language === "") type = "text/javascript";
|
|
58
|
+
else if (hasType) type = attrs.type.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
|
|
59
|
+
else type = `text/${attrs.language}`;
|
|
60
|
+
let kind = null;
|
|
61
|
+
if (type.toLowerCase() === "module") kind = "module";
|
|
62
|
+
else if (CLASSIC_SCRIPT_TYPE.test(type)) kind = "classic";
|
|
63
|
+
if (kind === "classic" && "nomodule" in attrs) return null;
|
|
64
|
+
return kind;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Acorn messages that are fixed text. Anything else interpolates source text
|
|
68
|
+
// (an identifier, a regex body, a character) and is reduced to its category,
|
|
69
|
+
// so a token at the error site never reaches doctor or QA output.
|
|
70
|
+
const FIXED_PARSE_MESSAGES = new Set([
|
|
71
|
+
"Unexpected token", "Unterminated string constant", "Unterminated template", "Unterminated template literal",
|
|
72
|
+
"Unterminated regular expression", "Unterminated comment", "Invalid regular expression flag",
|
|
73
|
+
"Duplicate regular expression flag", "Invalid number", "Identifier directly after number", "Assigning to rvalue",
|
|
74
|
+
"'return' outside of function", "'import' and 'export' may appear only with 'sourceType: module'",
|
|
75
|
+
"'import' and 'export' may only appear at the top level", "Cannot use 'import.meta' outside a module",
|
|
76
|
+
"Cannot use keyword 'await' outside an async function", "'super' keyword outside a method",
|
|
77
|
+
"super() call outside constructor of a subclass", "Illegal newline after throw", "Missing catch or finally clause",
|
|
78
|
+
"Multiple default clauses", "Argument name clash", "Redefinition of property", "Redefinition of __proto__ property",
|
|
79
|
+
"Bad escape sequence in untagged template literal", "Invalid use of 'super'", "'with' in strict mode",
|
|
80
|
+
"Deleting local variable in strict mode", "let is disallowed as a lexically bound name",
|
|
81
|
+
"Shorthand property assignments are valid only in destructuring patterns",
|
|
82
|
+
"Complex binding patterns require an initialization value", "Comma is not permitted after the rest element",
|
|
83
|
+
"Binding member expression", "Binding parenthesized expression", "Not enough stack space to parse input",
|
|
84
|
+
"Optional chaining cannot appear in left-hand side",
|
|
85
|
+
"Logical expressions and coalesce expressions cannot be mixed. Wrap either by parentheses",
|
|
86
|
+
]);
|
|
87
|
+
const PARSE_MESSAGE_CATEGORIES = [
|
|
88
|
+
[/^Invalid regular expression\b/, "Invalid regular expression"],
|
|
89
|
+
[/^Identifier .* has already been declared$/, "Identifier has already been declared"],
|
|
90
|
+
[/^Unexpected character\b/, "Unexpected character"],
|
|
91
|
+
[/^Unexpected keyword\b/, "Unexpected keyword"],
|
|
92
|
+
[/^The keyword .* is reserved$/, "Reserved keyword"],
|
|
93
|
+
[/^Label .* is already declared$/, "Duplicate label"],
|
|
94
|
+
[/^Duplicate export\b/, "Duplicate export"],
|
|
95
|
+
[/^Undefined export\b/, "Undefined export"],
|
|
96
|
+
[/^Unsyntactic (?:break|continue)$/, "Unsyntactic break or continue"],
|
|
97
|
+
[/^Escape sequence in keyword\b/, "Escape sequence in keyword"],
|
|
98
|
+
[/^Private field\b/, "Undeclared private field"],
|
|
99
|
+
[/^Expected number in radix\b/, "Invalid number"],
|
|
100
|
+
[/in strict mode$/, "Not allowed in strict mode"],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A parser error as a fixed-vocabulary category plus a 1-based position. The
|
|
105
|
+
* message never carries source text: acorn interpolates identifiers, regex
|
|
106
|
+
* bodies and characters from the script into some messages.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} error
|
|
109
|
+
* @returns {{ line: number, column: number, message: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function parseFailureDiagnostic(error) {
|
|
112
|
+
const line = Number.isInteger(error?.loc?.line) ? error.loc.line : 1;
|
|
113
|
+
const column = Number.isInteger(error?.loc?.column) ? error.loc.column + 1 : 1;
|
|
114
|
+
const raw = String(error?.message || "").replace(/\s*\(\d+:\d+\)$/, "");
|
|
115
|
+
let message = "Syntax error";
|
|
116
|
+
if (FIXED_PARSE_MESSAGES.has(raw)) message = raw;
|
|
117
|
+
else {
|
|
118
|
+
const category = PARSE_MESSAGE_CATEGORIES.find(([pattern]) => pattern.test(raw));
|
|
119
|
+
if (category) message = category[1];
|
|
120
|
+
}
|
|
121
|
+
return { line, column, message };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse one script the way the browser would read it.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} source
|
|
128
|
+
* @param {{ module?: boolean }} [options]
|
|
129
|
+
* @returns {null | { line: number, column: number, message: string }}
|
|
130
|
+
* null when the source parses; otherwise the 1-based position and a
|
|
131
|
+
* source-free diagnostic category (see parseFailureDiagnostic).
|
|
132
|
+
*/
|
|
133
|
+
export function parseScriptSyntax(source, { module = false } = {}) {
|
|
134
|
+
try {
|
|
135
|
+
parseJs(String(source ?? ""), {
|
|
136
|
+
ecmaVersion: "latest",
|
|
137
|
+
sourceType: module ? "module" : "script",
|
|
138
|
+
allowHashBang: true,
|
|
139
|
+
locations: true,
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return parseFailureDiagnostic(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A URL attribute value as the URL parser reads it: leading and trailing C0
|
|
151
|
+
* controls and space removed, nothing else (not U+00A0 or other Unicode
|
|
152
|
+
* whitespace, which String#trim would also strip).
|
|
153
|
+
*
|
|
154
|
+
* @param {string} value
|
|
155
|
+
*/
|
|
156
|
+
export function stripUrlSpace(value) {
|
|
157
|
+
return String(value).replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every HTML-namespace `<base href>` in a parse5 document (parsed with
|
|
162
|
+
* `sourceCodeLocationInfo`), in tree order, with the source offset at which
|
|
163
|
+
* the parser inserted it. An SVG or MathML `base` is not a base element.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} root a parse5 node
|
|
166
|
+
* @returns {Array<{ href: string, offset: number }>}
|
|
167
|
+
*/
|
|
168
|
+
export function documentBases(root) {
|
|
169
|
+
const bases = [];
|
|
170
|
+
const walk = (node) => {
|
|
171
|
+
if (node.tagName === "base" && node.namespaceURI === HTML_NAMESPACE) {
|
|
172
|
+
const href = (node.attrs || []).find((attr) => attr.name === "href");
|
|
173
|
+
if (href) bases.push({ href: href.value, offset: node.sourceCodeLocation?.startOffset ?? -1 });
|
|
174
|
+
}
|
|
175
|
+
// Template content is a separate fragment in parse5 and never walked here.
|
|
176
|
+
for (const child of node.childNodes || []) walk(child);
|
|
177
|
+
};
|
|
178
|
+
walk(root);
|
|
179
|
+
return bases;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The `<base href>` in effect when the parser prepares a script element: the
|
|
184
|
+
* parser prepares it at its end tag, and "prepare the script element" parses
|
|
185
|
+
* `src` then, against the document base URL, which is the first base element
|
|
186
|
+
* with an href in tree order among those already in the document. Foster
|
|
187
|
+
* parenting can place a base parsed earlier after the script in tree order
|
|
188
|
+
* (it still counts) or one parsed later before it (it does not), so this
|
|
189
|
+
* compares source offsets rather than tree position. null when no base was
|
|
190
|
+
* in the document yet.
|
|
191
|
+
*
|
|
192
|
+
* @param {Array<{ href: string, offset: number }>} bases from documentBases
|
|
193
|
+
* @param {object} scriptNode a parse5 script element
|
|
194
|
+
* @returns {string | null}
|
|
195
|
+
*/
|
|
196
|
+
export function baseInEffect(bases, scriptNode) {
|
|
197
|
+
const location = scriptNode?.sourceCodeLocation;
|
|
198
|
+
const preparedAt = location?.endTag?.startOffset ?? location?.endOffset ?? Infinity;
|
|
199
|
+
const base = bases.find((entry) => entry.offset < preparedAt);
|
|
200
|
+
return base ? base.href : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `<script src>` references on a page, in document order, with whether each
|
|
205
|
+
* is a module and the `<base href>` in effect when the browser prepares it
|
|
206
|
+
* (see baseInEffect), plus the document's first `<base href>` (null when
|
|
207
|
+
* none). A `<base>` parsed after a script does not move it, whether the
|
|
208
|
+
* script is async, deferred or a module: its URL is resolved when prepared,
|
|
209
|
+
* not when fetched.
|
|
210
|
+
*
|
|
211
|
+
* Only HTML-namespace script elements are references: an SVG `<script>`
|
|
212
|
+
* never loads a `src` attribute. Data-block types are dropped. Classic
|
|
213
|
+
* `nomodule` scripts are dropped: a
|
|
214
|
+
* module-capable browser never fetches or runs them, so they cannot fail on
|
|
215
|
+
* load there. A module script ignores `nomodule` and is kept (see scriptKind).
|
|
216
|
+
* Template content and noscript are inert and not walked.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} html
|
|
219
|
+
* @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }> }}
|
|
220
|
+
*/
|
|
221
|
+
export function pageScriptDocument(html) {
|
|
222
|
+
const refs = [];
|
|
223
|
+
let document;
|
|
224
|
+
try {
|
|
225
|
+
document = parseHtml(String(html ?? ""), { sourceCodeLocationInfo: true });
|
|
226
|
+
} catch {
|
|
227
|
+
return { base: null, refs };
|
|
228
|
+
}
|
|
229
|
+
const bases = documentBases(document);
|
|
230
|
+
const walk = (node) => {
|
|
231
|
+
const attrs = node.tagName ? Object.fromEntries((node.attrs || []).map((attr) => [attr.name, attr.value])) : {};
|
|
232
|
+
// Only an HTML-namespace <script> loads its `src`; an SVG script reads
|
|
233
|
+
// href / xlink:href, and a MathML "script" is not a script element.
|
|
234
|
+
if (node.tagName === "script" && node.namespaceURI === HTML_NAMESPACE) {
|
|
235
|
+
const kind = scriptKind(attrs);
|
|
236
|
+
// "prepare the script element" skips only an empty src; anything else,
|
|
237
|
+
// even whitespace, is parsed as a URL and fetched.
|
|
238
|
+
if (kind && typeof attrs.src === "string" && attrs.src !== "") {
|
|
239
|
+
refs.push({ src: stripUrlSpace(attrs.src), module: kind === "module", base: baseInEffect(bases, node) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (node.tagName === "noscript") return;
|
|
243
|
+
for (const child of node.childNodes || []) walk(child);
|
|
244
|
+
};
|
|
245
|
+
walk(document);
|
|
246
|
+
return { base: bases[0]?.href ?? null, refs };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The frozen base URL of a `<base href>` (HTML "set the frozen base URL"):
|
|
251
|
+
* the href parsed against the document's fallback base URL (its own URL),
|
|
252
|
+
* falling back to that URL when the parse fails or yields a `data:` or
|
|
253
|
+
* `javascript:` URL, which the browser refuses as a base.
|
|
254
|
+
*
|
|
255
|
+
* @param {string | null} href the base element's href, or null for none
|
|
256
|
+
* @param {string} documentUrl
|
|
257
|
+
* @returns {string} the URL scripts resolve against
|
|
258
|
+
*/
|
|
259
|
+
export function frozenBaseUrl(href, documentUrl) {
|
|
260
|
+
if (href === null || href === undefined) return documentUrl;
|
|
261
|
+
let url;
|
|
262
|
+
try {
|
|
263
|
+
url = new URL(href, documentUrl);
|
|
264
|
+
} catch {
|
|
265
|
+
return documentUrl;
|
|
266
|
+
}
|
|
267
|
+
return url.protocol === "data:" || url.protocol === "javascript:" ? documentUrl : url.href;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The `<script src>` references of pageScriptDocument, without any base. */
|
|
271
|
+
export function pageScriptReferences(html) {
|
|
272
|
+
return pageScriptDocument(html).refs.map(({ src, module }) => ({ src, module }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A synthetic origin standing in for wherever the built site is served. Page
|
|
276
|
+
// URLs are their path under the site root; a script URL on any other origin
|
|
277
|
+
// is not campaign-owned.
|
|
278
|
+
const BUILT_ORIGIN = "http://built-site.invalid";
|
|
279
|
+
|
|
280
|
+
function pageUrlFor(siteRoot, builtPath) {
|
|
281
|
+
const rel = relative(siteRoot, builtPath);
|
|
282
|
+
const segments = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel.split(sep) : [basename(builtPath)];
|
|
283
|
+
return `${BUILT_ORIGIN}/${segments.map(encodeURIComponent).join("/")}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The browser's view of one reference: the URL it resolves to against the
|
|
287
|
+
// base in effect when the script was prepared. null when that URL is not on
|
|
288
|
+
// the built origin.
|
|
289
|
+
function resolveScriptUrl(src, base, pageUrl) {
|
|
290
|
+
let url;
|
|
291
|
+
try {
|
|
292
|
+
url = new URL(src, frozenBaseUrl(base, pageUrl));
|
|
293
|
+
} catch {
|
|
294
|
+
return { remote: false, pathname: null };
|
|
295
|
+
}
|
|
296
|
+
if (url.origin !== BUILT_ORIGIN) return { remote: true, pathname: null };
|
|
297
|
+
return { remote: false, pathname: url.pathname };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Decode a URL path the way a static server does before it maps it onto the
|
|
301
|
+
// disk, then keep it inside `root`. null for a malformed escape, a NUL, or a
|
|
302
|
+
// path that escapes the root.
|
|
303
|
+
function fileUnder(root, pathname) {
|
|
304
|
+
let decoded;
|
|
305
|
+
try {
|
|
306
|
+
decoded = decodeURIComponent(pathname);
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (decoded.includes("\0")) return null;
|
|
311
|
+
const rootAbs = resolve(root);
|
|
312
|
+
const path = resolve(rootAbs, `.${posix.normalize(`/${decoded.replace(/\\/g, "/")}`)}`);
|
|
313
|
+
return path === rootAbs || path.startsWith(`${rootAbs}${sep}`) ? path : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isRemote(src) {
|
|
317
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function relFrom(root, path) {
|
|
321
|
+
const rel = relative(root, path);
|
|
322
|
+
return rel && !rel.startsWith("..") ? rel.split(sep).join("/") : path;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve every built page's local script references against the filesystem.
|
|
327
|
+
* Each src resolves as the browser resolves it: against the base in effect
|
|
328
|
+
* when the script is prepared (the first `<base href>` before it in tree
|
|
329
|
+
* order, else the page URL; a `data:`, `javascript:` or unparsable base falls
|
|
330
|
+
* back to the page URL), with the page URL being its path under the site
|
|
331
|
+
* root. The resulting path is
|
|
332
|
+
* percent-decoded and mapped under the site root first (page-kit emits
|
|
333
|
+
* `/<slug>/js/...`), then the campaign directory (a root-served campaign
|
|
334
|
+
* emits `/js/...`), never outside either. A base or src on another origin is
|
|
335
|
+
* remote and not read.
|
|
336
|
+
*
|
|
337
|
+
* @param {{ site_root: string, campaign_dir: string, pages: Array<{ page_id: string, built_path: string }> }} scope
|
|
338
|
+
* @param {string} targetRepo
|
|
339
|
+
*/
|
|
340
|
+
export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
341
|
+
const scripts = new Map();
|
|
342
|
+
const unresolved = new Map();
|
|
343
|
+
const pages = Array.isArray(scope?.pages) ? scope.pages : [];
|
|
344
|
+
for (const page of pages) {
|
|
345
|
+
let html;
|
|
346
|
+
try {
|
|
347
|
+
html = readFileSync(page.built_path, "utf8");
|
|
348
|
+
} catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const { refs } = pageScriptDocument(html);
|
|
352
|
+
const pageUrl = pageUrlFor(scope.site_root, page.built_path);
|
|
353
|
+
for (const ref of refs) {
|
|
354
|
+
if (isRemote(ref.src)) continue;
|
|
355
|
+
const { remote, pathname } = resolveScriptUrl(ref.src, ref.base, pageUrl);
|
|
356
|
+
if (remote) continue;
|
|
357
|
+
let path = null;
|
|
358
|
+
if (pathname) {
|
|
359
|
+
const candidates = [fileUnder(scope.site_root, pathname), fileUnder(scope.campaign_dir, pathname)].filter(Boolean);
|
|
360
|
+
path = candidates.find((candidate) => existsSync(candidate)) || null;
|
|
361
|
+
}
|
|
362
|
+
let isFile = false;
|
|
363
|
+
try {
|
|
364
|
+
isFile = Boolean(path) && existsSync(path) && statSync(path).isFile();
|
|
365
|
+
} catch {
|
|
366
|
+
isFile = false;
|
|
367
|
+
}
|
|
368
|
+
if (!isFile) {
|
|
369
|
+
const entry = unresolved.get(ref.src) || { src: ref.src, pages: [] };
|
|
370
|
+
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
371
|
+
unresolved.set(ref.src, entry);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const key = `${resolve(path)}\u0000${ref.module ? "module" : "script"}`;
|
|
375
|
+
if (!scripts.has(key)) {
|
|
376
|
+
let content = null;
|
|
377
|
+
try {
|
|
378
|
+
content = readFileSync(path, "utf8");
|
|
379
|
+
} catch {
|
|
380
|
+
content = null;
|
|
381
|
+
}
|
|
382
|
+
if (content == null) continue;
|
|
383
|
+
scripts.set(key, { file: relFrom(targetRepo, resolve(path)), module: ref.module, content, pages: [] });
|
|
384
|
+
}
|
|
385
|
+
const entry = scripts.get(key);
|
|
386
|
+
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { pages_scanned: pages.length, scripts: [...scripts.values()], unresolved: [...unresolved.values()] };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function gateBase(subject) {
|
|
393
|
+
return {
|
|
394
|
+
id: SCRIPT_SYNTAX,
|
|
395
|
+
scope: SCRIPT_SYNTAX,
|
|
396
|
+
waivable: false,
|
|
397
|
+
subject: subject && typeof subject === "object" ? subject : {},
|
|
398
|
+
waiver: null,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Evaluate the syntax of the campaign-owned scripts built pages load. Pure:
|
|
404
|
+
* the caller hands in script contents.
|
|
405
|
+
*
|
|
406
|
+
* @param {{ subject?: object, pages_scanned?: number,
|
|
407
|
+
* scripts?: Array<{ file: string, module?: boolean, content: string, pages?: string[] }>,
|
|
408
|
+
* unresolved?: Array<{ src: string, pages: string[] }> }} input
|
|
409
|
+
*/
|
|
410
|
+
export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [] } = {}) {
|
|
411
|
+
const list = Array.isArray(scripts) ? scripts : [];
|
|
412
|
+
const missing = Array.isArray(unresolved) ? unresolved : [];
|
|
413
|
+
// A local script the page loads that is not in the built output: a 404 at
|
|
414
|
+
// runtime. A warning, not a blocker (#502).
|
|
415
|
+
const warned = missing.map((entry) => {
|
|
416
|
+
const pages = Array.isArray(entry.pages) ? entry.pages : [];
|
|
417
|
+
return {
|
|
418
|
+
code: SCRIPT_SYNTAX_MISSING_SCRIPT,
|
|
419
|
+
src: entry.src,
|
|
420
|
+
pages,
|
|
421
|
+
message: `${entry.src} is loaded by a local <script src>${pages.length ? ` on ${pages.join(", ")}` : ""} but is not in the built output. The browser gets a 404 for it and nothing it would define runs. Add the file to the build, or remove the reference if the page does not need it.`,
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
const missingNote = warned.length ? ` ${warned.length} referenced local script(s) are not in the built output.` : "";
|
|
425
|
+
const common = { scripts_unresolved: missing, warned, pages_scanned: pagesScanned };
|
|
426
|
+
if (list.length === 0) {
|
|
427
|
+
return {
|
|
428
|
+
...gateBase(subject),
|
|
429
|
+
status: "not_applicable",
|
|
430
|
+
code: `${SCRIPT_SYNTAX}.not_applicable`,
|
|
431
|
+
reason: (pagesScanned
|
|
432
|
+
? `No built page loads a campaign-owned script from disk (${pagesScanned} page(s) read).`
|
|
433
|
+
: "No built page was available to scan; script syntax is checked once pages are built.") + missingNote,
|
|
434
|
+
findings: [],
|
|
435
|
+
scripts_scanned: 0,
|
|
436
|
+
...common,
|
|
437
|
+
required_actions: [],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const findings = [];
|
|
442
|
+
for (const script of list) {
|
|
443
|
+
const failure = parseScriptSyntax(script.content, { module: Boolean(script.module) });
|
|
444
|
+
if (!failure) continue;
|
|
445
|
+
const pages = Array.isArray(script.pages) ? script.pages : [];
|
|
446
|
+
findings.push({
|
|
447
|
+
code: SCRIPT_SYNTAX_PARSE_FAILURE,
|
|
448
|
+
file: script.file,
|
|
449
|
+
line: failure.line,
|
|
450
|
+
column: failure.column,
|
|
451
|
+
source_type: script.module ? "module" : "script",
|
|
452
|
+
pages,
|
|
453
|
+
message: `${script.file}:${failure.line}:${failure.column}: ${failure.message}. The browser throws a SyntaxError loading this ${script.module ? "module" : "script"}${pages.length ? ` on ${pages.join(", ")}` : ""}, and nothing in it runs. Fix the syntax at that position (a stray or missing bracket is the usual cause) and rebuild.`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (findings.length) {
|
|
458
|
+
return {
|
|
459
|
+
...gateBase(subject),
|
|
460
|
+
status: "blocked",
|
|
461
|
+
code: SCRIPT_SYNTAX_PARSE_FAILURE,
|
|
462
|
+
reason: `${findings.length} of ${list.length} campaign-owned script(s) do not parse: ${findings.map((finding) => `${finding.file}:${finding.line}:${finding.column}`).join(", ")}.${missingNote}`,
|
|
463
|
+
findings,
|
|
464
|
+
scripts_scanned: list.length,
|
|
465
|
+
...common,
|
|
466
|
+
required_actions: findings.map((finding) => `Fix the syntax error in ${finding.file} at line ${finding.line}, column ${finding.column}, then rebuild.`),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
...gateBase(subject),
|
|
472
|
+
status: "pass",
|
|
473
|
+
code: `${SCRIPT_SYNTAX}.pass`,
|
|
474
|
+
reason: `All ${list.length} campaign-owned script(s) loaded by ${pagesScanned} built page(s) parse.${missingNote}`,
|
|
475
|
+
findings: [],
|
|
476
|
+
scripts_scanned: list.length,
|
|
477
|
+
...common,
|
|
478
|
+
required_actions: [],
|
|
479
|
+
};
|
|
480
|
+
}
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -18,6 +18,18 @@ import { basename, join, relative, sep } from "node:path";
|
|
|
18
18
|
|
|
19
19
|
const HTML_EXT = ".html";
|
|
20
20
|
|
|
21
|
+
// The route tokens inferPageType reads for the funnel roles, exported so a
|
|
22
|
+
// caller can tell which token produced a guess (#529: an explicit "upsell"
|
|
23
|
+
// word is a stronger signal than "oto"). Tested against the lower-cased,
|
|
24
|
+
// trimmed route.
|
|
25
|
+
export const ROUTE_TOKENS = Object.freeze({
|
|
26
|
+
downsell: /down[\s_/-]*sell/,
|
|
27
|
+
upsell: /up[\s_/-]*sell/,
|
|
28
|
+
one_time_offer: /(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/,
|
|
29
|
+
receipt: /thank|receipt|confirm(ation)?|order[\s_/-]*complete/,
|
|
30
|
+
checkout: /checkout|\bcart\b|\border\b/,
|
|
31
|
+
});
|
|
32
|
+
|
|
21
33
|
// Funnel page-type inference from a built route or filename. Order matters:
|
|
22
34
|
// downsell is tested before upsell, and the broad fallbacks (landing/page) run
|
|
23
35
|
// last. Returns one of the page types QA understands; "page" for generic
|
|
@@ -25,10 +37,10 @@ const HTML_EXT = ".html";
|
|
|
25
37
|
export function inferPageType(routeOrName) {
|
|
26
38
|
const value = String(routeOrName || "").toLowerCase().trim();
|
|
27
39
|
if (value === "" || value === "/" || value === "index") return "landing";
|
|
28
|
-
if (
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
40
|
+
if (ROUTE_TOKENS.downsell.test(value)) return "downsell";
|
|
41
|
+
if (ROUTE_TOKENS.upsell.test(value) || ROUTE_TOKENS.one_time_offer.test(value)) return "upsell";
|
|
42
|
+
if (ROUTE_TOKENS.receipt.test(value)) return "receipt";
|
|
43
|
+
if (ROUTE_TOKENS.checkout.test(value)) return "checkout";
|
|
32
44
|
// The two-step bundle-selection step. Deliberately narrow, and anchored on
|
|
33
45
|
// BOTH ends: the route must *be* about choosing a bundle, not merely contain
|
|
34
46
|
// the words. An editorial "/our-choose-bundle-guide/" stays generic rather
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Where the Campaigns API key comes from, and why a candidate key was rejected.
|
|
2
|
+
import {
|
|
3
|
+
isNonEmptyString,
|
|
4
|
+
optionalString,
|
|
5
|
+
firstNonEmptyString,
|
|
6
|
+
readJsonIfExists,
|
|
7
|
+
resolveFromFile,
|
|
8
|
+
} from "./cli-helpers.mjs";
|
|
9
|
+
|
|
10
|
+
// The Campaigns API key VALUE for this packet, for the remit's X-Campaign-Key
|
|
11
|
+
// header (the receiver's tenant join). Same precedence as the doctor's
|
|
12
|
+
// presence check (resolveCampaignsApiKey): packet, then the packet-local
|
|
13
|
+
// CampaignSpec, then the declared env source. Campaign keys are
|
|
14
|
+
// public-by-design; the value is sent as a header, never written into the
|
|
15
|
+
// record. Best-effort: any read problem resolves to null (unscoped remit).
|
|
16
|
+
// A campaign key is an opaque token; the receiver hashes it. The shape gate
|
|
17
|
+
// below is about what is NOT a campaign key: whitespace, JSON, a URL, a
|
|
18
|
+
// multi-kilobyte blob. The env source is additionally restricted to variable
|
|
19
|
+
// names that name a campaign key, so a packet cannot point `api_key_source`
|
|
20
|
+
// at an arbitrary secret (`env:AWS_SECRET_ACCESS_KEY`) and have its value
|
|
21
|
+
// travel as a header.
|
|
22
|
+
const CAMPAIGN_KEY_SHAPE = /^[A-Za-z0-9._-]{8,256}$/;
|
|
23
|
+
// A variable name that names a campaign key: upper-case, starts with a letter,
|
|
24
|
+
// and contains CAMPAIGN anywhere — including at the start, so the documented
|
|
25
|
+
// default `CAMPAIGNS_API_KEY` qualifies (a leading `[A-Z]` that consumed the C
|
|
26
|
+
// used to refuse exactly that name and `CAMPAIGN_KEY`).
|
|
27
|
+
const CAMPAIGN_KEY_ENV_NAME = /^(?=[A-Z])[A-Z0-9_]*CAMPAIGN[A-Z0-9_]*$/;
|
|
28
|
+
|
|
29
|
+
function campaignKeyOrNull(value) {
|
|
30
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
31
|
+
return CAMPAIGN_KEY_SHAPE.test(trimmed) ? trimmed : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Resolve the key AND why a present value was refused. A value that is there
|
|
35
|
+
// but the wrong shape is a different operator problem from no value at all —
|
|
36
|
+
// a typo, a quoted key, a whole JSON blob pasted into the env var, the wrong
|
|
37
|
+
// secret exported under a campaign-key name — and the caller must be able to
|
|
38
|
+
// say which, naming the SOURCE (the env var, or the packet field) and never
|
|
39
|
+
// the value. `rejected` is null when nothing was refused.
|
|
40
|
+
export function resolveCampaignsApiKeySource(packet, packetPath, env = process.env, { spec: loadedSpec = undefined } = {}) {
|
|
41
|
+
const packetRaw = firstNonEmptyString(packet?.campaign?.campaigns_api_key, packet?.campaign?.api_key);
|
|
42
|
+
if (isNonEmptyString(packetRaw)) {
|
|
43
|
+
const packetKey = campaignKeyOrNull(packetRaw);
|
|
44
|
+
if (packetKey) return { key: packetKey, origin: packet?.campaign?.campaigns_api_key ? "packet.campaign.campaigns_api_key" : "packet.campaign.api_key", rejected: null };
|
|
45
|
+
return { key: null, origin: null, rejected: { kind: "malformed", source: packet?.campaign?.campaigns_api_key ? "packet.campaign.campaigns_api_key" : "packet.campaign.api_key" } };
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
// A caller that already holds the packet-local CampaignSpec (doctor) hands
|
|
49
|
+
// it in; otherwise it is read from the packet's local_path.
|
|
50
|
+
const localSpecPath = packet?.spec?.local_path;
|
|
51
|
+
if (loadedSpec !== undefined || (isNonEmptyString(localSpecPath) && isNonEmptyString(packetPath))) {
|
|
52
|
+
const spec = loadedSpec !== undefined ? loadedSpec : readJsonIfExists(resolveFromFile(packetPath, localSpecPath));
|
|
53
|
+
// Name the field the value actually came from: a refused source the
|
|
54
|
+
// operator cannot find in their CampaignSpec is worse than no name.
|
|
55
|
+
const specField = [
|
|
56
|
+
["campaign.campaigns_api_key", spec?.campaign?.campaigns_api_key],
|
|
57
|
+
["campaigns_api_key", spec?.campaigns_api_key],
|
|
58
|
+
["campaign.api_key", spec?.campaign?.api_key],
|
|
59
|
+
].find(([, value]) => isNonEmptyString(value));
|
|
60
|
+
if (specField) {
|
|
61
|
+
const specKey = campaignKeyOrNull(specField[1]);
|
|
62
|
+
if (specKey) return { key: specKey, origin: `the packet-local CampaignSpec ${specField[0]}`, rejected: null };
|
|
63
|
+
return { key: null, origin: null, rejected: { kind: "malformed", source: `the packet-local CampaignSpec ${specField[0]}` } };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// unreadable spec — fall through to env
|
|
68
|
+
}
|
|
69
|
+
const source = optionalString(packet?.campaign?.api_key_source);
|
|
70
|
+
if (source && source.startsWith("env:")) {
|
|
71
|
+
const envName = source.slice("env:".length).trim();
|
|
72
|
+
if (!CAMPAIGN_KEY_ENV_NAME.test(envName)) {
|
|
73
|
+
return { key: null, origin: null, rejected: { kind: "unsupported_env_name", source: `api_key_source "env:${envName}"` } };
|
|
74
|
+
}
|
|
75
|
+
const raw = env?.[envName];
|
|
76
|
+
if (isNonEmptyString(raw)) {
|
|
77
|
+
const envKey = campaignKeyOrNull(raw);
|
|
78
|
+
if (envKey) return { key: envKey, origin: `env:${envName}`, rejected: null };
|
|
79
|
+
return { key: null, origin: null, rejected: { kind: "malformed", source: `env:${envName}` } };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { key: null, origin: null, rejected: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Back-compat shape: the key value or null, for callers that only need the
|
|
86
|
+
// header value.
|
|
87
|
+
export function resolveCampaignsApiKeyValue(packet, packetPath, env = process.env) {
|
|
88
|
+
return resolveCampaignsApiKeySource(packet, packetPath, env).key;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One sentence an operator can act on, naming the refused source and never
|
|
92
|
+
// its value. `null` when nothing was refused.
|
|
93
|
+
export function describeCampaignKeyRejection(rejected) {
|
|
94
|
+
if (!rejected) return null;
|
|
95
|
+
if (rejected.kind === "unsupported_env_name") {
|
|
96
|
+
return `${rejected.source} does not name a campaign key, so its value was not read (an api_key_source env var must match ${CAMPAIGN_KEY_ENV_NAME.source}). No credential was taken from it.`;
|
|
97
|
+
}
|
|
98
|
+
return `the Campaigns API key from ${rejected.source} is not a campaign-key shape (expected ${CAMPAIGN_KEY_SHAPE.source} — 8-256 chars of letters, digits, dot, dash, underscore, one line, no whitespace) and was refused before any request. Fix the value at that source; it is not printed here.`;
|
|
99
|
+
}
|