@tokenoftrust/storefront-runner 2.0.40 → 2.0.42
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/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +1 -1
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/csp-compiler.ts +6 -3
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
- package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
- package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- package/tenants/home/theme.json +0 -6
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only closure checks for the optional visual-parity migration module.
|
|
3
|
+
* The tenant checkout remains portable: this reads only content/, public/, and
|
|
4
|
+
* .tot/visual-parity.json, never a preview host or a delivery provider.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { extname, join, relative } from "node:path";
|
|
9
|
+
|
|
10
|
+
const ERROR = "error";
|
|
11
|
+
// Public routes implemented by the renderer. Authentication and cart URLs are
|
|
12
|
+
// deliberately absent: they are flow-specific, not portable parity targets.
|
|
13
|
+
const PLATFORM_ROUTES = ["/collections", "/collections/", "/products/", "/search", "/saved", "/account", "/account/", "/blog", "/blog/"];
|
|
14
|
+
const ASSET_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
|
|
15
|
+
|
|
16
|
+
const finding = (rule, file, message, fix) => ({ level: ERROR, rule, file, message, fix });
|
|
17
|
+
const walk = (dir) => !existsSync(dir) ? [] : readdirSync(dir).flatMap((name) => {
|
|
18
|
+
const path = join(dir, name);
|
|
19
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
20
|
+
});
|
|
21
|
+
const pointer = (parts) => parts.length ? `/${parts.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}` : "/";
|
|
22
|
+
const stripQuery = (value) => value.split(/[?#]/, 1)[0];
|
|
23
|
+
const normalizeRoute = (value) => {
|
|
24
|
+
const path = stripQuery(value).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
25
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
26
|
+
};
|
|
27
|
+
const hasDotSegment = (value) => stripQuery(value).split("/").some((part) => part === "." || part === ".." || /^%2e(?:%2e)?$/i.test(part));
|
|
28
|
+
const safeLocator = (value) => {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value.startsWith("//") ? `https:${value}` : value);
|
|
31
|
+
return url.origin;
|
|
32
|
+
} catch {
|
|
33
|
+
if (value.startsWith("/")) return normalizeRoute(value);
|
|
34
|
+
return "[redacted authored value]";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parseConfig(tenantDir) {
|
|
39
|
+
const path = join(tenantDir, ".tot", "visual-parity.json");
|
|
40
|
+
if (!existsSync(path)) return { value: {}, findings: [] };
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object");
|
|
44
|
+
const findings = [];
|
|
45
|
+
if (value.externalNavigationAllowlist !== undefined && !Array.isArray(value.externalNavigationAllowlist)) {
|
|
46
|
+
findings.push(finding("parity-config-shape", ".tot/visual-parity.json", "externalNavigationAllowlist must be an array of HTTPS origins or URLs", "replace it with an explicit reviewed array, for example [\"https://partner.example\"]"));
|
|
47
|
+
}
|
|
48
|
+
for (const [index, entry] of (Array.isArray(value.externalNavigationAllowlist) ? value.externalNavigationAllowlist : []).entries()) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(entry);
|
|
51
|
+
if (typeof entry !== "string" || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(entry)) throw new Error("invalid");
|
|
52
|
+
} catch {
|
|
53
|
+
findings.push(finding("parity-allowlist-entry", `.tot/visual-parity.json/externalNavigationAllowlist/${index}`, "allowlist entries must be HTTPS origins or exact path prefixes without credentials, query, or fragment", "use https://partner.example for an origin or https://partner.example/returns for that path boundary"));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { value, findings };
|
|
57
|
+
} catch {
|
|
58
|
+
return { value: {}, findings: [finding("parity-config-parse", ".tot/visual-parity.json", "invalid JSON", "fix .tot/visual-parity.json before closing visual parity")] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tenantFromConfig(tenantDir) {
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
|
|
65
|
+
return typeof value?.tenant === "string" ? value.tenant : null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function allowedNavigation(value, allowlist) {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(value);
|
|
72
|
+
return allowlist.some((entry) => {
|
|
73
|
+
try {
|
|
74
|
+
const allowed = new URL(entry);
|
|
75
|
+
if (allowed.protocol !== "https:" || allowed.username || allowed.password) return false;
|
|
76
|
+
if (allowed.pathname === "/") return url.origin === allowed.origin;
|
|
77
|
+
return url.origin === allowed.origin && (url.pathname === allowed.pathname || url.pathname.startsWith(`${allowed.pathname.replace(/\/$/, "")}/`));
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
});
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assetType(bytes) {
|
|
84
|
+
const ascii = bytes.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
|
|
85
|
+
if (pngValid(bytes)) return ".png";
|
|
86
|
+
if (bytes.subarray(0, 3).toString("ascii") === "GIF") return ".gif";
|
|
87
|
+
if (jpegValid(bytes)) return ".jpg";
|
|
88
|
+
if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return ".webp";
|
|
89
|
+
if (bytes.subarray(4, 8).toString("ascii") === "ftyp" && bytes.subarray(8, 12).toString("ascii").toLowerCase().includes("avif")) return ".avif";
|
|
90
|
+
if (bytes.subarray(0, 4).toString("ascii") === "wOFF") return ".woff";
|
|
91
|
+
if (bytes.subarray(0, 4).toString("ascii") === "wOF2") return ".woff2";
|
|
92
|
+
if (icoValid(bytes)) return ".ico";
|
|
93
|
+
if (ascii.startsWith("<svg") || ascii.startsWith("<?xml") && ascii.includes("<svg")) return ".svg";
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function pngValid(bytes) {
|
|
97
|
+
if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return false;
|
|
98
|
+
let pos = 8, sawIhdr = false;
|
|
99
|
+
let sawIdat = false;
|
|
100
|
+
while (pos + 12 <= bytes.length) {
|
|
101
|
+
const length = bytes.readUInt32BE(pos); const type = bytes.subarray(pos + 4, pos + 8).toString("ascii");
|
|
102
|
+
if (length > bytes.length - pos - 12) return false;
|
|
103
|
+
if (type === "IHDR") { if (sawIhdr || length !== 13 || bytes.readUInt32BE(pos + 8) === 0 || bytes.readUInt32BE(pos + 12) === 0) return false; sawIhdr = true; }
|
|
104
|
+
if (type === "IDAT" && length > 0) sawIdat = true;
|
|
105
|
+
pos += 12 + length; if (type === "IEND") return sawIhdr && sawIdat && length === 0 && pos === bytes.length;
|
|
106
|
+
} return false;
|
|
107
|
+
}
|
|
108
|
+
function jpegValid(bytes) {
|
|
109
|
+
if (bytes.length < 10 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
|
110
|
+
let pos = 2, sof = false;
|
|
111
|
+
while (pos < bytes.length) {
|
|
112
|
+
if (bytes[pos++] !== 0xff) return false; while (bytes[pos] === 0xff) pos++;
|
|
113
|
+
const marker = bytes[pos++]; if (marker === 0xd9) return sof && pos === bytes.length;
|
|
114
|
+
if (marker === 0xda) { if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); const scan = pos + length; return sof && scan < bytes.length - 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; }
|
|
115
|
+
if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue;
|
|
116
|
+
if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); if (length < 2 || pos + length > bytes.length) return false;
|
|
117
|
+
if (marker >= 0xc0 && marker <= 0xc3 && length >= 8) { if (bytes.readUInt16BE(pos + 3) === 0 || bytes.readUInt16BE(pos + 5) === 0) return false; sof = true; }
|
|
118
|
+
pos += length;
|
|
119
|
+
} return false;
|
|
120
|
+
}
|
|
121
|
+
function icoValid(bytes) {
|
|
122
|
+
if (bytes.length < 22 || bytes.readUInt16LE(0) !== 0 || bytes.readUInt16LE(2) !== 1) return false;
|
|
123
|
+
const count = bytes.readUInt16LE(4); if (!count || bytes.length < 6 + count * 16) return false;
|
|
124
|
+
for (let i = 0; i < count; i++) { const at = 6 + i * 16, size = bytes.readUInt32LE(at + 8), offset = bytes.readUInt32LE(at + 12); if (size && offset >= 6 + count * 16 && offset + size <= bytes.length) { const payload = bytes.subarray(offset, offset + size); if (pngValid(payload) || payload.length >= 40 && payload.readInt32LE(0) >= 40 && payload.readInt32LE(4) > 0 && payload.readInt32LE(8) > 0) return true; } }
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkAsset(value, at, file, context) {
|
|
129
|
+
const { findings, tenantId, publicDir } = context;
|
|
130
|
+
if (!value || value.startsWith("#")) return;
|
|
131
|
+
if (/^data:/i.test(value)) {
|
|
132
|
+
findings.push(finding("parity-data-asset", `${file}${at}`, "embedded data assets are not portable parity inputs", "copy the asset into public/ and use a tenant-local path"));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) {
|
|
136
|
+
findings.push(finding("parity-external-asset", `${file}${at}`, `asset source ${safeLocator(value)} is off-host`, "copy the asset into public/ and reference it with /tenants/<tenant>/..."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const path = stripQuery(value);
|
|
140
|
+
let rel;
|
|
141
|
+
if (path.startsWith("/tenants/")) {
|
|
142
|
+
const prefix = `/tenants/${tenantId}/`;
|
|
143
|
+
if (!tenantId || !path.startsWith(prefix)) {
|
|
144
|
+
findings.push(finding("parity-cross-tenant-asset", `${file}${at}`, "asset does not belong to this tenant", "copy the asset into this tenant's public/ directory"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
rel = path.slice(prefix.length);
|
|
148
|
+
} else if (path.startsWith("/")) {
|
|
149
|
+
findings.push(finding("parity-unowned-asset", `${file}${at}`, "asset is not tenant-local", "use a tenant-local asset committed under public/"));
|
|
150
|
+
return;
|
|
151
|
+
} else rel = path;
|
|
152
|
+
if (!rel || hasDotSegment(rel)) {
|
|
153
|
+
findings.push(finding("parity-asset-path", `${file}${at}`, "asset path escapes or does not name public/", "use a direct tenant-local path under public/"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const asset = join(publicDir, rel);
|
|
157
|
+
if (!existsSync(asset)) {
|
|
158
|
+
findings.push(finding("parity-asset-missing", `${file}${at}`, "tenant-local asset is absent from public/", "add the referenced asset under public/ or correct the reference"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const extension = extname(rel).toLowerCase();
|
|
162
|
+
if (!ASSET_EXTENSIONS.has(extension)) {
|
|
163
|
+
findings.push(finding("parity-asset-extension", `${file}${at}`, "asset has an unsupported extension", "re-export favicon/assets as PNG or SVG; font containers require platform-managed validation"));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (statSync(asset).isDirectory()) {
|
|
167
|
+
findings.push(finding("parity-asset-directory", `${file}${at}`, "asset names a directory", "reference a file under public/"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (ASSET_EXTENSIONS.has(extension)) {
|
|
171
|
+
const actual = assetType(readFileSync(asset));
|
|
172
|
+
const compatible = actual === extension || actual === ".jpg" && extension === ".jpeg";
|
|
173
|
+
if (!actual) findings.push(finding("parity-asset-undecodable", `${file}${at}`, `asset does not pass bounded ${extension} structural integrity checks`, "replace it with a structurally valid file of the declared type"));
|
|
174
|
+
else if (!compatible) findings.push(finding("parity-asset-type", `${file}${at}`, `asset bytes do not match the ${extension} extension`, "export the asset in the declared format"));
|
|
175
|
+
else if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"].includes(extension)) context.decodeAssets.push({ asset, file: `${file}${at}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeImages(entries) {
|
|
180
|
+
if (!entries.length) return new Set();
|
|
181
|
+
const source = "const{createRequire}=require('node:module');const sharp=createRequire(process.argv[1])('sharp');(async()=>{let a=JSON.parse(process.argv[2]);let o=[];for(const p of a){try{let r=await sharp(p,{failOn:'error'}).raw().toBuffer({resolveWithObject:true});o.push(r.info.width>0&&r.info.height>0)}catch{o.push(false)}}process.stdout.write(JSON.stringify(o))})().catch(()=>process.exit(1));";
|
|
182
|
+
const result = spawnSync(process.execPath, ["-e", source, import.meta.url, JSON.stringify(entries.map((entry) => entry.asset))], { encoding: "utf8", timeout: 15000 });
|
|
183
|
+
try { if (result.status !== 0) throw new Error(); const output = JSON.parse(result.stdout); return new Set(entries.flatMap((_, index) => output[index] ? [] : [index])); } catch { return new Set(entries.map((_, index) => index)); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function inspectHtml(html, file, at, context) {
|
|
187
|
+
const attr = /\b(href|src)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))/gi;
|
|
188
|
+
let index = 0;
|
|
189
|
+
for (const match of html.matchAll(attr)) {
|
|
190
|
+
const value = (match[3] ?? match[4] ?? "").trim();
|
|
191
|
+
inspectReference(match[1].toLowerCase(), value, `${at}/html-attribute-${index++}`, file, context);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function inspectReference(kind, value, at, file, context) {
|
|
195
|
+
if (kind === "src") return checkAsset(value, at, file, context);
|
|
196
|
+
if (!value || value.startsWith("#") || /^(mailto:|tel:)/i.test(value)) return;
|
|
197
|
+
if (/^(javascript|vbscript|data):/i.test(value)) {
|
|
198
|
+
context.findings.push(finding("parity-executable-link", `${file}${at}`, "link uses an executable scheme", "replace it with a local route or a reviewed HTTPS destination"));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (/^https?:\/\//i.test(value) || value.startsWith("//")) {
|
|
202
|
+
if (!allowedNavigation(value.startsWith("//") ? `https:${value}` : value, context.allowlist)) context.findings.push(finding("parity-external-navigation", `${file}${at}`, `external navigation ${safeLocator(value)} is not on the reviewed allowlist`, "add its HTTPS origin or URL to .tot/visual-parity.json only after human review"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!value.startsWith("/")) {
|
|
206
|
+
context.findings.push(finding("parity-relative-link", `${file}${at}`, "relative link has no stable paired route", "use a root-absolute local route"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (hasDotSegment(value)) {
|
|
210
|
+
context.findings.push(finding("parity-route-path", `${file}${at}`, "local route contains a dot segment", "use a canonical root-absolute route"));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const route = normalizeRoute(value);
|
|
214
|
+
if (!context.routes.has(route) && !PLATFORM_ROUTES.some((platform) => platform.endsWith("/") ? route.startsWith(platform) : route === platform)) context.findings.push(finding("parity-route-missing", `${file}${at}`, "local route is not declared by a page slug or platform", "add the matching content/pages JSON record or correct the link"));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function inspectValue(value, file, parts, context) {
|
|
218
|
+
if (Array.isArray(value)) return value.forEach((item, index) => inspectValue(item, file, [...parts, index], context));
|
|
219
|
+
if (!value || typeof value !== "object") return;
|
|
220
|
+
for (const [key, child] of Object.entries(value)) {
|
|
221
|
+
const next = [...parts, key];
|
|
222
|
+
if (key === "body_html" && typeof child === "string") inspectHtml(child, file, pointer(next), context);
|
|
223
|
+
if ((key === "href" || key === "ctaHref") && typeof child === "string") inspectReference("href", child, pointer(next), file, context);
|
|
224
|
+
if ((key === "src" || key === "image" || key === "icon" || key === "logo" || key === "logoDark" || key === "favicon") && typeof child === "string") checkAsset(child, pointer(next), file, context);
|
|
225
|
+
if (typeof child === "string" && /(?:css|style|stylesheet)$/i.test(key)) {
|
|
226
|
+
for (const match of child.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) checkAsset(match[2].trim(), `${pointer(next)}/css`, file, context);
|
|
227
|
+
}
|
|
228
|
+
inspectValue(child, file, next, context);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pageShape(value, file) {
|
|
233
|
+
const findings = [];
|
|
234
|
+
const required = [["slug", "string"], ["title", "string"]];
|
|
235
|
+
for (const [key, type] of required) {
|
|
236
|
+
const valid = type === "array" ? Array.isArray(value[key]) : typeof value[key] === type;
|
|
237
|
+
if (!valid || (type === "string" && value[key].trim() === "")) findings.push(finding("parity-page-field", `${file}/${key}`, `${key} must be a non-empty ${type}`, `set ${key} to the page value expected by the renderer`));
|
|
238
|
+
}
|
|
239
|
+
if (value.isPolicy !== undefined && typeof value.isPolicy !== "boolean") findings.push(finding("parity-page-field", `${file}/isPolicy`, "isPolicy must be a boolean when present", "set isPolicy to true or false"));
|
|
240
|
+
if (value.sections !== undefined && !Array.isArray(value.sections)) findings.push(finding("parity-page-field", `${file}/sections`, "sections must be an array when present", "set sections to an array of renderer-supported sections"));
|
|
241
|
+
if (Array.isArray(value.sections)) value.sections.forEach((section, index) => {
|
|
242
|
+
const at = `${file}/sections/${index}`;
|
|
243
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) return findings.push(finding("parity-section-shape", at, "section must be an object", "use a renderer-supported prose section"));
|
|
244
|
+
const string = (v) => typeof v === "string" && v.trim() !== "";
|
|
245
|
+
const rich = (v) => typeof v === "string" ? v.trim() !== "" : Array.isArray(v) && v.length > 0 && v.every((run) => run && typeof run === "object" && !Array.isArray(run) && ((run.kind === "text" && string(run.text) && (run.strong === undefined || typeof run.strong === "boolean") && (run.em === undefined || typeof run.em === "boolean")) || (run.kind === "link" && string(run.text) && string(run.href) && (run.external === undefined || typeof run.external === "boolean"))));
|
|
246
|
+
switch (section.kind) {
|
|
247
|
+
case "heading": if (!string(section.text) || section.level !== undefined && section.level !== 2 && section.level !== 3 || section.id !== undefined && !string(section.id)) findings.push(finding("parity-section-shape", at, "heading requires string text, optional id, and level 2 or 3", "supply a valid heading")); break;
|
|
248
|
+
case "paragraph": if (!rich(section.text)) findings.push(finding("parity-section-shape", at, "paragraph requires RichText", "supply a valid string or inline-run array")); break;
|
|
249
|
+
case "list": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !rich(item)) || section.ordered !== undefined && typeof section.ordered !== "boolean") findings.push(finding("parity-section-shape", at, "list requires RichText items and optional boolean ordered", "supply valid list items")); break;
|
|
250
|
+
case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
|
|
251
|
+
case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
|
|
252
|
+
case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
|
|
253
|
+
case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
|
|
254
|
+
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
|
|
258
|
+
const sections = Array.isArray(value.sections) && value.sections.length > 0;
|
|
259
|
+
if (!bodyHtml && !sections) findings.push(finding("parity-page-body", file, "page needs non-empty body_html or non-empty sections", "add rendered body_html or one or more sections"));
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readStructured(path, file, findings) {
|
|
264
|
+
try { return JSON.parse(readFileSync(path, "utf8")); }
|
|
265
|
+
catch { findings.push(finding("parity-content-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @typedef {{level:string, rule:string, file:string, message:string, fix:string}} ParityFinding */
|
|
269
|
+
/** @typedef {{asset:string, file:string}} DecodeAsset */
|
|
270
|
+
/** @typedef {{tenantId?: string}} VisualParityOptions */
|
|
271
|
+
/** Validate the portable content closure required before human visual-parity review.
|
|
272
|
+
* @param {string} tenantDir
|
|
273
|
+
* @param {VisualParityOptions} [options]
|
|
274
|
+
*/
|
|
275
|
+
export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
276
|
+
const { tenantId } = options;
|
|
277
|
+
/** @type {ParityFinding[]} */
|
|
278
|
+
const findings = [];
|
|
279
|
+
const { value: config, findings: configFindings } = parseConfig(tenantDir);
|
|
280
|
+
findings.push(...configFindings);
|
|
281
|
+
const pagesDir = join(tenantDir, "content", "pages");
|
|
282
|
+
const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
|
|
283
|
+
if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
|
|
284
|
+
const pages = [];
|
|
285
|
+
for (const path of files) {
|
|
286
|
+
const file = relative(tenantDir, path);
|
|
287
|
+
try {
|
|
288
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
289
|
+
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
290
|
+
findings.push(finding("parity-page-shape", file, "page JSON must be an object", "write a page object with a non-empty slug")); continue;
|
|
291
|
+
}
|
|
292
|
+
findings.push(...pageShape(value, file));
|
|
293
|
+
if (typeof value.slug !== "string" || !value.slug.trim()) continue;
|
|
294
|
+
pages.push({ file, value });
|
|
295
|
+
} catch { findings.push(finding("parity-page-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); }
|
|
296
|
+
}
|
|
297
|
+
const routes = new Set(["/"]);
|
|
298
|
+
for (const page of pages) routes.add(normalizeRoute(page.value.slug));
|
|
299
|
+
/** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
|
|
300
|
+
const context = { findings, tenantId: tenantId ?? tenantFromConfig(tenantDir), publicDir: join(tenantDir, "public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
|
|
301
|
+
if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
|
|
302
|
+
for (const page of pages) inspectValue(page.value, page.file, [], context);
|
|
303
|
+
for (const name of ["home.json", "chrome.json"]) {
|
|
304
|
+
const path = join(tenantDir, "content", name);
|
|
305
|
+
if (existsSync(path)) {
|
|
306
|
+
const value = readStructured(path, `content/${name}`, findings);
|
|
307
|
+
if (value != null) inspectValue(value, `content/${name}`, [], context);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const themePath = join(tenantDir, "theme.json");
|
|
311
|
+
if (existsSync(themePath)) {
|
|
312
|
+
const value = readStructured(themePath, "theme.json", findings);
|
|
313
|
+
if (value != null) inspectValue(value, "theme.json", [], context);
|
|
314
|
+
}
|
|
315
|
+
for (const index of decodeImages(context.decodeAssets)) {
|
|
316
|
+
const entry = context.decodeAssets[index];
|
|
317
|
+
if (entry) findings.push(finding("parity-asset-decode", entry.file, "image decoder could not produce nonzero pixels", "replace the image with a decodable file"));
|
|
318
|
+
}
|
|
319
|
+
findings.sort((a, b) => `${a.file}\0${a.rule}`.localeCompare(`${b.file}\0${b.rule}`));
|
|
320
|
+
return { ok: findings.length === 0, profile: "visual-parity", findings };
|
|
321
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./blog-visibility": "./src/blog-visibility.mjs",
|
|
13
|
+
"./publication-redirects": "./src/publication-redirects.mjs",
|
|
14
|
+
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
13
15
|
"./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
|
|
14
16
|
"./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
|
|
15
17
|
},
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"mountPath": {
|
|
183
183
|
"allOf": [
|
|
184
184
|
{ "$ref": "#/$defs/path" },
|
|
185
|
-
{ "type": "string", "pattern": "^/(?!(?:\\.
|
|
185
|
+
{ "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
|
|
186
186
|
]
|
|
187
187
|
},
|
|
188
188
|
"aliasPath": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"mountPath": {
|
|
68
68
|
"allOf": [
|
|
69
69
|
{ "$ref": "#/$defs/normalizedPath" },
|
|
70
|
-
{ "type": "string", "pattern": "^/(?!(?:\\.
|
|
70
|
+
{ "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
|
|
71
71
|
]
|
|
72
72
|
},
|
|
73
73
|
"aliasPath": {
|
|
@@ -180,10 +180,16 @@ export interface ChromeAnnouncement {
|
|
|
180
180
|
* components/islands/IsolatedAgeGate.tsx uses for component pages — so tenant
|
|
181
181
|
* CSS/JS can't reach the dialog.
|
|
182
182
|
*/
|
|
183
|
+
export const CHROME_AGE_GATE_VARIANTS = ["default", "compact-modal"] as const;
|
|
184
|
+
export type ChromeAgeGateVariant = (typeof CHROME_AGE_GATE_VARIANTS)[number];
|
|
185
|
+
|
|
183
186
|
export interface ChromeAgeGate {
|
|
187
|
+
/** Raw-chrome fallback; component storefront enforcement uses TenantConfig.compliance.minAge. */
|
|
184
188
|
minAge: number;
|
|
185
|
-
/**
|
|
189
|
+
/** Raw-chrome fallback persistence key; the component storefront derives its own scoped key. */
|
|
186
190
|
storageKey?: string;
|
|
191
|
+
/** Component and raw-chrome visual presentation. */
|
|
192
|
+
variant?: ChromeAgeGateVariant;
|
|
187
193
|
title?: string;
|
|
188
194
|
lede?: string;
|
|
189
195
|
yesLabel?: string;
|
|
@@ -195,6 +201,16 @@ export interface ChromeAgeGate {
|
|
|
195
201
|
mark?: { text: string; className?: string }[];
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
/** Platform-owned persistence scope for a tenant's enforced age affirmation. */
|
|
205
|
+
export function deriveAgeGateStorageKey(tenantId: string, minAge: number): string {
|
|
206
|
+
return `tot:age-affirmed:${encodeURIComponent(tenantId)}:${minAge}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Resolve the enforcement-owned age token inside governed presentation copy. */
|
|
210
|
+
export function resolveAgeGateCopy(value: string | undefined, minAge: number): string | undefined {
|
|
211
|
+
return value?.replaceAll("{{minAge}}", String(minAge));
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
/** Footer brand block: identity + tagline/contact + optional inline newsletter. */
|
|
199
215
|
export interface ChromeFooterBrand {
|
|
200
216
|
logo?: ChromeLogo;
|
|
@@ -228,14 +244,12 @@ export interface ChromeHead {
|
|
|
228
244
|
// existing members.
|
|
229
245
|
// ---------------------------------------------------------------------------
|
|
230
246
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
233
|
-
export const HEADER_VARIANTS = ["primary", "minimal"] as const;
|
|
247
|
+
/** Header content shapes: full, minimal, or source-parity compact commerce chrome. */
|
|
248
|
+
export const HEADER_VARIANTS = ["primary", "minimal", "compact-centered"] as const;
|
|
234
249
|
export type HeaderVariant = (typeof HEADER_VARIANTS)[number];
|
|
235
250
|
|
|
236
|
-
/** Footer content shapes.
|
|
237
|
-
|
|
238
|
-
export const FOOTER_VARIANTS = ["default"] as const;
|
|
251
|
+
/** Footer content shapes. The string union keeps presentation explicit and validated. */
|
|
252
|
+
export const FOOTER_VARIANTS = ["default", "light-collapsible"] as const;
|
|
239
253
|
export type FooterVariant = (typeof FOOTER_VARIANTS)[number];
|
|
240
254
|
|
|
241
255
|
export function isHeaderVariant(v: unknown): v is HeaderVariant {
|
|
@@ -503,6 +517,29 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
|
|
|
503
517
|
if (typeof v.minAge !== "number" || !Number.isFinite(v.minAge)) {
|
|
504
518
|
errors.push(`${path}.minAge: required number`);
|
|
505
519
|
}
|
|
520
|
+
checkOptionalString(v.storageKey, `${path}.storageKey`, errors);
|
|
521
|
+
if (
|
|
522
|
+
v.variant !== undefined &&
|
|
523
|
+
!CHROME_AGE_GATE_VARIANTS.includes(v.variant as ChromeAgeGateVariant)
|
|
524
|
+
) {
|
|
525
|
+
errors.push(`${path}.variant: must be one of ${CHROME_AGE_GATE_VARIANTS.join(", ")}`);
|
|
526
|
+
}
|
|
527
|
+
for (const field of ["title", "lede", "yesLabel", "noLabel", "denyText", "fineText"] as const) {
|
|
528
|
+
checkOptionalString(v[field], `${path}.${field}`, errors);
|
|
529
|
+
}
|
|
530
|
+
if (v.mark !== undefined) {
|
|
531
|
+
if (!Array.isArray(v.mark)) {
|
|
532
|
+
errors.push(`${path}.mark: must be an array`);
|
|
533
|
+
} else {
|
|
534
|
+
v.mark.forEach((part, i) => {
|
|
535
|
+
if (!isObject(part) || !isNonEmptyString(part.text)) {
|
|
536
|
+
errors.push(`${path}.mark[${i}].text: required non-empty string`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
checkOptionalString(part.className, `${path}.mark[${i}].className`, errors);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
506
543
|
return errors.length === before;
|
|
507
544
|
}
|
|
508
545
|
|
|
@@ -26,6 +26,17 @@ export interface CspOptions {
|
|
|
26
26
|
connectSrc?: readonly string[];
|
|
27
27
|
/** Sandboxed-widget origins. */
|
|
28
28
|
frameSrc?: readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* Extra origins allowed to frame this response, beyond `'self'`. Used only for
|
|
31
|
+
* the native GA4 sandbox's paired apex/www alias — the sandbox mounts on the
|
|
32
|
+
* OTHER of the tenant's apex/www hosts (see resolveNativeGa4SandboxUrl), so that
|
|
33
|
+
* host must be allowed to frame it back; `frame-src` alone only grants the
|
|
34
|
+
* reverse direction (who the page may frame), not this one. Must be computed
|
|
35
|
+
* from the platform's own request context, never from tenant/adapter-declared
|
|
36
|
+
* config — csp-compiler.ts deliberately keeps integration-plane additions off
|
|
37
|
+
* this directive.
|
|
38
|
+
*/
|
|
39
|
+
frameAncestors?: readonly string[];
|
|
29
40
|
/**
|
|
30
41
|
* Extra `form-action` targets. Default is `'self'`; add-to-cart forms POST
|
|
31
42
|
* cross-origin to the checkout store's `/cart`, so that origin is added here.
|
|
@@ -43,7 +54,7 @@ export function buildCsp(opts: CspOptions): string {
|
|
|
43
54
|
"default-src": ["'self'"],
|
|
44
55
|
"base-uri": ["'self'"],
|
|
45
56
|
"object-src": ["'none'"],
|
|
46
|
-
"frame-ancestors": ["'self'"],
|
|
57
|
+
"frame-ancestors": ["'self'", ...(opts.frameAncestors ?? [])],
|
|
47
58
|
"form-action": ["'self'", ...(opts.formAction ?? [])],
|
|
48
59
|
"script-src": ["'self'", nonce, ...(opts.scriptSrc ?? [])],
|
|
49
60
|
"style-src": ["'self'", nonce, ...(opts.styleSrc ?? [])],
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const BLOCK_PALETTE_CURRENT_CONTRACT: "block-palette@6";
|
|
2
|
+
export declare const BLOCK_PALETTE_SUPPORTED_CONTRACTS: readonly [
|
|
3
|
+
"block-palette@1",
|
|
4
|
+
"block-palette@2",
|
|
5
|
+
"block-palette@3",
|
|
6
|
+
"block-palette@4",
|
|
7
|
+
"block-palette@5",
|
|
8
|
+
"block-palette@6",
|
|
9
|
+
];
|
|
10
|
+
export declare const FEATURED_PRODUCT_PRESENTATIONS: readonly ["grid", "carousel"];
|
|
11
|
+
export declare const PROMO_TILE_PRESENTATIONS: readonly [
|
|
12
|
+
"overlay",
|
|
13
|
+
"portrait-cards",
|
|
14
|
+
"editorial-cards",
|
|
15
|
+
"logo-strip",
|
|
16
|
+
];
|
|
17
|
+
export declare function isSafeRehostedAssetPath(value: unknown): value is string;
|
|
18
|
+
export declare function isSafeRehostedAssetPathForTenant(value: unknown, tenantId: unknown): value is string;
|
|
19
|
+
export declare function isSafeStorefrontHref(value: unknown): value is string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime values for the storefront block-palette contract.
|
|
3
|
+
*
|
|
4
|
+
* Plain JavaScript keeps one dependency-free authority consumable by the edge
|
|
5
|
+
* runtime and repository Node tooling. The separately published CLI mirrors
|
|
6
|
+
* these values and pins that mirror with a parity test.
|
|
7
|
+
*/
|
|
8
|
+
export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@6";
|
|
9
|
+
|
|
10
|
+
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
11
|
+
"block-palette@1",
|
|
12
|
+
"block-palette@2",
|
|
13
|
+
"block-palette@3",
|
|
14
|
+
"block-palette@4",
|
|
15
|
+
"block-palette@5",
|
|
16
|
+
BLOCK_PALETTE_CURRENT_CONTRACT,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
|
|
20
|
+
|
|
21
|
+
export const PROMO_TILE_PRESENTATIONS = Object.freeze([
|
|
22
|
+
"overlay",
|
|
23
|
+
"portrait-cards",
|
|
24
|
+
"editorial-cards",
|
|
25
|
+
"logo-strip",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** True only for static assets materialized into a governed serving namespace. */
|
|
29
|
+
export function isSafeRehostedAssetPath(value) {
|
|
30
|
+
if (typeof value !== "string" || !value.startsWith("/")) return false;
|
|
31
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
|
|
32
|
+
let decoded;
|
|
33
|
+
try {
|
|
34
|
+
decoded = decodeURIComponent(value);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
|
|
39
|
+
return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Bind a tenant asset reference to the tenant currently being rendered. */
|
|
43
|
+
export function isSafeRehostedAssetPathForTenant(value, tenantId) {
|
|
44
|
+
if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
|
|
45
|
+
return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Navigation authored in a commerce block stays on the current storefront. */
|
|
49
|
+
export function isSafeStorefrontHref(value) {
|
|
50
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
|
|
51
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
52
|
+
try {
|
|
53
|
+
const decoded = decodeURIComponent(value);
|
|
54
|
+
if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
|
|
55
|
+
const segments = value.split("/");
|
|
56
|
+
return !segments.some((segment, index) =>
|
|
57
|
+
index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -6,14 +6,25 @@
|
|
|
6
6
|
* used by docs, validators, CLI checks, and the storefront app itself.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import {
|
|
10
|
+
BLOCK_PALETTE_SUPPORTED_CONTRACTS,
|
|
11
|
+
FEATURED_PRODUCT_PRESENTATIONS,
|
|
12
|
+
PROMO_TILE_PRESENTATIONS,
|
|
13
|
+
} from "./extension-contract-values.mjs";
|
|
14
|
+
export {
|
|
14
15
|
BLOCK_PALETTE_CURRENT_CONTRACT,
|
|
15
|
-
|
|
16
|
+
BLOCK_PALETTE_SUPPORTED_CONTRACTS,
|
|
17
|
+
FEATURED_PRODUCT_PRESENTATIONS,
|
|
18
|
+
PROMO_TILE_PRESENTATIONS,
|
|
19
|
+
isSafeRehostedAssetPath,
|
|
20
|
+
isSafeRehostedAssetPathForTenant,
|
|
21
|
+
isSafeStorefrontHref,
|
|
22
|
+
} from "./extension-contract-values.mjs";
|
|
23
|
+
|
|
24
|
+
export type PromoTilePresentation =
|
|
25
|
+
(typeof PROMO_TILE_PRESENTATIONS)[number];
|
|
16
26
|
|
|
27
|
+
/** The versioned contract accepted by the platform block palette. */
|
|
17
28
|
export type BlockPaletteContract =
|
|
18
29
|
(typeof BLOCK_PALETTE_SUPPORTED_CONTRACTS)[number];
|
|
19
30
|
|
|
@@ -27,6 +38,9 @@ export const FEATURED_PRODUCT_SOURCE_KEYS = [
|
|
|
27
38
|
export type FeaturedProductSourceKey =
|
|
28
39
|
(typeof FEATURED_PRODUCT_SOURCE_KEYS)[number];
|
|
29
40
|
|
|
41
|
+
export type FeaturedProductPresentation =
|
|
42
|
+
(typeof FEATURED_PRODUCT_PRESENTATIONS)[number];
|
|
43
|
+
|
|
30
44
|
export type ProductSectionSource =
|
|
31
45
|
| FeaturedProductSourceKey
|
|
32
46
|
| { collection: string }
|
|
@@ -17,6 +17,14 @@ export * from "./product.js";
|
|
|
17
17
|
export * from "./tenant.js";
|
|
18
18
|
export * from "./publication-space.js";
|
|
19
19
|
export * from "./blog-visibility.mjs";
|
|
20
|
+
// Per-tenant build-time embed defaults (tenant-embed-defaults.mjs) are deliberately
|
|
21
|
+
// NOT re-exported here: this barrel is inside the v84 clean-clone ATTESTED source
|
|
22
|
+
// graph, so an `export *` widens that graph's membership and takes a human-review
|
|
23
|
+
// gate (`repin-v84-clean-clone.mjs` refuses a membership change, correctly). The
|
|
24
|
+
// module is reached by its own subpath export instead —
|
|
25
|
+
// `@tot/public-runtime/tenant-embed-defaults` — which keeps the attestation surface
|
|
26
|
+
// unchanged. Adding a convenience re-export here would cost an attestation review
|
|
27
|
+
// for nothing.
|
|
20
28
|
export * from "./publication-routing.js";
|
|
21
29
|
export * from "./publication-version.js";
|
|
22
30
|
export * from "./publication-artifact.js";
|