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:
Rahim
2026-06-17 17:13:40 -07:00
parent 1b31f3e8d7
commit b01a051744
53 changed files with 6097 additions and 8 deletions
+408
View File
@@ -0,0 +1,408 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
import ts from 'typescript';
import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles';
import { decompose, type UtilityCss } from './decompose';
import type { DesignSystem } from './design-system';
import { type CompiledRule, type EmittedCss, emitCss, type HoistOptions } from './emit';
import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
import { type DeriveClassNameOptions, deriveClassName, type NameTransform } from './naming';
/** Output target for `tailwindPlugin`. */
export type TailwindTarget =
/** Pass-through. JSX `className` values stay as authored. No CSS emitted. */
| 'tailwind'
/**
* Flatten every `cn(...)` call and dotted token reference to a single
* literal utility string on each `className` prop. No CSS emitted; token
* imports become unused (handled by `dropUnusedImports`).
*/
| 'tailwind-inlined'
/**
* Rewrite each `className` value to a semantic CSS class name and emit a
* sibling CSS file containing the compiled rules. The CSS is delivered
* through the `onCss` callback exactly once per source file.
*/
| 'vanilla-css';
/** Per-rule annotation hook; lets the consumer assign a `bag` for split-mode emission. */
export type BagFor = (info: { className: string; segments: readonly StyleSegment[] }) => string | undefined;
export interface TailwindPluginOptions {
/** Loaded Tailwind v4 design system (see `loadDesignSystem`). */
design: DesignSystem;
/** Output target. See `TailwindTarget`. */
target: TailwindTarget;
/**
* Absolute path of the source file currently being compiled. Required for
* `'tailwind-inlined'` and `'vanilla-css'` targets — the plugin resolves
* relative token imports from this directory.
*/
sourcePath?: string;
/**
* Hook for shaping the final class name (see `NameTransform`). Only used
* by `'vanilla-css'`. Identity by default.
*/
transformName?: NameTransform;
/**
* Per-tag / per-token-path class-name overrides. Only used by `'vanilla-css'`.
*/
overrides?: Record<string, string>;
/**
* Optional helper that decides which split-mode `bag` a rule belongs to.
* Only used by `'vanilla-css'`. Returns `undefined` to leave the rule
* unbagged.
*/
bagFor?: BagFor;
/** Receives the full `CompiledRule[]` once per compiled source file. */
onRules?: (rules: readonly CompiledRule[]) => void;
/** Convenience hook: pre-emit CSS via `emitCss` and forward the result. */
onCss?: (css: EmittedCss) => void;
/** Options forwarded to the internal `emitCss` call when `onCss` is set. */
emit?: { mode?: 'merged' | 'split'; baseCss?: readonly string[]; configDir?: string };
/**
* Hoist uniform CSS variable declarations to a single root rule. See
* `HoistOptions`. Forwarded to the internal `emitCss` call when `onCss` is
* set, and exposed on `onRules` consumers via the `hoist` field they may
* read off the plugin options.
*
* Pass `false` to disable. Plugin consumers driving `emitCss` themselves
* should pass the same value through.
*/
hoistVars?: false | HoistOptions;
/**
* Inline matching CSS custom properties into their consumers, dropping
* the matching declarations. Same shape as `EmitCssOptions['inlineVars']`:
*
* - `true` — inline `--tw-*` (Tailwind's internal slots).
* - `RegExp` — inline any `--name` matching.
* - omitted — no inlining.
*
* Forwarded to the internal `emitCss` call when `onCss` is set; consumers
* driving `emitCss` themselves should pass the same value through.
*/
inlineVars?: true | RegExp;
}
/**
* TS transformer that rewrites JSX `className` attributes per the chosen
* Tailwind target. Built on top of `analyzeStyles` (generic JSX walker) +
* `decompose` + `deriveClassName` + `emitCss`. Token references are resolved
* by statically evaluating the imported token module — see `evaluator.ts`.
*/
export function tailwindPlugin(options: TailwindPluginOptions): ts.TransformerFactory<ts.SourceFile> {
if (options.target === 'tailwind') {
return () => (sourceFile) => sourceFile;
}
if (options.target === 'tailwind-inlined') {
return inlinedPlugin(options);
}
return vanillaCssPlugin(options);
}
/* ─────────────────────────────────────────────────────────────────────────
* Target: tailwind-inlined
* ───────────────────────────────────────────────────────────────────────── */
function inlinedPlugin(options: TailwindPluginOptions): ts.TransformerFactory<ts.SourceFile> {
const env = buildTokenEnv(options.sourcePath);
return (transformContext) => {
return (sourceFile) => {
const visit: StyleVisitor = (info, factory) => {
if (info.kind !== 'segments' || !info.segments) return undefined;
const flat = flattenToLiteral(info.segments, env);
if (flat === null) return undefined;
return factory.createStringLiteral(flat);
};
return analyzeStyles({ visit })(transformContext)(sourceFile);
};
};
}
/* ─────────────────────────────────────────────────────────────────────────
* Target: vanilla-css
* ───────────────────────────────────────────────────────────────────────── */
function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory<ts.SourceFile> {
const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars } = options;
const env = buildTokenEnv(options.sourcePath);
return (transformContext) => {
return (sourceFile) => {
const rules: CompiledRule[] = [];
const visit: StyleVisitor = (info, factory) => {
if (info.kind !== 'segments' || !info.segments) return undefined;
const naming: DeriveClassNameOptions = {
element: info.element,
segments: info.segments,
...(transformName ? { transformName } : {}),
...(overrides ? { overrides } : {}),
};
const derived = deriveClassName(naming);
// Resolve each segment against the env. Literals resolve to themselves;
// tokens resolve via path walking; opaques and unresolved tokens are
// *passed through* — those are runtime expressions (e.g. a `className`
// prop the consumer composes onto the element). We rewrite the
// classname to the derived semantic name and wrap any pass-throughs in
// a `cn(...)` call so composition is preserved.
const passThrough: ts.Expression[] = [];
for (const seg of info.segments) {
if (seg.kind === 'literal') {
for (const utility of seg.value.split(/\s+/)) {
if (!utility) continue;
const css = decompose(utility, design);
if (css) rules.push(buildCompiledRule(derived.className, css, info.segments, bagFor));
}
continue;
}
if (seg.kind === 'token') {
const literal = resolveTokenPath(seg.path, env);
if (literal !== null) {
for (const utility of literal.split(/\s+/)) {
if (!utility) continue;
const css = decompose(utility, design);
if (css) rules.push(buildCompiledRule(derived.className, css, info.segments, bagFor));
}
continue;
}
// Fall through — token didn't resolve, treat as pass-through.
}
passThrough.push(seg.node);
}
if (passThrough.length === 0) {
return factory.createStringLiteral(derived.className);
}
return factory.createCallExpression(factory.createIdentifier('cn'), undefined, [
factory.createStringLiteral(derived.className),
...passThrough,
]);
};
const transformed = analyzeStyles({ visit })(transformContext)(sourceFile);
if (rules.length === 0) return transformed;
onRules?.(rules);
if (onCss) {
emitCss({
rules,
...(emit ?? {}),
...(hoistVars !== undefined ? { hoist: hoistVars } : {}),
...(inlineVars !== undefined ? { inlineVars } : {}),
})
.then(onCss)
.catch(() => {
// Swallowed; a misconfigured baseCss shouldn't crash the build.
});
}
return transformed;
};
};
}
/* ─────────────────────────────────────────────────────────────────────────
* Token environment
* ───────────────────────────────────────────────────────────────────────── */
/**
* Discover the token-namespace imports in the skin source and evaluate each
* referenced module on disk. Also folds local `const X = cn(<resolvable>)`
* declarations into the env so JSX `className={X}` references resolve.
*
* Reads + reparses the source file from disk rather than walking the in-flight
* SourceFile — earlier transforms in the pipeline (e.g. `transformImports`)
* may have rewritten relative specifiers to bare ones, which would defeat the
* on-disk module resolution we need here.
*
* If `sourcePath` is undefined or unreadable, returns an empty map; the plugin
* then leaves token-bearing className expressions alone.
*/
function buildTokenEnv(sourcePath: string | undefined): Map<string, TokenValue> {
const env = new Map<string, TokenValue>();
if (!sourcePath || !existsSync(sourcePath)) return env;
const source = readFileSync(sourcePath, 'utf8');
const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
// First pass: import declarations.
for (const stmt of sourceFile.statements) {
if (!ts.isImportDeclaration(stmt)) continue;
const specifier = stmt.moduleSpecifier;
if (!ts.isStringLiteral(specifier)) continue;
const id = specifier.text;
if (!id.startsWith('.')) continue;
const absolutePath = resolveModulePath(id, sourcePath);
if (!absolutePath) continue;
let exports: Record<string, TokenValue>;
try {
exports = loadTokenModule(absolutePath);
} catch (error) {
if (error instanceof EvaluationError) {
// Token grammar violation — skip this import so the className is
// treated as opaque rather than crashing the build.
continue;
}
throw error;
}
const clause = stmt.importClause;
if (!clause) continue;
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const spec of clause.namedBindings.elements) {
const sourceName = spec.propertyName?.text ?? spec.name.text;
const localName = spec.name.text;
const value = exports[sourceName];
if (value !== undefined) env.set(localName, value);
}
continue;
}
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
env.set(clause.namedBindings.name.text, exports as TokenValue);
}
}
// Second pass: top-level `const X = <expr>` declarations whose RHS resolves
// statically against the env. Lets skins write
// const iconButton = cn(styles.button.base, styles.button.icon);
// and reference `iconButton` in `className={iconButton}` without losing
// the resolution.
for (const stmt of sourceFile.statements) {
if (!ts.isVariableStatement(stmt)) continue;
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
const value = tryEvaluateLocal(decl.initializer, env);
if (value !== null) env.set(decl.name.text, value);
}
}
return env;
}
/**
* Evaluate a local declaration's RHS against `env`. Supports `cn(...)` calls,
* dotted access, identifier lookup, and string literals — same surface as the
* token-module evaluator, but without nested object literals (skins don't
* declare those locally) and without recursion across files.
*/
function tryEvaluateLocal(node: ts.Expression, env: Map<string, TokenValue>): TokenValue | null {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
if (ts.isIdentifier(node)) {
const v = env.get(node.text);
return v ?? null;
}
if (ts.isPropertyAccessExpression(node)) {
const root = tryEvaluateLocal(node.expression, env);
if (root === null || typeof root === 'string') return null;
if (!ts.isIdentifier(node.name)) return null;
const next = root[node.name.text];
return next ?? null;
}
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'cn') {
const parts: string[] = [];
for (const arg of node.arguments) {
const v = tryEvaluateLocal(arg, env);
if (v === null || typeof v !== 'string') return null;
if (v) parts.push(v);
}
return parts.join(' ');
}
if (ts.isParenthesizedExpression(node)) return tryEvaluateLocal(node.expression, env);
return null;
}
const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const;
function resolveModulePath(specifier: string, fromFile: string): string | null {
const base = isAbsolute(specifier) ? specifier : resolvePath(dirname(fromFile), specifier);
for (const ext of MODULE_EXTENSIONS) {
const candidate = `${base}${ext}`;
if (existsSync(candidate)) return candidate;
}
return null;
}
/* ─────────────────────────────────────────────────────────────────────────
* Segment → utility resolution
* ───────────────────────────────────────────────────────────────────────── */
/**
* Resolve every segment to a string. Returns `null` for opaque expressions
* or token paths that can't be walked against the env (so the caller can
* leave the source unchanged).
*/
function collectUtilities(segments: readonly StyleSegment[], env: Map<string, TokenValue>): string[] | null {
const out: string[] = [];
for (const seg of segments) {
if (seg.kind === 'literal') {
pushUtilities(out, seg.value);
continue;
}
if (seg.kind === 'token') {
const literal = resolveTokenPath(seg.path, env);
if (literal === null) return null;
pushUtilities(out, literal);
continue;
}
return null;
}
return out;
}
function flattenToLiteral(segments: readonly StyleSegment[], env: Map<string, TokenValue>): string | null {
const utilities = collectUtilities(segments, env);
if (utilities === null) return null;
return utilities.join(' ');
}
/**
* Walk `path` (e.g. `['styles', 'button', 'icon']`) against the env. The head
* segment is the local namespace name (or a top-level local const); subsequent
* segments index into the resolved object. Returns `null` if the path doesn't
* resolve to a string.
*/
function resolveTokenPath(path: readonly string[], env: Map<string, TokenValue>): string | null {
if (path.length === 0) return null;
const [head, ...rest] = path;
const root = env.get(head!);
if (root === undefined) return null;
let cursor: TokenValue = root;
for (const key of rest) {
if (typeof cursor === 'string') return null;
const next = cursor[key];
if (next === undefined) return null;
cursor = next;
}
return typeof cursor === 'string' ? cursor : null;
}
function pushUtilities(out: string[], raw: string): void {
for (const u of raw.split(/\s+/)) {
if (u.length > 0) out.push(u);
}
}
function buildCompiledRule(
className: string,
utility: UtilityCss,
segments: readonly StyleSegment[],
bagFor: BagFor | undefined
): CompiledRule {
const bag = bagFor?.({ className, segments });
return bag === undefined ? { className, utility } : { className, utility, bag };
}