create-nextblock 0.13.11 → 0.13.12
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 +1 -1
- package/templates/nextblock-template/components/BlockRenderer.tsx +4 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +3 -0
- package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +4 -0
- package/templates/nextblock-template/components/media/YouTubeFacade.tsx +43 -7
- package/templates/nextblock-template/lib/media/youtube.ts +19 -1
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/next.config.js +6 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -188,6 +188,10 @@ async function renderLoadedBlock({
|
|
|
188
188
|
parentBlockIndex={blockIndex}
|
|
189
189
|
botProtectionPublic={botProtectionPublic}
|
|
190
190
|
scriptNonce={scriptNonce}
|
|
191
|
+
// The first top-level block is above the fold, so its media is the LCP
|
|
192
|
+
// candidate. Renderers that own an image (video_embed's poster frame)
|
|
193
|
+
// use this to preload instead of lazy-loading it.
|
|
194
|
+
priority={blockIndex === 0}
|
|
191
195
|
/>
|
|
192
196
|
);
|
|
193
197
|
}
|
|
@@ -379,6 +379,9 @@ async function renderNestedBlock({
|
|
|
379
379
|
parentBlockIndex={parentBlockIndex}
|
|
380
380
|
botProtectionPublic={botProtectionPublic}
|
|
381
381
|
scriptNonce={scriptNonce}
|
|
382
|
+
// Hero sections also carry LCP-eligible media in dynamically loaded
|
|
383
|
+
// renderers (e.g. video_embed's poster frame), not just nested images.
|
|
384
|
+
priority={priority}
|
|
382
385
|
/>
|
|
383
386
|
);
|
|
384
387
|
}
|
package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx
CHANGED
|
@@ -8,12 +8,15 @@ interface VideoEmbedBlockRendererProps {
|
|
|
8
8
|
content: VideoEmbedBlockContent;
|
|
9
9
|
languageId: number;
|
|
10
10
|
visualEditAttributes?: VisualEditAttributes;
|
|
11
|
+
/** Set for hero / first-position blocks so the poster is preloaded (LCP). */
|
|
12
|
+
priority?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const VideoEmbedBlockRenderer: React.FC<VideoEmbedBlockRendererProps> = ({
|
|
14
16
|
content,
|
|
15
17
|
languageId,
|
|
16
18
|
visualEditAttributes,
|
|
19
|
+
priority = false,
|
|
17
20
|
}) => {
|
|
18
21
|
void languageId;
|
|
19
22
|
if (!content.url) {
|
|
@@ -54,6 +57,7 @@ const VideoEmbedBlockRenderer: React.FC<VideoEmbedBlockRendererProps> = ({
|
|
|
54
57
|
title={content.title}
|
|
55
58
|
query={embedParams.toString()}
|
|
56
59
|
className="absolute inset-0 h-full w-full rounded-lg"
|
|
60
|
+
priority={priority}
|
|
57
61
|
/>
|
|
58
62
|
) : (
|
|
59
63
|
<iframe
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
|
-
import
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import {
|
|
6
|
+
buildNoCookieEmbedUrl,
|
|
7
|
+
youTubePosterUrl,
|
|
8
|
+
YOUTUBE_POSTER_DIMENSIONS,
|
|
9
|
+
type YouTubePosterQuality,
|
|
10
|
+
} from '../../lib/media/youtube';
|
|
5
11
|
|
|
6
12
|
interface YouTubeFacadeProps {
|
|
7
13
|
videoId: string;
|
|
@@ -10,9 +16,19 @@ interface YouTubeFacadeProps {
|
|
|
10
16
|
query?: string;
|
|
11
17
|
/** Classes for the clickable surface / injected iframe. Defaults to filling its parent. */
|
|
12
18
|
className?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Above-the-fold embeds. The poster is very often the page's LCP element, so
|
|
21
|
+
* this preloads it, marks it fetchpriority=high and drops lazy-loading.
|
|
22
|
+
*/
|
|
23
|
+
priority?: boolean;
|
|
24
|
+
/** Responsive-srcset layout hint; override when the embed is not ~half-width. */
|
|
25
|
+
sizes?: string;
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
const FILL = 'absolute inset-0 h-full w-full';
|
|
29
|
+
// The video block is content-width, so it is full-bleed on phones and roughly a
|
|
30
|
+
// column wide on desktop. Callers that know better pass their own `sizes`.
|
|
31
|
+
const DEFAULT_SIZES = '(max-width: 768px) 100vw, 50vw';
|
|
16
32
|
|
|
17
33
|
/**
|
|
18
34
|
* Click-to-play YouTube facade: renders a poster + play button and loads no
|
|
@@ -21,11 +37,20 @@ const FILL = 'absolute inset-0 h-full w-full';
|
|
|
21
37
|
* cookies from the player JS, so the only reliable fix is not booting the
|
|
22
38
|
* player during the audit. See lib/media/youtube.ts.
|
|
23
39
|
*/
|
|
24
|
-
const YouTubeFacade: React.FC<YouTubeFacadeProps> = ({
|
|
40
|
+
const YouTubeFacade: React.FC<YouTubeFacadeProps> = ({
|
|
41
|
+
videoId,
|
|
42
|
+
title,
|
|
43
|
+
query,
|
|
44
|
+
className,
|
|
45
|
+
priority = false,
|
|
46
|
+
sizes,
|
|
47
|
+
}) => {
|
|
25
48
|
const [activated, setActivated] = useState(false);
|
|
26
|
-
const [
|
|
49
|
+
const [posterQuality, setPosterQuality] = useState<YouTubePosterQuality>('maxres');
|
|
27
50
|
const label = title || 'YouTube video';
|
|
28
51
|
const surface = className || FILL;
|
|
52
|
+
const poster = youTubePosterUrl(videoId, posterQuality);
|
|
53
|
+
const posterSize = YOUTUBE_POSTER_DIMENSIONS[posterQuality];
|
|
29
54
|
|
|
30
55
|
if (activated) {
|
|
31
56
|
return (
|
|
@@ -47,14 +72,25 @@ const YouTubeFacade: React.FC<YouTubeFacadeProps> = ({ videoId, title, query, cl
|
|
|
47
72
|
aria-label={`Play video: ${label}`}
|
|
48
73
|
className={`${surface} group flex items-center justify-center overflow-hidden border-0 bg-black p-0`}
|
|
49
74
|
>
|
|
50
|
-
{/*
|
|
51
|
-
|
|
75
|
+
{/* Routed through next/image on purpose: i.ytimg.com serves the poster as a
|
|
76
|
+
full-size JPEG with a 2h cache TTL, which made a ~205 KiB download the LCP
|
|
77
|
+
for a thumbnail-sized slot. The optimizer re-encodes to AVIF/WebP at the
|
|
78
|
+
displayed width and serves it same-origin under the app's long cache TTL. */}
|
|
79
|
+
<Image
|
|
52
80
|
src={poster}
|
|
53
81
|
alt=""
|
|
54
82
|
aria-hidden="true"
|
|
55
|
-
|
|
83
|
+
width={posterSize.width}
|
|
84
|
+
height={posterSize.height}
|
|
85
|
+
sizes={sizes || DEFAULT_SIZES}
|
|
86
|
+
quality={60}
|
|
87
|
+
priority={priority}
|
|
88
|
+
fetchPriority={priority ? 'high' : undefined}
|
|
89
|
+
loading={priority ? undefined : 'lazy'}
|
|
56
90
|
decoding="async"
|
|
57
|
-
|
|
91
|
+
// maxresdefault.jpg is not uploaded for every video; fall back to the
|
|
92
|
+
// hqdefault variant, which always exists.
|
|
93
|
+
onError={() => setPosterQuality((current) => (current === 'maxres' ? 'hq' : current))}
|
|
58
94
|
className="absolute inset-0 h-full w-full object-cover opacity-90 transition group-hover:opacity-100"
|
|
59
95
|
/>
|
|
60
96
|
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-black/70 text-white shadow-lg transition group-hover:scale-110 group-hover:bg-red-600">
|
|
@@ -76,6 +76,24 @@ export function rewriteYouTubeHostsInHtml(html: string): string {
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export
|
|
79
|
+
export type YouTubePosterQuality = 'maxres' | 'hq';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Intrinsic size of each poster variant. maxresdefault is 16:9 but is not
|
|
83
|
+
* uploaded for every video; hqdefault always exists and is 4:3 (the frame is
|
|
84
|
+
* letterboxed inside it), which is why the facade object-covers the poster.
|
|
85
|
+
*/
|
|
86
|
+
export const YOUTUBE_POSTER_DIMENSIONS: Record<
|
|
87
|
+
YouTubePosterQuality,
|
|
88
|
+
{ width: number; height: number }
|
|
89
|
+
> = {
|
|
90
|
+
maxres: { width: 1280, height: 720 },
|
|
91
|
+
hq: { width: 480, height: 360 },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export function youTubePosterUrl(
|
|
95
|
+
videoId: string,
|
|
96
|
+
quality: YouTubePosterQuality = 'maxres'
|
|
97
|
+
): string {
|
|
80
98
|
return `https://i.ytimg.com/vi/${videoId}/${quality === 'maxres' ? 'maxresdefault' : 'hqdefault'}.jpg`;
|
|
81
99
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
@@ -203,6 +203,12 @@ function getRemotePatterns() {
|
|
|
203
203
|
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**' },
|
|
204
204
|
{ protocol: 'https', hostname: '**.r2.cloudflarestorage.com', pathname: '/**' },
|
|
205
205
|
{ protocol: 'https', hostname: '**.supabase.co', pathname: '/**' },
|
|
206
|
+
// YouTube poster frames for the click-to-play facade
|
|
207
|
+
// (components/media/YouTubeFacade.tsx). Serving them through the optimizer
|
|
208
|
+
// turns a ~205 KiB third-party JPEG with a 2h TTL into a same-origin
|
|
209
|
+
// AVIF/WebP at the displayed size — the facade poster is frequently the LCP.
|
|
210
|
+
{ protocol: 'https', hostname: 'i.ytimg.com', pathname: '/vi/**' },
|
|
211
|
+
{ protocol: 'https', hostname: 'img.youtube.com', pathname: '/vi/**' },
|
|
206
212
|
);
|
|
207
213
|
|
|
208
214
|
// Add R2 Bucket URL if authenticated
|