@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.
- package/apps/storefront/src/components/content/storefront/home/Hero.astro +85 -23
- package/apps/storefront/src/lib/content/shared/heroSlideshow.ts +43 -15
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +10 -0
- package/apps/storefront/src/middleware/renderChain.ts +202 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/theme-stylesheets.mjs +321 -0
- package/scripts/tenant/validate.mjs +15 -0
|
@@ -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
|
-
|
|
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={
|
|
115
|
-
height={
|
|
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
|
-
</
|
|
165
|
+
</picture>
|
|
122
166
|
) : (
|
|
123
167
|
<img
|
|
124
168
|
src={block.image}
|
|
125
169
|
alt={block.imageAlt ?? block.headline}
|
|
126
|
-
width={
|
|
127
|
-
height={
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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;
|
|
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:
|
|
296
|
-
.hero-slideshow__slide > a
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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`));
|