From 9f74815ee354f9a3a61482ccea4e98ee1dff002c Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Fri, 16 Jan 2026 09:10:20 +0100 Subject: [PATCH] fix(tiptap): add null checks, extract video patterns to shared module - Add null checks for editor in RichTextEditor (handleChange, useEffect) - Extract video URL patterns to shared videoPatterns.ts module - Refactor VideoEmbed.tsx and preprocessMarkdown.ts to use shared patterns - Add helper functions: getVideoEmbedUrl, getVideoCanonicalUrl, parseVideoUrl - Remove unused markdownToTiptapJson function Co-Authored-By: Claude Opus 4.5 --- lib/src/Components/Input/RichTextEditor.tsx | 4 + .../TipTap/extensions/VideoEmbed.tsx | 61 ++-------- .../TipTap/utils/preprocessMarkdown.ts | 49 ++------ .../Components/TipTap/utils/videoPatterns.ts | 113 ++++++++++++++++++ 4 files changed, 143 insertions(+), 84 deletions(-) create mode 100644 lib/src/Components/TipTap/utils/videoPatterns.ts diff --git a/lib/src/Components/Input/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor.tsx index 84fec133..88437dc1 100644 --- a/lib/src/Components/Input/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor.tsx @@ -54,6 +54,8 @@ export function RichTextEditor({ ) const handleChange = () => { + if (!editor) return + let newValue: string | undefined = editor.getMarkdown() const regex = /!\[.*?\]\(.*?\)/g @@ -104,6 +106,8 @@ export function RichTextEditor({ }) useEffect(() => { + if (!editor) return + if (editor.getMarkdown() === '' || !editor.getMarkdown()) { editor.commands.setContent(defaultValue, { contentType: 'markdown' }) } diff --git a/lib/src/Components/TipTap/extensions/VideoEmbed.tsx b/lib/src/Components/TipTap/extensions/VideoEmbed.tsx index 3dbfe6d9..6b5a4faf 100644 --- a/lib/src/Components/TipTap/extensions/VideoEmbed.tsx +++ b/lib/src/Components/TipTap/extensions/VideoEmbed.tsx @@ -2,37 +2,15 @@ import { mergeAttributes, Node } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { + VIDEO_AUTOLINK_PATTERNS, + getVideoCanonicalUrl, + getVideoEmbedUrl, + parseVideoUrl, +} from '#components/TipTap/utils/videoPatterns' + import type { NodeViewProps } from '@tiptap/react' -// Regex patterns for video URL detection -// Using possessive-like patterns with specific character classes to avoid ReDoS -// YouTube IDs are typically 11 chars but we allow 10-12 for flexibility -const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})(?:&|$)/ -const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})(?:\?|$)/ -const RUMBLE_REGEX = /^https?:\/\/rumble\.com\/embed\/([a-zA-Z0-9]+)(?:\/|$)/ - -/** - * Extracts video provider and ID from a URL - */ -function parseVideoUrl(url: string): { provider: 'youtube' | 'rumble'; videoId: string } | null { - let match = YOUTUBE_REGEX.exec(url) - if (match) { - return { provider: 'youtube', videoId: match[1] } - } - - match = YOUTUBE_SHORT_REGEX.exec(url) - if (match) { - return { provider: 'youtube', videoId: match[1] } - } - - match = RUMBLE_REGEX.exec(url) - if (match) { - return { provider: 'rumble', videoId: match[1] } - } - - return null -} - export interface VideoEmbedOptions { HTMLAttributes: Record } @@ -72,9 +50,7 @@ export const VideoEmbed = Node.create({ }, tokenize: (src: string) => { // Match YouTube autolinks: - let match = /^]*>/.exec( - src, - ) + let match = VIDEO_AUTOLINK_PATTERNS.youtube.exec(src) if (match) { return { type: 'videoEmbed', @@ -85,7 +61,7 @@ export const VideoEmbed = Node.create({ } // Match YouTube short autolinks: - match = /^]*>/.exec(src) + match = VIDEO_AUTOLINK_PATTERNS.youtubeShort.exec(src) if (match) { return { type: 'videoEmbed', @@ -96,7 +72,7 @@ export const VideoEmbed = Node.create({ } // Match Rumble autolinks: - match = /^]*>/.exec(src) + match = VIDEO_AUTOLINK_PATTERNS.rumble.exec(src) if (match) { return { type: 'videoEmbed', @@ -124,10 +100,7 @@ export const VideoEmbed = Node.create({ // Serialize Tiptap node to Markdown renderMarkdown(node: { attrs: { provider: string; videoId: string } }) { const { provider, videoId } = node.attrs - const url = - provider === 'youtube' - ? `https://www.youtube.com/watch?v=${videoId}` - : `https://rumble.com/embed/${videoId}` + const url = getVideoCanonicalUrl(provider as 'youtube' | 'rumble', videoId) return `<${url}>` }, @@ -156,11 +129,7 @@ export const VideoEmbed = Node.create({ renderHTML({ node, HTMLAttributes }) { const { provider, videoId } = node.attrs as { provider: string; videoId: string } - - const src = - provider === 'youtube' - ? `https://www.youtube-nocookie.com/embed/${videoId}` - : `https://rumble.com/embed/${videoId}` + const src = getVideoEmbedUrl(provider as 'youtube' | 'rumble', videoId) return [ 'div', @@ -231,11 +200,7 @@ export const VideoEmbed = Node.create({ */ function VideoEmbedComponent({ node }: NodeViewProps) { const { provider, videoId } = node.attrs as { provider: string; videoId: string } - - const src = - provider === 'youtube' - ? `https://www.youtube-nocookie.com/embed/${videoId}` - : `https://rumble.com/embed/${videoId}` + const src = getVideoEmbedUrl(provider as 'youtube' | 'rumble', videoId) return ( diff --git a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts index 0a249419..f518f2cb 100644 --- a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts +++ b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts @@ -1,8 +1,6 @@ -import { Editor } from '@tiptap/core' - import { fixUrls, mailRegex } from '#utils/ReplaceURLs' -import type { JSONContent, Extensions } from '@tiptap/core' +import { VIDEO_PREPROCESS_PATTERNS, createVideoEmbedTag } from './videoPatterns' /** * Converts naked URLs to markdown links, but skips URLs that are already @@ -66,24 +64,6 @@ function convertNakedUrls(text: string): string { return result } -/** - * Converts pre-processed markdown/HTML to TipTap JSON format. - * Creates a temporary editor instance to parse the content. - */ -export function markdownToTiptapJson(content: string, extensions: Extensions): JSONContent { - // Create a temporary editor to parse HTML/markdown - const editor = new Editor({ - extensions, - content, - // We immediately destroy this, so no need for DOM attachment - }) - - const json = editor.getJSON() - editor.destroy() - - return json -} - /** * Pre-processes markdown text before passing to TipTap. * - Converts naked URLs to markdown links @@ -127,39 +107,36 @@ export function preprocessVideoLinks(text: string): string { let result = text // YouTube autolinks: - result = result.replace( - /&]+)[^>]*>/g, - '', + result = result.replace(VIDEO_PREPROCESS_PATTERNS.youtubeAutolink, (_, videoId: string) => + createVideoEmbedTag('youtube', videoId), ) // YouTube short autolinks: - result = result.replace( - /?]+)[^>]*>/g, - '', + result = result.replace(VIDEO_PREPROCESS_PATTERNS.youtubeShortAutolink, (_, videoId: string) => + createVideoEmbedTag('youtube', videoId), ) // YouTube: [Text](https://www.youtube.com/watch?v=VIDEO_ID) result = result.replace( - /\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g, - '', + VIDEO_PREPROCESS_PATTERNS.youtubeLink, + (_match: string, _text: string, videoId: string) => createVideoEmbedTag('youtube', videoId), ) // YouTube short URLs: [Text](https://youtu.be/VIDEO_ID) result = result.replace( - /\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g, - '', + VIDEO_PREPROCESS_PATTERNS.youtubeShortLink, + (_match: string, _text: string, videoId: string) => createVideoEmbedTag('youtube', videoId), ) // Rumble autolinks: - result = result.replace( - /]+)>/g, - '', + result = result.replace(VIDEO_PREPROCESS_PATTERNS.rumbleAutolink, (_, videoId: string) => + createVideoEmbedTag('rumble', videoId), ) // Rumble embed URLs: [Text](https://rumble.com/embed/VIDEO_ID) result = result.replace( - /\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g, - '', + VIDEO_PREPROCESS_PATTERNS.rumbleLink, + (_match: string, _text: string, videoId: string) => createVideoEmbedTag('rumble', videoId), ) return result diff --git a/lib/src/Components/TipTap/utils/videoPatterns.ts b/lib/src/Components/TipTap/utils/videoPatterns.ts new file mode 100644 index 00000000..04afe16c --- /dev/null +++ b/lib/src/Components/TipTap/utils/videoPatterns.ts @@ -0,0 +1,113 @@ +/** + * Shared video URL patterns for YouTube and Rumble. + * Used by both VideoEmbed extension and preprocessMarkdown utility. + */ + +export type VideoProvider = 'youtube' | 'rumble' + +export interface VideoInfo { + provider: VideoProvider + videoId: string +} + +// YouTube video ID pattern: 11 characters (alphanumeric, dash, underscore) +// We allow 10-12 for flexibility +const YOUTUBE_VIDEO_ID_PATTERN = '[a-zA-Z0-9_-]{10,12}' + +// Rumble video ID pattern: alphanumeric only +const RUMBLE_VIDEO_ID_PATTERN = '[a-zA-Z0-9]+' + +/** + * Regex patterns for parsing video URLs (used for paste handling) + */ +export const VIDEO_URL_PATTERNS = { + youtube: new RegExp( + `^https?:\\/\\/(?:www\\.)?youtube\\.com\\/watch\\?v=(${YOUTUBE_VIDEO_ID_PATTERN})(?:&|$)`, + ), + youtubeShort: new RegExp(`^https?:\\/\\/youtu\\.be\\/(${YOUTUBE_VIDEO_ID_PATTERN})(?:\\?|$)`), + rumble: new RegExp(`^https?:\\/\\/rumble\\.com\\/embed\\/(${RUMBLE_VIDEO_ID_PATTERN})(?:\\/|$)`), +} as const + +/** + * Regex patterns for markdown tokenizer (used in TipTap extension) + * These match autolinks: + */ +export const VIDEO_AUTOLINK_PATTERNS = { + youtube: new RegExp( + `^]*>`, + ), + youtubeShort: new RegExp(`^]*>`), + rumble: new RegExp(`^]*>`), +} as const + +/** + * Regex patterns for preprocessing markdown (global replacement) + * These match both autolinks and markdown links [text](url) + */ +export const VIDEO_PREPROCESS_PATTERNS = { + // Autolinks: + youtubeAutolink: /&]+)[^>]*>/g, + youtubeShortAutolink: /?]+)[^>]*>/g, + rumbleAutolink: /]+)>/g, + + // Markdown links: [text](url) + youtubeLink: /\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g, + youtubeShortLink: /\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g, + rumbleLink: /\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g, +} as const + +/** + * Generates embed URLs for video providers + */ +export function getVideoEmbedUrl(provider: VideoProvider, videoId: string): string { + // Sanitize videoId to only allow safe characters + const safeVideoId = videoId.replace(/[^a-zA-Z0-9_-]/g, '') + + switch (provider) { + case 'youtube': + return `https://www.youtube-nocookie.com/embed/${safeVideoId}` + case 'rumble': + return `https://rumble.com/embed/${safeVideoId}` + } +} + +/** + * Generates the canonical URL for a video (used in markdown serialization) + */ +export function getVideoCanonicalUrl(provider: VideoProvider, videoId: string): string { + switch (provider) { + case 'youtube': + return `https://www.youtube.com/watch?v=${videoId}` + case 'rumble': + return `https://rumble.com/embed/${videoId}` + } +} + +/** + * Extracts video provider and ID from a URL + */ +export function parseVideoUrl(url: string): VideoInfo | null { + let match = VIDEO_URL_PATTERNS.youtube.exec(url) + if (match) { + return { provider: 'youtube', videoId: match[1] } + } + + match = VIDEO_URL_PATTERNS.youtubeShort.exec(url) + if (match) { + return { provider: 'youtube', videoId: match[1] } + } + + match = VIDEO_URL_PATTERNS.rumble.exec(url) + if (match) { + return { provider: 'rumble', videoId: match[1] } + } + + return null +} + +/** + * Generates a video-embed HTML tag for preprocessing + */ +export function createVideoEmbedTag(provider: VideoProvider, videoId: string): string { + return `` +}