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,195 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { clearTokenModuleCache, EvaluationError, loadTokenModule } from '../evaluator';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'compiler-eval-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTokenModuleCache();
|
||||
});
|
||||
|
||||
const write = (relativePath: string, content: string): string => {
|
||||
const abs = join(dir, relativePath);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, content, 'utf8');
|
||||
return abs;
|
||||
};
|
||||
|
||||
describe('loadTokenModule — primitives', () => {
|
||||
it('reads a string-literal export', () => {
|
||||
const file = write('mod.ts', `export const a = 'foo bar';\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'foo bar' });
|
||||
});
|
||||
|
||||
it('reads a no-substitution template literal', () => {
|
||||
const file = write('mod.ts', 'export const a = `foo bar`;\n');
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'foo bar' });
|
||||
});
|
||||
|
||||
it('skips unexported declarations', () => {
|
||||
const file = write('mod.ts', `const internal = 'hidden';\nexport const a = 'visible';\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'visible' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTokenModule — object literals', () => {
|
||||
it('reads a plain object literal', () => {
|
||||
const file = write('mod.ts', `export const button = { base: 'flex items-center', icon: 'w-4 h-4' };\n`);
|
||||
expect(loadTokenModule(file)).toEqual({
|
||||
button: { base: 'flex items-center', icon: 'w-4 h-4' },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles nested objects', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`export const slider = { thumb: { base: 'rounded-full', persistent: 'opacity-100' } };\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({
|
||||
slider: { thumb: { base: 'rounded-full', persistent: 'opacity-100' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles spread of an in-scope object', () => {
|
||||
const file = write('mod.ts', `const base = { a: '1', b: '2' };\nexport const merged = { ...base, c: '3' };\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ merged: { a: '1', b: '2', c: '3' } });
|
||||
});
|
||||
|
||||
it('quoted property names are preserved verbatim', () => {
|
||||
const file = write('mod.ts', `export const x = { 'foo-bar': 'baz', '@some/key': 'qux' };\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ x: { 'foo-bar': 'baz', '@some/key': 'qux' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTokenModule — cn() calls', () => {
|
||||
it('joins literal-string args with space', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nexport const a = cn('flex', 'items-center');\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'flex items-center' });
|
||||
});
|
||||
|
||||
it('flattens identifier args that resolve to strings', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nconst base = 'flex';\nexport const a = cn(base, 'items-center');\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'flex items-center' });
|
||||
});
|
||||
|
||||
it('flattens dotted access against an in-scope object', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nconst pair = { a: 'foo', b: 'bar' };\nexport const a = cn(pair.a, pair.b);\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'foo bar' });
|
||||
});
|
||||
|
||||
it('flattens array-literal args', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nexport const a = cn('a', ['b', 'c'], 'd');\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'a b c d' });
|
||||
});
|
||||
|
||||
it('drops empty strings when joining', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nexport const a = cn('flex', '', 'items-center');\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'flex items-center' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTokenModule — relative imports', () => {
|
||||
it('resolves a relative .ts import', () => {
|
||||
write('base.ts', `export const value = 'flex';\n`);
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { value } from './base';\nimport { cn } from '@videojs/utils/style';\nexport const a = cn(value, 'gap-2');\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ a: 'flex gap-2' });
|
||||
});
|
||||
|
||||
it('resolves an aliased import', () => {
|
||||
write('base.ts', `export const button = { base: 'rounded' };\n`);
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { button as baseButton } from './base';\nimport { cn } from '@videojs/utils/style';\nexport const button = { ...baseButton, primary: cn(baseButton.base, 'bg-brand') };\n`
|
||||
);
|
||||
expect(loadTokenModule(file)).toEqual({ button: { base: 'rounded', primary: 'rounded bg-brand' } });
|
||||
});
|
||||
|
||||
it('honours `export * from`', () => {
|
||||
write('a.ts', `export const a = '1';\nexport const b = '2';\n`);
|
||||
const file = write('mod.ts', `export * from './a';\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('honours `export { x } from`', () => {
|
||||
write('a.ts', `export const a = '1';\nexport const b = '2';\n`);
|
||||
const file = write('mod.ts', `export { a as alpha } from './a';\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ alpha: '1' });
|
||||
});
|
||||
|
||||
it('honours `export * as ns from`', () => {
|
||||
write('a.ts', `export const a = '1';\nexport const b = '2';\n`);
|
||||
const file = write('mod.ts', `export * as everything from './a';\n`);
|
||||
expect(loadTokenModule(file)).toEqual({ everything: { a: '1', b: '2' } });
|
||||
});
|
||||
|
||||
it('caches per absolute path', () => {
|
||||
write('shared.ts', `export const v = 'x';\n`);
|
||||
const a = write('a.ts', `export { v } from './shared';\n`);
|
||||
const b = write('b.ts', `export { v } from './shared';\n`);
|
||||
const r1 = loadTokenModule(a);
|
||||
const r2 = loadTokenModule(b);
|
||||
expect(r1.v).toBe('x');
|
||||
expect(r2.v).toBe('x');
|
||||
// Same string identity confirms the cache fed both calls.
|
||||
expect(r1.v).toBe(r2.v);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTokenModule — diagnostics', () => {
|
||||
it('rejects function expressions', () => {
|
||||
const file = write('mod.ts', `export const a = (b) => b;\n`);
|
||||
expect(() => loadTokenModule(file)).toThrow(EvaluationError);
|
||||
});
|
||||
|
||||
it('rejects ternary expressions', () => {
|
||||
const file = write('mod.ts', `export const a = true ? 'x' : 'y';\n`);
|
||||
expect(() => loadTokenModule(file)).toThrow(EvaluationError);
|
||||
});
|
||||
|
||||
it('rejects unsupported call expressions', () => {
|
||||
const file = write('mod.ts', `export const a = String('x');\n`);
|
||||
expect(() => loadTokenModule(file)).toThrow(/Only `cn\(\.\.\.\)` calls are supported/);
|
||||
});
|
||||
|
||||
it('rejects spread of a string', () => {
|
||||
const file = write(
|
||||
'mod.ts',
|
||||
`import { cn } from '@videojs/utils/style';\nconst base = cn('a', 'b');\nexport const x = { ...base };\n`
|
||||
);
|
||||
expect(() => loadTokenModule(file)).toThrow(/Spread of a string token/);
|
||||
});
|
||||
|
||||
it('rejects unresolved identifier', () => {
|
||||
const file = write('mod.ts', `export const a = unknown;\n`);
|
||||
expect(() => loadTokenModule(file)).toThrow(/Unresolved identifier 'unknown'/);
|
||||
});
|
||||
|
||||
it('rejects access into a string', () => {
|
||||
const file = write('mod.ts', `const a = 'x';\nexport const b = a.foo;\n`);
|
||||
expect(() => loadTokenModule(file)).toThrow(/Cannot read property 'foo' of a string token/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user