@nextcommerce/campaigns-os 1.60.0 → 1.61.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 +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- 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 +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- 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 +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- 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/campaigns-os-readback.v2.schema.json +177 -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 +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -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 +186 -9
- 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-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- 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,
|
|
@@ -521,6 +522,357 @@ function collectCtaLabelRules(content, rootTokens = {}) {
|
|
|
521
522
|
return rules;
|
|
522
523
|
}
|
|
523
524
|
|
|
525
|
+
// --- Typography -----------------------------------------------------------
|
|
526
|
+
// The design source's self-hosted brand faces, and which of them its
|
|
527
|
+
// headings, body copy and buttons use. next-core takes font family and button
|
|
528
|
+
// radius as custom properties (contracts/brand-theme-target-tokens.next-core.v0.json,
|
|
529
|
+
// `typography`), so commerce pages carry the design's type through the same
|
|
530
|
+
// token layer as its colours; the @font-face rules ship beside brand-theme.css
|
|
531
|
+
// in brand-fonts.css. A family is mapped only when the source ships a face for
|
|
532
|
+
// it: naming a font the page cannot load would only change the fallback.
|
|
533
|
+
|
|
534
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
535
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif",
|
|
536
|
+
"ui-monospace", "ui-rounded", "math", "emoji", "inherit", "initial", "unset", "revert", "-apple-system",
|
|
537
|
+
"blinkmacsystemfont",
|
|
538
|
+
]);
|
|
539
|
+
const FALLBACK_GENERICS = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy"]);
|
|
540
|
+
const HEADING_ELEMENTS = new Set(["h1", "h2", "h3"]);
|
|
541
|
+
const HEADING_NAME_PARTS = new Set(["heading", "headline", "title"]);
|
|
542
|
+
const BODY_ELEMENTS = new Set(["html", "body", "p"]);
|
|
543
|
+
const FONT_FACE_DESCRIPTORS = new Set(["font-family", "font-style", "font-weight", "font-display", "font-stretch", "unicode-range", "src"]);
|
|
544
|
+
// brand-fonts.css is placed in the campaign's assets/css/ beside
|
|
545
|
+
// brand-theme.css, and build copies the source's assets/ tree into the
|
|
546
|
+
// campaign's, so face URLs are written relative to the source's assets/css/.
|
|
547
|
+
const FONTS_CSS_SOURCE_DIR = "assets/css";
|
|
548
|
+
export const BRAND_FONTS_FILENAME = "brand-fonts.css";
|
|
549
|
+
|
|
550
|
+
// A family name as read from source CSS, unquoted and stripped of anything
|
|
551
|
+
// that could not appear in a valid quoted family (control characters, braces,
|
|
552
|
+
// semicolons, at-signs, quotes and backslashes), so generated values stay
|
|
553
|
+
// valid by construction.
|
|
554
|
+
function unquoteFamily(name) {
|
|
555
|
+
return String(name || "").trim().replace(/^(["'])(.*)\1$/, "$2")
|
|
556
|
+
.replace(/[\u0000-\u001f\u007f{};@"'\\]/g, "").replace(/\s+/g, " ").trim();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// A value with its var() references resolved through the source's :root
|
|
560
|
+
// custom properties (Shopify themes route type through --font-body-family and
|
|
561
|
+
// friends). An unresolved var() takes its fallback, else stays unresolved.
|
|
562
|
+
function resolveCssVars(value, tokens = {}, depth = 0) {
|
|
563
|
+
const text = String(value || "");
|
|
564
|
+
if (depth > 5 || !/var\(/i.test(text)) return text;
|
|
565
|
+
const replaced = text.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (match, name, fallback) => {
|
|
566
|
+
if (isNonEmptyString(tokens[name])) return tokens[name];
|
|
567
|
+
return fallback !== undefined ? fallback : match;
|
|
568
|
+
});
|
|
569
|
+
return replaced === text ? text : resolveCssVars(replaced, tokens, depth + 1);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The first named family in a font-family value and the generic it falls
|
|
573
|
+
// back to, if it names one. null for unresolved var() and generic-only stacks.
|
|
574
|
+
function fontStack(value, tokens = {}) {
|
|
575
|
+
const cleaned = resolveCssVars(value, tokens).replace(/!important/gi, "").trim();
|
|
576
|
+
if (!cleaned || /var\(/i.test(cleaned)) return null;
|
|
577
|
+
const names = cleaned.split(",").map(unquoteFamily).filter(Boolean);
|
|
578
|
+
const family = names.find((name) => !GENERIC_FONT_FAMILIES.has(name.toLowerCase()));
|
|
579
|
+
if (!family) return null;
|
|
580
|
+
const generic = names.map((name) => name.toLowerCase()).find((name) => FALLBACK_GENERICS.has(name)) || null;
|
|
581
|
+
return { family, generic };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function extractFontFaces(content) {
|
|
585
|
+
const faces = [];
|
|
586
|
+
for (const match of stripCssComments(content).matchAll(/@font-face\s*\{([^{}]*)\}/gi)) {
|
|
587
|
+
const face = { family: null, descriptors: [], srcs: [] };
|
|
588
|
+
for (const [rawName, rawValue] of splitDeclarations(match[1])) {
|
|
589
|
+
const name = rawName.trim().toLowerCase();
|
|
590
|
+
const value = rawValue.trim();
|
|
591
|
+
if (!FONT_FACE_DESCRIPTORS.has(name) || !value) continue;
|
|
592
|
+
if (name === "font-family") face.family = unquoteFamily(value);
|
|
593
|
+
else if (name === "src") {
|
|
594
|
+
face.srcs = [...value.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)(?:\s*format\(\s*(["']?)([^"')]+)\3\s*\))?/gi)]
|
|
595
|
+
.map((src) => ({ url: src[2].trim(), format: src[4] ? src[4].trim() : null }));
|
|
596
|
+
} else face.descriptors.push([name, value]);
|
|
597
|
+
}
|
|
598
|
+
if (face.family && face.srcs.length) faces.push(face);
|
|
599
|
+
}
|
|
600
|
+
return faces;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// The class a stylesheet scopes itself under, such as the importer's
|
|
604
|
+
// `.cc-import`: the leading class of more than half the rules. A rule on that
|
|
605
|
+
// class alone sets the page's base type, as `body` would.
|
|
606
|
+
function scopeRootClass(content) {
|
|
607
|
+
const leading = new Map();
|
|
608
|
+
let rules = 0;
|
|
609
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{[^{}]+\}/g)) {
|
|
610
|
+
const selector = match[1].trim();
|
|
611
|
+
if (!selector || selector.startsWith("@")) continue;
|
|
612
|
+
rules += 1;
|
|
613
|
+
const first = selector.match(/^\.([A-Za-z_][\w-]*)(?=[\s>+~,]|$)/);
|
|
614
|
+
if (first) leading.set(first[1], (leading.get(first[1]) || 0) + 1);
|
|
615
|
+
}
|
|
616
|
+
const top = topEntry(leading);
|
|
617
|
+
return top && rules >= 10 && top[1] > rules / 2 ? top[0] : null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Custom properties a stylesheet declares on its page root (html, body or its
|
|
621
|
+
// scope root class), where Shopify themes and the importer keep their type and
|
|
622
|
+
// radius variables, rather than on :root.
|
|
623
|
+
function pageRootCustomProperties(content) {
|
|
624
|
+
const scopeRoot = scopeRootClass(content);
|
|
625
|
+
const roots = new Set(["html", "body", ...(scopeRoot ? [`.${scopeRoot}`] : [])]);
|
|
626
|
+
const tokens = {};
|
|
627
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
628
|
+
if (!match[1].split(",").some((part) => roots.has(part.trim()))) continue;
|
|
629
|
+
for (const [rawName, rawValue] of splitDeclarations(match[2])) {
|
|
630
|
+
const name = rawName.trim();
|
|
631
|
+
if (name.startsWith("--") && rawValue.trim()) tokens[name] = rawValue.trim();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return tokens;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// One compound selector that names a button: the element, a submit input, or
|
|
638
|
+
// a class starting btn/button/cta or with a cta part. Unlike isButtonSelector,
|
|
639
|
+
// a list qualifies when any of its selectors does, since type and radius rules
|
|
640
|
+
// group a button with siblings (`.button, .shopify-challenge__button`).
|
|
641
|
+
function buttonCompoundWeight(compound) {
|
|
642
|
+
if (compound.pseudos.length) return 0;
|
|
643
|
+
if (compound.type === "button") return compound.classes.length ? 1 : 3;
|
|
644
|
+
if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return 1;
|
|
645
|
+
if (compound.classes.some((name) => /^(?:btn|button)$/i.test(name))) return 3;
|
|
646
|
+
return compound.classes.some((name) => /^(?:btn|button|cta)/i.test(name) || name.toLowerCase().split(/[-_]+/).includes("cta")) ? 1 : 0;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// A border-radius value next-core's --radius-size--buttons can take: 0, or one
|
|
650
|
+
// to four lengths or percentages. Anything else (var() left unresolved,
|
|
651
|
+
// slash syntax, calc()) is reported, not mapped.
|
|
652
|
+
const RADIUS_VALUE = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/;
|
|
653
|
+
|
|
654
|
+
function newTypographyUsage() {
|
|
655
|
+
return { heading: new Map(), body: new Map(), button: new Map(), generics: new Map(), button_radius: new Map(), radius_rejected: new Set() };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Rule-level font usage by role: { heading, body, button } as Maps of family
|
|
659
|
+
// to weighted rule count, generics as family -> generic, and button radii.
|
|
660
|
+
// An element selector (h1, p, button) weighs 3 against a class's 1, so a
|
|
661
|
+
// `.card-title` cannot outvote the h1 rule.
|
|
662
|
+
function collectTypographyRules(content, usage = null, tokens = {}) {
|
|
663
|
+
const out = usage || newTypographyUsage();
|
|
664
|
+
const bump = (map, key, by = 1) => map.set(key, (map.get(key) || 0) + by);
|
|
665
|
+
const scopeRoot = scopeRootClass(content);
|
|
666
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
667
|
+
const selector = match[1].trim();
|
|
668
|
+
if (!selector || selector.startsWith("@")) continue;
|
|
669
|
+
let stack = null;
|
|
670
|
+
let radius = null;
|
|
671
|
+
for (const [rawName, rawValue] of splitDeclarations(match[2])) {
|
|
672
|
+
const name = rawName.trim().toLowerCase();
|
|
673
|
+
if (name === "font-family") stack = fontStack(rawValue, tokens) || stack;
|
|
674
|
+
if (name === "border-radius") radius = resolveCssVars(rawValue, tokens).replace(/!important/gi, "").trim();
|
|
675
|
+
}
|
|
676
|
+
if (!stack && !radius) continue;
|
|
677
|
+
const compounds = rightmostCompounds(selector) || [];
|
|
678
|
+
const parts = (compound) => compound.classes.flatMap((name) => name.toLowerCase().split(/[-_]+/));
|
|
679
|
+
const headingWeight = compounds.some((compound) => HEADING_ELEMENTS.has(compound.type)) ? 3
|
|
680
|
+
: compounds.some((compound) => parts(compound).some((part) => HEADING_NAME_PARTS.has(part))) ? 1 : 0;
|
|
681
|
+
const isScopeRoot = Boolean(scopeRoot) && selector.split(",").some((part) => part.trim() === `.${scopeRoot}`);
|
|
682
|
+
const bodyWeight = isScopeRoot || compounds.some((compound) => BODY_ELEMENTS.has(compound.type) && !compound.classes.length && !compound.pseudos.length) ? 3 : 0;
|
|
683
|
+
const buttonWeight = Math.max(0, ...compounds.map(buttonCompoundWeight));
|
|
684
|
+
if (stack) {
|
|
685
|
+
if (stack.generic && !out.generics.has(stack.family.toLowerCase())) out.generics.set(stack.family.toLowerCase(), stack.generic);
|
|
686
|
+
if (headingWeight) bump(out.heading, stack.family, headingWeight);
|
|
687
|
+
if (bodyWeight) bump(out.body, stack.family, bodyWeight);
|
|
688
|
+
if (buttonWeight) bump(out.button, stack.family, buttonWeight);
|
|
689
|
+
}
|
|
690
|
+
if (radius && buttonWeight) {
|
|
691
|
+
if (RADIUS_VALUE.test(radius)) bump(out.button_radius, radius, buttonWeight);
|
|
692
|
+
else out.radius_rejected.add(radius);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function topEntry(map, accept = () => true) {
|
|
699
|
+
return [...map.entries()].filter(([key]) => accept(key)).sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0] || null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// A face's src URLs rewritten for brand-fonts.css in the campaign's
|
|
703
|
+
// assets/css/. A remote, data: or root-relative URL, or a file outside the
|
|
704
|
+
// source root or missing from it, is dropped with a reason.
|
|
705
|
+
function relocateFaceSrcs(face, cssPath, sourceRoot) {
|
|
706
|
+
const kept = [];
|
|
707
|
+
const dropped = [];
|
|
708
|
+
const fontsCssDir = resolve(sourceRoot, FONTS_CSS_SOURCE_DIR);
|
|
709
|
+
for (const src of face.srcs) {
|
|
710
|
+
const bare = src.url.split(/[?#]/)[0];
|
|
711
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(bare)) {
|
|
712
|
+
dropped.push({ url: src.url, reason: "not a file the source ships (remote, data: or root-relative URL)" });
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const file = resolve(dirname(cssPath), bare);
|
|
716
|
+
if (!existsSync(file)) {
|
|
717
|
+
dropped.push({ url: src.url, reason: "file missing from the source" });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
// Containment is checked on real paths, so a symlink (or a symlinked
|
|
721
|
+
// directory) leading out of the source root is dropped, not shipped.
|
|
722
|
+
const inside = relative(realpathSync(sourceRoot), realpathSync(file));
|
|
723
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside)) {
|
|
724
|
+
dropped.push({ url: src.url, reason: "outside the source root" });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
kept.push({ url: relative(fontsCssDir, file).split("\\").join("/"), format: src.format, source_file: inside.split("\\").join("/") });
|
|
728
|
+
}
|
|
729
|
+
return { kept, dropped };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// The typography the design source declares, from every CSS file the theme
|
|
733
|
+
// inspection read: the families its headings, body copy and buttons use that
|
|
734
|
+
// it also ships faces for, the button radius, and the faces to ship.
|
|
735
|
+
function inferTypography(candidates, sourceRoot) {
|
|
736
|
+
const usage = newTypographyUsage();
|
|
737
|
+
const faces = [];
|
|
738
|
+
const seenFiles = new Set();
|
|
739
|
+
const tokens = Object.assign({}, ...candidates.map((candidate) => candidate.root_tokens || {}));
|
|
740
|
+
for (const candidate of candidates) {
|
|
741
|
+
if (candidate.inline_block_index !== undefined || seenFiles.has(resolve(candidate.path))) continue;
|
|
742
|
+
seenFiles.add(resolve(candidate.path));
|
|
743
|
+
const content = readFileSync(candidate.path, "utf8");
|
|
744
|
+
collectTypographyRules(content, usage, { ...tokens, ...pageRootCustomProperties(content) });
|
|
745
|
+
for (const face of extractFontFaces(content)) faces.push({ ...face, css_path: candidate.path });
|
|
746
|
+
}
|
|
747
|
+
const warnings = [];
|
|
748
|
+
const shippable = new Map();
|
|
749
|
+
const droppedSrcs = [];
|
|
750
|
+
for (const face of faces) {
|
|
751
|
+
const { kept, dropped } = relocateFaceSrcs(face, face.css_path, sourceRoot);
|
|
752
|
+
droppedSrcs.push(...dropped.map((drop) => ({ ...drop, family: face.family })));
|
|
753
|
+
if (!kept.length) continue;
|
|
754
|
+
const descriptors = Object.fromEntries(face.descriptors);
|
|
755
|
+
const key = [face.family.toLowerCase(), descriptors["font-weight"] || "400", descriptors["font-style"] || "normal", descriptors["font-stretch"] || "normal", descriptors["unicode-range"] || ""].join("|");
|
|
756
|
+
const existing = shippable.get(key);
|
|
757
|
+
if (!existing) shippable.set(key, { family: face.family, descriptors: face.descriptors, srcs: kept, css_path: face.css_path });
|
|
758
|
+
else {
|
|
759
|
+
// The same face declared again (often one rule per format): keep every
|
|
760
|
+
// file, in source order, so browsers can pick the format they support.
|
|
761
|
+
for (const src of kept) if (!existing.srcs.some((other) => other.url === src.url)) existing.srcs.push(src);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const faceFamilies = new Set([...shippable.values()].map((face) => face.family.toLowerCase()));
|
|
765
|
+
const hasFace = (family) => faceFamilies.has(String(family).toLowerCase());
|
|
766
|
+
const pick = (role) => {
|
|
767
|
+
const entry = topEntry(usage[role], hasFace);
|
|
768
|
+
return entry ? { family: entry[0], rule_weight: entry[1] } : null;
|
|
769
|
+
};
|
|
770
|
+
const heading = pick("heading");
|
|
771
|
+
const button = pick("button");
|
|
772
|
+
const body = pick("body") || (button && (!heading || button.family !== heading.family) ? { ...button, inferred_from: "button" } : null);
|
|
773
|
+
const unavailable = [];
|
|
774
|
+
for (const role of ["heading", "body", "button"]) {
|
|
775
|
+
const used = topEntry(usage[role]);
|
|
776
|
+
if (used && !hasFace(used[0])) {
|
|
777
|
+
unavailable.push({ role, family: used[0] });
|
|
778
|
+
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] }));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const radius = topEntry(usage.button_radius);
|
|
782
|
+
const used = new Set([heading, body, button].filter(Boolean).map((entry) => entry.family.toLowerCase()));
|
|
783
|
+
const shipped = [...shippable.values()].filter((face) => used.has(face.family.toLowerCase()));
|
|
784
|
+
const usedAnywhere = new Set(["heading", "body", "button"].flatMap((role) => [...usage[role].keys()].map((family) => family.toLowerCase())));
|
|
785
|
+
for (const drop of droppedSrcs.filter((entry) => usedAnywhere.has(entry.family.toLowerCase()))) {
|
|
786
|
+
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 }));
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
heading,
|
|
790
|
+
body,
|
|
791
|
+
button,
|
|
792
|
+
button_radius: radius ? { value: radius[0], rule_count: radius[1] } : null,
|
|
793
|
+
generics: usage.generics,
|
|
794
|
+
faces: shipped,
|
|
795
|
+
face_css_paths: [...new Set(shipped.map((face) => face.css_path))],
|
|
796
|
+
unavailable,
|
|
797
|
+
radius_rejected: [...usage.radius_rejected],
|
|
798
|
+
warnings,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// The family followed directly by the generic the source declared for it, so
|
|
803
|
+
// a face that fails to load degrades the way the design asked.
|
|
804
|
+
function familyValue(family, generics) {
|
|
805
|
+
return `"${unquoteFamily(family)}", ${generics.get(String(family).toLowerCase()) || "sans-serif"}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Typography mappings onto the contract's typography targets. Font families
|
|
809
|
+
// are mapped only with a shippable face; the button radius only alongside
|
|
810
|
+
// them, so a source with no brand faces maps exactly what it did before.
|
|
811
|
+
function mapTypography(typography, targetTokens) {
|
|
812
|
+
const targets = targetTokens.typography?.targets || {};
|
|
813
|
+
const mappings = [];
|
|
814
|
+
if (!typography.faces.length) return mappings;
|
|
815
|
+
const add = (role, target, value, derivation) => {
|
|
816
|
+
if (target && value) mappings.push({ source: `typography.${role}`, target, value, confidence: "high", derivation });
|
|
817
|
+
};
|
|
818
|
+
if (typography.heading) {
|
|
819
|
+
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 });
|
|
820
|
+
}
|
|
821
|
+
if (typography.body) {
|
|
822
|
+
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 });
|
|
823
|
+
}
|
|
824
|
+
if (typography.button_radius) {
|
|
825
|
+
add("button_radius", targets.button_radius, typography.button_radius.value, { method: "design-source-button-radius", rule_count: typography.button_radius.rule_count });
|
|
826
|
+
}
|
|
827
|
+
return mappings;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function renderBrandFontsCss({ sourcePaths, faces }) {
|
|
831
|
+
const lines = [
|
|
832
|
+
"/*",
|
|
833
|
+
" * Generated by Campaigns OS brand-theme v0.",
|
|
834
|
+
` * Source: ${sourcePaths.join(", ") || "unknown source"}`,
|
|
835
|
+
` * The design's brand faces for commerce pages. Load beside ${"brand-theme.css"}; URLs are relative to assets/css/.`,
|
|
836
|
+
" */",
|
|
837
|
+
];
|
|
838
|
+
for (const face of faces) {
|
|
839
|
+
const descriptors = new Map(face.descriptors);
|
|
840
|
+
// Shipping policy, not the source's: a block period on a large display
|
|
841
|
+
// face can hide commerce headings on a slow connection.
|
|
842
|
+
descriptors.set("font-display", "swap");
|
|
843
|
+
lines.push("@font-face {");
|
|
844
|
+
lines.push(` font-family: "${unquoteFamily(face.family)}";`);
|
|
845
|
+
for (const [name, value] of [...descriptors.entries()].sort((a, b) => a[0].localeCompare(b[0]))) lines.push(` ${name}: ${value};`);
|
|
846
|
+
lines.push(` src: ${face.srcs.map((src) => `url("${src.url}")${src.format ? ` format("${src.format}")` : ""}`).join(", ")};`);
|
|
847
|
+
lines.push("}");
|
|
848
|
+
}
|
|
849
|
+
lines.push("");
|
|
850
|
+
return lines.join("\n");
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export function validateGeneratedFontsCss(css) {
|
|
854
|
+
const errors = [];
|
|
855
|
+
if (!isNonEmptyString(css)) return { ok: false, errors: [issue("theme.fonts_css.empty", "Generated brand-fonts.css is empty.")], warnings: [] };
|
|
856
|
+
if (/<\/?script\b|javascript:/i.test(css)) errors.push(issue("theme.fonts_css.script", "Generated brand-fonts.css contains script-like content."));
|
|
857
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
858
|
+
const blocks = [];
|
|
859
|
+
const residual = withoutComments.replace(/@font-face\s*\{([^{}]*)\}/g, (_match, body) => {
|
|
860
|
+
blocks.push(body);
|
|
861
|
+
return "";
|
|
862
|
+
}).trim();
|
|
863
|
+
if (residual) errors.push(issue("theme.fonts_css.selector", "Generated brand-fonts.css must contain only @font-face rules.", { residual }));
|
|
864
|
+
for (const body of blocks) {
|
|
865
|
+
for (const [rawName, rawValue] of splitDeclarations(body)) {
|
|
866
|
+
const name = rawName.trim().toLowerCase();
|
|
867
|
+
if (!FONT_FACE_DESCRIPTORS.has(name)) errors.push(issue("theme.fonts_css.descriptor", `Generated @font-face declares ${name}, which is not a font descriptor.`));
|
|
868
|
+
for (const url of String(rawValue).matchAll(/url\(\s*["']?([^"')]+)/gi)) {
|
|
869
|
+
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] }));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return { ok: errors.length === 0, errors, warnings: [] };
|
|
874
|
+
}
|
|
875
|
+
|
|
524
876
|
function extractDeclarationColor(value) {
|
|
525
877
|
const cleaned = String(value || "").replace(/!important/gi, "").trim();
|
|
526
878
|
const exact = normalizeColor(cleaned);
|
|
@@ -1063,7 +1415,7 @@ function statusFor({ policy, selected, confidence, warnings, errors }) {
|
|
|
1063
1415
|
return "ready";
|
|
1064
1416
|
}
|
|
1065
1417
|
|
|
1066
|
-
function renderBrandThemeCss({ selected, mappings, confidence }) {
|
|
1418
|
+
function renderBrandThemeCss({ selected, mappings, confidence, bodyScoped = new Set() }) {
|
|
1067
1419
|
const unique = [];
|
|
1068
1420
|
const seen = new Set();
|
|
1069
1421
|
for (const mapping of mappings) {
|
|
@@ -1081,10 +1433,20 @@ function renderBrandThemeCss({ selected, mappings, confidence }) {
|
|
|
1081
1433
|
" */",
|
|
1082
1434
|
":root {",
|
|
1083
1435
|
];
|
|
1084
|
-
|
|
1436
|
+
const sorted = unique.sort((a, b) => a.target.localeCompare(b.target));
|
|
1437
|
+
for (const mapping of sorted.filter((entry) => !bodyScoped.has(entry.target))) {
|
|
1085
1438
|
lines.push(` ${mapping.target}: ${mapping.value};`);
|
|
1086
1439
|
}
|
|
1087
1440
|
lines.push("}");
|
|
1441
|
+
const scoped = sorted.filter((entry) => bodyScoped.has(entry.target));
|
|
1442
|
+
if (scoped.length) {
|
|
1443
|
+
lines.push("");
|
|
1444
|
+
lines.push("/* next-core re-declares these on body at its 991/767/479px breakpoints; body keeps them at every width. */");
|
|
1445
|
+
lines.push(":root,");
|
|
1446
|
+
lines.push("body {");
|
|
1447
|
+
for (const mapping of scoped) lines.push(` ${mapping.target}: ${mapping.value};`);
|
|
1448
|
+
lines.push("}");
|
|
1449
|
+
}
|
|
1088
1450
|
lines.push("");
|
|
1089
1451
|
return lines.join("\n");
|
|
1090
1452
|
}
|
|
@@ -1105,12 +1467,12 @@ export function validateGeneratedCss(css) {
|
|
|
1105
1467
|
|
|
1106
1468
|
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
1107
1469
|
const rootBlocks = [];
|
|
1108
|
-
const residual = withoutComments.replace(/:root
|
|
1470
|
+
const residual = withoutComments.replace(/:root(?:\s*,\s*body)?\s*\{([\s\S]*?)\}/g, (_match, body) => {
|
|
1109
1471
|
rootBlocks.push(body);
|
|
1110
1472
|
return "";
|
|
1111
1473
|
}).trim();
|
|
1112
1474
|
if (residual) {
|
|
1113
|
-
errors.push(issue("theme.css.selector", "Generated CSS must contain only :root blocks with custom properties.", { residual }));
|
|
1475
|
+
errors.push(issue("theme.css.selector", "Generated CSS must contain only :root (or :root, body) blocks with custom properties.", { residual }));
|
|
1114
1476
|
}
|
|
1115
1477
|
if (rootBlocks.length === 0) {
|
|
1116
1478
|
errors.push(issue("theme.css.root", "Generated CSS must include a :root block."));
|
|
@@ -1197,6 +1559,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1197
1559
|
const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo) || dirname(resolve(packetPath));
|
|
1198
1560
|
const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, ".campaign-runtime/theme");
|
|
1199
1561
|
const cssPath = resolve(themeOutDir, "brand-theme.css");
|
|
1562
|
+
const fontsCssPath = resolve(themeOutDir, BRAND_FONTS_FILENAME);
|
|
1200
1563
|
const reportPath = resolve(themeOutDir, "theme-report.json");
|
|
1201
1564
|
|
|
1202
1565
|
if (requestedPolicy === "off") {
|
|
@@ -1253,8 +1616,18 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1253
1616
|
|
|
1254
1617
|
const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens, { rootTokens: selected.root_tokens, ctaLabelRules: selected.cta_label_rules }) : { mappings: [], warnings: [] };
|
|
1255
1618
|
warnings.push(...mapped.warnings);
|
|
1619
|
+
const typography = selected ? inferTypography(candidates, sourceRoot) : null;
|
|
1620
|
+
if (typography) {
|
|
1621
|
+
mapped.mappings.push(...mapTypography(typography, contracts.targetTokens));
|
|
1622
|
+
warnings.push(...typography.warnings);
|
|
1623
|
+
}
|
|
1624
|
+
const fontsCss = typography?.faces.length && mapped.mappings.some((mapping) => mapping.source.startsWith("typography."))
|
|
1625
|
+
? renderBrandFontsCss({ sourcePaths: typography.face_css_paths.map((path) => relativeArtifactPath(targetRepo, path)), faces: typography.faces })
|
|
1626
|
+
: null;
|
|
1627
|
+
const fontsSafety = fontsCss ? validateGeneratedFontsCss(fontsCss) : null;
|
|
1628
|
+
if (fontsSafety && !fontsSafety.ok) errors.push(...fontsSafety.errors);
|
|
1256
1629
|
const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
|
|
1257
|
-
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
|
|
1630
|
+
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence, bodyScoped: new Set(contracts.targetTokens.typography?.body_scoped_tokens || []) }) : null;
|
|
1258
1631
|
const safety = css ? validateGeneratedCss(css) : { ok: false, errors: [issue("theme.css.empty", "No generated CSS because no source tokens were selected.")], warnings: [] };
|
|
1259
1632
|
if (css && !safety.ok) errors.push(...safety.errors);
|
|
1260
1633
|
|
|
@@ -1270,6 +1643,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1270
1643
|
const selectedSource = selected ? sourceFileEntry(selected, targetRepo) : null;
|
|
1271
1644
|
const generated = {
|
|
1272
1645
|
css_path: relativeArtifactPath(targetRepo, cssPath),
|
|
1646
|
+
...(fontsCss ? { fonts_css_path: relativeArtifactPath(targetRepo, fontsCssPath) } : {}),
|
|
1273
1647
|
report_path: relativeArtifactPath(targetRepo, reportPath),
|
|
1274
1648
|
safety,
|
|
1275
1649
|
stale,
|
|
@@ -1306,6 +1680,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1306
1680
|
producer_defaults: defaultMatch,
|
|
1307
1681
|
conflicts,
|
|
1308
1682
|
mappings: mapped.mappings,
|
|
1683
|
+
...(fontsCss || typography?.unavailable.length || typography?.radius_rejected.length ? { typography: typographyReportBlock(typography, targetRepo) } : {}),
|
|
1309
1684
|
generated,
|
|
1310
1685
|
existing_brand_layers: findExistingBrandLayers(targetRepo, cssPath),
|
|
1311
1686
|
warnings,
|
|
@@ -1313,7 +1688,24 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
1313
1688
|
next_action: nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy: requestedPolicy }),
|
|
1314
1689
|
};
|
|
1315
1690
|
|
|
1316
|
-
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings });
|
|
1691
|
+
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss, fontsCssPath });
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function typographyReportBlock(typography, targetRepo) {
|
|
1695
|
+
return {
|
|
1696
|
+
heading: typography.heading,
|
|
1697
|
+
body: typography.body,
|
|
1698
|
+
button: typography.button,
|
|
1699
|
+
button_radius: typography.button_radius,
|
|
1700
|
+
unavailable: typography.unavailable,
|
|
1701
|
+
radius_rejected: typography.radius_rejected,
|
|
1702
|
+
faces: typography.faces.map((face) => ({
|
|
1703
|
+
family: face.family,
|
|
1704
|
+
weight: Object.fromEntries(face.descriptors)["font-weight"] || "400",
|
|
1705
|
+
style: Object.fromEntries(face.descriptors)["font-style"] || "normal",
|
|
1706
|
+
files: face.srcs.map((src) => src.source_file),
|
|
1707
|
+
})),
|
|
1708
|
+
};
|
|
1317
1709
|
}
|
|
1318
1710
|
|
|
1319
1711
|
function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy }) {
|
|
@@ -1327,7 +1719,7 @@ function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate,
|
|
|
1327
1719
|
return "Review theme-report.json and decide whether to skip or collect stronger brand inputs.";
|
|
1328
1720
|
}
|
|
1329
1721
|
|
|
1330
|
-
function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings }) {
|
|
1722
|
+
function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss = null, fontsCssPath = null }) {
|
|
1331
1723
|
return {
|
|
1332
1724
|
ok: errors.length === 0,
|
|
1333
1725
|
status: contextTheme.status,
|
|
@@ -1343,12 +1735,14 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
|
|
|
1343
1735
|
},
|
|
1344
1736
|
absolute_paths: {
|
|
1345
1737
|
css_path: cssPath,
|
|
1738
|
+
...(fontsCss ? { fonts_css_path: fontsCssPath } : {}),
|
|
1346
1739
|
report_path: reportPath,
|
|
1347
1740
|
out_dir: dirname(cssPath),
|
|
1348
1741
|
},
|
|
1349
1742
|
context_theme: contextTheme,
|
|
1350
1743
|
report,
|
|
1351
1744
|
css,
|
|
1745
|
+
fonts_css: fontsCss,
|
|
1352
1746
|
errors,
|
|
1353
1747
|
warnings,
|
|
1354
1748
|
ready: contextTheme.status === "ready" ? [`Brand theme artifact can be generated with confidence ${contextTheme.confidence}.`] : [],
|
|
@@ -1363,7 +1757,7 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
|
|
|
1363
1757
|
|
|
1364
1758
|
export function writeThemeArtifacts(inspection, { writeCss = false, writeReport = true, force = false, packetPath = "<campaign-runtime.build.json>" } = {}) {
|
|
1365
1759
|
const errors = [...(inspection.errors || [])];
|
|
1366
|
-
const wrote = { report: false, css: false };
|
|
1760
|
+
const wrote = { report: false, css: false, ...(inspection.fonts_css ? { fonts_css: false } : {}) };
|
|
1367
1761
|
const alreadyCurrent = { css: false };
|
|
1368
1762
|
const reportPath = resolvePathFromInspection(inspection, "report_path");
|
|
1369
1763
|
const cssPath = resolvePathFromInspection(inspection, "css_path");
|
|
@@ -1394,6 +1788,17 @@ export function writeThemeArtifacts(inspection, { writeCss = false, writeReport
|
|
|
1394
1788
|
replaceFileAtomically(cssPath, inspection.css);
|
|
1395
1789
|
wrote.css = true;
|
|
1396
1790
|
}
|
|
1791
|
+
if (!errors.length && inspection.fonts_css) {
|
|
1792
|
+
const fontsPath = resolvePathFromInspection(inspection, "fonts_css_path");
|
|
1793
|
+
const current = existsSync(fontsPath) ? readFileSync(fontsPath, "utf8") : null;
|
|
1794
|
+
if (current !== null && current !== inspection.fonts_css && !force) {
|
|
1795
|
+
const safeCommand = `campaigns-os theme generate --packet ${shellToken(packetPath)} --force`;
|
|
1796
|
+
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] }));
|
|
1797
|
+
} else if (current !== inspection.fonts_css) {
|
|
1798
|
+
replaceFileAtomically(fontsPath, inspection.fonts_css);
|
|
1799
|
+
wrote.fonts_css = true;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1397
1802
|
}
|
|
1398
1803
|
|
|
1399
1804
|
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`,
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -96,14 +96,14 @@ function listHtmlFiles(root, links = null) {
|
|
|
96
96
|
return files.sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function routeForFile(campaignDir, file) {
|
|
99
|
+
export function routeForFile(campaignDir, file) {
|
|
100
100
|
const rel = relative(campaignDir, file).split(sep).join("/");
|
|
101
101
|
if (rel === "index.html") return "";
|
|
102
102
|
if (rel.endsWith("/index.html")) return rel.slice(0, -"/index.html".length);
|
|
103
103
|
return rel.replace(/\.html$/i, "");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function pageIdForRoute(route) {
|
|
106
|
+
export function pageIdForRoute(route) {
|
|
107
107
|
if (!route) return "index";
|
|
108
108
|
return route.replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "index";
|
|
109
109
|
}
|