@nextcommerce/campaigns-os 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/brand-theme.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
mkdirSync,
|
|
8
8
|
readdirSync,
|
|
9
9
|
readFileSync,
|
|
10
|
+
realpathSync,
|
|
10
11
|
renameSync,
|
|
11
12
|
rmSync,
|
|
12
13
|
statSync,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from "node:fs";
|
|
15
16
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { packetRuntimeRelPath } from "./campaign-workspace.mjs";
|
|
17
19
|
|
|
18
20
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
21
|
const SOURCE_DEFAULTS_PATH = "contracts/brand-theme-source-defaults.figma-sections-export.v0.json";
|
|
@@ -521,6 +523,357 @@ function collectCtaLabelRules(content, rootTokens = {}) {
|
|
|
521
523
|
return rules;
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
// --- Typography -----------------------------------------------------------
|
|
527
|
+
// The design source's self-hosted brand faces, and which of them its
|
|
528
|
+
// headings, body copy and buttons use. next-core takes font family and button
|
|
529
|
+
// radius as custom properties (contracts/brand-theme-target-tokens.next-core.v0.json,
|
|
530
|
+
// `typography`), so commerce pages carry the design's type through the same
|
|
531
|
+
// token layer as its colours; the @font-face rules ship beside brand-theme.css
|
|
532
|
+
// in brand-fonts.css. A family is mapped only when the source ships a face for
|
|
533
|
+
// it: naming a font the page cannot load would only change the fallback.
|
|
534
|
+
|
|
535
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
536
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif",
|
|
537
|
+
"ui-monospace", "ui-rounded", "math", "emoji", "inherit", "initial", "unset", "revert", "-apple-system",
|
|
538
|
+
"blinkmacsystemfont",
|
|
539
|
+
]);
|
|
540
|
+
const FALLBACK_GENERICS = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy"]);
|
|
541
|
+
const HEADING_ELEMENTS = new Set(["h1", "h2", "h3"]);
|
|
542
|
+
const HEADING_NAME_PARTS = new Set(["heading", "headline", "title"]);
|
|
543
|
+
const BODY_ELEMENTS = new Set(["html", "body", "p"]);
|
|
544
|
+
const FONT_FACE_DESCRIPTORS = new Set(["font-family", "font-style", "font-weight", "font-display", "font-stretch", "unicode-range", "src"]);
|
|
545
|
+
// brand-fonts.css is placed in the campaign's assets/css/ beside
|
|
546
|
+
// brand-theme.css, and build copies the source's assets/ tree into the
|
|
547
|
+
// campaign's, so face URLs are written relative to the source's assets/css/.
|
|
548
|
+
const FONTS_CSS_SOURCE_DIR = "assets/css";
|
|
549
|
+
export const BRAND_FONTS_FILENAME = "brand-fonts.css";
|
|
550
|
+
|
|
551
|
+
// A family name as read from source CSS, unquoted and stripped of anything
|
|
552
|
+
// that could not appear in a valid quoted family (control characters, braces,
|
|
553
|
+
// semicolons, at-signs, quotes and backslashes), so generated values stay
|
|
554
|
+
// valid by construction.
|
|
555
|
+
function unquoteFamily(name) {
|
|
556
|
+
return String(name || "").trim().replace(/^(["'])(.*)\1$/, "$2")
|
|
557
|
+
.replace(/[\u0000-\u001f\u007f{};@"'\\]/g, "").replace(/\s+/g, " ").trim();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// A value with its var() references resolved through the source's :root
|
|
561
|
+
// custom properties (Shopify themes route type through --font-body-family and
|
|
562
|
+
// friends). An unresolved var() takes its fallback, else stays unresolved.
|
|
563
|
+
function resolveCssVars(value, tokens = {}, depth = 0) {
|
|
564
|
+
const text = String(value || "");
|
|
565
|
+
if (depth > 5 || !/var\(/i.test(text)) return text;
|
|
566
|
+
const replaced = text.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (match, name, fallback) => {
|
|
567
|
+
if (isNonEmptyString(tokens[name])) return tokens[name];
|
|
568
|
+
return fallback !== undefined ? fallback : match;
|
|
569
|
+
});
|
|
570
|
+
return replaced === text ? text : resolveCssVars(replaced, tokens, depth + 1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// The first named family in a font-family value and the generic it falls
|
|
574
|
+
// back to, if it names one. null for unresolved var() and generic-only stacks.
|
|
575
|
+
function fontStack(value, tokens = {}) {
|
|
576
|
+
const cleaned = resolveCssVars(value, tokens).replace(/!important/gi, "").trim();
|
|
577
|
+
if (!cleaned || /var\(/i.test(cleaned)) return null;
|
|
578
|
+
const names = cleaned.split(",").map(unquoteFamily).filter(Boolean);
|
|
579
|
+
const family = names.find((name) => !GENERIC_FONT_FAMILIES.has(name.toLowerCase()));
|
|
580
|
+
if (!family) return null;
|
|
581
|
+
const generic = names.map((name) => name.toLowerCase()).find((name) => FALLBACK_GENERICS.has(name)) || null;
|
|
582
|
+
return { family, generic };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function extractFontFaces(content) {
|
|
586
|
+
const faces = [];
|
|
587
|
+
for (const match of stripCssComments(content).matchAll(/@font-face\s*\{([^{}]*)\}/gi)) {
|
|
588
|
+
const face = { family: null, descriptors: [], srcs: [] };
|
|
589
|
+
for (const [rawName, rawValue] of splitDeclarations(match[1])) {
|
|
590
|
+
const name = rawName.trim().toLowerCase();
|
|
591
|
+
const value = rawValue.trim();
|
|
592
|
+
if (!FONT_FACE_DESCRIPTORS.has(name) || !value) continue;
|
|
593
|
+
if (name === "font-family") face.family = unquoteFamily(value);
|
|
594
|
+
else if (name === "src") {
|
|
595
|
+
face.srcs = [...value.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)(?:\s*format\(\s*(["']?)([^"')]+)\3\s*\))?/gi)]
|
|
596
|
+
.map((src) => ({ url: src[2].trim(), format: src[4] ? src[4].trim() : null }));
|
|
597
|
+
} else face.descriptors.push([name, value]);
|
|
598
|
+
}
|
|
599
|
+
if (face.family && face.srcs.length) faces.push(face);
|
|
600
|
+
}
|
|
601
|
+
return faces;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The class a stylesheet scopes itself under, such as the importer's
|
|
605
|
+
// `.cc-import`: the leading class of more than half the rules. A rule on that
|
|
606
|
+
// class alone sets the page's base type, as `body` would.
|
|
607
|
+
function scopeRootClass(content) {
|
|
608
|
+
const leading = new Map();
|
|
609
|
+
let rules = 0;
|
|
610
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{[^{}]+\}/g)) {
|
|
611
|
+
const selector = match[1].trim();
|
|
612
|
+
if (!selector || selector.startsWith("@")) continue;
|
|
613
|
+
rules += 1;
|
|
614
|
+
const first = selector.match(/^\.([A-Za-z_][\w-]*)(?=[\s>+~,]|$)/);
|
|
615
|
+
if (first) leading.set(first[1], (leading.get(first[1]) || 0) + 1);
|
|
616
|
+
}
|
|
617
|
+
const top = topEntry(leading);
|
|
618
|
+
return top && rules >= 10 && top[1] > rules / 2 ? top[0] : null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Custom properties a stylesheet declares on its page root (html, body or its
|
|
622
|
+
// scope root class), where Shopify themes and the importer keep their type and
|
|
623
|
+
// radius variables, rather than on :root.
|
|
624
|
+
function pageRootCustomProperties(content) {
|
|
625
|
+
const scopeRoot = scopeRootClass(content);
|
|
626
|
+
const roots = new Set(["html", "body", ...(scopeRoot ? [`.${scopeRoot}`] : [])]);
|
|
627
|
+
const tokens = {};
|
|
628
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
629
|
+
if (!match[1].split(",").some((part) => roots.has(part.trim()))) continue;
|
|
630
|
+
for (const [rawName, rawValue] of splitDeclarations(match[2])) {
|
|
631
|
+
const name = rawName.trim();
|
|
632
|
+
if (name.startsWith("--") && rawValue.trim()) tokens[name] = rawValue.trim();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return tokens;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// One compound selector that names a button: the element, a submit input, or
|
|
639
|
+
// a class starting btn/button/cta or with a cta part. Unlike isButtonSelector,
|
|
640
|
+
// a list qualifies when any of its selectors does, since type and radius rules
|
|
641
|
+
// group a button with siblings (`.button, .shopify-challenge__button`).
|
|
642
|
+
function buttonCompoundWeight(compound) {
|
|
643
|
+
if (compound.pseudos.length) return 0;
|
|
644
|
+
if (compound.type === "button") return compound.classes.length ? 1 : 3;
|
|
645
|
+
if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return 1;
|
|
646
|
+
if (compound.classes.some((name) => /^(?:btn|button)$/i.test(name))) return 3;
|
|
647
|
+
return compound.classes.some((name) => /^(?:btn|button|cta)/i.test(name) || name.toLowerCase().split(/[-_]+/).includes("cta")) ? 1 : 0;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// A border-radius value next-core's --radius-size--buttons can take: 0, or one
|
|
651
|
+
// to four lengths or percentages. Anything else (var() left unresolved,
|
|
652
|
+
// slash syntax, calc()) is reported, not mapped.
|
|
653
|
+
const RADIUS_VALUE = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/;
|
|
654
|
+
|
|
655
|
+
function newTypographyUsage() {
|
|
656
|
+
return { heading: new Map(), body: new Map(), button: new Map(), generics: new Map(), button_radius: new Map(), radius_rejected: new Set() };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Rule-level font usage by role: { heading, body, button } as Maps of family
|
|
660
|
+
// to weighted rule count, generics as family -> generic, and button radii.
|
|
661
|
+
// An element selector (h1, p, button) weighs 3 against a class's 1, so a
|
|
662
|
+
// `.card-title` cannot outvote the h1 rule.
|
|
663
|
+
function collectTypographyRules(content, usage = null, tokens = {}) {
|
|
664
|
+
const out = usage || newTypographyUsage();
|
|
665
|
+
const bump = (map, key, by = 1) => map.set(key, (map.get(key) || 0) + by);
|
|
666
|
+
const scopeRoot = scopeRootClass(content);
|
|
667
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
668
|
+
const selector = match[1].trim();
|
|
669
|
+
if (!selector || selector.startsWith("@")) continue;
|
|
670
|
+
let stack = null;
|
|
671
|
+
let radius = null;
|
|
672
|
+
for (const [rawName, rawValue] of splitDeclarations(match[2])) {
|
|
673
|
+
const name = rawName.trim().toLowerCase();
|
|
674
|
+
if (name === "font-family") stack = fontStack(rawValue, tokens) || stack;
|
|
675
|
+
if (name === "border-radius") radius = resolveCssVars(rawValue, tokens).replace(/!important/gi, "").trim();
|
|
676
|
+
}
|
|
677
|
+
if (!stack && !radius) continue;
|
|
678
|
+
const compounds = rightmostCompounds(selector) || [];
|
|
679
|
+
const parts = (compound) => compound.classes.flatMap((name) => name.toLowerCase().split(/[-_]+/));
|
|
680
|
+
const headingWeight = compounds.some((compound) => HEADING_ELEMENTS.has(compound.type)) ? 3
|
|
681
|
+
: compounds.some((compound) => parts(compound).some((part) => HEADING_NAME_PARTS.has(part))) ? 1 : 0;
|
|
682
|
+
const isScopeRoot = Boolean(scopeRoot) && selector.split(",").some((part) => part.trim() === `.${scopeRoot}`);
|
|
683
|
+
const bodyWeight = isScopeRoot || compounds.some((compound) => BODY_ELEMENTS.has(compound.type) && !compound.classes.length && !compound.pseudos.length) ? 3 : 0;
|
|
684
|
+
const buttonWeight = Math.max(0, ...compounds.map(buttonCompoundWeight));
|
|
685
|
+
if (stack) {
|
|
686
|
+
if (stack.generic && !out.generics.has(stack.family.toLowerCase())) out.generics.set(stack.family.toLowerCase(), stack.generic);
|
|
687
|
+
if (headingWeight) bump(out.heading, stack.family, headingWeight);
|
|
688
|
+
if (bodyWeight) bump(out.body, stack.family, bodyWeight);
|
|
689
|
+
if (buttonWeight) bump(out.button, stack.family, buttonWeight);
|
|
690
|
+
}
|
|
691
|
+
if (radius && buttonWeight) {
|
|
692
|
+
if (RADIUS_VALUE.test(radius)) bump(out.button_radius, radius, buttonWeight);
|
|
693
|
+
else out.radius_rejected.add(radius);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function topEntry(map, accept = () => true) {
|
|
700
|
+
return [...map.entries()].filter(([key]) => accept(key)).sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0] || null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// A face's src URLs rewritten for brand-fonts.css in the campaign's
|
|
704
|
+
// assets/css/. A remote, data: or root-relative URL, or a file outside the
|
|
705
|
+
// source root or missing from it, is dropped with a reason.
|
|
706
|
+
function relocateFaceSrcs(face, cssPath, sourceRoot) {
|
|
707
|
+
const kept = [];
|
|
708
|
+
const dropped = [];
|
|
709
|
+
const fontsCssDir = resolve(sourceRoot, FONTS_CSS_SOURCE_DIR);
|
|
710
|
+
for (const src of face.srcs) {
|
|
711
|
+
const bare = src.url.split(/[?#]/)[0];
|
|
712
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(bare)) {
|
|
713
|
+
dropped.push({ url: src.url, reason: "not a file the source ships (remote, data: or root-relative URL)" });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const file = resolve(dirname(cssPath), bare);
|
|
717
|
+
if (!existsSync(file)) {
|
|
718
|
+
dropped.push({ url: src.url, reason: "file missing from the source" });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
// Containment is checked on real paths, so a symlink (or a symlinked
|
|
722
|
+
// directory) leading out of the source root is dropped, not shipped.
|
|
723
|
+
const inside = relative(realpathSync(sourceRoot), realpathSync(file));
|
|
724
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside)) {
|
|
725
|
+
dropped.push({ url: src.url, reason: "outside the source root" });
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
kept.push({ url: relative(fontsCssDir, file).split("\\").join("/"), format: src.format, source_file: inside.split("\\").join("/") });
|
|
729
|
+
}
|
|
730
|
+
return { kept, dropped };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The typography the design source declares, from every CSS file the theme
|
|
734
|
+
// inspection read: the families its headings, body copy and buttons use that
|
|
735
|
+
// it also ships faces for, the button radius, and the faces to ship.
|
|
736
|
+
function inferTypography(candidates, sourceRoot) {
|
|
737
|
+
const usage = newTypographyUsage();
|
|
738
|
+
const faces = [];
|
|
739
|
+
const seenFiles = new Set();
|
|
740
|
+
const tokens = Object.assign({}, ...candidates.map((candidate) => candidate.root_tokens || {}));
|
|
741
|
+
for (const candidate of candidates) {
|
|
742
|
+
if (candidate.inline_block_index !== undefined || seenFiles.has(resolve(candidate.path))) continue;
|
|
743
|
+
seenFiles.add(resolve(candidate.path));
|
|
744
|
+
const content = readFileSync(candidate.path, "utf8");
|
|
745
|
+
collectTypographyRules(content, usage, { ...tokens, ...pageRootCustomProperties(content) });
|
|
746
|
+
for (const face of extractFontFaces(content)) faces.push({ ...face, css_path: candidate.path });
|
|
747
|
+
}
|
|
748
|
+
const warnings = [];
|
|
749
|
+
const shippable = new Map();
|
|
750
|
+
const droppedSrcs = [];
|
|
751
|
+
for (const face of faces) {
|
|
752
|
+
const { kept, dropped } = relocateFaceSrcs(face, face.css_path, sourceRoot);
|
|
753
|
+
droppedSrcs.push(...dropped.map((drop) => ({ ...drop, family: face.family })));
|
|
754
|
+
if (!kept.length) continue;
|
|
755
|
+
const descriptors = Object.fromEntries(face.descriptors);
|
|
756
|
+
const key = [face.family.toLowerCase(), descriptors["font-weight"] || "400", descriptors["font-style"] || "normal", descriptors["font-stretch"] || "normal", descriptors["unicode-range"] || ""].join("|");
|
|
757
|
+
const existing = shippable.get(key);
|
|
758
|
+
if (!existing) shippable.set(key, { family: face.family, descriptors: face.descriptors, srcs: kept, css_path: face.css_path });
|
|
759
|
+
else {
|
|
760
|
+
// The same face declared again (often one rule per format): keep every
|
|
761
|
+
// file, in source order, so browsers can pick the format they support.
|
|
762
|
+
for (const src of kept) if (!existing.srcs.some((other) => other.url === src.url)) existing.srcs.push(src);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const faceFamilies = new Set([...shippable.values()].map((face) => face.family.toLowerCase()));
|
|
766
|
+
const hasFace = (family) => faceFamilies.has(String(family).toLowerCase());
|
|
767
|
+
const pick = (role) => {
|
|
768
|
+
const entry = topEntry(usage[role], hasFace);
|
|
769
|
+
return entry ? { family: entry[0], rule_weight: entry[1] } : null;
|
|
770
|
+
};
|
|
771
|
+
const heading = pick("heading");
|
|
772
|
+
const button = pick("button");
|
|
773
|
+
const body = pick("body") || (button && (!heading || button.family !== heading.family) ? { ...button, inferred_from: "button" } : null);
|
|
774
|
+
const unavailable = [];
|
|
775
|
+
for (const role of ["heading", "body", "button"]) {
|
|
776
|
+
const used = topEntry(usage[role]);
|
|
777
|
+
if (used && !hasFace(used[0])) {
|
|
778
|
+
unavailable.push({ role, family: used[0] });
|
|
779
|
+
warnings.push(issue("theme.typography.face_unavailable", `The source's ${role} font "${used[0]}" has no @font-face the campaign can ship, so it is not mapped.`, { role, family: used[0] }));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const radius = topEntry(usage.button_radius);
|
|
783
|
+
const used = new Set([heading, body, button].filter(Boolean).map((entry) => entry.family.toLowerCase()));
|
|
784
|
+
const shipped = [...shippable.values()].filter((face) => used.has(face.family.toLowerCase()));
|
|
785
|
+
const usedAnywhere = new Set(["heading", "body", "button"].flatMap((role) => [...usage[role].keys()].map((family) => family.toLowerCase())));
|
|
786
|
+
for (const drop of droppedSrcs.filter((entry) => usedAnywhere.has(entry.family.toLowerCase()))) {
|
|
787
|
+
warnings.push(issue("theme.typography.face_src_dropped", `A @font-face src for "${drop.family}" was left out of ${BRAND_FONTS_FILENAME}: ${drop.reason}.`, { family: drop.family, url: drop.url }));
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
heading,
|
|
791
|
+
body,
|
|
792
|
+
button,
|
|
793
|
+
button_radius: radius ? { value: radius[0], rule_count: radius[1] } : null,
|
|
794
|
+
generics: usage.generics,
|
|
795
|
+
faces: shipped,
|
|
796
|
+
face_css_paths: [...new Set(shipped.map((face) => face.css_path))],
|
|
797
|
+
unavailable,
|
|
798
|
+
radius_rejected: [...usage.radius_rejected],
|
|
799
|
+
warnings,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// The family followed directly by the generic the source declared for it, so
|
|
804
|
+
// a face that fails to load degrades the way the design asked.
|
|
805
|
+
function familyValue(family, generics) {
|
|
806
|
+
return `"${unquoteFamily(family)}", ${generics.get(String(family).toLowerCase()) || "sans-serif"}`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Typography mappings onto the contract's typography targets. Font families
|
|
810
|
+
// are mapped only with a shippable face; the button radius only alongside
|
|
811
|
+
// them, so a source with no brand faces maps exactly what it did before.
|
|
812
|
+
function mapTypography(typography, targetTokens) {
|
|
813
|
+
const targets = targetTokens.typography?.targets || {};
|
|
814
|
+
const mappings = [];
|
|
815
|
+
if (!typography.faces.length) return mappings;
|
|
816
|
+
const add = (role, target, value, derivation) => {
|
|
817
|
+
if (target && value) mappings.push({ source: `typography.${role}`, target, value, confidence: "high", derivation });
|
|
818
|
+
};
|
|
819
|
+
if (typography.heading) {
|
|
820
|
+
add("heading", targets.heading_family, familyValue(typography.heading.family, typography.generics), { method: "design-source-font-face", family: typography.heading.family, rule_weight: typography.heading.rule_weight });
|
|
821
|
+
}
|
|
822
|
+
if (typography.body) {
|
|
823
|
+
add("body", targets.body_family, familyValue(typography.body.family, typography.generics), { method: typography.body.inferred_from ? "design-source-font-face-from-button" : "design-source-font-face", family: typography.body.family, rule_weight: typography.body.rule_weight });
|
|
824
|
+
}
|
|
825
|
+
if (typography.button_radius) {
|
|
826
|
+
add("button_radius", targets.button_radius, typography.button_radius.value, { method: "design-source-button-radius", rule_count: typography.button_radius.rule_count });
|
|
827
|
+
}
|
|
828
|
+
return mappings;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function renderBrandFontsCss({ sourcePaths, faces }) {
|
|
832
|
+
const lines = [
|
|
833
|
+
"/*",
|
|
834
|
+
" * Generated by Campaigns OS brand-theme v0.",
|
|
835
|
+
` * Source: ${sourcePaths.join(", ") || "unknown source"}`,
|
|
836
|
+
` * The design's brand faces for commerce pages. Load beside ${"brand-theme.css"}; URLs are relative to assets/css/.`,
|
|
837
|
+
" */",
|
|
838
|
+
];
|
|
839
|
+
for (const face of faces) {
|
|
840
|
+
const descriptors = new Map(face.descriptors);
|
|
841
|
+
// Shipping policy, not the source's: a block period on a large display
|
|
842
|
+
// face can hide commerce headings on a slow connection.
|
|
843
|
+
descriptors.set("font-display", "swap");
|
|
844
|
+
lines.push("@font-face {");
|
|
845
|
+
lines.push(` font-family: "${unquoteFamily(face.family)}";`);
|
|
846
|
+
for (const [name, value] of [...descriptors.entries()].sort((a, b) => a[0].localeCompare(b[0]))) lines.push(` ${name}: ${value};`);
|
|
847
|
+
lines.push(` src: ${face.srcs.map((src) => `url("${src.url}")${src.format ? ` format("${src.format}")` : ""}`).join(", ")};`);
|
|
848
|
+
lines.push("}");
|
|
849
|
+
}
|
|
850
|
+
lines.push("");
|
|
851
|
+
return lines.join("\n");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function validateGeneratedFontsCss(css) {
|
|
855
|
+
const errors = [];
|
|
856
|
+
if (!isNonEmptyString(css)) return { ok: false, errors: [issue("theme.fonts_css.empty", "Generated brand-fonts.css is empty.")], warnings: [] };
|
|
857
|
+
if (/<\/?script\b|javascript:/i.test(css)) errors.push(issue("theme.fonts_css.script", "Generated brand-fonts.css contains script-like content."));
|
|
858
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
859
|
+
const blocks = [];
|
|
860
|
+
const residual = withoutComments.replace(/@font-face\s*\{([^{}]*)\}/g, (_match, body) => {
|
|
861
|
+
blocks.push(body);
|
|
862
|
+
return "";
|
|
863
|
+
}).trim();
|
|
864
|
+
if (residual) errors.push(issue("theme.fonts_css.selector", "Generated brand-fonts.css must contain only @font-face rules.", { residual }));
|
|
865
|
+
for (const body of blocks) {
|
|
866
|
+
for (const [rawName, rawValue] of splitDeclarations(body)) {
|
|
867
|
+
const name = rawName.trim().toLowerCase();
|
|
868
|
+
if (!FONT_FACE_DESCRIPTORS.has(name)) errors.push(issue("theme.fonts_css.descriptor", `Generated @font-face declares ${name}, which is not a font descriptor.`));
|
|
869
|
+
for (const url of String(rawValue).matchAll(/url\(\s*["']?([^"')]+)/gi)) {
|
|
870
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(url[1].trim())) errors.push(issue("theme.fonts_css.remote_url", "Generated brand-fonts.css must reference the campaign's own font files by relative URL.", { url: url[1] }));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return { ok: errors.length === 0, errors, warnings: [] };
|
|
875
|
+
}
|
|
876
|
+
|
|
524
877
|
function extractDeclarationColor(value) {
|
|
525
878
|
const cleaned = String(value || "").replace(/!important/gi, "").trim();
|
|
526
879
|
const exact = normalizeColor(cleaned);
|
|
@@ -1063,7 +1416,7 @@ function statusFor({ policy, selected, confidence, warnings, errors }) {
|
|
|
1063
1416
|
return "ready";
|
|
1064
1417
|
}
|
|
1065
1418
|
|
|
1066
|
-
function renderBrandThemeCss({ selected, mappings, confidence }) {
|
|
1419
|
+
function renderBrandThemeCss({ selected, mappings, confidence, bodyScoped = new Set() }) {
|
|
1067
1420
|
const unique = [];
|
|
1068
1421
|
const seen = new Set();
|
|
1069
1422
|
for (const mapping of mappings) {
|
|
@@ -1081,10 +1434,20 @@ function renderBrandThemeCss({ selected, mappings, confidence }) {
|
|
|
1081
1434
|
" */",
|
|
1082
1435
|
":root {",
|
|
1083
1436
|
];
|
|
1084
|
-
|
|
1437
|
+
const sorted = unique.sort((a, b) => a.target.localeCompare(b.target));
|
|
1438
|
+
for (const mapping of sorted.filter((entry) => !bodyScoped.has(entry.target))) {
|
|
1085
1439
|
lines.push(` ${mapping.target}: ${mapping.value};`);
|
|
1086
1440
|
}
|
|
1087
1441
|
lines.push("}");
|
|
1442
|
+
const scoped = sorted.filter((entry) => bodyScoped.has(entry.target));
|
|
1443
|
+
if (scoped.length) {
|
|
1444
|
+
lines.push("");
|
|
1445
|
+
lines.push("/* next-core re-declares these on body at its 991/767/479px breakpoints; body keeps them at every width. */");
|
|
1446
|
+
lines.push(":root,");
|
|
1447
|
+
lines.push("body {");
|
|
1448
|
+
for (const mapping of scoped) lines.push(` ${mapping.target}: ${mapping.value};`);
|
|
1449
|
+
lines.push("}");
|
|
1450
|
+
}
|
|
1088
1451
|
lines.push("");
|
|
1089
1452
|
return lines.join("\n");
|
|
1090
1453
|
}
|
|
@@ -1105,12 +1468,12 @@ export function validateGeneratedCss(css) {
|
|
|
1105
1468
|
|
|
1106
1469
|
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
1107
1470
|
const rootBlocks = [];
|
|
1108
|
-
const residual = withoutComments.replace(/:root
|
|
1471
|
+
const residual = withoutComments.replace(/:root(?:\s*,\s*body)?\s*\{([\s\S]*?)\}/g, (_match, body) => {
|
|
1109
1472
|
rootBlocks.push(body);
|
|
1110
1473
|
return "";
|
|
1111
1474
|
}).trim();
|
|
1112
1475
|
if (residual) {
|
|
1113
|
-
errors.push(issue("theme.css.selector", "Generated CSS must contain only :root blocks with custom properties.", { residual }));
|
|
1476
|
+
errors.push(issue("theme.css.selector", "Generated CSS must contain only :root (or :root, body) blocks with custom properties.", { residual }));
|
|
1114
1477
|
}
|
|
1115
1478
|
if (rootBlocks.length === 0) {
|
|
1116
1479
|
errors.push(issue("theme.css.root", "Generated CSS must include a :root block."));
|
|
@@ -1195,8 +1558,9 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1195
1558
|
const contracts = loadContracts(repoRoot);
|
|
1196
1559
|
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
1197
1560
|
const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo) || dirname(resolve(packetPath));
|
|
1198
|
-
const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, "
|
|
1561
|
+
const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, packetRuntimeRelPath(packet), "theme");
|
|
1199
1562
|
const cssPath = resolve(themeOutDir, "brand-theme.css");
|
|
1563
|
+
const fontsCssPath = resolve(themeOutDir, BRAND_FONTS_FILENAME);
|
|
1200
1564
|
const reportPath = resolve(themeOutDir, "theme-report.json");
|
|
1201
1565
|
|
|
1202
1566
|
if (requestedPolicy === "off") {
|
|
@@ -1253,8 +1617,18 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1253
1617
|
|
|
1254
1618
|
const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens, { rootTokens: selected.root_tokens, ctaLabelRules: selected.cta_label_rules }) : { mappings: [], warnings: [] };
|
|
1255
1619
|
warnings.push(...mapped.warnings);
|
|
1620
|
+
const typography = selected ? inferTypography(candidates, sourceRoot) : null;
|
|
1621
|
+
if (typography) {
|
|
1622
|
+
mapped.mappings.push(...mapTypography(typography, contracts.targetTokens));
|
|
1623
|
+
warnings.push(...typography.warnings);
|
|
1624
|
+
}
|
|
1625
|
+
const fontsCss = typography?.faces.length && mapped.mappings.some((mapping) => mapping.source.startsWith("typography."))
|
|
1626
|
+
? renderBrandFontsCss({ sourcePaths: typography.face_css_paths.map((path) => relativeArtifactPath(targetRepo, path)), faces: typography.faces })
|
|
1627
|
+
: null;
|
|
1628
|
+
const fontsSafety = fontsCss ? validateGeneratedFontsCss(fontsCss) : null;
|
|
1629
|
+
if (fontsSafety && !fontsSafety.ok) errors.push(...fontsSafety.errors);
|
|
1256
1630
|
const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
|
|
1257
|
-
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
|
|
1631
|
+
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence, bodyScoped: new Set(contracts.targetTokens.typography?.body_scoped_tokens || []) }) : null;
|
|
1258
1632
|
const safety = css ? validateGeneratedCss(css) : { ok: false, errors: [issue("theme.css.empty", "No generated CSS because no source tokens were selected.")], warnings: [] };
|
|
1259
1633
|
if (css && !safety.ok) errors.push(...safety.errors);
|
|
1260
1634
|
|
|
@@ -1270,6 +1644,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1270
1644
|
const selectedSource = selected ? sourceFileEntry(selected, targetRepo) : null;
|
|
1271
1645
|
const generated = {
|
|
1272
1646
|
css_path: relativeArtifactPath(targetRepo, cssPath),
|
|
1647
|
+
...(fontsCss ? { fonts_css_path: relativeArtifactPath(targetRepo, fontsCssPath) } : {}),
|
|
1273
1648
|
report_path: relativeArtifactPath(targetRepo, reportPath),
|
|
1274
1649
|
safety,
|
|
1275
1650
|
stale,
|
|
@@ -1306,6 +1681,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1306
1681
|
producer_defaults: defaultMatch,
|
|
1307
1682
|
conflicts,
|
|
1308
1683
|
mappings: mapped.mappings,
|
|
1684
|
+
...(fontsCss || typography?.unavailable.length || typography?.radius_rejected.length ? { typography: typographyReportBlock(typography, targetRepo) } : {}),
|
|
1309
1685
|
generated,
|
|
1310
1686
|
existing_brand_layers: findExistingBrandLayers(targetRepo, cssPath),
|
|
1311
1687
|
warnings,
|
|
@@ -1313,7 +1689,24 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1313
1689
|
next_action: nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy: requestedPolicy }),
|
|
1314
1690
|
};
|
|
1315
1691
|
|
|
1316
|
-
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings });
|
|
1692
|
+
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss, fontsCssPath });
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function typographyReportBlock(typography, targetRepo) {
|
|
1696
|
+
return {
|
|
1697
|
+
heading: typography.heading,
|
|
1698
|
+
body: typography.body,
|
|
1699
|
+
button: typography.button,
|
|
1700
|
+
button_radius: typography.button_radius,
|
|
1701
|
+
unavailable: typography.unavailable,
|
|
1702
|
+
radius_rejected: typography.radius_rejected,
|
|
1703
|
+
faces: typography.faces.map((face) => ({
|
|
1704
|
+
family: face.family,
|
|
1705
|
+
weight: Object.fromEntries(face.descriptors)["font-weight"] || "400",
|
|
1706
|
+
style: Object.fromEntries(face.descriptors)["font-style"] || "normal",
|
|
1707
|
+
files: face.srcs.map((src) => src.source_file),
|
|
1708
|
+
})),
|
|
1709
|
+
};
|
|
1317
1710
|
}
|
|
1318
1711
|
|
|
1319
1712
|
function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy }) {
|
|
@@ -1327,7 +1720,7 @@ function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate,
|
|
|
1327
1720
|
return "Review theme-report.json and decide whether to skip or collect stronger brand inputs.";
|
|
1328
1721
|
}
|
|
1329
1722
|
|
|
1330
|
-
function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings }) {
|
|
1723
|
+
function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss = null, fontsCssPath = null }) {
|
|
1331
1724
|
return {
|
|
1332
1725
|
ok: errors.length === 0,
|
|
1333
1726
|
status: contextTheme.status,
|
|
@@ -1343,12 +1736,14 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
|
|
|
1343
1736
|
},
|
|
1344
1737
|
absolute_paths: {
|
|
1345
1738
|
css_path: cssPath,
|
|
1739
|
+
...(fontsCss ? { fonts_css_path: fontsCssPath } : {}),
|
|
1346
1740
|
report_path: reportPath,
|
|
1347
1741
|
out_dir: dirname(cssPath),
|
|
1348
1742
|
},
|
|
1349
1743
|
context_theme: contextTheme,
|
|
1350
1744
|
report,
|
|
1351
1745
|
css,
|
|
1746
|
+
fonts_css: fontsCss,
|
|
1352
1747
|
errors,
|
|
1353
1748
|
warnings,
|
|
1354
1749
|
ready: contextTheme.status === "ready" ? [`Brand theme artifact can be generated with confidence ${contextTheme.confidence}.`] : [],
|
|
@@ -1363,7 +1758,7 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
|
|
|
1363
1758
|
|
|
1364
1759
|
export function writeThemeArtifacts(inspection, { writeCss = false, writeReport = true, force = false, packetPath = "<campaign-runtime.build.json>" } = {}) {
|
|
1365
1760
|
const errors = [...(inspection.errors || [])];
|
|
1366
|
-
const wrote = { report: false, css: false };
|
|
1761
|
+
const wrote = { report: false, css: false, ...(inspection.fonts_css ? { fonts_css: false } : {}) };
|
|
1367
1762
|
const alreadyCurrent = { css: false };
|
|
1368
1763
|
const reportPath = resolvePathFromInspection(inspection, "report_path");
|
|
1369
1764
|
const cssPath = resolvePathFromInspection(inspection, "css_path");
|
|
@@ -1394,6 +1789,17 @@ export function writeThemeArtifacts(inspection, { writeCss = false, writeReport
|
|
|
1394
1789
|
replaceFileAtomically(cssPath, inspection.css);
|
|
1395
1790
|
wrote.css = true;
|
|
1396
1791
|
}
|
|
1792
|
+
if (!errors.length && inspection.fonts_css) {
|
|
1793
|
+
const fontsPath = resolvePathFromInspection(inspection, "fonts_css_path");
|
|
1794
|
+
const current = existsSync(fontsPath) ? readFileSync(fontsPath, "utf8") : null;
|
|
1795
|
+
if (current !== null && current !== inspection.fonts_css && !force) {
|
|
1796
|
+
const safeCommand = `campaigns-os theme generate --packet ${shellToken(packetPath)} --force`;
|
|
1797
|
+
errors.push(issue("theme.generate.exists", `${BRAND_FONTS_FILENAME} already exists and differs from the current generated output; rerun \`${safeCommand}\` or pass a new --out-dir to overwrite safely.`, { safe_commands: [safeCommand] }));
|
|
1798
|
+
} else if (current !== inspection.fonts_css) {
|
|
1799
|
+
replaceFileAtomically(fontsPath, inspection.fonts_css);
|
|
1800
|
+
wrote.fonts_css = true;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1397
1803
|
}
|
|
1398
1804
|
|
|
1399
1805
|
return {
|
package/src/brief-summary.mjs
CHANGED
|
@@ -43,6 +43,7 @@ const STRENGTH = Object.freeze([NOT_RECORDED, "default", "source", "stated"]);
|
|
|
43
43
|
// design_authority.<page>.source values the generator writes; any other
|
|
44
44
|
// non-null value is shown verbatim in its own group.
|
|
45
45
|
const SUPPLIED_DESIGN = "provided_design_export";
|
|
46
|
+
const DESIGN_FILE = "design_file";
|
|
46
47
|
const TEMPLATE = "template";
|
|
47
48
|
|
|
48
49
|
const VALUE_WORDS = 12;
|
|
@@ -124,6 +125,7 @@ function statedOnly({ value, provenance }) {
|
|
|
124
125
|
function authorityGroups(brief, pageIds) {
|
|
125
126
|
const authority = own(brief, "design_authority");
|
|
126
127
|
const supplied = { kind: "supplied", source: SUPPLIED_DESIGN, pages: [], provenances: [] };
|
|
128
|
+
const designFile = { kind: "design_file", source: DESIGN_FILE, pages: [], provenances: [] };
|
|
127
129
|
const template = { kind: "template", source: TEMPLATE, pages: [], provenances: [] };
|
|
128
130
|
const others = new Map();
|
|
129
131
|
const unstated = { kind: "not_stated", source: null, pages: [], provenances: [] };
|
|
@@ -132,7 +134,8 @@ function authorityGroups(brief, pageIds) {
|
|
|
132
134
|
const source = notStated(recorded) ? null : recorded;
|
|
133
135
|
const provenance = pageId == null ? null : provenanceOf(brief, pageSourceField(pageId), source);
|
|
134
136
|
let group = unstated;
|
|
135
|
-
if (source ===
|
|
137
|
+
if (source === DESIGN_FILE) group = designFile;
|
|
138
|
+
else if (source === SUPPLIED_DESIGN) group = supplied;
|
|
136
139
|
else if (source === TEMPLATE) group = template;
|
|
137
140
|
else if (source != null) {
|
|
138
141
|
const key = canonicalJson(source);
|
|
@@ -142,7 +145,7 @@ function authorityGroups(brief, pageIds) {
|
|
|
142
145
|
group.pages.push(pageId);
|
|
143
146
|
group.provenances.push(provenance);
|
|
144
147
|
}
|
|
145
|
-
return [supplied, template, ...others.values(), unstated]
|
|
148
|
+
return [designFile, supplied, template, ...others.values(), unstated]
|
|
146
149
|
.filter((group) => group.pages.length)
|
|
147
150
|
.map(({ provenances, ...group }) => ({ ...group, provenance: weakest(provenances) }));
|
|
148
151
|
}
|
|
@@ -178,6 +181,7 @@ function renderLines(model, cuts, valueWords) {
|
|
|
178
181
|
const value = (item, fallback) => (item.value == null ? fallback : marked(item.value, item.provenance, valueWords, cut));
|
|
179
182
|
const group = (entry) => {
|
|
180
183
|
const ids = listItems(entry.pages, cuts[4], pageIdText, cut).join(", ");
|
|
184
|
+
if (entry.kind === "design_file") return `${ids} follow the design file, built from its HTML export ${MARKERS[entry.provenance]}`;
|
|
181
185
|
if (entry.kind === "supplied") return `${ids} follow the supplied design ${MARKERS[entry.provenance]}`;
|
|
182
186
|
if (entry.kind === "template") return `${ids} follow the template ${MARKERS[entry.provenance]}`;
|
|
183
187
|
if (entry.kind === "other") return `${ids} use ${marked(entry.source, entry.provenance, VALUE_WORDS, cut)}`;
|
package/src/browser-launch.mjs
CHANGED
|
@@ -56,6 +56,7 @@ export async function launchPackageChromium({
|
|
|
56
56
|
chromium: injectedChromium,
|
|
57
57
|
onMissing,
|
|
58
58
|
signal,
|
|
59
|
+
launchOptions = {},
|
|
59
60
|
} = {}) {
|
|
60
61
|
if (typeof onMissing !== "function") {
|
|
61
62
|
throw new Error("Campaigns OS browser launch requires an onMissing(kind, error) handler.");
|
|
@@ -71,7 +72,7 @@ export async function launchPackageChromium({
|
|
|
71
72
|
}
|
|
72
73
|
if (signal?.aborted) throw signal.reason ?? new Error("Campaigns OS browser launch was abandoned before launch.");
|
|
73
74
|
try {
|
|
74
|
-
return await chromium.launch({ headless: headed !== true });
|
|
75
|
+
return await chromium.launch({ ...launchOptions, headless: headed !== true });
|
|
75
76
|
} catch (error) {
|
|
76
77
|
if (isMissingBrowserError(error)) throw onMissing(MISSING_BROWSER_KINDS.BROWSER, error);
|
|
77
78
|
throw error;
|
package/src/build-brief.mjs
CHANGED
|
@@ -219,6 +219,7 @@ export function createCampaignBuildBriefArtifact({
|
|
|
219
219
|
spec = null,
|
|
220
220
|
activePages = [],
|
|
221
221
|
pageMappings = [],
|
|
222
|
+
designFiles = [],
|
|
222
223
|
templateFamily = null,
|
|
223
224
|
sourceAssetCrawl = null,
|
|
224
225
|
commerceZoneFindings = [],
|
|
@@ -230,7 +231,7 @@ export function createCampaignBuildBriefArtifact({
|
|
|
230
231
|
const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
|
|
231
232
|
const baseBrief = loaded
|
|
232
233
|
? cloneJsonOrEmpty(loaded.value)
|
|
233
|
-
: draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
|
|
234
|
+
: draftCampaignBuildBrief({ spec, activePages, pageMappings, designFiles, templateFamily, sourceAssetCrawl });
|
|
234
235
|
|
|
235
236
|
const normalized = normalizeCampaignBuildBrief(baseBrief, {
|
|
236
237
|
mode,
|
|
@@ -525,11 +526,17 @@ export function briefMaterialFingerprint(normalized) {
|
|
|
525
526
|
return { presentation: valueFingerprint(projection.presentation), qa_policy: valueFingerprint(projection.qa_policy) };
|
|
526
527
|
}
|
|
527
528
|
|
|
528
|
-
function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
|
|
529
|
+
function draftCampaignBuildBrief({ spec, activePages, pageMappings, designFiles, templateFamily, sourceAssetCrawl }) {
|
|
529
530
|
const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
|
|
530
531
|
// Entry-wise, so a page id named __proto__ stays a page.
|
|
531
532
|
const designAuthority = Object.fromEntries((activePages || []).map((page) => {
|
|
532
533
|
const mapped = pageMap.get(page.id);
|
|
534
|
+
const design = designFiles.find((entry) => entry.page_id === page.id)?.declaration;
|
|
535
|
+
if (mapped?.path && design) return [page.id, {
|
|
536
|
+
source: "design_file", reference: design.file_url,
|
|
537
|
+
frames: Object.fromEntries(Object.entries(design.frames).map(([viewport, frame]) => [viewport, frame.url])),
|
|
538
|
+
derived_artifact: { source: "provided_design_export", reference: mapped.path },
|
|
539
|
+
}];
|
|
533
540
|
return [page.id, {
|
|
534
541
|
source: mapped?.path ? "provided_design_export" : "template",
|
|
535
542
|
reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
|
|
@@ -625,8 +632,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
|
|
|
625
632
|
const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
|
|
626
633
|
const regulated = hasRegulatedSignals(spec);
|
|
627
634
|
const pageIds = [...new Set((activePages || []).map((page) => page.id).filter(Boolean))];
|
|
635
|
+
// A page added from the repo's page tree after handoff (#721) has the repo
|
|
636
|
+
// page itself as its authority; the brief is not asked to name one.
|
|
637
|
+
const repoPages = new Set((pageMappings || []).filter((page) => page?.origin === "repo").map((page) => page.page_id));
|
|
628
638
|
|
|
629
|
-
const missingDesignAuthority = pageIds.filter((pageId) => !isObject(brief.design_authority?.[pageId]) || !isNonEmptyString(brief.design_authority?.[pageId]?.source));
|
|
639
|
+
const missingDesignAuthority = pageIds.filter((pageId) => !repoPages.has(pageId) && (!isObject(brief.design_authority?.[pageId]) || !isNonEmptyString(brief.design_authority?.[pageId]?.source)));
|
|
630
640
|
if (missingDesignAuthority.length) {
|
|
631
641
|
addQuestion(questions, "page_design_authority", {
|
|
632
642
|
detail: `Missing page authority for: ${missingDesignAuthority.join(", ")}.`,
|