Skip to content

Commit 31f5fdb

Browse files
committed
feat: enhance YouTube video handling and update RSS feed links
- Added referrer policy to YouTube iframes to prevent errors related to cross-origin requests. - Improved YouTube video ID parsing in various components for better handling of video URLs. - Updated footer links to point to the new RSS feeds path. - Adjusted sitemap and reserved slugs to reflect the new feeds structure. - Removed obsolete podcast RSS feed generation file.
1 parent 821d746 commit 31f5fdb

File tree

16 files changed

+295
-57
lines changed

16 files changed

+295
-57
lines changed

apps/sanity/components/YouTubePreview.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function VideoPreview(props: { youtube: string }) {
7575
<iframe
7676
style={{ height: "100%", width: "100%", border: 0 }}
7777
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&fs=0`}
78+
referrerPolicy="strict-origin-when-cross-origin"
7879
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
7980
allowFullScreen
8081
title="YouTube video player"

apps/sanity/lib/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
*/
44
export function youtubeParser(url: string): string | null {
55
if (!url || typeof url !== "string") return null;
6+
const trimmed = url.trim();
7+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
68
const regExp =
79
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
8-
const match = url.match(regExp);
10+
const match = trimmed.match(regExp);
911
return match && match[2].length === 11 ? match[2] : null;
1012
}

apps/web/src/components/Footer.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ import Container from "./Container.astro";
104104
</li>
105105
<li>
106106
<a
107-
href="/rss.xml"
107+
href="/feeds"
108108
class="text-sm text-[--text-secondary] hover:text-[--text] transition-colors"
109-
>RSS Feed</a>
109+
>RSS feeds</a>
110110
</li>
111111
</ul>
112112
</div>

apps/web/src/components/course/LessonPlayer.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { useMemo } from "react";
22

3+
import { parseYoutubeVideoId, youtubeEmbedSrc } from "@/lib/youtube";
4+
5+
type SanityFileAsset = {
6+
url?: string | null;
7+
mimeType?: string | null;
8+
};
9+
310
type LessonRef = {
411
_id: string;
512
title: string;
613
slug: string;
714
locked?: boolean;
815
coverImage?: unknown;
16+
/** Raw string from CMS: may be URL or 11-char id */
917
youtube?: string | null;
18+
videoCloudinary?: { asset?: SanityFileAsset | null } | null;
1019
};
1120

1221
type Section = {
@@ -85,6 +94,7 @@ export default function LessonPlayer({
8594
const next = index >= 0 && index < lessons.length - 1 ? lessons[index + 1] : null;
8695

8796
const base = `/course/${courseSlug}/lesson`;
97+
const youtubeId = parseYoutubeVideoId(lesson.youtube ?? undefined);
8898

8999
return (
90100
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(240px,280px)_1fr] lg:items-start lg:gap-8">
@@ -137,15 +147,32 @@ export default function LessonPlayer({
137147
<LockBadge locked={lesson.locked} />
138148
</header>
139149

140-
{lesson.youtube ? (
150+
{youtubeId ? (
141151
<div className="aspect-video w-full bg-black">
142152
<iframe
143-
src={`https://www.youtube.com/embed/${lesson.youtube}`}
153+
src={youtubeEmbedSrc(youtubeId)}
144154
className="h-full w-full"
155+
referrerPolicy="strict-origin-when-cross-origin"
156+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
145157
allowFullScreen
146158
title={lesson.title}
147159
/>
148160
</div>
161+
) : lesson.videoCloudinary?.asset?.url ? (
162+
<div className="aspect-video w-full bg-black">
163+
<video
164+
className="h-full w-full"
165+
controls
166+
playsInline
167+
preload="metadata"
168+
title={lesson.title}
169+
>
170+
<source
171+
src={lesson.videoCloudinary.asset.url}
172+
type={lesson.videoCloudinary.asset.mimeType ?? undefined}
173+
/>
174+
</video>
175+
</div>
149176
) : (
150177
<div className="flex aspect-video w-full items-center justify-center bg-[--surface-hover] text-[--text-tertiary]">
151178
No video for this lesson

apps/web/src/layouts/BaseLayout.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const currentPath = Astro.url.pathname || "/";
4040
/>
4141
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
4242
<link rel="alternate" type="application/rss+xml" title="CodingCat.dev Blog" href="/rss.xml" />
43-
<link rel="alternate" type="application/rss+xml" title="CodingCat.dev Podcast" href="/podcast/rss.xml" />
43+
<link rel="alternate" type="application/rss+xml" title="CodingCat.dev Courses" href="/courses/rss.xml" />
4444

4545
{/* Dark mode script — MUST be before CSS to prevent FOUC */}
4646
<ThemeScript />

apps/web/src/lib/queries.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ const contentFields = `
4242
`;
4343

4444
const podcastFields = `
45-
podcastType[]->{
45+
podcastType->{
4646
...,
4747
"title": coalesce(title, "Missing Podcast Title"),
48+
"slug": slug.current,
4849
},
4950
season,
5051
episode,
@@ -225,7 +226,12 @@ const courseFields = `
225226

226227
const lessonFields = `
227228
locked,
228-
videoCloudinary
229+
videoCloudinary{
230+
asset->{
231+
url,
232+
mimeType
233+
}
234+
}
229235
`;
230236

231237
export const courseListQuery = groq`*[_type == "course" && defined(slug.current)] | order(date desc, _updatedAt desc) [$offset...$end] {
@@ -297,7 +303,26 @@ export const rssPostsQuery = groq`*[_type == "post" && defined(slug.current)] |
297303
${contentFields}
298304
}`;
299305

300-
export const rssPodcastsQuery = groq`*[_type == "podcast" && defined(slug.current)] | order(date desc) [0...20] {
306+
export const rssCoursesQuery = groq`*[_type == "course" && defined(slug.current)] | order(date desc) [0...20] {
301307
${baseFields},
302-
${contentFields}
308+
${courseFields}
309+
}`;
310+
311+
/** Full episode archive for this show (podcast apps expect the complete feed). */
312+
export const rssPodcastsByTypeQuery = groq`*[_type == "podcast" && defined(slug.current) && podcastType->slug.current == $podcastTypeSlug] | order(date desc) {
313+
${baseFields},
314+
${contentFields},
315+
${podcastFields}
316+
}`;
317+
318+
export const podcastTypeBySlugQuery = groq`*[_type == "podcastType" && slug.current == $podcastTypeSlug][0] {
319+
_id,
320+
"title": coalesce(title, "Untitled"),
321+
"slug": slug.current
322+
}`;
323+
324+
export const podcastTypesListQuery = groq`*[_type == "podcastType" && defined(slug.current)] | order(title asc) {
325+
_id,
326+
"title": coalesce(title, "Untitled"),
327+
"slug": slug.current
303328
}`;

apps/web/src/lib/youtube.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* YouTube embed helpers.
3+
*
4+
* Error 153 ("Video player configuration error") happens when the embed request sends no
5+
* Referer — e.g. site-wide Referrer-Policy: same-origin. Set
6+
* referrerpolicy="strict-origin-when-cross-origin" on the iframe.
7+
* @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
8+
*/
9+
10+
/** 11-char video ID from a bare id or youtu.be / watch / embed URLs */
11+
export function parseYoutubeVideoId(input: string | null | undefined): string | null {
12+
if (!input || typeof input !== "string") return null;
13+
const trimmed = input.trim();
14+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
15+
const regExp = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
16+
const match = trimmed.match(regExp);
17+
return match && match[2].length === 11 ? match[2] : null;
18+
}
19+
20+
/** Privacy-enhanced host; works better with some referrer/CSP setups than www.youtube.com */
21+
export function youtubeEmbedSrc(videoId: string): string {
22+
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`;
23+
}

apps/web/src/pages/[slug].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const prerender = false;
1010
const { slug } = Astro.params;
1111
1212
// Don't catch known routes — let them fall through to their own handlers
13-
const reservedSlugs = ["blog", "courses", "podcasts", "authors", "guests", "sponsors", "login", "dashboard", "rss.xml", "sitemap.xml", "course"];
13+
const reservedSlugs = ["blog", "courses", "feeds", "podcasts", "authors", "guests", "sponsors", "login", "dashboard", "rss.xml", "sitemap.xml", "course"];
1414
if (reservedSlugs.includes(slug!)) {
1515
return Astro.redirect(`/${slug}`);
1616
}

apps/web/src/pages/course/[slug].astro

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ContentCard from "@/components/ContentCard.astro";
77
import { PortableText } from "astro-portabletext";
88
import { loadQuery, urlForImage } from "@/utils/sanity";
99
import { courseQuery, lessonsInCourseQuery } from "@/lib/queries";
10+
import { parseYoutubeVideoId, youtubeEmbedSrc } from "@/lib/youtube";
1011
1112
export const prerender = false;
1213
@@ -54,6 +55,8 @@ const displayDescription = course.excerpt;
5455
const ogImage = coverUrl ?? `${siteUrl}/api/og/default.png?title=${encodeURIComponent(course.title)}`;
5556
5657
const showLockedHint = Astro.url.searchParams.has("locked");
58+
59+
const courseYoutubeId = parseYoutubeVideoId(course.youtube);
5760
---
5861

5962
<BaseLayout title={`${displayTitle} — CodingCat.dev`} description={displayDescription} ogImage={ogImage}>
@@ -109,11 +112,13 @@ const showLockedHint = Astro.url.searchParams.has("locked");
109112
/>
110113
)}
111114

112-
{course.youtube && (
115+
{courseYoutubeId && (
113116
<div class="aspect-video mb-8 rounded-xl overflow-hidden">
114117
<iframe
115-
src={`https://www.youtube.com/embed/${course.youtube}`}
118+
src={youtubeEmbedSrc(courseYoutubeId)}
116119
class="w-full h-full"
120+
referrerpolicy="strict-origin-when-cross-origin"
121+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
117122
allowfullscreen
118123
loading="lazy"
119124
title="Course video"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { APIRoute } from "astro";
2+
import { sanityFetch } from "@/utils/sanity";
3+
import { rssCoursesQuery } from "@/lib/queries";
4+
import { escapeXml } from "@/utils/xml";
5+
6+
export const prerender = false;
7+
8+
export const GET: APIRoute = async () => {
9+
const site = "https://codingcat.dev";
10+
const courses = await sanityFetch<any[]>(rssCoursesQuery);
11+
12+
const items = courses
13+
.map(
14+
(course) => ` <item>
15+
<title>${escapeXml(course.title)}</title>
16+
<link>${site}/course/${course.slug}</link>
17+
<guid>${site}/course/${course.slug}</guid>
18+
<pubDate>${new Date(course.date).toUTCString()}</pubDate>
19+
${course.excerpt ? `<description>${escapeXml(course.excerpt)}</description>` : ""}
20+
</item>`,
21+
)
22+
.join("\n");
23+
24+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
25+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
26+
<channel>
27+
<title>CodingCat.dev Courses</title>
28+
<link>${site}/courses</link>
29+
<description>Structured courses and learning paths from CodingCat.dev</description>
30+
<language>en-us</language>
31+
<atom:link href="${site}/courses/rss.xml" rel="self" type="application/rss+xml"/>
32+
${items}
33+
</channel>
34+
</rss>`;
35+
36+
return new Response(xml, {
37+
headers: {
38+
"Content-Type": "application/rss+xml",
39+
"Cache-Control": "public, max-age=3600",
40+
},
41+
});
42+
};

0 commit comments

Comments
 (0)