@nextcommerce/campaigns-os 1.33.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 +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { shellToken } from "./shell-token.mjs";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const SOURCE_DEFAULTS_PATH = "contracts/brand-theme-source-defaults.figma-sections-export.v0.json";
|
|
19
|
+
const TARGET_TOKENS_PATH = "contracts/brand-theme-target-tokens.next-core.v0.json";
|
|
20
|
+
|
|
21
|
+
export const BRAND_THEME_REPORT_SCHEMA = "campaign-runtime-brand-theme/v0";
|
|
22
|
+
export const THEME_POLICIES = new Set(["inspect_only", "auto", "off"]);
|
|
23
|
+
export const THEME_CONTEXT_STATUSES = new Set(["ready", "ready_with_warnings", "missing", "blocked", "disabled"]);
|
|
24
|
+
export const THEME_REPORT_STATUSES = new Set(["applied", "skipped", "blocked", "needs_review"]);
|
|
25
|
+
export const THEME_CONFIDENCES = new Set(["high", "medium", "low", "none"]);
|
|
26
|
+
|
|
27
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "_site", "dist", "build", ".next", "coverage", "qa-output"]);
|
|
28
|
+
const BRAND_LAYER_FILENAMES = new Set(["brand-theme.css", "checkout-brand.css"]);
|
|
29
|
+
|
|
30
|
+
function isObject(value) {
|
|
31
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isNonEmptyString(value) {
|
|
35
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(path) {
|
|
39
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonIfExists(path) {
|
|
43
|
+
return path && existsSync(path) ? readJson(path) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function replaceFileAtomically(path, content) {
|
|
47
|
+
const targetPath = resolve(path);
|
|
48
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
49
|
+
const stagedPath = join(dirname(targetPath), `.${basename(targetPath)}.${randomUUID()}.tmp`);
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(stagedPath, content, { flag: "wx" });
|
|
52
|
+
// Same-directory rename replaces the pathname's directory entry instead
|
|
53
|
+
// of following a symlink or hard-link alias planted at the final name.
|
|
54
|
+
renameSync(stagedPath, targetPath);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(stagedPath, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sha256(content) {
|
|
61
|
+
return createHash("sha256").update(content).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sha256File(path) {
|
|
65
|
+
return sha256(readFileSync(path));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveFromFile(filePath, targetPath) {
|
|
69
|
+
if (!isNonEmptyString(targetPath)) return null;
|
|
70
|
+
if (isAbsolute(targetPath)) return resolve(targetPath);
|
|
71
|
+
return resolve(dirname(resolve(filePath)), targetPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function relFromDir(dirPath, targetPath) {
|
|
75
|
+
const rel = relative(resolve(dirPath), resolve(targetPath));
|
|
76
|
+
return rel || ".";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function relativeArtifactPath(targetRepo, path) {
|
|
80
|
+
if (!targetRepo || !path) return path || null;
|
|
81
|
+
return relFromDir(targetRepo, path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadContracts(repoRoot = ROOT) {
|
|
85
|
+
return {
|
|
86
|
+
sourceDefaults: readJson(resolve(repoRoot, SOURCE_DEFAULTS_PATH)),
|
|
87
|
+
targetTokens: readJson(resolve(repoRoot, TARGET_TOKENS_PATH)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function issue(code, message, detail = null) {
|
|
92
|
+
return detail ? { code, message, detail } : { code, message };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanCssRef(value) {
|
|
96
|
+
const cleaned = String(value || "").trim().replace(/^["']|["']$/g, "").split("#")[0].split("?")[0];
|
|
97
|
+
if (!cleaned || !cleaned.endsWith(".css")) return null;
|
|
98
|
+
if (/^(?:https?:|data:|javascript:|\/\/)/i.test(cleaned)) return null;
|
|
99
|
+
return cleaned.replace(/^\/+/, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function candidateRoleFromPath(path, fallback = "shared") {
|
|
103
|
+
const lower = String(path || "").toLowerCase();
|
|
104
|
+
if (lower.includes("presell")) return "presell";
|
|
105
|
+
if (lower.includes("landing") || lower.includes("lander") || lower.includes("lp")) return "landing";
|
|
106
|
+
if (lower.includes("checkout")) return "checkout";
|
|
107
|
+
if (lower.includes("upsell")) return "upsell";
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function roleFromPageMapping(mapping) {
|
|
112
|
+
return candidateRoleFromPath(mapping?.page_id, "page");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function addUniquePath(paths, path) {
|
|
116
|
+
if (!path) return;
|
|
117
|
+
const resolved = resolve(path);
|
|
118
|
+
if (!paths.some((existing) => resolve(existing) === resolved)) paths.push(resolved);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function candidateCssPathsForRef(sourceRoot, htmlPath, ref) {
|
|
122
|
+
const cleaned = cleanCssRef(ref);
|
|
123
|
+
if (!cleaned) return [];
|
|
124
|
+
const paths = [];
|
|
125
|
+
addUniquePath(paths, resolve(dirname(htmlPath), cleaned));
|
|
126
|
+
addUniquePath(paths, resolve(sourceRoot, cleaned));
|
|
127
|
+
addUniquePath(paths, resolve(sourceRoot, "assets", cleaned));
|
|
128
|
+
if (cleaned.startsWith("css/")) addUniquePath(paths, resolve(sourceRoot, "assets", cleaned));
|
|
129
|
+
if (!cleaned.includes("/")) addUniquePath(paths, resolve(sourceRoot, "assets/css", cleaned));
|
|
130
|
+
return paths;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function extractCssRefs(content) {
|
|
134
|
+
const refs = [];
|
|
135
|
+
const linkPattern = /<link\b[^>]*\bhref\s*=\s*["']([^"']+\.css(?:[?#][^"']*)?)["'][^>]*>/gi;
|
|
136
|
+
for (const match of content.matchAll(linkPattern)) refs.push(match[1]);
|
|
137
|
+
|
|
138
|
+
const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
139
|
+
if (frontmatter) {
|
|
140
|
+
const block = frontmatter[1];
|
|
141
|
+
const linePattern = /(?:^|\s|-\s*)(["']?[^"'\n,\]]+\.css["']?)/g;
|
|
142
|
+
for (const match of block.matchAll(linePattern)) refs.push(match[1]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const importPattern = /@import\s+(?:url\()?["']?([^"')]+\.css(?:[?#][^"')]+)?)["']?\)?/gi;
|
|
146
|
+
for (const match of content.matchAll(importPattern)) refs.push(match[1]);
|
|
147
|
+
return [...new Set(refs.map(cleanCssRef).filter(Boolean))];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function extractRootBlocks(content) {
|
|
151
|
+
const blocks = [];
|
|
152
|
+
const rootPattern = /:root\s*\{([\s\S]*?)\}/g;
|
|
153
|
+
let index = 0;
|
|
154
|
+
for (const match of content.matchAll(rootPattern)) {
|
|
155
|
+
blocks.push({ index, body: match[1], offset: match.index || 0 });
|
|
156
|
+
index += 1;
|
|
157
|
+
}
|
|
158
|
+
return blocks;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractStyleBlocks(content) {
|
|
162
|
+
const blocks = [];
|
|
163
|
+
const stylePattern = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
164
|
+
let index = 0;
|
|
165
|
+
for (const match of content.matchAll(stylePattern)) {
|
|
166
|
+
blocks.push({ index, body: match[1] || "", offset: match.index || 0 });
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
return blocks;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function parseRootCustomProperties(content) {
|
|
173
|
+
const tokens = {};
|
|
174
|
+
const warnings = [];
|
|
175
|
+
for (const block of extractRootBlocks(content)) {
|
|
176
|
+
const declarationPattern = /(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+);/g;
|
|
177
|
+
for (const match of block.body.matchAll(declarationPattern)) {
|
|
178
|
+
tokens[match[1].trim()] = match[2].trim();
|
|
179
|
+
}
|
|
180
|
+
const bodyWithoutMatches = block.body
|
|
181
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
182
|
+
.replace(declarationPattern, "")
|
|
183
|
+
.trim();
|
|
184
|
+
if (bodyWithoutMatches) {
|
|
185
|
+
warnings.push(`Unparsed content in :root block ${block.index}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { tokens, warnings };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function tokenNameParts(name) {
|
|
192
|
+
return String(name || "").toLowerCase().replace(/^--/, "").split(/[^a-z0-9]+/).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hasTokenPart(parts, values) {
|
|
196
|
+
return values.some((value) => parts.includes(value));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hasTokenSequence(parts, firstValues, secondValues) {
|
|
200
|
+
return parts.some((part, index) => firstValues.includes(part) && secondValues.includes(parts[index + 1]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isSurfaceBgToken(parts) {
|
|
204
|
+
if (hasTokenPart(parts, ["surface", "background"])) return true;
|
|
205
|
+
if (parts.length === 1 && parts[0] === "bg") return true;
|
|
206
|
+
return hasTokenSequence(parts, ["page", "body", "site"], ["bg"])
|
|
207
|
+
|| hasTokenSequence(parts, ["bg"], ["page", "body", "site"]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isTextInverseToken(parts) {
|
|
211
|
+
return hasTokenSequence(parts, ["text", "foreground"], ["inverse"])
|
|
212
|
+
|| hasTokenSequence(parts, ["inverse"], ["text", "foreground"])
|
|
213
|
+
|| hasTokenSequence(parts, ["on"], ["primary", "cta", "brand", "accent"]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isTargetContractToken(name) {
|
|
217
|
+
const lower = String(name || "").toLowerCase();
|
|
218
|
+
return lower.startsWith("--brand--color--") || lower.startsWith("--component--") || lower.startsWith("--system-colors--");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
222
|
+
const tokens = {};
|
|
223
|
+
const addToken = (name, value) => {
|
|
224
|
+
const color = normalizeColor(value);
|
|
225
|
+
if (color && !tokens[name]) tokens[name] = color;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
for (const [name, value] of Object.entries(rootTokens || {})) {
|
|
229
|
+
const color = normalizeColor(value);
|
|
230
|
+
if (!color) continue;
|
|
231
|
+
const lower = name.toLowerCase();
|
|
232
|
+
if (isTargetContractToken(lower)) continue;
|
|
233
|
+
const parts = tokenNameParts(lower);
|
|
234
|
+
const ctaName = hasTokenPart(parts, ["cta", "button", "btn"]);
|
|
235
|
+
if (!ctaName && (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
|
|
236
|
+
addToken("--brand-primary", color);
|
|
237
|
+
}
|
|
238
|
+
if (ctaName) {
|
|
239
|
+
addToken("--brand-cta", color);
|
|
240
|
+
}
|
|
241
|
+
if (hasTokenPart(parts, ["accent"])) {
|
|
242
|
+
addToken("--brand-accent", color);
|
|
243
|
+
}
|
|
244
|
+
const hasCardSurfacePart = hasTokenPart(parts, ["card", "panel"]);
|
|
245
|
+
if (!hasCardSurfacePart && isSurfaceBgToken(parts)) addToken("--surface-bg", color);
|
|
246
|
+
if (
|
|
247
|
+
hasTokenSequence(parts, ["surface"], ["card", "panel"])
|
|
248
|
+
|| hasTokenSequence(parts, ["card", "panel"], ["surface", "background", "bg"])
|
|
249
|
+
) {
|
|
250
|
+
addToken("--surface-card", color);
|
|
251
|
+
}
|
|
252
|
+
if (hasTokenPart(parts, ["text"]) && hasTokenPart(parts, ["primary", "main"])) addToken("--text-primary", color);
|
|
253
|
+
if (hasTokenPart(parts, ["text", "foreground"]) && hasTokenPart(parts, ["secondary", "muted", "subtle"])) addToken("--text-secondary", color);
|
|
254
|
+
if (isTextInverseToken(parts)) addToken("--text-inverse", color);
|
|
255
|
+
if (hasTokenPart(parts, ["border", "outline", "stroke", "ring"])) addToken("--border-default", color);
|
|
256
|
+
if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
|
|
260
|
+
for (const match of content.matchAll(rulePattern)) {
|
|
261
|
+
const selector = match[1] || "";
|
|
262
|
+
for (const declaration of match[2].split(";")) {
|
|
263
|
+
const [rawName, ...rawValueParts] = declaration.split(":");
|
|
264
|
+
const name = String(rawName || "").trim().toLowerCase();
|
|
265
|
+
const rawValue = rawValueParts.join(":");
|
|
266
|
+
const color = resolveDeclarationColor(rawValue, rootTokens);
|
|
267
|
+
if (!color) continue;
|
|
268
|
+
if (["background", "background-color"].includes(name)) {
|
|
269
|
+
if (/(header|nav|announcement|brand|hero)/i.test(selector) && isStrongBrandColor(color)) {
|
|
270
|
+
addToken("--brand-primary", color);
|
|
271
|
+
} else if (isCtaLikeSelector(selector) && isStrongBrandColor(color)) {
|
|
272
|
+
addToken("--button-primary-bg", color);
|
|
273
|
+
addToken("--brand-cta", color);
|
|
274
|
+
addToken("--brand-accent", color);
|
|
275
|
+
} else if (/(card|panel|summary|product|form|checkout)/i.test(selector) && !isStrongBrandColor(color)) {
|
|
276
|
+
addToken("--surface-card", color);
|
|
277
|
+
} else if (/(body|main|page|wrapper)/i.test(selector) && !isStrongBrandColor(color)) {
|
|
278
|
+
addToken("--surface-bg", color);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (["border", "border-color", "outline", "outline-color", "box-shadow"].includes(name)) {
|
|
282
|
+
addToken("--border-default", color);
|
|
283
|
+
}
|
|
284
|
+
if (name === "color") {
|
|
285
|
+
if (/(star|rating|review)/i.test(selector)) addToken("--rating-star", color);
|
|
286
|
+
else if (isCtaLikeSelector(selector)) addToken("--text-inverse", color);
|
|
287
|
+
else if (/(muted|secondary|sub|caption|small)/i.test(selector)) addToken("--text-secondary", color);
|
|
288
|
+
else if (!isStrongBrandColor(color)) addToken("--text-primary", color);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (tokens["--brand-cta"] && !tokens["--brand-accent"]) tokens["--brand-accent"] = tokens["--brand-cta"];
|
|
293
|
+
if (tokens["--brand-accent"] && !tokens["--rating-star"]) tokens["--rating-star"] = tokens["--brand-accent"];
|
|
294
|
+
return tokens;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isCtaLikeSelector(selector) {
|
|
298
|
+
return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function extractDeclarationColor(value) {
|
|
302
|
+
const cleaned = String(value || "").replace(/!important/gi, "").trim();
|
|
303
|
+
const exact = normalizeColor(cleaned);
|
|
304
|
+
if (exact) return exact;
|
|
305
|
+
const match = cleaned.match(/#[0-9a-f]{3,6}\b|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/i);
|
|
306
|
+
return match ? normalizeColor(match[0]) : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resolveDeclarationColor(value, tokens = {}) {
|
|
310
|
+
const direct = extractDeclarationColor(value);
|
|
311
|
+
if (direct) return direct;
|
|
312
|
+
const varMatch = String(value || "").match(/var\(\s*(--[A-Za-z0-9_-]+)/);
|
|
313
|
+
if (!varMatch) return null;
|
|
314
|
+
return extractDeclarationColor(tokens[varMatch[1]]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isStrongBrandColor(value) {
|
|
318
|
+
const rgb = colorToRgb(value);
|
|
319
|
+
if (!rgb) return false;
|
|
320
|
+
const max = Math.max(rgb.r, rgb.g, rgb.b);
|
|
321
|
+
const min = Math.min(rgb.r, rgb.g, rgb.b);
|
|
322
|
+
const spread = max - min;
|
|
323
|
+
if (max < 32 || min > 242) return false;
|
|
324
|
+
if (spread < 24) return false;
|
|
325
|
+
const saturation = max === 0 ? 0 : spread / max;
|
|
326
|
+
return saturation >= 0.16;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function candidateFromFile(path, role, source = "css_file", referencedBy = []) {
|
|
330
|
+
const content = readFileSync(path, "utf8");
|
|
331
|
+
const parsed = parseRootCustomProperties(content);
|
|
332
|
+
const inferred = inferDesignIntentTokens(content, parsed.tokens);
|
|
333
|
+
return {
|
|
334
|
+
source,
|
|
335
|
+
path,
|
|
336
|
+
role,
|
|
337
|
+
hash: sha256File(path),
|
|
338
|
+
tokens: { ...inferred, ...parsed.tokens },
|
|
339
|
+
warnings: parsed.warnings,
|
|
340
|
+
referenced_by: referencedBy,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function inlineCandidatesFromHtml(path, role) {
|
|
345
|
+
const content = readFileSync(path, "utf8");
|
|
346
|
+
const candidates = [];
|
|
347
|
+
for (const styleBlock of extractStyleBlocks(content)) {
|
|
348
|
+
for (const block of extractRootBlocks(styleBlock.body)) {
|
|
349
|
+
const parsed = parseRootCustomProperties(`:root {${block.body}}`);
|
|
350
|
+
if (Object.keys(parsed.tokens).length === 0) continue;
|
|
351
|
+
const inferred = inferDesignIntentTokens(styleBlock.body, parsed.tokens);
|
|
352
|
+
candidates.push({
|
|
353
|
+
source: "html_inline_root",
|
|
354
|
+
path,
|
|
355
|
+
role,
|
|
356
|
+
hash: sha256(`${path}:${styleBlock.index}:${block.index}:${block.body}`),
|
|
357
|
+
inline_block_index: styleBlock.index,
|
|
358
|
+
tokens: { ...inferred, ...parsed.tokens },
|
|
359
|
+
warnings: parsed.warnings,
|
|
360
|
+
referenced_by: [],
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return candidates;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function collectManifestCssRefs(value, refs = []) {
|
|
368
|
+
if (Array.isArray(value)) {
|
|
369
|
+
for (const item of value) collectManifestCssRefs(item, refs);
|
|
370
|
+
return refs;
|
|
371
|
+
}
|
|
372
|
+
if (isObject(value)) {
|
|
373
|
+
for (const item of Object.values(value)) collectManifestCssRefs(item, refs);
|
|
374
|
+
return refs;
|
|
375
|
+
}
|
|
376
|
+
if (typeof value === "string") {
|
|
377
|
+
const cleaned = cleanCssRef(value);
|
|
378
|
+
if (cleaned) refs.push(cleaned);
|
|
379
|
+
}
|
|
380
|
+
return refs;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function discoverThemeCandidates({ sourceRoot, pageMappings = [], manifest = null }) {
|
|
384
|
+
const candidates = [];
|
|
385
|
+
const seenFiles = new Set();
|
|
386
|
+
|
|
387
|
+
function addFile(path, role, source, referencedBy = []) {
|
|
388
|
+
if (!path || seenFiles.has(resolve(path)) || !existsSync(path) || !statSync(path).isFile()) return;
|
|
389
|
+
seenFiles.add(resolve(path));
|
|
390
|
+
candidates.push(candidateFromFile(path, role, source, referencedBy));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const conventional of [
|
|
394
|
+
"assets/css/tokens.css",
|
|
395
|
+
"assets/css/landing/tokens.css",
|
|
396
|
+
"assets/css/presell/tokens.css",
|
|
397
|
+
"css/tokens.css",
|
|
398
|
+
"css/landing/tokens.css",
|
|
399
|
+
"css/presell/tokens.css",
|
|
400
|
+
"tokens.css",
|
|
401
|
+
]) {
|
|
402
|
+
addFile(resolve(sourceRoot, conventional), candidateRoleFromPath(conventional, "shared"), "conventional_token_path");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const mapping of Array.isArray(pageMappings) ? pageMappings : []) {
|
|
406
|
+
if (!isNonEmptyString(mapping?.path)) continue;
|
|
407
|
+
const htmlPath = resolve(sourceRoot, mapping.path);
|
|
408
|
+
if (!existsSync(htmlPath) || !statSync(htmlPath).isFile()) continue;
|
|
409
|
+
const role = roleFromPageMapping(mapping);
|
|
410
|
+
candidates.push(...inlineCandidatesFromHtml(htmlPath, role));
|
|
411
|
+
const content = readFileSync(htmlPath, "utf8");
|
|
412
|
+
for (const ref of extractCssRefs(content)) {
|
|
413
|
+
for (const cssPath of candidateCssPathsForRef(sourceRoot, htmlPath, ref)) {
|
|
414
|
+
addFile(cssPath, candidateRoleFromPath(ref, role), "mapped_html_reference", [{ page_id: mapping.page_id || null, path: mapping.path, ref }]);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const ref of [...new Set(collectManifestCssRefs(manifest))]) {
|
|
420
|
+
for (const cssPath of candidateCssPathsForRef(sourceRoot, sourceRoot, ref)) {
|
|
421
|
+
addFile(cssPath, candidateRoleFromPath(ref, "shared"), "manifest_reference", [{ ref }]);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return candidates;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeColor(value) {
|
|
429
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
430
|
+
if (!raw || raw.includes("var(")) return null;
|
|
431
|
+
const hex = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
432
|
+
if (hex) {
|
|
433
|
+
const valueHex = hex[1].toLowerCase();
|
|
434
|
+
if (valueHex.length === 3) return `#${valueHex.split("").map((part) => `${part}${part}`).join("")}`;
|
|
435
|
+
return `#${valueHex}`;
|
|
436
|
+
}
|
|
437
|
+
const rgb = raw.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d?(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/);
|
|
438
|
+
if (rgb) {
|
|
439
|
+
const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
|
|
440
|
+
if (alpha !== 1) return null;
|
|
441
|
+
const parts = rgb.slice(1, 4).map((part) => Number(part));
|
|
442
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
443
|
+
return `#${parts.map((part) => part.toString(16).padStart(2, "0")).join("")}`;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function colorToRgb(value) {
|
|
449
|
+
const normalized = normalizeColor(value);
|
|
450
|
+
if (!normalized) return null;
|
|
451
|
+
return {
|
|
452
|
+
r: Number.parseInt(normalized.slice(1, 3), 16),
|
|
453
|
+
g: Number.parseInt(normalized.slice(3, 5), 16),
|
|
454
|
+
b: Number.parseInt(normalized.slice(5, 7), 16),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function rgbToHex({ r, g, b }) {
|
|
459
|
+
return `#${[r, g, b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function mixColor(value, target, amount) {
|
|
463
|
+
const rgb = colorToRgb(value);
|
|
464
|
+
if (!rgb) return null;
|
|
465
|
+
const targetRgb = target === "white" ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 };
|
|
466
|
+
return rgbToHex({
|
|
467
|
+
r: rgb.r + (targetRgb.r - rgb.r) * amount,
|
|
468
|
+
g: rgb.g + (targetRgb.g - rgb.g) * amount,
|
|
469
|
+
b: rgb.b + (targetRgb.b - rgb.b) * amount,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function relativeLuminance({ r, g, b }) {
|
|
474
|
+
const channel = (value) => {
|
|
475
|
+
const c = value / 255;
|
|
476
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
477
|
+
};
|
|
478
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function contrastRatio(luminanceA, luminanceB) {
|
|
482
|
+
const lighter = Math.max(luminanceA, luminanceB);
|
|
483
|
+
const darker = Math.min(luminanceA, luminanceB);
|
|
484
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Pick the foreground color (dark vs light) most legible on `bgValue`. A light
|
|
488
|
+
// brand background (yellow/white/pastel) yields a dark foreground; a dark or
|
|
489
|
+
// saturated background yields a light one. This is the fix for white-on-light
|
|
490
|
+
// CTA text: never trust a copied source --text-inverse (which defaults to
|
|
491
|
+
// white), always derive from the background's luminance.
|
|
492
|
+
function readableForeground(bgValue, choices = {}) {
|
|
493
|
+
const bgRgb = colorToRgb(bgValue);
|
|
494
|
+
if (!bgRgb) return null;
|
|
495
|
+
// Normalize the choices once so the emitted value is always 6-digit hex,
|
|
496
|
+
// regardless of how the contract spells them (e.g. "#000" -> "#000000").
|
|
497
|
+
const darkValue = normalizeColor(choices.dark) || "#0a0a0a";
|
|
498
|
+
const lightValue = normalizeColor(choices.light) || "#ffffff";
|
|
499
|
+
const bgLuminance = relativeLuminance(bgRgb);
|
|
500
|
+
const darkContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(darkValue)));
|
|
501
|
+
const lightContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(lightValue)));
|
|
502
|
+
const useDark = darkContrast >= lightContrast;
|
|
503
|
+
return {
|
|
504
|
+
value: useDark ? darkValue : lightValue,
|
|
505
|
+
contrast: Math.round(Math.max(darkContrast, lightContrast) * 100) / 100,
|
|
506
|
+
on: useDark ? "dark" : "light",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Emit foreground/on-color tokens derived from the luminance of the background
|
|
511
|
+
// each sits on. Pairing comes from the contract's foreground_derivations so the
|
|
512
|
+
// generator stays data-driven. A foreground target already mapped from a source
|
|
513
|
+
// token is left untouched; one whose paired backgrounds are all unmapped is
|
|
514
|
+
// skipped (no background to read).
|
|
515
|
+
function deriveForegroundMappings(existingMappings, targetTokens) {
|
|
516
|
+
const config = targetTokens.foreground_derivations;
|
|
517
|
+
if (!isObject(config) || !isObject(config.derivations)) return { mappings: [], warnings: [] };
|
|
518
|
+
const allowedTargets = new Set(targetTokens.tokens || []);
|
|
519
|
+
const choices = isObject(config.foreground_choices) ? config.foreground_choices : { dark: "#0a0a0a", light: "#ffffff" };
|
|
520
|
+
const minContrast = Number(config.min_contrast_ratio) || 0;
|
|
521
|
+
const valueByTarget = new Map();
|
|
522
|
+
for (const mapping of existingMappings) {
|
|
523
|
+
if (!valueByTarget.has(mapping.target)) valueByTarget.set(mapping.target, mapping.value);
|
|
524
|
+
}
|
|
525
|
+
const mappings = [];
|
|
526
|
+
const warnings = [];
|
|
527
|
+
for (const [foregroundTarget, backgroundCandidates] of Object.entries(config.derivations)) {
|
|
528
|
+
if (!allowedTargets.has(foregroundTarget) || valueByTarget.has(foregroundTarget)) continue;
|
|
529
|
+
const candidates = Array.isArray(backgroundCandidates) ? backgroundCandidates : [backgroundCandidates];
|
|
530
|
+
const backgroundTarget = candidates.find((target) => valueByTarget.has(target));
|
|
531
|
+
if (!backgroundTarget) continue;
|
|
532
|
+
const backgroundValue = valueByTarget.get(backgroundTarget);
|
|
533
|
+
const readable = readableForeground(backgroundValue, choices);
|
|
534
|
+
if (!readable) continue;
|
|
535
|
+
if (minContrast && readable.contrast < minContrast) {
|
|
536
|
+
warnings.push(issue(
|
|
537
|
+
"theme.foreground.low_contrast",
|
|
538
|
+
`Derived ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) only reaches ${readable.contrast}:1 contrast (< ${minContrast}:1). Confirm the brand background or supply an explicit foreground.`,
|
|
539
|
+
{ foreground: foregroundTarget, background: backgroundTarget, background_value: backgroundValue, contrast: readable.contrast },
|
|
540
|
+
));
|
|
541
|
+
}
|
|
542
|
+
// Scale confidence by the achieved contrast so a strong derivation reads as
|
|
543
|
+
// trustworthy as a direct mapping (AAA >= 7:1 high, AA >= 4.5:1 medium).
|
|
544
|
+
const confidence = readable.contrast >= 7 ? "high" : readable.contrast >= 4.5 ? "medium" : "low";
|
|
545
|
+
mappings.push({
|
|
546
|
+
source: backgroundTarget,
|
|
547
|
+
target: foregroundTarget,
|
|
548
|
+
value: readable.value,
|
|
549
|
+
confidence,
|
|
550
|
+
derivation: { method: "foreground-from-luminance", background: backgroundTarget, background_value: backgroundValue, on: readable.on, contrast: readable.contrast },
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return { mappings, warnings };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function compareProducerDefaults(tokens, sourceDefaults) {
|
|
557
|
+
const matches = [];
|
|
558
|
+
const unresolved = [];
|
|
559
|
+
const defaults = sourceDefaults.tokens || {};
|
|
560
|
+
for (const [name, expected] of Object.entries(defaults)) {
|
|
561
|
+
if (!(name in tokens)) continue;
|
|
562
|
+
const actualColor = normalizeColor(tokens[name]);
|
|
563
|
+
const expectedColor = normalizeColor(expected);
|
|
564
|
+
if (!actualColor) {
|
|
565
|
+
unresolved.push(name);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (actualColor === expectedColor) matches.push(name);
|
|
569
|
+
}
|
|
570
|
+
const coreTokens = Array.isArray(sourceDefaults.core_tokens) ? sourceDefaults.core_tokens : Object.keys(defaults);
|
|
571
|
+
const presentCore = coreTokens.filter((name) => name in tokens);
|
|
572
|
+
const matchedCore = presentCore.filter((name) => matches.includes(name));
|
|
573
|
+
const exactCoreMatch = presentCore.length > 0 && matchedCore.length === presentCore.length;
|
|
574
|
+
return {
|
|
575
|
+
matched: exactCoreMatch,
|
|
576
|
+
producer: sourceDefaults.producer,
|
|
577
|
+
version: sourceDefaults.producer_version,
|
|
578
|
+
matched_tokens: matches,
|
|
579
|
+
unresolved_tokens: unresolved,
|
|
580
|
+
present_core_tokens: presentCore,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function selectSourceCandidate(candidates) {
|
|
585
|
+
const withTokens = candidates.filter((candidate) => Object.keys(candidate.tokens || {}).length > 0);
|
|
586
|
+
const roleRank = new Map([
|
|
587
|
+
["shared", 0],
|
|
588
|
+
["landing", 1],
|
|
589
|
+
["presell", 2],
|
|
590
|
+
["page", 3],
|
|
591
|
+
["checkout", 4],
|
|
592
|
+
["upsell", 5],
|
|
593
|
+
]);
|
|
594
|
+
return withTokens.sort((a, b) => {
|
|
595
|
+
const rankA = roleRank.has(a.role) ? roleRank.get(a.role) : 10;
|
|
596
|
+
const rankB = roleRank.has(b.role) ? roleRank.get(b.role) : 10;
|
|
597
|
+
if (rankA !== rankB) return rankA - rankB;
|
|
598
|
+
return String(a.path).localeCompare(String(b.path));
|
|
599
|
+
})[0] || null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function collectTokenConflicts(candidates) {
|
|
603
|
+
const valuesByToken = new Map();
|
|
604
|
+
for (const candidate of candidates) {
|
|
605
|
+
for (const [token, value] of Object.entries(candidate.tokens || {})) {
|
|
606
|
+
if (!["--brand-primary", "--brand-accent", "--surface-bg", "--surface-card", "--text-primary", "--text-secondary"].includes(token)) continue;
|
|
607
|
+
const normalized = normalizeColor(value) || value.trim();
|
|
608
|
+
const entries = valuesByToken.get(token) || [];
|
|
609
|
+
entries.push({ path: candidate.path, role: candidate.role, value: normalized });
|
|
610
|
+
valuesByToken.set(token, entries);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const conflicts = [];
|
|
614
|
+
for (const [token, entries] of valuesByToken.entries()) {
|
|
615
|
+
const unique = [...new Set(entries.map((entry) => entry.value))];
|
|
616
|
+
if (unique.length > 1) conflicts.push({ token, entries });
|
|
617
|
+
}
|
|
618
|
+
return conflicts;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function mapTokens(tokens, targetTokens) {
|
|
622
|
+
const allowedTargets = new Set(targetTokens.tokens || []);
|
|
623
|
+
const mappings = [];
|
|
624
|
+
const warnings = [];
|
|
625
|
+
const sourceMappings = targetTokens.source_mappings || {};
|
|
626
|
+
|
|
627
|
+
function addMapping(source, target, value, confidence = "high", derivation = null) {
|
|
628
|
+
if (!allowedTargets.has(target)) {
|
|
629
|
+
warnings.push(issue("theme.target_token.unknown", `Target token ${target} is not in the local next-core contract.`, { source }));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
mappings.push({ source, target, value, confidence, derivation });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const [source, targets] of Object.entries(sourceMappings)) {
|
|
636
|
+
if (!isNonEmptyString(tokens[source])) continue;
|
|
637
|
+
for (const target of targets) addMapping(source, target, tokens[source].trim());
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const derived = targetTokens.derived_mappings?.["--brand-primary"] || {};
|
|
641
|
+
if (isNonEmptyString(tokens["--brand-primary"])) {
|
|
642
|
+
for (const [target, config] of Object.entries(derived)) {
|
|
643
|
+
const method = config.method;
|
|
644
|
+
const value = method === "mix-black"
|
|
645
|
+
? mixColor(tokens["--brand-primary"], "black", Number(config.amount || 0))
|
|
646
|
+
: method === "mix-white"
|
|
647
|
+
? mixColor(tokens["--brand-primary"], "white", Number(config.amount || 0))
|
|
648
|
+
: null;
|
|
649
|
+
if (value) addMapping("--brand-primary", target, value, "medium", config);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const hasCta = mappings.some((mapping) => mapping.target === "--brand--color--cta-primary");
|
|
654
|
+
if (!hasCta && isNonEmptyString(tokens["--brand-accent"])) {
|
|
655
|
+
addMapping("--brand-accent", "--brand--color--cta-primary", tokens["--brand-accent"].trim(), "medium", { method: "cta-fallback-from-brand-accent" });
|
|
656
|
+
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-accent as --brand--color--cta-primary."));
|
|
657
|
+
} else if (!hasCta && isNonEmptyString(tokens["--brand-primary"])) {
|
|
658
|
+
addMapping("--brand-primary", "--brand--color--cta-primary", tokens["--brand-primary"].trim(), "medium", { method: "cta-fallback-from-brand-primary" });
|
|
659
|
+
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-primary as --brand--color--cta-primary."));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const hasSurface = mappings.some((mapping) => mapping.target === "--brand--color--surface");
|
|
663
|
+
if (!hasSurface && isNonEmptyString(tokens["--surface-bg"])) {
|
|
664
|
+
addMapping("--surface-bg", "--brand--color--surface", tokens["--surface-bg"].trim(), "medium", { method: "surface-fallback-from-background" });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const hasRating = mappings.some((mapping) => mapping.target === "--brand--color--rating-star");
|
|
668
|
+
if (!hasRating && isNonEmptyString(tokens["--brand-accent"])) {
|
|
669
|
+
addMapping("--brand-accent", "--brand--color--rating-star", tokens["--brand-accent"].trim(), "medium", { method: "rating-fallback-from-brand-accent" });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Foreground/on-color tokens derive from the luminance of the background they
|
|
673
|
+
// sit on (CTA, primary, accent), after all backgrounds are mapped above.
|
|
674
|
+
const foreground = deriveForegroundMappings(mappings, targetTokens);
|
|
675
|
+
for (const mapping of foreground.mappings) {
|
|
676
|
+
addMapping(mapping.source, mapping.target, mapping.value, mapping.confidence, mapping.derivation);
|
|
677
|
+
}
|
|
678
|
+
warnings.push(...foreground.warnings);
|
|
679
|
+
|
|
680
|
+
return { mappings, warnings };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function confidenceFor({ selected, defaultMatch, mappings, conflicts }) {
|
|
684
|
+
if (!selected) return "none";
|
|
685
|
+
if (defaultMatch.matched) return "low";
|
|
686
|
+
const targets = new Set(mappings.map((mapping) => mapping.target));
|
|
687
|
+
const hasCore = [
|
|
688
|
+
"--brand--color--primary",
|
|
689
|
+
"--brand--color--background",
|
|
690
|
+
"--brand--color--text-primary",
|
|
691
|
+
].every((target) => targets.has(target));
|
|
692
|
+
if (!hasCore) return "medium";
|
|
693
|
+
if (conflicts.length > 0) return "medium";
|
|
694
|
+
if (targets.has("--brand--color--cta-primary") || targets.has("--brand--color--accent")) return "high";
|
|
695
|
+
return "medium";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function statusFor({ policy, selected, confidence, warnings, errors }) {
|
|
699
|
+
if (policy === "off") return "disabled";
|
|
700
|
+
if (errors.length) return "blocked";
|
|
701
|
+
if (!selected || confidence === "none") return "missing";
|
|
702
|
+
if (confidence === "low" || warnings.length) return "ready_with_warnings";
|
|
703
|
+
return "ready";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function renderBrandThemeCss({ selected, mappings, confidence }) {
|
|
707
|
+
const unique = [];
|
|
708
|
+
const seen = new Set();
|
|
709
|
+
for (const mapping of mappings) {
|
|
710
|
+
if (seen.has(mapping.target)) continue;
|
|
711
|
+
seen.add(mapping.target);
|
|
712
|
+
unique.push(mapping);
|
|
713
|
+
}
|
|
714
|
+
const sourceLabel = selected?.path ? selected.path : "unknown source";
|
|
715
|
+
const lines = [
|
|
716
|
+
"/*",
|
|
717
|
+
" * Generated by Campaigns OS brand-theme v0.",
|
|
718
|
+
` * Source: ${sourceLabel}`,
|
|
719
|
+
` * Confidence: ${confidence}`,
|
|
720
|
+
" * Safety: :root custom-property overrides only.",
|
|
721
|
+
" */",
|
|
722
|
+
":root {",
|
|
723
|
+
];
|
|
724
|
+
for (const mapping of unique.sort((a, b) => a.target.localeCompare(b.target))) {
|
|
725
|
+
lines.push(` ${mapping.target}: ${mapping.value};`);
|
|
726
|
+
}
|
|
727
|
+
lines.push("}");
|
|
728
|
+
lines.push("");
|
|
729
|
+
return lines.join("\n");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function validateGeneratedCss(css) {
|
|
733
|
+
const errors = [];
|
|
734
|
+
const warnings = [];
|
|
735
|
+
if (!isNonEmptyString(css)) {
|
|
736
|
+
errors.push(issue("theme.css.empty", "Generated CSS is empty."));
|
|
737
|
+
return { ok: false, errors, warnings };
|
|
738
|
+
}
|
|
739
|
+
if (/<\/?script\b|javascript:/i.test(css)) {
|
|
740
|
+
errors.push(issue("theme.css.script", "Generated CSS contains script-like content."));
|
|
741
|
+
}
|
|
742
|
+
if (/url\s*\(\s*["']?(?:https?:|\/\/|data:|javascript:)/i.test(css)) {
|
|
743
|
+
errors.push(issue("theme.css.remote_url", "Generated CSS must not reference remote/data/javascript URLs."));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
747
|
+
const rootBlocks = [];
|
|
748
|
+
const residual = withoutComments.replace(/:root\s*\{([\s\S]*?)\}/g, (_match, body) => {
|
|
749
|
+
rootBlocks.push(body);
|
|
750
|
+
return "";
|
|
751
|
+
}).trim();
|
|
752
|
+
if (residual) {
|
|
753
|
+
errors.push(issue("theme.css.selector", "Generated CSS must contain only :root blocks with custom properties.", { residual }));
|
|
754
|
+
}
|
|
755
|
+
if (rootBlocks.length === 0) {
|
|
756
|
+
errors.push(issue("theme.css.root", "Generated CSS must include a :root block."));
|
|
757
|
+
}
|
|
758
|
+
for (const [index, block] of rootBlocks.entries()) {
|
|
759
|
+
const stripped = block.replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
760
|
+
for (const declaration of stripped.split(";")) {
|
|
761
|
+
const trimmed = declaration.trim();
|
|
762
|
+
if (!trimmed) continue;
|
|
763
|
+
if (!/^--[A-Za-z0-9_-]+\s*:\s*[^{}<>]+$/.test(trimmed)) {
|
|
764
|
+
errors.push(issue("theme.css.declaration", `Generated :root block ${index} contains a non-token declaration.`, { declaration: trimmed }));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function sourceFileEntry(candidate, targetRepo) {
|
|
772
|
+
return {
|
|
773
|
+
path: targetRepo ? relativeArtifactPath(targetRepo, candidate.path) : candidate.path,
|
|
774
|
+
role: candidate.role,
|
|
775
|
+
source: candidate.source,
|
|
776
|
+
hash: candidate.hash,
|
|
777
|
+
token_count: Object.keys(candidate.tokens || {}).length,
|
|
778
|
+
inline_block_index: candidate.inline_block_index ?? null,
|
|
779
|
+
referenced_by: candidate.referenced_by || [],
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function findExistingBrandLayers(targetRepo, expectedCssPath) {
|
|
784
|
+
const layers = [];
|
|
785
|
+
function add(path, source) {
|
|
786
|
+
if (!path || !existsSync(path) || !statSync(path).isFile()) return;
|
|
787
|
+
if (layers.some((layer) => resolve(layer.absolute_path) === resolve(path))) return;
|
|
788
|
+
layers.push({ path: relativeArtifactPath(targetRepo, path), absolute_path: path, source });
|
|
789
|
+
}
|
|
790
|
+
add(expectedCssPath, "expected_artifact_path");
|
|
791
|
+
|
|
792
|
+
function walk(dir, depth = 0) {
|
|
793
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory() || depth > 6) return;
|
|
794
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
795
|
+
if (entry.isDirectory()) {
|
|
796
|
+
if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), depth + 1);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (entry.isFile() && BRAND_LAYER_FILENAMES.has(entry.name)) {
|
|
800
|
+
add(join(dir, entry.name), "target_repo_scan");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (targetRepo && existsSync(join(targetRepo, "src"))) walk(join(targetRepo, "src"));
|
|
805
|
+
return layers.map(({ absolute_path: _absolutePath, ...layer }) => layer);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function detectStaleArtifact({ reportPath, selected, confidence, expectedCssPath }) {
|
|
809
|
+
if (!existsSync(expectedCssPath)) return { stale: false, reasons: [] };
|
|
810
|
+
const reasons = [];
|
|
811
|
+
if (!selected) reasons.push("no current source tokens were selected");
|
|
812
|
+
if (confidence === "low" || confidence === "none") reasons.push(`current confidence is ${confidence}`);
|
|
813
|
+
const prior = readJsonIfExists(reportPath);
|
|
814
|
+
const priorHash = prior?.selected_source?.hash || null;
|
|
815
|
+
if (priorHash && selected?.hash && priorHash !== selected.hash) {
|
|
816
|
+
reasons.push("selected source hash changed since the existing theme report");
|
|
817
|
+
}
|
|
818
|
+
if (!prior) reasons.push("existing brand-theme.css has no paired theme-report.json");
|
|
819
|
+
return { stale: reasons.length > 0, reasons };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function inferManifest(sourceRoot) {
|
|
823
|
+
const manifestPath = resolve(sourceRoot, ".campaigns-os/source-html-manifest.json");
|
|
824
|
+
return readJsonIfExists(manifestPath);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function inspectBrandTheme({ packet, packetPath, context = null, policy = "inspect_only", outDir = null, force = false, repoRoot = ROOT } = {}) {
|
|
828
|
+
if (!isObject(packet)) throw new Error("inspectBrandTheme requires packet object.");
|
|
829
|
+
if (!isNonEmptyString(packetPath)) throw new Error("inspectBrandTheme requires packetPath.");
|
|
830
|
+
const requestedPolicy = isNonEmptyString(policy) ? policy : "inspect_only";
|
|
831
|
+
if (!THEME_POLICIES.has(requestedPolicy)) throw new Error(`Unsupported theme policy "${requestedPolicy}". Use inspect_only, auto, or off.`);
|
|
832
|
+
|
|
833
|
+
const warnings = [];
|
|
834
|
+
const errors = [];
|
|
835
|
+
const contracts = loadContracts(repoRoot);
|
|
836
|
+
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
837
|
+
const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo) || dirname(resolve(packetPath));
|
|
838
|
+
const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, ".campaign-runtime/theme");
|
|
839
|
+
const cssPath = resolve(themeOutDir, "brand-theme.css");
|
|
840
|
+
const reportPath = resolve(themeOutDir, "theme-report.json");
|
|
841
|
+
|
|
842
|
+
if (requestedPolicy === "off") {
|
|
843
|
+
const contextTheme = {
|
|
844
|
+
status: "disabled",
|
|
845
|
+
policy: requestedPolicy,
|
|
846
|
+
source_kind: "html_funnel",
|
|
847
|
+
confidence: "none",
|
|
848
|
+
source_files: [],
|
|
849
|
+
selected_source: null,
|
|
850
|
+
generated: {
|
|
851
|
+
css_path: relativeArtifactPath(targetRepo, cssPath),
|
|
852
|
+
report_path: relativeArtifactPath(targetRepo, reportPath),
|
|
853
|
+
},
|
|
854
|
+
mappings: [],
|
|
855
|
+
warnings: [],
|
|
856
|
+
};
|
|
857
|
+
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report: null, css: null, errors, warnings });
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (!sourceRoot || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
|
861
|
+
errors.push(issue("theme.source_root", "Source root is missing or is not a directory."));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const contextManifest = Array.isArray(context?.source?.manifest?.pages) ? context.source.manifest : null;
|
|
865
|
+
const manifest = errors.length ? null : (contextManifest || inferManifest(sourceRoot));
|
|
866
|
+
const candidates = errors.length ? [] : discoverThemeCandidates({
|
|
867
|
+
sourceRoot,
|
|
868
|
+
pageMappings: packet.source_html?.pages || [],
|
|
869
|
+
manifest,
|
|
870
|
+
});
|
|
871
|
+
const selected = selectSourceCandidate(candidates);
|
|
872
|
+
const conflicts = collectTokenConflicts(candidates.filter((candidate) => Object.keys(candidate.tokens || {}).length > 0));
|
|
873
|
+
for (const conflict of conflicts) {
|
|
874
|
+
warnings.push(issue("theme.source_tokens.conflict", `Source token ${conflict.token} has conflicting values across source files.`, conflict));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const defaultMatch = selected
|
|
878
|
+
? compareProducerDefaults(selected.tokens, contracts.sourceDefaults)
|
|
879
|
+
: {
|
|
880
|
+
matched: false,
|
|
881
|
+
producer: contracts.sourceDefaults.producer,
|
|
882
|
+
version: contracts.sourceDefaults.producer_version,
|
|
883
|
+
matched_tokens: [],
|
|
884
|
+
unresolved_tokens: [],
|
|
885
|
+
present_core_tokens: [],
|
|
886
|
+
};
|
|
887
|
+
if (defaultMatch.matched) {
|
|
888
|
+
warnings.push(issue("theme.source_tokens.defaults", "Source tokens match figma-sections-export scaffold defaults; confirm brand values before applying to commerce pages."));
|
|
889
|
+
}
|
|
890
|
+
for (const token of defaultMatch.unresolved_tokens) {
|
|
891
|
+
warnings.push(issue("theme.source_tokens.unresolved", `Source token ${token} uses an unresolved value; it cannot be compared to producer defaults.`));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens) : { mappings: [], warnings: [] };
|
|
895
|
+
warnings.push(...mapped.warnings);
|
|
896
|
+
const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
|
|
897
|
+
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
|
|
898
|
+
const safety = css ? validateGeneratedCss(css) : { ok: false, errors: [issue("theme.css.empty", "No generated CSS because no source tokens were selected.")], warnings: [] };
|
|
899
|
+
if (css && !safety.ok) errors.push(...safety.errors);
|
|
900
|
+
|
|
901
|
+
const stale = detectStaleArtifact({ reportPath, selected, confidence, expectedCssPath: cssPath });
|
|
902
|
+
if (stale.stale) {
|
|
903
|
+
warnings.push(issue("theme.artifact.stale", "Existing brand-theme.css is stale or untracked; generate with --force or a new --out-dir before applying.", stale.reasons));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const status = statusFor({ policy: requestedPolicy, selected, confidence, warnings, errors });
|
|
907
|
+
const canGenerate = Boolean(css && safety.ok && selected && !["low", "none"].includes(confidence));
|
|
908
|
+
const canAutoGenerate = requestedPolicy === "auto" && canGenerate && confidence === "high" && (!existsSync(cssPath) || force) && !stale.stale;
|
|
909
|
+
const sourceFiles = candidates.map((candidate) => sourceFileEntry(candidate, targetRepo));
|
|
910
|
+
const selectedSource = selected ? sourceFileEntry(selected, targetRepo) : null;
|
|
911
|
+
const generated = {
|
|
912
|
+
css_path: relativeArtifactPath(targetRepo, cssPath),
|
|
913
|
+
report_path: relativeArtifactPath(targetRepo, reportPath),
|
|
914
|
+
safety,
|
|
915
|
+
stale,
|
|
916
|
+
can_generate: canGenerate,
|
|
917
|
+
can_auto_generate: canAutoGenerate,
|
|
918
|
+
overwrite_requires_force: existsSync(cssPath) && !force,
|
|
919
|
+
};
|
|
920
|
+
const contextTheme = {
|
|
921
|
+
status,
|
|
922
|
+
policy: requestedPolicy,
|
|
923
|
+
source_kind: "html_funnel",
|
|
924
|
+
producer: {
|
|
925
|
+
name: selected ? contracts.sourceDefaults.producer : "unknown",
|
|
926
|
+
version: selected ? contracts.sourceDefaults.producer_version : "unknown",
|
|
927
|
+
},
|
|
928
|
+
source_files: sourceFiles,
|
|
929
|
+
selected_source: selectedSource,
|
|
930
|
+
producer_defaults: defaultMatch,
|
|
931
|
+
confidence,
|
|
932
|
+
generated,
|
|
933
|
+
mappings: mapped.mappings,
|
|
934
|
+
unmapped_defaults: [],
|
|
935
|
+
warnings,
|
|
936
|
+
};
|
|
937
|
+
const report = {
|
|
938
|
+
schema_version: BRAND_THEME_REPORT_SCHEMA,
|
|
939
|
+
generated_at: new Date().toISOString(),
|
|
940
|
+
status,
|
|
941
|
+
policy: requestedPolicy,
|
|
942
|
+
source_kind: "html_funnel",
|
|
943
|
+
confidence,
|
|
944
|
+
source_files: sourceFiles,
|
|
945
|
+
selected_source: selectedSource,
|
|
946
|
+
producer_defaults: defaultMatch,
|
|
947
|
+
conflicts,
|
|
948
|
+
mappings: mapped.mappings,
|
|
949
|
+
generated,
|
|
950
|
+
existing_brand_layers: findExistingBrandLayers(targetRepo, cssPath),
|
|
951
|
+
warnings,
|
|
952
|
+
errors,
|
|
953
|
+
next_action: nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy: requestedPolicy }),
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy }) {
|
|
960
|
+
if (policy === "off") return "Theme discovery disabled.";
|
|
961
|
+
if (status === "missing") return "Proceed without generated brand theme, or provide source tokens/design-system artifacts.";
|
|
962
|
+
if (status === "blocked") return "Fix theme errors before applying a brand theme.";
|
|
963
|
+
if (stale.stale) return "Regenerate brand-theme.css with --force or a clean --out-dir before applying.";
|
|
964
|
+
if (canAutoGenerate) return "Auto policy can write brand-theme.css during prepare-build.";
|
|
965
|
+
if (canGenerate) return "Run campaigns-os theme generate, then apply brand-theme.css after next-core.css on commerce pages.";
|
|
966
|
+
if (confidence === "low") return "Confirm brand values; current source tokens look like producer defaults.";
|
|
967
|
+
return "Review theme-report.json and decide whether to skip or collect stronger brand inputs.";
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings }) {
|
|
971
|
+
return {
|
|
972
|
+
ok: errors.length === 0,
|
|
973
|
+
status: contextTheme.status,
|
|
974
|
+
confidence: contextTheme.confidence,
|
|
975
|
+
policy: contextTheme.policy,
|
|
976
|
+
map_id: packet.spec?.map_id || null,
|
|
977
|
+
public_route_slug: packet.campaign?.public_route_slug || null,
|
|
978
|
+
source_root: sourceRoot ? relativeArtifactPath(targetRepo, sourceRoot) : null,
|
|
979
|
+
paths: {
|
|
980
|
+
css_path: relativeArtifactPath(targetRepo, cssPath),
|
|
981
|
+
report_path: relativeArtifactPath(targetRepo, reportPath),
|
|
982
|
+
out_dir: relativeArtifactPath(targetRepo, dirname(cssPath)),
|
|
983
|
+
},
|
|
984
|
+
absolute_paths: {
|
|
985
|
+
css_path: cssPath,
|
|
986
|
+
report_path: reportPath,
|
|
987
|
+
out_dir: dirname(cssPath),
|
|
988
|
+
},
|
|
989
|
+
context_theme: contextTheme,
|
|
990
|
+
report,
|
|
991
|
+
css,
|
|
992
|
+
errors,
|
|
993
|
+
warnings,
|
|
994
|
+
ready: contextTheme.status === "ready" ? [`Brand theme artifact can be generated with confidence ${contextTheme.confidence}.`] : [],
|
|
995
|
+
next: {
|
|
996
|
+
stage: "theme",
|
|
997
|
+
owner: "campaigns-os",
|
|
998
|
+
action: report?.next_action || "Review theme state.",
|
|
999
|
+
actions: report?.next_action ? [report.next_action] : [],
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export function writeThemeArtifacts(inspection, { writeCss = false, writeReport = true, force = false, packetPath = "<campaign-runtime.build.json>" } = {}) {
|
|
1005
|
+
const errors = [...(inspection.errors || [])];
|
|
1006
|
+
const wrote = { report: false, css: false };
|
|
1007
|
+
const alreadyCurrent = { css: false };
|
|
1008
|
+
const reportPath = resolvePathFromInspection(inspection, "report_path");
|
|
1009
|
+
const cssPath = resolvePathFromInspection(inspection, "css_path");
|
|
1010
|
+
|
|
1011
|
+
if (writeReport && inspection.report) {
|
|
1012
|
+
replaceFileAtomically(reportPath, `${JSON.stringify(inspection.report, null, 2)}\n`);
|
|
1013
|
+
wrote.report = true;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (writeCss) {
|
|
1017
|
+
if (!inspection.context_theme?.generated?.can_generate) {
|
|
1018
|
+
errors.push(issue("theme.generate.not_ready", "brand-theme.css is not ready to generate; inspect theme-report.json for missing/default/conflicting source tokens."));
|
|
1019
|
+
} else if (!inspection.css) {
|
|
1020
|
+
errors.push(issue("theme.generate.empty", "No generated CSS is available."));
|
|
1021
|
+
} else if (existsSync(cssPath) && !force) {
|
|
1022
|
+
const existingCss = readFileSync(cssPath, "utf8");
|
|
1023
|
+
if (existingCss === inspection.css) {
|
|
1024
|
+
alreadyCurrent.css = true;
|
|
1025
|
+
} else {
|
|
1026
|
+
const safeCommand = `campaigns-os theme generate --packet ${shellToken(packetPath)} --force`;
|
|
1027
|
+
errors.push(issue(
|
|
1028
|
+
"theme.generate.exists",
|
|
1029
|
+
`brand-theme.css already exists and differs from the current generated output; rerun \`${safeCommand}\` or pass a new --out-dir to overwrite safely.`,
|
|
1030
|
+
{ safe_commands: [safeCommand] },
|
|
1031
|
+
));
|
|
1032
|
+
}
|
|
1033
|
+
} else {
|
|
1034
|
+
replaceFileAtomically(cssPath, inspection.css);
|
|
1035
|
+
wrote.css = true;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
...inspection,
|
|
1041
|
+
ok: errors.length === 0,
|
|
1042
|
+
status: errors.length ? "blocked" : inspection.status,
|
|
1043
|
+
errors,
|
|
1044
|
+
wrote,
|
|
1045
|
+
already_current: alreadyCurrent,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function resolvePathFromInspection(inspection, key) {
|
|
1050
|
+
if (isNonEmptyString(inspection.absolute_paths?.[key])) return resolve(inspection.absolute_paths[key]);
|
|
1051
|
+
const targetRepo = inspection.absolute_paths?.target_repo || null;
|
|
1052
|
+
const path = inspection.paths?.[key];
|
|
1053
|
+
if (!isNonEmptyString(path)) throw new Error(`Missing inspection path ${key}.`);
|
|
1054
|
+
if (isAbsolute(path)) return resolve(path);
|
|
1055
|
+
if (targetRepo) return resolve(targetRepo, path);
|
|
1056
|
+
return resolve(path);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export function validateThemeContextBlock(theme) {
|
|
1060
|
+
const errors = [];
|
|
1061
|
+
const warnings = [];
|
|
1062
|
+
if (theme === undefined || theme === null) return { ok: true, errors, warnings, ready: [] };
|
|
1063
|
+
if (!isObject(theme)) {
|
|
1064
|
+
errors.push(issue("context.theme.type", "context.theme must be an object."));
|
|
1065
|
+
return { ok: false, errors, warnings, ready: [] };
|
|
1066
|
+
}
|
|
1067
|
+
validateEnum(theme.status, THEME_CONTEXT_STATUSES, "context.theme.status", errors);
|
|
1068
|
+
validateEnum(theme.policy, THEME_POLICIES, "context.theme.policy", errors);
|
|
1069
|
+
validateEnum(theme.confidence, THEME_CONFIDENCES, "context.theme.confidence", errors);
|
|
1070
|
+
if (theme.source_kind !== "html_funnel") errors.push(issue("context.theme.source_kind", "context.theme.source_kind must be html_funnel in v0."));
|
|
1071
|
+
if (theme.generated) validateGeneratedPaths(theme.generated, "context.theme.generated", errors);
|
|
1072
|
+
if (theme.generated?.stale?.stale === true) {
|
|
1073
|
+
warnings.push(issue("context.theme.stale", "context.theme reports a stale brand-theme artifact."));
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
ok: errors.length === 0,
|
|
1077
|
+
errors,
|
|
1078
|
+
warnings,
|
|
1079
|
+
ready: errors.length ? [] : [`Brand theme context ${theme.status || "unknown"} (${theme.confidence || "unknown"} confidence)`],
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export function validateAssemblyReportThemeBlock(theme) {
|
|
1084
|
+
const errors = [];
|
|
1085
|
+
const warnings = [];
|
|
1086
|
+
if (theme === undefined || theme === null) return { ok: true, errors, warnings, ready: [] };
|
|
1087
|
+
if (!isObject(theme)) {
|
|
1088
|
+
errors.push(issue("report.theme.type", "report.theme must be an object."));
|
|
1089
|
+
return { ok: false, errors, warnings, ready: [] };
|
|
1090
|
+
}
|
|
1091
|
+
validateEnum(theme.status, THEME_REPORT_STATUSES, "report.theme.status", errors);
|
|
1092
|
+
if (theme.css_path !== undefined && theme.css_path !== null && (!isNonEmptyString(theme.css_path) || isAbsolute(theme.css_path))) {
|
|
1093
|
+
errors.push(issue("report.theme.css_path", "report.theme.css_path must be a relative path when present."));
|
|
1094
|
+
}
|
|
1095
|
+
if (theme.load_order !== undefined && !["after-next-core", "not-applied", "unknown"].includes(theme.load_order)) {
|
|
1096
|
+
errors.push(issue("report.theme.load_order", "report.theme.load_order must be after-next-core, not-applied, or unknown."));
|
|
1097
|
+
}
|
|
1098
|
+
if (theme.status === "applied" && theme.load_order !== "after-next-core") {
|
|
1099
|
+
errors.push(issue("report.theme.load_order", "Applied brand theme must report load_order after-next-core."));
|
|
1100
|
+
}
|
|
1101
|
+
if (theme.commerce_pages !== undefined && !Array.isArray(theme.commerce_pages)) {
|
|
1102
|
+
errors.push(issue("report.theme.commerce_pages", "report.theme.commerce_pages must be an array when present."));
|
|
1103
|
+
}
|
|
1104
|
+
if (theme.evidence !== undefined && !Array.isArray(theme.evidence)) {
|
|
1105
|
+
errors.push(issue("report.theme.evidence", "report.theme.evidence must be an array when present."));
|
|
1106
|
+
}
|
|
1107
|
+
if (theme.warnings !== undefined && !Array.isArray(theme.warnings)) {
|
|
1108
|
+
errors.push(issue("report.theme.warnings", "report.theme.warnings must be an array when present."));
|
|
1109
|
+
}
|
|
1110
|
+
if (theme.repair_loop_defect !== undefined && theme.repair_loop_defect !== null) {
|
|
1111
|
+
if (!isObject(theme.repair_loop_defect)) {
|
|
1112
|
+
errors.push(issue("report.theme.repair_loop_defect", "report.theme.repair_loop_defect must be null or an object when present."));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (theme.waiver !== undefined && theme.waiver !== null) {
|
|
1116
|
+
if (!isObject(theme.waiver) || !isNonEmptyString(theme.waiver.reason)) {
|
|
1117
|
+
errors.push(issue("report.theme.waiver", "report.theme.waiver must be null or an object with a non-empty reason string."));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// needs_review is an unresolved decision, never a green signal: it means a
|
|
1121
|
+
// generatable brand layer exists but nobody generated/applied/waived it.
|
|
1122
|
+
// Surfacing it under `ready` is exactly how the recovery-relief dogfood run
|
|
1123
|
+
// shipped starter-blue commerce pages, so it reports as a warning instead.
|
|
1124
|
+
if (errors.length === 0 && theme.status === "needs_review" && !theme.waiver) {
|
|
1125
|
+
warnings.push(issue(
|
|
1126
|
+
"report.theme.needs_review",
|
|
1127
|
+
"Assembly report theme is needs_review: a generatable brand theme has not been generated/applied. The theme gate blocks polish/deploy/QA for commerce-page campaigns until report.theme is applied (load_order after-next-core) or explicitly waived (campaigns-os theme waive).",
|
|
1128
|
+
));
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
ok: errors.length === 0,
|
|
1132
|
+
errors,
|
|
1133
|
+
warnings,
|
|
1134
|
+
ready: errors.length || theme.status === "needs_review" ? [] : [`Assembly report theme ${theme.status || "unknown"}`],
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function validateEnum(value, allowed, code, errors) {
|
|
1139
|
+
if (!allowed.has(value)) {
|
|
1140
|
+
errors.push(issue(code, `${code} must be one of: ${[...allowed].join(", ")}.`));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function validateGeneratedPaths(generated, prefix, errors) {
|
|
1145
|
+
for (const key of ["css_path", "report_path"]) {
|
|
1146
|
+
const value = generated[key];
|
|
1147
|
+
if (value !== undefined && (!isNonEmptyString(value) || isAbsolute(value))) {
|
|
1148
|
+
errors.push(issue(`${prefix}.${key}`, `${prefix}.${key} must be a relative path when present.`));
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|