mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): render YouTube Shorts and live videos
This commit is contained in:
parent
e651045ac5
commit
641b280617
6 changed files with 153 additions and 82 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
|
|
@ -57,18 +15,11 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading video…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !videoId) {
|
||||
const videoId = extractYouTubeVideoId(url)
|
||||
if (!videoId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-red-400">
|
||||
Error: {error || "Failed to extract video ID"}
|
||||
Error: Invalid YouTube URL format
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
40
apps/web/components/youtube-rendering.test.ts
Normal file
40
apps/web/components/youtube-rendering.test.ts
Normal file
|
|
@ -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<typeof YoutubePreview>["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}"`)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue