From ae53b5223901f210ee07168cfc6a062edac22840 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 10 Nov 2025 20:38:10 +0100 Subject: [PATCH] asciidoc youtube rendering --- .../Note/AsciidocArticle/AsciidocArticle.tsx | 120 +++++++++++++++++- .../Note/MarkdownArticle/preprocessMarkup.ts | 19 ++- 2 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 91d05ed..94c9066 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -2,6 +2,7 @@ import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import WebPreview from '@/components/WebPreview' +import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' import { useMediaExtraction } from '@/hooks' @@ -17,7 +18,7 @@ import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import Wikilink from '@/components/UniversalContent/Wikilink' import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' import logger from '@/lib/logger' -import { WS_URL_REGEX } from '@/constants' +import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' /** * Truncate link display text to 200 characters, adding ellipsis if truncated @@ -29,6 +30,17 @@ function truncateLinkText(text: string, maxLength: number = 200): string { return text.substring(0, maxLength) + '...' } +/** + * Check if a URL is a YouTube URL + */ +function isYouTubeUrl(url: string): boolean { + // Create a new regex instance to avoid state issues with global regex + // Keep the 'i' flag for case-insensitivity but remove 'g' to avoid state issues + const flags = YOUTUBE_URL_REGEX.flags.replace('g', '') + const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags) + return regex.test(url) +} + export default function AsciidocArticle({ event, className, @@ -112,7 +124,29 @@ export default function AsciidocArticle({ return media }, [event.id, JSON.stringify(event.tags)]) - // Extract non-media links from tags + // Extract YouTube URLs from tags (for display at top) + const tagYouTubeUrls = useMemo(() => { + const youtubeUrls: string[] = [] + const seenUrls = new Set() + + event.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .forEach(tag => { + const url = tag[1] + if (!url.startsWith('http://') && !url.startsWith('https://')) return + if (!isYouTubeUrl(url)) return + + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + youtubeUrls.push(cleaned) + seenUrls.add(cleaned) + } + }) + + return youtubeUrls + }, [event.id, JSON.stringify(event.tags)]) + + // Extract non-media links from tags (excluding YouTube URLs) const tagLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() @@ -123,6 +157,7 @@ export default function AsciidocArticle({ const url = tag[1] if (!url.startsWith('http://') && !url.startsWith('https://')) return if (isImage(url) || isMedia(url)) return + if (isYouTubeUrl(url)) return // Exclude YouTube URLs const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { @@ -185,7 +220,22 @@ export default function AsciidocArticle({ return urls }, [event.content]) - // Extract non-media links from content + // Extract YouTube URLs from content + const youtubeUrlsInContent = useMemo(() => { + const urls = new Set() + const urlRegex = /https?:\/\/[^\s<>"']+/g + let match + while ((match = urlRegex.exec(event.content)) !== null) { + const url = match[0] + const cleaned = cleanUrl(url) + if (cleaned && isYouTubeUrl(cleaned)) { + urls.add(cleaned) + } + } + return urls + }, [event.content]) + + // Extract non-media links from content (excluding YouTube URLs) const contentLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() @@ -193,7 +243,7 @@ export default function AsciidocArticle({ let match while ((match = urlRegex.exec(event.content)) !== null) { const url = match[0] - if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) { const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { links.push(cleaned) @@ -225,6 +275,14 @@ export default function AsciidocArticle({ }) }, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo]) + // Filter tag YouTube URLs to only show what's not in content + const leftoverTagYouTubeUrls = useMemo(() => { + return tagYouTubeUrls.filter(url => { + const cleaned = cleanUrl(url) + return cleaned && !youtubeUrlsInContent.has(cleaned) + }) + }, [tagYouTubeUrls, youtubeUrlsInContent]) + // Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards) const leftoverTagLinks = useMemo(() => { const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean)) @@ -331,8 +389,13 @@ export default function AsciidocArticle({ return `` }) - // Handle relay URLs (wss:// or ws://) in links - convert to relay page links + // Handle YouTube URLs and relay URLs in links htmlString = htmlString.replace(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g, (match, href, linkText) => { + // Check if the href is a YouTube URL + if (isYouTubeUrl(href)) { + const cleanedUrl = cleanUrl(href) + return `
` + } // Check if the href is a relay URL if (isWebsocketUrl(href)) { const relayPath = `/relays/${encodeURIComponent(href)}` @@ -343,6 +406,18 @@ export default function AsciidocArticle({ return match.replace(/ tags) + // Create a new regex instance to avoid state issues + const youtubeRegex = new RegExp(YOUTUBE_URL_REGEX.source, YOUTUBE_URL_REGEX.flags) + htmlString = htmlString.replace(youtubeRegex, (match) => { + // Only replace if not already in a tag (basic check) + if (!match.includes('<') && !match.includes('>') && isYouTubeUrl(match)) { + const cleanedUrl = cleanUrl(match) + return `
` + } + return match + }) + // Handle relay URLs in plain text (not in
tags) - convert to relay page links htmlString = htmlString.replace(WS_URL_REGEX, (match) => { // Only replace if not already in a tag (basic check) @@ -418,6 +493,23 @@ export default function AsciidocArticle({ reactRootsRef.current.set(container, root) }) + // Process YouTube URLs - replace placeholders with React components + const youtubePlaceholders = contentRef.current.querySelectorAll('.youtube-placeholder[data-youtube-url]') + youtubePlaceholders.forEach((element) => { + const youtubeUrl = element.getAttribute('data-youtube-url') + if (!youtubeUrl) return + + // Create a container for React component + const container = document.createElement('div') + container.className = 'my-2' + element.parentNode?.replaceChild(container, element) + + // Use React to render the component + const root = createRoot(container) + root.render() + reactRootsRef.current.set(container, root) + }) + // Process wikilinks - replace placeholders with React components const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]') wikilinks.forEach((element) => { @@ -753,6 +845,24 @@ export default function AsciidocArticle({ )} + {/* YouTube URLs from tags (only if not in content) */} + {leftoverTagYouTubeUrls.length > 0 && ( +
+ {leftoverTagYouTubeUrls.map((url) => { + const cleaned = cleanUrl(url) + return ( +
+ +
+ ) + })} +
+ )} + {/* Parsed AsciiDoc content */} {isLoading ? (
Loading content...
diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts index 19ff002..c5329fa 100644 --- a/src/components/Note/MarkdownArticle/preprocessMarkup.ts +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -1,5 +1,15 @@ import { isImage, isVideo, isAudio } from '@/lib/url' -import { URL_REGEX } from '@/constants' +import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' + +/** + * Check if a URL is a YouTube URL + */ +function isYouTubeUrl(url: string): boolean { + // Create a new regex instance to avoid state issues with global regex + const flags = YOUTUBE_URL_REGEX.flags.replace('g', '') + const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags) + return regex.test(url) +} /** * Preprocess content to convert raw media URLs and hyperlinks to markdown syntax @@ -151,10 +161,11 @@ export function preprocessAsciidocMediaLinks(content: string): string { continue // In code block } - // Check if it's a media URL + // Check if it's a media URL or YouTube URL const isImageUrl = isImage(url) const isVideoUrl = isVideo(url) const isAudioUrl = isAudio(url) + const isYouTube = isYouTubeUrl(url) let replacement: string if (isImageUrl) { @@ -166,6 +177,10 @@ export function preprocessAsciidocMediaLinks(content: string): string { } else if (isAudioUrl) { // Audio: convert to audio::url[] replacement = `audio::${url}[]` + } else if (isYouTube) { + // YouTube URLs: convert to link:url[url] (will be handled in post-processing) + // This allows AsciiDoc to process it as a link, then we'll replace it with YouTube player + replacement = `link:${url}[${url}]` } else { // Regular hyperlinks: convert to link:url[url] replacement = `link:${url}[${url}]`