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 <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2026-01-16 09:10:20 +01:00
parent 04c8001114
commit 9f74815ee3
4 changed files with 143 additions and 84 deletions

View File

@ -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' })
}

View File

@ -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<string, unknown>
}
@ -72,9 +50,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
},
tokenize: (src: string) => {
// Match YouTube autolinks: <https://www.youtube.com/watch?v=VIDEO_ID>
let match = /^<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(
src,
)
let match = VIDEO_AUTOLINK_PATTERNS.youtube.exec(src)
if (match) {
return {
type: 'videoEmbed',
@ -85,7 +61,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
}
// Match YouTube short autolinks: <https://youtu.be/VIDEO_ID>
match = /^<https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(src)
match = VIDEO_AUTOLINK_PATTERNS.youtubeShort.exec(src)
if (match) {
return {
type: 'videoEmbed',
@ -96,7 +72,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
}
// Match Rumble autolinks: <https://rumble.com/embed/VIDEO_ID>
match = /^<https?:\/\/rumble\.com\/embed\/([a-zA-Z0-9]+)[^>]*>/.exec(src)
match = VIDEO_AUTOLINK_PATTERNS.rumble.exec(src)
if (match) {
return {
type: 'videoEmbed',
@ -124,10 +100,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
// 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<VideoEmbedOptions>({
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<VideoEmbedOptions>({
*/
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 (
<NodeViewWrapper>

View File

@ -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: <https://www.youtube.com/watch?v=VIDEO_ID>
result = result.replace(
/<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^>&]+)[^>]*>/g,
'<video-embed provider="youtube" video-id="$1"></video-embed>',
result = result.replace(VIDEO_PREPROCESS_PATTERNS.youtubeAutolink, (_, videoId: string) =>
createVideoEmbedTag('youtube', videoId),
)
// YouTube short autolinks: <https://youtu.be/VIDEO_ID>
result = result.replace(
/<https?:\/\/youtu\.be\/([^>?]+)[^>]*>/g,
'<video-embed provider="youtube" video-id="$1"></video-embed>',
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-embed provider="youtube" video-id="$2"></video-embed>',
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-embed provider="youtube" video-id="$2"></video-embed>',
VIDEO_PREPROCESS_PATTERNS.youtubeShortLink,
(_match: string, _text: string, videoId: string) => createVideoEmbedTag('youtube', videoId),
)
// Rumble autolinks: <https://rumble.com/embed/VIDEO_ID>
result = result.replace(
/<https?:\/\/rumble\.com\/embed\/([^>]+)>/g,
'<video-embed provider="rumble" video-id="$1"></video-embed>',
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-embed provider="rumble" video-id="$2"></video-embed>',
VIDEO_PREPROCESS_PATTERNS.rumbleLink,
(_match: string, _text: string, videoId: string) => createVideoEmbedTag('rumble', videoId),
)
return result

View File

@ -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: <https://...>
*/
export const VIDEO_AUTOLINK_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 preprocessing markdown (global replacement)
* These match both autolinks <url> and markdown links [text](url)
*/
export const VIDEO_PREPROCESS_PATTERNS = {
// Autolinks: <https://...>
youtubeAutolink: /<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^>&]+)[^>]*>/g,
youtubeShortAutolink: /<https?:\/\/youtu\.be\/([^>?]+)[^>]*>/g,
rumbleAutolink: /<https?:\/\/rumble\.com\/embed\/([^>]+)>/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 `<video-embed provider="${provider}" video-id="${videoId}"></video-embed>`
}