mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(compiler): add @videojs/compiler package
Introduce the build-time compiler for constrained-JSX skins, rebased onto current main as a clean, additive package. Provides: - core pipeline: parse, compile, generate, JSX runtime, define-component - transforms: import rewriting, replace/wrap, drop-unused locals/imports - matchers + react lowering (add-prop, child-as-prop) - styles className analyzer - tailwind subsystem: design-system loader, decompose, evaluator, deriveClassName, emitCss, tailwindPlugin (three targets) Self-contained: the integration smoke test now reads a vendored skin fixture instead of the skins package, so the compiler builds and tests green independently. 158 tests pass. Wiring: register the project reference (tsconfig), commitlint scope, CI test-matrix entry, and lockfile entries (lightningcss, tailwindcss). The skin migration that consumes this compiler is parked separately — its token refactor conflicts with skin work that landed on main; see the follow-up commit and .claude/plans/compiler-rebase-audit.md.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { compile } from '../../compile';
|
||||
import type { DesignSystem } from '../design-system';
|
||||
import { loadDesignSystem } from '../design-system';
|
||||
import type { CompiledRule } from '../emit';
|
||||
import { clearTokenModuleCache } from '../evaluator';
|
||||
import { tailwindPlugin } from '../plugin';
|
||||
|
||||
const MINIMAL_CSS = `
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand: oklch(0.7 0.2 250);
|
||||
}
|
||||
`;
|
||||
|
||||
let design: DesignSystem;
|
||||
|
||||
beforeAll(async () => {
|
||||
const cssDir = mkdtempSync(join(tmpdir(), 'compiler-tw-plugin-'));
|
||||
const cssPath = join(cssDir, 'tailwind.css');
|
||||
writeFileSync(cssPath, MINIMAL_CSS, 'utf8');
|
||||
design = await loadDesignSystem(cssPath);
|
||||
}, 30_000);
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'compiler-tw-fixture-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTokenModuleCache();
|
||||
});
|
||||
|
||||
const writeFixture = (relative: string, content: string): string => {
|
||||
const abs = join(workDir, relative);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, content, 'utf8');
|
||||
return abs;
|
||||
};
|
||||
|
||||
const collapse = (s: string): string => s.replace(/\s+/g, '');
|
||||
|
||||
describe('tailwindPlugin — target: tailwind (passthrough)', () => {
|
||||
it('leaves className values unchanged', () => {
|
||||
const source = `function App(){ return <Foo className="flex items-center"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind' })],
|
||||
});
|
||||
expect(code).toContain('"flex items-center"');
|
||||
});
|
||||
|
||||
it('does not call onRules / onCss', () => {
|
||||
const source = `function App(){ return <Foo className="flex"/>; }`;
|
||||
let called = 0;
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'tailwind',
|
||||
onRules: () => {
|
||||
called++;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailwindPlugin — target: tailwind-inlined', () => {
|
||||
it('flattens a literal-string className to itself', () => {
|
||||
const source = `function App(){ return <Foo className="flex items-center"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })],
|
||||
});
|
||||
expect(code).toContain('"flex items-center"');
|
||||
});
|
||||
|
||||
it('flattens a `cn(...)` call into a single literal string', () => {
|
||||
const source = `function App(){ return <Foo className={cn('flex', 'items-center', 'gap-2')}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })],
|
||||
});
|
||||
expect(code).toContain('"flex items-center gap-2"');
|
||||
expect(code).not.toMatch(/cn\(/);
|
||||
});
|
||||
|
||||
it('resolves token references via the on-disk evaluator', () => {
|
||||
writeFixture(
|
||||
'tokens.ts',
|
||||
`import { cn } from '@videojs/utils/style';
|
||||
export const tokens = { button: { base: cn('rounded', 'p-2') } };
|
||||
`
|
||||
);
|
||||
const source = `import { tokens as styles } from './tokens';
|
||||
function App(){ return <Foo className={cn('flex', styles.button.base)}/>; }`;
|
||||
const sourcePath = writeFixture('skin.tsx', source);
|
||||
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
filename: sourcePath,
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind-inlined', sourcePath })],
|
||||
});
|
||||
expect(code).toContain('"flex rounded p-2"');
|
||||
});
|
||||
|
||||
it('leaves the className alone if a token cannot be resolved', () => {
|
||||
const source = `import { tokens as styles } from './missing';
|
||||
function App(){ return <Foo className={cn('flex', styles.unknown)}/>; }`;
|
||||
const sourcePath = writeFixture('skin.tsx', source);
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
filename: sourcePath,
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind-inlined', sourcePath })],
|
||||
});
|
||||
expect(code).toMatch(/cn\(/);
|
||||
});
|
||||
|
||||
it('leaves opaque expressions intact', () => {
|
||||
const source = `function App(){ return <Foo className={cn('flex', isOn && 'on')}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })],
|
||||
});
|
||||
expect(code).toMatch(/cn\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailwindPlugin — target: vanilla-css', () => {
|
||||
it('rewrites className to a tag-derived semantic name', () => {
|
||||
const source = `function App(){ return <PlayButton className="flex items-center"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
expect(code).toContain('"play-button"');
|
||||
expect(code).not.toContain('"flex items-center"');
|
||||
});
|
||||
|
||||
it('rewrites className to a token-path-derived name on a bare HTML element', () => {
|
||||
const source = `function App(){ return <div className={styles.bufferingIndicator}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
expect(code).toContain('"buffering-indicator"');
|
||||
});
|
||||
|
||||
it('honours overrides keyed by tag', () => {
|
||||
const source = `function App(){ return <PlayButton className="flex"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
overrides: { PlayButton: 'custom' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(code).toContain('"custom"');
|
||||
});
|
||||
|
||||
it('runs the transformName hook', () => {
|
||||
const source = `function App(){ return <PlayButton className="flex"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
transformName: (ctx) => `app-${ctx.defaultName}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(code).toContain('"app-play-button"');
|
||||
});
|
||||
|
||||
it('collects CompiledRule[] via onRules', () => {
|
||||
const source = `function App(){ return <Foo className="flex"/>; }`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured!.length).toBeGreaterThan(0);
|
||||
expect(captured![0]!.className).toBe('foo');
|
||||
expect(captured![0]!.utility.declarations).toContainEqual({ property: 'display', value: 'flex' });
|
||||
});
|
||||
|
||||
it('expands a `cn(...)` call into one rule per utility', () => {
|
||||
const source = `function App(){ return <Foo className={cn('flex', 'opacity-50')}/>; }`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured!.length).toBe(2);
|
||||
expect(captured![0]!.className).toBe('foo');
|
||||
expect(captured![1]!.className).toBe('foo');
|
||||
});
|
||||
|
||||
it('resolves token references via the on-disk evaluator', () => {
|
||||
writeFixture(
|
||||
'tokens.ts',
|
||||
`import { cn } from '@videojs/utils/style';
|
||||
export const tokens = { button: cn('flex', 'gap-2') };
|
||||
`
|
||||
);
|
||||
const source = `import { tokens as styles } from './tokens';
|
||||
function App(){ return <Foo className={styles.button}/>; }`;
|
||||
const sourcePath = writeFixture('skin.tsx', source);
|
||||
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
filename: sourcePath,
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
sourcePath,
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured!.length).toBe(2);
|
||||
expect(captured![0]!.className).toBe('foo');
|
||||
});
|
||||
|
||||
it('annotates rules with a bag via bagFor', () => {
|
||||
const source = `function App(){ return <PlayButton className="flex"/>; }`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
bagFor: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined),
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured![0]!.bag).toBe('controls');
|
||||
});
|
||||
|
||||
it('skips opaque expressions', () => {
|
||||
const source = `function App(){ return <Foo className={isOn ? 'a' : 'b'}/>; }`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured).toBeUndefined();
|
||||
expect(code).toContain('isOn');
|
||||
});
|
||||
|
||||
it('resolves a local cn() const referenced via className={X}', () => {
|
||||
writeFixture(
|
||||
'tokens.ts',
|
||||
`import { cn } from '@videojs/utils/style';
|
||||
export const tokens = { button: { base: 'flex', icon: 'w-4 h-4' } };
|
||||
`
|
||||
);
|
||||
const source = `import { tokens as styles } from './tokens';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
const iconButton = cn(styles.button.base, styles.button.icon);
|
||||
function App(){ return <PlayButton className={iconButton}/>; }`;
|
||||
const sourcePath = writeFixture('skin.tsx', source);
|
||||
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
filename: sourcePath,
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
sourcePath,
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(code).toContain('"play-button"');
|
||||
expect(captured!.length).toBe(3);
|
||||
const utilities = captured!.map((r) => r.utility.utility).sort();
|
||||
expect(utilities).toEqual(['flex', 'h-4', 'w-4']);
|
||||
});
|
||||
|
||||
it('preserves opaque expressions by wrapping the derived name in cn()', () => {
|
||||
const source = `function App({ extra }){ return <PlayButton className={cn('flex', extra)}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
expect(code).toMatch(/cn\("play-button",\s*extra\)/);
|
||||
});
|
||||
|
||||
it('handles multiple elements in one source', () => {
|
||||
const source = `function App(){
|
||||
return <PlayButton className="flex"><PlayIcon className="opacity-50"/></PlayButton>;
|
||||
}`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(captured!.length).toBe(2);
|
||||
const names = captured!.map((r) => r.className);
|
||||
expect(names).toContain('play-button');
|
||||
expect(names).toContain('play-icon');
|
||||
expect(collapse(code)).toContain(collapse(`<PlayButton className="play-button">`));
|
||||
expect(collapse(code)).toContain(collapse(`<PlayIcon className="play-icon"/>`));
|
||||
});
|
||||
|
||||
it('forwards CSS through onCss when set', async () => {
|
||||
const source = `function App(){ return <Foo className="flex"/>; }`;
|
||||
const cssPromise = new Promise<string>((resolve) => {
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onCss: (out) => {
|
||||
if (out.kind === 'merged') resolve(out.css);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const css = await cssPromise;
|
||||
expect(collapse(css)).toContain(collapse('.foo{display:flex;}'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user