@tokenoftrust/storefront-runner 2.2.126 → 2.3.1
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/package.json +3 -1
- package/apps/storefront/public/shared/commerce-chrome.css +57 -15
- package/apps/storefront/public/shared/commerce-marketing.css +7 -7
- package/apps/storefront/src/components/Badge.astro +1 -1
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
- package/apps/storefront/src/components/Section.astro +4 -1
- package/apps/storefront/src/components/Seo.astro +7 -5
- package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
- package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
- package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
- package/apps/storefront/src/components/content/ProseSections.astro +2 -2
- package/apps/storefront/src/components/home/Hero.astro +34 -12
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
- package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
- package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
- package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
- package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
- package/apps/storefront/src/components/marketing/Faq.astro +1 -1
- package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
- package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
- package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
- package/apps/storefront/src/components/marketing/Steps.astro +1 -1
- package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
- package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
- package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
- package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
- package/apps/storefront/src/components/plp/Pagination.astro +1 -1
- package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
- package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
- package/apps/storefront/src/config/devTenantSeed.ts +23 -34
- package/apps/storefront/src/config/resolver.ts +0 -38
- package/apps/storefront/src/layouts/Layout.astro +8 -1
- package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
- package/apps/storefront/src/lib/blog/pagination.ts +20 -10
- package/apps/storefront/src/lib/blog/presentation.ts +94 -0
- package/apps/storefront/src/lib/blog/rss.ts +1 -1
- package/apps/storefront/src/lib/blog/types.ts +24 -7
- package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +26 -3
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
- package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
- package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
- package/apps/storefront/src/lib/jsonld.ts +5 -5
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
- package/apps/storefront/src/lib/rawChrome.ts +74 -12
- package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
- package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
- package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
- package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
- package/apps/storefront/src/pages/[...slug].astro +13 -8
- package/apps/storefront/src/pages/blog/[slug].astro +56 -24
- package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
- package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
- package/apps/storefront/src/pages/collections/[handle].astro +5 -4
- package/apps/storefront/src/pages/collections/index.astro +4 -3
- package/apps/storefront/src/pages/index.astro +34 -27
- package/apps/storefront/src/pages/products/[handle].astro +5 -4
- package/apps/storefront/src/pages/saved.astro +4 -3
- package/apps/storefront/src/pages/search.astro +4 -3
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
- package/apps/storefront/src/pages/style-guide/index.astro +6 -5
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
- package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
- package/apps/storefront/src/pages/the-build/index.astro +2 -0
- package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
- package/apps/storefront/src/styles/admin.css +47 -0
- package/apps/storefront/src/styles/global.css +104 -56
- package/apps/storefront/src/themes/derivedTokens.ts +34 -0
- package/apps/storefront/src/themes/schema.ts +6 -0
- package/apps/storefront/test/pipeline/harness.ts +164 -0
- package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
- package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
- package/apps/storefront/vitest.workers.config.cts +67 -0
- package/docs/widget-library-guide.md +1496 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +400 -5
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/checkout.ts +13 -0
- package/packages/public-runtime/src/chrome.ts +93 -2
- package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
- package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
- package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +48 -4
- package/pnpm-lock.runner.yaml +469 -0
- package/scripts/tenant/validate.mjs +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
export const TOT_CONFIG_KEYS = [
|
|
19
19
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
20
|
-
"siteType", "compliance", "capabilities", "features", "embeds",
|
|
20
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds",
|
|
21
21
|
];
|
|
22
22
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
23
23
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -59,6 +59,99 @@ const ERROR = "error";
|
|
|
59
59
|
const WARN = "warn";
|
|
60
60
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
|
|
64
|
+
* `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
|
|
65
|
+
* exactly what the reconcile fails. Mirrors
|
|
66
|
+
* packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
|
|
67
|
+
* test/declared-config-parity.test.mjs pins the two together.
|
|
68
|
+
*/
|
|
69
|
+
export function isRegulatedCompliance(compliance) {
|
|
70
|
+
const minAge = compliance?.minAge;
|
|
71
|
+
return (typeof minAge === "number" && minAge > 0) || !!compliance?.nicotineWarning;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function declaresJurisdictionNotice(compliance) {
|
|
75
|
+
const text = compliance?.shippingRestriction;
|
|
76
|
+
return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Record<string, any> | null | undefined} compliance
|
|
81
|
+
* @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
|
|
82
|
+
*/
|
|
83
|
+
export function complianceCoherenceFailures(compliance) {
|
|
84
|
+
/** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
|
|
85
|
+
const out = {};
|
|
86
|
+
if (!isRegulatedCompliance(compliance)) return out;
|
|
87
|
+
const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
|
|
88
|
+
if (minAge == null || minAge < 18) {
|
|
89
|
+
out.age_gate = {
|
|
90
|
+
field: "compliance.minAge",
|
|
91
|
+
detail: "Age-restricted/nicotine products require `compliance.minAge` (18 or higher) — none is set.",
|
|
92
|
+
hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (compliance?.nicotineWarning && (minAge == null || minAge < 21)) {
|
|
96
|
+
out.excise = {
|
|
97
|
+
field: "compliance.minAge",
|
|
98
|
+
detail: "Nicotine products (`compliance.nicotineWarning`) require `compliance.minAge` of 21 or higher.",
|
|
99
|
+
hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!declaresJurisdictionNotice(compliance)) {
|
|
103
|
+
out.jurisdiction = {
|
|
104
|
+
field: "compliance.pactAct",
|
|
105
|
+
detail:
|
|
106
|
+
"Regulated products require a shipping/jurisdiction notice: set `compliance.pactAct: true` " +
|
|
107
|
+
"or `compliance.shippingRestriction` — neither is set.",
|
|
108
|
+
hint:
|
|
109
|
+
"Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
|
|
110
|
+
"`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The compliance findings `tot validate` reports for a declared block: an ERROR per
|
|
118
|
+
* coherence failure the hosted preview would fail (`evidence-<check>`), and a WARNING
|
|
119
|
+
* when a regulated store declares no `ruleProfile` — its preview renders, but going
|
|
120
|
+
* live refuses because no checkout rules resolve.
|
|
121
|
+
* @param {Record<string, any>} config
|
|
122
|
+
* @param {string} file
|
|
123
|
+
* @returns {Finding[]}
|
|
124
|
+
*/
|
|
125
|
+
export function complianceFindings(config, file = ".tot/config.json") {
|
|
126
|
+
const compliance = isObject(config?.compliance) ? config.compliance : null;
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
|
|
129
|
+
out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
|
|
130
|
+
`${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
|
|
131
|
+
}
|
|
132
|
+
// The hosted reconcile refuses a declaration whose `capabilities` switch off the
|
|
133
|
+
// compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
|
|
134
|
+
const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
|
|
135
|
+
if (compliance?.minAge && capabilities.ageVerification?.enabled === false) {
|
|
136
|
+
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
137
|
+
"`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
|
|
138
|
+
"Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
|
|
139
|
+
}
|
|
140
|
+
if (compliance?.nicotineWarning && capabilities.exciseTax?.enabled === false) {
|
|
141
|
+
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
142
|
+
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` — the hosted preview refuses this revision.",
|
|
143
|
+
"Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
|
|
144
|
+
}
|
|
145
|
+
if (isRegulatedCompliance(compliance) && !compliance?.ruleProfile) {
|
|
146
|
+
out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
|
|
147
|
+
"This store is regulated (`compliance.minAge`/`compliance.nicotineWarning`) but declares no " +
|
|
148
|
+
"`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
|
|
149
|
+
"and going live refuses.",
|
|
150
|
+
'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
62
155
|
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
63
156
|
function mk(level, rule, file, message, fix) {
|
|
64
157
|
return { level, rule, file, message, fix };
|
|
@@ -160,12 +253,30 @@ export function droppedMandatedNotices(previous, current) {
|
|
|
160
253
|
.map((key) => ({ key, notice: COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (key)] }));
|
|
161
254
|
}
|
|
162
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The FDA nicotine warning presentation this revision would put live that the live site does
|
|
258
|
+
* not already show: a custom (non-preset) presentation whose resolved values differ from the
|
|
259
|
+
* live revision's. Null when the warning is off, invalid, a validated preset, or unchanged.
|
|
260
|
+
* @param {Record<string, any> | null | undefined} current
|
|
261
|
+
* @param {Record<string, any> | null | undefined} previous
|
|
262
|
+
* @returns {string | null} the exact presentation, as describeNicotineWarningPresentation states it
|
|
263
|
+
*/
|
|
264
|
+
export function unreviewedNicotineWarningPresentation(current, previous) {
|
|
265
|
+
const now = resolveNicotineWarning(isObject(current?.compliance) ? current.compliance.nicotineWarning : undefined);
|
|
266
|
+
if (!now.enabled || !now.custom) return null;
|
|
267
|
+
const before = resolveNicotineWarning(isObject(previous?.compliance) ? previous.compliance.nicotineWarning : undefined);
|
|
268
|
+
const spec = describeNicotineWarningPresentation(now.presentation);
|
|
269
|
+
return before.enabled && before.custom && describeNicotineWarningPresentation(before.presentation) === spec ? null : spec;
|
|
270
|
+
}
|
|
271
|
+
|
|
163
272
|
/**
|
|
164
273
|
* The publication validator's findings for a revision's declaration: an owner-approvable
|
|
165
|
-
* `CONFIG:` finding per unknown key
|
|
166
|
-
* live site shows and this revision drops
|
|
167
|
-
*
|
|
168
|
-
*
|
|
274
|
+
* `CONFIG:` finding per unknown key or invalid nicotine-warning presentation, a `COMPLIANCE:`
|
|
275
|
+
* finding per mandated notice the live site shows and this revision drops, and a
|
|
276
|
+
* `COMPLIANCE:` finding for a custom nicotine-warning presentation the live site does not
|
|
277
|
+
* already show. All are QUALITY findings (scripts/publish/lib/finding-severity.mjs):
|
|
278
|
+
* publication halts until the owner accepts them through "Accept issue & continue", which
|
|
279
|
+
* records who accepted these exact findings, when, and why.
|
|
169
280
|
* @param {Record<string, any> | null | undefined} current
|
|
170
281
|
* @param {Record<string, any> | null | undefined} previous
|
|
171
282
|
* @returns {string[]}
|
|
@@ -174,6 +285,10 @@ export function declarationFindings(current, previous) {
|
|
|
174
285
|
const out = [];
|
|
175
286
|
if (isObject(current)) {
|
|
176
287
|
for (const unknown of unknownConfigKeys(current)) out.push(`CONFIG: ${unknownKeyMessage(unknown)}`);
|
|
288
|
+
const warning = resolveNicotineWarning(isObject(current.compliance) ? current.compliance.nicotineWarning : undefined);
|
|
289
|
+
if (warning.enabled && warning.problems.length > 0) {
|
|
290
|
+
out.push(`CONFIG: ${nicotineWarningProblemMessage(warning.problems)}`);
|
|
291
|
+
}
|
|
177
292
|
}
|
|
178
293
|
for (const { key, notice } of droppedMandatedNotices(previous, current)) {
|
|
179
294
|
out.push(
|
|
@@ -181,9 +296,22 @@ export function declarationFindings(current, previous) {
|
|
|
181
296
|
`declares it — publishing removes a mandated notice from the store.`,
|
|
182
297
|
);
|
|
183
298
|
}
|
|
299
|
+
const custom = unreviewedNicotineWarningPresentation(current, previous);
|
|
300
|
+
if (custom) {
|
|
301
|
+
out.push(
|
|
302
|
+
`COMPLIANCE: the FDA nicotine warning uses a custom presentation outside the validated presets — ${custom}. ` +
|
|
303
|
+
`Approving records that you reviewed exactly these values; changing any of them asks again.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
184
306
|
return out;
|
|
185
307
|
}
|
|
186
308
|
|
|
309
|
+
/** The owner-facing sentence for an invalid `compliance.nicotineWarning`. */
|
|
310
|
+
function nicotineWarningProblemMessage(problems) {
|
|
311
|
+
return `\`compliance.nicotineWarning\` is not a valid presentation (${problems.join("; ")}). ` +
|
|
312
|
+
`The warning renders in the default "dark" preset instead.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
187
315
|
/**
|
|
188
316
|
* The validator's report on a `.tot/config.json`: a WARNING per unknown key (owner-approved
|
|
189
317
|
* before publication), an error per wrong-shaped value of a known key.
|
|
@@ -202,6 +330,10 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
202
330
|
? `rename it to \`${unknown.suggestion}\`, or keep it and approve the warning when you publish`
|
|
203
331
|
: "remove it, or keep it and approve the warning when you publish"));
|
|
204
332
|
}
|
|
333
|
+
if (config.displayName !== undefined && !(typeof config.displayName === "string" && config.displayName.trim())) {
|
|
334
|
+
out.push(mk(ERROR, "config-display-name", file,
|
|
335
|
+
"`displayName` must be a non-empty string — the store's name as visitors read it (page titles, og:site_name, the copyright line)"));
|
|
336
|
+
}
|
|
205
337
|
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
206
338
|
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
207
339
|
}
|
|
@@ -210,6 +342,18 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
210
342
|
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
211
343
|
}
|
|
212
344
|
}
|
|
345
|
+
out.push(...complianceFindings(config, file));
|
|
346
|
+
const warning = resolveNicotineWarning(isObject(config.compliance) ? config.compliance.nicotineWarning : undefined);
|
|
347
|
+
if (warning.enabled && warning.problems.length > 0) {
|
|
348
|
+
out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
|
|
349
|
+
'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
|
|
350
|
+
} else if (warning.custom) {
|
|
351
|
+
out.push(mk(WARN, "config-nicotine-warning-custom", `${file} compliance`,
|
|
352
|
+
`\`compliance.nicotineWarning\` uses a custom presentation outside the validated presets — ` +
|
|
353
|
+
`${describeNicotineWarningPresentation(warning.presentation)}. It renders in preview; it goes live only ` +
|
|
354
|
+
`after you approve it in the go-live compliance review.`,
|
|
355
|
+
'use a preset (`"dark"`, `"light"`) to skip the review, or keep it and approve it when you publish'));
|
|
356
|
+
}
|
|
213
357
|
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
214
358
|
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
215
359
|
}
|
|
@@ -219,3 +363,254 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
219
363
|
}
|
|
220
364
|
return out;
|
|
221
365
|
}
|
|
366
|
+
|
|
367
|
+
// ── The FDA nicotine warning (21 CFR 1143.3): what compliance.nicotineWarning may declare ──
|
|
368
|
+
//
|
|
369
|
+
// The wording is fixed by law and is not configurable. The presentation is one of the
|
|
370
|
+
// validated presets, or a custom style built on one:
|
|
371
|
+
//
|
|
372
|
+
// "nicotineWarning": true → the "dark" preset
|
|
373
|
+
// "nicotineWarning": { "preset": "light" } → a validated preset
|
|
374
|
+
// "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
|
|
375
|
+
//
|
|
376
|
+
// Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
|
|
377
|
+
// review. A custom presentation is a deviation the owner approves at go-live:
|
|
378
|
+
// declarationFindings raises it as a COMPLIANCE finding that spells out every resolved style
|
|
379
|
+
// value, and the owner's "Accept issue & continue" records who approved exactly those values,
|
|
380
|
+
// when, and why. Any change to the styling produces a different finding, so it is reviewed again.
|
|
381
|
+
//
|
|
382
|
+
// The style keys are a closed set of colours, case, weight and size. Nothing here can position,
|
|
383
|
+
// hide, shrink below the floor, or dismiss the warning, and a declaration that is invalid renders
|
|
384
|
+
// the default preset rather than nothing. The hosted renderer (NicotineWarning.astro and the
|
|
385
|
+
// raw-HTML path) renders through nicotineWarningHtml, so every surface presents it identically.
|
|
386
|
+
|
|
387
|
+
export const NICOTINE_WARNING_TEXT =
|
|
388
|
+
"WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
|
|
389
|
+
|
|
390
|
+
/** The floor every presentation clears: WCAG AAA text contrast, a 12px minimum, semibold or heavier. */
|
|
391
|
+
export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fontWeight: 600 });
|
|
392
|
+
|
|
393
|
+
/** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
|
|
394
|
+
const MAX_FONT_SIZE_PX = 24;
|
|
395
|
+
|
|
396
|
+
export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The validated presets. `dark` is the platform default; `light` matches a light bar with a
|
|
400
|
+
* dark border and bold normal-case text.
|
|
401
|
+
* @type {Readonly<Record<string, Readonly<NicotineWarningPresentation>>>}
|
|
402
|
+
*/
|
|
403
|
+
export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
404
|
+
dark: Object.freeze({
|
|
405
|
+
background: "#111111",
|
|
406
|
+
text: "#ffffff",
|
|
407
|
+
border: "#111111",
|
|
408
|
+
textTransform: "uppercase",
|
|
409
|
+
fontWeight: 600,
|
|
410
|
+
fontSizePx: 12,
|
|
411
|
+
}),
|
|
412
|
+
light: Object.freeze({
|
|
413
|
+
background: "#e6e6e6",
|
|
414
|
+
text: "#000000",
|
|
415
|
+
border: "#000000",
|
|
416
|
+
textTransform: "none",
|
|
417
|
+
fontWeight: 700,
|
|
418
|
+
fontSizePx: 14,
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @typedef {{
|
|
424
|
+
* background: string,
|
|
425
|
+
* text: string,
|
|
426
|
+
* border: string,
|
|
427
|
+
* textTransform: "uppercase" | "none",
|
|
428
|
+
* fontWeight: number,
|
|
429
|
+
* fontSizePx: number,
|
|
430
|
+
* }} NicotineWarningPresentation
|
|
431
|
+
*/
|
|
432
|
+
|
|
433
|
+
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
434
|
+
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
435
|
+
]);
|
|
436
|
+
const TEXT_TRANSFORMS = ["uppercase", "none"];
|
|
437
|
+
const FONT_WEIGHTS = [600, 700, 800, 900];
|
|
438
|
+
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
439
|
+
|
|
440
|
+
/** `#abc` / `#AABBCC` → `#aabbcc`. */
|
|
441
|
+
function normalizeHex(value) {
|
|
442
|
+
const hex = value.slice(1).toLowerCase();
|
|
443
|
+
return `#${hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function luminance(hex) {
|
|
447
|
+
const n = normalizeHex(hex);
|
|
448
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
449
|
+
const c = parseInt(n.slice(i, i + 2), 16) / 255;
|
|
450
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
451
|
+
});
|
|
452
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** WCAG 2 contrast ratio between two hex colours. */
|
|
456
|
+
export function contrastRatio(a, b) {
|
|
457
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
458
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Why a presentation is not conspicuous enough — empty when it clears the floor.
|
|
463
|
+
* @param {NicotineWarningPresentation} p
|
|
464
|
+
* @returns {string[]}
|
|
465
|
+
*/
|
|
466
|
+
export function conspicuityProblems(p) {
|
|
467
|
+
const out = [];
|
|
468
|
+
const ratio = contrastRatio(p.text, p.background);
|
|
469
|
+
if (ratio < CONSPICUITY_FLOOR.contrast) {
|
|
470
|
+
out.push(`text ${p.text} on background ${p.background} has contrast ${ratio.toFixed(2)}:1 — the floor is ${CONSPICUITY_FLOOR.contrast}:1`);
|
|
471
|
+
}
|
|
472
|
+
if (p.fontSizePx < CONSPICUITY_FLOOR.fontSizePx) {
|
|
473
|
+
out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
|
|
474
|
+
}
|
|
475
|
+
if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
|
|
476
|
+
out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor`);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Why one declared style value is not usable, or null. */
|
|
482
|
+
function styleValueProblem(key, value) {
|
|
483
|
+
switch (key) {
|
|
484
|
+
case "background":
|
|
485
|
+
case "text":
|
|
486
|
+
case "border":
|
|
487
|
+
return typeof value === "string" && HEX.test(value) ? null : `\`${key}\` must be a hex colour like "#e6e6e6"`;
|
|
488
|
+
case "textTransform":
|
|
489
|
+
return TEXT_TRANSFORMS.includes(value) ? null : `\`textTransform\` must be one of ${TEXT_TRANSFORMS.map((t) => `"${t}"`).join(", ")}`;
|
|
490
|
+
case "fontWeight":
|
|
491
|
+
return FONT_WEIGHTS.includes(value) ? null : `\`fontWeight\` must be one of ${FONT_WEIGHTS.join(", ")}`;
|
|
492
|
+
case "fontSizePx":
|
|
493
|
+
return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
|
|
494
|
+
? null
|
|
495
|
+
: `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
|
|
496
|
+
default:
|
|
497
|
+
return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function samePresentation(a, b) {
|
|
502
|
+
return NICOTINE_WARNING_STYLE_KEYS.every((k) => a[k] === b[k]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* @typedef {{
|
|
507
|
+
* enabled: boolean,
|
|
508
|
+
* presentation: NicotineWarningPresentation,
|
|
509
|
+
* preset: string | null,
|
|
510
|
+
* custom: boolean,
|
|
511
|
+
* problems: string[],
|
|
512
|
+
* }} ResolvedNicotineWarning
|
|
513
|
+
*/
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Resolve a `compliance.nicotineWarning` declaration to what renders.
|
|
517
|
+
*
|
|
518
|
+
* `preset` names the preset the presentation equals (null for a custom one); `custom` is
|
|
519
|
+
* true when the owner must review it; `problems` lists why the declaration is invalid, in
|
|
520
|
+
* which case the default preset renders. A disabled declaration (absent, `false`) resolves
|
|
521
|
+
* `enabled: false`.
|
|
522
|
+
* @param {unknown} declaration
|
|
523
|
+
* @returns {ResolvedNicotineWarning}
|
|
524
|
+
*/
|
|
525
|
+
export function resolveNicotineWarning(declaration) {
|
|
526
|
+
const fallback = NICOTINE_WARNING_PRESETS[DEFAULT_NICOTINE_WARNING_PRESET];
|
|
527
|
+
const resolved = (/** @type {Partial<ResolvedNicotineWarning>} */ fields) => ({
|
|
528
|
+
enabled: true,
|
|
529
|
+
presentation: { ...fallback },
|
|
530
|
+
preset: DEFAULT_NICOTINE_WARNING_PRESET,
|
|
531
|
+
custom: false,
|
|
532
|
+
problems: [],
|
|
533
|
+
...fields,
|
|
534
|
+
});
|
|
535
|
+
if (!declaration) return resolved({ enabled: false });
|
|
536
|
+
if (declaration === true) return resolved({});
|
|
537
|
+
if (!isObject(declaration)) {
|
|
538
|
+
return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
|
|
539
|
+
}
|
|
540
|
+
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } = /** @type {Record<string, unknown>} */ (declaration);
|
|
541
|
+
const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: preset, style)`);
|
|
542
|
+
const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
|
|
543
|
+
if (!base) {
|
|
544
|
+
problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
|
|
545
|
+
}
|
|
546
|
+
if (style !== undefined && !isObject(style)) problems.push("`style` must be an object");
|
|
547
|
+
const declaredStyle = isObject(style) ? /** @type {Record<string, unknown>} */ (style) : {};
|
|
548
|
+
for (const [key, value] of Object.entries(declaredStyle)) {
|
|
549
|
+
const problem = styleValueProblem(key, value);
|
|
550
|
+
if (problem) problems.push(problem);
|
|
551
|
+
}
|
|
552
|
+
if (problems.length > 0 || !base) return resolved({ problems });
|
|
553
|
+
|
|
554
|
+
/** @type {NicotineWarningPresentation} */
|
|
555
|
+
const presentation = { ...base };
|
|
556
|
+
for (const [key, value] of Object.entries(declaredStyle)) {
|
|
557
|
+
presentation[/** @type {keyof NicotineWarningPresentation} */ (key)] =
|
|
558
|
+
/** @type {never} */ (typeof value === "string" && HEX.test(value) ? normalizeHex(value) : value);
|
|
559
|
+
}
|
|
560
|
+
const conspicuity = conspicuityProblems(presentation);
|
|
561
|
+
if (conspicuity.length > 0) return resolved({ problems: conspicuity });
|
|
562
|
+
const matching = Object.keys(NICOTINE_WARNING_PRESETS).find((name) => samePresentation(NICOTINE_WARNING_PRESETS[name], presentation));
|
|
563
|
+
return resolved({ presentation, preset: matching ?? null, custom: !matching });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The exact, human-readable statement of a custom presentation — what the owner approves.
|
|
568
|
+
* Every resolved value is in it, so two presentations that differ in any value describe
|
|
569
|
+
* differently.
|
|
570
|
+
* @param {NicotineWarningPresentation} p
|
|
571
|
+
*/
|
|
572
|
+
export function describeNicotineWarningPresentation(p) {
|
|
573
|
+
return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
|
|
574
|
+
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The warning's markup — the one rendering shared by NicotineWarning.astro and the raw-HTML
|
|
579
|
+
* serve path, so both present the same declaration identically. "bar" is the strip at the top
|
|
580
|
+
* of every page; "block" is the reinforced box on the product page. Every interpolated value
|
|
581
|
+
* is a validated hex colour, enum or number. Styling is inline (no utility classes), so the
|
|
582
|
+
* markup renders the same wherever it is served, whatever stylesheet the page carries.
|
|
583
|
+
* @param {unknown} declaration the tenant's `compliance.nicotineWarning`
|
|
584
|
+
* @param {"bar" | "block"} variant
|
|
585
|
+
*/
|
|
586
|
+
export function nicotineWarningHtml(declaration, variant) {
|
|
587
|
+
const { presentation, preset } = resolveNicotineWarning(declaration);
|
|
588
|
+
const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
|
|
589
|
+
`data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
|
|
590
|
+
return variant === "bar"
|
|
591
|
+
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
592
|
+
: `<div ${attrs}><p style="margin:0">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Inline CSS for a presentation. `variant` "block" (the product page) never renders smaller
|
|
597
|
+
* than 14px.
|
|
598
|
+
* @param {NicotineWarningPresentation} p
|
|
599
|
+
* @param {"bar" | "block"} variant
|
|
600
|
+
*/
|
|
601
|
+
export function nicotineWarningStyle(p, variant) {
|
|
602
|
+
const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
|
|
603
|
+
const frame = variant === "bar"
|
|
604
|
+
? [`border-bottom:2px solid ${p.border}`]
|
|
605
|
+
: [`border:2px solid ${p.border}`, "border-radius:var(--radius-md)", "padding:0.75rem 1rem"];
|
|
606
|
+
return [
|
|
607
|
+
...frame,
|
|
608
|
+
`background-color:${p.background}`,
|
|
609
|
+
`color:${p.text}`,
|
|
610
|
+
`text-transform:${p.textTransform}`,
|
|
611
|
+
`letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
612
|
+
`font-weight:${p.fontWeight}`,
|
|
613
|
+
`font-size:${size}px`,
|
|
614
|
+
"line-height:1.4",
|
|
615
|
+
].join(";");
|
|
616
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./retention-holds": "./src/retention-holds.ts",
|
|
23
23
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
24
24
|
"./declared-tenant-config": "./src/declared-tenant-config.mjs",
|
|
25
|
+
"./compliance-coherence": "./src/compliance-coherence.mjs",
|
|
25
26
|
"./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
|
|
26
27
|
"./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
|
|
27
28
|
"./integration/visitor-region": "./src/integration/visitor-region.mjs"
|
|
@@ -59,6 +59,19 @@ export interface ManagedCheckoutConfig {
|
|
|
59
59
|
cartDrawerCss?: string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The ONE shared ToT-owned demo checkout store (test mode, cart validation
|
|
64
|
+
* OFF, no secret). The local dev loop (`synthesizeDevTenant`) wires every
|
|
65
|
+
* local commerce checkout to this exact config; the hosted QA/test tier's
|
|
66
|
+
* demo-checkout fallback (`resolveTenantCommerce`) reuses this same constant
|
|
67
|
+
* rather than a second copy of the literal, so "the same as `tot dev`" stays
|
|
68
|
+
* true by construction instead of by two authors agreeing.
|
|
69
|
+
*/
|
|
70
|
+
export const SHARED_DEMO_CHECKOUT: ManagedCheckoutConfig = {
|
|
71
|
+
storeSubDomain: "tot-preview",
|
|
72
|
+
mode: "unsigned-demo",
|
|
73
|
+
};
|
|
74
|
+
|
|
62
75
|
/** Resolved, request-scoped commerce facts stashed on `Astro.locals`. */
|
|
63
76
|
export interface ResolvedManagedCheckout extends ManagedCheckoutConfig {
|
|
64
77
|
/** `https://<storeDomain>` — the loader + cart origin (for CSP + links). */
|
|
@@ -141,6 +141,35 @@ export interface ChromeSearch {
|
|
|
141
141
|
placeholder?: string;
|
|
142
142
|
/** sr-only label (defaults to "Search"). */
|
|
143
143
|
label?: string;
|
|
144
|
+
/** Visible text on the submit button (e.g. "Search"). Omit for the icon button. */
|
|
145
|
+
buttonLabel?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a header action link stands for. "link" is a plain text link (e.g.
|
|
150
|
+
* "Register"); "account" and "cart" can show their icon, and a "cart" link is
|
|
151
|
+
* the cart affordance — bound to the managed checkout like the icon cart, with
|
|
152
|
+
* its item count.
|
|
153
|
+
*/
|
|
154
|
+
export const HEADER_ACTION_KINDS = ["link", "account", "cart"] as const;
|
|
155
|
+
export type HeaderActionKind = (typeof HEADER_ACTION_KINDS)[number];
|
|
156
|
+
|
|
157
|
+
/** A labelled link in the header's actions cluster (e.g. "Sign in", "Register", "Cart"). */
|
|
158
|
+
export type ChromeHeaderActionLink = ChromeLink & {
|
|
159
|
+
/** Defaults to "link". */
|
|
160
|
+
kind?: HeaderActionKind;
|
|
161
|
+
/** Show the kind's icon before the label ("account" / "cart" only). */
|
|
162
|
+
icon?: boolean;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Narrow-viewport header arrangement. Every field defaults to the standard layout. */
|
|
166
|
+
export interface ChromeHeaderMobile {
|
|
167
|
+
/** Side the menu button sits on (default "right"); "left" also opens the drawer from the left. */
|
|
168
|
+
menu?: "left" | "right";
|
|
169
|
+
/** Brand alignment in the bar (default "start"). */
|
|
170
|
+
brand?: "start" | "center";
|
|
171
|
+
/** Search placement (default "row", a full-width row under the bar); "menu" moves it into the menu drawer. */
|
|
172
|
+
search?: "row" | "menu";
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
/** The thin utility bar above the header: minor links, locale chips, social, cue. */
|
|
@@ -292,8 +321,18 @@ export interface ChromeHeader {
|
|
|
292
321
|
announcement?: ChromeAnnouncement;
|
|
293
322
|
/** Header-actions member-price cue (in addition to any utility-nav cue). */
|
|
294
323
|
memberCue?: ChromeActionLink;
|
|
324
|
+
/** Icon account link. Not rendered when `actionLinks` is set. */
|
|
295
325
|
accountHref?: string;
|
|
326
|
+
/** Icon cart link (bound to the managed checkout). Not rendered when `actionLinks` is set. */
|
|
296
327
|
cartHref?: string;
|
|
328
|
+
/**
|
|
329
|
+
* Labelled action links (text, optional icon) in the header's actions
|
|
330
|
+
* cluster, in order. When set they replace the `accountHref` / `cartHref`
|
|
331
|
+
* icons; a `kind: "cart"` entry is the cart affordance.
|
|
332
|
+
*/
|
|
333
|
+
actionLinks?: ChromeHeaderActionLink[];
|
|
334
|
+
/** Narrow-viewport arrangement (menu side, brand alignment, search placement). */
|
|
335
|
+
mobile?: ChromeHeaderMobile;
|
|
297
336
|
/** Marketing conversion CTAs (non-commerce tenants), shown in the header's
|
|
298
337
|
* actions cluster in place of the commerce search/saved controls. */
|
|
299
338
|
ctas?: ChromeCta[];
|
|
@@ -308,8 +347,14 @@ export interface ChromeFooter {
|
|
|
308
347
|
/** Accepted-payment marks (text tokens, e.g. "VISA"). */
|
|
309
348
|
payments?: string[];
|
|
310
349
|
social?: SocialLink[];
|
|
311
|
-
/** The
|
|
350
|
+
/** The compliance/nicotine warning block, always led by a bold "WARNING:" label. */
|
|
312
351
|
warning?: string;
|
|
352
|
+
/**
|
|
353
|
+
* The merchant's own legal/disclaimer copy (e.g. an FDA statement or PACT Act
|
|
354
|
+
* line), rendered verbatim with no label. Line breaks are kept. Never a
|
|
355
|
+
* substitute for `warning` or the declared compliance notices.
|
|
356
|
+
*/
|
|
357
|
+
disclaimer?: string;
|
|
313
358
|
/** Bottom bar: copyright + a legal note line. */
|
|
314
359
|
bottom?: { copyright?: string; note?: string };
|
|
315
360
|
}
|
|
@@ -325,6 +370,11 @@ export interface ChromeConfig {
|
|
|
325
370
|
head?: ChromeHead;
|
|
326
371
|
/** Skip-link target/label overrides (defaults to "#main" / "Skip to main content"). */
|
|
327
372
|
skipLink?: { href?: string; label?: string };
|
|
373
|
+
/**
|
|
374
|
+
* Breadcrumb trail copy. `homeLabel` names the first crumb on every trail — the visible one and
|
|
375
|
+
* the `BreadcrumbList` structured data alike (defaults to "Home").
|
|
376
|
+
*/
|
|
377
|
+
breadcrumbs?: { homeLabel?: string };
|
|
328
378
|
/** FDA/compliance warning strip at the very top (e.g. the nicotine line). */
|
|
329
379
|
warningBar?: string;
|
|
330
380
|
/**
|
|
@@ -543,6 +593,34 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
|
|
|
543
593
|
return errors.length === before;
|
|
544
594
|
}
|
|
545
595
|
|
|
596
|
+
function validateHeaderActionLink(v: unknown, path: string, errors: string[]): void {
|
|
597
|
+
if (!validateLink(v, path, errors)) return;
|
|
598
|
+
const link = v as Record<string, unknown>;
|
|
599
|
+
if (link.kind !== undefined && !(HEADER_ACTION_KINDS as readonly unknown[]).includes(link.kind)) {
|
|
600
|
+
errors.push(`${path}.kind: must be one of ${HEADER_ACTION_KINDS.join(", ")}`);
|
|
601
|
+
}
|
|
602
|
+
checkOptionalBoolean(link.icon, `${path}.icon`, errors);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const HEADER_MOBILE_OPTIONS = {
|
|
606
|
+
menu: ["left", "right"],
|
|
607
|
+
brand: ["start", "center"],
|
|
608
|
+
search: ["row", "menu"],
|
|
609
|
+
} as const;
|
|
610
|
+
|
|
611
|
+
function validateHeaderMobile(v: unknown, path: string, errors: string[]): void {
|
|
612
|
+
if (!isObject(v)) {
|
|
613
|
+
errors.push(`${path}: must be an object`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const [field, allowed] of Object.entries(HEADER_MOBILE_OPTIONS)) {
|
|
617
|
+
const value = v[field];
|
|
618
|
+
if (value !== undefined && !(allowed as readonly unknown[]).includes(value)) {
|
|
619
|
+
errors.push(`${path}.${field}: must be one of ${allowed.join(", ")}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
546
624
|
function validateHeader(v: unknown, path: string, errors: string[]): v is ChromeHeader {
|
|
547
625
|
if (!isObject(v)) {
|
|
548
626
|
errors.push(`${path}: required object`);
|
|
@@ -560,7 +638,15 @@ function validateHeader(v: unknown, path: string, errors: string[]): v is Chrome
|
|
|
560
638
|
} else {
|
|
561
639
|
v.nav.forEach((n, i) => validateNavItem(n, `${path}.nav[${i}]`, errors));
|
|
562
640
|
}
|
|
563
|
-
if (v.search !== undefined
|
|
641
|
+
if (v.search !== undefined) {
|
|
642
|
+
if (!isObject(v.search)) errors.push(`${path}.search: must be an object`);
|
|
643
|
+
else checkOptionalString(v.search.buttonLabel, `${path}.search.buttonLabel`, errors);
|
|
644
|
+
}
|
|
645
|
+
if (v.actionLinks !== undefined) {
|
|
646
|
+
if (!Array.isArray(v.actionLinks)) errors.push(`${path}.actionLinks: must be an array`);
|
|
647
|
+
else v.actionLinks.forEach((l, i) => validateHeaderActionLink(l, `${path}.actionLinks[${i}]`, errors));
|
|
648
|
+
}
|
|
649
|
+
if (v.mobile !== undefined) validateHeaderMobile(v.mobile, `${path}.mobile`, errors);
|
|
564
650
|
if (v.utilityNav !== undefined) {
|
|
565
651
|
const u = v.utilityNav;
|
|
566
652
|
if (!isObject(u)) {
|
|
@@ -604,6 +690,7 @@ function validateFooter(v: unknown, path: string, errors: string[]): v is Chrome
|
|
|
604
690
|
errors.push(`${path}.payments: must be an array of strings`);
|
|
605
691
|
}
|
|
606
692
|
checkOptionalString(v.warning, `${path}.warning`, errors);
|
|
693
|
+
checkOptionalString(v.disclaimer, `${path}.disclaimer`, errors);
|
|
607
694
|
if (v.brand !== undefined && !isObject(v.brand)) errors.push(`${path}.brand: must be an object`);
|
|
608
695
|
if (v.newsletter !== undefined && !isObject(v.newsletter)) errors.push(`${path}.newsletter: must be an object`);
|
|
609
696
|
if (v.bottom !== undefined && !isObject(v.bottom)) errors.push(`${path}.bottom: must be an object`);
|
|
@@ -634,6 +721,10 @@ export function validateChromeConfig(raw: unknown): ChromeConfigValidationResult
|
|
|
634
721
|
}
|
|
635
722
|
if (raw.head !== undefined && !isObject(raw.head)) errors.push("head: must be an object");
|
|
636
723
|
if (raw.skipLink !== undefined && !isObject(raw.skipLink)) errors.push("skipLink: must be an object");
|
|
724
|
+
if (raw.breadcrumbs !== undefined) {
|
|
725
|
+
if (!isObject(raw.breadcrumbs)) errors.push("breadcrumbs: must be an object");
|
|
726
|
+
else checkOptionalString(raw.breadcrumbs.homeLabel, "breadcrumbs.homeLabel", errors);
|
|
727
|
+
}
|
|
637
728
|
|
|
638
729
|
if (errors.length > 0) return { ok: false, config: null, errors };
|
|
639
730
|
return { ok: true, config: raw as unknown as ChromeConfig, errors: [] };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Types for the dependency-free compliance-coherence module (compliance-coherence.mjs). */
|
|
2
|
+
|
|
3
|
+
export type CoherenceCheckId = "age_gate" | "excise" | "jurisdiction";
|
|
4
|
+
|
|
5
|
+
export interface CoherenceFailure {
|
|
6
|
+
/** The `compliance.*` field that resolves the failure. */
|
|
7
|
+
field: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
hint: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const COHERENCE_CHECK_IDS: readonly CoherenceCheckId[];
|
|
13
|
+
export function isRegulatedCompliance(compliance: object | null | undefined): boolean;
|
|
14
|
+
export function declaresJurisdictionNotice(compliance: object | null | undefined): boolean;
|
|
15
|
+
export function complianceCoherenceFailures(
|
|
16
|
+
compliance: object | null | undefined,
|
|
17
|
+
): Partial<Record<CoherenceCheckId, CoherenceFailure>>;
|