@tokenoftrust/storefront-runner 3.12.0 → 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
|
-
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.12.
|
|
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",
|
|
@@ -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`));
|