@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.
Files changed (150) hide show
  1. package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
  2. package/apps/storefront/package.json +1 -0
  3. package/apps/storefront/platform-reconcile-tenants.json +3 -0
  4. package/apps/storefront/public/favicon.ico +0 -0
  5. package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +1 -1
  6. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  7. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  8. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  9. package/apps/storefront/src/components/Img.astro +6 -5
  10. package/apps/storefront/src/components/ProductCard.astro +6 -4
  11. package/apps/storefront/src/components/Seo.astro +2 -1
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  14. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  15. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  16. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  17. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  18. package/apps/storefront/src/components/home/Hero.astro +156 -4
  19. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  20. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  21. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  22. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  23. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  24. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  25. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  26. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  27. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  28. package/apps/storefront/src/config/tenants.ts +22 -0
  29. package/apps/storefront/src/layouts/Layout.astro +69 -44
  30. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  31. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  32. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  33. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  34. package/apps/storefront/src/lib/basePath.ts +70 -9
  35. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  36. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  37. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  38. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  39. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  40. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  41. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  42. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  43. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  44. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  45. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  46. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  47. package/apps/storefront/src/lib/diag/report.ts +109 -0
  48. package/apps/storefront/src/lib/diag/session.ts +93 -0
  49. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  50. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  51. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  52. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  53. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  54. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  55. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  56. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  57. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  58. package/apps/storefront/src/lib/previewReload.ts +53 -9
  59. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  60. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  63. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  64. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  65. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  66. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  67. package/apps/storefront/src/middleware/index.ts +13 -1
  68. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  69. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  70. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  71. package/apps/storefront/src/pages/[...slug].astro +6 -2
  72. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  73. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  74. package/apps/storefront/src/pages/cart.ts +23 -0
  75. package/apps/storefront/src/pages/index.astro +64 -85
  76. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  77. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  78. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  79. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  80. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  81. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  82. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  83. package/apps/storefront/src/styles/fonts.css +7 -0
  84. package/apps/storefront/src/styles/global.css +397 -3
  85. package/package.json +4 -1
  86. package/packages/cli/src/visual-parity.mjs +321 -0
  87. package/packages/public-runtime/package.json +2 -0
  88. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  89. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  90. package/packages/public-runtime/src/chrome.ts +44 -7
  91. package/packages/public-runtime/src/csp.ts +12 -1
  92. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  93. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  94. package/packages/public-runtime/src/extension-contract.ts +20 -6
  95. package/packages/public-runtime/src/index.ts +8 -0
  96. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  97. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  98. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  99. package/packages/public-runtime/src/integration/csp-compiler.ts +6 -3
  100. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  101. package/packages/public-runtime/src/integration/index.ts +1 -0
  102. package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  104. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  105. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
  108. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
  109. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
  110. package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
  111. package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
  112. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  113. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  114. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  115. package/packages/public-runtime/src/publication-space.ts +24 -3
  116. package/packages/public-runtime/src/static-bundle.ts +145 -26
  117. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  118. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  119. package/packages/public-runtime/src/tenant.ts +59 -1
  120. package/pnpm-lock.runner.yaml +12 -0
  121. package/pnpm-workspace.yaml +4 -0
  122. package/scripts/dev/git-status.mjs +1 -1
  123. package/scripts/dev/publish.mjs +6 -0
  124. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  125. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  126. package/scripts/tenant/validate.mjs +171 -8
  127. package/apps/storefront/src/lib/cfImage.ts +0 -55
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  129. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  145. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  146. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  147. package/tenants/home/public/js/storefront.js +0 -129
  148. package/tenants/home/public/logo-wordmark.png +0 -0
  149. package/tenants/home/public/pages/storefront.css +0 -235
  150. package/tenants/home/theme.json +0 -6
@@ -30,6 +30,15 @@ import { dirname, resolve, join, relative } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { KNOWN_EMBEDS, lintTenantEmbeds } from "./lint-embeds.mjs";
32
32
  import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
33
+ import {
34
+ BLOCK_PALETTE_SUPPORTED_CONTRACTS,
35
+ FEATURED_PRODUCT_PRESENTATIONS,
36
+ PROMO_TILE_PRESENTATIONS,
37
+ isSafeRehostedAssetPath,
38
+ isSafeRehostedAssetPathForTenant,
39
+ isSafeStorefrontHref,
40
+ } from "../../packages/public-runtime/src/extension-contract-values.mjs";
41
+ import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
33
42
 
34
43
  /** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
35
44
  const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
@@ -43,6 +52,8 @@ const NUL = String.fromCharCode(0);
43
52
  // --- findings model ----------------------------------------------------------
44
53
  const ERROR = "error";
45
54
  const WARN = "warn";
55
+ const mergeFindings = (...groups) => [...new Map(groups.flat().map((item) => [JSON.stringify(item), item])).values()]
56
+ .sort((a, b) => `${a.file}\0${a.rule}\0${a.message}`.localeCompare(`${b.file}\0${b.rule}\0${b.message}`));
46
57
  /** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
47
58
 
48
59
  function mk(level, rule, file, message, fix) {
@@ -166,8 +177,8 @@ function validateRawHtmlBody(html) {
166
177
  // `@tot/public-runtime` package at runtime — this is a copy. Parity test:
167
178
  // packages/public-runtime/tests/chrome-config-shape-parity.test.ts. Kept in
168
179
  // step with packages/cli/src/validate.mjs's identical copy.
169
- const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
170
- const CHROME_FOOTER_VARIANTS = new Set(["default"]);
180
+ const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal", "compact-centered"]);
181
+ const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible"]);
171
182
  /**
172
183
  * The declarative-interaction-tracking rule, mirrored: every actionable item —
173
184
  * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
@@ -302,10 +313,11 @@ function validateScriptsDoc(doc, file, config) {
302
313
  return out;
303
314
  }
304
315
 
305
- const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
316
+ const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
306
317
  const KNOWN_BLOCKS = new Set([
307
318
  "hero",
308
319
  "promo_tiles",
320
+ "trust_strip",
309
321
  "featured_collections",
310
322
  "featured_products",
311
323
  "membership_band",
@@ -325,6 +337,7 @@ const KNOWN_BLOCKS = new Set([
325
337
  const REQUIRED_BLOCK_PROPS = {
326
338
  hero: ["headline"],
327
339
  promo_tiles: ["tiles"],
340
+ trust_strip: ["items"],
328
341
  featured_collections: ["handles"],
329
342
  membership_band: ["tiers"],
330
343
  editorial: ["title"],
@@ -352,6 +365,7 @@ const PRODUCT_SOURCE_KEYS = new Set([
352
365
  "recentlyViewed",
353
366
  ]);
354
367
  const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
368
+ const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
355
369
 
356
370
  function validateProductSource(value, file, at) {
357
371
  const out = [];
@@ -387,7 +401,124 @@ function validateProductSource(value, file, at) {
387
401
  return out;
388
402
  }
389
403
 
390
- function validateHomeDoc(doc, file) {
404
+ function validateVisualAsset(value, file, at, out, tenantId) {
405
+ const safe = tenantId
406
+ ? isSafeRehostedAssetPathForTenant(value, tenantId)
407
+ : isSafeRehostedAssetPath(value);
408
+ if (!safe) {
409
+ out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
410
+ }
411
+ }
412
+
413
+ function validatePromoTiles(block, file, at, out, tenantId) {
414
+ if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
415
+ out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
416
+ }
417
+ if (!Array.isArray(block.tiles)) {
418
+ out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
419
+ return;
420
+ }
421
+ block.tiles.forEach((tile, index) => {
422
+ const tileAt = `${at}.tiles[${index}]`;
423
+ if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
424
+ out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
425
+ return;
426
+ }
427
+ for (const prop of ["label", "href"]) {
428
+ if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
429
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
430
+ }
431
+ }
432
+ for (const prop of ["imageAlt", "meta"]) {
433
+ if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
434
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
435
+ }
436
+ }
437
+ if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
438
+ if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
439
+ out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
440
+ }
441
+ if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
442
+ out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
443
+ }
444
+ });
445
+ if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
446
+ out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
447
+ }
448
+ for (const prop of ["ctaLabel", "ctaHref"]) {
449
+ if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
450
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
451
+ }
452
+ }
453
+ if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
454
+ out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
455
+ }
456
+ }
457
+
458
+ function validateHeroSlides(slides, file, at, out, tenantId) {
459
+ if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
460
+ slides.forEach((slide, index) => {
461
+ const slideAt = `${at}.slides[${index}]`;
462
+ if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
463
+ for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
464
+ validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
465
+ for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
466
+ if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
467
+ if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
468
+ });
469
+ }
470
+
471
+ function validateTrustStrip(block, file, at, out, tenantId) {
472
+ if (!Array.isArray(block.items)) {
473
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
474
+ return;
475
+ }
476
+ block.items.forEach((item, index) => {
477
+ const itemAt = `${at}.items[${index}]`;
478
+ if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
479
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
480
+ return;
481
+ }
482
+ if (item.kind === "claim") {
483
+ if (typeof item.label !== "string" || !item.label.trim()) {
484
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
485
+ }
486
+ if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
487
+ if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
488
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
489
+ }
490
+ } else if (
491
+ typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
492
+ typeof item.count !== "number" || item.count <= 0
493
+ ) {
494
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
495
+ }
496
+ });
497
+ }
498
+
499
+ function validateFaq(block, file, at, out) {
500
+ if (block.lede !== undefined && (typeof block.lede !== "string" || !block.lede.trim())) {
501
+ out.push(mk(ERROR, "block-prop", file, `${at}.lede must be a non-empty plain-text string`));
502
+ }
503
+ if (!Array.isArray(block.items)) {
504
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
505
+ return;
506
+ }
507
+ block.items.forEach((item, index) => {
508
+ const itemAt = `${at}.items[${index}]`;
509
+ if (item == null || typeof item !== "object" || Array.isArray(item)) {
510
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
511
+ return;
512
+ }
513
+ for (const prop of ["q", "a"]) {
514
+ if (typeof item[prop] !== "string" || !item[prop].trim()) {
515
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
516
+ }
517
+ }
518
+ });
519
+ }
520
+
521
+ function validateHomeDoc(doc, file, tenantId) {
391
522
  const out = [];
392
523
  if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
393
524
  return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
@@ -417,6 +548,9 @@ function validateHomeDoc(doc, file) {
417
548
  for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
418
549
  if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
419
550
  }
551
+ if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
552
+ out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
553
+ }
420
554
  if (component === "featured_products") {
421
555
  if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
422
556
  if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
@@ -425,7 +559,21 @@ function validateHomeDoc(doc, file) {
425
559
  if (block.limit !== undefined && typeof block.limit !== "number") {
426
560
  out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
427
561
  }
562
+ if (block.presentation !== undefined && !FEATURED_PRODUCT_PRESENTATIONS.includes(block.presentation)) out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be grid or carousel`));
428
563
  }
564
+ if (component === "hero" && block.image !== undefined) {
565
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
566
+ }
567
+ if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
568
+ if (component === "editorial" && block.image !== undefined) {
569
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
570
+ if (typeof block.imageAlt !== "string") {
571
+ out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
572
+ }
573
+ }
574
+ if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
575
+ if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
576
+ if (component === "faq") validateFaq(block, file, at, out);
429
577
  });
430
578
  return out;
431
579
  }
@@ -856,6 +1004,7 @@ export function validateTenant(tenantDir, opts = {}) {
856
1004
  );
857
1005
  }
858
1006
  const scope = opts.scope || config?.scope;
1007
+ const activeTenantId = opts.tenantId || scope || config?.tenant;
859
1008
  // platform-route awareness: which framework routes the host platform owns for this tenant
860
1009
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
861
1010
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -914,7 +1063,7 @@ export function validateTenant(tenantDir, opts = {}) {
914
1063
  if (error) {
915
1064
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
916
1065
  } else if (name === "home.json") {
917
- findings.push(...validateHomeDoc(value, "content/home.json"));
1066
+ findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
918
1067
  } else if (name === "chrome.json") {
919
1068
  findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
920
1069
  if (opts.previousChromeJson != null) {
@@ -1114,7 +1263,7 @@ function resolveTenantDir(argv) {
1114
1263
  if (!dir) return { error: "--workspace requires a path" };
1115
1264
  return { dir: resolve(dir), tenantId: null };
1116
1265
  }
1117
- const id = argv.find((a) => !a.startsWith("--"));
1266
+ const id = argv.find((a, index) => !a.startsWith("--") && argv[index - 1] !== "--profile");
1118
1267
  if (!id) return { error: "usage: node scripts/tenant/validate.mjs <tenant> | --workspace <dir> [--json]" };
1119
1268
  return { dir: tenantDir(id), tenantId: id };
1120
1269
  }
@@ -1123,6 +1272,12 @@ const isMain = resolve(process.argv[1] || "") === resolve(fileURLToPath(import.m
1123
1272
  if (isMain) {
1124
1273
  const argv = process.argv.slice(2);
1125
1274
  const asJson = argv.includes("--json");
1275
+ const profileIndex = argv.indexOf("--profile");
1276
+ const profile = profileIndex === -1 ? null : argv[profileIndex + 1];
1277
+ if (profileIndex !== -1 && profile !== "visual-parity") {
1278
+ console.error("usage: --profile visual-parity");
1279
+ process.exit(2);
1280
+ }
1126
1281
  const { dir, tenantId, error } = resolveTenantDir(argv);
1127
1282
  if (error) {
1128
1283
  console.error(error);
@@ -1134,14 +1289,22 @@ if (isMain) {
1134
1289
  process.exit(2);
1135
1290
  }
1136
1291
  const mode = argv.includes("--workspace") ? "workspace" : "monorepo";
1137
- const { ok, findings } = validateTenant(dir, { tenantId: tenantId ?? undefined, mode });
1292
+ const baseline = validateTenant(dir, { tenantId: tenantId ?? undefined, mode });
1293
+ const result = profile === "visual-parity"
1294
+ ? (() => {
1295
+ const parity = validateVisualParityClosure(dir, { tenantId: tenantId ?? undefined });
1296
+ const findings = mergeFindings(baseline.findings, parity.findings);
1297
+ return { ok: !findings.some((item) => item.level === ERROR), findings };
1298
+ })()
1299
+ : baseline;
1300
+ const { ok, findings } = result;
1138
1301
  if (asJson) {
1139
1302
  console.log(JSON.stringify({ ok, findings }, null, 2));
1140
1303
  process.exit(ok ? 0 : 1);
1141
1304
  }
1142
1305
  const errors = findings.filter((f) => f.level === ERROR);
1143
1306
  const warns = findings.filter((f) => f.level === WARN);
1144
- console.log(`\ntot validate — ${tenantId ?? dir}\n`);
1307
+ console.log(`\ntot validate${profile ? ` --profile ${profile}` : ""} — ${tenantId ?? dir}\n`);
1145
1308
  // Named, grouped callouts FIRST — see printLoudAdvisories above.
1146
1309
  printLoudAdvisories(findings, { log: (s) => console.log(s) });
1147
1310
  for (const f of findings) {
@@ -1,55 +0,0 @@
1
- /**
2
- * Cloudflare Image Resizing URL helpers (Epic Phase-2 D2).
3
- *
4
- * Rehosted product images live in the PRODUCT_IMAGES R2 bucket, which does NOT
5
- * transform on a `?width=` query the way Shopify's CDN does. Cloudflare Image
6
- * Resizing — the `/cdn-cgi/image/<options>/<source>` path, run at the zone edge —
7
- * resizes AND reformats (`format=auto` → AVIF/WebP where supported) any same-zone
8
- * or allowlisted source.
9
- *
10
- * We emit ROOT-RELATIVE `/cdn-cgi/image/...` URLs so they resolve against
11
- * whichever zone is serving the page (and an extracted/self-hosted tenant's zone
12
- * too — no hardcoded host). Referenced (non-rehosted) Shopify CDN URLs keep their
13
- * native `?width=` scheme in Img.astro, since Shopify already resizes those — no
14
- * need to route them back through our resizer.
15
- */
16
- export interface CfImageOpts {
17
- width: number;
18
- height?: number;
19
- fit?: "cover" | "contain" | "scale-down" | "crop" | "pad";
20
- format?: "auto" | "avif" | "webp";
21
- quality?: number;
22
- }
23
-
24
- /** The responsive widths we generate, in CSS px. */
25
- export const CF_IMAGE_WIDTHS = [320, 480, 640, 960, 1280, 1600] as const;
26
-
27
- /** Build a single `/cdn-cgi/image/...` URL for a source image. */
28
- export function cfImageUrl(src: string, opts: CfImageOpts): string {
29
- const options = [
30
- `width=${Math.round(opts.width)}`,
31
- opts.height ? `height=${Math.round(opts.height)}` : null,
32
- `fit=${opts.fit ?? "cover"}`,
33
- `format=${opts.format ?? "auto"}`,
34
- `quality=${opts.quality ?? 82}`,
35
- ]
36
- .filter(Boolean)
37
- .join(",");
38
- // Cloudflare accepts both absolute source URLs and same-zone paths verbatim
39
- // after the options segment.
40
- return `/cdn-cgi/image/${options}/${src}`;
41
- }
42
-
43
- /**
44
- * Build a `srcset` of resized variants up to ~2x the display width, so the
45
- * browser picks the right density. `displayWidth` is the intrinsic/layout width.
46
- */
47
- export function cfImageSrcset(
48
- src: string,
49
- displayWidth: number,
50
- opts: Omit<CfImageOpts, "width"> = {},
51
- ): string {
52
- const widths = CF_IMAGE_WIDTHS.filter((w) => w <= displayWidth * 2);
53
- const list = widths.length ? widths : [displayWidth];
54
- return list.map((w) => `${cfImageUrl(src, { ...opts, width: w })} ${w}w`).join(", ");
55
- }
@@ -1,120 +0,0 @@
1
- /**
2
- * The scheduled Cloudflare Worker entry for `dt-prune-and-rollup`.
3
- *
4
- * A SEPARATE, DEDICATED Worker script from the Astro-adapted storefront
5
- * Worker — deliberately, not a `scheduled` export bolted onto the Astro
6
- * entry. The @astrojs/cloudflare adapter OWNS `dist/server/entry.mjs` and
7
- * bakes its target environment in at BUILD time via `CLOUDFLARE_ENV`
8
- * (`wrangler.toml`'s own "DEPLOY FOOTGUN" comment); the documented way to add
9
- * a non-`fetch` handler there is a custom entrypoint that re-imports the
10
- * adapter's generated `handle()`, which would make this cron trigger's
11
- * correctness depend on the SAME footgun-prone build/deploy sequencing as
12
- * the whole storefront. This module instead has nothing to do with Astro at
13
- * all — it is a plain `ExportedHandler` deployed under its own Worker name
14
- * (`wrangler.prune-rollup.toml`, `[triggers]` below), reusing the tracking
15
- * library's already-built, framework-agnostic pieces
16
- * (`lib/tracking/pruneRollup.ts`, `d1Control.ts`, `interactionStore.ts`).
17
- * There is no other scheduled-Worker precedent in this repo to follow; this
18
- * is the first one, chosen to keep the footgun-prone Astro deploy path
19
- * completely untouched.
20
- *
21
- * BINDINGS (declared in `wrangler.prune-rollup.toml`, not this file):
22
- * - `STOREFRONT_APPS_DB` — the SAME shared D1 database the main storefront
23
- * Worker binds, for the `tracking_tenant_databases` directory.
24
- * - `INTERACTION_ANALYTICS` — the SAME Analytics Engine dataset the main
25
- * Worker writes rollups to.
26
- * - `CF_ACCOUNT_ID` / `TRACKING_D1_API_TOKEN` (secret) — D1 REST
27
- * credentials for reaching each tenant's OWN database (Worker bindings
28
- * are static; a per-tenant database can only be addressed over the D1
29
- * HTTP API, same reasoning as `d1Control.ts`'s module doc).
30
- * - `TRACKING_D1_NAME_PREFIX` (optional) — unused by this worker directly,
31
- * kept only for parity/documentation; the prune sweep addresses tenants
32
- * by `database_uuid` from the directory, never by re-deriving the name.
33
- */
34
- import type { Queryable } from "../lib/d1/catalog.js";
35
- import type { D1Like } from "../lib/d1/catalog.js";
36
- import { fromD1 } from "../lib/d1/catalog.js";
37
- import type { AnalyticsEngineLike } from "../lib/activity/store.js";
38
- import type { D1ControlConfig } from "../lib/tracking/d1Control.js";
39
- import { runPruneSweep, type PruneSweepResult } from "../lib/tracking/pruneRollup.js";
40
-
41
- /** The env bindings this Worker needs — a local, minimal slice (this repo
42
- * does not take a `@cloudflare/workers-types` dependency; see `d1Control.ts`
43
- * and `activity/store.ts` for the same convention). */
44
- export interface PruneRollupEnv {
45
- STOREFRONT_APPS_DB: D1Like;
46
- INTERACTION_ANALYTICS?: AnalyticsEngineLike;
47
- CF_ACCOUNT_ID?: string;
48
- TRACKING_D1_API_TOKEN?: string;
49
- /** Tenants processed per invocation; overridable for a slower/faster cron. */
50
- PRUNE_BATCH_SIZE?: string;
51
- }
52
-
53
- /** The minimal slice of Cloudflare's `ScheduledController` this handler uses. */
54
- export interface ScheduledControllerLike {
55
- scheduledTime: number;
56
- }
57
-
58
- /** The minimal slice of Cloudflare's `ExecutionContext` this handler uses. */
59
- export interface ExecutionContextLike {
60
- waitUntil(promise: Promise<unknown>): void;
61
- }
62
-
63
- /** Build the D1 REST control config from env, or undefined when unconfigured
64
- * (mirrors `d1Control.ts`'s `readD1Control()` "skip, never fail" posture). */
65
- function controlFromEnv(env: PruneRollupEnv): D1ControlConfig | undefined {
66
- if (!env.CF_ACCOUNT_ID || !env.TRACKING_D1_API_TOKEN) return undefined;
67
- return { accountId: env.CF_ACCOUNT_ID, apiToken: env.TRACKING_D1_API_TOKEN };
68
- }
69
-
70
- /**
71
- * The pure orchestration the `scheduled` export below delegates to — kept
72
- * separate so it is callable/testable without a real `ScheduledController`.
73
- * Never throws: a misconfigured environment (no D1 REST credentials) logs
74
- * and no-ops rather than failing the cron invocation.
75
- */
76
- export async function handleScheduledPrune(
77
- env: PruneRollupEnv,
78
- now: string = new Date().toISOString(),
79
- ): Promise<PruneSweepResult | undefined> {
80
- const control = controlFromEnv(env);
81
- if (!control) {
82
- console.error(
83
- "[tracking:prune] skipped — CF_ACCOUNT_ID/TRACKING_D1_API_TOKEN not configured",
84
- );
85
- return undefined;
86
- }
87
-
88
- const apps: Queryable = fromD1(env.STOREFRONT_APPS_DB);
89
- const batchSize = env.PRUNE_BATCH_SIZE ? Number.parseInt(env.PRUNE_BATCH_SIZE, 10) : undefined;
90
-
91
- const result = await runPruneSweep({
92
- apps,
93
- control,
94
- analytics: env.INTERACTION_ANALYTICS,
95
- now,
96
- ...(batchSize && Number.isFinite(batchSize) ? { batchSize } : {}),
97
- });
98
-
99
- console.info(
100
- `[tracking:prune] swept ${result.tenantsSwept} tenant(s), pruned ${result.totalPrunedRows} row(s), ` +
101
- `wrote ${result.totalAggregatesWritten} aggregate(s)`,
102
- );
103
- return result;
104
- }
105
-
106
- export default {
107
- async scheduled(
108
- controller: ScheduledControllerLike,
109
- env: PruneRollupEnv,
110
- ctx: ExecutionContextLike,
111
- ): Promise<void> {
112
- ctx.waitUntil(
113
- handleScheduledPrune(env, new Date(controller.scheduledTime).toISOString()).catch((err) => {
114
- console.error(
115
- `[tracking:prune] sweep threw: ${err instanceof Error ? err.message : String(err)}`,
116
- );
117
- }),
118
- );
119
- },
120
- };
@@ -1,129 +0,0 @@
1
- /* Storefront early-access landing — interactions.
2
- * Served at /tenants/home/js/storefront.js (mirrored from tenants/home/public/).
3
- */
4
- (function () {
5
- // 1) comparison expand/collapse
6
- document.querySelectorAll(".cmp .top").forEach(function (btn) {
7
- btn.addEventListener("click", function () {
8
- const card = btn.closest(".cmp"), open = card.getAttribute("aria-expanded") === "true";
9
- card.setAttribute("aria-expanded", open ? "false" : "true");
10
- btn.setAttribute("aria-expanded", open ? "false" : "true");
11
- });
12
- });
13
-
14
- // 2) persona tabs
15
- const tabs = document.querySelectorAll(".seg [data-persona]");
16
- const panels = document.querySelectorAll(".ppanel");
17
- tabs.forEach(function (tab) {
18
- tab.addEventListener("click", function () {
19
- const who = tab.getAttribute("data-persona");
20
- tabs.forEach(function (t) { t.setAttribute("aria-selected", t === tab ? "true" : "false"); });
21
- panels.forEach(function (p) { p.classList.toggle("on", p.getAttribute("data-panel") === who); });
22
- });
23
- });
24
-
25
- // ---- request-access modal --------------------------------------------------
26
- // Board address the mailto: fallback targets (mirrors the server route's
27
- // recipient). Kept in sync with apps/storefront/src/pages/api/request-access.ts.
28
- const BOARD_EMAIL = "darrin84+l6gajmvvoo1x4edew5qs@boards.trello.com";
29
- const modal = document.getElementById("ra-modal");
30
- const form = modal && modal.querySelector("form.ra");
31
-
32
- function openModal() {
33
- if (!modal) return;
34
- modal.classList.remove("done");
35
- if (form) form.classList.remove("has-error");
36
- modal.classList.add("on");
37
- document.body.style.overflow = "hidden";
38
- const first = modal.querySelector("input[name=email]");
39
- if (first) setTimeout(function () { first.focus(); }, 40);
40
- }
41
- function closeModal() {
42
- if (!modal) return;
43
- modal.classList.remove("on");
44
- document.body.style.overflow = "";
45
- }
46
-
47
- // Any element pointing at #request opens the modal instead of jumping.
48
- document.querySelectorAll('a[href="#request"]').forEach(function (a) {
49
- a.addEventListener("click", function (e) {
50
- e.preventDefault();
51
- openModal();
52
- });
53
- });
54
- if (modal) {
55
- modal.querySelectorAll("[data-close]").forEach(function (el) {
56
- el.addEventListener("click", closeModal);
57
- });
58
- document.addEventListener("keydown", function (e) {
59
- if (e.key === "Escape" && modal.classList.contains("on")) closeModal();
60
- });
61
- }
62
-
63
- function mailtoFallback(fields) {
64
- const lines = [
65
- "First name: " + fields.firstName,
66
- "Last name: " + fields.lastName,
67
- "Email: " + fields.email,
68
- fields.phone ? "Phone: " + fields.phone : null,
69
- fields.details ? "\nDetails:\n" + fields.details : null,
70
- ].filter(Boolean).join("\n");
71
- const subject = "Early-access request — " + fields.firstName + " " + fields.lastName;
72
- return "mailto:" + BOARD_EMAIL + "?subject=" + encodeURIComponent(subject) + "&body=" + encodeURIComponent(lines);
73
- }
74
-
75
- if (form) {
76
- form.addEventListener("submit", async function (e) {
77
- e.preventDefault();
78
- form.classList.remove("has-error");
79
- const fd = new FormData(form);
80
- const fields = {
81
- email: String(fd.get("email") || "").trim(),
82
- firstName: String(fd.get("firstName") || "").trim(),
83
- lastName: String(fd.get("lastName") || "").trim(),
84
- phone: String(fd.get("phone") || "").trim(),
85
- details: String(fd.get("details") || "").trim(),
86
- };
87
-
88
- const btn = form.querySelector("button[type=submit]");
89
- const label = btn ? btn.textContent : "";
90
- if (btn) { btn.disabled = true; btn.textContent = "Sending…"; }
91
-
92
- try {
93
- const res = await fetch("/api/request-access", {
94
- method: "POST",
95
- headers: { "content-type": "application/json", accept: "application/json" },
96
- body: JSON.stringify(fields),
97
- });
98
- const data = await res.json().catch(function () { return {}; });
99
- if (res.ok && data && data.ok) {
100
- // If the endpoint couldn't send server-side yet, it hands back a
101
- // mailto: so the request is never dropped on the floor.
102
- if (data.fallback === "mailto") {
103
- window.location.href = data.mailto || mailtoFallback(fields);
104
- }
105
- modal.classList.add("done");
106
- } else {
107
- throw new Error((data && data.error) || "send_failed");
108
- }
109
- } catch (err) {
110
- // Never a dead form: offer the mailto: as a client-side last resort.
111
- // Built with DOM methods (no innerHTML) so field values can't inject markup.
112
- form.classList.add("has-error");
113
- const errEl = form.querySelector(".err");
114
- if (errEl) {
115
- errEl.textContent = "Something went wrong. ";
116
- const link = document.createElement("a");
117
- link.href = mailtoFallback(fields);
118
- link.style.color = "inherit";
119
- link.style.textDecoration = "underline";
120
- link.textContent = "Email us directly";
121
- errEl.appendChild(link);
122
- errEl.appendChild(document.createTextNode(" and we'll take it from there."));
123
- }
124
- } finally {
125
- if (btn) { btn.disabled = false; btn.textContent = label; }
126
- }
127
- });
128
- }
129
- })();