Skip to content

Commit f139d3c

Browse files
DavidBabinecclaude
andauthored
fix(import): route YouTube iframes and <video> elements to the base.video module (CoreBunch#151)
* fix(import): route YouTube iframes and <video> elements to the base.video module YouTube <iframe> embeds and <video> elements imported via paste-HTML, Super Import, or the AI agent's site_insert_html tool previously fell through to the catch-all rule as base.container nodes. base.container declares no CSP sources, so YouTube iframes stayed blocked by the published page's frame-src 'none' even after PR CoreBunch#141 (which only fires when base.video renders). New rules added to HTML_TO_MODULE_RULES in src/core/htmlImport/rules.ts: - iframe: YouTube host check (youtube.com/m.youtube.com/youtube-nocookie.com/ youtu.be) maps to base.video; any other iframe falls back to base.container with tag:'custom',customTag:'iframe' so Vimeo, Maps, and arbitrary embeds keep working. rel=0 in the embed URL sets noRelatedVideos:true; playsinline=1 sets playsinline:true; the title attribute maps to the title prop. - video: maps to base.video with videoUrl from <video src> or the first <source src> child, and all boolean attributes (controls, autoplay, loop, muted, playsinline). recurse:false so <source> children are consumed, not emitted as extra nodes. Inline YouTube host detection in rules.ts — src/core/ must not import from src/modules/, so parseYoutubeId from src/modules/base/video/youtube.ts is not imported. The importer only needs host-level detection to decide the module; base.video's render() re-parses the stored videoUrl at publish time. base.video (src/modules/base/video/) extended with two new props: - title (default: 'YouTube video') — used as the iframe title attribute, replacing the previous hardcoded string. Improves accessibility. - noRelatedVideos (default: false) — appends rel=0 to the YouTube embed URL to suppress recommended videos. youtubeEmbedUrl() updated to build the query string from autoplay and noRelatedVideos together. VideoEditor.tsx wired up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: restore docs files accidentally staged from prior branch The chore/gitignore-superpowers branch had these files staged for deletion. When switching to fix/import-video-embeds-to-base-video the staged index was inherited and the previous commit swept them in unintentionally. Restore them from origin/main to keep this PR scope clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f4e7240 commit f139d3c

7 files changed

Lines changed: 290 additions & 9 deletions

File tree

src/__tests__/base-modules.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,15 +891,17 @@ describe('base.svg — render() specifics', () => {
891891
// ---------------------------------------------------------------------------
892892

893893
describe('base.video — render() specifics', () => {
894-
it('exposes the v4 schema (single videoUrl, playback, poster, perf hints)', () => {
894+
it('exposes the v4 schema (single videoUrl, playback, poster, perf hints, title, noRelatedVideos)', () => {
895895
expect(Object.keys(VideoModule.schema).sort()).toEqual([
896896
'autoplay',
897897
'controls',
898898
'loop',
899899
'muted',
900+
'noRelatedVideos',
900901
'playsinline',
901902
'poster',
902903
'preload',
904+
'title',
903905
'videoUrl',
904906
])
905907
})
@@ -1042,6 +1044,51 @@ describe('base.video — render() specifics', () => {
10421044
const out = renderModule(VideoModule, { videoUrl: '' })
10431045
expect(out.cspSources).toBeUndefined()
10441046
})
1047+
1048+
// --- title prop ---
1049+
1050+
it('title prop is used as the iframe title attribute for YouTube embeds', () => {
1051+
const { html } = renderModule(VideoModule, {
1052+
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
1053+
title: 'Rick Astley Never Gonna Give You Up',
1054+
})
1055+
expect(html).toContain('title="Rick Astley Never Gonna Give You Up"')
1056+
expect(html).not.toContain('title="YouTube video"')
1057+
})
1058+
1059+
it('title prop defaults to "YouTube video" when not set', () => {
1060+
const { html } = renderModule(VideoModule, {
1061+
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
1062+
})
1063+
expect(html).toContain('title="YouTube video"')
1064+
})
1065+
1066+
// --- noRelatedVideos prop ---
1067+
1068+
it('noRelatedVideos: true appends rel=0 to the YouTube embed URL', () => {
1069+
const { html } = renderModule(VideoModule, {
1070+
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
1071+
noRelatedVideos: true,
1072+
})
1073+
expect(html).toMatch(/youtube\.com\/embed\/dQw4w9WgXcQ[^"]*rel=0/)
1074+
})
1075+
1076+
it('noRelatedVideos: false (default) does NOT add rel=0', () => {
1077+
const { html } = renderModule(VideoModule, {
1078+
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
1079+
})
1080+
expect(html).not.toContain('rel=0')
1081+
})
1082+
1083+
it('noRelatedVideos still declares cspSources for frame-src YouTube', () => {
1084+
const out = renderModule(VideoModule, {
1085+
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
1086+
noRelatedVideos: true,
1087+
})
1088+
const frameSrc = out.cspSources?.find((r) => r.directive === 'frame-src')
1089+
expect(frameSrc?.sources).toContain('https://www.youtube.com')
1090+
expect(frameSrc?.sources).toContain('https://www.youtube-nocookie.com')
1091+
})
10451092
})
10461093

10471094
// ---------------------------------------------------------------------------

src/__tests__/htmlImport/mapping.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1311,7 +1311,132 @@ describe('base.loop — <instatic-loop>', () => {
13111311
})
13121312

13131313
// ---------------------------------------------------------------------------
1314-
// 13. Empty input and edge cases
1314+
// 13. iframe and video → base.video mapping
1315+
// ---------------------------------------------------------------------------
1316+
1317+
describe('base.video — <iframe> import mapping', () => {
1318+
it('YouTube embed URL → base.video with videoUrl set', () => {
1319+
const node = single('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>')
1320+
expect(node.moduleId).toBe('base.video')
1321+
expect(node.props.videoUrl).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
1322+
})
1323+
1324+
it('YouTube watch URL in iframe src → base.video', () => {
1325+
const node = single('<iframe src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"></iframe>')
1326+
expect(node.moduleId).toBe('base.video')
1327+
expect(node.props.videoUrl).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
1328+
})
1329+
1330+
it('youtube-nocookie.com embed → base.video', () => {
1331+
const node = single('<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>')
1332+
expect(node.moduleId).toBe('base.video')
1333+
expect(node.props.videoUrl).toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')
1334+
})
1335+
1336+
it('iframe with title attr → title prop propagated to base.video', () => {
1337+
const node = single('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="My Demo Video"></iframe>')
1338+
expect(node.moduleId).toBe('base.video')
1339+
expect(node.props.title).toBe('My Demo Video')
1340+
})
1341+
1342+
it('rel=0 in embed URL → noRelatedVideos true', () => {
1343+
const node = single('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0"></iframe>')
1344+
expect(node.moduleId).toBe('base.video')
1345+
expect(node.props.noRelatedVideos).toBe(true)
1346+
})
1347+
1348+
it('playsinline=1 in embed URL → playsinline true', () => {
1349+
const node = single('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ?playsinline=1"></iframe>')
1350+
expect(node.moduleId).toBe('base.video')
1351+
expect(node.props.playsinline).toBe(true)
1352+
})
1353+
1354+
it('full-featured embed iframe → all mapped props', () => {
1355+
const node = single(
1356+
'<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&playsinline=1" title="Full Demo"></iframe>',
1357+
)
1358+
expect(node.moduleId).toBe('base.video')
1359+
expect(node.props.videoUrl).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&playsinline=1')
1360+
expect(node.props.title).toBe('Full Demo')
1361+
expect(node.props.noRelatedVideos).toBe(true)
1362+
expect(node.props.playsinline).toBe(true)
1363+
})
1364+
1365+
it('Vimeo iframe → falls back to base.container (not base.video)', () => {
1366+
const node = single('<iframe src="https://player.vimeo.com/video/123456789"></iframe>')
1367+
expect(node.moduleId).toBe('base.container')
1368+
expect(node.props.tag).toBe('custom')
1369+
expect(node.props.customTag).toBe('iframe')
1370+
})
1371+
1372+
it('Google Maps iframe → falls back to base.container', () => {
1373+
const node = single('<iframe src="https://maps.google.com/maps?q=London"></iframe>')
1374+
expect(node.moduleId).toBe('base.container')
1375+
expect(node.props.customTag).toBe('iframe')
1376+
})
1377+
1378+
it('arbitrary iframe → falls back to base.container (no moduleId base.video)', () => {
1379+
const node = single('<iframe src="https://example.com/form"></iframe>')
1380+
expect(node.moduleId).toBe('base.container')
1381+
expect(node.props.customTag).toBe('iframe')
1382+
})
1383+
1384+
it('YouTube iframe produces no children (recurse: false)', () => {
1385+
// iframes have no meaningful DOM children to recurse into
1386+
const node = single('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>')
1387+
expect(node.children).toHaveLength(0)
1388+
})
1389+
})
1390+
1391+
describe('base.video — <video> import mapping', () => {
1392+
it('<video src="..."> → base.video with videoUrl from src attr', () => {
1393+
const node = single('<video src="clip.mp4"></video>')
1394+
expect(node.moduleId).toBe('base.video')
1395+
expect(node.props.videoUrl).toBe('clip.mp4')
1396+
})
1397+
1398+
it('<video controls loop> → base.video with controls and loop true', () => {
1399+
const node = single('<video src="clip.mp4" controls loop></video>')
1400+
expect(node.moduleId).toBe('base.video')
1401+
expect(node.props.controls).toBe(true)
1402+
expect(node.props.loop).toBe(true)
1403+
})
1404+
1405+
it('<video autoplay muted playsinline> → all boolean attrs mapped', () => {
1406+
const node = single('<video src="clip.mp4" autoplay muted playsinline></video>')
1407+
expect(node.moduleId).toBe('base.video')
1408+
expect(node.props.autoplay).toBe(true)
1409+
expect(node.props.muted).toBe(true)
1410+
expect(node.props.playsinline).toBe(true)
1411+
})
1412+
1413+
it('<video> without controls attr → controls false', () => {
1414+
const node = single('<video src="clip.mp4"></video>')
1415+
expect(node.moduleId).toBe('base.video')
1416+
expect(node.props.controls).toBe(false)
1417+
})
1418+
1419+
it('<video><source src="..."></video> → videoUrl from first source child', () => {
1420+
const node = single('<video><source src="clip.mp4" type="video/mp4"></video>')
1421+
expect(node.moduleId).toBe('base.video')
1422+
expect(node.props.videoUrl).toBe('clip.mp4')
1423+
})
1424+
1425+
it('<video> with both src and source → prefers src attr', () => {
1426+
const node = single('<video src="direct.mp4"><source src="fallback.webm"></video>')
1427+
expect(node.moduleId).toBe('base.video')
1428+
expect(node.props.videoUrl).toBe('direct.mp4')
1429+
})
1430+
1431+
it('<video> produces no children (recurse: false)', () => {
1432+
// <source> children are consumed by the rule, not emitted as nodes
1433+
const node = single('<video><source src="clip.mp4"></video>')
1434+
expect(node.children).toHaveLength(0)
1435+
})
1436+
})
1437+
1438+
// ---------------------------------------------------------------------------
1439+
// 14. Empty input and edge cases
13151440
// ---------------------------------------------------------------------------
13161441

13171442
describe('edge cases', () => {

src/core/htmlImport/rules.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,95 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [
455455
// recurse intentionally omitted — void elements must remain childless.
456456
},
457457

458+
// YouTube iframes and native <video> elements → base.video (LEAF).
459+
//
460+
// NOTE on layering: src/core/ MUST NOT import from src/modules/. The
461+
// canonical YouTube URL parser lives in src/modules/base/video/youtube.ts
462+
// (parseYoutubeId). We inline a minimal equivalent here — a hostname check
463+
// against the known YouTube domains. It does NOT extract the video ID;
464+
// base.video's render() re-parses the stored videoUrl at publish time.
465+
//
466+
// `<iframe src="https://www.youtube.com/embed/ID">` → base.video
467+
// `<iframe src="https://player.vimeo.com/...">` → base.container fallback
468+
// `<video src="clip.mp4" controls>` → base.video
469+
// `<video><source src="clip.mp4"></video>` → base.video (videoUrl from <source>)
470+
471+
// Inline YouTube host check — the canonical parser (parseYoutubeId) lives in
472+
// src/modules/base/video/youtube.ts; layering rules forbid importing it here.
473+
// This minimal variant only needs to distinguish "YouTube" from "not YouTube"
474+
// so non-YouTube iframes still fall back to base.container.
475+
{
476+
match: 'iframe',
477+
map: (el) => {
478+
const src = attr(el, 'src')
479+
let isYoutube = false
480+
if (src) {
481+
try {
482+
const host = new URL(src).hostname.toLowerCase().replace(/^www\./, '')
483+
isYoutube = host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com' || host === 'youtu.be'
484+
} catch {
485+
// malformed URL — not YouTube
486+
}
487+
}
488+
489+
if (!isYoutube) {
490+
// Non-YouTube iframes (Vimeo, maps, forms, arbitrary embeds) fall back
491+
// to base.container so their src/attributes are preserved via htmlAttributes.
492+
return { moduleId: 'base.container', props: { tag: 'custom', customTag: 'iframe' } }
493+
}
494+
495+
// Map YouTube-specific query params from the src URL
496+
let noRelatedVideos = false
497+
let playsinline = false
498+
try {
499+
const params = new URL(src).searchParams
500+
noRelatedVideos = params.get('rel') === '0'
501+
playsinline = params.get('playsinline') === '1'
502+
} catch {
503+
// ignore malformed src
504+
}
505+
506+
return {
507+
moduleId: 'base.video',
508+
props: {
509+
videoUrl: src,
510+
...(attr(el, 'title') ? { title: attr(el, 'title') } : {}),
511+
...(noRelatedVideos ? { noRelatedVideos: true } : {}),
512+
...(playsinline ? { playsinline: true } : {}),
513+
},
514+
}
515+
},
516+
// Iframes are leaf nodes — no meaningful DOM children to recurse into.
517+
recurse: false,
518+
},
519+
520+
{
521+
match: 'video',
522+
map: (el) => {
523+
// Prefer the <video src> attribute; fall back to the first <source src>
524+
// child. Recurse is false so <source> children are consumed here, not
525+
// emitted as separate nodes.
526+
const videoUrl =
527+
attr(el, 'src')
528+
|| el.querySelector('source')?.getAttribute('src')
529+
|| ''
530+
531+
return {
532+
moduleId: 'base.video',
533+
props: {
534+
videoUrl,
535+
autoplay: el.hasAttribute('autoplay'),
536+
loop: el.hasAttribute('loop'),
537+
muted: el.hasAttribute('muted'),
538+
controls: el.hasAttribute('controls'),
539+
playsinline: el.hasAttribute('playsinline'),
540+
},
541+
}
542+
},
543+
// Do not recurse — <source> children are consumed by the rule above.
544+
recurse: false,
545+
},
546+
458547
// Catch-all for every other tag (li, figure, blockquote, form, table,
459548
// dialog, …). MUST use tag:'custom' + customTag so resolveHtmlTag
460549
// emits the real element name — tag:'div' + customTag would render <div>.

src/modules/base/video/VideoEditor.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export const VideoEditor: React.FC<ModuleComponentProps<VideoStoredProps>> = ({
9090

9191
// ─── YouTube ────────────────────────────────────────────────────────────
9292
if (youtubeId) {
93-
const src = youtubeEmbedUrl(youtubeId, props.autoplay)
93+
const src = youtubeEmbedUrl(youtubeId, props.autoplay, props.noRelatedVideos)
94+
const iframeTitle = props.title || 'YouTube video'
9495
if (posterUrl) {
9596
return (
9697
<div {...nodeWrapperProps} className={mcClassName} style={FACADE_WRAP_STYLE}>
@@ -105,7 +106,7 @@ export const VideoEditor: React.FC<ModuleComponentProps<VideoStoredProps>> = ({
105106
/>
106107
<iframe
107108
src={src}
108-
title="YouTube video"
109+
title={iframeTitle}
109110
loading="lazy"
110111
frameBorder="0"
111112
allow="autoplay; encrypted-media; fullscreen"
@@ -121,7 +122,7 @@ export const VideoEditor: React.FC<ModuleComponentProps<VideoStoredProps>> = ({
121122
<div {...nodeWrapperProps} className={mcClassName} style={FACADE_WRAP_STYLE}>
122123
<iframe
123124
src={src}
124-
title="YouTube video"
125+
title={iframeTitle}
125126
loading="lazy"
126127
frameBorder="0"
127128
allow="autoplay; encrypted-media; fullscreen"

src/modules/base/video/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export const VideoModule: ModuleDefinition<VideoProps> = {
9191
{ label: 'Auto', value: 'auto' },
9292
],
9393
},
94+
title: { type: 'text', label: 'Video title', description: 'Accessibility label for the embedded YouTube player iframe.' },
95+
noRelatedVideos: { type: 'toggle', label: 'Hide related videos', description: 'Adds rel=0 to suppress YouTube recommended videos after playback.' },
9496
},
9597

9698
// Single source of truth: defaults are derived from the schema's `default`
@@ -118,6 +120,8 @@ export const VideoModule: ModuleDefinition<VideoProps> = {
118120
return renderYoutube({
119121
youtubeId,
120122
autoplay: Boolean(props.autoplay),
123+
noRelatedVideos: Boolean(props.noRelatedVideos),
124+
title: String(props.title || 'YouTube video'),
121125
posterUrl: String(props.poster ?? ''),
122126
posterMedia: props._resolvedMediaByKey?.poster ?? null,
123127
})
@@ -167,6 +171,9 @@ export const VideoModule: ModuleDefinition<VideoProps> = {
167171
interface YoutubeRenderInput {
168172
youtubeId: string
169173
autoplay: boolean
174+
noRelatedVideos: boolean
175+
/** Accessibility title for the iframe element. */
176+
title: string
170177
/** Raw author-set poster URL (already escapeProps-passed). */
171178
posterUrl: string
172179
/** Resolved poster asset (variants, intrinsic dims) if the publisher pre-pass ran. */
@@ -201,12 +208,12 @@ const YOUTUBE_CSP_SOURCES: CspSourceRequirement[] = [
201208
* Without a poster: emit just the iframe, also `loading="lazy"`.
202209
*/
203210
function renderYoutube(input: YoutubeRenderInput): RenderOutput {
204-
const embedSrc = youtubeEmbedUrl(input.youtubeId, input.autoplay)
211+
const embedSrc = youtubeEmbedUrl(input.youtubeId, input.autoplay, input.noRelatedVideos)
205212
if (!embedSrc) return { html: '' }
206213

207214
const iframeAttrs = [
208215
`src="${embedSrc}"`,
209-
`title="YouTube video"`,
216+
`title="${input.title}"`,
210217
`loading="lazy"`,
211218
`frameborder="0"`,
212219
`allow="autoplay; encrypted-media; fullscreen"`,

src/modules/base/video/props.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export const VideoPropsSchema = Type.Object({
1212
[Type.Literal('none'), Type.Literal('metadata'), Type.Literal('auto')],
1313
{ default: 'metadata' },
1414
),
15+
/** Iframe title attribute for YouTube embeds. Improves accessibility. */
16+
title: Type.String({ default: 'YouTube video' }),
17+
/** When true, appends rel=0 to the YouTube embed URL to suppress related videos. */
18+
noRelatedVideos: Type.Boolean({ default: false }),
1519
})
1620

1721
export type VideoStoredProps = Static<typeof VideoPropsSchema>

0 commit comments

Comments
 (0)