@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/readback.mjs
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* most once, plus two fixed Git metadata files for the staleness comparison
|
|
9
9
|
* (the nearest `.git` entry at the target or one of its ancestors — funnels are
|
|
10
10
|
* usually subdirectories of their enclosing campaign repository — to locate the
|
|
11
|
-
* Git directory, and that directory's `logs/HEAD` reflog)
|
|
11
|
+
* Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
|
|
12
|
+
* and normalized Campaign Build Brief the Build Packet names, whose current
|
|
13
|
+
* content decides each stage's effective status. It writes nothing,
|
|
12
14
|
* starts no process, and touches no network. That contract is why the CLI
|
|
13
15
|
* exempts `readback` from lifecycle-journal capture the way it exempts doctor
|
|
14
16
|
* inspection: a command declared read-only must not append a journal entry.
|
|
@@ -49,6 +51,9 @@ import { closeSync, fstatSync, openSync, readdirSync, readSync, statSync } from
|
|
|
49
51
|
import { isAbsolute, join, resolve } from "node:path";
|
|
50
52
|
import { fileURLToPath } from "node:url";
|
|
51
53
|
|
|
54
|
+
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
|
|
55
|
+
import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
|
|
56
|
+
|
|
52
57
|
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
53
58
|
export const MAX_GIT_METADATA_BYTES = 64 * 1024;
|
|
54
59
|
|
|
@@ -1268,22 +1273,52 @@ export function renderBlocker(blocker) {
|
|
|
1268
1273
|
return `(non-string entry, shown as written) ${rendered}`;
|
|
1269
1274
|
}
|
|
1270
1275
|
|
|
1271
|
-
|
|
1276
|
+
/**
|
|
1277
|
+
* The campaign's current inputs, read through the projected Build Packet: the
|
|
1278
|
+
* material of the CampaignSpec and of the normalized brief it names. A value
|
|
1279
|
+
* that cannot be read is null, and the stages that depend on it read unknown.
|
|
1280
|
+
*/
|
|
1281
|
+
export function readbackInputs(views) {
|
|
1282
|
+
const packet = artifactData(views, "packet");
|
|
1283
|
+
if (packet === null) return { briefMaterial: null, specMaterial: null };
|
|
1284
|
+
return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Superseded records shown per stage, newest last (the report keeps five).
|
|
1288
|
+
const HISTORY_RENDER_CAP = 5;
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Each stage at its effective status against `inputs` (readbackInputs): a
|
|
1292
|
+
* stage owed again by a brief or CampaignSpec change reads `required`, one
|
|
1293
|
+
* whose inputs cannot be confirmed reads `unknown`, and where that differs
|
|
1294
|
+
* from the record the line adds the recorded status and the reason. The
|
|
1295
|
+
* header carries the report's recorded status beside the one recomputed from
|
|
1296
|
+
* the effective statuses. Superseded records are listed only under the
|
|
1297
|
+
* "history (not current proof)" label.
|
|
1298
|
+
*/
|
|
1299
|
+
function renderStages(views, lines, inputs = null) {
|
|
1272
1300
|
const report = artifactData(views, "report");
|
|
1273
1301
|
if (report === null) return;
|
|
1274
|
-
|
|
1302
|
+
const current = { briefMaterial: inputs?.briefMaterial ?? null, specMaterial: inputs?.specMaterial ?? null };
|
|
1303
|
+
const currency = assessInputCurrency({ report, ...current });
|
|
1304
|
+
const effectiveReport = deriveAssemblyReportSummary(report, current).status;
|
|
1305
|
+
lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}; effective: ${effectiveReport}]`);
|
|
1275
1306
|
const stages = report.stages;
|
|
1276
1307
|
if (isPlainObject(stages)) {
|
|
1277
1308
|
for (const [name, stage] of Object.entries(stages)) {
|
|
1278
1309
|
if (!isPlainObject(stage)) continue;
|
|
1279
1310
|
const status = stage.status ?? "(no status recorded)";
|
|
1311
|
+
const effective = effectiveStageStatus(name, stage, currency);
|
|
1312
|
+
const shown = effective === stage.status
|
|
1313
|
+
? status
|
|
1314
|
+
: `${effective.padEnd(10)} (recorded: ${status}; ${currency.reasons?.[name] ?? "status_unrecognized"})`;
|
|
1280
1315
|
const counters = [];
|
|
1281
1316
|
const blockers = stage.blockers;
|
|
1282
1317
|
const warnings = stage.warnings;
|
|
1283
1318
|
if (Array.isArray(blockers) && blockers.length) counters.push(`${blockers.length} blocker(s)`);
|
|
1284
1319
|
if (Array.isArray(warnings) && warnings.length) counters.push(`${warnings.length} warning(s)`);
|
|
1285
1320
|
const suffix = counters.length ? ` (${counters.join(", ")})` : "";
|
|
1286
|
-
lines.push(` ${name.padEnd(14)} ${
|
|
1321
|
+
lines.push(` ${name.padEnd(14)} ${shown}${suffix}`);
|
|
1287
1322
|
if (Array.isArray(blockers) && blockers.length) {
|
|
1288
1323
|
for (const blocker of blockers.slice(0, BLOCKER_RENDER_CAP)) {
|
|
1289
1324
|
lines.push(` blocker: ${renderBlocker(blocker)}`);
|
|
@@ -1291,6 +1326,16 @@ function renderStages(views, lines) {
|
|
|
1291
1326
|
const hidden = blockers.length - BLOCKER_RENDER_CAP;
|
|
1292
1327
|
if (hidden > 0) lines.push(` ... and ${hidden} more blocker(s) recorded in the assembly report`);
|
|
1293
1328
|
}
|
|
1329
|
+
const history = Array.isArray(stage.history) ? stage.history.filter(isPlainObject) : [];
|
|
1330
|
+
if (history.length) {
|
|
1331
|
+
lines.push(` history (not current proof): ${history.length} superseded record(s)`);
|
|
1332
|
+
for (const entry of history.slice(-HISTORY_RENDER_CAP)) {
|
|
1333
|
+
lines.push(
|
|
1334
|
+
` ${oneLine(String(recorded(entry.archived_at)))} ${oneLine(String(recorded(entry.reason_code)))}: ` +
|
|
1335
|
+
`was ${oneLine(String(recorded(entry.status)))}${entry.archived_by ? ` (archived by ${oneLine(String(entry.archived_by))})` : ""}`,
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1294
1339
|
}
|
|
1295
1340
|
}
|
|
1296
1341
|
lines.push("");
|
|
@@ -1496,8 +1541,10 @@ function renderFindings(views, lines) {
|
|
|
1496
1541
|
* artifact table says nothing about how the packet was chosen. The
|
|
1497
1542
|
* one-argument form keeps working: the artifact table shows each loaded
|
|
1498
1543
|
* artifact's generated_at regardless of whether an assessment was supplied.
|
|
1544
|
+
* `inputs` is readbackInputs(views); when omitted, every input reads as not
|
|
1545
|
+
* readable, so a build, Polish or QA record reads unknown.
|
|
1499
1546
|
*/
|
|
1500
|
-
export function projectReadback(views, staleness = null, packetSelection = null) {
|
|
1547
|
+
export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
|
|
1501
1548
|
const lines = [
|
|
1502
1549
|
"CAMPAIGNS OS RUN-ARTIFACT READBACK",
|
|
1503
1550
|
"A read-only projection of this run's emitted artifacts. Campaigns OS",
|
|
@@ -1509,7 +1556,7 @@ export function projectReadback(views, staleness = null, packetSelection = null)
|
|
|
1509
1556
|
renderStaleness(staleness, lines);
|
|
1510
1557
|
renderArtifactTable(views, lines, packetSelection);
|
|
1511
1558
|
renderIdentity(views, lines);
|
|
1512
|
-
renderStages(views, lines);
|
|
1559
|
+
renderStages(views, lines, inputs);
|
|
1513
1560
|
renderContext(views, lines);
|
|
1514
1561
|
renderDoctor(views, lines);
|
|
1515
1562
|
renderVerdict(views, lines);
|
|
@@ -1806,7 +1853,7 @@ export function resolvePaths(root, overrides = {}) {
|
|
|
1806
1853
|
export function projectTarget(root, overrides = {}) {
|
|
1807
1854
|
const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
|
|
1808
1855
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1809
|
-
return { views, staleness: assessStaleness(root, views), packetSelection };
|
|
1856
|
+
return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
|
|
1810
1857
|
}
|
|
1811
1858
|
|
|
1812
1859
|
// The bundled synthetic sample `--example` projects. It is already on the
|
|
@@ -1837,6 +1884,7 @@ export const EXAMPLE_HEAD_DETAIL =
|
|
|
1837
1884
|
export function projectExample() {
|
|
1838
1885
|
const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
|
|
1839
1886
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1887
|
+
const inputs = readbackInputs(views);
|
|
1840
1888
|
for (const view of Object.values(views)) {
|
|
1841
1889
|
const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1842
1890
|
view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
|
|
@@ -1844,7 +1892,7 @@ export function projectExample() {
|
|
|
1844
1892
|
const staleness = assessStaleness(EXAMPLE_ROOT, views, {
|
|
1845
1893
|
headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
|
|
1846
1894
|
});
|
|
1847
|
-
return { views, staleness, packetSelection };
|
|
1895
|
+
return { views, staleness, packetSelection, inputs };
|
|
1848
1896
|
}
|
|
1849
1897
|
|
|
1850
1898
|
const OVERRIDE_FLAGS = {
|
|
@@ -1938,9 +1986,9 @@ export function runReadbackCommand(args) {
|
|
|
1938
1986
|
if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
|
|
1939
1987
|
throw error;
|
|
1940
1988
|
}
|
|
1941
|
-
const { views, staleness, packetSelection } = projection;
|
|
1989
|
+
const { views, staleness, packetSelection, inputs } = projection;
|
|
1942
1990
|
const text = request.json
|
|
1943
1991
|
? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
|
|
1944
|
-
: projectReadback(views, staleness, packetSelection);
|
|
1992
|
+
: projectReadback(views, staleness, packetSelection, inputs);
|
|
1945
1993
|
return { exitCode: 0, text };
|
|
1946
1994
|
}
|
|
@@ -210,3 +210,74 @@ export function isKnownCheckoutFieldName(value) {
|
|
|
210
210
|
return SDK_CHECKOUT_FIELD_NAMES.includes(name)
|
|
211
211
|
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
|
|
212
212
|
}
|
|
213
|
+
|
|
214
|
+
// The Campaign Cart template placeholders, for the raw cart placeholder check
|
|
215
|
+
// (built_output.cart_placeholders). Generated from the SDK renderers at the
|
|
216
|
+
// same tag as the attribute index above, v0.4.38:
|
|
217
|
+
//
|
|
218
|
+
// src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
|
|
219
|
+
// src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
|
|
220
|
+
// 228-240,256-314 ({item.*} and its {line.*} alias, {property.*},
|
|
221
|
+
// {discount.*})
|
|
222
|
+
// src/features/cart/package-selector/package-selector.renderer.ts:26
|
|
223
|
+
// ({package.<any key>})
|
|
224
|
+
// src/features/cart/bundle-selector/bundle-selector.renderer.ts:37-42
|
|
225
|
+
// ({bundle.<any key>})
|
|
226
|
+
// src/features/cart/package-toggle/package-toggle.enhancer.ts:59-65
|
|
227
|
+
// ({toggle.<any key>})
|
|
228
|
+
// src/features/cart/quantity-control/quantity-control.renderer.ts:69-70
|
|
229
|
+
// ({quantity}, {step})
|
|
230
|
+
// src/features/cart/remove-item/remove-item.renderer.ts:26 ({quantity})
|
|
231
|
+
// src/features/display/quantity-text/quantity-text.enhancer.ts:124,149
|
|
232
|
+
// ({qty...} and {singular|plural})
|
|
233
|
+
//
|
|
234
|
+
// Every namespaced renderer replaces any `{<namespace>.<key>}` in its
|
|
235
|
+
// template (item.property.<key> and the package, bundle and toggle keys are
|
|
236
|
+
// open-ended; an unmapped key renders empty), so the namespace alone makes a
|
|
237
|
+
// token known. `{tax}` is not a cart-summary var at this tag. Regenerate the
|
|
238
|
+
// lists whole from the renderers when the index pin advances; never edit them
|
|
239
|
+
// by hand.
|
|
240
|
+
export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
241
|
+
cart_summary_vars: Object.freeze([
|
|
242
|
+
"subtotal",
|
|
243
|
+
"total",
|
|
244
|
+
"shipping",
|
|
245
|
+
"shippingName",
|
|
246
|
+
"shippingCode",
|
|
247
|
+
"shippingOriginal",
|
|
248
|
+
"shippingDiscountAmount",
|
|
249
|
+
"shippingDiscountPercentage",
|
|
250
|
+
"totalDiscount",
|
|
251
|
+
"totalDiscountPercentage",
|
|
252
|
+
"discounts",
|
|
253
|
+
"currency",
|
|
254
|
+
"isCalculating",
|
|
255
|
+
"isEmpty",
|
|
256
|
+
"itemCount",
|
|
257
|
+
"totalQuantity",
|
|
258
|
+
"isFreeShipping",
|
|
259
|
+
"hasShippingDiscount",
|
|
260
|
+
"hasDiscounts",
|
|
261
|
+
]),
|
|
262
|
+
namespaces: Object.freeze(["item", "line", "discount", "property", "package", "bundle", "toggle"]),
|
|
263
|
+
// Tokens the SDK substitutes in live markup, and the elements that own them.
|
|
264
|
+
live_tokens: Object.freeze(["quantity", "step", "qty"]),
|
|
265
|
+
// [data-next-quantity="increase|decrease|set"]: {quantity} and {step}.
|
|
266
|
+
quantity_control: Object.freeze({ attribute: "data-next-quantity", values: Object.freeze(["increase", "decrease", "set"]), tokens: Object.freeze(["quantity", "step"]) }),
|
|
267
|
+
// [data-next-remove-item]: {quantity} only.
|
|
268
|
+
remove_item: Object.freeze({ attribute: "data-next-remove-item", tokens: Object.freeze(["quantity"]) }),
|
|
269
|
+
// [data-next-quantity-text]: {qty...} and the singular/plural form.
|
|
270
|
+
// `qty_forms` is the renderer's own {qty} pattern (quantity-text.enhancer.ts:
|
|
271
|
+
// 124, /\{qty([*+\-]?\d*)\}/): {qty}, {qty*2}, {qty+1}, {qty-1}.
|
|
272
|
+
quantity_text: Object.freeze({ attribute: "data-next-quantity-text", tokens: Object.freeze(["qty"]), qty_forms: "qty[*+\\-]?\\d*" }),
|
|
273
|
+
// Item lists whose innerHTML the SDK uses as the row template or replaces
|
|
274
|
+
// (cart-item-list.enhancer.ts:21-35, order-item-list.enhancer.ts:26-36).
|
|
275
|
+
item_list_containers: Object.freeze(["data-next-cart-items", "data-next-order-items"]),
|
|
276
|
+
item_template_selector: "data-item-template-selector",
|
|
277
|
+
item_template: "data-item-template",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// The SDK versions whose renderer files above were verified unchanged from
|
|
281
|
+
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39 and v0.4.40). A page whose
|
|
282
|
+
// loader pins another version cannot pass the placeholder check.
|
|
283
|
+
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
86
86
|
|
|
87
87
|
// Attributes whose value names a <template> by id. The SDK reads the template
|
|
88
88
|
// they point at, so that template is SDK-owned wherever it sits.
|
|
89
|
-
const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
89
|
+
export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
90
90
|
|
|
91
91
|
// Containers whose DIRECT <template> child the SDK clones (each does a
|
|
92
92
|
// `:scope > template` lookup at v0.4.38: cart-summary and its
|
|
@@ -98,7 +98,7 @@ const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
|
98
98
|
// package-toggle).
|
|
99
99
|
// Only the direct child: a vendor template nested deeper inside SDK chrome is
|
|
100
100
|
// never read, so it may use any syntax.
|
|
101
|
-
const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
101
|
+
export const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
102
102
|
"data-next-cart-summary",
|
|
103
103
|
"data-summary-lines",
|
|
104
104
|
"data-next-discounts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Read-only source evidence. SDK migration names come exclusively from the supplied manifest.
|
|
2
2
|
import { parse as parseJs } from 'acorn';
|
|
3
3
|
import { parse as parseHtml } from 'parse5';
|
|
4
|
+
import { parseDocument, isSeq, isScalar, LineCounter } from 'yaml';
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
6
7
|
import { resolve, relative, dirname, posix, sep } from 'node:path';
|
|
@@ -261,6 +262,35 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
|
|
|
261
262
|
visit(ast, null);
|
|
262
263
|
return findings;
|
|
263
264
|
}
|
|
265
|
+
// Page Kit's campaign_asset filter serves src/<slug>/assets/<path>, <slug> being
|
|
266
|
+
// the campaign folder the template sits in. A layout's {% for script in scripts %}
|
|
267
|
+
// loop loads each page's frontmatter `scripts:` entries through the same filter.
|
|
268
|
+
const CAMPAIGN_ASSET_SRC = /^\{\{-?\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z_]\w*))\s*\|\s*campaign_asset\s*-?\}\}$/;
|
|
269
|
+
function campaignAssetRoot(path) {
|
|
270
|
+
const match = /^((?:.*\/)?src\/[^/]+)\//.exec(path);
|
|
271
|
+
return match ? `${match[1]}/assets` : null;
|
|
272
|
+
}
|
|
273
|
+
function loopsOverPageScripts(text, name) {
|
|
274
|
+
return new RegExp(`\\{%-?\\s*for\\s+${name}\\s+in\\s+(?:page\\.)?scripts\\b`).test(text);
|
|
275
|
+
}
|
|
276
|
+
// Each local-or-remote entry of a page's frontmatter `scripts:` list with the
|
|
277
|
+
// file line it is declared on, or an error naming why the list cannot be read.
|
|
278
|
+
function frontmatterScripts(text) {
|
|
279
|
+
const block = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
280
|
+
if (!block) return { scripts: [] };
|
|
281
|
+
const lineCounter = new LineCounter();
|
|
282
|
+
const document = parseDocument(block[1], { lineCounter });
|
|
283
|
+
if (document.errors.length) {
|
|
284
|
+
const { line } = lineCounter.linePos(document.errors[0].pos[0]);
|
|
285
|
+
return { error: `Frontmatter is not valid YAML (${document.errors[0].code}); review the scripts this page loads.`, line: line + 1 };
|
|
286
|
+
}
|
|
287
|
+
const scripts = document.get('scripts', true);
|
|
288
|
+
if (scripts === undefined || scripts === null || (isScalar(scripts) && scripts.value === null)) return { scripts: [] };
|
|
289
|
+
if (!isSeq(scripts) || !scripts.items.every(item => isScalar(item) && typeof item.value === 'string'))
|
|
290
|
+
return { error: 'Frontmatter scripts is not a list of paths; review the scripts this page loads.', line: lineCounter.linePos(scripts.range?.[0] ?? 0).line + 1 };
|
|
291
|
+
// linePos is 1-based within the YAML block, which starts on the file's line 2.
|
|
292
|
+
return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
|
|
293
|
+
}
|
|
264
294
|
export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
|
|
265
295
|
version(targetSdkVersion);
|
|
266
296
|
if (!Array.isArray(scope) || !scope.length)
|
|
@@ -320,20 +350,50 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
|
|
|
320
350
|
for (const child of node.childNodes ?? []) findBase(child);
|
|
321
351
|
}
|
|
322
352
|
findBase(document);
|
|
353
|
+
const assetRoot = campaignAssetRoot(path);
|
|
354
|
+
const requireSelected = (sourcePath, line) => {
|
|
355
|
+
if (!selected.includes(sourcePath))
|
|
356
|
+
unknown(path, 'shared-script-outside-scope', sourcePath, line);
|
|
357
|
+
};
|
|
358
|
+
// Page Kit serves campaign_asset paths from the assets folder only; an entry
|
|
359
|
+
// that normalizes outside it is not a file the page loads from there.
|
|
360
|
+
const requireAsset = (asset, line) => {
|
|
361
|
+
const sourcePath = posix.normalize(`${assetRoot}/${asset.split(/[?#]/)[0]}`);
|
|
362
|
+
if (sourcePath.startsWith(`${assetRoot}/`)) requireSelected(sourcePath, line);
|
|
363
|
+
else unknown(path, 'shared-script-outside-scope', `${asset} resolves outside ${assetRoot}`, line);
|
|
364
|
+
};
|
|
365
|
+
if (assetRoot) {
|
|
366
|
+
const frontmatter = frontmatterScripts(text);
|
|
367
|
+
if (frontmatter.error)
|
|
368
|
+
unknown(path, 'shared-script-outside-scope', frontmatter.error, frontmatter.line);
|
|
369
|
+
for (const { value, line } of frontmatter.scripts ?? []) {
|
|
370
|
+
if (/^(?:[a-z]+:)?\/\//i.test(value) || value.startsWith('data:')) continue;
|
|
371
|
+
requireAsset(value, line);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
323
374
|
function html(node) {
|
|
324
375
|
if (node.tagName === 'script') {
|
|
325
376
|
const attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
|
|
326
377
|
const type = (attrs.type ?? '').trim().toLowerCase();
|
|
327
378
|
if (!type || ['module', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript'].includes(type)) {
|
|
328
|
-
|
|
379
|
+
const campaignAsset = attrs.src ? CAMPAIGN_ASSET_SRC.exec(attrs.src.trim()) : null;
|
|
380
|
+
if (campaignAsset) {
|
|
381
|
+
const line = node.sourceCodeLocation?.startLine ?? 1;
|
|
382
|
+
const literal = campaignAsset[1] ?? campaignAsset[2];
|
|
383
|
+
if (assetRoot && literal !== undefined)
|
|
384
|
+
requireAsset(literal, line);
|
|
385
|
+
else if (!assetRoot || !loopsOverPageScripts(text, campaignAsset[3]))
|
|
386
|
+
unknown(path, 'shared-script-outside-scope', attrs.src, line);
|
|
387
|
+
// A frontmatter scripts loop: each page's own entries are checked above.
|
|
388
|
+
}
|
|
389
|
+
else if (attrs.src) {
|
|
329
390
|
if (baseHref !== null && !attrs.src.startsWith('/') && !/^[a-z]+:/i.test(attrs.src)) {
|
|
330
391
|
unknown(path, 'html-base-script-resolution', `Relative script ${attrs.src} resolves against base ${baseHref}; include/review the actual dependency.`, node.sourceCodeLocation?.startLine ?? 1);
|
|
331
392
|
}
|
|
332
393
|
if (!/^(?:[a-z]+:)?\/\//i.test(attrs.src) && !attrs.src.startsWith('data:')) {
|
|
333
394
|
const localSource = attrs.src.split(/[?#]/)[0];
|
|
334
395
|
const sourcePath = posix.normalize(localSource.startsWith('/') ? localSource.slice(1) : posix.join(posix.dirname(path), localSource));
|
|
335
|
-
|
|
336
|
-
unknown(path, 'shared-script-outside-scope', sourcePath, node.sourceCodeLocation?.startLine ?? 1);
|
|
396
|
+
requireSelected(sourcePath, node.sourceCodeLocation?.startLine ?? 1);
|
|
337
397
|
}
|
|
338
398
|
}
|
|
339
399
|
else if (node.sourceCodeLocation?.startTag) {
|
package/src/source-prep.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// source asset crawl (source_asset.* codes) so the two checks never disagree
|
|
9
9
|
// about the same reference.
|
|
10
10
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
12
12
|
import { collectDocumentWrapperNames } from "./adapter-decision-contract.mjs";
|
|
13
13
|
|
|
14
14
|
export const SOURCE_PREP_DOCUMENT_WRAPPER = "source_html.prep.document_wrapper";
|
|
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
201
201
|
const policyNote = wrapperPolicy === "preserve_document_wrappers"
|
|
202
202
|
? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
|
|
203
203
|
: "";
|
|
204
|
-
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
204
|
+
return `Mapped source HTML (the converted page-kit page once it exists at page_kit.output_path, else the design) is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
205
205
|
}
|
|
206
206
|
if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
|
|
207
207
|
const listed = sample.map((page) => {
|
|
@@ -220,6 +220,26 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
220
220
|
return `Mapped source HTML still links to source files instead of CampaignSpec routes: ${listed}${more}. Replace internal links and CTA destinations with CampaignSpec-derived routes, usually via campaign_link; source filenames like checkout.html are not built campaign URLs. See ${docs}.`;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
// The converted page-kit page for a mapped design (page_kit.output_path under
|
|
224
|
+
// the target repo), when it has been written.
|
|
225
|
+
function convertedPageContent(targetRoot, page) {
|
|
226
|
+
const outputPath = page?.page_kit?.output_path;
|
|
227
|
+
if (!isNonEmptyString(targetRoot) || !isNonEmptyString(outputPath)) return null;
|
|
228
|
+
// output_path is repo-relative. One that is absolute or climbs out of the
|
|
229
|
+
// target repo is not a converted page of this campaign, so it reads like a
|
|
230
|
+
// missing one and the design is checked instead.
|
|
231
|
+
const root = resolve(targetRoot);
|
|
232
|
+
const fullPath = resolve(root, outputPath);
|
|
233
|
+
const rel = relative(root, fullPath);
|
|
234
|
+
if (isAbsolute(outputPath) || rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
235
|
+
if (!safeIsFile(fullPath)) return null;
|
|
236
|
+
try {
|
|
237
|
+
return { path: toPosixPath(outputPath), content: readFileSync(fullPath, "utf8") };
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
223
243
|
/**
|
|
224
244
|
* Evaluates the page-kit source-preparation expectations for every mapped
|
|
225
245
|
* source page. Deterministic: same files in, same findings out. Unreadable or
|
|
@@ -227,7 +247,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
227
247
|
*
|
|
228
248
|
* @returns {{ checked_page_count: number, findings: Array<{code, severity, message, docs, pages}> }}
|
|
229
249
|
*/
|
|
230
|
-
export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null }) {
|
|
250
|
+
export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null, targetRoot = null }) {
|
|
231
251
|
const mappedPaths = new Set(
|
|
232
252
|
pages
|
|
233
253
|
.map((page) => (isNonEmptyString(page?.path) ? toPosixPath(page.path) : null))
|
|
@@ -247,14 +267,24 @@ export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolic
|
|
|
247
267
|
continue;
|
|
248
268
|
}
|
|
249
269
|
checked += 1;
|
|
250
|
-
|
|
270
|
+
let pageFindings = inspectPageContent({ content, sourceRoot, pagePath: page.path, mappedPaths });
|
|
271
|
+
// Wrappers are stripped when the design is converted into the page-kit
|
|
272
|
+
// page at page_kit.output_path, the file page-kit builds. Once that page
|
|
273
|
+
// exists, it is the one checked; the design keeps its wrappers.
|
|
274
|
+
const converted = convertedPageContent(targetRoot, page);
|
|
275
|
+
if (converted) {
|
|
276
|
+
pageFindings = pageFindings.filter((finding) => finding.code !== SOURCE_PREP_DOCUMENT_WRAPPER);
|
|
277
|
+
const wrappers = collectDocumentWrapperNames(converted.content);
|
|
278
|
+
if (wrappers.length) pageFindings.push({ code: SOURCE_PREP_DOCUMENT_WRAPPER, wrappers, path: converted.path });
|
|
279
|
+
}
|
|
251
280
|
for (const finding of pageFindings) {
|
|
252
281
|
if (!byCode.has(finding.code)) byCode.set(finding.code, new Map());
|
|
253
282
|
const pagesForCode = byCode.get(finding.code);
|
|
254
|
-
|
|
255
|
-
|
|
283
|
+
const findingPath = finding.path || page.path;
|
|
284
|
+
if (!pagesForCode.has(findingPath)) {
|
|
285
|
+
pagesForCode.set(findingPath, { page_id: page.page_id || null, path: findingPath, wrappers: [], hrefs: [], variants: [] });
|
|
256
286
|
}
|
|
257
|
-
const entry = pagesForCode.get(
|
|
287
|
+
const entry = pagesForCode.get(findingPath);
|
|
258
288
|
if (finding.wrappers) entry.wrappers.push(...finding.wrappers);
|
|
259
289
|
if (finding.hrefs) entry.hrefs.push(...finding.hrefs);
|
|
260
290
|
if (finding.variant) entry.variants.push({ variant: finding.variant, lines: finding.lines || [] });
|