From b01a051744a65503e06450d9d2b03f98ccbf65b4 Mon Sep 17 00:00:00 2001 From: Rahim Date: Tue, 16 Jun 2026 15:42:12 -0700 Subject: [PATCH] feat(compiler): add @videojs/compiler package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 1 + commitlint.config.js | 1 + packages/compiler/package.json | 74 +++ packages/compiler/src/cli.ts | 130 ++++ packages/compiler/src/compile.ts | 131 ++++ packages/compiler/src/config.ts | 51 ++ packages/compiler/src/define-component.ts | 58 ++ packages/compiler/src/generate.ts | 150 +++++ packages/compiler/src/index.ts | 19 + packages/compiler/src/jsx-dev-runtime.ts | 2 + packages/compiler/src/jsx-runtime.ts | 98 +++ packages/compiler/src/matchers/has-child.ts | 44 ++ packages/compiler/src/matchers/index.ts | 2 + packages/compiler/src/matchers/tag.ts | 41 ++ packages/compiler/src/parse.ts | 29 + packages/compiler/src/plugins/vite.ts | 28 + packages/compiler/src/react/add-prop.ts | 89 +++ packages/compiler/src/react/child-as-prop.ts | 68 +++ packages/compiler/src/react/index.ts | 11 + packages/compiler/src/styles/analyze.ts | 236 ++++++++ packages/compiler/src/styles/index.ts | 8 + .../compiler/src/styles/tests/analyze.test.ts | 200 ++++++ packages/compiler/src/tailwind/decompose.ts | 206 +++++++ .../compiler/src/tailwind/design-system.ts | 127 ++++ packages/compiler/src/tailwind/emit.ts | 570 ++++++++++++++++++ packages/compiler/src/tailwind/evaluator.ts | 304 ++++++++++ packages/compiler/src/tailwind/index.ts | 13 + packages/compiler/src/tailwind/naming.ts | 181 ++++++ packages/compiler/src/tailwind/plugin.ts | 408 +++++++++++++ .../src/tailwind/tests/decompose.test.ts | 102 ++++ .../compiler/src/tailwind/tests/emit.test.ts | 477 +++++++++++++++ .../src/tailwind/tests/evaluator.test.ts | 195 ++++++ .../src/tailwind/tests/naming.test.ts | 225 +++++++ .../src/tailwind/tests/plugin.test.ts | 384 ++++++++++++ packages/compiler/src/tests/compile.test.ts | 269 +++++++++ .../src/tests/fixtures/video-skin.tsx | 233 +++++++ packages/compiler/src/tests/generate.test.ts | 138 +++++ .../compiler/src/tests/integration.test.ts | 72 +++ .../compiler/src/tests/jsx-types.test-d.tsx | 47 ++ packages/compiler/src/tests/tsconfig.json | 14 + .../compiler/src/transforms/add-import.ts | 79 +++ .../src/transforms/drop-unused-imports.ts | 96 +++ .../src/transforms/drop-unused-locals.ts | 122 ++++ packages/compiler/src/transforms/imports.ts | 141 +++++ packages/compiler/src/transforms/replace.ts | 64 ++ .../tests/drop-unused-locals.test.ts | 37 ++ packages/compiler/src/transforms/wrap.ts | 40 ++ packages/compiler/tsconfig.json | 11 + packages/compiler/tsconfig.preset.json | 7 + packages/compiler/tsdown.config.ts | 22 + packages/compiler/vitest.config.ts | 7 + pnpm-lock.yaml | 41 +- tsconfig.json | 2 + 53 files changed, 6097 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/package.json create mode 100644 packages/compiler/src/cli.ts create mode 100644 packages/compiler/src/compile.ts create mode 100644 packages/compiler/src/config.ts create mode 100644 packages/compiler/src/define-component.ts create mode 100644 packages/compiler/src/generate.ts create mode 100644 packages/compiler/src/index.ts create mode 100644 packages/compiler/src/jsx-dev-runtime.ts create mode 100644 packages/compiler/src/jsx-runtime.ts create mode 100644 packages/compiler/src/matchers/has-child.ts create mode 100644 packages/compiler/src/matchers/index.ts create mode 100644 packages/compiler/src/matchers/tag.ts create mode 100644 packages/compiler/src/parse.ts create mode 100644 packages/compiler/src/plugins/vite.ts create mode 100644 packages/compiler/src/react/add-prop.ts create mode 100644 packages/compiler/src/react/child-as-prop.ts create mode 100644 packages/compiler/src/react/index.ts create mode 100644 packages/compiler/src/styles/analyze.ts create mode 100644 packages/compiler/src/styles/index.ts create mode 100644 packages/compiler/src/styles/tests/analyze.test.ts create mode 100644 packages/compiler/src/tailwind/decompose.ts create mode 100644 packages/compiler/src/tailwind/design-system.ts create mode 100644 packages/compiler/src/tailwind/emit.ts create mode 100644 packages/compiler/src/tailwind/evaluator.ts create mode 100644 packages/compiler/src/tailwind/index.ts create mode 100644 packages/compiler/src/tailwind/naming.ts create mode 100644 packages/compiler/src/tailwind/plugin.ts create mode 100644 packages/compiler/src/tailwind/tests/decompose.test.ts create mode 100644 packages/compiler/src/tailwind/tests/emit.test.ts create mode 100644 packages/compiler/src/tailwind/tests/evaluator.test.ts create mode 100644 packages/compiler/src/tailwind/tests/naming.test.ts create mode 100644 packages/compiler/src/tailwind/tests/plugin.test.ts create mode 100644 packages/compiler/src/tests/compile.test.ts create mode 100644 packages/compiler/src/tests/fixtures/video-skin.tsx create mode 100644 packages/compiler/src/tests/generate.test.ts create mode 100644 packages/compiler/src/tests/integration.test.ts create mode 100644 packages/compiler/src/tests/jsx-types.test-d.tsx create mode 100644 packages/compiler/src/tests/tsconfig.json create mode 100644 packages/compiler/src/transforms/add-import.ts create mode 100644 packages/compiler/src/transforms/drop-unused-imports.ts create mode 100644 packages/compiler/src/transforms/drop-unused-locals.ts create mode 100644 packages/compiler/src/transforms/imports.ts create mode 100644 packages/compiler/src/transforms/replace.ts create mode 100644 packages/compiler/src/transforms/tests/drop-unused-locals.test.ts create mode 100644 packages/compiler/src/transforms/wrap.ts create mode 100644 packages/compiler/tsconfig.json create mode 100644 packages/compiler/tsconfig.preset.json create mode 100644 packages/compiler/tsdown.config.ts create mode 100644 packages/compiler/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c771e28..b4be5d47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,7 @@ jobs: matrix: package: - '@videojs/cli' + - '@videojs/compiler' - '@videojs/core' - '@videojs/store' - '@videojs/utils' diff --git a/commitlint.config.js b/commitlint.config.js index 63f104fa..6869a4dc 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -18,6 +18,7 @@ export default { 'ci', 'claude', 'cli', + 'compiler', 'core', 'design', 'element', diff --git a/packages/compiler/package.json b/packages/compiler/package.json new file mode 100644 index 00000000..0785947a --- /dev/null +++ b/packages/compiler/package.json @@ -0,0 +1,74 @@ +{ + "name": "@videojs/compiler", + "type": "module", + "description": "Compiler for Video.js", + "license": "Apache-2.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/videojs/v10", + "directory": "packages/compiler" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./jsx-runtime": { + "types": "./dist/jsx-runtime.d.ts", + "default": "./dist/jsx-runtime.js" + }, + "./jsx-dev-runtime": { + "types": "./dist/jsx-dev-runtime.d.ts", + "default": "./dist/jsx-dev-runtime.js" + }, + "./vite": { + "types": "./dist/plugins/vite.d.ts", + "default": "./dist/plugins/vite.js" + }, + "./matchers": { + "types": "./dist/matchers/index.d.ts", + "default": "./dist/matchers/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, + "./styles": { + "types": "./dist/styles/index.d.ts", + "default": "./dist/styles/index.js" + }, + "./tailwind": { + "types": "./dist/tailwind/index.d.ts", + "default": "./dist/tailwind/index.js" + }, + "./tsconfig.preset.json": "./tsconfig.preset.json" + }, + "bin": { + "vjs": "./dist/cli.js" + }, + "files": [ + "dist", + "tsconfig.preset.json" + ], + "scripts": { + "build": "tsdown", + "build:watch": "tsdown --watch ./src --no-clean", + "dev": "pnpm run build:watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rimraf --glob dist types '*.tsbuildinfo'" + }, + "dependencies": { + "@videojs/utils": "workspace:*", + "lightningcss": "^1.32.0", + "tailwindcss": "^4.2.1", + "typescript": "^6.0.2" + }, + "devDependencies": { + "@videojs/core": "workspace:*", + "tsdown": "^0.21.4", + "vitest": "^4.1.0" + } +} diff --git a/packages/compiler/src/cli.ts b/packages/compiler/src/cli.ts new file mode 100644 index 00000000..36e58598 --- /dev/null +++ b/packages/compiler/src/cli.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { compile } from './compile'; +import type { CompilerConfig } from './config'; +import { generate } from './generate'; + +interface ConfigModule { + default?: CompilerConfig; + config?: CompilerConfig; +} + +const CONFIG_FILENAMES = ['compiler.config.js', 'compiler.config.mjs', 'compiler.config.ts', 'compiler.config.mts']; + +function findConfig(cwd: string, override: string | undefined): string { + if (override) { + const path = isAbsolute(override) ? override : resolve(cwd, override); + if (!existsSync(path)) throw new Error(`Config file not found: ${path}`); + return path; + } + for (const name of CONFIG_FILENAMES) { + const path = resolve(cwd, name); + if (existsSync(path)) return path; + } + throw new Error( + `No compiler config found in ${cwd}. Expected one of: ${CONFIG_FILENAMES.join(', ')}, or pass --config .` + ); +} + +async function loadConfig(path: string): Promise { + const mod = (await import(pathToFileURL(path).href)) as ConfigModule; + const config = mod.default ?? mod.config; + if (!config) { + throw new Error(`Config file ${path} must export a default \`CompilerConfig\` (use \`defineConfig\`).`); + } + return resolveConfigPaths(config, path); +} + +function resolveConfigPaths(config: CompilerConfig, configPath: string): CompilerConfig { + if (!config.generate) return config; + const base = resolve(configPath, '..'); + const { output, components } = config.generate; + return { + ...config, + generate: { + components, + output: isAbsolute(output) ? output : resolve(base, output), + }, + }; +} + +interface ParsedArgs { + command: string | undefined; + positional: string[]; + configOverride: string | undefined; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + let command: string | undefined; + let configOverride: string | undefined; + const positional: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--config' || arg === '-c') { + configOverride = argv[++i]; + } else if (!command && !arg.startsWith('-')) { + command = arg; + } else if (!arg.startsWith('-')) { + positional.push(arg); + } + } + return { command, positional, configOverride }; +} + +function printHelp(): void { + process.stdout.write( + [ + 'Usage: vjs [options]', + '', + 'Commands:', + ' generate Generate components from the configured manifests', + ' compile Compile a JSX file (stub)', + '', + 'Options:', + ' -c, --config Path to a compiler config (default: compiler.config.ts in cwd)', + ' -h, --help Show this help', + '', + ].join('\n') + ); +} + +async function runGenerate(configOverride: string | undefined): Promise { + const cwd = process.cwd(); + const configPath = findConfig(cwd, configOverride); + const config = await loadConfig(configPath); + const result = await generate(config); + process.stdout.write(`Wrote ${result.outputPath}\n`); +} + +function runCompile(positional: readonly string[]): void { + const file = positional[0]; + if (!file) throw new Error('Usage: vjs compile '); + compile('', { filename: file, target: 'react' }); +} + +async function main(): Promise { + const { command, positional, configOverride } = parseArgs(process.argv.slice(2)); + if (!command || command === 'help' || command === '--help' || command === '-h') { + printHelp(); + return; + } + + switch (command) { + case 'generate': + await runGenerate(configOverride); + return; + case 'compile': + runCompile(positional); + return; + default: + throw new Error(`Unknown command: ${command}`); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/packages/compiler/src/compile.ts b/packages/compiler/src/compile.ts new file mode 100644 index 00000000..db13daed --- /dev/null +++ b/packages/compiler/src/compile.ts @@ -0,0 +1,131 @@ +import ts from 'typescript'; +import { parse } from './parse'; +import { dropUnusedImports } from './transforms/drop-unused-imports'; +import { dropUnusedLocals } from './transforms/drop-unused-locals'; +import { type ImportRule, transformImports } from './transforms/imports'; + +export type CompileTarget = 'react' | 'html'; + +export interface CompileOptions { + filename?: string | undefined; + target: CompileTarget; + /** Per-source-module rewrite rules. See `ImportRule`. */ + imports?: Record | undefined; + /** Additional TS transformers, applied after `transformImports`, in array order. */ + plugins?: readonly ts.TransformerFactory[] | undefined; + /** Directory relative paths in `imports` rules resolve against. Typically the compiler.config.js dir. */ + configDir?: string | undefined; + /** Output file path (used to project relative-path import targets). */ + outputFile?: string | undefined; +} + +export interface CompileResult { + code: string; + map?: unknown; +} + +const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false, +}); + +/** + * Compile a constrained-JSX skin to a target-flavored TSX module. + * + * 1. Parse the source into a TSX SourceFile. + * 2. Apply `transformImports(opts.imports)` so cross-package symbols re-route. + * 3. Apply each `opts.plugins` transformer in order (target-specific lowering). + * 4. Print to a string, then insert blank lines between top-level statements + * so the artifact stays skim-readable. The consumer's formatter (Biome) + * handles indentation/quotes/import grouping but won't *add* blank lines + * that aren't already present, so we seed them here. + */ +export function compile(source: string, options: CompileOptions): CompileResult { + const { ast } = parse(source, { filename: options.filename }); + const transformers: ts.TransformerFactory[] = []; + + if (options.imports) { + transformers.push( + transformImports({ + rules: options.imports, + configDir: options.configDir, + outputFile: options.outputFile, + }) + ); + } + + if (options.plugins) transformers.push(...options.plugins); + + // Final passes: prune locals the rewrites left behind, then prune imports. + // Order matters — dropping a local may make the imports it referenced + // unused. Always run when any transformer ran. + if (transformers.length > 0) { + transformers.push(dropUnusedLocals()); + transformers.push(dropUnusedImports()); + } + + if (transformers.length === 0) { + return { code: separateTopLevel(printer.printFile(ast)) }; + } + + const result = ts.transform(ast, transformers as ts.TransformerFactory[]); + const transformed = result.transformed[0]!; + const code = separateTopLevel(printer.printFile(transformed)); + + result.dispose(); + + return { code }; +} + +/** + * Insert blank lines between top-level statements so the printer's dense + * output is at least readable. Biome will normalize quote/indent/import + * style on top of this; it doesn't add blank lines, so we do. + * + * Heuristic: at column 0, a blank line goes between any two adjacent + * non-blank lines that look like the boundary between top-level constructs: + * + * - after the last `import` line (before non-import code) + * - between a `}` (end of function/class/interface/etc.) and the next + * top-level token + * - between `;` and the next top-level token (covers consts/types) + * - before a leading-comment block that introduces a top-level decl + */ +function separateTopLevel(code: string): string { + const lines = code.split('\n'); + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + out.push(line); + const next = lines[i + 1]; + if (next === undefined || next === '') continue; + + if (isTopLevelBoundary(line, next)) out.push(''); + } + return out.join('\n'); +} + +function isTopLevelBoundary(line: string, next: string): boolean { + // Only consider column-0 boundaries; nested blocks shouldn't get blank lines. + if (line.startsWith(' ') || line.startsWith('\t')) return false; + if (next.startsWith(' ') || next.startsWith('\t')) return false; + + const isImport = (s: string) => s.startsWith('import ') || s.startsWith('import{'); + const startsTopLevel = (s: string) => + /^(export\s+(default\s+)?)?(function|class|const|let|var|interface|type|enum|namespace|async\s+function)\b/.test( + s + ) || + s.startsWith('//') || + s.startsWith('/*'); + + // Boundary: end of an import block. + if (isImport(line) && !isImport(next)) return true; + + // Boundary: end of a top-level block (`}`) → start of another decl. + if (line === '}' && startsTopLevel(next)) return true; + + // Boundary: end-of-statement (`;`) at col 0 → start of another decl. + if (line.endsWith(';') && startsTopLevel(next)) return true; + + return false; +} diff --git a/packages/compiler/src/config.ts b/packages/compiler/src/config.ts new file mode 100644 index 00000000..c398ea7c --- /dev/null +++ b/packages/compiler/src/config.ts @@ -0,0 +1,51 @@ +import type ts from 'typescript'; +import type { ImportRule } from './transforms/imports'; + +/** + * Bulk-defined component entry. Globs `files`, derives each component's name + * from the filename (extension stripped) via `name(stem)`, and inline-emits + * `createComponent({ name })` calls. Components defined this way are + * BaseProps-only — to type Props, parts, or partProps, use a manifest file. + */ +export interface BulkComponentEntry { + files: string; + name: (filename: string) => string; +} + +export type ComponentEntry = string | BulkComponentEntry; + +export interface GenerateConfig { + /** + * Component sources. Each entry is either: + * - a glob string matching `*-component.ts` manifest files, or + * - a `{ files, name }` object that bulk-defines components from arbitrary files. + */ + components: readonly ComponentEntry[]; + /** Path the generator writes the components file to. */ + output: string; +} + +/** + * Per-target compile configuration. Currently only `react` is shipped, but + * the shape is extensible for `html`/etc. + */ +export interface ReactTargetConfig { + /** Per-source-module rewrite rules. */ + imports: Record; + /** Plugins applied in order after `transformImports`. */ + plugins?: readonly ts.TransformerFactory[]; +} + +export interface CompileTargetsConfig { + react?: ReactTargetConfig; +} + +export interface CompilerConfig { + generate?: GenerateConfig; + /** Per-target compile rules consumed by `compile()`. */ + targets?: CompileTargetsConfig; +} + +export function defineConfig(config: CompilerConfig): CompilerConfig { + return config; +} diff --git a/packages/compiler/src/define-component.ts b/packages/compiler/src/define-component.ts new file mode 100644 index 00000000..5a916dca --- /dev/null +++ b/packages/compiler/src/define-component.ts @@ -0,0 +1,58 @@ +declare const __PROPS_BRAND__: unique symbol; + +export interface ComponentManifest< + Props = unknown, + Parts extends readonly string[] = readonly string[], + PartProps extends Record = Record, +> { + name: string; + parts?: Parts; + dataAttrs?: Record; + partProps?: PartProps; + readonly [__PROPS_BRAND__]?: Props; +} + +export type InferProps = + T extends ComponentManifest> ? P : never; + +export type InferParts = + T extends ComponentManifest> + ? readonly string[] extends Parts + ? never + : Parts[number] + : never; + +export type InferPartProps = + T extends ComponentManifest + ? K extends keyof PartProps + ? PartProps[K] + : never + : never; + +/** + * Define a component manifest. + * + * Curried so the `Props` generic can be supplied without disabling inference + * of `Parts` and `PartProps` from the manifest body: + * + * @example + * const Slider = defineComponent()({ + * name: 'Slider', + * parts: SliderParts, + * dataAttrs: SliderDataAttrs, + * }); + * + * const Controls = defineComponent()({ + * name: 'Controls', + * parts: ControlsParts, + * dataAttrs: ControlsDataAttrs, + * }); + */ +export function defineComponent() { + return < + const Parts extends readonly string[] = readonly string[], + const PartProps extends Record = Record, + >( + manifest: Omit, typeof __PROPS_BRAND__> + ): ComponentManifest => manifest as ComponentManifest; +} diff --git a/packages/compiler/src/generate.ts b/packages/compiler/src/generate.ts new file mode 100644 index 00000000..95b87a01 --- /dev/null +++ b/packages/compiler/src/generate.ts @@ -0,0 +1,150 @@ +import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path'; +import ts from 'typescript'; + +import type { BulkComponentEntry, CompilerConfig } from './config'; + +interface ManifestComponent { + kind: 'manifest'; + name: string; + /** Module specifier (relative to output) for `import XDef from '...'`. */ + manifestFrom: string; +} + +interface InlineComponent { + kind: 'inline'; + name: string; +} + +type ResolvedComponent = ManifestComponent | InlineComponent; + +function isDefineComponentCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) return false; + const callee = node.expression; + if (ts.isIdentifier(callee) && callee.text === 'defineComponent') return true; + // Curried form: defineComponent

()(...) — outer call's expression is the inner call. + return ( + ts.isCallExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === 'defineComponent' + ); +} + +function findDefaultExportCall(sourceFile: ts.SourceFile): ts.CallExpression | null { + for (const stmt of sourceFile.statements) { + if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; + if (isDefineComponentCall(stmt.expression)) return stmt.expression; + } + return null; +} + +function parseComponentName(manifestPath: string): string { + const sourceText = readFileSync(manifestPath, 'utf8'); + const sourceFile = ts.createSourceFile(manifestPath, sourceText, ts.ScriptTarget.Latest, true); + const call = findDefaultExportCall(sourceFile); + if (!call) { + throw new Error(`No \`export default defineComponent(...)\` found in ${manifestPath}`); + } + const arg = call.arguments[0]; + if (!arg || !ts.isObjectLiteralExpression(arg)) { + throw new Error(`defineComponent() in ${manifestPath} must take an object literal`); + } + for (const prop of arg.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'name' && + ts.isStringLiteral(prop.initializer) + ) { + return prop.initializer.text; + } + } + throw new Error(`defineComponent() in ${manifestPath} is missing a literal \`name:\` field`); +} + +function manifestPathToImport(manifestPath: string, outputFile: string): string { + let rel = relative(dirname(outputFile), manifestPath); + if (!rel.startsWith('.')) rel = `./${rel}`; + return rel.replace(/\.ts$/, ''); +} + +function fileStem(filePath: string): string { + const base = basename(filePath); + const ext = extname(base); + return ext ? base.slice(0, -ext.length) : base; +} + +function resolveManifestEntry(pattern: string, cwd: string, outputAbsolute: string): ManifestComponent[] { + const matches = globSync(pattern, { cwd }).map((p) => (isAbsolute(p) ? p : resolve(cwd, p))); + return matches.map((manifestPath) => ({ + kind: 'manifest', + name: parseComponentName(manifestPath), + manifestFrom: manifestPathToImport(manifestPath, outputAbsolute), + })); +} + +function resolveBulkEntry(entry: BulkComponentEntry, cwd: string): InlineComponent[] { + const matches = globSync(entry.files, { cwd }); + return matches.map((file) => ({ + kind: 'inline', + name: entry.name(fileStem(file)), + })); +} + +function emitHeader(entries: readonly ResolvedComponent[]): string { + const manifestLines = entries + .filter((e): e is ManifestComponent => e.kind === 'manifest') + .map((e) => `import ${e.name}Def from '${e.manifestFrom}';`) + .join('\n'); + const head = `// AUTO-GENERATED by \`@videojs/compiler\`. DO NOT EDIT. +import { createComponent } from '@videojs/compiler/jsx-runtime';`; + return manifestLines ? `${head}\n\n${manifestLines}` : head; +} + +function manifestRef(entry: ResolvedComponent): string { + return entry.kind === 'manifest' ? `${entry.name}Def` : `{ name: '${entry.name}' }`; +} + +function emitComponents(entries: readonly ResolvedComponent[]): string { + return entries.map((e) => `export const ${e.name} = createComponent(${manifestRef(e)});`).join('\n'); +} + +function emitMetadata(entries: readonly ResolvedComponent[]): string { + const lines = entries.map((e) => ` ${e.name}: ${manifestRef(e)},`); + return `export const COMPONENTS = {\n${lines.join('\n')}\n} as const; + +export type Components = typeof COMPONENTS;`; +} + +export interface GenerateResult { + outputPath: string; + source: string; +} + +export async function generate(config: CompilerConfig): Promise { + if (!config.generate) { + throw new Error('@videojs/compiler: generate() requires a `generate` field in the compiler config'); + } + + const { components, output } = config.generate; + const cwd = process.cwd(); + const outputAbsolute = isAbsolute(output) ? output : resolve(cwd, output); + + const resolved: ResolvedComponent[] = components.flatMap((entry) => + typeof entry === 'string' ? resolveManifestEntry(entry, cwd, outputAbsolute) : resolveBulkEntry(entry, cwd) + ); + const entries = resolved.sort((a, b) => a.name.localeCompare(b.name)); + + if (entries.length === 0) { + throw new Error(`No component sources matched: ${JSON.stringify(components)}`); + } + + const source = `${[emitHeader(entries), emitComponents(entries), emitMetadata(entries)].join('\n\n')}\n`; + // Skip the write when contents are unchanged so watch-mode rebuilds don't + // re-trigger themselves. + const existing = existsSync(outputAbsolute) ? readFileSync(outputAbsolute, 'utf8') : null; + + if (existing !== source) { + writeFileSync(outputAbsolute, source, 'utf8'); + } + + return { outputPath: outputAbsolute, source }; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts new file mode 100644 index 00000000..929b217b --- /dev/null +++ b/packages/compiler/src/index.ts @@ -0,0 +1,19 @@ +export { type CompileOptions, type CompileResult, type CompileTarget, compile } from './compile'; +export { + type CompilerConfig, + type CompileTargetsConfig, + defineConfig, + type ReactTargetConfig, +} from './config'; +export { + type ComponentManifest, + defineComponent, + type InferParts, + type InferProps, +} from './define-component'; +export { type GenerateResult, generate } from './generate'; +export { type ParseOptions, type ParseResult, parse } from './parse'; +export { type AddImportContext, type AddImportRef, addNamedImport } from './transforms/add-import'; +export { type ImportRef, type ImportRewriteOptions, type ImportRule, transformImports } from './transforms/imports'; +export { type ReplaceOptions, replace } from './transforms/replace'; +export { type WrapOptions, wrap } from './transforms/wrap'; diff --git a/packages/compiler/src/jsx-dev-runtime.ts b/packages/compiler/src/jsx-dev-runtime.ts new file mode 100644 index 00000000..ae3601ff --- /dev/null +++ b/packages/compiler/src/jsx-dev-runtime.ts @@ -0,0 +1,2 @@ +export * from './jsx-runtime'; +export { jsx as jsxDEV } from './jsx-runtime'; diff --git a/packages/compiler/src/jsx-runtime.ts b/packages/compiler/src/jsx-runtime.ts new file mode 100644 index 00000000..0d2ab32b --- /dev/null +++ b/packages/compiler/src/jsx-runtime.ts @@ -0,0 +1,98 @@ +import type { ComponentManifest, InferPartProps, InferParts, InferProps } from './define-component'; + +export const VIDEOJS_NODE = Symbol.for('@videojs/node'); + +export interface ComponentNode { + readonly [VIDEOJS_NODE]: true; + readonly type: unknown; + readonly props: Record; + readonly key: string | number | null; +} + +export interface BaseProps { + className?: string | undefined; + children?: unknown; +} + +export interface Component { + (props: BaseProps & Props): unknown; + readonly $$component: { name: string; part: string | null }; +} + +type PartComponentProps = K extends 'Root' + ? InferProps + : InferPartProps extends never + ? unknown + : InferPartProps; + +type CompoundComponent = { + [K in InferParts & string]: Component>; +}; + +export type CreateComponentResult = [InferParts] extends [never] + ? Component> + : CompoundComponent; + +function makePart(name: string, part: string | null): Component { + const fn = (_props: BaseProps & Props): unknown => { + throw new Error(`@videojs/compiler: <${name}${part ? `.${part}` : ''}> can only be evaluated by the compiler.`); + }; + + Object.assign(fn, { $$component: { name, part } }); + + return fn as Component; +} + +export function createComponent>>( + manifest: M +): CreateComponentResult { + const parts = manifest.parts ?? []; + + if (parts.length === 0) { + return makePart(manifest.name, null) as CreateComponentResult; + } + + const compound: Record> = {}; + + for (const part of parts) { + compound[part] = makePart(manifest.name, part); + } + + return compound as CreateComponentResult; +} + +function createNode(type: unknown, props: Record, key?: string | number | null): ComponentNode { + return { + [VIDEOJS_NODE]: true, + type, + props, + key: key ?? null, + }; +} + +export function jsx(type: unknown, props: Record, key?: string | number | null): ComponentNode { + return createNode(type, props, key); +} + +export function jsxs(type: unknown, props: Record, key?: string | number | null): ComponentNode { + return createNode(type, props, key); +} + +export const Fragment: unique symbol = Symbol.for('@videojs/fragment') as never; + +export namespace JSX { + export type Element = unknown; + + export interface ElementChildrenAttribute { + children: Record; + } + + export interface IntrinsicAttributes { + key?: string | number | undefined; + } + + export interface IntrinsicElements { + div: BaseProps; + span: BaseProps; + } +} diff --git a/packages/compiler/src/matchers/has-child.ts b/packages/compiler/src/matchers/has-child.ts new file mode 100644 index 00000000..0ba49bab --- /dev/null +++ b/packages/compiler/src/matchers/has-child.ts @@ -0,0 +1,44 @@ +import ts from 'typescript'; +import type { JsxElementLike, Matcher } from './tag'; + +export interface HasChildOptions { + /** When `true`, search all descendants (not just direct children). Default: `false`. */ + deep?: boolean; +} + +/** + * Match a JSX element that has any child satisfying `childMatcher`. + * + * Composes with `byTag` / `anyTag` for nested shape checks: + * + * // Popover.Root whose Popover.Trigger child contains a MuteButton: + * byTag('Popover.Root', { + * when: hasChild(byTag('Popover.Trigger', { when: hasChild(byTag('MuteButton')) })), + * }) + * + * Self-closing elements have no children and never match. Whitespace-only + * `JsxText` nodes are skipped. With `deep: true`, descendants of every + * JsxElement are searched recursively. + */ +export function hasChild(childMatcher: Matcher, opts: HasChildOptions = {}): Matcher { + const { deep = false } = opts; + return (node) => { + if (!ts.isJsxElement(node)) return false; + return findIn(node.children, childMatcher, deep); + }; +} + +function findIn(children: readonly ts.JsxChild[], match: Matcher, deep: boolean): boolean { + for (const child of children) { + if (ts.isJsxText(child)) continue; + if (ts.isJsxFragment(child)) { + if (deep && findIn(child.children, match, deep)) return true; + continue; + } + if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) { + if (match(child as JsxElementLike)) return true; + if (deep && ts.isJsxElement(child) && findIn(child.children, match, deep)) return true; + } + } + return false; +} diff --git a/packages/compiler/src/matchers/index.ts b/packages/compiler/src/matchers/index.ts new file mode 100644 index 00000000..99a79555 --- /dev/null +++ b/packages/compiler/src/matchers/index.ts @@ -0,0 +1,2 @@ +export { hasChild } from './has-child'; +export { anyTag, byTag, type JsxElementLike, type Matcher, tagName } from './tag'; diff --git a/packages/compiler/src/matchers/tag.ts b/packages/compiler/src/matchers/tag.ts new file mode 100644 index 00000000..3c52daff --- /dev/null +++ b/packages/compiler/src/matchers/tag.ts @@ -0,0 +1,41 @@ +import ts from 'typescript'; + +/** A JSX element that helpers can transform — either an open/close pair or self-closing. */ +export type JsxElementLike = ts.JsxElement | ts.JsxSelfClosingElement; + +/** Predicate over a JSX element. Single shape across `replace`, `wrap`, `childAsProp`, `addProp`. */ +export type Matcher = (node: JsxElementLike) => boolean; + +/** Read the textual tag of a JSX element, including dotted names like `Popover.Root`. */ +export function tagName(node: JsxElementLike): string { + const tagNode = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName; + return readTag(tagNode); +} + +function readTag(name: ts.JsxTagNameExpression): string { + if (ts.isIdentifier(name)) return name.text; + if (ts.isPropertyAccessExpression(name)) + return `${readTag(name.expression as ts.JsxTagNameExpression)}.${name.name.text}`; + // ThisExpression / JsxNamespacedName — uncommon in our skins; fall back to source text. + return name.getText(); +} + +/** + * Match a JSX element by tag, with an optional refinement predicate. + * + * `byTag('Popover.Root', { when })` matches when the element's tag equals + * `Popover.Root` AND `when(node)` is true. + */ +export function byTag(tag: string, opts: { when?: Matcher } = {}): Matcher { + const { when } = opts; + return (node) => { + if (tagName(node) !== tag) return false; + return when ? when(node) : true; + }; +} + +/** Match a JSX element if its tag is in the given list. */ +export function anyTag(tags: readonly string[]): Matcher { + const set = new Set(tags); + return (node) => set.has(tagName(node)); +} diff --git a/packages/compiler/src/parse.ts b/packages/compiler/src/parse.ts new file mode 100644 index 00000000..d80d9141 --- /dev/null +++ b/packages/compiler/src/parse.ts @@ -0,0 +1,29 @@ +import ts from 'typescript'; + +export interface ParseOptions { + filename?: string | undefined; +} + +export interface ParseResult { + ast: ts.SourceFile; +} + +/** + * Parse a constrained-JSX skin source into a TypeScript SourceFile. + * + * The parser is intentionally thin — `ts.createSourceFile` configured for TSX + * with parent pointers set so transforms can walk back up the tree. + */ +export function parse(source: string, options: ParseOptions = {}): ParseResult { + const filename = options.filename ?? 'input.tsx'; + + const ast = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ts.ScriptKind.TSX + ); + + return { ast }; +} diff --git a/packages/compiler/src/plugins/vite.ts b/packages/compiler/src/plugins/vite.ts new file mode 100644 index 00000000..72b0e0df --- /dev/null +++ b/packages/compiler/src/plugins/vite.ts @@ -0,0 +1,28 @@ +import { type CompileTarget, compile } from '../compile'; + +export interface VideojsCompilerPluginOptions { + target?: CompileTarget | undefined; + include?: readonly string[] | undefined; +} + +export interface VitePlugin { + name: string; + enforce?: 'pre' | 'post'; + transform?: (code: string, id: string) => { code: string; map?: unknown } | null; +} + +export function vjsCompiler(options: VideojsCompilerPluginOptions = {}): VitePlugin { + const target: CompileTarget = options.target ?? 'react'; + const include = options.include ?? ['.tsx']; + + return { + name: '@videojs/compiler', + enforce: 'pre', + transform(code, id) { + if (!include.some((ext) => id.endsWith(ext))) return null; + return compile(code, { filename: id, target }); + }, + }; +} + +export default vjsCompiler; diff --git a/packages/compiler/src/react/add-prop.ts b/packages/compiler/src/react/add-prop.ts new file mode 100644 index 00000000..29f12941 --- /dev/null +++ b/packages/compiler/src/react/add-prop.ts @@ -0,0 +1,89 @@ +import ts from 'typescript'; +import type { JsxElementLike, Matcher } from '../matchers'; +import { type AddImportContext, addNamedImport } from '../transforms/add-import'; + +export interface AddPropImportRef { + source: string; + name: string; + /** `'jsx'` emits `` as the value; `'ref'` emits `Imported`. Defaults to `'jsx'`. */ + kind?: 'jsx' | 'ref'; +} + +export interface AddPropOptions { + match: Matcher; + prop: string; + value: ts.Expression | AddPropImportRef; + /** When the matched element already has the prop set: skip by default; set to `true` to force overwrite. */ + overwrite?: boolean; +} + +/** + * For elements matching `match`, set `prop` to `value`. `value` is either a + * literal expression or an `ImportRef`. With `kind: 'jsx'` (default) the + * compiler emits `={}`; with `kind: 'ref'` it emits + * `={Imported}`. Either form auto-adds the import. + * + * Default behavior skips matches whose `prop` is already present; + * `overwrite: true` forces replacement. + */ +export function addProp(opts: AddPropOptions, ctx: AddImportContext = {}): ts.TransformerFactory { + return (context) => { + const factory = context.factory; + let needsImport: AddPropImportRef | null = null; + + const buildAttribute = (): ts.JsxAttribute => { + const expr = isImportRef(opts.value) ? buildRefExpression(opts.value, factory) : opts.value; + return factory.createJsxAttribute( + factory.createIdentifier(opts.prop), + factory.createJsxExpression(undefined, expr) + ); + }; + + const visit = (node: ts.Node): ts.Node => { + const out = ts.visitEachChild(node, visit, context); + if (!ts.isJsxElement(out) && !ts.isJsxSelfClosingElement(out)) return out; + if (!opts.match(out as JsxElementLike)) return out; + + const attrs = ts.isJsxElement(out) ? out.openingElement.attributes : out.attributes; + const existingIdx = attrs.properties.findIndex( + (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === opts.prop + ); + if (existingIdx !== -1 && !opts.overwrite) return out; + + if (isImportRef(opts.value)) needsImport = opts.value; + const newAttribute = buildAttribute(); + const nextProperties = + existingIdx === -1 + ? [...attrs.properties, newAttribute] + : attrs.properties.map((p, i) => (i === existingIdx ? newAttribute : p)); + const newAttrs = factory.createJsxAttributes(nextProperties); + + if (ts.isJsxSelfClosingElement(out)) { + return factory.createJsxSelfClosingElement(out.tagName, out.typeArguments, newAttrs); + } + return factory.createJsxElement( + factory.createJsxOpeningElement(out.openingElement.tagName, out.openingElement.typeArguments, newAttrs), + out.children, + out.closingElement + ); + }; + + return (sourceFile) => { + let result = ts.visitEachChild(sourceFile, visit, context); + if (needsImport) { + result = addNamedImport(result, { source: needsImport.source, name: needsImport.name }, factory, ctx); + } + return result; + }; + }; +} + +function isImportRef(value: ts.Expression | AddPropImportRef): value is AddPropImportRef { + return typeof (value as AddPropImportRef).source === 'string' && typeof (value as AddPropImportRef).name === 'string'; +} + +function buildRefExpression(ref: AddPropImportRef, factory: ts.NodeFactory): ts.Expression { + const id = factory.createIdentifier(ref.name); + if ((ref.kind ?? 'jsx') === 'ref') return id; + return factory.createJsxSelfClosingElement(id, undefined, factory.createJsxAttributes([])); +} diff --git a/packages/compiler/src/react/child-as-prop.ts b/packages/compiler/src/react/child-as-prop.ts new file mode 100644 index 00000000..f1730f56 --- /dev/null +++ b/packages/compiler/src/react/child-as-prop.ts @@ -0,0 +1,68 @@ +import ts from 'typescript'; +import type { JsxElementLike, Matcher } from '../matchers'; + +export interface ChildAsPropOptions { + match: Matcher; + prop: string; +} + +/** + * For elements matching `match`, lift the single JSX-element child into the + * named prop (turning the element into a self-closing form): + * + * + * → }/> + * + * Skips no-op cases: + * - element is already self-closing + * - prop is already set + * - children are zero, multiple, or text-only (no single JSX-element child) + */ +export function childAsProp(opts: ChildAsPropOptions): ts.TransformerFactory { + return (context) => { + const visit = (node: ts.Node): ts.Node => { + const out = ts.visitEachChild(node, visit, context); + if (!ts.isJsxElement(out)) return out; + if (!opts.match(out as JsxElementLike)) return out; + + const opening = out.openingElement; + if (hasAttribute(opening.attributes, opts.prop)) return out; + + const elementChild = singleElementChild(out.children); + if (!elementChild) return out; + + const factory = context.factory; + const newAttrs = factory.createJsxAttributes([ + ...opening.attributes.properties, + factory.createJsxAttribute( + factory.createIdentifier(opts.prop), + factory.createJsxExpression(undefined, elementChild) + ), + ]); + + return factory.createJsxSelfClosingElement(opening.tagName, opening.typeArguments, newAttrs); + }; + + return (sourceFile) => ts.visitEachChild(sourceFile, visit, context); + }; +} + +function hasAttribute(attrs: ts.JsxAttributes, name: string): boolean { + return attrs.properties.some((p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === name); +} + +function singleElementChild( + children: readonly ts.JsxChild[] +): ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | null { + let found: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | null = null; + for (const child of children) { + if (ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces) continue; + if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) { + if (found) return null; + found = child; + continue; + } + return null; + } + return found; +} diff --git a/packages/compiler/src/react/index.ts b/packages/compiler/src/react/index.ts new file mode 100644 index 00000000..4a8b8b04 --- /dev/null +++ b/packages/compiler/src/react/index.ts @@ -0,0 +1,11 @@ +/** + * React-target plugins for `@videojs/compiler`. Houses the framework-pattern + * helpers that lower constrained-JSX skin idioms into React's render-prop + * slot composition idiom. Re-exports `replace` and `wrap` for convenience so + * a config can import everything from one subpath. + */ + +export { type ReplaceOptions, replace } from '../transforms/replace'; +export { type WrapOptions, wrap } from '../transforms/wrap'; +export { type AddPropImportRef, type AddPropOptions, addProp } from './add-prop'; +export { type ChildAsPropOptions, childAsProp } from './child-as-prop'; diff --git a/packages/compiler/src/styles/analyze.ts b/packages/compiler/src/styles/analyze.ts new file mode 100644 index 00000000..1eb585c8 --- /dev/null +++ b/packages/compiler/src/styles/analyze.ts @@ -0,0 +1,236 @@ +import ts from 'typescript'; +import type { JsxElementLike } from '../matchers'; + +/** + * A single segment within a `className` attribute. Either a literal class + * string we can statically analyze, a dotted token reference (e.g. + * `styles.button.icon`) we can resolve later, or an opaque expression we + * have to leave alone. + */ +export type StyleSegment = + | { kind: 'literal'; value: string; node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral } + | { kind: 'token'; path: readonly string[]; node: ts.PropertyAccessExpression | ts.Identifier } + | { kind: 'opaque'; node: ts.Expression }; + +/** + * The `className` attribute on a JSX element, plus everything the visitor + * needs to inspect or rewrite it. Two shapes: + * + * - `kind: 'segments'` — the value is either a literal string, a `cn(...)` + * call, or a single dotted token reference. We can decompose it into + * ordered `StyleSegment`s. + * - `kind: 'opaque'` — anything else (computed expressions, ternaries that + * don't reduce, function calls other than `cn`). Visitors should pass. + */ +export interface StyleAttributeInfo { + /** The element this className lives on. */ + element: JsxElementLike; + /** The JSX attribute node, for source-location reporting. */ + attribute: ts.JsxAttribute; + /** The expression *inside* the attribute (the `className={…}` payload). */ + expression: ts.Expression; + /** Decomposition. */ + kind: 'segments' | 'opaque'; + /** Defined when `kind === 'segments'`. */ + segments?: readonly StyleSegment[]; +} + +/** + * Visitor return type: + * - `undefined` — leave the attribute unchanged. + * - a `ts.Expression` — replace the attribute's value with this. + */ +export type StyleVisitorResult = ts.Expression | undefined; + +/** The visitor invoked for each `className` attribute. */ +export type StyleVisitor = (info: StyleAttributeInfo, factory: ts.NodeFactory) => StyleVisitorResult; + +export interface AnalyzeStylesOptions { + visit: StyleVisitor; + /** + * Name of the helper call we treat as a class-merge (default: `'cn'`). + * Override if your skin module uses a different name. + */ + mergeFn?: string; +} + +/** + * TS transformer that walks every JSX `className` attribute and invokes a + * visitor with structural info. The visitor decides whether to replace the + * attribute's value (returning a new expression) or leave it alone. + * + * The visitor is purely structural — it does not know about Tailwind or + * any other style system. Higher-level plugins (e.g. `tailwindPlugin`) + * compose it. + */ +export function analyzeStyles(options: AnalyzeStylesOptions): ts.TransformerFactory { + const { visit, mergeFn = 'cn' } = options; + + return (transformContext) => { + const factory = transformContext.factory; + + return (sourceFile) => { + const visitNode = (node: ts.Node): ts.Node => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const visited = visitJsxElement(node as JsxElementLike, factory, visit, mergeFn, transformContext); + // Continue descending into the (possibly transformed) element. + return ts.visitEachChild(visited, visitNode, transformContext); + } + return ts.visitEachChild(node, visitNode, transformContext); + }; + + return ts.visitEachChild(sourceFile, visitNode, transformContext); + }; + }; +} + +function visitJsxElement( + element: JsxElementLike, + factory: ts.NodeFactory, + visit: StyleVisitor, + mergeFn: string, + context: ts.TransformationContext +): JsxElementLike { + const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes; + const classNameAttr = findClassNameAttribute(attrs); + if (!classNameAttr) return element; + + const expression = readAttributeExpression(classNameAttr); + if (!expression) return element; + + const info: StyleAttributeInfo = decompose(element, classNameAttr, expression, mergeFn); + + const replacement = visit(info, factory); + if (replacement === undefined) return element; + + return rewriteAttribute(element, classNameAttr, replacement, factory, context); +} + +function findClassNameAttribute(attrs: ts.JsxAttributes): ts.JsxAttribute | undefined { + for (const prop of attrs.properties) { + if (ts.isJsxAttribute(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'className') { + return prop; + } + } + return undefined; +} + +function readAttributeExpression(attr: ts.JsxAttribute): ts.Expression | undefined { + const init = attr.initializer; + if (!init) return undefined; + if (ts.isStringLiteral(init)) return init; + if (ts.isJsxExpression(init) && init.expression) return init.expression; + return undefined; +} + +function decompose( + element: JsxElementLike, + attribute: ts.JsxAttribute, + expression: ts.Expression, + mergeFn: string +): StyleAttributeInfo { + // Literal string: `className="foo bar"` or `className={'foo bar'}`. + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + return { + element, + attribute, + expression, + kind: 'segments', + segments: [{ kind: 'literal', value: expression.text, node: expression }], + }; + } + + // Single dotted token reference: `className={styles.button.icon}`. + if (ts.isPropertyAccessExpression(expression) || ts.isIdentifier(expression)) { + const path = readDottedPath(expression); + if (path) { + return { + element, + attribute, + expression, + kind: 'segments', + segments: [{ kind: 'token', path, node: expression }], + }; + } + } + + // `cn(...)` call: decompose each argument. + if (ts.isCallExpression(expression) && isMergeCall(expression, mergeFn)) { + const segments: StyleSegment[] = []; + for (const arg of expression.arguments) { + segments.push(classifySegment(arg)); + } + return { + element, + attribute, + expression, + kind: 'segments', + segments, + }; + } + + // Anything else (ternaries, function calls, conditional spreads, …) + return { element, attribute, expression, kind: 'opaque' }; +} + +function isMergeCall(call: ts.CallExpression, mergeFn: string): boolean { + const callee = call.expression; + return ts.isIdentifier(callee) && callee.text === mergeFn; +} + +function classifySegment(arg: ts.Expression): StyleSegment { + if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { + return { kind: 'literal', value: arg.text, node: arg }; + } + if (ts.isPropertyAccessExpression(arg) || ts.isIdentifier(arg)) { + const path = readDottedPath(arg); + if (path) return { kind: 'token', path, node: arg }; + } + return { kind: 'opaque', node: arg }; +} + +/** + * Read a dotted path like `styles.button.icon` into `['styles', 'button', 'icon']`. + * Returns `null` for any non-dotted expression (e.g. element access, computed property). + */ +function readDottedPath(expr: ts.Expression): readonly string[] | null { + if (ts.isIdentifier(expr)) return [expr.text]; + if (ts.isPropertyAccessExpression(expr)) { + const head = readDottedPath(expr.expression); + if (!head) return null; + if (!ts.isIdentifier(expr.name)) return null; + return [...head, expr.name.text]; + } + return null; +} + +function rewriteAttribute( + element: JsxElementLike, + attribute: ts.JsxAttribute, + replacement: ts.Expression, + factory: ts.NodeFactory, + _context: ts.TransformationContext +): JsxElementLike { + const newAttribute = factory.updateJsxAttribute( + attribute, + attribute.name, + ts.isStringLiteral(replacement) ? replacement : factory.createJsxExpression(undefined, replacement) + ); + + if (ts.isJsxElement(element)) { + const opening = element.openingElement; + const newAttrs = factory.updateJsxAttributes( + opening.attributes, + opening.attributes.properties.map((p) => (p === attribute ? newAttribute : p)) + ); + const newOpening = factory.updateJsxOpeningElement(opening, opening.tagName, opening.typeArguments, newAttrs); + return factory.updateJsxElement(element, newOpening, element.children, element.closingElement); + } + + // Self-closing element. + const newAttrs = factory.updateJsxAttributes( + element.attributes, + element.attributes.properties.map((p) => (p === attribute ? newAttribute : p)) + ); + return factory.updateJsxSelfClosingElement(element, element.tagName, element.typeArguments, newAttrs); +} diff --git a/packages/compiler/src/styles/index.ts b/packages/compiler/src/styles/index.ts new file mode 100644 index 00000000..c3131e4b --- /dev/null +++ b/packages/compiler/src/styles/index.ts @@ -0,0 +1,8 @@ +export { + type AnalyzeStylesOptions, + analyzeStyles, + type StyleAttributeInfo, + type StyleSegment, + type StyleVisitor, + type StyleVisitorResult, +} from './analyze'; diff --git a/packages/compiler/src/styles/tests/analyze.test.ts b/packages/compiler/src/styles/tests/analyze.test.ts new file mode 100644 index 00000000..287761fa --- /dev/null +++ b/packages/compiler/src/styles/tests/analyze.test.ts @@ -0,0 +1,200 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { compile } from '../../compile'; +import type { StyleAttributeInfo, StyleSegment } from '../analyze'; +import { analyzeStyles } from '../analyze'; + +function collectSegments(source: string): StyleAttributeInfo[] { + const collected: StyleAttributeInfo[] = []; + compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (info) => { + collected.push(info); + return undefined; + }, + }), + ], + }); + return collected; +} + +const collapse = (s: string): string => s.replace(/\s+/g, ''); + +describe('analyzeStyles — decomposition', () => { + it('classifies a literal-string className', () => { + const infos = collectSegments(`function App(){ return

; }`); + expect(infos).toHaveLength(1); + expect(infos[0]!.kind).toBe('segments'); + expect(infos[0]!.segments).toEqual([{ kind: 'literal', value: 'foo bar', node: expect.any(Object) }]); + }); + + it('classifies an expression-wrapped string literal', () => { + const infos = collectSegments(`function App(){ return
; }`); + expect(infos).toHaveLength(1); + expect(infos[0]!.segments?.[0]).toMatchObject({ kind: 'literal', value: 'foo' }); + }); + + it('classifies a single dotted token reference', () => { + const infos = collectSegments(`function App(){ return
; }`); + expect(infos).toHaveLength(1); + expect(infos[0]!.segments).toHaveLength(1); + expect(infos[0]!.segments?.[0]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'icon'] }); + }); + + it('decomposes a `cn(...)` call into mixed segments', () => { + const source = `function App(){ + return
; + }`; + const infos = collectSegments(source); + expect(infos).toHaveLength(1); + expect(infos[0]!.kind).toBe('segments'); + const kinds = infos[0]!.segments?.map((s: StyleSegment) => s.kind); + expect(kinds).toEqual(['literal', 'token', 'opaque']); + expect(infos[0]!.segments?.[1]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'base'] }); + }); + + it('marks anything else as opaque', () => { + const infos = collectSegments(`function App(){ return
; }`); + expect(infos).toHaveLength(1); + expect(infos[0]!.kind).toBe('opaque'); + expect(infos[0]!.segments).toBeUndefined(); + }); + + it('sees className on self-closing elements', () => { + const infos = collectSegments(`function App(){ return ; }`); + expect(infos).toHaveLength(1); + }); + + it('walks nested elements', () => { + const source = `function App(){ + return
; + }`; + const infos = collectSegments(source); + expect(infos).toHaveLength(3); + }); + + it('skips elements without className', () => { + const source = `function App(){ + return
; + }`; + const infos = collectSegments(source); + expect(infos).toHaveLength(1); + expect(infos[0]!.segments?.[0]).toMatchObject({ value: 'b' }); + }); + + it('honours custom mergeFn name', () => { + const source = `function App(){ return
; }`; + const infos: StyleAttributeInfo[] = []; + compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + mergeFn: 'twMerge', + visit: (info) => { + infos.push(info); + return undefined; + }, + }), + ], + }); + expect(infos[0]!.kind).toBe('segments'); + expect(infos[0]!.segments).toHaveLength(2); + }); +}); + +describe('analyzeStyles — rewriting', () => { + it('replaces the className value when the visitor returns an expression', () => { + const source = `function App(){ return
; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (_, factory) => factory.createStringLiteral('rewritten'), + }), + ], + }); + expect(code).toMatch(/className="rewritten"/); + expect(code).not.toContain('foo bar'); + }); + + it('leaves the className alone when the visitor returns undefined', () => { + const source = `function App(){ return
; }`; + const { code } = compile(source, { + target: 'react', + plugins: [analyzeStyles({ visit: () => undefined })], + }); + expect(code).toContain('"foo"'); + }); + + it('rewrites a self-closing element', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (_, factory) => factory.createStringLiteral('bar'), + }), + ], + }); + expect(collapse(code)).toContain(collapse(``)); + }); + + it('only rewrites elements the visitor opts to change', () => { + const source = `function App(){ + return
; + }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (info, factory) => { + const hasRewrite = info.segments?.some((s) => s.kind === 'literal' && s.value === 'rewrite'); + return hasRewrite ? factory.createStringLiteral('rewritten') : undefined; + }, + }), + ], + }); + expect(code).toContain('"keep"'); + expect(code).toContain('"rewritten"'); + expect(code).not.toContain('"rewrite"'); + }); + + it('passes a NodeFactory the visitor can use to build any expression', () => { + const source = `function App(){ return
; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (_, factory) => + factory.createCallExpression(factory.createIdentifier('cn'), undefined, [ + factory.createStringLiteral('a'), + factory.createStringLiteral('b'), + ]), + }), + ], + }); + expect(code).toMatch(/className=\{cn\("a",\s*"b"\)\}/); + }); +}); + +describe('analyzeStyles — element identity', () => { + it('exposes the element on the StyleAttributeInfo so visitors can reason about its tag', () => { + const source = `function App(){ return ; }`; + const tags: string[] = []; + compile(source, { + target: 'react', + plugins: [ + analyzeStyles({ + visit: (info) => { + const tag = ts.isJsxElement(info.element) ? info.element.openingElement.tagName : info.element.tagName; + if (ts.isIdentifier(tag)) tags.push(tag.text); + return undefined; + }, + }), + ], + }); + expect(tags).toEqual(['PlayButton']); + }); +}); diff --git a/packages/compiler/src/tailwind/decompose.ts b/packages/compiler/src/tailwind/decompose.ts new file mode 100644 index 00000000..98fcb3bf --- /dev/null +++ b/packages/compiler/src/tailwind/decompose.ts @@ -0,0 +1,206 @@ +import type { DesignSystem } from './design-system'; + +/** A CSS declaration extracted from a utility. */ +export interface Declaration { + property: string; + value: string; +} + +/** Variant kinds we recognize when decomposing a utility. */ +export type VariantKind = + | 'media' // @media (...) wrapper + | 'container' // @container (...) wrapper + | 'supports' // @supports (...) wrapper + | 'pseudo' // selector tail like `:hover`, `::before`, `:focus-visible` + | 'attribute' // `[data-x]`, `[data-x=y]` + | 'group' // `:is(:where(.group)... *)` (Tailwind v4 group-* variant) + | 'peer' // `:is(:where(.peer)... ~ *)` (Tailwind v4 peer-* variant) + | 'descendant' // `& > *`, `& *`, etc. + | 'parent'; // anything else + +export interface Variant { + kind: VariantKind; + /** Selector segment this variant adds, if any. */ + selector?: string; + /** At-rule wrapper, if any. */ + atRule?: { name: string; params: string }; + /** Original raw form (for diagnostics + emit). */ + raw: string; +} + +export interface UtilityCss { + utility: string; + declarations: readonly Declaration[]; + variants: readonly Variant[]; +} + +/** + * Decompose a Tailwind v4 utility into its declarations + variant chain. + * + * Tailwind v4 emits CSS in nested form: the outer rule is the utility class + * selector, and any variants are nested inside using `&:hover`, `@media (...)`, + * `&[data-x]`, etc. Multiple variants on a single utility produce multiply + * nested blocks. We walk the nesting tree, collecting one `Variant` per + * nesting level (outermost → innermost), and read the innermost declarations. + * + * Returns `null` for utilities Tailwind doesn't recognize. + */ +export function decompose(utility: string, design: DesignSystem): UtilityCss | null { + const css = design.compileUtility(utility); + if (!css) return null; + + const trimmed = css.trim(); + const outerOpen = trimmed.indexOf('{'); + if (outerOpen === -1) return null; + const outerBlock = readBalancedBlock(trimmed, outerOpen); + if (!outerBlock) return null; + + // The outer selector is the escaped utility class. We walk the body to + // collect variants from each nesting level + the innermost declarations. + const body = outerBlock.inner; + + const variants: Variant[] = []; + const declarations: Declaration[] = []; + walkNested(body, variants, declarations); + + return { utility, declarations, variants }; +} + +/** + * Walk a CSS body collecting declarations directly at this level and + * recursing into nested at-rules / `&`-prefixed selector rules. Each + * recursion level pushes one `Variant` describing the nesting it represents. + * + * - Pure declarations (`prop: value;`) at the current level go into `declarations`. + * - Nested `& { ... }` blocks add a selector variant and recurse. + * - Nested `@media (...) { ... }`, `@container (...)`, `@supports (...)` add + * the corresponding at-rule variant and recurse. + */ +function walkNested(body: string, variants: Variant[], declarations: Declaration[]): void { + let i = 0; + const n = body.length; + + while (i < n) { + while (i < n && /[\s;]/.test(body[i]!)) i++; + if (i >= n) break; + + if (body[i] === '@') { + const headerEnd = body.indexOf('{', i); + if (headerEnd === -1) break; + const header = body.slice(i, headerEnd).trim(); + const m = header.match(/^@([\w-]+)\s*([\s\S]*)$/); + if (!m) break; + const [, name, params] = m; + const block = readBalancedBlock(body, headerEnd); + if (!block) break; + + variants.push({ + kind: name === 'media' ? 'media' : name === 'container' ? 'container' : 'supports', + atRule: { name: name!, params: params!.trim() }, + raw: `@${name} ${params}`, + }); + walkNested(block.inner.trim(), variants, declarations); + i = block.end + 1; + continue; + } + + if (body[i] === '&') { + const headerEnd = body.indexOf('{', i); + if (headerEnd === -1) break; + // Preserve the leading character after `&` so `& *` (descendant) doesn't + // get folded down to bare `*` (which classifies as 'parent'). + const rawTail = body.slice(i + 1, headerEnd); + const trimmedRight = rawTail.replace(/\s+$/, ''); + const isDescendant = /^\s/.test(rawTail); + const selectorTail = isDescendant ? ` ${trimmedRight.trim()}` : trimmedRight.trim(); + const block = readBalancedBlock(body, headerEnd); + if (!block) break; + + variants.push(classifySelectorTail(selectorTail)); + walkNested(block.inner.trim(), variants, declarations); + i = block.end + 1; + continue; + } + + // Plain declaration `prop: value;` — read with bracket-balance awareness + // so `var()`, `calc()`, `oklch(from var(--x) ...)` survive intact. + const propStart = i; + let colonIdx = -1; + while (i < n && body[i] !== ';' && body[i] !== '{') { + if (body[i] === ':' && colonIdx === -1) colonIdx = i; + i++; + } + if (body[i] === '{') break; + if (colonIdx === -1) { + if (body[i] === ';') i++; + continue; + } + + const property = body.slice(propStart, colonIdx).trim(); + let valueEnd = colonIdx + 1; + let depth = 0; + let quote: string | null = null; + while (valueEnd < n) { + const c = body[valueEnd]!; + if (quote) { + if (c === '\\') { + valueEnd += 2; + continue; + } + if (c === quote) quote = null; + valueEnd++; + continue; + } + if (c === '"' || c === "'") { + quote = c; + valueEnd++; + continue; + } + if (c === '(' || c === '{' || c === '[') { + depth++; + valueEnd++; + continue; + } + if (c === ')' || c === '}' || c === ']') { + depth--; + valueEnd++; + continue; + } + if (c === ';' && depth === 0) break; + valueEnd++; + } + const value = body.slice(colonIdx + 1, valueEnd).trim(); + if (property && value) declarations.push({ property, value }); + i = valueEnd; + if (body[i] === ';') i++; + } +} + +interface BalancedBlock { + inner: string; + end: number; +} + +function readBalancedBlock(body: string, openIdx: number): BalancedBlock | null { + let depth = 0; + for (let i = openIdx; i < body.length; i++) { + const c = body[i]; + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) return { inner: body.slice(openIdx + 1, i), end: i }; + } + } + return null; +} + +function classifySelectorTail(tail: string): Variant { + if (/^:is\(:where\(\.group/.test(tail)) return { kind: 'group', selector: tail, raw: tail }; + if (/^:is\(:where\(\.peer/.test(tail)) return { kind: 'peer', selector: tail, raw: tail }; + if (tail.startsWith('[')) return { kind: 'attribute', selector: tail, raw: tail }; + if (tail.startsWith(':')) return { kind: 'pseudo', selector: tail, raw: tail }; + if (tail.startsWith('>') || tail.startsWith('+') || tail.startsWith('~') || tail.startsWith(' ')) { + return { kind: 'descendant', selector: tail, raw: tail }; + } + return { kind: 'parent', selector: tail, raw: tail }; +} diff --git a/packages/compiler/src/tailwind/design-system.ts b/packages/compiler/src/tailwind/design-system.ts new file mode 100644 index 00000000..f51bd2f5 --- /dev/null +++ b/packages/compiler/src/tailwind/design-system.ts @@ -0,0 +1,127 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { __unstable__loadDesignSystem } from 'tailwindcss'; + +/** + * A loaded Tailwind v4 design system. Wraps Tailwind's + * `__unstable__loadDesignSystem` return value with a small surface focused on + * what `decompose` needs: + * + * - `compileUtility(name)` — compile a single utility class to CSS, or + * `null` if Tailwind doesn't recognize it. + * + * Internally caches per-utility output so repeated lookups (the same utility + * referenced on many JSX elements) don't re-walk Tailwind's pipeline. + */ +export interface DesignSystem { + /** The path the design system was loaded from, for diagnostics. */ + readonly cssPath: string; + /** Compile a single utility class to CSS. Returns `null` for unknown candidates. */ + compileUtility(utility: string): string | null; +} + +/** + * Load a design system from a Tailwind v4 entry CSS file. `@import "tailwindcss"` + * and other `@import` directives resolve via Tailwind's own `loadStylesheet` + * callback (the recommended hook for the v4 design-system loader). + */ +export async function loadDesignSystem(cssPath: string): Promise { + const absolute = resolve(cssPath); + const raw = readFileSync(absolute, 'utf8'); + + const ds = await __unstable__loadDesignSystem(raw, { + base: dirname(absolute), + loadStylesheet: async (id, base) => { + const resolved = resolveStylesheet(id, base); + return { + path: resolved, + base: dirname(resolved), + content: readFileSync(resolved, 'utf8'), + }; + }, + }); + + const cache = new Map(); + + return { + cssPath: absolute, + compileUtility(utility: string): string | null { + const cached = cache.get(utility); + if (cached !== undefined) return cached; + const [css] = ds.candidatesToCss([utility]); + const value = css ?? null; + cache.set(utility, value); + return value; + }, + }; +} + +/** + * Resolve a stylesheet `@import` against the calling file's base directory. + * + * - Bare specifiers (`tailwindcss`, `@some/pkg/file.css`) walk node_modules + * up from `base` until found. + * - Relative / absolute paths resolve as-is. + */ +function resolveStylesheet(id: string, base: string): string { + if (isAbsolute(id)) return id; + if (id.startsWith('.')) return resolve(base, id); + + // Walk up node_modules from `base` first (the file's own directory), + // then fall back to walking up from the compiler package itself — + // covers temp-dir test fixtures that have no local node_modules. + const fromBase = walkUpForPackage(id, base); + if (fromBase) return fromBase; + + const compilerDir = dirname(fileURLToPath(import.meta.url)); + const fromCompiler = walkUpForPackage(id, compilerDir); + if (fromCompiler) return fromCompiler; + + throw new Error(`Cannot resolve stylesheet '${id}' from '${base}'`); +} + +function walkUpForPackage(id: string, start: string): string | null { + let dir = start; + while (true) { + const candidate = resolve(dir, 'node_modules', id); + if (existsSync(candidate)) return resolveBareEntry(candidate); + if (existsSync(`${candidate}.css`)) return `${candidate}.css`; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * For a resolved package directory, return the CSS entry — either via the + * `style` / `exports['.'].style` / `main` field in `package.json`, falling + * back to `index.css`. + */ +function resolveBareEntry(pkgDir: string): string { + const pkgJson = resolve(pkgDir, 'package.json'); + if (existsSync(pkgJson)) { + try { + const pkg = JSON.parse(readFileSync(pkgJson, 'utf8')) as { + style?: string; + main?: string; + exports?: Record | string; + }; + const exportsField = pkg.exports; + if (typeof exportsField === 'string') return resolve(pkgDir, exportsField); + if (exportsField && typeof exportsField === 'object' && '.' in exportsField) { + const root = (exportsField as Record)['.']; + const style = + root && typeof root === 'object' && 'style' in root ? (root as Record).style : undefined; + if (style) return resolve(pkgDir, style); + } + if (pkg.style) return resolve(pkgDir, pkg.style); + if (pkg.main?.endsWith('.css')) return resolve(pkgDir, pkg.main); + } catch { + // fall through + } + } + const fallback = resolve(pkgDir, 'index.css'); + if (existsSync(fallback)) return fallback; + return pkgDir; +} diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts new file mode 100644 index 00000000..f424665f --- /dev/null +++ b/packages/compiler/src/tailwind/emit.ts @@ -0,0 +1,570 @@ +import { isAbsolute, resolve } from 'node:path'; +import { bundleAsync } from 'lightningcss'; +import type { Declaration, UtilityCss } from './decompose'; + +/** A compiled rule: a class name + the utility's declarations and variants. */ +export interface CompiledRule { + /** Final CSS class name (e.g. `play-button`). */ + className: string; + /** Declarations + variants extracted from the utility. */ + utility: UtilityCss; + /** + * Optional grouping key for `mode: 'split'`. Rules with the same `bag` + * end up in the same `.css` file. Ignored in merged mode. + */ + bag?: string; +} + +/** Output of `emitCss`. Discriminated by `kind`. */ +export type EmittedCss = { kind: 'merged'; css: string } | { kind: 'split'; index: string; bags: Map }; + +/** + * Hoist configuration. When provided, every CSS custom property declaration + * whose `(name, value)` is uniform across every rule that emits it is lifted + * to a single rule on `rootSelector` and dropped from each individual rule. + * Catches Tailwind's internal `--tw-*` resets (always-the-same-default) plus + * any user-set tokens that happen to be uniform. + * + * Pass `false` to disable. If `hoist` is omitted, `emitCss` does **not** + * hoist — callers opt in by configuration. + */ +export interface HoistOptions { + /** Selector to attach hoisted declarations to (e.g. `.media-default-skin`). */ + rootSelector: string; +} + +export interface EmitCssOptions { + /** Compiled rules to emit. */ + rules: readonly CompiledRule[]; + /** + * Layout mode: + * - `'merged'` (default): one CSS string with all rules. + * - `'split'`: one string per `bag` plus an `index` string with + * `@import` lines for each bag in stable order. + */ + mode?: 'merged' | 'split'; + /** + * Optional list of CSS files to prepend to the output (verbatim, after + * `@import` resolution via Lightning CSS). In `'split'` mode they go into + * `index` only, not duplicated across bags. + */ + baseCss?: readonly string[]; + /** + * Directory relative `baseCss` paths resolve against. Defaults to `cwd`. + */ + configDir?: string; + /** + * Hoist uniform `--` declarations to a single root rule. + * See `HoistOptions`. Pass `false` (or omit) to leave declarations on + * each rule. + */ + hoist?: false | HoistOptions; + /** + * Inline matching CSS custom properties into the values that reference + * them, then drop the declarations themselves. Useful for stripping + * Tailwind's internal `--tw-*` slots from the final output. + * + * - `true` — inline `--tw-*` (regex `/^--tw-/`). + * - `RegExp` — inline any `--name` whose name (excluding the leading + * `--`) matches. + * - omitted — no inlining. + * + * Resolution is per-rule: the setter for a property must live in the + * same merged rule (root or nested) as the reference for inlining to + * apply. References to unset matching properties keep their `var()` + * fallback if present, otherwise they're left alone. + */ + inlineVars?: true | RegExp; +} + +/** + * Compose `CompiledRule[]` into final CSS. Rules sharing the same emit + * context (same selector chain + at-rule wrappers) merge their declarations + * into a single CSS rule. Selectors that produce identical declaration sets + * collapse into a comma-separated selector list. + * + * `baseCss` files are read via `lightningcss.bundleAsync` so their `@import` + * chains flatten before prepending — the consumer's `tailwind.css` (or + * any other base file) lands at the top of the output as a single block. + */ +export async function emitCss(opts: EmitCssOptions): Promise { + const mode = opts.mode ?? 'merged'; + const configDir = opts.configDir ?? process.cwd(); + + const hoist = opts.hoist === false ? undefined : opts.hoist; + const inlineVars = normalizeInlineMatcher(opts.inlineVars); + + if (mode === 'merged') { + const base = await bundleBaseCss(opts.baseCss ?? [], configDir); + const body = composeRules(opts.rules, hoist, inlineVars); + return { kind: 'merged', css: joinSections(base, body) }; + } + + // Split mode: group rules by `bag`. + const byBag = new Map(); + for (const rule of opts.rules) { + const bag = rule.bag ?? ''; + const arr = byBag.get(bag) ?? []; + arr.push(rule); + byBag.set(bag, arr); + } + + const bags = new Map(); + const importLines: string[] = []; + // Sort bag names for deterministic output. + const sortedBags = [...byBag.keys()].sort(); + for (const bagName of sortedBags) { + const bagRules = byBag.get(bagName)!; + bags.set(bagName, composeRules(bagRules, undefined, inlineVars)); + importLines.push(`@import "./${bagName || 'index'}.css";`); + } + + const base = await bundleBaseCss(opts.baseCss ?? [], configDir); + const index = joinSections(base, importLines.join('\n')); + return { kind: 'split', index, bags }; +} + +/** Internal: read each `baseCss` file via Lightning CSS, return concatenated string. */ +async function bundleBaseCss(paths: readonly string[], configDir: string): Promise { + if (paths.length === 0) return ''; + const decoder = new TextDecoder(); + const out: string[] = []; + for (const p of paths) { + const filename = isAbsolute(p) ? p : resolve(configDir, p); + const result = await bundleAsync({ filename }); + out.push(decoder.decode(result.code).trim()); + } + return out.join('\n\n'); +} + +/** Joins non-empty sections with two newlines. */ +function joinSections(...sections: string[]): string { + return sections.filter((s) => s.length > 0).join('\n\n'); +} + +/* ───────────────────────────────────────────────────────────────────────── + * Rule composition + * ───────────────────────────────────────────────────────────────────────── */ + +/** + * Per-rule emit unit: a (selector, at-rule path) tuple maps to one or more + * declarations. We bucket by `(atRulePath, selector)` so multiple rules + * landing on the same selector + same at-rule wrappers merge their + * declarations, and selectors with identical declaration sets fold into a + * comma-separated list. + */ +interface EmitUnit { + /** Outer-to-inner at-rule wrappers (e.g. [`@media (hover: hover)`]). */ + atRulePath: readonly string[]; + /** Selector text for the rule (e.g. `.play-button:hover` or `.play-button`). */ + selector: string; + /** Declarations to emit inside the rule. */ + declarations: readonly Declaration[]; +} + +function composeRules( + rules: readonly CompiledRule[], + hoist: HoistOptions | undefined, + inlineVars: RegExp | undefined +): string { + // Step 1: turn each CompiledRule into one EmitUnit. + const units: EmitUnit[] = []; + for (const rule of rules) { + units.push(buildEmitUnit(rule)); + } + + // Step 2: merge units by (atRulePath, selector). Dedupe declarations by + // `(property, value)` since multiple utilities (e.g. `flex` + `items-center` + // both setting `align-items: center`) can produce identical declarations. + const merged = new Map }>(); + for (const u of units) { + const key = `${u.atRulePath.join('||')}\n${u.selector}`; + let entry = merged.get(key); + if (!entry) { + entry = { + atRulePath: u.atRulePath, + selector: u.selector, + declarations: [], + declSet: new Set(), + }; + merged.set(key, entry); + } + for (const d of u.declarations) { + const dk = `${d.property}:${d.value}`; + if (entry.declSet.has(dk)) continue; + entry.declSet.add(dk); + entry.declarations.push(d); + } + } + + // Step 2.5 (optional): hoist uniform CSS variable declarations to a single + // root rule. See `applyHoist` for the conformance rule. + if (hoist) applyHoist(merged, hoist.rootSelector); + + // Step 2.6 (optional): inline matching CSS custom properties into their + // consumers, then drop the matching declarations. Pulls setters from the + // consumer's own rule plus the hoist root (when set) so consumers in + // separate rules from the original setter still resolve. + if (inlineVars) applyInline(merged, inlineVars, hoist?.rootSelector); + + // Step 3: collapse units that share the same (atRulePath, declarations) into + // a comma-separated selector list. Sort the declaration set to make the + // collapse key stable. Skip units whose declarations were entirely hoisted. + const collapsed = new Map< + string, + { atRulePath: readonly string[]; selectors: string[]; declarations: readonly Declaration[] } + >(); + for (const u of merged.values()) { + if (u.declarations.length === 0) continue; + const declKey = sortDeclarations(u.declarations) + .map((d) => `${d.property}:${d.value}`) + .join(';'); + const key = `${u.atRulePath.join('||')}\n${declKey}`; + const existing = collapsed.get(key); + if (existing) { + if (!existing.selectors.includes(u.selector)) existing.selectors.push(u.selector); + } else { + collapsed.set(key, { + atRulePath: u.atRulePath, + selectors: [u.selector], + declarations: sortDeclarations(u.declarations), + }); + } + } + + // Step 4: sort selectors within each entry, then sort entries by at-rule + // path depth + selector for stable output. The hoist root (if any) sorts + // first inside its at-rule depth so cascade-dependent vars declare before + // consumers that read them. + const rootSelector = hoist?.rootSelector; + for (const entry of collapsed.values()) entry.selectors.sort(); + const entries = [...collapsed.values()].sort((a, b) => { + const depthDelta = a.atRulePath.length - b.atRulePath.length; + if (depthDelta !== 0) return depthDelta; + const atDelta = a.atRulePath.join('||').localeCompare(b.atRulePath.join('||')); + if (atDelta !== 0) return atDelta; + if (rootSelector) { + const aIsRoot = a.selectors.includes(rootSelector); + const bIsRoot = b.selectors.includes(rootSelector); + if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1; + } + return a.selectors[0]!.localeCompare(b.selectors[0]!); + }); + + // Step 5: emit. At-rule wrappers nest from outer to inner; rule body lists + // selectors comma-separated and declarations one per line. + return entries.map(serializeEntry).join('\n\n'); +} + +/** + * Walk every merged unit and hoist CSS custom property declarations whose + * `(name, value)` is uniform across every root-depth occurrence (and matches + * any nested-depth occurrence too). The hoisted declarations attach to a + * synthetic / merged-into `rootSelector` unit at root depth. + * + * Conformance rules: + * + * 1. The property must appear at **root depth at least once** — purely + * contextual values (`@media` / pseudo-only) stay where they are. + * 2. **Every** root-depth occurrence must agree on the same value. + * 3. **Every** nested-depth occurrence must also agree on that same value + * — otherwise the nested override is meaningful and we'd mis-hoist it + * to root. + * + * Mutates `merged` in place. + */ +function applyHoist( + merged: Map }>, + rootSelector: string +): void { + // Collect (property, value) per depth bucket so we can apply the + // root-vs-nested conformance rule. + const rootValues = new Map>(); + const allValues = new Map>(); + for (const entry of merged.values()) { + const isRoot = entry.atRulePath.length === 0; + for (const d of entry.declarations) { + if (!d.property.startsWith('--')) continue; + const all = allValues.get(d.property) ?? new Set(); + all.add(d.value); + allValues.set(d.property, all); + if (isRoot) { + const at = rootValues.get(d.property) ?? new Set(); + at.add(d.value); + rootValues.set(d.property, at); + } + } + } + + // Hoist candidates: (1) appears at root depth, (2) all root-depth + // occurrences agree, (3) all occurrences (root + nested) agree. + const hoisted = new Map(); + for (const [name, atRoot] of rootValues.entries()) { + if (atRoot.size !== 1) continue; + const all = allValues.get(name)!; + if (all.size !== 1) continue; + hoisted.set(name, [...atRoot][0]!); + } + if (hoisted.size === 0) return; + + // Drop hoisted declarations from every existing unit. We only touch + // matching `(property, value)` pairs so future divergence (where a + // future nested occurrence sets a different value) wouldn't accidentally + // get stripped here. + for (const entry of merged.values()) { + const next: Declaration[] = []; + const nextSet = new Set(); + for (const d of entry.declarations) { + if (hoisted.get(d.property) === d.value) continue; + next.push(d); + nextSet.add(`${d.property}:${d.value}`); + } + entry.declarations = next; + entry.declSet = nextSet; + } + + // Merge hoisted declarations into / create the root unit at root depth. + const rootKey = `\n${rootSelector}`; + let rootEntry = merged.get(rootKey); + if (!rootEntry) { + rootEntry = { + atRulePath: [], + selector: rootSelector, + declarations: [], + declSet: new Set(), + }; + merged.set(rootKey, rootEntry); + } + for (const [property, value] of hoisted.entries()) { + const dk = `${property}:${value}`; + if (rootEntry.declSet.has(dk)) continue; + rootEntry.declSet.add(dk); + rootEntry.declarations.push({ property, value }); + } +} + +function normalizeInlineMatcher(opt: true | RegExp | undefined): RegExp | undefined { + if (opt === undefined) return undefined; + if (opt === true) return /^--tw-/; + return opt; +} + +/** + * Inline matching CSS custom properties into the values that reference them, + * then drop the matching declarations. + * + * Setter resolution order, narrowest first: + * + * 1. Setters declared in the same merged rule as the reference. + * 2. Setters declared on the hoist root rule (`hoistRootSelector`), if any. + * + * This mirrors the natural cascade — local declarations win, and hoist-root + * declarations act as the skin's defaults. Cycles break after a fixed-point + * pass. + * + * Mutates `merged` in place. + */ +function applyInline( + merged: Map }>, + match: RegExp, + hoistRootSelector: string | undefined +): void { + // Pull root-scope setters once. They serve as fallback when a consumer + // rule doesn't declare the property locally. + const rootSetters = new Map(); + if (hoistRootSelector !== undefined) { + const rootEntry = merged.get(`\n${hoistRootSelector}`); + if (rootEntry) { + for (const d of rootEntry.declarations) { + if (d.property.startsWith('--') && match.test(d.property)) { + rootSetters.set(d.property, d.value); + } + } + // Resolve root setters internally so chains within the root collapse. + resolveSettersInPlace(rootSetters, match); + } + } + + for (const entry of merged.values()) { + const isRoot = hoistRootSelector !== undefined && entry.selector === hoistRootSelector; + const localSetters = new Map(); + for (const d of entry.declarations) { + if (d.property.startsWith('--') && match.test(d.property)) { + localSetters.set(d.property, d.value); + } + } + + // Effective setters: rule-local first, hoist root as fallback. + const setters = new Map(rootSetters); + for (const [name, value] of localSetters) setters.set(name, value); + resolveSettersInPlace(setters, match); + + const next: Declaration[] = []; + const nextSet = new Set(); + for (const d of entry.declarations) { + if (d.property.startsWith('--') && match.test(d.property)) { + // The hoist root is where matching setters live for the rest of the + // file to inline — drop it from the root unit too, since by now + // every consumer has substituted its value. This leaves the root + // free of `--tw-*` declarations, matching the spike's wipe. + if (isRoot) continue; + // Non-root rules also drop matching setters: their value has been + // baked into the consumers above. + continue; + } + const inlined = inlineValue(d.value, setters, match); + next.push({ property: d.property, value: inlined }); + nextSet.add(`${d.property}:${inlined}`); + } + entry.declarations = next; + entry.declSet = nextSet; + } +} + +/** + * Resolve a `setters` map to a fixed point so values that reference other + * matching properties substitute recursively. Mutates the map in place. + */ +function resolveSettersInPlace(setters: Map, match: RegExp): void { + if (setters.size === 0) return; + for (let pass = 0; pass < 10; pass++) { + let changed = false; + for (const [name, value] of setters.entries()) { + const next = inlineValue(value, setters, match); + if (next !== value) { + setters.set(name, next); + changed = true; + } + } + if (!changed) break; + } +} + +/** + * Replace every `var(--name)` / `var(--name, fallback)` reference in `value` + * where `--name` matches `match` AND has an entry in `setters`. References to + * unset matching properties collapse to their `var()` fallback if present, + * else stay as `var(...)` (the runtime CSS engine will resolve them — or + * not — at use time). + */ +function inlineValue(value: string, setters: Map, match: RegExp): string { + let out = ''; + let i = 0; + while (i < value.length) { + const start = value.indexOf('var(', i); + if (start === -1) { + out += value.slice(i); + break; + } + out += value.slice(i, start); + + // Find the matching closing paren, accounting for nested `var()`. + let depth = 1; + let j = start + 4; + while (j < value.length && depth > 0) { + const c = value[j]!; + if (c === '(') depth++; + else if (c === ')') depth--; + if (depth === 0) break; + j++; + } + if (depth !== 0) { + // Unclosed `var()` — bail and emit the remainder verbatim. + out += value.slice(start); + break; + } + const inner = value.slice(start + 4, j); + const replacement = resolveVarRef(inner, setters, match); + out += replacement; + i = j + 1; + } + return out; +} + +/** + * Resolve a single `var()` argument list (the bit inside the parens). Returns + * the substituted string. If the property doesn't match or isn't set, returns + * the original `var()` text. + */ +function resolveVarRef(inner: string, setters: Map, match: RegExp): string { + const commaIdx = findTopLevelComma(inner); + const name = (commaIdx === -1 ? inner : inner.slice(0, commaIdx)).trim(); + const fallback = commaIdx === -1 ? undefined : inner.slice(commaIdx + 1).trim(); + + if (!name.startsWith('--')) return `var(${inner})`; + + if (match.test(name)) { + if (setters.has(name)) { + // Recurse so a value containing further `var(...)` references resolves. + return inlineValue(setters.get(name)!, setters, match); + } + if (fallback !== undefined) { + // Inline the fallback (it may itself reference vars we should resolve). + return inlineValue(fallback, setters, match); + } + // Unset, no fallback — leave the reference for the browser to try. + return `var(${inner})`; + } + + // Property doesn't match the inline filter. Still process the fallback so + // nested `var(--tw-x, var(--tw-y))` references inline through. + if (fallback === undefined) return `var(${inner})`; + return `var(${name}, ${inlineValue(fallback, setters, match)})`; +} + +function findTopLevelComma(s: string): number { + let depth = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]!; + if (c === '(') depth++; + else if (c === ')') depth--; + else if (c === ',' && depth === 0) return i; + } + return -1; +} + +function buildEmitUnit(rule: CompiledRule): EmitUnit { + const atRulePath: string[] = []; + let selectorTail = ''; + + for (const v of rule.utility.variants) { + if (v.atRule) { + atRulePath.push(`@${v.atRule.name} ${v.atRule.params}`.trim()); + } else if (v.selector) { + selectorTail += v.selector; + } + } + + return { + atRulePath, + selector: `.${rule.className}${selectorTail}`, + declarations: rule.utility.declarations, + }; +} + +function sortDeclarations(decls: readonly Declaration[]): readonly Declaration[] { + return [...decls].sort((a, b) => a.property.localeCompare(b.property)); +} + +function serializeEntry(entry: { + atRulePath: readonly string[]; + selectors: string[]; + declarations: readonly Declaration[]; +}): string { + const indent = (n: number): string => ' '.repeat(n); + const inner = serializeRule(entry.selectors, entry.declarations, entry.atRulePath.length); + let out = inner; + for (let i = entry.atRulePath.length - 1; i >= 0; i--) { + const wrapper = entry.atRulePath[i]!; + out = `${indent(i)}${wrapper} {\n${out}\n${indent(i)}}`; + } + return out; +} + +function serializeRule(selectors: readonly string[], declarations: readonly Declaration[], depth: number): string { + const indent = ' '.repeat(depth); + const inner = ' '.repeat(depth + 1); + const selectorList = selectors.join(`,\n${indent}`); + const declLines = declarations.map((d) => `${inner}${d.property}: ${d.value};`).join('\n'); + return `${indent}${selectorList} {\n${declLines}\n${indent}}`; +} diff --git a/packages/compiler/src/tailwind/evaluator.ts b/packages/compiler/src/tailwind/evaluator.ts new file mode 100644 index 00000000..d49ecfbe --- /dev/null +++ b/packages/compiler/src/tailwind/evaluator.ts @@ -0,0 +1,304 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import ts from 'typescript'; + +/** + * The shape a token module evaluates to: every leaf is a literal string, every + * branch is a plain object whose keys are property names. + * + * Token sources are constrained to a small grammar (imports + plain object + * literals + spreads + `cn(...)` of string-literal args + dotted access) so we + * can statically resolve them without running JS — see `loadTokenModule`. + */ +export type TokenValue = string | { readonly [key: string]: TokenValue }; + +/** + * Errors thrown when a token source uses syntax outside the supported grammar + * (function expressions, ternaries, spreads of non-objects, …). Messages + * include the file + line so consumers can point users at the offending decl. + */ +export class EvaluationError extends Error { + constructor(message: string) { + super(message); + this.name = 'EvaluationError'; + } +} + +/** + * Parse and evaluate a token module from disk. Returns an object whose keys + * are the module's named exports and whose values are `TokenValue` trees. + * + * Caches per absolute path. Recurses into relative imports. + */ +export function loadTokenModule(absolutePath: string): Record { + const cached = moduleCache.get(absolutePath); + if (cached) return cached; + + // Seed the cache with an empty record before recursing so cyclic imports + // see *something* instead of looping forever. The cycle is only legal if + // neither side actually reads through to the other during evaluation — + // the same rule TypeScript applies. + const exports: Record = {}; + moduleCache.set(absolutePath, exports); + + const source = readFileSync(absolutePath, 'utf8'); + const sourceFile = ts.createSourceFile(absolutePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + const env = new Map(); + + for (const stmt of sourceFile.statements) { + if (ts.isImportDeclaration(stmt)) { + processImport(stmt, absolutePath, env); + continue; + } + if (ts.isExportDeclaration(stmt)) { + processReExport(stmt, absolutePath, exports, env); + continue; + } + if (ts.isVariableStatement(stmt)) { + const isExport = (stmt.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + const value = evaluate(decl.initializer, env, absolutePath); + env.set(decl.name.text, value); + if (isExport) exports[decl.name.text] = value; + } + } + } + + return exports; +} + +const moduleCache = new Map>(); + +/** + * Reset the module cache. Test-only — production builds run once per process + * so the cache is effectively immutable. + */ +export function clearTokenModuleCache(): void { + moduleCache.clear(); +} + +/* ───────────────────────────────────────────────────────────────────────── + * Imports + * ───────────────────────────────────────────────────────────────────────── */ + +function processImport(stmt: ts.ImportDeclaration, fromFile: string, env: Map): void { + const specifier = stmt.moduleSpecifier; + if (!ts.isStringLiteral(specifier)) return; + const id = specifier.text; + const clause = stmt.importClause; + if (!clause) return; + + // Bare specifiers like `@videojs/utils/style` are ignored — we recognize + // `cn` as a built-in by callee name in `evaluateCall`. Anything else + // referenced from a token expression must be a relative import to a token + // module on disk. + if (!id.startsWith('.')) return; + + const importedPath = resolveRelativeModule(id, fromFile); + const imported = loadTokenModule(importedPath); + + if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { + env.set(clause.namedBindings.name.text, imported as TokenValue); + return; + } + + 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; + if (!(sourceName in imported)) { + throw new EvaluationError( + `Module '${importedPath}' has no export '${sourceName}' (imported as '${localName}')` + ); + } + env.set(localName, imported[sourceName]!); + } + } +} + +function processReExport( + stmt: ts.ExportDeclaration, + fromFile: string, + exports: Record, + env: Map +): void { + const specifier = stmt.moduleSpecifier; + if (!specifier || !ts.isStringLiteral(specifier)) return; + const id = specifier.text; + if (!id.startsWith('.')) return; + + const importedPath = resolveRelativeModule(id, fromFile); + const imported = loadTokenModule(importedPath); + + if (!stmt.exportClause) { + // `export * from './x'` — pull every export through. + for (const [name, value] of Object.entries(imported)) { + exports[name] = value; + env.set(name, value); + } + return; + } + + if (ts.isNamespaceExport(stmt.exportClause)) { + // `export * as ns from './x'` — bind the imported module's exports as a + // single namespace object under `ns`. + const name = stmt.exportClause.name.text; + exports[name] = imported as TokenValue; + env.set(name, imported as TokenValue); + return; + } + + if (ts.isNamedExports(stmt.exportClause)) { + for (const spec of stmt.exportClause.elements) { + const sourceName = spec.propertyName?.text ?? spec.name.text; + if (!(sourceName in imported)) { + throw new EvaluationError(`Module '${importedPath}' has no export '${sourceName}'`); + } + const value = imported[sourceName]!; + exports[spec.name.text] = value; + env.set(spec.name.text, value); + } + } +} + +/* ───────────────────────────────────────────────────────────────────────── + * Expression evaluation + * ───────────────────────────────────────────────────────────────────────── */ + +function evaluate(node: ts.Expression, env: Map, fromFile: string): TokenValue { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + if (ts.isIdentifier(node)) { + const v = env.get(node.text); + if (v === undefined) throw evalError(node, fromFile, `Unresolved identifier '${node.text}'`); + return v; + } + if (ts.isPropertyAccessExpression(node)) { + return evaluatePropertyAccess(node, env, fromFile); + } + if (ts.isObjectLiteralExpression(node)) { + return evaluateObject(node, env, fromFile); + } + if (ts.isCallExpression(node)) { + return evaluateCall(node, env, fromFile); + } + if (ts.isArrayLiteralExpression(node)) { + // Arrays only appear as `cn(...)` arguments. We model them as the + // space-join of their elements (matching `cn`'s `.flat()` semantics). + const parts: string[] = []; + for (const el of node.elements) { + const v = evaluate(el, env, fromFile); + if (typeof v === 'string') { + if (v) parts.push(v); + } else { + throw evalError(node, fromFile, 'Arrays in token expressions must contain strings only'); + } + } + return parts.join(' '); + } + if (ts.isParenthesizedExpression(node)) { + return evaluate(node.expression, env, fromFile); + } + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { + return evaluate(node.expression, env, fromFile); + } + throw evalError(node, fromFile, `Unsupported expression: ${ts.SyntaxKind[node.kind]}`); +} + +function evaluatePropertyAccess( + node: ts.PropertyAccessExpression, + env: Map, + fromFile: string +): TokenValue { + const root = evaluate(node.expression, env, fromFile); + if (typeof root === 'string') { + throw evalError(node, fromFile, `Cannot read property '${node.name.text}' of a string token`); + } + if (!ts.isIdentifier(node.name)) { + throw evalError(node, fromFile, 'Computed property access is not supported'); + } + const next = root[node.name.text]; + if (next === undefined) { + throw evalError(node, fromFile, `Property '${node.name.text}' does not exist on token`); + } + return next; +} + +function evaluateObject(node: ts.ObjectLiteralExpression, env: Map, fromFile: string): TokenValue { + const out: Record = {}; + for (const prop of node.properties) { + if (ts.isSpreadAssignment(prop)) { + const v = evaluate(prop.expression, env, fromFile); + if (typeof v === 'string') { + throw evalError(prop, fromFile, 'Spread of a string token is not supported'); + } + Object.assign(out, v); + continue; + } + if (ts.isPropertyAssignment(prop)) { + const key = readPropertyKey(prop.name, fromFile); + out[key] = evaluate(prop.initializer, env, fromFile); + continue; + } + if (ts.isShorthandPropertyAssignment(prop)) { + const v = env.get(prop.name.text); + if (v === undefined) throw evalError(prop, fromFile, `Unresolved identifier '${prop.name.text}'`); + out[prop.name.text] = v; + continue; + } + throw evalError(prop, fromFile, `Unsupported object property: ${ts.SyntaxKind[prop.kind]}`); + } + return out; +} + +function readPropertyKey(name: ts.PropertyName, fromFile: string): string { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text; + if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text; + if (ts.isNumericLiteral(name)) return name.text; + throw evalError(name, fromFile, 'Computed property keys are not supported'); +} + +function evaluateCall(node: ts.CallExpression, env: Map, fromFile: string): TokenValue { + if (!ts.isIdentifier(node.expression) || node.expression.text !== 'cn') { + throw evalError(node, fromFile, 'Only `cn(...)` calls are supported in token expressions'); + } + const parts: string[] = []; + for (const arg of node.arguments) { + const v = evaluate(arg, env, fromFile); + if (typeof v === 'string') { + if (v) parts.push(v); + continue; + } + throw evalError(arg, fromFile, 'cn() arguments must be strings or arrays of strings'); + } + return parts.join(' '); +} + +/* ───────────────────────────────────────────────────────────────────────── + * Module resolution + * ───────────────────────────────────────────────────────────────────────── */ + +const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const; + +/** Resolve a relative `./foo` / `../foo` specifier from `fromFile`. */ +function resolveRelativeModule(specifier: string, fromFile: string): string { + const base = isAbsolute(specifier) ? specifier : resolve(dirname(fromFile), specifier); + for (const ext of MODULE_EXTENSIONS) { + const candidate = `${base}${ext}`; + if (existsSync(candidate)) return candidate; + } + throw new EvaluationError(`Cannot resolve token module '${specifier}' from '${fromFile}'`); +} + +/* ───────────────────────────────────────────────────────────────────────── + * Diagnostics + * ───────────────────────────────────────────────────────────────────────── */ + +function evalError(node: ts.Node, fromFile: string, message: string): EvaluationError { + const sourceFile = node.getSourceFile(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + return new EvaluationError(`${message} (${fromFile}:${line + 1}:${character + 1})`); +} diff --git a/packages/compiler/src/tailwind/index.ts b/packages/compiler/src/tailwind/index.ts new file mode 100644 index 00000000..6056de0a --- /dev/null +++ b/packages/compiler/src/tailwind/index.ts @@ -0,0 +1,13 @@ +export { type Declaration, decompose, type UtilityCss, type Variant, type VariantKind } from './decompose'; +export { type DesignSystem, loadDesignSystem } from './design-system'; +export { type CompiledRule, type EmitCssOptions, type EmittedCss, emitCss, type HoistOptions } from './emit'; +export { clearTokenModuleCache, EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; +export { + type DeriveClassNameOptions, + type DerivedClassName, + DiagnosticError, + deriveClassName, + type NameContext, + type NameTransform, +} from './naming'; +export { type BagFor, type TailwindPluginOptions, type TailwindTarget, tailwindPlugin } from './plugin'; diff --git a/packages/compiler/src/tailwind/naming.ts b/packages/compiler/src/tailwind/naming.ts new file mode 100644 index 00000000..2737f72b --- /dev/null +++ b/packages/compiler/src/tailwind/naming.ts @@ -0,0 +1,181 @@ +import { kebabCase } from '@videojs/utils/string'; +import type ts from 'typescript'; +import type { JsxElementLike } from '../matchers'; +import { tagName } from '../matchers'; +import type { StyleSegment } from '../styles'; + +/** Result of deriving a CSS class name for a JSX element. */ +export interface DerivedClassName { + /** The full class name. */ + className: string; + /** Which derivation rule produced the name. */ + source: 'tag' | 'token-path' | 'override'; +} + +/** Context passed to a `NameTransform`. */ +export type NameContext = + | { + /** Derivation came from a JSX component tag. */ + source: 'tag'; + /** The original tag (e.g. `'Foo'` or `'Foo.Bar'`). */ + tag: string; + /** Default name the compiler would emit (kebab-cased, dotted parts joined with `-`). */ + defaultName: string; + } + | { + /** Derivation came from a dotted token reference on a bare HTML element. */ + source: 'token-path'; + /** The original token path (e.g. `['styles', 'foo', 'bar']`). */ + tokenPath: readonly string[]; + /** Default name the compiler would emit (leading namespace dropped, kebab-cased, joined with `-`). */ + defaultName: string; + }; + +/** + * Hook for transforming the derived class name. Receives the original input + * (tag or token path) plus the default kebab-cased name; returns the final + * class name. Identity by default. + */ +export type NameTransform = (context: NameContext) => string; + +export interface DeriveClassNameOptions { + /** The element whose class name we're deriving. */ + element: JsxElementLike; + /** + * Segments parsed from the element's `className` (when `kind: 'segments'`). + * Used as a fallback when the element is bare HTML. + */ + segments?: readonly StyleSegment[]; + /** + * Optional hook for shaping the final class name. Receives both the + * derivation source (tag or token path) and the default name; returns + * whatever class name the consumer wants. Defaults to identity. + */ + transformName?: NameTransform; + /** + * Per-tag or per-token-path overrides. Keyed by JSX tag (`'Foo'`, + * `'Foo.Bar'`) or by a dotted token path joined with `.` + * (`'styles.foo.bar'`); value is the literal class name to emit. + * Overrides win over `transformName`. + */ + overrides?: Record; +} + +/** + * Diagnostic thrown when no rule matches — typically a bare HTML element + * with arbitrary class strings and no token-path indication of intent. + * Resolution is up to the consumer (move classes onto a component, + * extract a token, add an override). + */ +export class DiagnosticError extends Error { + constructor( + message: string, + /** Source file the offending element lives in (best-effort). */ + public readonly fileName?: string, + /** Line number (1-based, best-effort). */ + public readonly line?: number + ) { + super(message); + this.name = 'DiagnosticError'; + } +} + +/** + * Derive a semantic CSS class name for a JSX element. + * + * Priority order: + * 1. **Override** — `overrides[tag]` or `overrides[token-path]` if set. + * 2. **JSX component tag** — kebab-cased, dotted parts joined with `-`. + * Result passed through `transformName` for final shaping. + * 3. **Token path** — for bare HTML elements with a single dotted token + * reference. Leading namespace identifier is dropped; remaining parts + * kebab-cased and joined with `-`. Result passed through `transformName`. + * 4. **Diagnostic** — no rule matched; throws `DiagnosticError`. + */ +export function deriveClassName(opts: DeriveClassNameOptions): DerivedClassName { + const overrides = opts.overrides ?? {}; + const transform: NameTransform = opts.transformName ?? ((ctx) => ctx.defaultName); + + const tag = tagName(opts.element); + + // 1. Override hit by tag. + if (overrides[tag]) return { className: overrides[tag]!, source: 'override' }; + + // 2. JSX component tag derivation. + if (isComponentTag(tag)) { + const defaultName = tagToDefaultName(tag); + const className = transform({ source: 'tag', tag, defaultName }); + return { className, source: 'tag' }; + } + + // 3. Token-path derivation. + if (opts.segments) { + const tokenPath = singleTokenPath(opts.segments); + if (tokenPath) { + const overrideKey = tokenPath.join('.'); + if (overrides[overrideKey]) return { className: overrides[overrideKey]!, source: 'override' }; + const defaultName = tokenPathToDefaultName(tokenPath); + if (defaultName) { + const className = transform({ source: 'token-path', tokenPath, defaultName }); + return { className, source: 'token-path' }; + } + } + } + + // 4. Diagnostic — no rule matched. + const loc = sourceLocation(opts.element); + throw new DiagnosticError( + `Cannot derive a CSS class name for <${tag}>${loc ? ` at ${loc.fileName}:${loc.line}` : ''}.\n` + + `Tag is bare HTML and the className doesn't reference a single token. ` + + `Resolve by: (a) using a JSX component instead of <${tag}>, ` + + `(b) extracting the classes into a single token reference, ` + + `or (c) adding an entry to \`overrides\`.`, + loc?.fileName, + loc?.line + ); +} + +function isComponentTag(tag: string): boolean { + // Component tags start uppercase; HTML tags are lowercase. + // For dotted tags we look at the first segment. + const head = tag.split('.')[0]!; + return /^[A-Z]/.test(head); +} + +function tagToDefaultName(tag: string): string { + return tag + .split('.') + .map((part) => kebabCase(part).replace(/^-/, '')) + .join('-'); +} + +function singleTokenPath(segments: readonly StyleSegment[]): readonly string[] | null { + // Accept a single token segment plus any number of literal segments + // (literals contribute utilities, the token names the class). Reject + // multiple token segments (ambiguous) or any opaque expression. + let tokenSegment: readonly string[] | null = null; + for (const seg of segments) { + if (seg.kind === 'literal') continue; + if (seg.kind === 'opaque') return null; + if (seg.kind === 'token') { + if (tokenSegment) return null; + tokenSegment = seg.path; + } + } + return tokenSegment; +} + +function tokenPathToDefaultName(path: readonly string[]): string | null { + // Drop the leading identifier (the namespace under which the tokens are + // imported) — it's not semantic. + if (path.length < 2) return null; + const meaningful = path.slice(1); + return meaningful.map((p) => kebabCase(p).replace(/^-/, '')).join('-'); +} + +function sourceLocation(node: ts.Node): { fileName: string; line: number } | null { + const sourceFile = node.getSourceFile?.(); + if (!sourceFile) return null; + const { line } = sourceFile.getLineAndCharacterOfPosition(node.pos); + return { fileName: sourceFile.fileName, line: line + 1 }; +} diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts new file mode 100644 index 00000000..186d93e4 --- /dev/null +++ b/packages/compiler/src/tailwind/plugin.ts @@ -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; + /** + * 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 { + 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 { + 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 { + 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()` + * 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 { + const env = new Map(); + 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; + 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 = ` 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): 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[] | 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 | 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 | 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 }; +} diff --git a/packages/compiler/src/tailwind/tests/decompose.test.ts b/packages/compiler/src/tailwind/tests/decompose.test.ts new file mode 100644 index 00000000..04eacbd3 --- /dev/null +++ b/packages/compiler/src/tailwind/tests/decompose.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { decompose } from '../decompose'; +import type { DesignSystem } from '../design-system'; +import { loadDesignSystem } from '../design-system'; + +let design: DesignSystem; + +const MINIMAL_CSS = ` +@import "tailwindcss"; + +@theme { + --color-brand: oklch(0.7 0.2 250); + --media-color-primary: oklch(1 0 0); +} +`; + +beforeAll(async () => { + const dir = mkdtempSync(join(tmpdir(), 'compiler-tw-')); + const cssPath = join(dir, 'tailwind.css'); + writeFileSync(cssPath, MINIMAL_CSS, 'utf8'); + design = await loadDesignSystem(cssPath); +}, 30_000); + +describe('decompose — base utilities', () => { + it('handles a plain utility', () => { + const r = decompose('flex', design); + expect(r).not.toBeNull(); + expect(r!.utility).toBe('flex'); + expect(r!.variants).toEqual([]); + expect(r!.declarations).toEqual([{ property: 'display', value: 'flex' }]); + }); + + it('handles a utility with multiple declarations', () => { + const r = decompose('p-4', design); + expect(r).not.toBeNull(); + // Tailwind v4 emits `padding: calc(var(--spacing) * 4);`. + const props = r!.declarations.map((d) => d.property); + expect(props).toContain('padding'); + }); + + it('returns null for unknown utilities', () => { + expect(decompose('not-a-real-utility', design)).toBeNull(); + }); +}); + +describe('decompose — variants', () => { + it('captures :hover as a pseudo variant', () => { + const r = decompose('hover:opacity-100', design); + expect(r).not.toBeNull(); + // Tailwind v4 nests `&:hover` *inside* `@media (hover: hover)` so we + // see both — pseudo for the selector tail, media for the hover gate. + const pseudo = r!.variants.find((v) => v.kind === 'pseudo'); + expect(pseudo).toBeDefined(); + expect(pseudo!.selector).toMatch(/:hover/); + const media = r!.variants.find((v) => v.kind === 'media'); + expect(media).toBeDefined(); + expect(media!.atRule!.params).toContain('hover'); + expect(r!.declarations[0]).toMatchObject({ property: 'opacity', value: '100%' }); + }); + + it('captures :focus-visible as a pseudo variant', () => { + const r = decompose('focus-visible:outline-current', design); + expect(r).not.toBeNull(); + expect(r!.variants[0]!.kind).toBe('pseudo'); + expect(r!.variants[0]!.selector).toMatch(/:focus-visible/); + }); + + it('captures @media-style at-rule wrappers', () => { + const r = decompose('motion-reduce:opacity-50', design); + expect(r).not.toBeNull(); + const media = r!.variants.find((v) => v.kind === 'media'); + expect(media).toBeDefined(); + expect(media!.atRule!.params).toContain('reduce'); + }); + + it('captures attribute-selector variants from data-[…]', () => { + const r = decompose('data-[state=open]:opacity-100', design); + expect(r).not.toBeNull(); + const attr = r!.variants.find((v) => v.kind === 'attribute'); + expect(attr).toBeDefined(); + expect(attr!.selector).toContain('[data-state='); + }); + + it('captures group-data variants', () => { + const r = decompose('group-data-paused:opacity-100', design); + expect(r).not.toBeNull(); + const grp = r!.variants.find((v) => v.kind === 'group'); + expect(grp).toBeDefined(); + expect(grp!.selector).toContain('group'); + }); +}); + +describe('decompose — caching', () => { + it('returns the same compiled CSS on repeat lookups (DesignSystem cache)', () => { + const a = design.compileUtility('flex'); + const b = design.compileUtility('flex'); + expect(a).toBe(b); + }); +}); diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts new file mode 100644 index 00000000..9fc3be4c --- /dev/null +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -0,0 +1,477 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { CompiledRule } from '../emit'; +import { emitCss } from '../emit'; + +const collapse = (s: string): string => s.replace(/\s+/g, ''); + +function rule( + className: string, + declarations: { property: string; value: string }[], + variants: any[] = [], + bag?: string +): CompiledRule { + const utility = { utility: 'mock', declarations, variants }; + return bag === undefined ? { className, utility } : { className, utility, bag }; +} + +describe('emitCss — merged mode', () => { + it('emits a single rule for one CompiledRule', async () => { + const out = await emitCss({ + rules: [rule('foo', [{ property: 'display', value: 'flex' }])], + }); + expect(out.kind).toBe('merged'); + expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.foo{display:flex;}')); + }); + + it('merges declarations across rules sharing the same selector', async () => { + const out = await emitCss({ + rules: [rule('foo', [{ property: 'display', value: 'flex' }]), rule('foo', [{ property: 'gap', value: '1rem' }])], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.foo{display:flex;gap:1rem;}')); + }); + + it('collapses selectors with identical declarations into a comma list', async () => { + const out = await emitCss({ + rules: [ + rule('foo', [{ property: 'display', value: 'flex' }]), + rule('bar', [{ property: 'display', value: 'flex' }]), + ], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.bar,.foo{display:flex;}')); + }); + + it('wraps rules in @media at-rule from variants', async () => { + const variant = { + kind: 'media' as const, + atRule: { name: 'media', params: '(prefers-reduced-motion: reduce)' }, + raw: '@media (prefers-reduced-motion: reduce)', + }; + const out = await emitCss({ + rules: [rule('foo', [{ property: 'opacity', value: '0.5' }], [variant])], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain( + collapse('@media (prefers-reduced-motion: reduce){.foo{opacity:0.5;}}') + ); + }); + + it('appends selector variants to the class selector', async () => { + const variant = { kind: 'pseudo' as const, selector: ':hover', raw: ':hover' }; + const out = await emitCss({ + rules: [rule('foo', [{ property: 'opacity', value: '1' }], [variant])], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.foo:hover{opacity:1;}')); + }); + + it('composes nested at-rule + selector variants', async () => { + const variants = [ + { kind: 'pseudo' as const, selector: ':hover', raw: ':hover' }, + { + kind: 'media' as const, + atRule: { name: 'media', params: '(hover: hover)' }, + raw: '@media (hover: hover)', + }, + ]; + const out = await emitCss({ + rules: [rule('foo', [{ property: 'opacity', value: '1' }], variants)], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain( + collapse('@media (hover: hover){.foo:hover{opacity:1;}}') + ); + }); + + it('sorts declarations alphabetically for stable output', async () => { + const out = await emitCss({ + rules: [ + rule('foo', [ + { property: 'z-index', value: '1' }, + { property: 'color', value: 'red' }, + { property: 'display', value: 'flex' }, + ]), + ], + }); + expect(out.kind === 'merged' && out.css.match(/color/i)!.index! < out.css.match(/display/i)!.index!).toBe(true); + }); +}); + +describe('emitCss — split mode', () => { + it('groups rules by bag', async () => { + const out = await emitCss({ + mode: 'split', + rules: [ + rule('a', [{ property: 'color', value: 'red' }], [], 'one'), + rule('b', [{ property: 'color', value: 'blue' }], [], 'two'), + ], + }); + expect(out.kind).toBe('split'); + if (out.kind !== 'split') return; + expect(out.bags.size).toBe(2); + expect(collapse(out.bags.get('one')!)).toContain(collapse('.a{color:red;}')); + expect(collapse(out.bags.get('two')!)).toContain(collapse('.b{color:blue;}')); + }); + + it('emits an index with @import lines for each bag in stable order', async () => { + const out = await emitCss({ + mode: 'split', + rules: [ + rule('a', [{ property: 'color', value: 'red' }], [], 'two'), + rule('b', [{ property: 'color', value: 'blue' }], [], 'one'), + ], + }); + if (out.kind !== 'split') throw new Error('expected split'); + const oneIdx = out.index.indexOf('./one.css'); + const twoIdx = out.index.indexOf('./two.css'); + expect(oneIdx).toBeGreaterThan(-1); + expect(twoIdx).toBeGreaterThan(-1); + expect(oneIdx).toBeLessThan(twoIdx); + }); +}); + +describe('emitCss — baseCss prepend', () => { + it('prepends a single base CSS file (read via Lightning)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'emit-css-')); + const basePath = join(dir, 'base.css'); + writeFileSync(basePath, '.base { color: green; }', 'utf8'); + const out = await emitCss({ + rules: [rule('foo', [{ property: 'display', value: 'flex' }])], + baseCss: [basePath], + }); + expect(out.kind === 'merged' && out.css.indexOf('.base') < out.css.indexOf('.foo')).toBe(true); + }); + + it('flattens @import chains in the base file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'emit-css-import-')); + const inner = join(dir, 'inner.css'); + const outer = join(dir, 'outer.css'); + writeFileSync(inner, '.from-inner { color: blue; }', 'utf8'); + writeFileSync(outer, '@import "./inner.css";\n.from-outer { color: red; }', 'utf8'); + const out = await emitCss({ + rules: [rule('foo', [{ property: 'display', value: 'flex' }])], + baseCss: [outer], + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toContain('.from-inner'); + expect(out.css).toContain('.from-outer'); + expect(out.css).not.toContain('@import'); + }); + + it('puts baseCss in index only (split mode), not duplicated across bags', async () => { + const dir = mkdtempSync(join(tmpdir(), 'emit-css-split-base-')); + const basePath = join(dir, 'base.css'); + writeFileSync(basePath, '.base { color: green; }', 'utf8'); + const out = await emitCss({ + mode: 'split', + rules: [ + rule('a', [{ property: 'color', value: 'red' }], [], 'one'), + rule('b', [{ property: 'color', value: 'blue' }], [], 'two'), + ], + baseCss: [basePath], + }); + if (out.kind !== 'split') throw new Error('expected split'); + expect(out.index).toContain('.base'); + expect(out.bags.get('one')!).not.toContain('.base'); + expect(out.bags.get('two')!).not.toContain('.base'); + }); + + it('resolves relative baseCss paths against configDir', async () => { + const dir = mkdtempSync(join(tmpdir(), 'emit-css-relpath-')); + writeFileSync(join(dir, 'base.css'), '.base { color: green; }', 'utf8'); + const out = await emitCss({ + rules: [rule('foo', [{ property: 'display', value: 'flex' }])], + baseCss: ['./base.css'], + configDir: dir, + }); + expect(out.kind === 'merged' && out.css).toContain('.base'); + }); +}); + +describe('emitCss — hoist', () => { + it('hoists a CSS variable that is uniform across rules to the root selector', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'display', value: 'flex' }, + ]), + rule('b', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'color', value: 'red' }, + ]), + ], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain(collapse('.skin{--tw-border-style:none;}')); + expect(out.css).toMatch(/\.a\s*{\s*display:\s*flex;\s*}/); + expect(out.css).toMatch(/\.b\s*{\s*color:\s*red;\s*}/); + // Hoisted value should not appear in either per-rule body. + expect(/\.a\s*{[^}]*--tw-border-style/.test(out.css)).toBe(false); + expect(/\.b\s*{[^}]*--tw-border-style/.test(out.css)).toBe(false); + }); + + it('does not hoist a variable whose value differs across rules', async () => { + const out = await emitCss({ + rules: [ + rule('a', [{ property: '--tw-duration', value: '150ms' }]), + rule('b', [{ property: '--tw-duration', value: '300ms' }]), + ], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/\.a\s*{[^}]*--tw-duration:\s*150ms/); + expect(out.css).toMatch(/\.b\s*{[^}]*--tw-duration:\s*300ms/); + expect(/\.skin\s*{[^}]*--tw-duration/.test(out.css)).toBe(false); + }); + + it('merges hoisted declarations into an existing rule on the root selector', async () => { + const out = await emitCss({ + rules: [ + rule('skin', [{ property: 'display', value: 'block' }]), + rule('a', [{ property: '--tw-border-style', value: 'none' }]), + rule('b', [{ property: '--tw-border-style', value: 'none' }]), + ], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain(collapse('.skin{--tw-border-style:none;display:block;}')); + }); + + it('drops a rule whose declarations were entirely hoisted', async () => { + const out = await emitCss({ + rules: [ + rule('a', [{ property: '--tw-border-style', value: 'none' }]), + rule('b', [{ property: '--tw-border-style', value: 'none' }]), + ], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).not.toMatch(/\.a\s*{/); + expect(out.css).not.toMatch(/\.b\s*{/); + expect(collapse(out.css)).toContain(collapse('.skin{--tw-border-style:none;}')); + }); + + it('puts the hoist root rule first in the output', async () => { + const out = await emitCss({ + rules: [ + rule('z-last', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'display', value: 'flex' }, + ]), + rule('a-first', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'color', value: 'red' }, + ]), + ], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + const skinIdx = out.css.indexOf('.skin'); + const otherIdx = out.css.indexOf('.a-first'); + expect(skinIdx).toBeGreaterThanOrEqual(0); + expect(otherIdx).toBeGreaterThan(skinIdx); + }); + + it('leaves rules untouched when hoist is false', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'display', value: 'flex' }, + ]), + rule('b', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'color', value: 'red' }, + ]), + ], + hoist: false, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/\.a\s*{[^}]*--tw-border-style/); + expect(out.css).toMatch(/\.b\s*{[^}]*--tw-border-style/); + }); + + it('does not hoist a property that only appears inside an at-rule wrapper', async () => { + const motionVariant = { + kind: 'media' as const, + atRule: { name: 'media', params: '(prefers-reduced-motion: reduce)' }, + raw: '@media (prefers-reduced-motion: reduce)', + }; + const out = await emitCss({ + rules: [rule('a', [{ property: '--media-error-dialog-transition-duration', value: '50ms' }], [motionVariant])], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)/); + expect(/\.skin\s*{[^}]*--media-error-dialog-transition-duration/.test(out.css)).toBe(false); + }); + + it('only hoists CSS custom properties (no plain props)', async () => { + const out = await emitCss({ + rules: [rule('a', [{ property: 'color', value: 'red' }]), rule('b', [{ property: 'color', value: 'red' }])], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + // Plain `color` should remain on each rule, not hoist into `.skin`. + expect(out.css).toMatch(/\.a[^{]*{[^}]*color:\s*red/); + expect(out.css).toMatch(/\.b[^{]*{[^}]*color:\s*red/); + expect(/\.skin\s*{[^}]*color:\s*red/.test(out.css)).toBe(false); + }); +}); + +describe('emitCss — inlineVars', () => { + it('inlines a `--tw-*` reference and drops the setter declaration', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'border-style', value: 'var(--tw-border-style)' }, + ]), + ], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/border-style:\s*none/); + expect(out.css).not.toMatch(/--tw-border-style:/); + expect(out.css).not.toMatch(/var\(--tw-border-style/); + }); + + it('keeps `var()` fallback when the property is unset', async () => { + const out = await emitCss({ + rules: [rule('a', [{ property: 'border-style', value: 'var(--tw-border-style, solid)' }])], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/border-style:\s*solid/); + }); + + it('leaves the var() reference alone when no fallback is set', async () => { + const out = await emitCss({ + rules: [rule('a', [{ property: 'border-style', value: 'var(--tw-border-style)' }])], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/border-style:\s*var\(--tw-border-style\)/); + }); + + it('resolves nested setter chains', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-shadow-color', value: 'red' }, + { property: '--tw-shadow', value: 'inset 0 1px var(--tw-shadow-color)' }, + { property: 'box-shadow', value: 'var(--tw-shadow)' }, + ]), + ], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/box-shadow:\s*inset 0 1px red/); + expect(out.css).not.toMatch(/--tw-/); + }); + + it('does not inline non-matching properties (default --tw-* matcher)', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--media-color', value: 'red' }, + { property: 'color', value: 'var(--media-color)' }, + ]), + ], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/--media-color:\s*red/); + expect(out.css).toMatch(/color:\s*var\(--media-color\)/); + }); + + it('honours a custom RegExp matcher', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--media-color', value: 'red' }, + { property: 'color', value: 'var(--media-color)' }, + ]), + ], + inlineVars: /^--media-/, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/color:\s*red/); + expect(out.css).not.toMatch(/--media-color:/); + }); + + it('per-rule scope: a setter in one rule does not affect another', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-x', value: '1px' }, + { property: 'margin', value: 'var(--tw-x)' }, + ]), + rule('b', [{ property: 'padding', value: 'var(--tw-x)' }]), + ], + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/\.a\s*{\s*margin:\s*1px;?\s*}/); + expect(out.css).toMatch(/\.b\s*{\s*padding:\s*var\(--tw-x\);?\s*}/); + }); + + it('leaves rules untouched when inlineVars is omitted', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-border-style', value: 'none' }, + { property: 'border-style', value: 'var(--tw-border-style)' }, + ]), + ], + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/--tw-border-style:\s*none/); + expect(out.css).toMatch(/border-style:\s*var\(--tw-border-style\)/); + }); + + it('inlines a setter from the hoist root into a consumer in a separate rule', async () => { + const out = await emitCss({ + rules: [ + rule('a', [{ property: '--tw-outline-style', value: 'none' }]), + rule('b', [{ property: 'outline-style', value: 'var(--tw-outline-style)' }]), + ], + hoist: { rootSelector: '.skin' }, + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + // After hoist, --tw-outline-style: none lifts to .skin; inline then + // resolves consumers anywhere by reading from the root setters. + expect(out.css).toMatch(/\.b\s*{\s*outline-style:\s*none;?\s*}/); + expect(out.css).not.toMatch(/--tw-outline-style/); + }); + + it('runs alongside hoist; --tw-* gets resolved, other vars hoist', async () => { + const out = await emitCss({ + rules: [ + rule('a', [ + { property: '--tw-border-style', value: 'none' }, + { property: '--media-color', value: 'red' }, + { property: 'border-style', value: 'var(--tw-border-style)' }, + { property: 'color', value: 'var(--media-color)' }, + ]), + rule('b', [ + { property: '--tw-border-style', value: 'none' }, + { property: '--media-color', value: 'red' }, + { property: 'background', value: 'var(--media-color)' }, + ]), + ], + hoist: { rootSelector: '.skin' }, + inlineVars: true, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + // --tw-* gone everywhere. + expect(out.css).not.toMatch(/--tw-border-style/); + // --media-color still hoists since it's not in the matcher. + expect(collapse(out.css)).toContain(collapse('.skin{--media-color:red;}')); + // Consumers stay on their rules. + expect(out.css).toMatch(/\.a\s*{[^}]*border-style:\s*none/); + expect(out.css).toMatch(/\.a\s*{[^}]*color:\s*var\(--media-color\)/); + }); +}); diff --git a/packages/compiler/src/tailwind/tests/evaluator.test.ts b/packages/compiler/src/tailwind/tests/evaluator.test.ts new file mode 100644 index 00000000..0ed2f0a4 --- /dev/null +++ b/packages/compiler/src/tailwind/tests/evaluator.test.ts @@ -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/); + }); +}); diff --git a/packages/compiler/src/tailwind/tests/naming.test.ts b/packages/compiler/src/tailwind/tests/naming.test.ts new file mode 100644 index 00000000..02866074 --- /dev/null +++ b/packages/compiler/src/tailwind/tests/naming.test.ts @@ -0,0 +1,225 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import type { JsxElementLike } from '../../matchers'; +import { parse } from '../../parse'; +import type { StyleSegment } from '../../styles'; +import { DiagnosticError, deriveClassName } from '../naming'; + +/** Parse a tiny TSX snippet and return its first JsxElement / JsxSelfClosingElement. */ +function firstElement(source: string): JsxElementLike { + const { ast } = parse(`function App(){ return ${source}; }`); + let found: JsxElementLike | null = null; + const visit = (node: ts.Node): void => { + if (found) return; + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(ast, visit); + if (!found) throw new Error(`No JSX element in: ${source}`); + return found; +} + +const literal = (value: string): StyleSegment => ({ + kind: 'literal', + value, + node: null as never, +}); + +const token = (path: readonly string[]): StyleSegment => ({ + kind: 'token', + path, + node: null as never, +}); + +const opaque = (): StyleSegment => ({ kind: 'opaque', node: null as never }); + +describe('deriveClassName — tag derivation', () => { + it('kebab-cases a simple component tag', () => { + const r = deriveClassName({ element: firstElement(``) }); + expect(r.source).toBe('tag'); + expect(r.className).toBe('foo-bar'); + }); + + it('flattens compound tags', () => { + const r = deriveClassName({ element: firstElement(``) }); + expect(r.className).toBe('outer-inner'); + }); + + it('honours overrides keyed by tag', () => { + const r = deriveClassName({ + element: firstElement(``), + overrides: { XYZWidget: 'xyz-widget' }, + }); + expect(r.source).toBe('override'); + expect(r.className).toBe('xyz-widget'); + }); +}); + +describe('deriveClassName — token-path derivation', () => { + it('derives from a single token segment on a bare HTML element', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'fooBar'])], + }); + expect(r.source).toBe('token-path'); + expect(r.className).toBe('foo-bar'); + }); + + it('extends the path for multi-segment tails', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'fooBar', 'inner'])], + }); + expect(r.className).toBe('foo-bar-inner'); + }); + + it('drops the leading namespace identifier', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['tokens', 'foo'])], + }); + expect(r.className).toBe('foo'); + }); + + it('combines literal segments with a single token (token names the class)', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [literal('flex'), token(['styles', 'foo'])], + }); + expect(r.className).toBe('foo'); + }); + + it('throws on multiple tokens (ambiguous)', () => { + expect(() => + deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'a']), token(['styles', 'b'])], + }) + ).toThrow(DiagnosticError); + }); + + it('throws on an opaque expression next to a token', () => { + expect(() => + deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'a']), opaque()], + }) + ).toThrow(DiagnosticError); + }); + + it('honours overrides keyed by dotted token path', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'foo', 'bar'])], + overrides: { 'styles.foo.bar': 'special' }, + }); + expect(r.source).toBe('override'); + expect(r.className).toBe('special'); + }); +}); + +describe('deriveClassName — transformName', () => { + it('default is identity (returns defaultName as the class)', () => { + const r = deriveClassName({ element: firstElement(``) }); + expect(r.className).toBe('foo-bar'); + }); + + it('lets the consumer reshape the name (e.g. add a prefix)', () => { + const r = deriveClassName({ + element: firstElement(``), + transformName: (ctx) => `app-${ctx.defaultName}`, + }); + expect(r.className).toBe('app-foo-bar'); + }); + + it('lets the consumer drop a tail segment by inspecting the original tag', () => { + const r = deriveClassName({ + element: firstElement(``), + transformName: (ctx) => { + if (ctx.source === 'tag' && ctx.tag.endsWith('.Root')) { + return ctx.defaultName.replace(/-root$/, ''); + } + return ctx.defaultName; + }, + }); + expect(r.className).toBe('foo'); + }); + + it('exposes the token path so consumers can branch on token-path source', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'foo', 'root'])], + transformName: (ctx) => { + if (ctx.source === 'token-path' && ctx.tokenPath.at(-1) === 'root') { + return ctx.defaultName.replace(/-root$/, ''); + } + return ctx.defaultName; + }, + }); + expect(r.className).toBe('foo'); + }); + + it('overrides win over transformName', () => { + const r = deriveClassName({ + element: firstElement(``), + overrides: { FooBar: 'override-wins' }, + transformName: () => 'transform-wins', + }); + expect(r.className).toBe('override-wins'); + expect(r.source).toBe('override'); + }); + + it('transformName receives source = "tag" for tag derivation', () => { + let receivedSource: string | undefined; + deriveClassName({ + element: firstElement(``), + transformName: (ctx) => { + receivedSource = ctx.source; + return ctx.defaultName; + }, + }); + expect(receivedSource).toBe('tag'); + }); + + it('transformName receives source = "token-path" for token derivation', () => { + let receivedSource: string | undefined; + deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'foo'])], + transformName: (ctx) => { + receivedSource = ctx.source; + return ctx.defaultName; + }, + }); + expect(receivedSource).toBe('token-path'); + }); +}); + +describe('deriveClassName — diagnostics', () => { + it('throws DiagnosticError for a bare HTML element with no token reference', () => { + expect(() => + deriveClassName({ + element: firstElement(`
`), + segments: [literal('foo bar')], + }) + ).toThrow(DiagnosticError); + }); + + it('throws DiagnosticError when no segments are provided to a bare HTML element', () => { + expect(() => deriveClassName({ element: firstElement(``) })).toThrow(DiagnosticError); + }); + + it('includes the tag name in the error message', () => { + let caught: DiagnosticError | null = null; + try { + deriveClassName({ element: firstElement(`
`), segments: [literal('x')] }); + } catch (e) { + caught = e as DiagnosticError; + } + expect(caught).toBeInstanceOf(DiagnosticError); + expect(caught!.message).toContain('
'); + }); +}); diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts new file mode 100644 index 00000000..fd948478 --- /dev/null +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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
; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; }`; + 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 ; + }`; + 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(``)); + expect(collapse(code)).toContain(collapse(``)); + }); + + it('forwards CSS through onCss when set', async () => { + const source = `function App(){ return ; }`; + const cssPromise = new Promise((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;}')); + }); +}); diff --git a/packages/compiler/src/tests/compile.test.ts b/packages/compiler/src/tests/compile.test.ts new file mode 100644 index 00000000..54b28912 --- /dev/null +++ b/packages/compiler/src/tests/compile.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest'; +import { compile, parse } from '..'; +import { anyTag, byTag, hasChild } from '../matchers'; +import { addProp, childAsProp, replace, wrap } from '../react'; + +/** + * The TS printer emits `` (space before slash) — collapse all whitespace + * so test substrings can ignore that detail and focus on structure. + */ +const collapse = (s: string): string => s.replace(/\s+/g, ''); + +describe('parse', () => { + it('produces a TSX SourceFile with parent pointers set', () => { + const { ast } = parse('const x = ;'); + expect(ast.statements.length).toBe(1); + expect(ast.statements[0]!.parent).toBe(ast); + }); +}); + +describe('compile (no transforms)', () => { + it('round-trips a simple TSX module', () => { + const source = `import { Foo } from 'bar';\nexport function App() { return ; }\n`; + const { code } = compile(source, { target: 'react' }); + // Identifier and JSX preserved; quote/whitespace style is whatever the printer decides. + expect(code).toContain('Foo'); + expect(code).toContain('bar'); + expect(collapse(code)).toContain(collapse(`return;`)); + }); +}); + +describe('compile (transformImports — bare-string rule)', () => { + it('rewrites the module specifier and leaves identifiers untouched', () => { + const source = `import { PlayIcon } from '@videojs/icons/components';\nconst _x = PlayIcon;`; + const { code } = compile(source, { + target: 'react', + imports: { '@videojs/icons/components': '@videojs/icons/react' }, + }); + expect(code).toContain(`import { PlayIcon } from "@videojs/icons/react"`); + }); + + it('leaves unrelated imports untouched', () => { + const source = `import { Other } from 'unrelated';\nimport { PlayIcon } from '@videojs/icons/components';\nconst _ = [Other, PlayIcon];`; + const { code } = compile(source, { + target: 'react', + imports: { '@videojs/icons/components': '@videojs/icons/react' }, + }); + expect(code).toMatch(/from ['"]unrelated['"]/); + expect(code).toContain('PlayIcon'); + }); +}); + +describe('compile (transformImports — function rule)', () => { + it('rewrites per-identifier source and bucket-merges by resolved target', () => { + const source = `import { PlayButton, MuteButton } from '@videojs/core/components';\nconst _ = [PlayButton, MuteButton];`; + const { code } = compile(source, { + target: 'react', + imports: { + '@videojs/core/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }), + }, + }); + expect(code).toContain(`import { PlayButton } from "./ui/playbutton"`); + expect(code).toContain(`import { MuteButton } from "./ui/mutebutton"`); + }); + + it('renames identifiers when the rule returns a different `name`', () => { + const source = `import { OldName } from 'src';\nconst _ = OldName;`; + const { code } = compile(source, { + target: 'react', + imports: { src: (_name) => ({ source: 'dst', name: 'NewName' }) }, + }); + expect(code).toContain(`import { NewName as OldName } from "dst"`); + }); +}); + +describe('replace', () => { + it('substitutes a matched element with a new tag and adds the import', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], + }); + expect(code).toContain(` { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], + }); + expect(collapse(code)).toContain(collapse(``)); + }); +}); + +describe('wrap', () => { + it('wraps a matched element with a new tag and adds the import', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [wrap({ match: byTag('Inner'), with: { source: 'pkg', name: 'Outer' } })], + }); + expect(collapse(code)).toContain(collapse(``)); + expect(code).toContain(`import { Outer } from "pkg"`); + }); +}); + +describe('childAsProp', () => { + it('lifts a single JSX-element child into the named prop', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + }); + expect(collapse(code)).toContain(collapse(`}/>`)); + }); + + it('skips when prop is already set', () => { + const source = `function App(){ return }>; }`; + const { code } = compile(source, { + target: 'react', + plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + }); + expect(collapse(code)).toContain(collapse(``)); + expect(collapse(code)).toContain(collapse(``)); + }); + + it('skips when there are multiple JSX-element children', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + }); + expect(collapse(code)).toContain(collapse(``)); + }); + + it('matches an array of tags via anyTag', () => { + const source = `function App(){ return <>; }`; + const { code } = compile(source, { + target: 'react', + plugins: [childAsProp({ match: anyTag(['T1', 'T2']), prop: 'render' })], + }); + const trimmed = collapse(code); + expect(trimmed).toContain(collapse(`}/>`)); + expect(trimmed).toContain(collapse(`}/>`)); + }); +}); + +describe('addProp', () => { + it('emits a JSX value by default and adds the import', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } })], + }); + expect(collapse(code)).toContain(collapse(`}/>`)); + expect(code).toContain(`import { Button } from "./button"`); + }); + + it('emits a bare reference when kind is "ref"', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + addProp({ + match: byTag('PlayButton'), + prop: 'as', + value: { source: './button', name: 'Button', kind: 'ref' }, + }), + ], + }); + expect(collapse(code)).toContain(collapse(``)); + }); + + it('skips elements where the prop is already set', () => { + const source = `function App(){ return }/>; }`; + const { code } = compile(source, { + target: 'react', + plugins: [addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } })], + }); + expect(collapse(code)).toContain(collapse(``)); + expect(code).not.toContain('import { Button }'); + }); + + it('overwrites the existing prop when overwrite is true', () => { + const source = `function App(){ return }/>; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + addProp({ + match: byTag('PlayButton'), + prop: 'render', + overwrite: true, + value: { source: './button', name: 'Button' }, + }), + ], + }); + expect(collapse(code)).toContain(collapse(`}/>`)); + }); +}); + +describe('matchers', () => { + it('byTag supports dotted tags', () => { + const source = `function App(){ return ; }`; + const { code } = compile(source, { + target: 'react', + plugins: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })], + }); + expect(code).toContain(` { + const source = `function App(){ return <>; }`; + const isA1 = (node: import('../matchers').JsxElementLike) => { + const attrs = 'attributes' in node ? node.attributes : (node as never); + const props = (attrs as { properties?: ReadonlyArray<{ initializer?: { text?: string } }> }).properties ?? []; + return props.some((p) => p.initializer?.text === '1'); + }; + const { code } = compile(source, { + target: 'react', + plugins: [replace({ match: byTag('Foo', { when: isA1 }), with: { source: 'pkg', name: 'Bar' } })], + }); + expect(code).toContain(` { + const source = `function App(){ return <>
; }`; + const { code } = compile(source, { + target: 'react', + plugins: [replace({ match: byTag('A', { when: hasChild(byTag('B')) }), with: { source: 'p', name: 'Z' } })], + }); + // First has direct child → replaced; second has only a nested → not replaced. + expect(code).toContain('
'); + }); + + it('hasChild with deep:true matches descendants', () => { + const source = `function App(){ return
; }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + replace({ + match: byTag('A', { when: hasChild(byTag('B'), { deep: true }) }), + with: { source: 'p', name: 'Z' }, + }), + ], + }); + expect(code).toContain(''); + }); + + it('hasChild composes with byTag for nested shape checks', () => { + const source = `function App(){ + return <>; + }`; + const { code } = compile(source, { + target: 'react', + plugins: [ + replace({ + match: byTag('Outer', { when: hasChild(byTag('Inner', { when: hasChild(byTag('Target')) })) }), + with: { source: 'p', name: 'Matched' }, + }), + ], + }); + // Only the Outer with Inner→Target gets replaced. + expect(code).toContain(''); + expect(code).toContain(' + + + +
+ +
+
+ + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + {SEEK_TIME} + + + + Seek backward {SEEK_TIME} seconds + + + + + + + + {SEEK_TIME} + + + + Seek forward {SEEK_TIME} seconds + +
+ +
+ + + + + + + + +
+ + + +
+
+ +
+ +
+ + + + + Toggle playback rate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + {/* Hotkeys */} + + + + + + + + + + + + + + + + + + + {/* Gestures */} + + + + + + + ); +} diff --git a/packages/compiler/src/tests/generate.test.ts b/packages/compiler/src/tests/generate.test.ts new file mode 100644 index 00000000..8587175c --- /dev/null +++ b/packages/compiler/src/tests/generate.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { generate } from '../generate'; + +const STUB = 'const defineComponent: any = () => (m: any) => m;'; + +function setup(): { dir: string; output: string; pattern: string } { + const dir = mkdtempSync(join(tmpdir(), 'videojs-compiler-')); + mkdirSync(join(dir, 'play-button')); + mkdirSync(join(dir, 'slider')); + mkdirSync(join(dir, 'hotkey')); + + writeFileSync( + join(dir, 'play-button', 'play-button-data-attrs.ts'), + `export const PlayButtonDataAttrs = {} as const;` + ); + writeFileSync( + join(dir, 'play-button', 'play-button-component.ts'), + `import { PlayButtonDataAttrs } from './play-button-data-attrs'; + ${STUB} + export default defineComponent<{ disabled?: boolean }>()({ + name: 'PlayButton', + dataAttrs: PlayButtonDataAttrs, + });` + ); + + writeFileSync(join(dir, 'slider', 'slider-parts.ts'), `export const SliderParts = ['Root', 'Track'] as const;`); + writeFileSync(join(dir, 'slider', 'slider-data-attrs.ts'), `export const SliderDataAttrs = {} as const;`); + writeFileSync( + join(dir, 'slider', 'slider-component.ts'), + `import { SliderDataAttrs } from './slider-data-attrs'; + import { SliderParts } from './slider-parts'; + ${STUB} + export default defineComponent<{ orientation?: 'horizontal' | 'vertical' }>()({ + name: 'Slider', + parts: SliderParts, + dataAttrs: SliderDataAttrs, + });` + ); + + writeFileSync( + join(dir, 'hotkey', 'hotkey-component.ts'), + `${STUB} + export default defineComponent()({ name: 'Hotkey' });` + ); + + return { dir, output: join(dir, 'out.ts'), pattern: join(dir, '*/*-component.ts') }; +} + +function setupBulk(): { dir: string; output: string } { + const dir = mkdtempSync(join(tmpdir(), 'videojs-compiler-bulk-')); + mkdirSync(join(dir, 'assets')); + writeFileSync(join(dir, 'assets', 'play.svg'), ''); + writeFileSync(join(dir, 'assets', 'pause.svg'), ''); + return { dir, output: join(dir, 'out.ts') }; +} + +describe('generate (manifest entries)', () => { + it('imports each manifest as `Def` default-import', async () => { + const { output, pattern } = setup(); + await generate({ generate: { components: [pattern], output } }); + const source = readFileSync(output, 'utf8'); + expect(source).toContain("import PlayButtonDef from './play-button/play-button-component';"); + expect(source).toContain("import SliderDef from './slider/slider-component';"); + expect(source).toContain("import HotkeyDef from './hotkey/hotkey-component';"); + }); + + it('emits createComponent(Def) for each component', async () => { + const { output, pattern } = setup(); + await generate({ generate: { components: [pattern], output } }); + const source = readFileSync(output, 'utf8'); + expect(source).toContain('export const PlayButton = createComponent(PlayButtonDef);'); + expect(source).toContain('export const Slider = createComponent(SliderDef);'); + expect(source).toContain('export const Hotkey = createComponent(HotkeyDef);'); + }); + + it('emits COMPONENTS referencing each definition', async () => { + const { output, pattern } = setup(); + await generate({ generate: { components: [pattern], output } }); + const source = readFileSync(output, 'utf8'); + expect(source).toContain('export const COMPONENTS = {'); + expect(source).toContain('export type Components = typeof COMPONENTS;'); + expect(source).toContain('PlayButton: PlayButtonDef,'); + expect(source).toContain('Slider: SliderDef,'); + expect(source).toContain('Hotkey: HotkeyDef,'); + }); +}); + +describe('generate (bulk entries)', () => { + it('inlines createComponent({ name }) for each matched file', async () => { + const { dir, output } = setupBulk(); + await generate({ + generate: { + components: [{ files: join(dir, 'assets/*.svg'), name: (f) => `${f[0]!.toUpperCase()}${f.slice(1)}Icon` }], + output, + }, + }); + const source = readFileSync(output, 'utf8'); + expect(source).toContain("export const PauseIcon = createComponent({ name: 'PauseIcon' });"); + expect(source).toContain("export const PlayIcon = createComponent({ name: 'PlayIcon' });"); + }); + + it('emits COMPONENTS with inline manifests for bulk entries', async () => { + const { dir, output } = setupBulk(); + await generate({ + generate: { + components: [{ files: join(dir, 'assets/*.svg'), name: (f) => `${f[0]!.toUpperCase()}${f.slice(1)}Icon` }], + output, + }, + }); + const source = readFileSync(output, 'utf8'); + expect(source).toContain("PlayIcon: { name: 'PlayIcon' },"); + expect(source).toContain("PauseIcon: { name: 'PauseIcon' },"); + }); + + it('strips the file extension before passing to name()', async () => { + const { dir, output } = setupBulk(); + let received: string | null = null; + await generate({ + generate: { + components: [ + { + files: join(dir, 'assets/*.svg'), + name: (f) => { + if (received === null) received = f; + return `${f}Icon`; + }, + }, + ], + output, + }, + }); + expect(received).not.toContain('.svg'); + }); +}); diff --git a/packages/compiler/src/tests/integration.test.ts b/packages/compiler/src/tests/integration.test.ts new file mode 100644 index 00000000..7362b874 --- /dev/null +++ b/packages/compiler/src/tests/integration.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { compile, type ImportRule } from '..'; +import { anyTag, byTag, hasChild } from '../matchers'; +import { childAsProp, replace } from '../react'; + +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 + * `@videojs/react`'s 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. + */ +describe('integration: default/video skin → React', () => { + const source = readFileSync(skinSource, 'utf8'); + + const imports: Record = { + '@videojs/core/components': (name) => ({ + source: `./src/ui/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`, + name, + }), + '@videojs/icons/components': '@videojs/icons/react', + '../tailwind': '@videojs/skins/default/tailwind', + }; + + const { code } = compile(source, { + target: 'react', + imports, + plugins: [ + replace({ + match: byTag('Popover.Root', { + when: hasChild(byTag('Popover.Trigger', { when: hasChild(byTag('MuteButton')) })), + }), + with: { source: './volume-popover', name: 'VolumePopover' }, + mapChildren: () => [], + }), + childAsProp({ match: anyTag(['Tooltip.Trigger', 'Popover.Trigger']), prop: 'render' }), + ], + }); + + it('rewrites @videojs/core/components 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 @videojs/icons/components to @videojs/icons/react', () => { + expect(code).toContain('@videojs/icons/react'); + expect(code).not.toContain('@videojs/icons/components'); + }); + + it('substitutes the volume Popover.Root with VolumePopover', () => { + expect(code).toContain(' { + expect(code).toMatch(/ { + expect(code).toContain('@videojs/skins/default/tailwind'); + }); +}); diff --git a/packages/compiler/src/tests/jsx-types.test-d.tsx b/packages/compiler/src/tests/jsx-types.test-d.tsx new file mode 100644 index 00000000..783fd2bf --- /dev/null +++ b/packages/compiler/src/tests/jsx-types.test-d.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @videojs/compiler */ + +import { PlayButton, Slider, Time } from '@videojs/core/components'; +import { describe, it } from 'vitest'; + +describe('constrained JSX', () => { + it('accepts a single component', () => { + void (); + }); + + it('rejects invalid props on a single component', () => { + // @ts-expect-error - className must be a string + void (); + }); + + it('accepts compound parts inside their root', () => { + void ( + + + + + + + ); + }); + + it('rejects invalid compound root props', () => { + // @ts-expect-error - `bogus` is not a valid orientation + void (); + }); + + it('rejects invalid Time.Value props', () => { + void (); + // @ts-expect-error - `bogus` not in Time type union + void (); + }); + + it('accepts div and span as layout intrinsics', () => { + void ( +
+ hello +
+ ); + // @ts-expect-error - arbitrary HTML attributes (id) are not allowed on layout intrinsics + void (
); + }); +}); diff --git a/packages/compiler/src/tests/tsconfig.json b/packages/compiler/src/tests/tsconfig.json new file mode 100644 index 00000000..7f93e72f --- /dev/null +++ b/packages/compiler/src/tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@videojs/compiler", + "noEmit": true, + "isolatedDeclarations": false, + "composite": false, + "incremental": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["**/*.test-d.tsx"] +} diff --git a/packages/compiler/src/transforms/add-import.ts b/packages/compiler/src/transforms/add-import.ts new file mode 100644 index 00000000..85eb0c9e --- /dev/null +++ b/packages/compiler/src/transforms/add-import.ts @@ -0,0 +1,79 @@ +import ts from 'typescript'; +import { resolveRelative } from './imports'; + +export interface AddImportRef { + source: string; + name: string; +} + +export interface AddImportContext { + configDir?: string | undefined; + outputFile?: string | undefined; +} + +/** + * Add a named import (`import { name } from "source"`) to a SourceFile if not + * already present. Existing imports from the same source are extended in + * place; otherwise a new import is appended after the last import statement. + * + * Relative `source` values are resolved against `configDir` and re-projected + * relative to `outputFile` (same rule as `transformImports`). + */ +export function addNamedImport( + sourceFile: ts.SourceFile, + ref: AddImportRef, + factory: ts.NodeFactory, + context: AddImportContext = {} +): ts.SourceFile { + const target = ref.source.startsWith('.') + ? resolveRelative(ref.source, { rules: {}, configDir: context.configDir, outputFile: context.outputFile }) + : ref.source; + + // Already imported? + for (const stmt of sourceFile.statements) { + if (!ts.isImportDeclaration(stmt)) continue; + if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue; + if (stmt.moduleSpecifier.text !== target) continue; + const clause = stmt.importClause; + if (!clause?.namedBindings || !ts.isNamedImports(clause.namedBindings)) continue; + if (clause.namedBindings.elements.some((e) => e.name.text === ref.name)) { + return sourceFile; + } + const updated = factory.updateImportDeclaration( + stmt, + stmt.modifiers, + factory.createImportClause( + false, + clause.name, + factory.createNamedImports([ + ...clause.namedBindings.elements, + factory.createImportSpecifier(false, undefined, factory.createIdentifier(ref.name)), + ]) + ), + stmt.moduleSpecifier, + stmt.attributes + ); + return factory.updateSourceFile( + sourceFile, + sourceFile.statements.map((s) => (s === stmt ? updated : s)) + ); + } + + // Append new import after the last import statement. + const newImport = factory.createImportDeclaration( + undefined, + factory.createImportClause( + false, + undefined, + factory.createNamedImports([factory.createImportSpecifier(false, undefined, factory.createIdentifier(ref.name))]) + ), + factory.createStringLiteral(target) + ); + let lastImportIdx = -1; + for (let i = 0; i < sourceFile.statements.length; i++) { + if (ts.isImportDeclaration(sourceFile.statements[i]!)) lastImportIdx = i; + } + const next = [...sourceFile.statements]; + next.splice(lastImportIdx + 1, 0, newImport); + return factory.updateSourceFile(sourceFile, next); +} diff --git a/packages/compiler/src/transforms/drop-unused-imports.ts b/packages/compiler/src/transforms/drop-unused-imports.ts new file mode 100644 index 00000000..ceb28c65 --- /dev/null +++ b/packages/compiler/src/transforms/drop-unused-imports.ts @@ -0,0 +1,96 @@ +import ts from 'typescript'; + +/** + * Remove import specifiers that aren't referenced anywhere else in the + * SourceFile. Source-to-source rewrites (`replace`, `wrap`, `addProp`, + * `transformImports`) frequently leave behind imports that the original + * skin used but the compiled artifact no longer does — `dropUnusedImports` + * runs as a final pass to clean those up. + * + * Safe-by-default: side-effect imports (`import 'x'`) are preserved. Default + * imports and namespace imports are preserved unless their local name is + * never referenced in any non-import position. + */ +export function dropUnusedImports(): ts.TransformerFactory { + return (context) => { + return (sourceFile) => { + const used = collectReferencedIdentifiers(sourceFile); + + const next: ts.Statement[] = []; + for (const stmt of sourceFile.statements) { + if (!ts.isImportDeclaration(stmt)) { + next.push(stmt); + continue; + } + const trimmed = trimImport(stmt, used, context.factory); + if (trimmed) next.push(trimmed); + } + return context.factory.updateSourceFile(sourceFile, next); + }; + }; +} + +function collectReferencedIdentifiers(sourceFile: ts.SourceFile): Set { + const used = new Set(); + + const visit = (node: ts.Node, inImport: boolean): void => { + if (ts.isImportDeclaration(node)) { + // Skip the import declaration entirely — its identifiers are declarations, + // not references. (Module specifier is a string literal, no identifiers.) + return; + } + if (ts.isIdentifier(node) && !inImport) { + used.add(node.text); + } + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxClosingElement(node)) { + collectFromTagName(node.tagName, used); + } + ts.forEachChild(node, (c) => visit(c, inImport)); + }; + + ts.forEachChild(sourceFile, (c) => visit(c, false)); + return used; +} + +function collectFromTagName(name: ts.JsxTagNameExpression, into: Set): void { + if (ts.isIdentifier(name)) { + into.add(name.text); + return; + } + if (ts.isPropertyAccessExpression(name)) { + collectFromTagName(name.expression as ts.JsxTagNameExpression, into); + } +} + +function trimImport( + stmt: ts.ImportDeclaration, + used: Set, + factory: ts.NodeFactory +): ts.ImportDeclaration | null { + const clause = stmt.importClause; + if (!clause) return stmt; // side-effect import — keep as-is + + const keepDefault = clause.name && used.has(clause.name.text) ? clause.name : undefined; + + let keepNamedBindings: ts.NamedImportBindings | undefined; + if (clause.namedBindings) { + if (ts.isNamespaceImport(clause.namedBindings)) { + if (used.has(clause.namedBindings.name.text)) keepNamedBindings = clause.namedBindings; + } else { + const keptSpecs = clause.namedBindings.elements.filter((spec) => used.has(spec.name.text)); + if (keptSpecs.length > 0) { + keepNamedBindings = factory.createNamedImports(keptSpecs); + } + } + } + + if (!keepDefault && !keepNamedBindings) return null; + + return factory.updateImportDeclaration( + stmt, + stmt.modifiers, + factory.createImportClause(clause.isTypeOnly, keepDefault, keepNamedBindings), + stmt.moduleSpecifier, + stmt.attributes + ); +} diff --git a/packages/compiler/src/transforms/drop-unused-locals.ts b/packages/compiler/src/transforms/drop-unused-locals.ts new file mode 100644 index 00000000..d0503ad7 --- /dev/null +++ b/packages/compiler/src/transforms/drop-unused-locals.ts @@ -0,0 +1,122 @@ +import ts from 'typescript'; + +/** + * Remove top-level `const x = ;` declarations whose name isn't + * referenced anywhere else in the SourceFile, when `` is provably + * side-effect-free (a `cn(...)` call, a string literal, or a property access). + * + * Source-to-source rewrites (`tailwindPlugin` resolving local cn() consts + * into class strings, `replace`/`childAsProp` replacing JSX) frequently leave + * locals stranded. This pass cleans them up so the generated artifact doesn't + * trip TypeScript's `noUnusedLocals` warning. + */ +export function dropUnusedLocals(): ts.TransformerFactory { + return (context) => { + return (sourceFile) => { + const used = collectReferencedIdentifiers(sourceFile); + const next: ts.Statement[] = []; + + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) { + next.push(stmt); + continue; + } + // Preserve exported declarations untouched — they're API surface. + const isExport = (stmt.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + if (isExport) { + next.push(stmt); + continue; + } + + const keptDecls = stmt.declarationList.declarations.filter((decl) => { + if (!ts.isIdentifier(decl.name)) return true; + if (used.has(decl.name.text)) return true; + if (!decl.initializer) return true; + return !isPureExpression(decl.initializer); + }); + + if (keptDecls.length === 0) continue; + if (keptDecls.length === stmt.declarationList.declarations.length) { + next.push(stmt); + continue; + } + next.push( + context.factory.updateVariableStatement( + stmt, + stmt.modifiers, + context.factory.updateVariableDeclarationList(stmt.declarationList, keptDecls) + ) + ); + } + + return context.factory.updateSourceFile(sourceFile, next); + }; + }; +} + +/** Walk every non-declaration position in the source, collecting referenced identifier names. */ +function collectReferencedIdentifiers(sourceFile: ts.SourceFile): Set { + const used = new Set(); + + const visit = (node: ts.Node, declaring: boolean): void => { + if (ts.isImportDeclaration(node)) return; + + // Variable declarators: their name is a declaration, but the initializer + // is a normal expression that may reference *other* identifiers. + if (ts.isVariableDeclaration(node)) { + if (node.initializer) visit(node.initializer, false); + return; + } + + if (ts.isIdentifier(node) && !declaring) { + used.add(node.text); + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxClosingElement(node)) { + collectFromTagName(node.tagName, used); + } + + ts.forEachChild(node, (c) => visit(c, declaring)); + }; + + ts.forEachChild(sourceFile, (c) => visit(c, false)); + return used; +} + +function collectFromTagName(name: ts.JsxTagNameExpression, into: Set): void { + if (ts.isIdentifier(name)) { + into.add(name.text); + return; + } + if (ts.isPropertyAccessExpression(name)) { + collectFromTagName(name.expression as ts.JsxTagNameExpression, into); + } +} + +/** + * Conservative pattern check: drop only `const X = cn()` where every + * argument is a string literal, identifier, dotted access, array literal of + * the same, or a nested `cn(...)` call. Skipping other shapes (bare + * identifier RHS, property access, etc.) keeps the pass narrow — the caller + * presumably had a reason to materialize the binding. + */ +function isPureExpression(node: ts.Expression): boolean { + if (!ts.isCallExpression(node)) return false; + if (!ts.isIdentifier(node.expression) || node.expression.text !== 'cn') return false; + return node.arguments.every(isPureCnArgument); +} + +function isPureCnArgument(node: ts.Expression): boolean { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return true; + if (ts.isIdentifier(node)) return true; + if (ts.isPropertyAccessExpression(node)) return isPureCnArgument(node.expression); + if (ts.isParenthesizedExpression(node)) return isPureCnArgument(node.expression); + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) return isPureCnArgument(node.expression); + if (ts.isArrayLiteralExpression(node)) { + return node.elements.every((e) => !ts.isSpreadElement(e) && isPureCnArgument(e as ts.Expression)); + } + if (ts.isCallExpression(node)) { + return isPureExpression(node); + } + return false; +} diff --git a/packages/compiler/src/transforms/imports.ts b/packages/compiler/src/transforms/imports.ts new file mode 100644 index 00000000..87d0e16c --- /dev/null +++ b/packages/compiler/src/transforms/imports.ts @@ -0,0 +1,141 @@ +import { dirname, relative, resolve, sep } from 'node:path'; +import ts from 'typescript'; + +/** + * Per-identifier rewrite target. `source` may be either a bare specifier + * (`@videojs/icons/react`) or a relative path. Relative paths are resolved + * against the configured `configDir` and re-projected as a relative path from + * the output file at print time. + */ +export interface ImportRef { + source: string; + name: string; +} + +/** + * Rewrite rule for a given source module. + * - `string`: rewrite the module specifier; identifier names pass through. + * - function: per-identifier full power. Receives the local name; returns the + * target `{ source, name }`. + */ +export type ImportRule = string | ((name: string) => ImportRef); + +export interface ImportRewriteOptions { + /** Map: original module specifier → rewrite rule. */ + rules: Record; + /** Directory that relative `source` values in rules resolve against (typically the compiler.config.js dir). */ + configDir?: string | undefined; + /** Output file path (used to project relative-path rules into a relative import). */ + outputFile?: string | undefined; +} + +/** + * TS transformer that rewrites `import { X } from 'oldSource'` into one or + * more imports per the rule for `oldSource`. If the rule is a function it + * may map each identifier to a different `{ source, name }`, in which case + * one `import` statement is emitted per unique resolved source. + */ +export function transformImports(options: ImportRewriteOptions): ts.TransformerFactory { + const { rules } = options; + return (context) => { + return (sourceFile) => { + const newStatements: ts.Statement[] = []; + for (const stmt of sourceFile.statements) { + const rewritten = rewriteImportStatement(stmt, rules, options, context.factory); + if (rewritten === null) { + newStatements.push(stmt); + continue; + } + newStatements.push(...rewritten); + } + return context.factory.updateSourceFile(sourceFile, newStatements); + }; + }; +} + +function rewriteImportStatement( + stmt: ts.Statement, + rules: Record, + options: ImportRewriteOptions, + factory: ts.NodeFactory +): ts.Statement[] | null { + if (!ts.isImportDeclaration(stmt)) return null; + if (!ts.isStringLiteral(stmt.moduleSpecifier)) return null; + + const originalSource = stmt.moduleSpecifier.text; + const rule = rules[originalSource]; + if (rule === undefined) return null; + + const clause = stmt.importClause; + const resolvedBareTarget = (target: string): string => + target.startsWith('.') ? resolveRelative(target, options) : target; + + if (!clause || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) { + // Default-only or namespace-only imports — bare-string rewrite still applies; function form + // wouldn't have a name to receive, so leave it untouched in that case. + if (typeof rule === 'string') { + return [updateModuleSpecifier(stmt, resolvedBareTarget(rule), factory)]; + } + return null; + } + + if (typeof rule === 'string') { + return [updateModuleSpecifier(stmt, resolvedBareTarget(rule), factory)]; + } + + // Function form: bucket elements by resolved target source. + const buckets = new Map(); + for (const element of clause.namedBindings.elements) { + const localName = element.name.text; + const importedName = element.propertyName?.text ?? localName; + const target = rule(importedName); + const resolvedSource = + options.configDir && target.source.startsWith('.') ? resolveRelative(target.source, options) : target.source; + const propertyName = target.name === localName ? undefined : factory.createIdentifier(target.name); + const spec = factory.createImportSpecifier(false, propertyName, factory.createIdentifier(localName)); + const bucket = buckets.get(resolvedSource); + if (bucket) bucket.specs.push(spec); + else buckets.set(resolvedSource, { resolvedSource, specs: [spec] }); + } + + const out: ts.ImportDeclaration[] = []; + for (const { resolvedSource, specs } of buckets.values()) { + out.push( + factory.createImportDeclaration( + undefined, + factory.createImportClause(false, undefined, factory.createNamedImports(specs)), + factory.createStringLiteral(resolvedSource) + ) + ); + } + return out; +} + +function updateModuleSpecifier( + stmt: ts.ImportDeclaration, + resolvedSource: string, + factory: ts.NodeFactory +): ts.ImportDeclaration { + return factory.updateImportDeclaration( + stmt, + stmt.modifiers, + stmt.importClause, + factory.createStringLiteral(resolvedSource), + stmt.attributes + ); +} + +/** + * Resolve a relative `source` (from a rule) against `configDir`, then express + * the result as a relative path *from* the output file. Bare specifiers should + * not be passed here. + */ +export function resolveRelative(source: string, options: ImportRewriteOptions): string { + if (!source.startsWith('.')) return source; + const { configDir, outputFile } = options; + if (!configDir || !outputFile) return source; + const absolute = resolve(configDir, source); + let rel = relative(dirname(outputFile), absolute); + if (!rel.startsWith('.')) rel = `./${rel}`; + return rel.split(sep).join('/'); +} diff --git a/packages/compiler/src/transforms/replace.ts b/packages/compiler/src/transforms/replace.ts new file mode 100644 index 00000000..b1bb4f31 --- /dev/null +++ b/packages/compiler/src/transforms/replace.ts @@ -0,0 +1,64 @@ +import ts from 'typescript'; +import type { JsxElementLike, Matcher } from '../matchers'; +import { type AddImportContext, type AddImportRef, addNamedImport } from './add-import'; + +export interface ReplaceOptions { + match: Matcher; + with: AddImportRef; + /** Reshape the new element's attributes from the original's. Defaults to passthrough. */ + mapProps?: (original: ts.JsxAttributes, factory: ts.NodeFactory) => ts.JsxAttributes; + /** Reshape the new element's children from the original's. Defaults to passthrough (open form only). */ + mapChildren?: (originalChildren: readonly ts.JsxChild[], factory: ts.NodeFactory) => readonly ts.JsxChild[]; +} + +/** + * Substitute a matched JSX element with a different element drawn from a + * different import. The new tag's import is added if missing. + * + * `mapProps` and `mapChildren` default to identity (props pass through; + * children pass through when the original is an open element). Self-closing + * matches always emit a self-closing replacement when `mapChildren` is not + * provided. + */ +export function replace(opts: ReplaceOptions, ctx: AddImportContext = {}): ts.TransformerFactory { + return (transformContext) => { + return (sourceFile) => { + const factory = transformContext.factory; + let didReplace = false; + + const visit = (node: ts.Node): ts.Node => { + if ((ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && opts.match(node as JsxElementLike)) { + didReplace = true; + return buildReplacement(node as JsxElementLike, opts, factory); + } + return ts.visitEachChild(node, visit, transformContext); + }; + + let result = ts.visitEachChild(sourceFile, visit, transformContext); + if (didReplace) result = addNamedImport(result, opts.with, factory, ctx); + return result; + }; + }; +} + +function buildReplacement( + node: JsxElementLike, + opts: ReplaceOptions, + factory: ts.NodeFactory +): ts.JsxElement | ts.JsxSelfClosingElement { + const newTag = factory.createIdentifier(opts.with.name); + const originalAttrs = ts.isJsxElement(node) ? node.openingElement.attributes : node.attributes; + const attrs = opts.mapProps ? opts.mapProps(originalAttrs, factory) : originalAttrs; + + if (ts.isJsxSelfClosingElement(node) && !opts.mapChildren) { + return factory.createJsxSelfClosingElement(newTag, undefined, attrs); + } + + const originalChildren = ts.isJsxElement(node) ? node.children : ([] as readonly ts.JsxChild[]); + const children = opts.mapChildren ? opts.mapChildren(originalChildren, factory) : originalChildren; + return factory.createJsxElement( + factory.createJsxOpeningElement(newTag, undefined, attrs), + children, + factory.createJsxClosingElement(newTag) + ); +} diff --git a/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts b/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts new file mode 100644 index 00000000..cbab7b7b --- /dev/null +++ b/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { compile } from '../../compile'; +import { dropUnusedLocals } from '../drop-unused-locals'; + +const wrap = (source: string): string => compile(source, { target: 'react', plugins: [dropUnusedLocals()] }).code; + +describe('dropUnusedLocals', () => { + it('drops an unused cn() local', () => { + const code = wrap(`const x = cn('a', 'b');\nfunction App(){ return ; }`); + expect(code).not.toContain('const x ='); + }); + + it('keeps a referenced cn() local', () => { + const code = wrap(`const x = cn('a', 'b');\nfunction App(){ return ; }`); + expect(code).toContain('const x ='); + }); + + it('keeps an unused non-cn() local (conservative)', () => { + const code = wrap(`const x = computeSomething();\nfunction App(){ return ; }`); + expect(code).toContain('const x ='); + }); + + it('keeps an unused cn() with non-pure args (conservative)', () => { + const code = wrap(`const x = cn('a', sideEffect());\nfunction App(){ return ; }`); + expect(code).toContain('const x ='); + }); + + it('keeps exported declarations untouched', () => { + const code = wrap(`export const x = cn('a', 'b');\nfunction App(){ return ; }`); + expect(code).toContain('export const x'); + }); + + it('drops nested cn() arg patterns too', () => { + const code = wrap(`const x = cn('a', cn('b', 'c'));\nfunction App(){ return ; }`); + expect(code).not.toContain('const x ='); + }); +}); diff --git a/packages/compiler/src/transforms/wrap.ts b/packages/compiler/src/transforms/wrap.ts new file mode 100644 index 00000000..733f57ad --- /dev/null +++ b/packages/compiler/src/transforms/wrap.ts @@ -0,0 +1,40 @@ +import ts from 'typescript'; +import type { JsxElementLike, Matcher } from '../matchers'; +import { type AddImportContext, type AddImportRef, addNamedImport } from './add-import'; + +export interface WrapOptions { + match: Matcher; + with: AddImportRef; +} + +/** + * Wrap a matched JSX subtree with another component: + * + * The wrapper's import is added if missing. + */ +export function wrap(opts: WrapOptions, ctx: AddImportContext = {}): ts.TransformerFactory { + return (transformContext) => { + return (sourceFile) => { + const factory = transformContext.factory; + let didWrap = false; + + const visit = (node: ts.Node): ts.Node => { + if ((ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && opts.match(node as JsxElementLike)) { + didWrap = true; + const tag = factory.createIdentifier(opts.with.name); + const inner = ts.visitEachChild(node, visit, transformContext) as ts.JsxChild; + return factory.createJsxElement( + factory.createJsxOpeningElement(tag, undefined, factory.createJsxAttributes([])), + [inner], + factory.createJsxClosingElement(tag) + ); + } + return ts.visitEachChild(node, visit, transformContext); + }; + + let result = ts.visitEachChild(sourceFile, visit, transformContext); + if (didWrap) result = addNamedImport(result, opts.with, factory, ctx); + return result; + }; + }; +} diff --git a/packages/compiler/tsconfig.json b/packages/compiler/tsconfig.json new file mode 100644 index 00000000..78ec2dc5 --- /dev/null +++ b/packages/compiler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022"], + "types": ["node"], + "declarationDir": "types" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/tests/**"] +} diff --git a/packages/compiler/tsconfig.preset.json b/packages/compiler/tsconfig.preset.json new file mode 100644 index 00000000..4c2ec35f --- /dev/null +++ b/packages/compiler/tsconfig.preset.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@videojs/compiler" + } +} diff --git a/packages/compiler/tsdown.config.ts b/packages/compiler/tsdown.config.ts new file mode 100644 index 00000000..4b483ca9 --- /dev/null +++ b/packages/compiler/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: './src/index.ts', + cli: './src/cli.ts', + 'jsx-runtime': './src/jsx-runtime.ts', + 'jsx-dev-runtime': './src/jsx-dev-runtime.ts', + 'plugins/vite': './src/plugins/vite.ts', + 'matchers/index': './src/matchers/index.ts', + 'react/index': './src/react/index.ts', + 'styles/index': './src/styles/index.ts', + 'tailwind/index': './src/tailwind/index.ts', + }, + platform: 'neutral', + format: 'es', + sourcemap: true, + clean: true, + hash: false, + unbundle: true, + dts: true, +}); diff --git a/packages/compiler/vitest.config.ts b/packages/compiler/vitest.config.ts new file mode 100644 index 00000000..6ec74eee --- /dev/null +++ b/packages/compiler/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22f4c3b1..b1ee5166 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,31 @@ importers: specifier: ^4.1.0 version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + packages/compiler: + dependencies: + '@videojs/utils': + specifier: workspace:* + version: link:../utils + lightningcss: + specifier: ^1.32.0 + version: 1.32.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + devDependencies: + '@videojs/core': + specifier: workspace:* + version: link:../core + tsdown: + specifier: ^0.21.4 + version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.1)(typescript@6.0.2) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + packages/core: dependencies: '@videojs/spf': @@ -16402,8 +16427,8 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 @@ -16434,8 +16459,8 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 @@ -16466,8 +16491,8 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 @@ -16498,8 +16523,8 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 diff --git a/tsconfig.json b/tsconfig.json index ce28dea6..646ce95f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,8 @@ { "path": "packages/html" }, { "path": "packages/react" }, + { "path": "packages/compiler" }, + { "path": "packages/cli" } ], "files": []