@bygghammar/cms-platform 0.61.1 → 0.62.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.61.1",
3
+ "version": "0.62.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -219,7 +219,10 @@
219
219
  "./server/page-seo-save-core": "./src/server/page-seo-save-core.ts",
220
220
  "./site/routes/public-page": "./src/site/routes/public-page.tsx",
221
221
  "./site/routes/preview-page": "./src/site/routes/preview-page.tsx",
222
- "./site/routes/section-preview": "./src/site/routes/section-preview.tsx"
222
+ "./site/routes/section-preview": "./src/site/routes/section-preview.tsx",
223
+ "./media/priority": "./src/media/priority.tsx",
224
+ "./media/preload": "./src/media/preload.ts",
225
+ "./ui/NearViewport": "./src/ui/NearViewport.tsx"
223
226
  },
224
227
  "dependencies": {
225
228
  "fflate": "^0.8.3"
@@ -2,6 +2,7 @@ import { ArrowRight, ChevronDown } from "lucide-react";
2
2
  import { useT } from "../../i18n";
3
3
  import { FocalImage } from "../../host";
4
4
  import { LazyVideo } from "../../host";
5
+ import { useClaimPriority } from "../../media/priority";
5
6
  import type { HeroFullProps } from "../types";
6
7
 
7
8
  const HEIGHTS: Record<NonNullable<HeroFullProps["height"]>, string> = {
@@ -56,6 +57,12 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
56
57
  const poster = p.posterSrc;
57
58
  const posterMobile = p.posterSrcMobile || poster;
58
59
 
60
+ // A hero image is a candidate for the page's one priority image, but only the
61
+ // first block to ask wins: an editor can place a hero halfway down a page.
62
+ const heroIsPriority = useClaimPriority(
63
+ mediaType === "image" ? image || imageMobile : null,
64
+ );
65
+
59
66
  const alignClass =
60
67
  align === "center" ? "items-center text-center" : align === "right" ? "items-end text-right" : "items-start text-left";
61
68
  const rowClass =
@@ -128,25 +135,23 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
128
135
  />
129
136
  </>
130
137
  ) : image || imageMobile ? (
131
- <>
132
- {image ? (
133
- <FocalImage
134
- src={image}
135
- alt={t(p.imageAlt) || ""}
136
- sizes="100vw"
137
- className="hidden size-full object-cover md:block"
138
- />
139
- ) : null}
140
- {imageMobile ? (
141
- <FocalImage
142
- src={imageMobile}
143
- alt={t(p.imageAlt) || ""}
144
- sizes="100vw"
145
- className="size-full object-cover md:hidden"
146
- />
147
- ) : null}
148
- </>
138
+ // One <picture> with one <img>: two CSS-hidden images can both be
139
+ // fetched, so the hidden twin would double the hero's cost. The
140
+ // mobile artwork becomes a media-scoped <source> instead.
141
+ <FocalImage
142
+ src={image || imageMobile}
143
+ artDirected={
144
+ imageMobile && image && imageMobile !== image
145
+ ? [{ media: "(max-width: 767px)", src: imageMobile }]
146
+ : undefined
147
+ }
148
+ alt={t(p.imageAlt) || ""}
149
+ sizes="100vw"
150
+ priority={heroIsPriority}
151
+ className="size-full object-cover"
152
+ />
149
153
  ) : null}
154
+
150
155
  <div className={`absolute inset-0 ${OVERLAYS[p.overlay ?? "medium"]}`} />
151
156
  </div>
152
157
 
@@ -1,7 +1,9 @@
1
+ import { lazy, Suspense } from "react";
1
2
  import { Instagram as InstagramIcon, ExternalLink } from "lucide-react";
2
3
  import { useT } from "../../i18n";
3
- import type { InstagramProps, InstagramItem } from "../types";
4
- import { FocalImage } from "../../host";
4
+ import type { InstagramProps } from "../types";
5
+ import { NearViewport, ChunkErrorBoundary } from "../../ui/NearViewport";
6
+ import { ASPECT_CLASS } from "./InstagramTiles";
5
7
 
6
8
  const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
7
9
  none: "gap-0",
@@ -10,61 +12,16 @@ const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
10
12
  lg: "gap-5 md:gap-8",
11
13
  };
12
14
 
13
- const ASPECT_CLASS: Record<NonNullable<InstagramProps["aspect"]>, string> = {
14
- square: "aspect-square",
15
- portrait: "aspect-[4/5]",
16
- landscape: "aspect-[4/3]",
17
- };
15
+ // Dynamic import + a near-viewport gate. `lazy` alone would fetch the chunk on
16
+ // first render, which is exactly what we are trying to avoid for a wall of
17
+ // photos that usually sits at the bottom of the page.
18
+ const InstagramTiles = lazy(() => import("./InstagramTiles"));
18
19
 
19
20
  function clamp(n: number | undefined, min: number, max: number, fallback: number) {
20
21
  const v = typeof n === "number" && Number.isFinite(n) ? n : fallback;
21
22
  return Math.min(max, Math.max(min, v));
22
23
  }
23
24
 
24
- function Tile({
25
- item,
26
- aspect,
27
- showOverlay,
28
- }: {
29
- item: InstagramItem;
30
- aspect: NonNullable<InstagramProps["aspect"]>;
31
- showOverlay: boolean;
32
- }) {
33
- const { t } = useT();
34
- const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
35
- const inner = (
36
- <div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
37
- {item.image ? (
38
- <FocalImage
39
- src={item.image}
40
- responsive={false}
41
- alt={alt}
42
- loading="lazy"
43
- className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
44
- />
45
- ) : (
46
- <div className="absolute inset-0 grid place-items-center text-ink/25">
47
- <InstagramIcon className="size-8" />
48
- </div>
49
- )}
50
- {showOverlay && item.image ? (
51
- <div className="absolute inset-0 grid place-items-center bg-ink/0 group-hover:bg-ink/40 transition-colors">
52
- <InstagramIcon className="size-6 text-paper opacity-0 group-hover:opacity-100 transition-opacity" />
53
- </div>
54
- ) : null}
55
- </div>
56
- );
57
-
58
- if (item.href) {
59
- return (
60
- <a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
61
- {inner}
62
- </a>
63
- );
64
- }
65
- return inner;
66
- }
67
-
68
25
  export function InstagramBlock({ props }: { props: InstagramProps }) {
69
26
  const { t } = useT();
70
27
  const layout = props.layout ?? "grid";
@@ -85,11 +42,27 @@ export function InstagramBlock({ props }: { props: InstagramProps }) {
85
42
  const profileHref =
86
43
  props.profileHref ?? (handle ? `https://instagram.com/${handle}` : undefined);
87
44
 
88
- // Inline styles for arbitrary column counts (Tailwind JIT can't infer runtime numbers).
89
- const gridStyle = {
90
- display: "grid",
91
- gridTemplateColumns: `repeat(var(--ig-cols, ${cM}), minmax(0, 1fr))`,
92
- } as React.CSSProperties;
45
+ // Reserve the tile wall's height so nothing moves when the chunk arrives:
46
+ // the same aspect ratio, the same number of rows, the same gaps.
47
+ const reservedRows = layout === "grid" ? rows : 1;
48
+ const placeholder = (
49
+ <div className={`ig-reserve ${gap}`} style={{ display: "grid", gridTemplateColumns: "1fr" }}>
50
+ {Array.from({ length: reservedRows }).map((_, r) => (
51
+ <div key={r} className={`${ASPECT_CLASS[aspect]} w-full bg-ink/5`} />
52
+ ))}
53
+ </div>
54
+ );
55
+
56
+ const staticFallback = profileHref ? (
57
+ <a
58
+ href={profileHref}
59
+ target="_blank"
60
+ rel="noreferrer noopener"
61
+ className="inline-flex items-center gap-2 text-sm text-ink underline"
62
+ >
63
+ View the photos on Instagram <ExternalLink className="size-3.5" />
64
+ </a>
65
+ ) : null;
93
66
 
94
67
  return (
95
68
  <section className="bg-white py-20 md:py-28">
@@ -122,38 +95,28 @@ export function InstagramBlock({ props }: { props: InstagramProps }) {
122
95
  </div>
123
96
  ) : null}
124
97
 
125
- {layout === "carousel" ? (
126
- <div className={`flex overflow-x-auto snap-x snap-mandatory ${gap} -mx-6 px-6 pb-2`}>
127
- {items.map((item) => (
128
- <div
129
- key={item.id}
130
- className="snap-start shrink-0"
131
- style={{ width: `min(80vw, ${100 / cD}%)` }}
132
- >
133
- <Tile item={item} aspect={aspect} showOverlay={showOverlay} />
134
- </div>
135
- ))}
136
- </div>
98
+ {items.length > 0 ? (
99
+ <NearViewport placeholder={placeholder}>
100
+ <ChunkErrorBoundary fallback={staticFallback}>
101
+ <Suspense fallback={placeholder}>
102
+ <InstagramTiles
103
+ items={items}
104
+ layout={layout}
105
+ aspect={aspect}
106
+ showOverlay={showOverlay}
107
+ gap={gap}
108
+ columnsMobile={cM}
109
+ columnsTablet={cT}
110
+ columnsDesktop={cD}
111
+ />
112
+ </Suspense>
113
+ </ChunkErrorBoundary>
114
+ </NearViewport>
137
115
  ) : (
138
- <>
139
- <style>{`
140
- .ig-grid { --ig-cols: ${cM}; }
141
- @media (min-width: 768px) { .ig-grid { --ig-cols: ${cT}; } }
142
- @media (min-width: 1024px) { .ig-grid { --ig-cols: ${cD}; } }
143
- `}</style>
144
- <div className={`ig-grid ${gap}`} style={gridStyle}>
145
- {items.map((item) => (
146
- <Tile key={item.id} item={item} aspect={aspect} showOverlay={showOverlay} />
147
- ))}
148
- </div>
149
- </>
150
- )}
151
-
152
- {items.length === 0 ? (
153
116
  <div className="mt-8 border border-dashed border-ink/15 p-10 text-center text-ink/70 text-sm">
154
117
  No Instagram posts yet. Add images in the block inspector.
155
118
  </div>
156
- ) : null}
119
+ )}
157
120
  </div>
158
121
  </section>
159
122
  );
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The Instagram grid/carousel itself, in its own chunk.
3
+ *
4
+ * The block's heading, handle and profile link stay in the server-rendered
5
+ * HTML (they are content, and crawlers should see them); the tile wall — dozens
6
+ * of images plus the scroll/grid machinery — is only imported once the reader
7
+ * approaches it. See `InstagramBlock`.
8
+ */
9
+ import { Instagram as InstagramIcon } from "lucide-react";
10
+ import { useT } from "../../i18n";
11
+ import { FocalImage } from "../../host";
12
+ import type { InstagramProps, InstagramItem } from "../types";
13
+
14
+ export const ASPECT_CLASS: Record<NonNullable<InstagramProps["aspect"]>, string> = {
15
+ square: "aspect-square",
16
+ portrait: "aspect-[4/5]",
17
+ landscape: "aspect-[4/3]",
18
+ };
19
+
20
+ export type InstagramTilesProps = {
21
+ items: InstagramItem[];
22
+ layout: NonNullable<InstagramProps["layout"]>;
23
+ aspect: NonNullable<InstagramProps["aspect"]>;
24
+ showOverlay: boolean;
25
+ gap: string;
26
+ columnsMobile: number;
27
+ columnsTablet: number;
28
+ columnsDesktop: number;
29
+ };
30
+
31
+ function Tile({
32
+ item,
33
+ aspect,
34
+ showOverlay,
35
+ sizes,
36
+ }: {
37
+ item: InstagramItem;
38
+ aspect: NonNullable<InstagramProps["aspect"]>;
39
+ showOverlay: boolean;
40
+ sizes: string;
41
+ }) {
42
+ const { t } = useT();
43
+ const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
44
+ const inner = (
45
+ <div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
46
+ {item.image ? (
47
+ <FocalImage
48
+ src={item.image}
49
+ sizes={sizes}
50
+ alt={alt}
51
+ className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
52
+ />
53
+ ) : (
54
+ <div className="absolute inset-0 grid place-items-center text-ink/25">
55
+ <InstagramIcon className="size-8" />
56
+ </div>
57
+ )}
58
+ {showOverlay && item.image ? (
59
+ <div className="absolute inset-0 grid place-items-center bg-ink/0 group-hover:bg-ink/40 transition-colors">
60
+ <InstagramIcon className="size-6 text-paper opacity-0 group-hover:opacity-100 transition-opacity" />
61
+ </div>
62
+ ) : null}
63
+ </div>
64
+ );
65
+
66
+ if (item.href) {
67
+ return (
68
+ <a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
69
+ {inner}
70
+ </a>
71
+ );
72
+ }
73
+ return inner;
74
+ }
75
+
76
+ /**
77
+ * `sizes` for one tile in a column grid: the tile is roughly the container width
78
+ * divided by the column count at that breakpoint, so the browser never fetches
79
+ * a full-width variant for a quarter-width tile.
80
+ */
81
+ export function tileSizes(cM: number, cT: number, cD: number): string {
82
+ const pct = (cols: number) => `${Math.round(100 / Math.max(1, cols))}vw`;
83
+ return [
84
+ `(max-width: 767px) ${pct(cM)}`,
85
+ `(max-width: 1023px) ${pct(cT)}`,
86
+ `${Math.round(1280 / Math.max(1, cD))}px`,
87
+ ].join(", ");
88
+ }
89
+
90
+ export function InstagramTiles({
91
+ items,
92
+ layout,
93
+ aspect,
94
+ showOverlay,
95
+ gap,
96
+ columnsMobile: cM,
97
+ columnsTablet: cT,
98
+ columnsDesktop: cD,
99
+ }: InstagramTilesProps) {
100
+ const sizes = tileSizes(cM, cT, cD);
101
+ const gridStyle = {
102
+ display: "grid",
103
+ gridTemplateColumns: `repeat(var(--ig-cols, ${cM}), minmax(0, 1fr))`,
104
+ } as React.CSSProperties;
105
+
106
+ if (layout === "carousel") {
107
+ return (
108
+ <div className={`flex overflow-x-auto snap-x snap-mandatory ${gap} -mx-6 px-6 pb-2`}>
109
+ {items.map((item) => (
110
+ <div
111
+ key={item.id}
112
+ className="snap-start shrink-0"
113
+ style={{ width: `min(80vw, ${100 / Math.max(1, cD)}%)` }}
114
+ >
115
+ <Tile item={item} aspect={aspect} showOverlay={showOverlay} sizes={sizes} />
116
+ </div>
117
+ ))}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <>
124
+ <style>{`
125
+ .ig-grid { --ig-cols: ${cM}; }
126
+ @media (min-width: 768px) { .ig-grid { --ig-cols: ${cT}; } }
127
+ @media (min-width: 1024px) { .ig-grid { --ig-cols: ${cD}; } }
128
+ `}</style>
129
+ <div className={`ig-grid ${gap}`} style={gridStyle}>
130
+ {items.map((item) => (
131
+ <Tile key={item.id} item={item} aspect={aspect} showOverlay={showOverlay} sizes={sizes} />
132
+ ))}
133
+ </div>
134
+ </>
135
+ );
136
+ }
137
+
138
+ export default InstagramTiles;
@@ -7,6 +7,7 @@ import { Reveal } from "../../ui/Reveal";
7
7
  import { Lightbox } from "../../ui/Lightbox";
8
8
  import { FocalImage } from "../../host";
9
9
  import { LazyVideo } from "../../host";
10
+ import { useClaimPriority } from "../../media/priority";
10
11
  import type { MediaSplitProps, MediaSplitSlide } from "../types";
11
12
 
12
13
 
@@ -48,6 +49,9 @@ function Slideshow({
48
49
  radius: string;
49
50
  }) {
50
51
  const { t } = useT();
52
+ // The first slide is the only slide visible on load, so it is this block's
53
+ // priority candidate — but only if no earlier block already claimed it.
54
+ const slidePriority = useClaimPriority(slides[0]?.image ?? null);
51
55
  const trackRef = useRef<HTMLDivElement>(null);
52
56
  const [index, setIndex] = useState(0);
53
57
  const indexRef = useRef(0);
@@ -129,7 +133,7 @@ function Slideshow({
129
133
  src={s.image}
130
134
  sizes="(min-width: 768px) 50vw, 100vw"
131
135
  alt={alt}
132
- loading={i === 0 ? "eager" : "lazy"}
136
+ priority={i === 0 && slidePriority}
133
137
  className={`absolute inset-0 h-full w-full ${props.mediaFit === "contain" ? "object-contain" : "object-cover"}`}
134
138
  />
135
139
  ) : (
@@ -202,6 +206,11 @@ function Slideshow({
202
206
  * then the rest of the text.
203
207
  */
204
208
  export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
209
+ // Claimed before rendering the media so a media-split placed first on a page
210
+ // gets the priority image when there is no hero above it.
211
+ const imagePriority = useClaimPriority(
212
+ props.mediaType === "slideshow" || props.mediaType === "video" ? null : props.image ?? null,
213
+ );
205
214
  const { t } = useT();
206
215
  const side = props.mediaSide ?? "left";
207
216
  const tone = props.tone ?? "white";
@@ -300,6 +309,7 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
300
309
  src={props.image}
301
310
  sizes="(min-width: 768px) 50vw, 100vw"
302
311
  alt={props.imageAlt ? t(props.imageAlt as Parameters<typeof t>[0]) : ""}
312
+ priority={imagePriority}
303
313
  className={`absolute inset-0 h-full w-full ${fitClass}`}
304
314
  />
305
315
  </div>
@@ -12,11 +12,21 @@ import type { LocalizedString } from "../i18n/types";
12
12
  * server functions per-site while block markup and inspector UI stay shared.
13
13
  */
14
14
 
15
+ export type HostArtDirectedSource = {
16
+ media: string;
17
+ src: string;
18
+ sizes?: string;
19
+ };
20
+
15
21
  export type HostFocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
16
22
  src: string | undefined;
17
23
  /** When set, the host may build a responsive srcSet. */
18
24
  sizes?: string;
19
25
  responsive?: boolean;
26
+ /** Breakpoint-specific artwork, so only one file downloads per viewport. */
27
+ artDirected?: HostArtDirectedSource[];
28
+ /** The page's single first-screen image: eager, high fetch priority. */
29
+ priority?: boolean;
20
30
  };
21
31
 
22
32
  export type HostLazyVideoProps = {
@@ -67,17 +67,42 @@ export function useFocalStyle(src: string | undefined): {
67
67
  };
68
68
  }
69
69
 
70
+ export type ArtDirectedSource = {
71
+ /** Media query this artwork applies to, e.g. `(max-width: 767px)`. */
72
+ media: string;
73
+ /** The image URL for that breakpoint. */
74
+ src: string;
75
+ /** `sizes` for that breakpoint; defaults to the element's `sizes`. */
76
+ sizes?: string;
77
+ };
78
+
70
79
  export type FocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
71
80
  src: string | undefined;
72
81
  /** When set, builds a responsive srcSet from the media library variants. */
73
82
  sizes?: string;
74
83
  responsive?: boolean;
84
+ /**
85
+ * Breakpoint-specific artwork. Rendered as `<source media=…>` before the
86
+ * default candidates, so exactly ONE file downloads per viewport — unlike two
87
+ * CSS-hidden `<img>` elements, which can both be fetched.
88
+ */
89
+ artDirected?: ArtDirectedSource[];
90
+ /**
91
+ * Marks this as the page's largest first-screen image: eager, high fetch
92
+ * priority. At most one image per page should set it — `HeroFullBlock` and
93
+ * other first-screen blocks claim it through `useClaimPriority`.
94
+ */
95
+ priority?: boolean;
75
96
  };
76
97
 
77
98
  /**
78
99
  * `<img>` that always crops around the image's editor-chosen focus point.
79
100
  * Falls back to a centred crop when no focus point is stored.
80
101
  *
102
+ * Loading defaults are safe rather than fast: `loading="lazy"` and
103
+ * `decoding="async"`, so an image nobody asked to prioritize can never compete
104
+ * with the first screen. The one image that should compete says `priority`.
105
+ *
81
106
  * When the source is a responsive WebP variant with a `-fallback.png` sibling,
82
107
  * renders a `<picture>` so browsers that do not support WebP (or need explicit
83
108
  * alpha handling) still receive a transparent PNG.
@@ -86,8 +111,13 @@ export function FocalImage({
86
111
  src,
87
112
  sizes,
88
113
  responsive = true,
114
+ artDirected,
115
+ priority = false,
89
116
  style,
90
117
  onError,
118
+ loading,
119
+ decoding,
120
+ fetchPriority,
91
121
  ...rest
92
122
  }: FocalImageProps) {
93
123
  const focal = useFocalStyle(src);
@@ -97,10 +127,37 @@ export function FocalImage({
97
127
  const [srcSetFailed, setSrcSetFailed] = useState(false);
98
128
  useEffect(() => setSrcSetFailed(false), [src]);
99
129
 
130
+ if (process.env['NODE_ENV'] !== "production" && priority) {
131
+ if (loading === "lazy") {
132
+ console.warn(
133
+ "[FocalImage] priority image was also given loading=\"lazy\"; priority wins.",
134
+ );
135
+ }
136
+ if (fetchPriority && fetchPriority !== "high") {
137
+ console.warn(
138
+ `[FocalImage] priority image was also given fetchPriority="${fetchPriority}"; priority wins.`,
139
+ );
140
+ }
141
+ }
142
+
143
+ // `priority` always wins over conflicting props; otherwise the caller decides
144
+ // and, failing that, the safe default applies. `decoding` is never "sync":
145
+ // blocking the main thread on a decode delays paint instead of helping it.
146
+ const loadingAttr = priority ? "eager" : (loading ?? "lazy");
147
+ const fetchPriorityAttr = priority ? "high" : fetchPriority;
148
+ const decodingAttr = decoding === "sync" ? "async" : (decoding ?? "async");
149
+
100
150
  const img = responsive && !srcSetFailed ? responsiveImage(src, sizes) : { src: src ?? "" };
101
151
  const merged: CSSProperties = { ...focal.style, ...style };
102
152
  const className = [focal.className, rest.className].filter(Boolean).join(" ");
103
153
 
154
+ const imgProps = {
155
+ ...rest,
156
+ loading: loadingAttr,
157
+ decoding: decodingAttr,
158
+ fetchPriority: fetchPriorityAttr,
159
+ };
160
+
104
161
  // Only wrap in <picture> when a fallback PNG is advertised. The fallback URL
105
162
  // is always derived, but it only exists for transparent uploads; using it for
106
163
  // every image would cause unnecessary 404s on opaque assets.
@@ -112,17 +169,33 @@ export function FocalImage({
112
169
  img.type === "image/webp" &&
113
170
  img.srcSet;
114
171
 
115
- if (hasFallback && "fallbackSrc" in img) {
172
+ const artSources =
173
+ responsive && !srcSetFailed
174
+ ? (artDirected ?? [])
175
+ .map((a) => ({ media: a.media, img: responsiveImage(a.src, a.sizes ?? sizes) }))
176
+ .filter((a) => a.img.src)
177
+ : [];
178
+
179
+ if (artSources.length || (hasFallback && "fallbackSrc" in img)) {
116
180
  // The <picture> is only a source selector: it must not take the layout or
117
181
  // focus classes, or the inner <img> loses `object-position` and the crop
118
182
  // ignores the editor-chosen focus point.
119
183
  return (
120
184
  <picture style={{ display: "contents" }}>
121
- <source srcSet={img.srcSet} sizes={img.sizes} type="image/webp" />
185
+ {artSources.map((a) => (
186
+ <source
187
+ key={a.media}
188
+ media={a.media}
189
+ srcSet={a.img.srcSet ?? a.img.src}
190
+ sizes={a.img.srcSet ? a.img.sizes : undefined}
191
+ type={a.img.srcSet ? "image/webp" : undefined}
192
+ />
193
+ ))}
194
+ {img.srcSet ? <source srcSet={img.srcSet} sizes={img.sizes} type="image/webp" /> : null}
122
195
  <img
123
- {...rest}
196
+ {...imgProps}
124
197
  className={className}
125
- src={img.fallbackSrc}
198
+ src={hasFallback && "fallbackSrc" in img ? img.fallbackSrc : img.src}
126
199
  alt={rest.alt}
127
200
  style={merged}
128
201
  onError={(event) => {
@@ -136,7 +209,7 @@ export function FocalImage({
136
209
 
137
210
  return (
138
211
  <img
139
- {...rest}
212
+ {...imgProps}
140
213
  className={className}
141
214
  src={img.src}
142
215
  srcSet={"srcSet" in img ? img.srcSet : undefined}
@@ -150,3 +223,4 @@ export function FocalImage({
150
223
  />
151
224
  );
152
225
  }
226
+
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Preload attributes for a page's one priority image.
3
+ *
4
+ * A preload only helps when the browser would otherwise discover the image
5
+ * late: a CSS background, an image inserted after hydration, or artwork behind
6
+ * a client-only component. When the image is already an `<img>` in the
7
+ * server-rendered HTML, `fetchpriority="high"` on that element is enough — a
8
+ * preload adds a second candidate list that can disagree with the rendered one
9
+ * and fetch a second file. `heroPreloadLink` therefore refuses to build a link
10
+ * unless the caller states the image is discovered late.
11
+ *
12
+ * The attributes are derived from exactly the same `responsiveImage()` output
13
+ * the rendered element uses, so the browser cannot pick a different candidate.
14
+ */
15
+ import { responsiveImage } from "./srcset";
16
+
17
+ export type PreloadLink = {
18
+ rel: "preload";
19
+ as: "image";
20
+ href: string;
21
+ imageSrcSet?: string;
22
+ imageSizes?: string;
23
+ media?: string;
24
+ fetchPriority: "high";
25
+ crossOrigin?: "anonymous";
26
+ };
27
+
28
+ export type HeroPreloadInput = {
29
+ /** The image URL the page will render. */
30
+ src: string | undefined | null;
31
+ /** The exact `sizes` the rendered element uses. */
32
+ sizes?: string;
33
+ /** Media query, when the artwork is art-directed per breakpoint. */
34
+ media?: string;
35
+ /**
36
+ * True only when the image is NOT an `<img>`/`<picture>` in the initial HTML
37
+ * (CSS background, client-only component, post-hydration insert).
38
+ */
39
+ discoveredLate: boolean;
40
+ /** Set when the asset is served from another origin with CORS. */
41
+ crossOrigin?: "anonymous";
42
+ };
43
+
44
+ /**
45
+ * The preload link for a late-discovered hero, or null when preloading would
46
+ * be redundant (already in the HTML) or impossible (no image).
47
+ */
48
+ export function heroPreloadLink(input: HeroPreloadInput): PreloadLink | null {
49
+ if (!input.src || !input.discoveredLate) return null;
50
+ const img = responsiveImage(input.src, input.sizes);
51
+ if (!img.src) return null;
52
+ const link: PreloadLink = {
53
+ rel: "preload",
54
+ as: "image",
55
+ href: img.src,
56
+ fetchPriority: "high",
57
+ };
58
+ if (img.srcSet) {
59
+ link.imageSrcSet = img.srcSet;
60
+ link.imageSizes = img.sizes;
61
+ }
62
+ if (input.media) link.media = input.media;
63
+ if (input.crossOrigin) link.crossOrigin = input.crossOrigin;
64
+ return link;
65
+ }
66
+
67
+ /**
68
+ * The same link as the `head().links` shape TanStack routes expect: React DOM
69
+ * spells these attributes camelCase, the HTML attributes are lowercase.
70
+ */
71
+ export function heroPreloadHeadLink(
72
+ input: HeroPreloadInput,
73
+ ): Record<string, string> | null {
74
+ const link = heroPreloadLink(input);
75
+ if (!link) return null;
76
+ const out: Record<string, string> = {
77
+ rel: link.rel,
78
+ as: link.as,
79
+ href: link.href,
80
+ fetchpriority: link.fetchPriority,
81
+ };
82
+ if (link.imageSrcSet) out.imagesrcset = link.imageSrcSet;
83
+ if (link.imageSizes) out.imagesizes = link.imageSizes;
84
+ if (link.media) out.media = link.media;
85
+ if (link.crossOrigin) out.crossorigin = link.crossOrigin;
86
+ return out;
87
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Exactly one priority image per page.
3
+ *
4
+ * The largest image on the first screen should download eagerly at high fetch
5
+ * priority; every other image on the page should not. Which image that is
6
+ * depends on what the editor put first, so it cannot be hardcoded to one block
7
+ * type — and it must be decided while rendering (server included), never after
8
+ * hydration by measuring the viewport, or the decision arrives too late to
9
+ * matter.
10
+ *
11
+ * A page wraps its rendered blocks in `PriorityMediaProvider`. The first image
12
+ * that asks (`useClaimPriority`) during the first render pass wins, and every
13
+ * later asker is told no. Render order is deterministic and identical on the
14
+ * server and the client, so the same image wins in both.
15
+ */
16
+ import { createContext, useContext, useRef, type ReactNode } from "react";
17
+
18
+ type Claim = {
19
+ /** The winning claim key, or null while nobody has claimed yet. */
20
+ claimed: string | null;
21
+ /** Whether this page allows a priority image at all. */
22
+ enabled: boolean;
23
+ };
24
+
25
+ const PriorityContext = createContext<{ ref: { current: Claim } } | null>(null);
26
+
27
+ /**
28
+ * Scopes the single priority claim to one page render.
29
+ *
30
+ * `enabled` exists for renders where no image should be prioritized at all —
31
+ * the block library preview, an admin thumbnail grid, an email render.
32
+ */
33
+ export function PriorityMediaProvider({
34
+ enabled = true,
35
+ children,
36
+ }: {
37
+ enabled?: boolean;
38
+ children: ReactNode;
39
+ }) {
40
+ const ref = useRef<Claim>({ claimed: null, enabled });
41
+ ref.current.enabled = enabled;
42
+ return <PriorityContext.Provider value={{ ref }}>{children}</PriorityContext.Provider>;
43
+ }
44
+
45
+ /**
46
+ * Ask to be the page's priority image.
47
+ *
48
+ * `key` is normally the image URL. Asking twice with the same key (a component
49
+ * that re-renders) keeps the claim rather than losing it to a later image.
50
+ * Outside a provider nothing is prioritized, which is the safe answer for
51
+ * previews and standalone component renders.
52
+ */
53
+ export function useClaimPriority(key: string | undefined | null): boolean {
54
+ const ctx = useContext(PriorityContext);
55
+ if (!ctx || !key) return false;
56
+ const state = ctx.ref.current;
57
+ if (!state.enabled) return false;
58
+ if (state.claimed === null) {
59
+ state.claimed = key;
60
+ return true;
61
+ }
62
+ return state.claimed === key;
63
+ }
64
+
65
+ /** The claim that won, for tests and for deriving a matching preload link. */
66
+ export function usePriorityClaim(): string | null {
67
+ const ctx = useContext(PriorityContext);
68
+ return ctx ? ctx.ref.current.claimed : null;
69
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
9
9
 
10
+ import { PriorityMediaProvider } from "../media/priority";
10
11
  import type { BlockDef } from "../blocks/registry";
11
12
  import type { BlockInstance, BlockProps } from "../blocks/types";
12
13
 
@@ -91,8 +92,11 @@ export function DraftPageRendererView({
91
92
  edit?: boolean;
92
93
  selectedId?: string | null;
93
94
  }) {
95
+ // Every page render gets its own priority scope, so the first first-screen
96
+ // image loads eagerly and the rest stay lazy. In the builder (`edit`) the
97
+ // editor jumps around the page, so no image is treated as the hero.
94
98
  return (
95
- <>
99
+ <PriorityMediaProvider enabled={!edit}>
96
100
  {blocks.map((b) => (
97
101
  <RenderBlock
98
102
  key={b.id}
@@ -103,7 +107,7 @@ export function DraftPageRendererView({
103
107
  selectedId={selectedId}
104
108
  />
105
109
  ))}
106
- </>
110
+ </PriorityMediaProvider>
107
111
  );
108
112
  }
109
113
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Renders its children only once the placeholder is near the viewport.
3
+ *
4
+ * `React.lazy` on its own does not defer anything: if the lazy element is part
5
+ * of the first render, its chunk is requested immediately. Heavy integrations
6
+ * (embeds, interactive maps, third-party scripts) therefore need a second gate
7
+ * — this one — so the import starts when the reader is actually approaching.
8
+ *
9
+ * The placeholder keeps the block's height, so nothing shifts when the real
10
+ * content arrives. Without IntersectionObserver (old browsers, SSR) the gate
11
+ * opens on mount rather than never.
12
+ */
13
+ import { Component, useEffect, useRef, useState, type ReactNode } from "react";
14
+
15
+ export type NearViewportProps = {
16
+ children: ReactNode;
17
+ /** Space reserved before loading; keep it equal to the loaded height. */
18
+ placeholder?: ReactNode;
19
+ /** How early to start loading. */
20
+ rootMargin?: string;
21
+ className?: string;
22
+ };
23
+
24
+ export function NearViewport({
25
+ children,
26
+ placeholder = null,
27
+ rootMargin = "800px 0px",
28
+ className,
29
+ }: NearViewportProps) {
30
+ const ref = useRef<HTMLDivElement | null>(null);
31
+ const [near, setNear] = useState(false);
32
+
33
+ useEffect(() => {
34
+ if (near) return;
35
+ const node = ref.current;
36
+ if (!node) return;
37
+ if (typeof IntersectionObserver === "undefined") {
38
+ setNear(true);
39
+ return;
40
+ }
41
+ const io = new IntersectionObserver(
42
+ (entries) => {
43
+ if (entries.some((e) => e.isIntersecting)) {
44
+ setNear(true);
45
+ io.disconnect();
46
+ }
47
+ },
48
+ { rootMargin },
49
+ );
50
+ io.observe(node);
51
+ return () => io.disconnect();
52
+ }, [near, rootMargin]);
53
+
54
+ return (
55
+ <div ref={ref} className={className}>
56
+ {near ? children : placeholder}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Keeps one failed chunk load from blanking the page.
63
+ *
64
+ * A deferred chunk is fetched long after the document loaded, so a flaky
65
+ * network or a deploy that replaced the file is a normal outcome, not a bug.
66
+ * The fallback keeps the reserved space and whatever static alternative the
67
+ * block passed in.
68
+ */
69
+ export class ChunkErrorBoundary extends Component<
70
+ { children: ReactNode; fallback?: ReactNode },
71
+ { failed: boolean }
72
+ > {
73
+ state = { failed: false };
74
+
75
+ static getDerivedStateFromError() {
76
+ return { failed: true };
77
+ }
78
+
79
+ render() {
80
+ if (this.state.failed) return this.props.fallback ?? null;
81
+ return this.props.children;
82
+ }
83
+ }