fix(builder): VideoBlock parses more YouTube/Vimeo URL shapes (D4)

Extend detectVideoType to handle youtube.com/shorts/ID, youtube.com/live/ID,
youtube.com/watch?...&v=ID (v not the first query param), and Vimeo
private-hash URLs (vimeo.com/ID/HASH -> ?h=HASH player param). Existing
shapes (youtu.be/ID, youtube.com/embed/ID, youtube.com/watch?v=ID,
vimeo.com/ID, direct files) keep working. The emitted embed src still
passes through safeUrl unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:46:48 -07:00
parent cdcc3969bc
commit fdd088b4bf
2 changed files with 93 additions and 7 deletions
@@ -0,0 +1,61 @@
import { describe, test, expect } from 'vitest';
import { VideoBlock } from './VideoBlock';
const toHtml = (VideoBlock as any).toHtml;
function embedSrc(videoUrl: string): string {
const { html } = toHtml({ videoUrl }, '');
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
return m ? m[1].replace(/&amp;/g, '&') : '';
}
describe('VideoBlock URL parsing (D4)', () => {
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtu.be/ID resolves to embed URL', () => {
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/shorts/ID resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/live/ID resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('vimeo.com/ID (existing case) resolves to player URL', () => {
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
});
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
expect(src).toContain('https://player.vimeo.com/video/123456789');
expect(src).toContain('h=abcdef1234');
});
test('direct .mp4 file still works', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
expect(html).toContain('<video src="https://example.com/clip.mp4"');
});
test('unrecognized URL yields no output (type "none")', () => {
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
expect(html).toBe('');
});
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:');
});
});
+32 -7
View File
@@ -26,18 +26,43 @@ interface VideoBlockProps {
/* ---------- URL detection ---------- */ /* ---------- URL detection ---------- */
/**
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
* may appear anywhere in the query string (not just as the first param).
*/
function extractYouTubeId(url: string): string | null {
const pathMatch = url.match(
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
);
if (pathMatch) return pathMatch[1];
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
if (queryMatch) {
const v = new URLSearchParams(queryMatch[1]).get('v');
if (v) return v;
}
return null;
}
function detectVideoType(url: string): { type: VideoType; embedUrl: string } { function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
if (!url) return { type: 'none', embedUrl: '' }; if (!url) return { type: 'none', embedUrl: '' };
// YouTube // YouTube
const ytMatch = url.match( const ytId = extractYouTubeId(url);
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/ if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
);
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
// Vimeo // Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
const vmMatch = url.match(/vimeo\.com\/(\d+)/); // (the hash becomes the player's `h` query param).
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` }; const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
if (vmMatch) {
const embedUrl = vmMatch[2]
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
: `https://player.vimeo.com/video/${vmMatch[1]}`;
return { type: 'vimeo', embedUrl };
}
// Direct file // Direct file
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url }; if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };