Skip to content

Commit fa09c1b

Browse files
committed
fix(seo): promote embedded videos as primary page content
1 parent 4e73cb1 commit fa09c1b

4 files changed

Lines changed: 139 additions & 67 deletions

File tree

‎app/posts/[title]/page.tsx‎

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
buildBreadcrumbSchema,
1717
buildPublisherSchema,
1818
} from "@/lib/seo";
19+
import { removeYouTubeIframe } from "@/lib/video";
1920

2021
type RouteParams = {
2122
title: string;
@@ -153,6 +154,9 @@ export default async function ArticleDetails({
153154
}
154155

155156
const processedHtml = await processMarkdown(article.content || "");
157+
const articleHtml = article.video?.videoId
158+
? removeYouTubeIframe(processedHtml, article.video.videoId)
159+
: processedHtml;
156160
const articleUrl = absoluteUrl(`/posts/${article.slug}`);
157161
const articleImages = Array.from(
158162
new Set(
@@ -204,23 +208,6 @@ export default async function ArticleDetails({
204208
]),
205209
];
206210

207-
if (article.video) {
208-
jsonLd.push({
209-
"@context": "https://schema.org",
210-
"@type": "VideoObject",
211-
name: article.title,
212-
description: article.description,
213-
thumbnailUrl: [article.video.thumbnailUrl || absoluteUrl(article.img)],
214-
uploadDate: article.date.toISOString(),
215-
contentUrl: article.video.url,
216-
embedUrl: article.video.embedUrl,
217-
potentialAction: {
218-
"@type": "WatchAction",
219-
target: article.video.url,
220-
},
221-
});
222-
}
223-
224211
return (
225212
<main className="max-w-[95rem] w-full mx-auto px-4 md:pt-8 sm:pt-4 xs:pt-2 lg:pb-4 md:pb-4 sm:pb-2 xs:pb-2">
226213
<JsonLd data={jsonLd} />
@@ -282,18 +269,28 @@ export default async function ArticleDetails({
282269
</div>
283270
</article>
284271

285-
<div className="relative w-full h-auto aspect-[16/9]">
286-
<Image
287-
src={article.img}
288-
alt={article.imgAlt}
289-
fill
290-
sizes="(min-width: 768px) 768px, 100vw"
291-
className="object-cover w-full h-auto"
272+
{article.video?.embedUrl ? (
273+
<iframe
274+
src={article.video.embedUrl}
275+
title={`${article.title} video`}
276+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
277+
allowFullScreen
278+
className="block w-full !h-auto !my-0 aspect-video"
292279
/>
293-
</div>
280+
) : (
281+
<div className="relative w-full h-auto aspect-[16/9]">
282+
<Image
283+
src={article.img}
284+
alt={article.imgAlt}
285+
fill
286+
sizes="(min-width: 768px) 768px, 100vw"
287+
className="object-cover w-full h-auto"
288+
/>
289+
</div>
290+
)}
294291

295292
<div className="w-full">
296-
<ArticleContent htmlContent={processedHtml} />
293+
<ArticleContent htmlContent={articleHtml} />
297294
</div>
298295

299296
{article.author ? (

‎lib/content.ts‎

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
slugifySegment,
88
} from "@/lib/seo";
99
import { safeTimestampToDate } from "@/lib/utils";
10+
import { extractYouTubeEmbedId, extractYouTubeId } from "@/lib/video";
1011

1112
const ARTICLE_UPDATED_FIELDS = [
1213
"updatedAt",
@@ -205,57 +206,21 @@ function extractBiography(source: unknown, name: string) {
205206
return { summary: fallback, body: fallback };
206207
}
207208

208-
function extractYouTubeId(rawValue: string): string | undefined {
209-
const value = rawValue.trim();
210-
211-
if (/^[a-zA-Z0-9_-]{11}$/.test(value)) {
212-
return value;
213-
}
214-
215-
try {
216-
const url = new URL(value);
217-
const hostname = url.hostname.toLowerCase();
218-
219-
if (hostname === "youtu.be") {
220-
const id = url.pathname.replace(/\//g, "").trim();
221-
return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : undefined;
222-
}
223-
224-
if (hostname === "youtube.com" || hostname.endsWith(".youtube.com")) {
225-
const searchId = url.searchParams.get("v");
226-
if (searchId && /^[a-zA-Z0-9_-]{11}$/.test(searchId)) {
227-
return searchId;
228-
}
229-
230-
const pathSegments = url.pathname.split("/").filter(Boolean);
231-
const embeddedId = pathSegments[pathSegments.length - 1];
232-
if (embeddedId && /^[a-zA-Z0-9_-]{11}$/.test(embeddedId)) {
233-
return embeddedId;
234-
}
235-
}
236-
} catch {
237-
return undefined;
238-
}
239-
240-
return undefined;
241-
}
242-
243209
function buildVideoRecord(
244210
source: Record<string, unknown>,
245211
): ArticleVideoRecord | undefined {
246212
const directUrl = pickString(source, VIDEO_URL_FIELDS);
247213
const sourceUrl = pickString(source, ["sourceUrl", "source_url"]);
214+
const content = pickString(source, ["content"]);
248215
const candidate =
249216
directUrl ||
250217
(sourceUrl && /(youtu\.be|youtube\.com|vimeo\.com)/i.test(sourceUrl)
251218
? sourceUrl
252219
: undefined);
253220

254-
if (!candidate) {
255-
return undefined;
256-
}
257-
258-
const videoId = extractYouTubeId(candidate);
221+
const videoId = candidate
222+
? extractYouTubeId(candidate)
223+
: extractYouTubeEmbedId(content || "");
259224
if (videoId) {
260225
return {
261226
platform: "youtube",
@@ -266,7 +231,7 @@ function buildVideoRecord(
266231
};
267232
}
268233

269-
if (!/^https?:\/\//i.test(candidate)) {
234+
if (!candidate || !/^https?:\/\//i.test(candidate)) {
270235
return undefined;
271236
}
272237

‎lib/video.test.mts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
extractYouTubeEmbedId,
5+
extractYouTubeId,
6+
removeYouTubeIframe,
7+
} from "./video.ts";
8+
9+
test("normalizes and removes the selected YouTube embed", () => {
10+
const videoId = "6JgkhKIoFOA";
11+
const iframe = `<iframe src="https://www.youtube.com/embed/${videoId}?si=test" title="YouTube video player"></iframe>`;
12+
const html = `${iframe}<p>Keep the guide.</p>${iframe}`;
13+
14+
assert.equal(extractYouTubeEmbedId(iframe), videoId);
15+
assert.equal(extractYouTubeId(`https://youtu.be/${videoId}`), videoId);
16+
assert.equal(
17+
extractYouTubeEmbedId(
18+
`<p>See https://youtube.com/watch?v=${videoId} for more.</p>`,
19+
),
20+
undefined,
21+
);
22+
assert.equal(
23+
extractYouTubeId(`https://notyoutube.com/embed/${videoId}`),
24+
undefined,
25+
);
26+
assert.equal(
27+
extractYouTubeId(`https://evil-youtube.com/embed/${videoId}`),
28+
undefined,
29+
);
30+
assert.equal(
31+
extractYouTubeEmbedId(
32+
`<iframe src="https://www.youtube.com/embed/${videoId}X"></iframe>`,
33+
),
34+
undefined,
35+
);
36+
assert.equal(removeYouTubeIframe(html, videoId), `<p>Keep the guide.</p>`);
37+
38+
const otherVideoId = "5zlE8KEz6wk";
39+
const otherIframe = `<iframe src="https://www.youtube.com/embed/${otherVideoId}"></iframe>`;
40+
assert.equal(
41+
removeYouTubeIframe(`${iframe}${otherIframe}`, otherVideoId),
42+
iframe,
43+
);
44+
});

‎lib/video.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const YOUTUBE_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/;
2+
const YOUTUBE_IFRAME_PATTERN =
3+
/<iframe\b[^>]*\bsrc\s*=\s*(["'])(https:\/\/(?:(?:www|m)\.)?youtube(?:-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}(?:[?&#][^"']*)?)\1[^>]*>[\s\S]*?<\/iframe>/gi;
4+
5+
export function extractYouTubeId(rawValue: string): string | undefined {
6+
const value = rawValue.trim();
7+
8+
if (YOUTUBE_ID_PATTERN.test(value)) {
9+
return value;
10+
}
11+
12+
try {
13+
const url = new URL(value);
14+
const hostname = url.hostname.toLowerCase();
15+
const isYouTubeHost =
16+
hostname === "youtube.com" ||
17+
hostname.endsWith(".youtube.com") ||
18+
hostname === "youtube-nocookie.com" ||
19+
hostname.endsWith(".youtube-nocookie.com");
20+
21+
if (hostname === "youtu.be") {
22+
const pathSegments = url.pathname.split("/").filter(Boolean);
23+
return pathSegments.length === 1 &&
24+
YOUTUBE_ID_PATTERN.test(pathSegments[0])
25+
? pathSegments[0]
26+
: undefined;
27+
}
28+
29+
if (!isYouTubeHost) {
30+
return undefined;
31+
}
32+
33+
if (url.pathname === "/watch") {
34+
const videoId = url.searchParams.get("v") || "";
35+
return YOUTUBE_ID_PATTERN.test(videoId) ? videoId : undefined;
36+
}
37+
38+
const pathSegments = url.pathname.split("/").filter(Boolean);
39+
return pathSegments.length === 2 &&
40+
["embed", "shorts", "live"].includes(pathSegments[0]) &&
41+
YOUTUBE_ID_PATTERN.test(pathSegments[1])
42+
? pathSegments[1]
43+
: undefined;
44+
} catch {
45+
return undefined;
46+
}
47+
}
48+
49+
export function extractYouTubeEmbedId(html: string): string | undefined {
50+
for (const match of html.matchAll(YOUTUBE_IFRAME_PATTERN)) {
51+
const videoId = extractYouTubeId(match[2]);
52+
if (videoId) {
53+
return videoId;
54+
}
55+
}
56+
57+
return undefined;
58+
}
59+
60+
export function removeYouTubeIframe(html: string, videoId: string) {
61+
return html.replace(
62+
YOUTUBE_IFRAME_PATTERN,
63+
(iframe, _quote: string, sourceUrl: string) =>
64+
extractYouTubeId(sourceUrl) === videoId ? "" : iframe,
65+
);
66+
}

0 commit comments

Comments
 (0)