refactor(core)!: tighten constrained jsx boundary

This commit is contained in:
Rahim
2026-06-20 15:37:34 -07:00
parent 5fcab69d34
commit 99613ad768
31 changed files with 314 additions and 400 deletions
+19 -23
View File
@@ -66,14 +66,14 @@ describe('compile (transformImports — bare-string rule)', () => {
describe('compile (transformImports — function rule)', () => {
it('rewrites per-identifier source and bucket-merges by resolved target', async () => {
const source = `import { Alpha, Beta } from '@fixture/components';\nconst _ = [Alpha, Beta];`;
const source = `import { Alpha, Beta } from '@fixture/widgets';\nconst _ = [Alpha, Beta];`;
const { code } = await compileJsx(source, {
imports: {
'@fixture/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }),
'@fixture/widgets': (name) => ({ source: `./widgets/${name.toLowerCase()}`, name }),
},
});
expect(code).toContain(`import { Alpha } from "./ui/alpha"`);
expect(code).toContain(`import { Beta } from "./ui/beta"`);
expect(code).toContain(`import { Alpha } from "./widgets/alpha"`);
expect(code).toContain(`import { Beta } from "./widgets/beta"`);
});
it('renames identifiers when the rule returns a different `name`', async () => {
@@ -154,7 +154,7 @@ describe('childAsProp', () => {
describe('replaceJsxChild', () => {
it('replaces matched JSX children with expression helpers', async () => {
const source = `function App({ values }){ return <Container><Token name="poster-image"/></Container>; }`;
const source = `function App({ values }){ return <Host><Token name="poster-image"/></Host>; }`;
const { code } = await compileJsx(source, {
transforms: [
replaceJsxChild({
@@ -164,68 +164,64 @@ describe('replaceJsxChild', () => {
],
});
expect(collapse(code)).toContain(collapse(`<Container>{values["poster-image"]}</Container>`));
expect(collapse(code)).toContain(collapse(`<Host>{values["poster-image"]}</Host>`));
});
});
describe('addProp', () => {
it('emits a JSX value by default and adds the import', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const source = `function App(){ return <Action/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
transforms: [addProp({ match: byTag('Action'), prop: 'render', value: { source: './button', name: 'Button' } })],
});
expect(collapse(code)).toContain(collapse(`<PlayButton render={<Button/>}/>`));
expect(collapse(code)).toContain(collapse(`<Action render={<Button/>}/>`));
expect(code).toContain(`import { Button } from "./button"`);
});
it('emits a bare reference when kind is "ref"', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const source = `function App(){ return <Action/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
match: byTag('Action'),
prop: 'as',
value: { source: './button', name: 'Button', kind: 'ref' },
}),
],
});
expect(collapse(code)).toContain(collapse(`<PlayButton as={Button}/>`));
expect(collapse(code)).toContain(collapse(`<Action as={Button}/>`));
});
it('skips elements where the prop is already set', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const source = `function App(){ return <Action render={<X/>}/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
transforms: [addProp({ match: byTag('Action'), prop: 'render', value: { source: './button', name: 'Button' } })],
});
expect(collapse(code)).toContain(collapse(`<X/>`));
expect(code).not.toContain('import { Button }');
});
it('overwrites the existing prop when overwrite is true', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const source = `function App(){ return <Action render={<X/>}/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
match: byTag('Action'),
prop: 'render',
overwrite: true,
value: { source: './button', name: 'Button' },
}),
],
});
expect(collapse(code)).toContain(collapse(`<PlayButton render={<Button/>}/>`));
expect(collapse(code)).toContain(collapse(`<Action render={<Button/>}/>`));
});
});
describe('matchers', () => {
it('byTag supports dotted tags', async () => {
const source = `function App(){ return <Popover.Root foo="bar"/>; }`;
const source = `function App(){ return <Alpha.Root foo="bar"/>; }`;
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })],
transforms: [replace({ match: byTag('Alpha.Root'), with: { source: 'pkg', name: 'NewRoot' } })],
});
expect(code).toContain(`<NewRoot`);
});
-233
View File
@@ -1,233 +0,0 @@
import {
BufferingIndicator,
CaptionsButton,
CastButton,
Container,
Controls,
ErrorDialog,
FullscreenButton,
Gesture,
Hotkey,
MuteButton,
PiPButton,
PlayButton,
PlaybackRateButton,
Popover,
Poster,
SeekButton,
Slider,
Time,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@fixture/components';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@fixture/icons/components';
import { cn } from '@videojs/utils/style';
import { video as styles } from '../tailwind';
const SEEK_TIME = 10;
const iconButton = cn(styles.button.base, styles.button.subtle, styles.button.icon);
export interface VideoSkinProps {
className?: string;
}
export function VideoSkin({ className }: VideoSkinProps) {
return (
<Container data-skin="default-video" className={cn(styles.container, className)}>
<Poster className={styles.poster} />
<BufferingIndicator className={styles.bufferingIndicator.root}>
<div className={styles.bufferingIndicator.container}>
<SpinnerIcon className={styles.icon} />
</div>
</BufferingIndicator>
<ErrorDialog.Root>
<ErrorDialog.Popup className={styles.error.root}>
<div className={styles.error.dialog}>
<div className={styles.error.content}>
<ErrorDialog.Title className={styles.error.title}>Something went wrong.</ErrorDialog.Title>
<ErrorDialog.Description className={styles.error.description} />
</div>
<div className={styles.error.actions}>
<ErrorDialog.Close className={cn(styles.button.base, styles.button.primary)}>OK</ErrorDialog.Close>
</div>
</div>
</ErrorDialog.Popup>
</ErrorDialog.Root>
<Controls.Root className={styles.controls}>
<Tooltip.Provider>
<div className={styles.buttonGroupStart}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PlayButton className={cn(iconButton, 'group')}>
<RestartIcon className={cn(styles.icon, 'hidden group-data-ended:block')} />
<PlayIcon className={cn(styles.icon, 'hidden group-not-data-ended:group-data-paused:block')} />
<PauseIcon className={cn(styles.icon, 'hidden group-not-data-paused:group-not-data-ended:block')} />
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={-SEEK_TIME} className={iconButton}>
<span className={styles.iconContainer}>
<SeekIcon className={cn(styles.icon, styles.iconFlipped)} />
<span className={styles.seek.labelBackward}>{SEEK_TIME}</span>
</span>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Seek backward {SEEK_TIME} seconds</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={SEEK_TIME} className={iconButton}>
<span className={styles.iconContainer}>
<SeekIcon className={styles.icon} />
<span className={styles.seek.labelForward}>{SEEK_TIME}</span>
</span>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Seek forward {SEEK_TIME} seconds</Tooltip.Popup>
</Tooltip.Root>
</div>
<div className={styles.time.group}>
<Time.Value type="current" className={styles.time.current} />
<TimeSlider.Root className={styles.slider.root}>
<TimeSlider.Track className={styles.slider.track}>
<TimeSlider.Fill className={cn(styles.slider.fill.base, styles.slider.fill.fill)} />
<TimeSlider.Buffer className={cn(styles.slider.fill.base, styles.slider.fill.buffer)} />
</TimeSlider.Track>
<TimeSlider.Thumb className={cn(styles.slider.thumb.base, styles.slider.thumb.interactive)} />
<div className={styles.preview.root}>
<Slider.Thumbnail className={styles.preview.thumbnail} />
<TimeSlider.Value className={styles.preview.time} />
<SpinnerIcon className={cn(styles.icon, styles.preview.spinner)} />
</div>
</TimeSlider.Root>
<Time.Value type="duration" className={styles.time.duration} />
</div>
<div className={styles.buttonGroupEnd}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PlaybackRateButton className={cn(iconButton, styles.playbackRate.button)} />
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Toggle playback rate</Tooltip.Popup>
</Tooltip.Root>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger>
<MuteButton className={cn(iconButton, 'group')}>
<VolumeOffIcon className={cn(styles.icon, 'hidden group-data-[volume-level=off]:block')} />
<VolumeLowIcon className={cn(styles.icon, 'hidden group-data-[volume-level=low]:block')} />
<VolumeHighIcon className={cn(styles.icon, 'hidden group-data-[volume-level=high]:block')} />
</MuteButton>
</Popover.Trigger>
<Popover.Popup className={cn(styles.popup.popover, styles.popup.volume)}>
<VolumeSlider.Root className={styles.slider.root} orientation="vertical" thumbAlignment="edge">
<VolumeSlider.Track className={styles.slider.track}>
<VolumeSlider.Fill className={cn(styles.slider.fill.base, styles.slider.fill.fill)} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={cn(styles.slider.thumb.base, styles.slider.thumb.persistent)} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CaptionsButton className={cn(iconButton, 'group')}>
<CaptionsOffIcon className={cn(styles.icon, 'hidden group-not-data-active:block')} />
<CaptionsOnIcon className={cn(styles.icon, 'hidden group-data-active:block')} />
</CaptionsButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CastButton className={cn(iconButton, 'group')}>
<CastEnterIcon className={cn(styles.icon, 'hidden group-not-data-[cast-state=connected]:block')} />
<CastExitIcon className={cn(styles.icon, 'hidden group-data-[cast-state=connected]:block')} />
</CastButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PiPButton className={cn(iconButton, 'group')}>
<PipEnterIcon className={cn(styles.icon, 'hidden group-not-data-pip:block')} />
<PipExitIcon className={cn(styles.icon, 'hidden group-data-pip:block')} />
</PiPButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<FullscreenButton className={cn(iconButton, 'group')}>
<FullscreenEnterIcon className={cn(styles.icon, 'hidden group-not-data-fullscreen:block')} />
<FullscreenExitIcon className={cn(styles.icon, 'hidden group-data-fullscreen:block')} />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
</div>
</Tooltip.Provider>
</Controls.Root>
<div className={styles.overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="k" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
<Hotkey keys="f" action="toggleFullscreen" />
<Hotkey keys="c" action="toggleSubtitles" />
<Hotkey keys="i" action="togglePictureInPicture" />
<Hotkey keys="ArrowRight" action="seekStep" value={5} />
<Hotkey keys="ArrowLeft" action="seekStep" value={-5} />
<Hotkey keys="l" action="seekStep" value={10} />
<Hotkey keys="j" action="seekStep" value={-10} />
<Hotkey keys="ArrowUp" action="volumeStep" value={0.05} />
<Hotkey keys="ArrowDown" action="volumeStep" value={-0.05} />
<Hotkey keys="0-9" action="seekToPercent" />
<Hotkey keys="Home" action="seekToPercent" value={0} />
<Hotkey keys="End" action="seekToPercent" value={100} />
<Hotkey keys=">" action="speedUp" />
<Hotkey keys="<" action="speedDown" />
{/* Gestures */}
<Gesture type="tap" action="togglePaused" pointer="mouse" region="center" />
<Gesture type="tap" action="toggleControls" pointer="touch" />
<Gesture type="doubletap" action="seekStep" value={-10} region="left" />
<Gesture type="doubletap" action="toggleFullscreen" region="center" />
<Gesture type="doubletap" action="seekStep" value={10} region="right" />
</Container>
);
}
+46 -34
View File
@@ -1,33 +1,46 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { beforeAll, describe, expect, it } from 'vitest';
import { compile } from '..';
import { anyTag, byTag, childAsProp, hasChild, jsx, replace } from '../jsx';
import type { ImportRule } from '../transforms';
const __dirname = dirname(fileURLToPath(import.meta.url));
const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx');
/**
* End-to-end smoke test: feed a representative constrained-JSX video skin
* (vendored under `fixtures/`) through `compile()` with the same shape
* a package build hook uses, and sanity-check the output's structural
* shape. Snapshot-style assertions intentionally use `.toContain` over a full
* snapshot to keep the test resilient to incidental whitespace differences
* from the TS printer.
* End-to-end smoke test for the generic JSX transform pipeline. The compiler
* package deliberately avoids Video.js UI semantics here: transforms operate on
* JSX tags and imports only.
*/
describe('integration: default/video skin → JSX', () => {
const source = readFileSync(skinSource, 'utf8');
describe('integration: JSX transform pipeline', () => {
const source = `
import { Alpha, Beta, Gamma } from '@fixture/widgets';
import { Icon } from '@fixture/icons/components';
import { tokens } from '../tokens';
export function Example() {
return (
<Alpha.Root className={tokens.root}>
<Alpha.Trigger>
<Beta className={tokens.action}>
<Icon className={tokens.icon} />
</Beta>
</Alpha.Trigger>
<Gamma.Root>
<Gamma.Trigger>
<Beta className={tokens.secondary} />
</Gamma.Trigger>
<Gamma.Panel className={tokens.panel} />
</Gamma.Root>
</Alpha.Root>
);
}
`;
let code = '';
const imports: Record<string, ImportRule> = {
'@fixture/components': (name) => ({
source: `./src/ui/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
'@fixture/widgets': (name) => ({
source: `./widgets/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
name,
}),
'@fixture/icons/components': '@fixture/icons/jsx',
'../tailwind': '@videojs/skins/default/tailwind',
'../tokens': '@fixture/tokens',
};
beforeAll(async () => {
@@ -37,13 +50,13 @@ describe('integration: default/video skin → JSX', () => {
imports,
transforms: [
replace({
match: byTag('Popover.Root', {
when: hasChild(byTag('Popover.Trigger', { when: hasChild(byTag('MuteButton')) })),
match: byTag('Gamma.Root', {
when: hasChild(byTag('Gamma.Trigger', { when: hasChild(byTag('Beta')) })),
}),
with: { source: './volume-popover', name: 'VolumePopover' },
with: { source: './replacement', name: 'Replacement' },
mapChildren: () => [],
}),
childAsProp({ match: anyTag(['Tooltip.Trigger', 'Popover.Trigger']), prop: 'render' }),
childAsProp({ match: anyTag(['Alpha.Trigger', 'Gamma.Trigger']), prop: 'render' }),
],
}),
},
@@ -51,12 +64,11 @@ describe('integration: default/video skin → JSX', () => {
code = result.code;
});
it('rewrites component imports to per-identifier UI sources', () => {
expect(code).toMatch(/import \{ PlayButton \} from "\.\/src\/ui\/play-button"/);
// MuteButton lives under the volume Popover.Root subtree, which is replaced
// wholesale by VolumePopover — its import is correctly dropped by the
// unused-imports cleanup pass.
expect(code).not.toMatch(/import \{ MuteButton \}/);
it('rewrites component imports to per-identifier sources', () => {
expect(code).toMatch(/import \{ Alpha \} from "\.\/widgets\/alpha"/);
expect(code).toMatch(/import \{ Beta \} from "\.\/widgets\/beta"/);
// Gamma only appears under the replaced subtree, so cleanup drops it.
expect(code).not.toMatch(/import \{ Gamma \}/);
});
it('rewrites icon component imports', () => {
@@ -64,16 +76,16 @@ describe('integration: default/video skin → JSX', () => {
expect(code).not.toContain('@fixture/icons/components');
});
it('substitutes the volume Popover.Root with VolumePopover', () => {
expect(code).toContain('<VolumePopover');
expect(code).toContain('import { VolumePopover }');
it('substitutes the matched subtree with the replacement component', () => {
expect(code).toContain('<Replacement');
expect(code).toContain('import { Replacement }');
});
it('lifts Tooltip.Trigger / Popover.Trigger children into render props', () => {
expect(code).toMatch(/<Tooltip\.Trigger render=\{<PlayButton/);
it('lifts matched trigger children into render props', () => {
expect(code).toMatch(/<Alpha\.Trigger render=\{<Beta/);
});
it('keeps unrelated relative tailwind import re-routed to the skins package', () => {
expect(code).toContain('@videojs/skins/default/tailwind');
it('keeps unrelated relative token imports re-routed by config', () => {
expect(code).toContain('@fixture/tokens');
});
});