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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.13.11",
3
+ "version": "0.13.12",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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
  }
@@ -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 { buildNoCookieEmbedUrl, youTubePosterUrl } from '../../lib/media/youtube';
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> = ({ videoId, title, query, className }) => {
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 [poster, setPoster] = useState(() => youTubePosterUrl(videoId, 'maxres'));
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
- {/* eslint-disable-next-line @next/next/no-img-element */}
51
- <img
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
- loading="lazy"
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
- onError={() => setPoster(youTubePosterUrl(videoId, 'hq'))}
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 function youTubePosterUrl(videoId: string, quality: 'maxres' | 'hq' = 'maxres'): string {
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/dev/types/routes.d.ts";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.13.11",
3
+ "version": "0.13.12",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",