@tokenoftrust/storefront-runner 3.11.1 → 3.12.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.
@@ -24,7 +24,7 @@ import type { CatalogProduct } from "@tot/public-runtime";
24
24
  import Img from "@/components/assets/storefront/Img.astro";
25
25
  import { withBase } from "@/lib/publication/shared/basePath";
26
26
  import { pageUrlForm } from "@/lib/content/seo/urlForm";
27
- import { heroSlideshowView } from "@/lib/content/shared/heroSlideshow";
27
+ import { heroFrame, heroSlideshowView, HERO_PHONE_MEDIA } from "@/lib/content/shared/heroSlideshow";
28
28
 
29
29
  interface Props {
30
30
  block: HeroBlock;
@@ -44,6 +44,8 @@ const heroImg = block.image;
44
44
  const hasImage = Boolean(heroImg || (prodImg && featureProduct));
45
45
  const slideshowView = heroSlideshowView(block, instanceKey, basePath);
46
46
  const slides = slideshowView.slides;
47
+ const bannerFrame = heroFrame(block.width, block.height);
48
+ const featureFrame = heroFrame(block.width, block.height, 1600, 1000);
47
49
  const slideshow = slides.length > 0;
48
50
  const slideId = slideshowView.id;
49
51
  const notice = block.notice?.trim();
@@ -76,11 +78,23 @@ const notice = block.notice?.trim();
76
78
  checked={slide.checked}
77
79
  aria-label={slide.radioLabel}
78
80
  />
79
- <div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget}>
81
+ <div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget} style={`--hero-aspect: ${slide.aspectRatio}`}>
80
82
  {slide.ctaHref ? (
81
83
  <a href={slide.href} aria-label={slide.ctaLabel ?? slide.headline ?? slide.imageAlt} class="block w-full">
82
- <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
84
+ {slide.imageMobile ? (
85
+ <picture>
86
+ <source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
87
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
88
+ </picture>
89
+ ) : (
90
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
91
+ )}
83
92
  </a>
93
+ ) : slide.imageMobile ? (
94
+ <picture>
95
+ <source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
96
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
97
+ </picture>
84
98
  ) : (
85
99
  <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
86
100
  )}
@@ -108,23 +122,53 @@ const notice = block.notice?.trim();
108
122
  aria-label={block.ctaLabel ?? block.headline}
109
123
  class="block w-full"
110
124
  >
125
+ {block.imageMobile ? (
126
+ <picture>
127
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
128
+ <img
129
+ src={block.image}
130
+ alt={block.imageAlt ?? ""}
131
+ width={bannerFrame.width}
132
+ height={bannerFrame.height}
133
+ loading="eager"
134
+ fetchpriority="high"
135
+ decoding="sync"
136
+ class="block h-auto w-full"
137
+ />
138
+ </picture>
139
+ ) : (
140
+ <img
141
+ src={block.image}
142
+ alt={block.imageAlt ?? ""}
143
+ width={bannerFrame.width}
144
+ height={bannerFrame.height}
145
+ loading="eager"
146
+ fetchpriority="high"
147
+ decoding="sync"
148
+ class="block h-auto w-full"
149
+ />
150
+ )}
151
+ </a>
152
+ ) : block.imageMobile ? (
153
+ <picture>
154
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
111
155
  <img
112
156
  src={block.image}
113
- alt={block.imageAlt ?? ""}
114
- width={1920}
115
- height={650}
157
+ alt={block.imageAlt ?? block.headline}
158
+ width={bannerFrame.width}
159
+ height={bannerFrame.height}
116
160
  loading="eager"
117
161
  fetchpriority="high"
118
162
  decoding="sync"
119
163
  class="block h-auto w-full"
120
164
  />
121
- </a>
165
+ </picture>
122
166
  ) : (
123
167
  <img
124
168
  src={block.image}
125
169
  alt={block.imageAlt ?? block.headline}
126
- width={1920}
127
- height={650}
170
+ width={bannerFrame.width}
171
+ height={bannerFrame.height}
128
172
  loading="eager"
129
173
  fetchpriority="high"
130
174
  decoding="sync"
@@ -226,16 +270,32 @@ const notice = block.notice?.trim();
226
270
  width/height attributes mean the final render still uses the
227
271
  image's natural ratio; the dims below only size the pre-load box.
228
272
  */}
229
- <img
230
- src={heroImg}
231
- alt={block.imageAlt ?? block.headline}
232
- width={1600}
233
- height={1000}
234
- loading="eager"
235
- fetchpriority="high"
236
- decoding="sync"
237
- class="h-full w-full object-cover"
238
- />
273
+ {block.imageMobile ? (
274
+ <picture>
275
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
276
+ <img
277
+ src={heroImg}
278
+ alt={block.imageAlt ?? block.headline}
279
+ width={featureFrame.width}
280
+ height={featureFrame.height}
281
+ loading="eager"
282
+ fetchpriority="high"
283
+ decoding="sync"
284
+ class="h-full w-full object-cover"
285
+ />
286
+ </picture>
287
+ ) : (
288
+ <img
289
+ src={heroImg}
290
+ alt={block.imageAlt ?? block.headline}
291
+ width={featureFrame.width}
292
+ height={featureFrame.height}
293
+ loading="eager"
294
+ fetchpriority="high"
295
+ decoding="sync"
296
+ class="h-full w-full object-cover"
297
+ />
298
+ )}
239
299
  </div>
240
300
  </div>
241
301
  ) : (
@@ -289,11 +349,13 @@ const notice = block.notice?.trim();
289
349
 
290
350
  <style>
291
351
  .hero-slideshow { position: relative; overflow: hidden; }
292
- .hero-slideshow__slides { display: grid; aspect-ratio: 1920 / 650; background: var(--color-bg); }
352
+ .hero-slideshow__slides { display: grid; background: var(--color-bg); }
293
353
  .hero-slideshow__radio { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
294
- .hero-slideshow__slide { grid-area: 1 / 1; display: none; min-width: 0; }
295
- .hero-slideshow__radio:checked + .hero-slideshow__slide { display: grid; }
296
- .hero-slideshow__slide > a { min-width: 0; }
354
+ .hero-slideshow__slide { grid-area: 1 / 1; display: none; width: 100%; height: auto; align-self: start; justify-self: stretch; min-width: 0; aspect-ratio: var(--hero-aspect, 1920 / 650); }
355
+ .hero-slideshow__radio:checked + .hero-slideshow__slide { display: block; }
356
+ .hero-slideshow__slide > a,
357
+ .hero-slideshow__slide > picture,
358
+ .hero-slideshow__slide > a > picture { display: block; width: 100%; height: 100%; min-width: 0; }
297
359
  .hero-slideshow__image { display: block; width: 100%; height: 100%; object-fit: contain; }
298
360
  .hero-slideshow__controls { position: absolute; inset-inline: 0; bottom: 1rem; z-index: 1; display: flex; justify-content: center; gap: .5rem; }
299
361
  .hero-slideshow__controls label { display: grid; place-items: center; inline-size: 2.75rem; block-size: 2.75rem; border-radius: 999px; background: color-mix(in srgb, var(--color-bg) 86%, transparent); color: var(--color-text); cursor: pointer; font: 600 .75rem/1 var(--font-mono); }
@@ -16,26 +16,54 @@ export function nextHeroSlideIndex(current: number, slideCount: number) {
16
16
  return current < 0 ? 0 : (current + 1) % slideCount;
17
17
  }
18
18
 
19
+ /** Reserved slideshow box when a slide does not declare its own frame. */
20
+ export const HERO_FRAME_WIDTH = 1920;
21
+ export const HERO_FRAME_HEIGHT = 650;
22
+
23
+ /** Matches the slideshow's own phone layout. One `<source>` so that viewport fetches one file. */
24
+ export const HERO_PHONE_MEDIA = "(max-width: 40rem)";
25
+
26
+ export function heroFrame(
27
+ width: number | undefined,
28
+ height: number | undefined,
29
+ fallbackWidth = HERO_FRAME_WIDTH,
30
+ fallbackHeight = HERO_FRAME_HEIGHT,
31
+ ) {
32
+ const declared = typeof width === "number"
33
+ && typeof height === "number"
34
+ && Number.isFinite(width)
35
+ && Number.isFinite(height)
36
+ && width > 0
37
+ && height > 0;
38
+ const frameWidth = declared ? width : fallbackWidth;
39
+ const frameHeight = declared ? height : fallbackHeight;
40
+ return { width: frameWidth, height: frameHeight, aspectRatio: `${frameWidth} / ${frameHeight}` };
41
+ }
42
+
19
43
  export function heroSlideshowView(block: HeroBlock, instanceKey: string, basePath: string) {
20
44
  const slides = block.slides ?? [];
21
45
  return {
22
46
  id: `hero-slides-${instanceKey}`,
23
47
  autoplay: slides.length > 1,
24
48
  intervalMs: 6_000,
25
- slides: slides.map((slide, index) => ({
26
- ...slide,
27
- href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
28
- checked: index === 0,
29
- loading: index === 0 ? ("eager" as const) : ("lazy" as const),
30
- fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
31
- decoding: index === 0 ? ("sync" as const) : ("async" as const),
32
- width: 1920,
33
- height: 650,
34
- radioId: `hero-slides-${instanceKey}-${index}`,
35
- radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
36
- groupRole: "group" as const,
37
- groupLabel: `${index + 1} of ${slides.length}`,
38
- focusTarget: "slide" as const,
39
- })),
49
+ slides: slides.map((slide, index) => {
50
+ const frame = heroFrame(slide.width, slide.height);
51
+ return {
52
+ ...slide,
53
+ href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
54
+ checked: index === 0,
55
+ loading: index === 0 ? ("eager" as const) : ("lazy" as const),
56
+ fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
57
+ decoding: index === 0 ? ("sync" as const) : ("async" as const),
58
+ width: frame.width,
59
+ height: frame.height,
60
+ aspectRatio: frame.aspectRatio,
61
+ radioId: `hero-slides-${instanceKey}-${index}`,
62
+ radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
63
+ groupRole: "group" as const,
64
+ groupLabel: `${index + 1} of ${slides.length}`,
65
+ focusTarget: "slide" as const,
66
+ };
67
+ }),
40
68
  };
41
69
  }
@@ -73,11 +73,21 @@ export interface HeroBlock {
73
73
  featureCollection?: string;
74
74
  /** Direct hero image URL (e.g. the merchant's own homepage hero slide). */
75
75
  image?: string;
76
+ /** Phone asset for the single image. The desktop `image` stays the fallback. */
77
+ imageMobile?: string;
76
78
  imageAlt?: string;
79
+ /** Intrinsic frame for the single image. Both are required together. */
80
+ width?: number;
81
+ height?: number;
77
82
  /** Optional static, governed campaign slides. The first slide is the LCP image. */
78
83
  slides?: Array<{
79
84
  image: string;
80
85
  imageAlt: string;
86
+ /** Phone asset. The desktop `image` stays the fallback. */
87
+ imageMobile?: string;
88
+ /** Intrinsic frame. Both are required together; omitted keeps the reserved box. */
89
+ width?: number;
90
+ height?: number;
81
91
  headline?: string;
82
92
  ctaLabel?: string;
83
93
  ctaHref?: string;
@@ -49,6 +49,13 @@ import {
49
49
  resolveCapabilities,
50
50
  capabilitiesArtifactPath,
51
51
  resolveServableChannelVersion,
52
+ resolveChannelVersion,
53
+ serveTenantAsset,
54
+ parseTenantAssetPath,
55
+ compileRouteTierIndexV1,
56
+ parsePublicationVersionStateV1Json,
57
+ publicationVersionStateKey,
58
+ readPublicationRootAssetV1,
52
59
  channelFromEnv,
53
60
  resolveEnvironmentOnlyPlan,
54
61
  INTEGRATION_NONCE_PLACEHOLDER,
@@ -66,6 +73,17 @@ import {
66
73
  } from "@tot/public-runtime";
67
74
  import { readBakedManagedInstalls } from "@/lib/integrations/integration/bakedManagedInstalls";
68
75
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
76
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
77
+ import {
78
+ distinctTenantThemeGroups,
79
+ inlineDistinctTenantThemeGroups,
80
+ previewThemeInlineApplies,
81
+ selectServedThemeCss,
82
+ } from "@tot/public-runtime/theme-stylesheets";
83
+ import {
84
+ resolvePreviewVersionFromCookies,
85
+ resolveSharedPreviewAssetVersion,
86
+ } from "@/lib/assets/preview-version-cookie";
69
87
  import type {
70
88
  CapabilityConfig,
71
89
  ConsentSnapshot,
@@ -75,7 +93,10 @@ import type {
75
93
  ThemeTokens,
76
94
  BrowserEnvironment,
77
95
  PublicationArtifactBucketV1,
96
+ PublicationArtifactReadBucketV1,
97
+ PublicationChannelV1,
78
98
  PublicationRoutePlanV1,
99
+ R2BucketLike,
79
100
  RouteTierIndexV1,
80
101
  } from "@tot/public-runtime";
81
102
  import {
@@ -359,7 +380,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
359
380
  // Digest-addressed static-bundle serving. Deliberately before the edge page
360
381
  // cache/catalog/theme machinery. A selected publication bypasses this legacy
361
382
  // whole-site bundle until B1 can compose mounted spaces into it reproducibly.
362
- const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
363
383
  const cookieRevision = await resolvePreviewVersionFromCookies({
364
384
  tenantId: tenant.tenant_id,
365
385
  previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
@@ -787,6 +807,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
787
807
  nonce: cspNonce,
788
808
  });
789
809
 
810
+ // Distinct tenant theme sheets are render-blocking `/tenants/` fetches. Inline
811
+ // them into this preview document. Publication capture keeps the source links
812
+ // so the asset pipeline concatenates the same run for both CDNs.
813
+ response = await inlinePreviewTenantThemes(response, {
814
+ request,
815
+ customizationEnv: customization.env,
816
+ tenant,
817
+ kvGet,
818
+ isPreviewHost,
819
+ previewToken,
820
+ previewSecret,
821
+ nowSeconds,
822
+ pinnedVersionId: cookieRevision,
823
+ });
824
+
790
825
  // Baseline security headers on every response — set before the page-cache
791
826
  // store below so cached copies carry them too.
792
827
  applySecurityHeaders(response.headers);
@@ -919,3 +954,169 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
919
954
  // matching nonce/CSP pair, and no nonce is reused across responses (ADR 0018).
920
955
  return await stampResponseNonce(response, freshNonce());
921
956
  }
957
+
958
+ /**
959
+ * Inline a run of distinct tenant theme stylesheets into a preview document.
960
+ * Bytes follow the `/tenants/<id>/` route: publication root, then the preview
961
+ * overlay, then the asset store. A miss leaves that run as links.
962
+ */
963
+ async function inlinePreviewTenantThemes(response: Response, input: {
964
+ request: Request;
965
+ customizationEnv: "live" | "test";
966
+ tenant: TenantConfig;
967
+ kvGet: (key: string) => Promise<string | null>;
968
+ isPreviewHost: boolean;
969
+ previewToken: string | null;
970
+ previewSecret: string | null;
971
+ nowSeconds: number;
972
+ pinnedVersionId: string | null;
973
+ }): Promise<Response> {
974
+ if (!previewThemeInlineApplies({
975
+ method: input.request.method,
976
+ customizationEnv: input.customizationEnv,
977
+ captureProbe: input.request.headers.get(HTML_CACHE_POLICY_CAPTURE_PROBE),
978
+ contentType: response.headers.get("content-type") ?? "",
979
+ }) || response.status !== 200) return response;
980
+
981
+ const html = await response.text();
982
+ if (distinctTenantThemeGroups(html).length === 0) return htmlResponse(response, html);
983
+
984
+ try {
985
+ const shared = input.pinnedVersionId
986
+ ? { preview: false, versionId: null }
987
+ : await resolveSharedPreviewAssetVersion({
988
+ tenantId: input.tenant.tenant_id,
989
+ previewHost: input.isPreviewHost,
990
+ previewToken: input.previewToken,
991
+ secret: input.previewSecret,
992
+ nowSeconds: input.nowSeconds,
993
+ readPreviewChannelVersion: () =>
994
+ resolveChannelVersion(input.kvGet, input.tenant.tenant_id, "preview"),
995
+ });
996
+ const previewVersionId = input.pinnedVersionId ?? shared.versionId;
997
+ const publicationChannel: PublicationChannelV1 =
998
+ shared.preview || input.customizationEnv === "test" ? "preview" : "live";
999
+ const assetChannel = shared.preview ? "test" as const : input.customizationEnv;
1000
+ const bucket = await readR2("TENANT_ASSETS");
1001
+ const paths = [...new Set(
1002
+ distinctTenantThemeGroups(html).flatMap((group) => group.sheets.map((sheet) => sheet.path)),
1003
+ )];
1004
+ const css = new Map<string, string | null>();
1005
+ await Promise.all(paths.map(async (pathname) => {
1006
+ try {
1007
+ css.set(pathname, bucket
1008
+ ? await readPreviewThemeCss({
1009
+ tenant: input.tenant,
1010
+ pathname,
1011
+ bucket: bucket as unknown as R2BucketLike,
1012
+ kvGet: input.kvGet,
1013
+ publicationChannel,
1014
+ assetChannel,
1015
+ previewVersionId,
1016
+ })
1017
+ : null);
1018
+ } catch {
1019
+ css.set(pathname, null);
1020
+ }
1021
+ }));
1022
+ const next = inlineDistinctTenantThemeGroups(html, (pathname) => css.get(pathname) ?? null);
1023
+ return htmlResponse(response, next);
1024
+ } catch {
1025
+ return htmlResponse(response, html);
1026
+ }
1027
+ }
1028
+
1029
+ function htmlResponse(response: Response, html: string): Response {
1030
+ const headers = new Headers(response.headers);
1031
+ headers.delete("content-length");
1032
+ headers.delete("content-encoding");
1033
+ return new Response(html, { status: response.status, statusText: response.statusText, headers });
1034
+ }
1035
+
1036
+ async function readPreviewThemeCss(input: {
1037
+ tenant: TenantConfig;
1038
+ pathname: string;
1039
+ bucket: R2BucketLike;
1040
+ kvGet: (key: string) => Promise<string | null>;
1041
+ publicationChannel: PublicationChannelV1;
1042
+ assetChannel: "live" | "test";
1043
+ previewVersionId: string | null;
1044
+ }): Promise<string | null> {
1045
+ return selectServedThemeCss({
1046
+ previewVersionId: input.previewVersionId,
1047
+ publicationRoot: () => publicationRootThemeCss(input),
1048
+ overlay: () => overlayThemeCss(input),
1049
+ asset: () => assetThemeCss(input),
1050
+ });
1051
+ }
1052
+
1053
+ /** Same order as GET `/tenants/<id>/<path>`: a selected root file answers, including its 404. */
1054
+ async function publicationRootThemeCss(input: {
1055
+ tenant: TenantConfig;
1056
+ pathname: string;
1057
+ bucket: R2BucketLike;
1058
+ kvGet: (key: string) => Promise<string | null>;
1059
+ publicationChannel: PublicationChannelV1;
1060
+ }): Promise<"legacy" | "absent" | string> {
1061
+ const parsed = parseTenantAssetPath(input.pathname);
1062
+ if (!parsed || parsed.tenantId !== input.tenant.tenant_id) return "legacy";
1063
+ if (!input.tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return "legacy";
1064
+ const [stateJson, recoveryJson] = await Promise.all([
1065
+ input.kvGet(publicationVersionStateKey(input.tenant.tenant_id)),
1066
+ input.publicationChannel === "live"
1067
+ ? input.kvGet(publicationRecoveryKeyV1(input.tenant.tenant_id))
1068
+ : Promise.resolve(null),
1069
+ ]);
1070
+ if (stateJson === null || publicationRecoveryActiveV1(recoveryJson)) return "legacy";
1071
+ const state = parsePublicationVersionStateV1Json(stateJson);
1072
+ if (!state.ok) return "legacy";
1073
+ const read = await readPublicationRootAssetV1({
1074
+ index: compileRouteTierIndexV1(input.tenant),
1075
+ state: state.value,
1076
+ channel: input.publicationChannel,
1077
+ bucket: input.bucket as unknown as PublicationArtifactReadBucketV1,
1078
+ relativePath: parsed.rel,
1079
+ });
1080
+ if (read.status === "legacy-root") return "legacy";
1081
+ if (read.status === "absent") return "absent";
1082
+ try {
1083
+ return new TextDecoder("utf-8", { fatal: true }).decode(read.bytes);
1084
+ } catch {
1085
+ return "absent";
1086
+ }
1087
+ }
1088
+
1089
+ async function overlayThemeCss(input: {
1090
+ tenant: TenantConfig;
1091
+ pathname: string;
1092
+ kvGet: (key: string) => Promise<string | null>;
1093
+ previewVersionId: string | null;
1094
+ }): Promise<string | null> {
1095
+ if (!input.previewVersionId) return null;
1096
+ const parsed = parseTenantAssetPath(input.pathname);
1097
+ if (!parsed || parsed.tenantId !== input.tenant.tenant_id) return null;
1098
+ return readWithImagePreviewRewriteOverlay(
1099
+ input.kvGet,
1100
+ input.tenant.tenant_id,
1101
+ input.previewVersionId,
1102
+ `tenants/${input.tenant.tenant_id}/public/${parsed.rel}`,
1103
+ async () => null,
1104
+ );
1105
+ }
1106
+
1107
+ async function assetThemeCss(input: {
1108
+ pathname: string;
1109
+ bucket: R2BucketLike;
1110
+ kvGet: (key: string) => Promise<string | null>;
1111
+ assetChannel: "live" | "test";
1112
+ previewVersionId: string | null;
1113
+ }): Promise<string | null> {
1114
+ const res = await serveTenantAsset({
1115
+ bucket: input.bucket,
1116
+ kvGet: input.kvGet,
1117
+ channel: input.assetChannel,
1118
+ ...(input.previewVersionId ? { versionId: input.previewVersionId } : {}),
1119
+ }, input.pathname);
1120
+ if (!res || res.status !== 200) return null;
1121
+ return res.text();
1122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.11.1",
3
+ "version": "3.12.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",
@@ -31,6 +31,7 @@
31
31
  "./publication-recovery": "./src/publication-recovery.ts",
32
32
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
33
33
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
34
+ "./theme-stylesheets": "./src/theme-stylesheets.mjs",
34
35
  "./compliance-coherence": "./src/compliance-coherence.mjs",
35
36
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
36
37
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
@@ -0,0 +1,321 @@
1
+ // @ts-check
2
+ /**
3
+ * Distinct tenant theme stylesheets.
4
+ *
5
+ * A run of two or more different `/tenants/<id>/themes/*.css` links is one
6
+ * theme sheet. `/shared/` and `/_astro/` stay their own links and split a run.
7
+ * The same path repeated is not this case. Print, disabled, alternate, and
8
+ * commented links are not members. Integrity and an unusual media query split
9
+ * a run and are left in place.
10
+ *
11
+ * Preview inlines the run into the document already being rendered. Publication
12
+ * concatenates the same run in the asset pipeline. Neither path branches on a
13
+ * tenant id, and neither adds a Worker in front of public HTML.
14
+ */
15
+
16
+ const TENANT_THEME_STYLESHEET = /^\/tenants\/[^/]+\/themes\/[^/?#]+\.css$/i;
17
+ const SIMPLE_MEDIA = new Set(["", "all", "screen"]);
18
+ const CSS_REF_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)|@import\s+(['"])([^'"]+)\3/g;
19
+
20
+ /**
21
+ * @param {string} pathname
22
+ */
23
+ export function isTenantThemeStylesheetPath(pathname) {
24
+ return TENANT_THEME_STYLESHEET.test(pathname);
25
+ }
26
+
27
+ /**
28
+ * Preview inlines only a dynamic HTML GET that is not the publication capture
29
+ * and not the live document. The capture must still see the source links so
30
+ * the asset pipeline can copy each file.
31
+ * @param {{ method: string, customizationEnv: string, captureProbe: string | null, contentType: string }} input
32
+ */
33
+ export function previewThemeInlineApplies(input) {
34
+ if (input.method !== "GET") return false;
35
+ if (input.customizationEnv === "live") return false;
36
+ if (input.captureProbe != null) return false;
37
+ return input.contentType.toLowerCase().includes("text/html");
38
+ }
39
+
40
+ /**
41
+ * Publication-root, then the preview rewrite overlay, then the asset store.
42
+ * `"absent"` stops the cascade: that is a selected publication file the
43
+ * browser would 404, not a cue to serve a different copy.
44
+ * @param {{
45
+ * previewVersionId: string | null,
46
+ * publicationRoot: () => Promise<"legacy" | "absent" | string>,
47
+ * overlay: () => Promise<string | null>,
48
+ * asset: () => Promise<string | null>,
49
+ * }} input
50
+ * @returns {Promise<string | null>}
51
+ */
52
+ export async function selectServedThemeCss(input) {
53
+ const root = await input.publicationRoot();
54
+ if (root === "absent") return null;
55
+ if (root !== "legacy") return root;
56
+ if (input.previewVersionId) {
57
+ const overlay = await input.overlay();
58
+ if (overlay != null) return overlay;
59
+ }
60
+ return input.asset();
61
+ }
62
+
63
+ /**
64
+ * @param {string} html
65
+ * @returns {{ sheets: { start: number, end: number, path: string }[] }[]}
66
+ */
67
+ export function distinctTenantThemeGroups(html) {
68
+ /** @type {{ start: number, end: number, path: string }[]} */
69
+ let run = [];
70
+ /** @type {{ sheets: { start: number, end: number, path: string }[] }[]} */
71
+ const groups = [];
72
+ const flush = () => {
73
+ if (run.length >= 2 && new Set(run.map((sheet) => sheet.path)).size >= 2) {
74
+ groups.push({ sheets: run });
75
+ }
76
+ run = [];
77
+ };
78
+ for (const sheet of blockingStylesheetLinks(html)) {
79
+ if (sheet.member) run.push(sheet);
80
+ else flush();
81
+ }
82
+ flush();
83
+ return groups;
84
+ }
85
+
86
+ /**
87
+ * Replace each distinct theme run with one inline style. A run whose bytes
88
+ * are missing, not utf-8, or whose relative url escapes the site is left as links.
89
+ * @param {string} html
90
+ * @param {(pathname: string) => string | null} cssFor
91
+ */
92
+ export function inlineDistinctTenantThemeGroups(html, cssFor) {
93
+ const groups = distinctTenantThemeGroups(html);
94
+ if (groups.length === 0) return html;
95
+ /** @type {{ start: number, end: number, replacement: string }[]} */
96
+ const edits = [];
97
+ for (const group of groups) {
98
+ /** @type {string[]} */
99
+ const parts = [];
100
+ let skip = false;
101
+ for (const sheet of group.sheets) {
102
+ const css = cssFor(sheet.path);
103
+ const prepared = css == null ? null : prepareInlineSheet(css, sheet.path);
104
+ if (prepared == null) {
105
+ skip = true;
106
+ break;
107
+ }
108
+ parts.push(prepared);
109
+ }
110
+ const first = group.sheets[0];
111
+ if (skip || !first) continue;
112
+ const style = `<style data-tot-theme>${escapeStyleBody(parts.join("\n"))}</style>`;
113
+ edits.push({ start: first.start, end: first.end, replacement: style });
114
+ for (const sheet of group.sheets.slice(1)) {
115
+ edits.push({ start: sheet.start, end: sheet.end, replacement: "" });
116
+ }
117
+ }
118
+ return applyStylesheetEdits(html, edits);
119
+ }
120
+
121
+ /**
122
+ * @param {string} html
123
+ * @param {{ start: number, end: number, replacement: string }[]} edits
124
+ */
125
+ export function applyStylesheetEdits(html, edits) {
126
+ const ordered = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
127
+ let out = html;
128
+ for (const edit of ordered) {
129
+ out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * @param {string} css
136
+ * @param {string} stylesheetPath
137
+ * @returns {string | null}
138
+ */
139
+ function prepareInlineSheet(css, stylesheetPath) {
140
+ const charset = /^\uFEFF?@charset\s+["']([^"']+)["']\s*;/.exec(css);
141
+ const charsetLabel = charset?.[1];
142
+ if (charset && charsetLabel) {
143
+ if (charsetLabel.toLowerCase() !== "utf-8") return null;
144
+ css = css.slice(charset[0].length).replace(/^\s+/, "");
145
+ }
146
+ return rewriteRelativeCssUrls(css, stylesheetPath);
147
+ }
148
+
149
+ /**
150
+ * Root-absolute urls, so an inlined sheet resolves the same files the link did.
151
+ * A relative reference that escapes the site cannot be rewritten; the caller
152
+ * leaves the links in place.
153
+ * @param {string} css
154
+ * @param {string} stylesheetPath
155
+ * @returns {string | null}
156
+ */
157
+ function rewriteRelativeCssUrls(css, stylesheetPath) {
158
+ const cssRel = stylesheetPath.replace(/^\/+/, "");
159
+ let escaped = false;
160
+ const out = css.replace(CSS_REF_RE, (whole, q1, u1, q2, u2) => {
161
+ const raw = (u1 ?? u2 ?? "").trim();
162
+ if (!raw || raw.startsWith("#") || raw.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return whole;
163
+ const cut = raw.search(/[?#]/);
164
+ const path = cut === -1 ? raw : raw.slice(0, cut);
165
+ const suffix = cut === -1 ? "" : raw.slice(cut);
166
+ if (path.startsWith("/")) return whole;
167
+ let rel = null;
168
+ try {
169
+ rel = resolveRelative(cssRel, decodeURIComponent(path));
170
+ } catch {
171
+ rel = null;
172
+ }
173
+ if (!rel) {
174
+ escaped = true;
175
+ return whole;
176
+ }
177
+ const target = `/${rel}${suffix}`;
178
+ return u1 !== undefined ? `url(${q1}${target}${q1})` : `@import ${q2}${target}${q2}`;
179
+ });
180
+ return escaped ? null : out;
181
+ }
182
+
183
+ /**
184
+ * @param {string} fromRel
185
+ * @param {string} ref
186
+ * @returns {string | null}
187
+ */
188
+ function resolveRelative(fromRel, ref) {
189
+ const parts = fromRel.split("/").slice(0, -1);
190
+ for (const seg of ref.split("/")) {
191
+ if (seg === "" || seg === ".") continue;
192
+ if (seg === "..") {
193
+ if (!parts.length) return null;
194
+ parts.pop();
195
+ } else parts.push(seg);
196
+ }
197
+ return parts.length ? parts.join("/") : null;
198
+ }
199
+
200
+ /**
201
+ * @param {string} css
202
+ */
203
+ function escapeStyleBody(css) {
204
+ return css.replace(/<\/style/gi, "<\\/style");
205
+ }
206
+
207
+ /**
208
+ * @param {string} html
209
+ * @returns {{ start: number, end: number, path: string, member: boolean }[]}
210
+ */
211
+ function blockingStylesheetLinks(html) {
212
+ const head = headSpan(html);
213
+ const comments = commentRanges(html);
214
+ /** @type {{ start: number, end: number, path: string, member: boolean }[]} */
215
+ const sheets = [];
216
+ for (const match of html.slice(head.start, head.end).matchAll(/<link\b([^>]*)>/gi)) {
217
+ const start = head.start + (match.index ?? 0);
218
+ if (inside(comments, start)) continue;
219
+ const attrs = match[1] ?? "";
220
+ const parsed = parseTagAttrs(attrs);
221
+ const rel = relTokens(parsed.rel);
222
+ if (!rel.includes("stylesheet") || rel.includes("alternate")) continue;
223
+ if (hasFlag(attrs, "disabled")) continue;
224
+ const media = (parsed.media || "").trim().toLowerCase();
225
+ if (media === "print") continue;
226
+ const href = (parsed.href || "").trim();
227
+ const path = pathnameOf(href);
228
+ const local = href.startsWith("/") && !href.startsWith("//");
229
+ const integrity = Object.prototype.hasOwnProperty.call(parsed, "integrity");
230
+ const odd = !SIMPLE_MEDIA.has(media);
231
+ if (!path) continue;
232
+ const member = local && !integrity && !odd && isTenantThemeStylesheetPath(path);
233
+ sheets.push({
234
+ start,
235
+ end: start + match[0].length,
236
+ path,
237
+ member,
238
+ });
239
+ }
240
+ return sheets;
241
+ }
242
+
243
+ /**
244
+ * @param {string} html
245
+ */
246
+ function headSpan(html) {
247
+ const explicit = /<head[\s>][\s\S]*?<\/head>/i.exec(html);
248
+ if (explicit && explicit.index !== undefined) {
249
+ return { start: explicit.index, end: explicit.index + explicit[0].length };
250
+ }
251
+ const bodyAt = html.search(/<body[\s>]/i);
252
+ return { start: 0, end: bodyAt >= 0 ? bodyAt : html.length };
253
+ }
254
+
255
+ /**
256
+ * @param {string} html
257
+ * @returns {[number, number][]}
258
+ */
259
+ function commentRanges(html) {
260
+ /** @type {[number, number][]} */
261
+ const ranges = [];
262
+ for (const match of html.matchAll(/<!--[\s\S]*?-->/g)) {
263
+ const start = match.index ?? 0;
264
+ ranges.push([start, start + match[0].length]);
265
+ }
266
+ return ranges;
267
+ }
268
+
269
+ /**
270
+ * @param {[number, number][]} ranges
271
+ * @param {number} index
272
+ */
273
+ function inside(ranges, index) {
274
+ return ranges.some(([start, end]) => index >= start && index < end);
275
+ }
276
+
277
+ /**
278
+ * @param {string} href
279
+ * @returns {string}
280
+ */
281
+ function pathnameOf(href) {
282
+ const bare = href.split(/[?#]/)[0] ?? "";
283
+ if (/^https?:/i.test(bare)) {
284
+ try {
285
+ return new URL(bare).pathname;
286
+ } catch {
287
+ return bare;
288
+ }
289
+ }
290
+ return bare;
291
+ }
292
+
293
+ /**
294
+ * @param {string} attrs
295
+ * @returns {Record<string, string>}
296
+ */
297
+ function parseTagAttrs(attrs) {
298
+ /** @type {Record<string, string>} */
299
+ const out = {};
300
+ for (const match of attrs.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
301
+ const name = match[1];
302
+ if (!name) continue;
303
+ out[name.toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? "";
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * @param {string} attrs
310
+ * @param {string} name
311
+ */
312
+ function hasFlag(attrs, name) {
313
+ return new RegExp(`(?:^|\\s)${name}(?=\\s|/|=|$)`, "i").test(attrs);
314
+ }
315
+
316
+ /**
317
+ * @param {string | undefined} rel
318
+ */
319
+ function relTokens(rel) {
320
+ return String(rel || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
321
+ }
@@ -492,6 +492,19 @@ function validatePromoTiles(block, file, at, out, tenantId) {
492
492
  }
493
493
  }
494
494
 
495
+ function validateHeroMediaFields(owner, file, at, out, tenantId) {
496
+ if ((owner.width === undefined) !== (owner.height === undefined)) out.push(mk(ERROR, "block-prop", file, `${at}.width and ${at}.height must be supplied together`));
497
+ for (const prop of ["width", "height"]) {
498
+ if (owner[prop] !== undefined && (typeof owner[prop] !== "number" || !Number.isInteger(owner[prop]) || owner[prop] <= 0)) {
499
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a positive integer`));
500
+ }
501
+ }
502
+ if (owner.imageMobile !== undefined) {
503
+ if (typeof owner.imageMobile !== "string" || !owner.imageMobile.trim()) out.push(mk(ERROR, "block-prop", file, `${at}.imageMobile must be a non-empty string`));
504
+ else validateVisualAsset(owner.imageMobile, file, `${at}.imageMobile`, out, tenantId);
505
+ }
506
+ }
507
+
495
508
  function validateHeroSlides(slides, file, at, out, tenantId) {
496
509
  if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
497
510
  slides.forEach((slide, index) => {
@@ -499,6 +512,7 @@ function validateHeroSlides(slides, file, at, out, tenantId) {
499
512
  if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
500
513
  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`));
501
514
  validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
515
+ validateHeroMediaFields(slide, file, slideAt, out, tenantId);
502
516
  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`));
503
517
  if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
504
518
  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`));
@@ -601,6 +615,7 @@ function validateHomeDoc(doc, file, tenantId) {
601
615
  if (component === "hero" && block.image !== undefined) {
602
616
  validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
603
617
  }
618
+ if (component === "hero") validateHeroMediaFields(block, file, at, out, tenantId);
604
619
  if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
605
620
  if (component === "hero" && block.notice !== undefined && (typeof block.notice !== "string" || !block.notice.trim())) {
606
621
  out.push(mk(ERROR, "block-prop", file, `${at}.notice must be a non-empty plain-text string`));