From 641b2806174426f7966c6b0edcdc024443a12cdf Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:07:03 +0530 Subject: [PATCH] fix(web): render YouTube Shorts and live videos --- .github/workflows/ci.yml | 4 ++ .../document-modal/content/yt-video.tsx | 57 ++------------- apps/web/components/utils.ts | 30 +------- apps/web/components/youtube-rendering.test.ts | 40 +++++++++++ apps/web/lib/url-helpers.test.ts | 69 ++++++++++++++++++- apps/web/lib/url-helpers.ts | 35 ++++++++++ 6 files changed, 153 insertions(+), 82 deletions(-) create mode 100644 apps/web/components/youtube-rendering.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..c1398b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,9 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run web unit tests + working-directory: apps/web + run: bun test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/components/document-modal/content/yt-video.tsx b/apps/web/components/document-modal/content/yt-video.tsx index c0f78459..ccca4f9b 100644 --- a/apps/web/components/document-modal/content/yt-video.tsx +++ b/apps/web/components/document-modal/content/yt-video.tsx @@ -1,54 +1,12 @@ "use client" -import { useState, useEffect } from "react" +import { extractYouTubeVideoId } from "@/lib/url-helpers" interface YoutubeVideoProps { url: string | null | undefined } -// Extract YouTube video ID from various URL formats -function extractVideoId(url: string): string | null { - if (!url) return null - - const patterns = [ - /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, - /youtube\.com\/watch\?.*v=([^&\n?#]+)/, - ] - - for (const pattern of patterns) { - const match = url.match(pattern) - if (match?.[1]) { - return match[1] - } - } - - return null -} - export function YoutubeVideo({ url }: YoutubeVideoProps) { - const [videoId, setVideoId] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - if (!url) { - setError("No YouTube URL provided") - setLoading(false) - return - } - - const id = extractVideoId(url) - if (!id) { - setError("Invalid YouTube URL format") - setLoading(false) - return - } - - setVideoId(id) - setLoading(false) - setError(null) - }, [url]) - if (!url) { return (
@@ -57,18 +15,11 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) { ) } - if (loading) { - return ( -
- Loading video… -
- ) - } - - if (error || !videoId) { + const videoId = extractYouTubeVideoId(url) + if (!videoId) { return (
- Error: {error || "Failed to extract video ID"} + Error: Invalid YouTube URL format
) } diff --git a/apps/web/components/utils.ts b/apps/web/components/utils.ts index 3da32018..5ba5d666 100644 --- a/apps/web/components/utils.ts +++ b/apps/web/components/utils.ts @@ -1,35 +1,9 @@ "use client" import { useQuery } from "@tanstack/react-query" -import { isYouTubeUrl } from "@/lib/url-helpers" +import { extractYouTubeVideoId, isYouTubeUrl } from "@/lib/url-helpers" -export { isYouTubeUrl } - -export function extractYouTubeVideoId( - url: string | undefined | null, -): string | null { - if (!url) return null - - // Handle youtu.be format - const youtuBeMatch = url.match(/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/) - if (youtuBeMatch?.[1]) return youtuBeMatch[1] - - // Handle youtube.com/watch?v= format - const watchMatch = url.match(/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/) - if (watchMatch?.[1]) return watchMatch[1] - - // Handle youtube.com/embed/ format - const embedMatch = url.match(/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/) - if (embedMatch?.[1]) return embedMatch[1] - - // Handle m.youtube.com format - const mobileMatch = url.match( - /(?:m\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/, - ) - if (mobileMatch?.[1]) return mobileMatch[1] - - return null -} +export { extractYouTubeVideoId, isYouTubeUrl } export function useYouTubeChannelName(url: string | undefined | null) { const videoId = extractYouTubeVideoId(url) diff --git a/apps/web/components/youtube-rendering.test.ts b/apps/web/components/youtube-rendering.test.ts new file mode 100644 index 00000000..9164cffc --- /dev/null +++ b/apps/web/components/youtube-rendering.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test" +import { createElement, type ComponentProps } from "react" +import { renderToStaticMarkup } from "react-dom/server" + +const VIDEO_ID = "dQw4w9WgXcQ" +const SHORTS_URL = `https://www.youtube.com/shorts/${VIDEO_ID}` + +mock.module("@/lib/fonts", () => ({ dmSansClassName: () => "" })) + +let YoutubePreview: typeof import("./document-cards/youtube-preview").YoutubePreview +let YoutubeVideo: typeof import("./document-modal/content/yt-video").YoutubeVideo + +beforeAll(async () => { + ;({ YoutubePreview } = await import("./document-cards/youtube-preview")) + ;({ YoutubeVideo } = await import("./document-modal/content/yt-video")) +}) + +describe("YouTube Shorts rendering", () => { + it("renders a Shorts embed in the memory card", () => { + const document = { + url: SHORTS_URL, + title: "A short", + content: null, + } as ComponentProps["document"] + + const html = renderToStaticMarkup( + createElement(YoutubePreview, { document }), + ) + + expect(html).toContain(`src="https://www.youtube.com/embed/${VIDEO_ID}"`) + }) + + it("renders the same Shorts embed in the document modal", () => { + const html = renderToStaticMarkup( + createElement(YoutubeVideo, { url: SHORTS_URL }), + ) + + expect(html).toContain(`src="https://www.youtube.com/embed/${VIDEO_ID}"`) + }) +}) diff --git a/apps/web/lib/url-helpers.test.ts b/apps/web/lib/url-helpers.test.ts index bc34c9c5..eca70142 100644 --- a/apps/web/lib/url-helpers.test.ts +++ b/apps/web/lib/url-helpers.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test" -import { isYouTubeUrl } from "./url-helpers" +import { extractYouTubeVideoId, isYouTubeUrl } from "./url-helpers" + +const VIDEO_ID = "dQw4w9WgXcQ" describe("isYouTubeUrl", () => { it("matches canonical youtube.com watch URLs", () => { @@ -71,3 +73,68 @@ describe("isYouTubeUrl", () => { expect(isYouTubeUrl(undefined)).toBe(false) }) }) + +describe("extractYouTubeVideoId", () => { + it("extracts exact IDs from supported YouTube video URL formats", () => { + expect( + extractYouTubeVideoId( + `https://www.youtube.com/watch?feature=share&v=${VIDEO_ID}`, + ), + ).toBe(VIDEO_ID) + expect(extractYouTubeVideoId(`https://youtu.be/${VIDEO_ID}?si=abc`)).toBe( + VIDEO_ID, + ) + expect( + extractYouTubeVideoId( + `https://www.youtube.com/embed/${VIDEO_ID}?start=10`, + ), + ).toBe(VIDEO_ID) + expect( + extractYouTubeVideoId( + `https://www.youtube.com/shorts/${VIDEO_ID}?feature=share`, + ), + ).toBe(VIDEO_ID) + expect( + extractYouTubeVideoId(`https://www.youtube.com/live/${VIDEO_ID}`), + ).toBe(VIDEO_ID) + }) + + it("supports mobile, case-insensitive hosts, and scheme-less URLs", () => { + expect( + extractYouTubeVideoId(`https://m.youtube.com/watch?v=${VIDEO_ID}`), + ).toBe(VIDEO_ID) + expect( + extractYouTubeVideoId(`HTTPS://WWW.YOUTUBE.COM/shorts/${VIDEO_ID}`), + ).toBe(VIDEO_ID) + expect(extractYouTubeVideoId(`youtu.be/${VIDEO_ID}`)).toBe(VIDEO_ID) + }) + + it("rejects lookalike hosts and URLs that only mention YouTube in a path", () => { + expect( + extractYouTubeVideoId( + `https://youtube.com.evil.example/watch?v=${VIDEO_ID}`, + ), + ).toBeNull() + expect( + extractYouTubeVideoId( + `https://evil.example/youtube.com/shorts/${VIDEO_ID}`, + ), + ).toBeNull() + expect( + extractYouTubeVideoId(`https://youtube.com@evil.example/${VIDEO_ID}`), + ).toBeNull() + }) + + it("rejects malformed IDs and unsupported YouTube routes", () => { + expect( + extractYouTubeVideoId("https://www.youtube.com/shorts/too-short"), + ).toBeNull() + expect( + extractYouTubeVideoId(`https://www.youtube.com/embed/${VIDEO_ID}x`), + ).toBeNull() + expect( + extractYouTubeVideoId(`https://www.youtube.com/channel/${VIDEO_ID}`), + ).toBeNull() + expect(extractYouTubeVideoId(null)).toBeNull() + }) +}) diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index e9f1dbc8..6810f5e4 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -169,6 +169,11 @@ const hostnameMatches = (hostname: string, domain: string): boolean => { ) } +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/ + +const validYouTubeVideoId = (candidate: string | null | undefined) => + candidate && YOUTUBE_VIDEO_ID_PATTERN.test(candidate) ? candidate : null + /** * Checks if a URL is a Twitter/X URL. */ @@ -197,6 +202,36 @@ export const isYouTubeUrl = (url: string | undefined | null): boolean => { ) } +/** + * Extracts an exact 11-character video ID from supported YouTube video URLs. + */ +export const extractYouTubeVideoId = ( + url: string | undefined | null, +): string | null => { + if (!url) return null + const parsed = parseWebUrl(url) + if (!parsed) return null + + const segments = parsed.pathname.split("/").filter(Boolean) + if (hostnameMatches(parsed.hostname, "youtu.be")) { + return segments.length === 1 ? validYouTubeVideoId(segments[0]) : null + } + if (!hostnameMatches(parsed.hostname, "youtube.com")) return null + + const route = segments[0] + if (route === "watch" && segments.length === 1) { + return validYouTubeVideoId(parsed.searchParams.get("v")) + } + if ( + (route === "embed" || route === "shorts" || route === "live") && + segments.length === 2 + ) { + return validYouTubeVideoId(segments[1]) + } + + return null +} + /** * Checks if a URL is a LinkedIn profile URL (not a company page). */