diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 0785947a..62d94999 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -27,6 +27,10 @@ "types": "./dist/plugins/vite.d.ts", "default": "./dist/plugins/vite.js" }, + "./ast": { + "types": "./dist/ast/index.d.ts", + "default": "./dist/ast/index.js" + }, "./matchers": { "types": "./dist/matchers/index.d.ts", "default": "./dist/matchers/index.js" @@ -62,6 +66,7 @@ }, "dependencies": { "@videojs/utils": "workspace:*", + "kleur": "^4.1.5", "lightningcss": "^1.32.0", "tailwindcss": "^4.2.1", "typescript": "^6.0.2" diff --git a/packages/compiler/src/ast/index.ts b/packages/compiler/src/ast/index.ts new file mode 100644 index 00000000..5f657e6f --- /dev/null +++ b/packages/compiler/src/ast/index.ts @@ -0,0 +1,3 @@ +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'; diff --git a/packages/compiler/src/cli.ts b/packages/compiler/src/cli.ts index 36e58598..226af621 100644 --- a/packages/compiler/src/cli.ts +++ b/packages/compiler/src/cli.ts @@ -1,77 +1,51 @@ #!/usr/bin/env node -import { existsSync } from 'node:fs'; -import { isAbsolute, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; -import { compile } from './compile'; -import type { CompilerConfig } from './config'; +import { CompilerError, compile } from './compile'; +import type { CompilerDiagnostic } from './config'; +import { + type DiagnosticFormat, + formatCompilerDiagnostic, + formatCompilerDiagnosticJsonLine, + formatDiagnosticSummaryJsonLine, +} from './diagnostics'; 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), - }, - }; -} +import { CONFIG_FILENAMES, loadConfig } from './load-config'; interface ParsedArgs { command: string | undefined; positional: string[]; configOverride: string | undefined; + outFile: string | undefined; + diagnosticsFormat: DiagnosticFormat; } +let currentDiagnosticsFormat: DiagnosticFormat = 'default'; + function parseArgs(argv: readonly string[]): ParsedArgs { let command: string | undefined; let configOverride: string | undefined; + let outFile: string | undefined; + let diagnosticsFormat: DiagnosticFormat = 'default'; 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 (arg === '--out' || arg === '-o') { + outFile = argv[++i]; + } else if (arg === '--diagnostics') { + diagnosticsFormat = parseDiagnosticsFormat(argv[++i]); + } else if (arg.startsWith('--diagnostics=')) { + diagnosticsFormat = parseDiagnosticsFormat(arg.slice('--diagnostics='.length)); } else if (!command && !arg.startsWith('-')) { command = arg; } else if (!arg.startsWith('-')) { positional.push(arg); } } - return { command, positional, configOverride }; + return { command, positional, configOverride, outFile, diagnosticsFormat }; } function printHelp(): void { @@ -81,10 +55,12 @@ function printHelp(): void { '', 'Commands:', ' generate Generate components from the configured manifests', - ' compile Compile a JSX file (stub)', + ' compile Compile a JSX file', '', 'Options:', ' -c, --config Path to a compiler config (default: compiler.config.ts in cwd)', + ' -o, --out Write compiled code to a file (default: stdout)', + ' --diagnostics Diagnostic output: default or jsonl (default: default)', ' -h, --help Show this help', '', ].join('\n') @@ -93,20 +69,59 @@ function printHelp(): void { 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); + const loaded = await loadConfig(cwd, configOverride); + if (!loaded) { + throw new Error( + `No compiler config found in ${cwd}. Expected one of: ${CONFIG_FILENAMES.join(', ')}, or pass --config .` + ); + } + const result = await generate(loaded.config); process.stdout.write(`Wrote ${result.outputPath}\n`); } -function runCompile(positional: readonly string[]): void { +async function runCompile( + positional: readonly string[], + configOverride: string | undefined, + outFile: string | undefined, + diagnosticsFormat: DiagnosticFormat +): Promise { const file = positional[0]; if (!file) throw new Error('Usage: vjs compile '); - compile('', { filename: file, target: 'react' }); + + const cwd = process.cwd(); + const inputPath = isAbsolute(file) ? file : resolve(cwd, file); + const outputPath = outFile ? (isAbsolute(outFile) ? outFile : resolve(cwd, outFile)) : undefined; + const loaded = await loadConfig(cwd, configOverride); + const source = readFileSync(inputPath, 'utf8'); + const result = await compile(source, { + filename: inputPath, + config: loaded?.config, + configDir: loaded?.configDir ?? cwd, + ...(outputPath ? { outputFile: outputPath } : {}), + }); + + writeDiagnostics(result.diagnostics, diagnosticsFormat, { summary: diagnosticsFormat === 'jsonl' }); + + if (outputPath) { + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, result.code, 'utf8'); + process.stdout.write(`Wrote ${outputPath}\n`); + } else { + process.stdout.write(result.code); + } + + const assetBase = outputPath ? dirname(outputPath) : cwd; + for (const asset of result.assets) { + const assetPath = isAbsolute(asset.fileName) ? asset.fileName : resolve(assetBase, asset.fileName); + mkdirSync(dirname(assetPath), { recursive: true }); + writeFileSync(assetPath, asset.source, 'utf8'); + process.stdout.write(`Wrote ${assetPath}\n`); + } } async function main(): Promise { - const { command, positional, configOverride } = parseArgs(process.argv.slice(2)); + const { command, positional, configOverride, outFile, diagnosticsFormat } = parseArgs(process.argv.slice(2)); + currentDiagnosticsFormat = diagnosticsFormat; if (!command || command === 'help' || command === '--help' || command === '-h') { printHelp(); return; @@ -117,14 +132,55 @@ async function main(): Promise { await runGenerate(configOverride); return; case 'compile': - runCompile(positional); + await runCompile(positional, configOverride, outFile, diagnosticsFormat); return; default: throw new Error(`Unknown command: ${command}`); } } +function parseDiagnosticsFormat(value: string | undefined): DiagnosticFormat { + if (value === 'default' || value === 'jsonl') return value; + throw new Error(`Invalid diagnostics mode: ${value ?? ''}. Expected 'default' or 'jsonl'.`); +} + +function writeDiagnostics( + diagnostics: readonly CompilerDiagnostic[], + format: DiagnosticFormat, + options: { summary?: boolean | undefined } = {} +): void { + if (format === 'jsonl') { + for (const diagnostic of diagnostics) { + process.stderr.write(formatCompilerDiagnosticJsonLine(diagnostic)); + } + if (options.summary) process.stderr.write(formatDiagnosticSummaryJsonLine(diagnostics)); + return; + } + + for (const diagnostic of diagnostics) { + process.stderr.write(formatCompilerDiagnostic(diagnostic)); + } +} + +function errorDiagnostic(error: unknown): CompilerDiagnostic { + return { + level: 'error', + code: 'cli-error', + message: error instanceof Error ? error.message : String(error), + plugin: 'videojs/compiler', + }; +} + main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + if (error instanceof CompilerError) { + writeDiagnostics(error.diagnostics, currentDiagnosticsFormat, { summary: currentDiagnosticsFormat === 'jsonl' }); + process.exit(1); + } + + if (currentDiagnosticsFormat === 'jsonl') { + writeDiagnostics([errorDiagnostic(error)], currentDiagnosticsFormat, { summary: true }); + } else { + 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 index db13daed..9d240a76 100644 --- a/packages/compiler/src/compile.ts +++ b/packages/compiler/src/compile.ts @@ -1,18 +1,22 @@ import ts from 'typescript'; +import { + type CompilerAsset, + type CompilerConfig, + type CompilerContext, + type CompilerDiagnostic, + type CompilerPipelineStep, + type CompilerTransform, + react, +} from './config'; +import { fatalDiagnosticFromError, withDiagnosticSource } from './diagnostics'; 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'; +import { transformImports } from './transforms/imports'; 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; + config?: CompilerConfig | 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). */ @@ -21,7 +25,19 @@ export interface CompileOptions { export interface CompileResult { code: string; - map?: unknown; + map: null; + assets: readonly CompilerAsset[]; + diagnostics: readonly CompilerDiagnostic[]; +} + +export class CompilerError extends Error { + constructor( + public readonly diagnostics: readonly CompilerDiagnostic[], + options?: { cause?: unknown } + ) { + super(diagnostics[0]?.message ?? '@videojs/compiler failed', options); + this.name = 'CompilerError'; + } } const printer = ts.createPrinter({ @@ -33,28 +49,56 @@ const printer = ts.createPrinter({ * 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). + * 2. Apply target import rewrites so cross-package symbols re-route. + * 3. Apply style transforms, then target transforms. * 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[] = []; +export async function compile(source: string, options: CompileOptions = {}): Promise { + const filename = options.filename ?? 'input.tsx'; + const config = options.config ?? {}; + const target = config.target ?? react(); + const assets: CompilerAsset[] = []; + const diagnostics: CompilerDiagnostic[] = []; + const context: CompilerContext = { + filename, + configDir: options.configDir ?? process.cwd(), + ...(options.outputFile ? { outputFile: options.outputFile } : {}), + addAsset(asset) { + assets.push(asset); + }, + report(diagnostic) { + diagnostics.push(withDiagnosticSource(diagnostic, source, filename)); + }, + }; - if (options.imports) { + const { ast } = parse(source, { filename }); + const transformers: CompilerTransform[] = []; + let styleStep: CompilerPipelineStep | undefined; + + if (target.imports) { transformers.push( transformImports({ - rules: options.imports, - configDir: options.configDir, + rules: target.imports, + configDir: context.configDir, outputFile: options.outputFile, }) ); } - if (options.plugins) transformers.push(...options.plugins); + try { + styleStep = config.styles ? await config.styles.setup(context) : undefined; + if (styleStep?.transform) transformers.push(styleStep.transform); + } catch (error) { + throw new CompilerError( + [fatalDiagnosticFromError(error, { filename, sourceText: source, plugin: config.styles?.name })], + { cause: error } + ); + } + + if (target.transforms) transformers.push(...target.transforms); // Final passes: prune locals the rewrites left behind, then prune imports. // Order matters — dropping a local may make the imports it referenced @@ -65,16 +109,24 @@ export function compile(source: string, options: CompileOptions): CompileResult } if (transformers.length === 0) { - return { code: separateTopLevel(printer.printFile(ast)) }; + return { code: separateTopLevel(printer.printFile(ast)), map: null, assets, diagnostics }; } - const result = ts.transform(ast, transformers as ts.TransformerFactory[]); - const transformed = result.transformed[0]!; - const code = separateTopLevel(printer.printFile(transformed)); + let result: ts.TransformationResult | undefined; + try { + result = ts.transform(ast, transformers); + const transformed = result.transformed[0]!; + const code = separateTopLevel(printer.printFile(transformed)); - result.dispose(); + await styleStep?.finish?.(); - return { code }; + return { code, map: null, assets, diagnostics }; + } catch (error) { + if (error instanceof CompilerError) throw error; + throw new CompilerError([fatalDiagnosticFromError(error, { filename, sourceText: source })], { cause: error }); + } finally { + result?.dispose(); + } } /** diff --git a/packages/compiler/src/config.ts b/packages/compiler/src/config.ts index c398ea7c..c06ca5be 100644 --- a/packages/compiler/src/config.ts +++ b/packages/compiler/src/config.ts @@ -1,6 +1,46 @@ import type ts from 'typescript'; import type { ImportRule } from './transforms/imports'; +export type CompilerTransform = ts.TransformerFactory; + +export interface CompilerAsset { + type: 'css'; + fileName: string; + source: string; + sourceFile?: string | undefined; +} + +export interface CompilerDiagnostic { + level: 'warning' | 'error'; + code: string; + message: string; + file?: string | undefined; + line?: number | undefined; + column?: number | undefined; + endLine?: number | undefined; + endColumn?: number | undefined; + sourceText?: string | undefined; + plugin?: string | undefined; +} + +export interface CompilerContext { + filename: string; + configDir: string; + outputFile?: string | undefined; + addAsset(asset: CompilerAsset): void; + report(diagnostic: CompilerDiagnostic): void; +} + +export interface CompilerPipelineStep { + transform?: CompilerTransform | undefined; + finish?: (() => void | Promise) | undefined; +} + +export interface StylePipeline { + name: string; + setup(context: CompilerContext): CompilerPipelineStep | Promise; +} + /** * Bulk-defined component entry. Globs `files`, derives each component's name * from the filename (extension stripped) via `name(stem)`, and inline-emits @@ -29,23 +69,34 @@ export interface GenerateConfig { * Per-target compile configuration. Currently only `react` is shipped, but * the shape is extensible for `html`/etc. */ -export interface ReactTargetConfig { +export interface ReactTargetOptions { /** Per-source-module rewrite rules. */ - imports: Record; - /** Plugins applied in order after `transformImports`. */ - plugins?: readonly ts.TransformerFactory[]; + imports?: Record | undefined; + /** Transforms applied in order after `transformImports`. */ + transforms?: readonly CompilerTransform[] | undefined; } -export interface CompileTargetsConfig { - react?: ReactTargetConfig; +export interface CompilerTarget { + name: 'react' | 'html'; + imports?: Record | undefined; + transforms?: readonly CompilerTransform[] | undefined; } export interface CompilerConfig { + files?: readonly string[] | undefined; generate?: GenerateConfig; - /** Per-target compile rules consumed by `compile()`. */ - targets?: CompileTargetsConfig; + target?: CompilerTarget | undefined; + styles?: StylePipeline | undefined; } -export function defineConfig(config: CompilerConfig): CompilerConfig { +export function defineConfig(config: Config): Config { return config; } + +export function react(options: ReactTargetOptions = {}): CompilerTarget { + return { + name: 'react', + ...(options.imports ? { imports: options.imports } : {}), + ...(options.transforms ? { transforms: options.transforms } : {}), + }; +} diff --git a/packages/compiler/src/define-component.ts b/packages/compiler/src/define-component.ts index 5a916dca..d8162b64 100644 --- a/packages/compiler/src/define-component.ts +++ b/packages/compiler/src/define-component.ts @@ -1,9 +1,9 @@ declare const __PROPS_BRAND__: unique symbol; export interface ComponentManifest< - Props = unknown, + Props extends object = Record, Parts extends readonly string[] = readonly string[], - PartProps extends Record = Record, + PartProps extends Partial> = Partial>, > { name: string; parts?: Parts; @@ -13,17 +13,17 @@ export interface ComponentManifest< } export type InferProps = - T extends ComponentManifest> ? P : never; + T extends ComponentManifest>> ? P : never; export type InferParts = - T extends ComponentManifest> + T extends ComponentManifest>> ? readonly string[] extends Parts ? never : Parts[number] : never; export type InferPartProps = - T extends ComponentManifest + T extends ComponentManifest ? K extends keyof PartProps ? PartProps[K] : never @@ -48,10 +48,10 @@ export type InferPartProps = * dataAttrs: ControlsDataAttrs, * }); */ -export function defineComponent() { +export function defineComponent>() { return < const Parts extends readonly string[] = readonly string[], - const PartProps extends Record = Record, + const PartProps extends Partial> = Partial>, >( manifest: Omit, typeof __PROPS_BRAND__> ): ComponentManifest => manifest as ComponentManifest; diff --git a/packages/compiler/src/diagnostics.ts b/packages/compiler/src/diagnostics.ts new file mode 100644 index 00000000..9e5b4278 --- /dev/null +++ b/packages/compiler/src/diagnostics.ts @@ -0,0 +1,400 @@ +import { isAbsolute, relative } from 'node:path'; +import kleur from 'kleur'; +import type ts from 'typescript'; +import type { CompilerDiagnostic } from './config'; + +export type LogLevelName = 'silent' | 'error' | 'warn' | 'info' | 'verbose'; +export type DiagnosticFormat = 'default' | 'jsonl'; + +export enum LogLevel { + Silent = 0, + Error = 1, + Warn = 2, + Info = 3, + Verbose = 4, +} + +export interface DiagnosticLocation { + file?: string | undefined; + line?: number | undefined; + column?: number | undefined; + endLine?: number | undefined; + endColumn?: number | undefined; + sourceText?: string | undefined; +} + +export interface FormatDiagnosticOptions { + color?: boolean | undefined; + cwd?: string | undefined; + frameLines?: number | undefined; +} + +export interface DiagnosticJsonFrameLine { + line: number; + text: string; + highlight: boolean; +} + +export interface DiagnosticJsonEvent { + type: 'diagnostic'; + level: CompilerDiagnostic['level']; + code: string; + message: string; + plugin?: string | undefined; + file?: string | undefined; + range?: + | { + start: { line: number; column?: number | undefined }; + end?: { line: number; column?: number | undefined } | undefined; + } + | undefined; + frame?: readonly DiagnosticJsonFrameLine[] | undefined; +} + +export interface DiagnosticSummaryJsonEvent { + type: 'summary'; + errors: number; + warnings: number; +} + +export function mapLogLevelStringToNumber(level: LogLevelName): LogLevel { + switch (level) { + case 'silent': + return LogLevel.Silent; + case 'error': + return LogLevel.Error; + case 'warn': + return LogLevel.Warn; + case 'info': + return LogLevel.Info; + case 'verbose': + return LogLevel.Verbose; + } +} + +export function mapLogLevelToString(level: LogLevel): LogLevelName { + switch (level) { + case LogLevel.Silent: + return 'silent'; + case LogLevel.Error: + return 'error'; + case LogLevel.Warn: + return 'warn'; + case LogLevel.Info: + return 'info'; + case LogLevel.Verbose: + return 'verbose'; + } +} + +export function diagnosticLocationFromNode(node: ts.Node): DiagnosticLocation { + const sourceFile = node.getSourceFile(); + const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()); + return { + file: sourceFile.fileName, + line: start.line + 1, + column: start.character + 1, + endLine: end.line + 1, + endColumn: end.character + 1, + sourceText: sourceFile.text, + }; +} + +export function withDiagnosticSource( + diagnostic: CompilerDiagnostic, + sourceText: string, + filename: string +): CompilerDiagnostic { + if (diagnostic.sourceText) return diagnostic; + if (diagnostic.file && diagnostic.file !== filename) return diagnostic; + return { ...diagnostic, file: diagnostic.file ?? filename, sourceText }; +} + +export function fatalDiagnosticFromError( + error: unknown, + options: { filename: string; sourceText?: string | undefined; plugin?: string | undefined } +): CompilerDiagnostic { + const detail = isDiagnosticErrorLike(error) ? error : undefined; + const message = error instanceof Error ? error.message : String(error); + const file = detail?.file ?? detail?.fileName ?? options.filename; + const sourceText = detail?.sourceText ?? (file === options.filename ? options.sourceText : undefined); + return { + level: 'error', + code: detail?.diagnosticCode ?? 'compiler-fatal', + message, + file, + ...(typeof detail?.line === 'number' ? { line: detail.line } : {}), + ...(typeof detail?.column === 'number' ? { column: detail.column } : {}), + ...(typeof detail?.endLine === 'number' ? { endLine: detail.endLine } : {}), + ...(typeof detail?.endColumn === 'number' ? { endColumn: detail.endColumn } : {}), + ...(sourceText ? { sourceText } : {}), + ...((detail?.plugin ?? options.plugin) ? { plugin: detail?.plugin ?? options.plugin } : {}), + }; +} + +export function formatCompilerDiagnostic( + diagnostic: CompilerDiagnostic, + options: FormatDiagnosticOptions = {} +): string { + const color = options.color ?? shouldUseColor(process.stderr); + const colors = createColors(color); + const plugin = diagnostic.plugin ?? 'videojs/compiler'; + const badge = formatLevelBadge(diagnostic.level, colors, color); + const lines = [`${colors.dim(formatPluginName(plugin, colors))} ${badge}`, '', colors.bold('MESSAGE'), '']; + + lines.push(diagnostic.message); + + const hasLocation = diagnostic.file && diagnostic.line; + if (hasLocation && diagnostic.sourceText) { + lines.push('', colors.bold('CODE'), ''); + lines.push(formatLocation(diagnostic, options.cwd ?? process.cwd(), colors)); + lines.push('', formatCodeFrame(diagnostic, diagnostic.sourceText, options.frameLines ?? 5, colors)); + } else if (hasLocation) { + lines.push('', colors.bold('LOCATION'), '', formatLocation(diagnostic, options.cwd ?? process.cwd(), colors)); + } + + return `${lines.join('\n')}\n`; +} + +export function formatCompilerDiagnosticJsonLine( + diagnostic: CompilerDiagnostic, + options: FormatDiagnosticOptions = {} +): string { + return `${JSON.stringify(compilerDiagnosticToJsonEvent(diagnostic, options))}\n`; +} + +export function compilerDiagnosticToJsonEvent( + diagnostic: CompilerDiagnostic, + options: FormatDiagnosticOptions = {} +): DiagnosticJsonEvent { + const cwd = options.cwd ?? process.cwd(); + const file = diagnostic.file ? formatFilePath(diagnostic.file, cwd) : undefined; + const range = diagnosticRange(diagnostic); + const frame = diagnostic.sourceText + ? buildJsonFrame(diagnostic, diagnostic.sourceText, options.frameLines ?? 5) + : undefined; + + return { + type: 'diagnostic', + level: diagnostic.level, + code: diagnostic.code, + message: diagnostic.message, + ...(diagnostic.plugin ? { plugin: diagnostic.plugin } : {}), + ...(file ? { file } : {}), + ...(range ? { range } : {}), + ...(frame && frame.length > 0 ? { frame } : {}), + }; +} + +export function formatDiagnosticSummaryJsonLine(diagnostics: readonly CompilerDiagnostic[]): string { + return `${JSON.stringify(diagnosticSummaryToJsonEvent(diagnostics))}\n`; +} + +export function diagnosticSummaryToJsonEvent(diagnostics: readonly CompilerDiagnostic[]): DiagnosticSummaryJsonEvent { + let errors = 0; + let warnings = 0; + for (const diagnostic of diagnostics) { + if (diagnostic.level === 'error') errors++; + if (diagnostic.level === 'warning') warnings++; + } + return { type: 'summary', errors, warnings }; +} + +export function shouldUseColor(stream: NodeJS.WriteStream): boolean { + return Boolean(stream.isTTY && !process.env.NO_COLOR); +} + +interface DiagnosticErrorLike { + diagnosticCode?: string | undefined; + file?: string | undefined; + fileName?: string | undefined; + line?: number | undefined; + column?: number | undefined; + endLine?: number | undefined; + endColumn?: number | undefined; + sourceText?: string | undefined; + plugin?: string | undefined; +} + +interface Colors { + black(text: string): string; + bold(text: string): string; + dim(text: string): string; + white(text: string): string; + yellow(text: string): string; + bgRed(text: string): string; + bgYellow(text: string): string; +} + +interface CodeFrame { + firstLineNumber: number; + totalLines: number; + linesBefore: string[]; + relevantLines: string[]; + linesAfter: string[]; + hiddenLines: number; +} + +function isDiagnosticErrorLike(error: unknown): error is DiagnosticErrorLike { + return typeof error === 'object' && error !== null; +} + +function createColors(enabled: boolean): Colors { + if (enabled) return kleur; + const passthrough = (text: string): string => text; + return { + black: passthrough, + bold: passthrough, + dim: passthrough, + white: passthrough, + yellow: passthrough, + bgRed: passthrough, + bgYellow: passthrough, + }; +} + +function formatPluginName(name: string, colors: Colors): string { + return `[${name.startsWith('videojs') || name.startsWith('@videojs') ? colors.dim(name) : colors.yellow(name)}]`; +} + +function formatLevelBadge(level: CompilerDiagnostic['level'], colors: Colors, color: boolean): string { + if (!color) return level.toUpperCase(); + const label = colors.bold(colors.black(` ${level.toUpperCase()} `)); + return level === 'error' ? colors.bgRed(label) : colors.bgYellow(label); +} + +function formatLocation(diagnostic: CompilerDiagnostic, cwd: string, colors: Colors): string { + const file = formatFilePath(diagnostic.file!, cwd); + const line = diagnostic.line!; + const lineRange = diagnostic.endLine && diagnostic.endLine !== line ? `${line}-${diagnostic.endLine}` : `${line}`; + const column = diagnostic.column ? `:${diagnostic.column}` : ''; + return `${colors.dim(file)} ${colors.dim('L:')}${colors.dim(`${lineRange}${column}`)}`; +} + +function diagnosticRange(diagnostic: CompilerDiagnostic): DiagnosticJsonEvent['range'] { + if (!diagnostic.line) return undefined; + const start = { + line: diagnostic.line, + ...(diagnostic.column ? { column: diagnostic.column } : {}), + }; + const endLine = diagnostic.endLine ?? diagnostic.line; + const end = { + line: endLine, + ...(diagnostic.endColumn ? { column: diagnostic.endColumn } : {}), + }; + return diagnostic.endLine || diagnostic.endColumn ? { start, end } : { start }; +} + +function formatFilePath(file: string, cwd: string): string { + if (!isAbsolute(file)) return file; + const next = relative(cwd, file); + return next && !next.startsWith('..') ? next : file; +} + +function formatCodeFrame( + diagnostic: CompilerDiagnostic, + sourceText: string, + frameLines: number, + colors: Colors +): string { + const codeFrame = buildCodeFrame(sourceText, diagnostic.line!, diagnostic.endLine ?? diagnostic.line!, frameLines); + const { firstLineNumber, linesBefore, relevantLines, linesAfter } = codeFrame; + const printed: string[] = []; + const maxDigits = Math.max(1, String(firstLineNumber + codeFrame.totalLines).length); + const printLine = (line: string, lineNumber: number, relevant = false): string => { + const number = String(lineNumber).padStart(maxDigits, ' '); + const text = `${relevant ? '> ' : ' '}${colors.bold(number)} | ${line}`; + return relevant ? colors.white(text) : colors.dim(text); + }; + + for (let i = 0; i < linesBefore.length; i++) { + printed.push(printLine(linesBefore[i]!, firstLineNumber + i)); + } + + for (let i = 0; i < relevantLines.length; i++) { + printed.push(printLine(relevantLines[i]!, firstLineNumber + linesBefore.length + i, true)); + } + + for (let i = 0; i < linesAfter.length; i++) { + printed.push(printLine(linesAfter[i]!, firstLineNumber + linesBefore.length + relevantLines.length + i)); + } + + if (codeFrame.hiddenLines > 0) { + const label = codeFrame.hiddenLines === 1 ? 'line' : 'lines'; + printed.push(colors.dim(`${codeFrame.hiddenLines} ${label} hidden...`)); + } + + return printed.join('\n'); +} + +function buildJsonFrame( + diagnostic: CompilerDiagnostic, + sourceText: string, + frameLines: number +): DiagnosticJsonFrameLine[] { + if (!diagnostic.line) return []; + const codeFrame = buildCodeFrame(sourceText, diagnostic.line, diagnostic.endLine ?? diagnostic.line, frameLines); + const lines: DiagnosticJsonFrameLine[] = []; + const { firstLineNumber, linesBefore, relevantLines, linesAfter } = codeFrame; + + for (let i = 0; i < linesBefore.length; i++) { + lines.push({ line: firstLineNumber + i, text: linesBefore[i]!, highlight: false }); + } + + for (let i = 0; i < relevantLines.length; i++) { + lines.push({ line: firstLineNumber + linesBefore.length + i, text: relevantLines[i]!, highlight: true }); + } + + for (let i = 0; i < linesAfter.length; i++) { + lines.push({ + line: firstLineNumber + linesBefore.length + relevantLines.length + i, + text: linesAfter[i]!, + highlight: false, + }); + } + + return lines; +} + +function buildCodeFrame( + sourceText: string, + startLineNumber: number, + endLineNumber: number, + frameSize: number +): CodeFrame { + const lines = splitSourceLines(sourceText); + const firstRelevant = Math.max(0, startLineNumber - 1); + const lastRelevant = Math.max(firstRelevant, Math.min(lines.length - 1, endLineNumber - 1)); + const start = Math.max(0, firstRelevant - frameSize); + const end = Math.min(lines.length - 1, lastRelevant + frameSize); + const maxLines = 15; + const visibleEnd = Math.min(end, start + maxLines - 1); + const linesBefore: string[] = []; + const relevantLines: string[] = []; + const linesAfter: string[] = []; + + for (let i = start; i <= visibleEnd; i++) { + const line = lines[i] ?? ''; + if (i < firstRelevant) { + linesBefore.push(line); + } else if (i <= lastRelevant) { + relevantLines.push(line); + } else { + linesAfter.push(line); + } + } + + return { + firstLineNumber: start + 1, + totalLines: linesBefore.length + relevantLines.length + linesAfter.length, + linesBefore, + relevantLines, + linesAfter, + hiddenLines: end - visibleEnd, + }; +} + +function splitSourceLines(sourceText: string): string[] { + return sourceText.replace(/\r\n?/g, '\n').split('\n'); +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 929b217b..1cff2a5b 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,19 +1,43 @@ -export { type CompileOptions, type CompileResult, type CompileTarget, compile } from './compile'; +export { type CompileOptions, type CompileResult, CompilerError, compile } from './compile'; export { + type CompilerAsset, type CompilerConfig, - type CompileTargetsConfig, + type CompilerContext, + type CompilerDiagnostic, + type CompilerPipelineStep, + type CompilerTarget, + type CompilerTransform, defineConfig, - type ReactTargetConfig, + type ReactTargetOptions, + react, + type StylePipeline, } from './config'; export { type ComponentManifest, defineComponent, + type InferPartProps, type InferParts, type InferProps, } from './define-component'; +export { + compilerDiagnosticToJsonEvent, + type DiagnosticFormat, + type DiagnosticJsonEvent, + type DiagnosticJsonFrameLine, + type DiagnosticLocation, + type DiagnosticSummaryJsonEvent, + diagnosticLocationFromNode, + diagnosticSummaryToJsonEvent, + type FormatDiagnosticOptions, + formatCompilerDiagnostic, + formatCompilerDiagnosticJsonLine, + formatDiagnosticSummaryJsonLine, + LogLevel, + type LogLevelName, + mapLogLevelStringToNumber, + mapLogLevelToString, + shouldUseColor, +} from './diagnostics'; 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'; +export { type TailwindMode, type TailwindOptions, tailwind } from './tailwind'; +export type { ImportRef, ImportRule } from './transforms/imports'; diff --git a/packages/compiler/src/jsx-runtime.ts b/packages/compiler/src/jsx-runtime.ts index 0d2ab32b..09e695aa 100644 --- a/packages/compiler/src/jsx-runtime.ts +++ b/packages/compiler/src/jsx-runtime.ts @@ -2,9 +2,11 @@ import type { ComponentManifest, InferPartProps, InferParts, InferProps } from ' export const VIDEOJS_NODE = Symbol.for('@videojs/node'); +export type ComponentType = string | Component | typeof Fragment; + export interface ComponentNode { readonly [VIDEOJS_NODE]: true; - readonly type: unknown; + readonly type: ComponentType; readonly props: Record; readonly key: string | number | null; } @@ -14,16 +16,16 @@ export interface BaseProps { children?: unknown; } -export interface Component { - (props: BaseProps & Props): unknown; +export interface Component { + (props: BaseProps & Props): ComponentNode; readonly $$component: { name: string; part: string | null }; } type PartComponentProps = K extends 'Root' ? InferProps - : InferPartProps extends never - ? unknown - : InferPartProps; + : [NonNullable>] extends [never] + ? Record + : NonNullable>; type CompoundComponent = { [K in InferParts & string]: Component>; @@ -33,8 +35,8 @@ export type CreateComponentResult = [InferParts] extends [never] ? Component> : CompoundComponent; -function makePart(name: string, part: string | null): Component { - const fn = (_props: BaseProps & Props): unknown => { +function makePart(name: string, part: string | null): Component { + const fn = (_props: BaseProps & Props): ComponentNode => { throw new Error(`@videojs/compiler: <${name}${part ? `.${part}` : ''}> can only be evaluated by the compiler.`); }; @@ -43,16 +45,16 @@ function makePart(name: string, part: string | null): Component { return fn as Component; } -export function createComponent>>( - manifest: M -): CreateComponentResult { +export function createComponent< + M extends ComponentManifest>>, +>(manifest: M): CreateComponentResult { const parts = manifest.parts ?? []; if (parts.length === 0) { return makePart(manifest.name, null) as CreateComponentResult; } - const compound: Record> = {}; + const compound: Record> = {}; for (const part of parts) { compound[part] = makePart(manifest.name, part); @@ -61,7 +63,7 @@ export function createComponent; } -function createNode(type: unknown, props: Record, key?: string | number | null): ComponentNode { +function createNode(type: ComponentType, props: Record, key?: string | number | null): ComponentNode { return { [VIDEOJS_NODE]: true, type, @@ -70,18 +72,18 @@ function createNode(type: unknown, props: Record, key?: string }; } -export function jsx(type: unknown, props: Record, key?: string | number | null): ComponentNode { +export function jsx(type: ComponentType, props: Record, key?: string | number | null): ComponentNode { return createNode(type, props, key); } -export function jsxs(type: unknown, props: Record, key?: string | number | null): ComponentNode { +export function jsxs(type: ComponentType, 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 type Element = ComponentNode; export interface ElementChildrenAttribute { children: Record; diff --git a/packages/compiler/src/load-config.ts b/packages/compiler/src/load-config.ts new file mode 100644 index 00000000..4660c5ae --- /dev/null +++ b/packages/compiler/src/load-config.ts @@ -0,0 +1,65 @@ +import { existsSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { CompilerConfig } from './config'; + +interface ConfigModule { + default?: CompilerConfig; + config?: CompilerConfig; +} + +export interface LoadedCompilerConfig { + config: CompilerConfig; + configPath: string; + configDir: string; +} + +export const CONFIG_FILENAMES = [ + 'compiler.config.js', + 'compiler.config.mjs', + 'compiler.config.ts', + 'compiler.config.mts', +]; + +export function findConfig(cwd: string, override: string | undefined): string | null { + 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; + } + + return null; +} + +export async function loadConfigFile(configPath: string): Promise { + const mod = (await import(pathToFileURL(configPath).href)) as ConfigModule; + const config = mod.default ?? mod.config; + if (!config) { + throw new Error(`Config file ${configPath} must export a default compiler config (use \`defineConfig\`).`); + } + return { config: resolveConfigPaths(config, configPath), configPath, configDir: dirname(configPath) }; +} + +export async function loadConfig(cwd: string, override: string | undefined): Promise { + const configPath = findConfig(cwd, override); + return configPath ? loadConfigFile(configPath) : null; +} + +function resolveConfigPaths(config: CompilerConfig, configPath: string): CompilerConfig { + if (!config.generate) return config; + const base = dirname(configPath); + const { output, components } = config.generate; + return { + ...config, + generate: { + components, + output: isAbsolute(output) ? output : resolve(base, output), + }, + }; +} diff --git a/packages/compiler/src/plugins/tests/vite.test.ts b/packages/compiler/src/plugins/tests/vite.test.ts new file mode 100644 index 00000000..b5cc1129 --- /dev/null +++ b/packages/compiler/src/plugins/tests/vite.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CompilerConfig } from '../../config'; +import { vjsCompiler } from '../vite'; + +const createCssStyle = (source: string): NonNullable => ({ + name: 'fixture', + setup(context) { + return { + transform: () => (sourceFile) => sourceFile, + finish() { + context.addAsset({ type: 'css', fileName: 'skin.css', source }); + }, + }; + }, +}); + +describe('vjsCompiler', () => { + it('imports emitted CSS assets as virtual modules', async () => { + const plugin = vjsCompiler({ config: { styles: createCssStyle('.foo{display:flex;}') } }); + plugin.configResolved?.({ root: '/workspace' }); + + const result = await plugin.transform!.call( + { warn: () => {} }, + `function App(){ return ; }`, + '/workspace/skin.tsx' + ); + + expect(result).not.toBeNull(); + const match = result!.code.match(/^import "([^"]+)";/); + expect(match).not.toBeNull(); + + const id = match![1]!; + expect(id).toContain('virtual:@videojs/compiler/css/'); + expect(plugin.resolveId?.(id)).toBe(`\0${id}`); + expect(plugin.load?.(`\0${id}`)).toBe('.foo{display:flex;}'); + expect(result!.code).toContain('function App'); + }); + + it('forwards compiler warnings to Vite', async () => { + const warn = vi.fn(); + const plugin = vjsCompiler({ + config: { + styles: { + name: 'fixture', + setup(context) { + context.report({ level: 'warning', code: 'fixture-warning', message: 'Check this', plugin: 'fixture' }); + return { transform: () => (sourceFile) => sourceFile }; + }, + }, + }, + }); + + await plugin.transform!.call({ warn }, `function App(){ return ; }`, '/workspace/skin.tsx'); + + expect(warn).toHaveBeenCalledWith('Check this'); + }); + + it('forwards located compiler warnings to Vite', async () => { + const warn = vi.fn(); + const plugin = vjsCompiler({ + config: { + styles: { + name: 'fixture', + setup(context) { + context.report({ + level: 'warning', + code: 'fixture-warning', + message: 'Check this location', + file: context.filename, + line: 1, + column: 24, + plugin: 'fixture', + }); + return { transform: () => (sourceFile) => sourceFile }; + }, + }, + }, + }); + + await plugin.transform!.call({ warn }, `function App(){ return ; }`, '/workspace/skin.tsx'); + + expect(warn).toHaveBeenCalledWith({ + message: 'Check this location', + id: '/workspace/skin.tsx', + loc: { file: '/workspace/skin.tsx', line: 1, column: 24 }, + pluginCode: 'fixture-warning', + }); + }); +}); diff --git a/packages/compiler/src/plugins/vite.ts b/packages/compiler/src/plugins/vite.ts index 72b0e0df..37f8582b 100644 --- a/packages/compiler/src/plugins/vite.ts +++ b/packages/compiler/src/plugins/vite.ts @@ -1,28 +1,101 @@ -import { type CompileTarget, compile } from '../compile'; +import { compile } from '../compile'; +import type { CompilerConfig, CompilerDiagnostic } from '../config'; +import { type LoadedCompilerConfig, loadConfig } from '../load-config'; export interface VideojsCompilerPluginOptions { - target?: CompileTarget | undefined; + config?: CompilerConfig | undefined; + configFile?: string | undefined; include?: readonly string[] | undefined; + exclude?: readonly string[] | undefined; +} + +export interface VitePluginContext { + warn(warning: string | VitePluginWarning): void; +} + +export interface VitePluginWarning { + message: string; + id?: string | undefined; + loc?: { file?: string | undefined; line: number; column?: number | undefined } | undefined; + pluginCode?: string | undefined; } export interface VitePlugin { name: string; enforce?: 'pre' | 'post'; - transform?: (code: string, id: string) => { code: string; map?: unknown } | null; + configResolved?: (config: { root: string }) => void; + resolveId?: (id: string) => string | null; + load?: (id: string) => string | null; + transform?: (this: VitePluginContext, code: string, id: string) => Promise<{ code: string; map: null } | null>; } export function vjsCompiler(options: VideojsCompilerPluginOptions = {}): VitePlugin { - const target: CompileTarget = options.target ?? 'react'; const include = options.include ?? ['.tsx']; + const exclude = options.exclude ?? []; + const cssById = new Map(); + let root = process.cwd(); + let loadedConfig: LoadedCompilerConfig | null | undefined; + + const getConfig = async (): Promise<{ config: CompilerConfig; configDir: string }> => { + if (options.config) return { config: options.config, configDir: root }; + loadedConfig ??= await loadConfig(root, options.configFile); + return loadedConfig + ? { config: loadedConfig.config, configDir: loadedConfig.configDir } + : { config: {}, configDir: root }; + }; return { name: '@videojs/compiler', enforce: 'pre', - transform(code, id) { + configResolved(config) { + root = config.root; + }, + resolveId(id) { + return cssById.has(id) ? `\0${id}` : null; + }, + load(id) { + if (!id.startsWith('\0')) return null; + return cssById.get(id.slice(1)) ?? null; + }, + async transform(code, id) { if (!include.some((ext) => id.endsWith(ext))) return null; - return compile(code, { filename: id, target }); + if (exclude.some((ext) => id.endsWith(ext))) return null; + + const { config, configDir } = await getConfig(); + const result = await compile(code, { filename: id, config, configDir }); + for (const diagnostic of result.diagnostics) { + if (diagnostic.level === 'warning') this.warn(viteWarningFromDiagnostic(diagnostic)); + } + + const imports = result.assets + .filter((asset) => asset.type === 'css') + .map((asset, index) => { + const publicId = cssVirtualId(id, asset.fileName, index); + cssById.set(publicId, asset.source); + return `import ${JSON.stringify(publicId)};`; + }); + + return { code: imports.length > 0 ? `${imports.join('\n')}\n${result.code}` : result.code, map: result.map }; }, }; } +function cssVirtualId(id: string, fileName: string, index: number): string { + return `virtual:@videojs/compiler/css/${encodeURIComponent(id)}/${index}/${encodeURIComponent(fileName)}`; +} + +function viteWarningFromDiagnostic(diagnostic: CompilerDiagnostic): string | VitePluginWarning { + if (!diagnostic.file || !diagnostic.line) return diagnostic.message; + return { + message: diagnostic.message, + id: diagnostic.file, + loc: { + file: diagnostic.file, + line: diagnostic.line, + ...(diagnostic.column ? { column: diagnostic.column } : {}), + }, + pluginCode: diagnostic.code, + }; +} + export default vjsCompiler; diff --git a/packages/compiler/src/styles/analyze.ts b/packages/compiler/src/styles/analyze.ts index 1eb585c8..3cc0edc3 100644 --- a/packages/compiler/src/styles/analyze.ts +++ b/packages/compiler/src/styles/analyze.ts @@ -22,17 +22,25 @@ export type StyleSegment = * - `kind: 'opaque'` — anything else (computed expressions, ternaries that * don't reduce, function calls other than `cn`). Visitors should pass. */ -export interface StyleAttributeInfo { +export type StyleAttributeInfo = StyleAttributeSegmentsInfo | StyleAttributeOpaqueInfo; + +export interface StyleAttributeBaseInfo { /** 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; +} + +export interface StyleAttributeSegmentsInfo extends StyleAttributeBaseInfo { /** Decomposition. */ - kind: 'segments' | 'opaque'; - /** Defined when `kind === 'segments'`. */ - segments?: readonly StyleSegment[]; + kind: 'segments'; + segments: readonly StyleSegment[]; +} + +export interface StyleAttributeOpaqueInfo extends StyleAttributeBaseInfo { + kind: 'opaque'; } /** diff --git a/packages/compiler/src/styles/tests/analyze.test.ts b/packages/compiler/src/styles/tests/analyze.test.ts index 287761fa..c9f0d27e 100644 --- a/packages/compiler/src/styles/tests/analyze.test.ts +++ b/packages/compiler/src/styles/tests/analyze.test.ts @@ -1,200 +1,212 @@ import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { compile } from '../../compile'; -import type { StyleAttributeInfo, StyleSegment } from '../analyze'; +import { react } from '../../config'; +import type { StyleAttributeInfo, StyleAttributeSegmentsInfo, StyleSegment } from '../analyze'; import { analyzeStyles } from '../analyze'; -function collectSegments(source: string): StyleAttributeInfo[] { +async function collectSegments(source: string): Promise { const collected: StyleAttributeInfo[] = []; - compile(source, { - target: 'react', - plugins: [ - analyzeStyles({ - visit: (info) => { - collected.push(info); - return undefined; - }, + await compile(source, { + config: { + target: react({ + transforms: [ + analyzeStyles({ + visit: (info) => { + collected.push(info); + return undefined; + }, + }), + ], }), - ], + }, }); return collected; } +const compileWithTransform = (source: string, transform: ReturnType) => + compile(source, { config: { target: react({ transforms: [transform] }) } }); + const collapse = (s: string): string => s.replace(/\s+/g, ''); +function expectSegments(info: StyleAttributeInfo): asserts info is StyleAttributeSegmentsInfo { + expect(info.kind).toBe('segments'); + if (info.kind !== 'segments') throw new Error('Expected segmented style attribute info'); +} + describe('analyzeStyles — decomposition', () => { - it('classifies a literal-string className', () => { - const infos = collectSegments(`function App(){ return
; }`); + it('classifies a literal-string className', async () => { + const infos = await 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) }]); + const info = infos[0]!; + expectSegments(info); + expect(info.segments).toEqual([{ kind: 'literal', value: 'foo bar', node: expect.any(Object) }]); }); - it('classifies an expression-wrapped string literal', () => { - const infos = collectSegments(`function App(){ return
; }`); + it('classifies an expression-wrapped string literal', async () => { + const infos = await collectSegments(`function App(){ return
; }`); expect(infos).toHaveLength(1); - expect(infos[0]!.segments?.[0]).toMatchObject({ kind: 'literal', value: 'foo' }); + const info = infos[0]!; + expectSegments(info); + expect(info.segments[0]).toMatchObject({ kind: 'literal', value: 'foo' }); }); - it('classifies a single dotted token reference', () => { - const infos = collectSegments(`function App(){ return
; }`); + it('classifies a single dotted token reference', async () => { + const infos = await 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'] }); + const info = infos[0]!; + expectSegments(info); + expect(info.segments).toHaveLength(1); + expect(info.segments[0]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'icon'] }); }); - it('decomposes a `cn(...)` call into mixed segments', () => { + it('decomposes a `cn(...)` call into mixed segments', async () => { const source = `function App(){ return
; }`; - const infos = collectSegments(source); + const infos = await collectSegments(source); expect(infos).toHaveLength(1); - expect(infos[0]!.kind).toBe('segments'); - const kinds = infos[0]!.segments?.map((s: StyleSegment) => s.kind); + const info = infos[0]!; + expectSegments(info); + const kinds = info.segments.map((s: StyleSegment) => s.kind); expect(kinds).toEqual(['literal', 'token', 'opaque']); - expect(infos[0]!.segments?.[1]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'base'] }); + expect(info.segments[1]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'base'] }); }); - it('marks anything else as opaque', () => { - const infos = collectSegments(`function App(){ return
; }`); + it('marks anything else as opaque', async () => { + const infos = await collectSegments(`function App(){ return
; }`); expect(infos).toHaveLength(1); expect(infos[0]!.kind).toBe('opaque'); - expect(infos[0]!.segments).toBeUndefined(); + expect('segments' in infos[0]!).toBe(false); }); - it('sees className on self-closing elements', () => { - const infos = collectSegments(`function App(){ return ; }`); + it('sees className on self-closing elements', async () => { + const infos = await collectSegments(`function App(){ return ; }`); expect(infos).toHaveLength(1); }); - it('walks nested elements', () => { + it('walks nested elements', async () => { const source = `function App(){ return
; }`; - const infos = collectSegments(source); + const infos = await collectSegments(source); expect(infos).toHaveLength(3); }); - it('skips elements without className', () => { + it('skips elements without className', async () => { const source = `function App(){ return
; }`; - const infos = collectSegments(source); + const infos = await collectSegments(source); expect(infos).toHaveLength(1); - expect(infos[0]!.segments?.[0]).toMatchObject({ value: 'b' }); + const info = infos[0]!; + expectSegments(info); + expect(info.segments[0]).toMatchObject({ value: 'b' }); }); - it('honours custom mergeFn name', () => { + it('honours custom mergeFn name', async () => { const source = `function App(){ return
; }`; const infos: StyleAttributeInfo[] = []; - compile(source, { - target: 'react', - plugins: [ - analyzeStyles({ - mergeFn: 'twMerge', - visit: (info) => { - infos.push(info); - return undefined; - }, + await compile(source, { + config: { + target: react({ + transforms: [ + analyzeStyles({ + mergeFn: 'twMerge', + visit: (info) => { + infos.push(info); + return undefined; + }, + }), + ], }), - ], + }, }); - expect(infos[0]!.kind).toBe('segments'); - expect(infos[0]!.segments).toHaveLength(2); + const info = infos[0]!; + expectSegments(info); + expect(info.segments).toHaveLength(2); }); }); describe('analyzeStyles — rewriting', () => { - it('replaces the className value when the visitor returns an expression', () => { + it('replaces the className value when the visitor returns an expression', async () => { const source = `function App(){ return
; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ - analyzeStyles({ - visit: (_, factory) => factory.createStringLiteral('rewritten'), - }), - ], - }); + const { code } = await compileWithTransform( + source, + 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', () => { + it('leaves the className alone when the visitor returns undefined', async () => { const source = `function App(){ return
; }`; - const { code } = compile(source, { - target: 'react', - plugins: [analyzeStyles({ visit: () => undefined })], - }); + const { code } = await compileWithTransform(source, analyzeStyles({ visit: () => undefined })); expect(code).toContain('"foo"'); }); - it('rewrites a self-closing element', () => { + it('rewrites a self-closing element', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ - analyzeStyles({ - visit: (_, factory) => factory.createStringLiteral('bar'), - }), - ], - }); + const { code } = await compileWithTransform( + source, + analyzeStyles({ + visit: (_, factory) => factory.createStringLiteral('bar'), + }) + ); expect(collapse(code)).toContain(collapse(``)); }); - it('only rewrites elements the visitor opts to change', () => { + it('only rewrites elements the visitor opts to change', async () => { 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; - }, - }), - ], - }); + const { code } = await compileWithTransform( + source, + analyzeStyles({ + visit: (info, factory) => { + const hasRewrite = + info.kind === 'segments' && 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', () => { + it('passes a NodeFactory the visitor can use to build any expression', async () => { 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'), - ]), - }), - ], - }); + const { code } = await compileWithTransform( + source, + 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', () => { + it('exposes the element on the StyleAttributeInfo so visitors can reason about its tag', async () => { 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; - }, - }), - ], - }); + await compileWithTransform( + source, + 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/evaluator.ts b/packages/compiler/src/tailwind/evaluator.ts index d49ecfbe..5c8b4723 100644 --- a/packages/compiler/src/tailwind/evaluator.ts +++ b/packages/compiler/src/tailwind/evaluator.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, isAbsolute, resolve } from 'node:path'; import ts from 'typescript'; +import { type DiagnosticLocation, diagnosticLocationFromNode } from '../diagnostics'; /** * The shape a token module evaluates to: every leaf is a literal string, every @@ -18,9 +19,23 @@ export type TokenValue = string | { readonly [key: string]: TokenValue }; * include the file + line so consumers can point users at the offending decl. */ export class EvaluationError extends Error { - constructor(message: string) { + public readonly diagnosticCode = 'tailwind-evaluation'; + public readonly fileName?: string; + public readonly line?: number; + public readonly column?: number; + public readonly endLine?: number; + public readonly endColumn?: number; + public readonly sourceText?: string; + + constructor(message: string, location?: DiagnosticLocation | undefined) { super(message); this.name = 'EvaluationError'; + if (location?.file) this.fileName = location.file; + if (location?.line !== undefined) this.line = location.line; + if (location?.column !== undefined) this.column = location.column; + if (location?.endLine !== undefined) this.endLine = location.endLine; + if (location?.endColumn !== undefined) this.endColumn = location.endColumn; + if (location?.sourceText) this.sourceText = location.sourceText; } } @@ -109,7 +124,9 @@ function processImport(stmt: ts.ImportDeclaration, fromFile: string, env: Map${loc ? ` at ${loc.fileName}:${loc.line}` : ''}.\n` + + `Cannot derive a CSS class name for <${tag}>.\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 + { ...diagnosticLocationFromNode(opts.element), diagnosticCode: 'tailwind-class-name' } ); } @@ -172,10 +190,3 @@ function tokenPathToDefaultName(path: readonly string[]): string | 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 index 70b8ef11..b93d278a 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -1,10 +1,12 @@ import { existsSync, readFileSync } from 'node:fs'; -import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from 'node:path'; import ts from 'typescript'; +import type { CompilerContext, StylePipeline } from '../config'; +import { diagnosticLocationFromNode } from '../diagnostics'; import { tagName } from '../matchers'; import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles'; import { decompose, type UtilityCss } from './decompose'; -import type { DesignSystem } from './design-system'; +import { type DesignSystem, loadDesignSystem } from './design-system'; import { type CompiledRule, type EmittedCss, @@ -15,63 +17,54 @@ import { import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming'; -/** Output target for `tailwindPlugin`. */ -export type TailwindTarget = +/** Styling mode for Tailwind-backed className handling. */ +export type TailwindMode = /** Pass-through. JSX `className` values stay as authored. No CSS emitted. */ - | 'tailwind' + | 'preserve' /** * 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' + | 'inline' /** - * 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. + * Rewrite each `className` value to a semantic CSS class name and return CSS + * as compiler assets. */ - | 'vanilla-css'; + | 'extract'; /** 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; +export interface TailwindOptions { + /** Styling mode. Defaults to `'preserve'`. */ + mode?: TailwindMode | undefined; + /** Loaded Tailwind v4 design system. Required for `'extract'` unless `input` is provided. */ + design?: DesignSystem | undefined; + /** Tailwind CSS entry used to load the design system when `design` is omitted. */ + input?: string | undefined; + /** CSS asset name for `'extract'`. Defaults to the compiled source basename with `.css`. */ + output?: string | undefined; /** * Hook for shaping the final class name (see `NameTransform`). Only used - * by `'vanilla-css'`. Identity by default. + * by `'extract'`. Identity by default. */ transformName?: NameTransform; /** - * Per-tag / per-token-path class-name overrides. Only used by `'vanilla-css'`. + * Per-tag / per-token-path class-name overrides. Only used by `'extract'`. */ 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 + * Only used by `'extract'`. 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. */ + /** Options forwarded to `emitCss` for `'extract'`. */ 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. + * `HoistOptions`. Forwarded to the internal `emitCss` call in extract mode. * * Pass `false` to disable. Plugin consumers driving `emitCss` themselves * should pass the same value through. @@ -85,8 +78,7 @@ export interface TailwindPluginOptions { * - `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. + * Forwarded to the internal `emitCss` call in extract mode. */ inlineVars?: true | RegExp; /** @@ -96,33 +88,86 @@ export interface TailwindPluginOptions { * `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in), * with an optional `resolve` hook for the per-property config. * - * Forwarded to the internal `emitCss` call when `onCss` is set; consumers - * driving `emitCss` themselves should pass the same value through. + * Forwarded to the internal `emitCss` call in extract mode. */ properties?: RegisteredPropertiesOptions; } +interface TailwindTransformOptions extends Omit { + mode: TailwindMode; + sourcePath?: string | undefined; + onRules?: ((rules: readonly CompiledRule[]) => void) | undefined; +} + +export function tailwind(options: TailwindOptions = {}): StylePipeline { + return { + name: 'tailwind', + async setup(context) { + const mode = options.mode ?? 'preserve'; + + if (mode === 'preserve') return {}; + + if (mode === 'inline') { + return { + transform: tailwindPlugin({ ...options, mode, sourcePath: context.filename }), + }; + } + + const design = await resolveDesignSystem(options, context); + const rules: CompiledRule[] = []; + + return { + transform: tailwindPlugin({ + ...options, + mode, + design, + sourcePath: context.filename, + onRules: (nextRules) => { + rules.push(...nextRules); + }, + }), + async finish() { + if (rules.length === 0) return; + const emitted = await emitCss({ + rules, + ...(options.emit ?? {}), + ...(options.hoistVars !== undefined ? { hoist: options.hoistVars } : {}), + ...(options.inlineVars !== undefined ? { inlineVars: options.inlineVars } : {}), + ...(options.properties ? { properties: options.properties } : {}), + resolveThemeVar: (name) => design.resolveThemeVar(name), + ...(options.hoistVars ? { themeSelector: options.hoistVars.rootSelector } : {}), + }); + addCssAssets(context, options.output, emitted); + }, + }; + }, + }; +} + /** * 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') { +export function tailwindPlugin(options: TailwindTransformOptions): ts.TransformerFactory { + if (options.mode === 'preserve') { return () => (sourceFile) => sourceFile; } - if (options.target === 'tailwind-inlined') { + if (options.mode === 'inline') { return inlinedPlugin(options); } - return vanillaCssPlugin(options); + if (!options.design) { + throw new Error('@videojs/compiler: tailwind extract mode requires `design` or `input`'); + } + return vanillaCssPlugin({ ...options, design: options.design }); } /* ───────────────────────────────────────────────────────────────────────── * Target: tailwind-inlined * ───────────────────────────────────────────────────────────────────────── */ -function inlinedPlugin(options: TailwindPluginOptions): ts.TransformerFactory { +function inlinedPlugin(options: TailwindTransformOptions): ts.TransformerFactory { const env = buildTokenEnv(options.sourcePath); return (transformContext) => { @@ -143,8 +188,10 @@ function inlinedPlugin(options: TailwindPluginOptions): ts.TransformerFactory { - const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars, properties } = options; +function vanillaCssPlugin( + options: TailwindTransformOptions & { design: DesignSystem } +): ts.TransformerFactory { + const { design, transformName, overrides, bagFor, onRules } = options; const env = buildTokenEnv(options.sourcePath); @@ -249,30 +296,50 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory onRules?.(rules); - if (onCss) { - // Scope the emitted theme variables to the skin's hoist root when one - // is configured, so they don't leak to a global `:root`. - const themeSelector = hoistVars ? hoistVars.rootSelector : undefined; - emitCss({ - rules, - ...(emit ?? {}), - ...(hoistVars !== undefined ? { hoist: hoistVars } : {}), - ...(inlineVars !== undefined ? { inlineVars } : {}), - ...(properties ? { properties } : {}), - resolveThemeVar: (name) => design.resolveThemeVar(name), - ...(themeSelector ? { themeSelector } : {}), - }) - .then(onCss) - .catch(() => { - // Swallowed; a misconfigured baseCss shouldn't crash the build. - }); - } - return transformed; }; }; } +async function resolveDesignSystem(options: TailwindOptions, context: CompilerContext): Promise { + if (options.design) return options.design; + if (!options.input) { + throw new Error('@videojs/compiler: tailwind extract mode requires `input` when `design` is not provided'); + } + const input = isAbsolute(options.input) ? options.input : resolvePath(context.configDir, options.input); + return loadDesignSystem(input); +} + +function addCssAssets(context: CompilerContext, output: string | undefined, emitted: EmittedCss): void { + if (emitted.kind === 'merged') { + context.addAsset({ + type: 'css', + fileName: output ?? defaultCssFileName(context), + source: emitted.css, + sourceFile: context.filename, + }); + return; + } + + const indexFile = output ?? defaultCssFileName(context); + context.addAsset({ type: 'css', fileName: indexFile, source: emitted.index, sourceFile: context.filename }); + const dir = dirname(indexFile); + for (const [bag, source] of emitted.bags) { + context.addAsset({ + type: 'css', + fileName: join(dir, `${bag || 'index'}.css`), + source, + sourceFile: context.filename, + }); + } +} + +function defaultCssFileName(context: CompilerContext): string { + const file = basename(context.outputFile ?? context.filename); + const ext = extname(file); + return `${ext ? file.slice(0, -ext.length) : file}.css`; +} + /* ───────────────────────────────────────────────────────────────────────── * Token environment * ───────────────────────────────────────────────────────────────────────── */ @@ -473,18 +540,13 @@ function buildCompiledRule( function collisionError(element: ts.Node, className: string, first: string, next: string): DiagnosticError { const tag = tagName(element as Parameters[0]); - const sourceFile = element.getSourceFile?.(); - const loc = sourceFile ? sourceFile.getLineAndCharacterOfPosition(element.pos) : undefined; - const fileName = sourceFile?.fileName; - const line = loc ? loc.line + 1 : undefined; return new DiagnosticError( `vanilla-css: class name '${className}' is derived from elements with different styles` + - `${fileName ? ` (this one at ${fileName}:${line})` : ''}.\n` + + `.\n` + ` <${tag}> resolves to: ${next}\n` + ` an earlier element resolved to: ${first}\n` + `Merging these would put conflicting declarations in a single '.${className}' rule. ` + `Disambiguate with a distinct token, a distinct component, or an \`overrides\` entry.`, - fileName, - line + { ...diagnosticLocationFromNode(element), diagnosticCode: 'tailwind-class-collision' } ); } diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index 6790d8d7..232a1cb5 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -2,12 +2,13 @@ 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 { compile as compileSource } from '../../compile'; +import { type CompilerTransform, react } from '../../config'; import type { DesignSystem } from '../design-system'; import { loadDesignSystem } from '../design-system'; import type { CompiledRule } from '../emit'; import { clearTokenModuleCache } from '../evaluator'; -import { tailwindPlugin } from '../plugin'; +import { tailwind, tailwindPlugin } from '../plugin'; const MINIMAL_CSS = ` @import "tailwindcss"; @@ -45,25 +46,37 @@ const writeFixture = (relative: string, content: string): string => { const collapse = (s: string): string => s.replace(/\s+/g, ''); -describe('tailwindPlugin — target: tailwind (passthrough)', () => { - it('leaves className values unchanged', () => { +const compile = ( + source: string, + options: { + filename?: string | undefined; + target?: 'react' | undefined; + plugins?: readonly CompilerTransform[] | undefined; + } = {} +) => compileSource(source, { filename: options.filename, config: { target: react({ transforms: options.plugins }) } }); + +const compileTailwind = (source: string, options: Parameters[0], filename?: string) => + compileSource(source, { filename, config: { styles: tailwind(options) } }); + +describe('tailwindPlugin — mode: preserve', () => { + it('preserves static className values', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'tailwind' })], + plugins: [tailwindPlugin({ design, mode: 'preserve' })], }); expect(code).toContain('"flex items-center"'); }); - it('does not call onRules / onCss', () => { + it('skips extracted rule callbacks', async () => { const source = `function App(){ return ; }`; let called = 0; - compile(source, { + await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'tailwind', + mode: 'preserve', onRules: () => { called++; }, @@ -74,27 +87,27 @@ describe('tailwindPlugin — target: tailwind (passthrough)', () => { }); }); -describe('tailwindPlugin — target: tailwind-inlined', () => { - it('flattens a literal-string className to itself', () => { +describe('tailwindPlugin — mode: inline', () => { + it('preserves static className values', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })], + plugins: [tailwindPlugin({ design, mode: 'inline' })], }); expect(code).toContain('"flex items-center"'); }); - it('flattens a `cn(...)` call into a single literal string', () => { + it('folds static cn calls', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })], + plugins: [tailwindPlugin({ design, mode: 'inline' })], }); expect(code).toContain('"flex items-center gap-2"'); expect(code).not.toMatch(/cn\(/); }); - it('resolves token references via the on-disk evaluator', () => { + it('resolves imported token objects', async () => { writeFixture( 'tokens.ts', `import { cn } from '@videojs/utils/style'; @@ -105,67 +118,67 @@ export const tokens = { button: { base: cn('rounded', 'p-2') } }; function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', filename: sourcePath, - plugins: [tailwindPlugin({ design, target: 'tailwind-inlined', sourcePath })], + plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })], }); expect(code).toContain('"flex rounded p-2"'); }); - it('leaves the className alone if a token cannot be resolved', () => { + it('leaves unresolved imports untouched', async () => { const source = `import { tokens as styles } from './missing'; function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', filename: sourcePath, - plugins: [tailwindPlugin({ design, target: 'tailwind-inlined', sourcePath })], + plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })], }); expect(code).toMatch(/cn\(/); }); - it('leaves opaque expressions intact', () => { + it('leaves dynamic cn calls untouched', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'tailwind-inlined' })], + plugins: [tailwindPlugin({ design, mode: 'inline' })], }); expect(code).toMatch(/cn\(/); }); }); -describe('tailwindPlugin — target: vanilla-css', () => { - it('rewrites className to a tag-derived semantic name', () => { +describe('tailwindPlugin — mode: extract', () => { + it('replaces static utilities with component class names', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); expect(code).toContain('"play-button"'); expect(code).not.toContain('"flex items-center"'); }); - it('preserves marker utilities (group/peer) alongside the derived name', () => { + it('preserves group marker classes', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); // `group` produces no declarations but is required by descendant // `group-*` variants, so it must survive on the element. expect(code).toContain('"play-button group"'); }); - it('keeps markers and still emits rules for declaration-producing utilities', () => { + it('extracts cn utilities and preserves group marker classes', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', onRules: (rules) => { captured = rules; }, @@ -178,51 +191,51 @@ describe('tailwindPlugin — target: vanilla-css', () => { expect(captured!.flatMap((r) => r.utility.declarations)).toContainEqual({ property: 'display', value: 'flex' }); }); - it('preserves a marker while wrapping pass-through expressions in cn()', () => { + it('keeps dynamic cn expressions', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); expect(code).toMatch(/cn\("play-button group",\s*extra\)/); }); - it('throws a diagnostic when two elements derive the same name with different styles', () => { + it('throws on generated class style collisions', async () => { const source = `function App(){ return
; }`; - expect(() => + await expect( compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }) - ).toThrow(/class name 'seek-icon' is derived from elements with different styles/); + ).rejects.toThrow(/class name 'seek-icon' is derived from elements with different styles/); }); - it('does not flag identical recurrences of the same derived name', () => { + it('handles duplicate component styles', async () => { const source = `function App(){ return
; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); expect(code).toContain('"play-button"'); }); - it('rewrites className to a token-path-derived name on a bare HTML element', () => { + it('derives class names from style member expressions', async () => { const source = `function App(){ return
; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); expect(code).toContain('"buffering-indicator"'); }); - it('honours overrides keyed by tag', () => { + it('applies component class overrides', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', overrides: { PlayButton: 'custom' }, }), ], @@ -230,14 +243,14 @@ describe('tailwindPlugin — target: vanilla-css', () => { expect(code).toContain('"custom"'); }); - it('runs the transformName hook', () => { + it('applies transformed generated class names', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', transformName: (ctx) => `app-${ctx.defaultName}`, }), ], @@ -245,15 +258,15 @@ describe('tailwindPlugin — target: vanilla-css', () => { expect(code).toContain('"app-play-button"'); }); - it('collects CompiledRule[] via onRules', () => { + it('reports extracted rules through onRules', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - compile(source, { + await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', onRules: (rules) => { captured = rules; }, @@ -266,15 +279,15 @@ describe('tailwindPlugin — target: vanilla-css', () => { expect(captured![0]!.utility.declarations).toContainEqual({ property: 'display', value: 'flex' }); }); - it('expands a `cn(...)` call into one rule per utility', () => { + it('emits one rule per extracted utility', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - compile(source, { + await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', onRules: (rules) => { captured = rules; }, @@ -286,7 +299,7 @@ describe('tailwindPlugin — target: vanilla-css', () => { expect(captured![1]!.className).toBe('foo'); }); - it('resolves token references via the on-disk evaluator', () => { + it('resolves imported tokens before extraction', async () => { writeFixture( 'tokens.ts', `import { cn } from '@videojs/utils/style'; @@ -298,13 +311,13 @@ function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); let captured: readonly CompiledRule[] | undefined; - compile(source, { + await compile(source, { target: 'react', filename: sourcePath, plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', sourcePath, onRules: (rules) => { captured = rules; @@ -316,15 +329,15 @@ function App(){ return ; }`; expect(captured![0]!.className).toBe('foo'); }); - it('annotates rules with a bag via bagFor', () => { + it('assigns rule bags with bagFor', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - compile(source, { + await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', bagFor: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined), onRules: (rules) => { captured = rules; @@ -335,15 +348,15 @@ function App(){ return ; }`; expect(captured![0]!.bag).toBe('controls'); }); - it('skips opaque expressions', () => { + it('skips dynamic conditional class expressions', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', onRules: (rules) => { captured = rules; }, @@ -354,7 +367,7 @@ function App(){ return ; }`; expect(code).toContain('isOn'); }); - it('resolves a local cn() const referenced via className={X}', () => { + it('resolves local cn constants and imported token members', async () => { writeFixture( 'tokens.ts', `import { cn } from '@videojs/utils/style'; @@ -368,13 +381,13 @@ function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); let captured: readonly CompiledRule[] | undefined; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', filename: sourcePath, plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', sourcePath, onRules: (rules) => { captured = rules; @@ -388,26 +401,26 @@ function App(){ return ; }`; expect(utilities).toEqual(['flex', 'h-4', 'w-4']); }); - it('preserves opaque expressions by wrapping the derived name in cn()', () => { + it('preserves dynamic cn suffixes after extraction', async () => { const source = `function App({ extra }){ return ; }`; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', - plugins: [tailwindPlugin({ design, target: 'vanilla-css' })], + plugins: [tailwindPlugin({ design, mode: 'extract' })], }); expect(code).toMatch(/cn\("play-button",\s*extra\)/); }); - it('handles multiple elements in one source', () => { + it('extracts parent and child element class names', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; - const { code } = compile(source, { + const { code } = await compile(source, { target: 'react', plugins: [ tailwindPlugin({ design, - target: 'vanilla-css', + mode: 'extract', onRules: (rules) => { captured = rules; }, @@ -422,68 +435,31 @@ function App(){ return ; }`; expect(collapse(code)).toContain(collapse(``)); }); - it('forwards CSS through onCss when set', async () => { + it('returns CSS assets in extract mode', 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; + const { assets } = await compileTailwind(source, { mode: 'extract', design }); + const css = assets[0]!.source; expect(collapse(css)).toContain(collapse('.foo{display:flex;}')); }); - it('emits referenced theme variables in the onCss output', async () => { + it('emits referenced theme variables in extracted CSS', async () => { // `p-4` lowers to `padding: calc(var(--spacing) * 4)` — the output must // define `--spacing` so it resolves without a separate Tailwind theme. const source = `function App(){ return ; }`; - const cssPromise = new Promise((resolve) => { - compile(source, { - target: 'react', - plugins: [ - tailwindPlugin({ - design, - target: 'vanilla-css', - hoistVars: { rootSelector: '[data-skin="x"]' }, - onCss: (out) => { - if (out.kind === 'merged') resolve(out.css); - }, - }), - ], - }); + const { assets } = await compileTailwind(source, { + mode: 'extract', + design, + hoistVars: { rootSelector: '[data-skin="x"]' }, }); - const css = await cssPromise; + const css = assets[0]!.source; expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/); }); it('forwards the `properties` option (inline) so --tw-content resolves', async () => { // `after:absolute` emits `content: var(--tw-content)` with no setter. const source = `function App(){ return ; }`; - const cssPromise = new Promise((resolve) => { - compile(source, { - target: 'react', - plugins: [ - tailwindPlugin({ - design, - target: 'vanilla-css', - properties: { mode: 'inline' }, - onCss: (out) => { - if (out.kind === 'merged') resolve(out.css); - }, - }), - ], - }); - }); - const css = await cssPromise; + const { assets } = await compileTailwind(source, { mode: 'extract', design, properties: { mode: 'inline' } }); + const css = assets[0]!.source; expect(css).not.toMatch(/var\(--tw-content\)/); expect(collapse(css)).toContain(collapse('content: "";')); }); diff --git a/packages/compiler/src/tests/cli.test.ts b/packages/compiler/src/tests/cli.test.ts new file mode 100644 index 00000000..52c197a8 --- /dev/null +++ b/packages/compiler/src/tests/cli.test.ts @@ -0,0 +1,159 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); +const cliPath = fileURLToPath(new URL('../cli.ts', import.meta.url)); +const tsxPath = pathToFileURL(require.resolve('tsx')).href; + +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'compiler-cli-')); +}); + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('vjs compile', () => { + it('writes compiled code and emitted CSS assets', () => { + const inputPath = join(workDir, 'src', 'skin.tsx'); + const configPath = join(workDir, 'compiler.config.mjs'); + const outputPath = join(workDir, 'dist', 'skin.js'); + mkdirSync(dirname(inputPath), { recursive: true }); + writeFileSync(inputPath, `export function App(){ return ; }\n`, 'utf8'); + writeFileSync( + configPath, + `export default { + styles: { + name: 'fixture', + setup(context) { + return { + transform: () => (sourceFile) => sourceFile, + finish() { + context.addAsset({ type: 'css', fileName: 'skin.css', source: '.foo{display:flex;}' }); + }, + }; + }, + }, +}; +`, + 'utf8' + ); + + execFileSync( + process.execPath, + ['--import', tsxPath, cliPath, 'compile', inputPath, '--config', configPath, '--out', outputPath], + { + encoding: 'utf8', + } + ); + + expect(readFileSync(outputPath, 'utf8')).toContain('function App'); + expect(readFileSync(join(workDir, 'dist', 'skin.css'), 'utf8')).toBe('.foo{display:flex;}'); + }); + + it('prints compiler diagnostics with code frames', () => { + const inputPath = join(workDir, 'src', 'skin.tsx'); + const configPath = join(workDir, 'compiler.config.mjs'); + mkdirSync(dirname(inputPath), { recursive: true }); + writeFileSync(inputPath, `export function App(){ return ; }\n`, 'utf8'); + writeFileSync( + configPath, + `import { readFileSync } from 'node:fs'; + +export default { + styles: { + name: 'fixture', + setup(context) { + const error = new Error('Fixture failed'); + error.fileName = context.filename; + error.line = 1; + error.column = 30; + error.sourceText = readFileSync(context.filename, 'utf8'); + throw error; + }, + }, +}; +`, + 'utf8' + ); + + const result = spawnSync( + process.execPath, + ['--import', tsxPath, cliPath, 'compile', inputPath, '--config', configPath], + { + encoding: 'utf8', + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('[fixture] ERROR'); + expect(result.stderr).toContain('MESSAGE'); + expect(result.stderr).toContain('Fixture failed'); + expect(result.stderr).toContain('CODE'); + expect(result.stderr).toContain('> 1 | export function App'); + }); + + it('prints jsonl compiler diagnostics for agents', () => { + const inputPath = join(workDir, 'src', 'skin.tsx'); + const configPath = join(workDir, 'compiler.config.mjs'); + mkdirSync(dirname(inputPath), { recursive: true }); + writeFileSync(inputPath, `export function App(){ return ; }\n`, 'utf8'); + writeFileSync( + configPath, + `import { readFileSync } from 'node:fs'; + +export default { + styles: { + name: 'fixture', + setup(context) { + const error = new Error('Fixture failed'); + error.fileName = context.filename; + error.line = 1; + error.column = 30; + error.sourceText = readFileSync(context.filename, 'utf8'); + throw error; + }, + }, +}; +`, + 'utf8' + ); + + const result = spawnSync( + process.execPath, + ['--import', tsxPath, cliPath, 'compile', inputPath, '--config', configPath, '--diagnostics', 'jsonl'], + { + encoding: 'utf8', + } + ); + + const events = result.stderr + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + + expect(result.status).toBe(1); + expect(events[0]).toMatchObject({ + type: 'diagnostic', + level: 'error', + code: 'compiler-fatal', + plugin: 'fixture', + message: 'Fixture failed', + range: { start: { line: 1, column: 30 } }, + }); + expect(events[0].frame).toContainEqual({ + line: 1, + text: `export function App(){ return ; }`, + highlight: true, + }); + expect(events[0]).not.toHaveProperty('sourceText'); + expect(events[1]).toEqual({ type: 'summary', errors: 1, warnings: 0 }); + }); +}); diff --git a/packages/compiler/src/tests/compile.test.ts b/packages/compiler/src/tests/compile.test.ts index 54b28912..3a0d364e 100644 --- a/packages/compiler/src/tests/compile.test.ts +++ b/packages/compiler/src/tests/compile.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { compile, parse } from '..'; +import { compile, type ReactTargetOptions, react } from '..'; +import { parse } from '../ast'; import { anyTag, byTag, hasChild } from '../matchers'; import { addProp, childAsProp, replace, wrap } from '../react'; @@ -9,6 +10,9 @@ import { addProp, childAsProp, replace, wrap } from '../react'; */ const collapse = (s: string): string => s.replace(/\s+/g, ''); +const compileReact = (source: string, options: ReactTargetOptions = {}) => + compile(source, { config: { target: react(options) } }); + describe('parse', () => { it('produces a TSX SourceFile with parent pointers set', () => { const { ast } = parse('const x = ;'); @@ -18,9 +22,9 @@ describe('parse', () => { }); describe('compile (no transforms)', () => { - it('round-trips a simple TSX module', () => { + it('round-trips a simple TSX module', async () => { const source = `import { Foo } from 'bar';\nexport function App() { return ; }\n`; - const { code } = compile(source, { target: 'react' }); + const { code } = await compileReact(source); // Identifier and JSX preserved; quote/whitespace style is whatever the printer decides. expect(code).toContain('Foo'); expect(code).toContain('bar'); @@ -29,19 +33,17 @@ describe('compile (no transforms)', () => { }); describe('compile (transformImports — bare-string rule)', () => { - it('rewrites the module specifier and leaves identifiers untouched', () => { + it('rewrites the module specifier and leaves identifiers untouched', async () => { const source = `import { PlayIcon } from '@videojs/icons/components';\nconst _x = PlayIcon;`; - const { code } = compile(source, { - target: 'react', + const { code } = await compileReact(source, { imports: { '@videojs/icons/components': '@videojs/icons/react' }, }); expect(code).toContain(`import { PlayIcon } from "@videojs/icons/react"`); }); - it('leaves unrelated imports untouched', () => { + it('leaves unrelated imports untouched', async () => { const source = `import { Other } from 'unrelated';\nimport { PlayIcon } from '@videojs/icons/components';\nconst _ = [Other, PlayIcon];`; - const { code } = compile(source, { - target: 'react', + const { code } = await compileReact(source, { imports: { '@videojs/icons/components': '@videojs/icons/react' }, }); expect(code).toMatch(/from ['"]unrelated['"]/); @@ -50,10 +52,9 @@ describe('compile (transformImports — bare-string rule)', () => { }); describe('compile (transformImports — function rule)', () => { - it('rewrites per-identifier source and bucket-merges by resolved target', () => { + it('rewrites per-identifier source and bucket-merges by resolved target', async () => { const source = `import { PlayButton, MuteButton } from '@videojs/core/components';\nconst _ = [PlayButton, MuteButton];`; - const { code } = compile(source, { - target: 'react', + const { code } = await compileReact(source, { imports: { '@videojs/core/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }), }, @@ -62,10 +63,9 @@ describe('compile (transformImports — function rule)', () => { expect(code).toContain(`import { MuteButton } from "./ui/mutebutton"`); }); - it('renames identifiers when the rule returns a different `name`', () => { + it('renames identifiers when the rule returns a different `name`', async () => { const source = `import { OldName } from 'src';\nconst _ = OldName;`; - const { code } = compile(source, { - target: 'react', + const { code } = await compileReact(source, { imports: { src: (_name) => ({ source: 'dst', name: 'NewName' }) }, }); expect(code).toContain(`import { NewName as OldName } from "dst"`); @@ -73,32 +73,29 @@ describe('compile (transformImports — function rule)', () => { }); describe('replace', () => { - it('substitutes a matched element with a new tag and adds the import', () => { + it('substitutes a matched element with a new tag and adds the import', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], + const { code } = await compileReact(source, { + transforms: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], }); expect(code).toContain(` { + it('preserves children when matching an open element', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], + const { code } = await compileReact(source, { + transforms: [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', () => { + it('wraps a matched element with a new tag and adds the import', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [wrap({ match: byTag('Inner'), with: { source: 'pkg', name: 'Outer' } })], + const { code } = await compileReact(source, { + transforms: [wrap({ match: byTag('Inner'), with: { source: 'pkg', name: 'Outer' } })], }); expect(collapse(code)).toContain(collapse(``)); expect(code).toContain(`import { Outer } from "pkg"`); @@ -106,39 +103,35 @@ describe('wrap', () => { }); describe('childAsProp', () => { - it('lifts a single JSX-element child into the named prop', () => { + it('lifts a single JSX-element child into the named prop', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + const { code } = await compileReact(source, { + transforms: [childAsProp({ match: byTag('T'), prop: 'render' })], }); expect(collapse(code)).toContain(collapse(`}/>`)); }); - it('skips when prop is already set', () => { + it('skips when prop is already set', async () => { const source = `function App(){ return }>; }`; - const { code } = compile(source, { - target: 'react', - plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + const { code } = await compileReact(source, { + transforms: [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', () => { + it('skips when there are multiple JSX-element children', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [childAsProp({ match: byTag('T'), prop: 'render' })], + const { code } = await compileReact(source, { + transforms: [childAsProp({ match: byTag('T'), prop: 'render' })], }); expect(collapse(code)).toContain(collapse(``)); }); - it('matches an array of tags via anyTag', () => { + it('matches an array of tags via anyTag', async () => { const source = `function App(){ return <>; }`; - const { code } = compile(source, { - target: 'react', - plugins: [childAsProp({ match: anyTag(['T1', 'T2']), prop: 'render' })], + const { code } = await compileReact(source, { + transforms: [childAsProp({ match: anyTag(['T1', 'T2']), prop: 'render' })], }); const trimmed = collapse(code); expect(trimmed).toContain(collapse(`}/>`)); @@ -147,21 +140,21 @@ describe('childAsProp', () => { }); describe('addProp', () => { - it('emits a JSX value by default and adds the import', () => { + it('emits a JSX value by default and adds the import', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } })], + const { code } = await compileReact(source, { + transforms: [ + 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"', () => { + it('emits a bare reference when kind is "ref"', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ + const { code } = await compileReact(source, { + transforms: [ addProp({ match: byTag('PlayButton'), prop: 'as', @@ -172,21 +165,21 @@ describe('addProp', () => { expect(collapse(code)).toContain(collapse(``)); }); - it('skips elements where the prop is already set', () => { + it('skips elements where the prop is already set', async () => { const source = `function App(){ return }/>; }`; - const { code } = compile(source, { - target: 'react', - plugins: [addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } })], + const { code } = await compileReact(source, { + transforms: [ + 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', () => { + it('overwrites the existing prop when overwrite is true', async () => { const source = `function App(){ return }/>; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ + const { code } = await compileReact(source, { + transforms: [ addProp({ match: byTag('PlayButton'), prop: 'render', @@ -200,46 +193,42 @@ describe('addProp', () => { }); describe('matchers', () => { - it('byTag supports dotted tags', () => { + it('byTag supports dotted tags', async () => { const source = `function App(){ return ; }`; - const { code } = compile(source, { - target: 'react', - plugins: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })], + const { code } = await compileReact(source, { + transforms: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })], }); expect(code).toContain(` { + it('byTag honours `when` refinement', async () => { 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' } })], + const { code } = await compileReact(source, { + transforms: [replace({ match: byTag('Foo', { when: isA1 }), with: { source: 'pkg', name: 'Bar' } })], }); expect(code).toContain(` { + it('hasChild matches direct children only by default', async () => { 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' } })], + const { code } = await compileReact(source, { + transforms: [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', () => { + it('hasChild with deep:true matches descendants', async () => { const source = `function App(){ return
; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ + const { code } = await compileReact(source, { + transforms: [ replace({ match: byTag('A', { when: hasChild(byTag('B'), { deep: true }) }), with: { source: 'p', name: 'Z' }, @@ -249,13 +238,12 @@ describe('matchers', () => { expect(code).toContain(''); }); - it('hasChild composes with byTag for nested shape checks', () => { + it('hasChild composes with byTag for nested shape checks', async () => { const source = `function App(){ return <>; }`; - const { code } = compile(source, { - target: 'react', - plugins: [ + const { code } = await compileReact(source, { + transforms: [ replace({ match: byTag('Outer', { when: hasChild(byTag('Inner', { when: hasChild(byTag('Target')) })) }), with: { source: 'p', name: 'Matched' }, diff --git a/packages/compiler/src/tests/diagnostics.test.ts b/packages/compiler/src/tests/diagnostics.test.ts new file mode 100644 index 00000000..fc7edf8a --- /dev/null +++ b/packages/compiler/src/tests/diagnostics.test.ts @@ -0,0 +1,111 @@ +import type ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { CompilerError, compile, react } from '..'; +import { + diagnosticLocationFromNode, + formatCompilerDiagnostic, + formatCompilerDiagnosticJsonLine, + formatDiagnosticSummaryJsonLine, +} from '../diagnostics'; +import { DiagnosticError } from '../tailwind'; + +describe('formatCompilerDiagnostic', () => { + it('renders a diagnostic code frame', () => { + const output = formatCompilerDiagnostic( + { + level: 'error', + code: 'fixture-error', + message: 'Something went wrong', + file: '/workspace/skin.tsx', + line: 2, + column: 3, + sourceText: `const a = 1;\nconst b = 2;\nconst c = 3;`, + }, + { color: false, cwd: '/workspace' } + ); + + expect(output).toContain('[videojs/compiler] ERROR'); + expect(output).toContain('MESSAGE'); + expect(output).toContain('Something went wrong'); + expect(output).toContain('CODE'); + expect(output).toContain('skin.tsx L:2:3'); + expect(output).toContain('> 2 | const b = 2;'); + }); +}); + +describe('formatCompilerDiagnosticJsonLine', () => { + it('renders parseable agent diagnostics without source text', () => { + const line = formatCompilerDiagnosticJsonLine( + { + level: 'warning', + code: 'fixture-warning', + message: 'Check this', + plugin: 'fixture', + file: '/workspace/skin.tsx', + line: 2, + column: 3, + endLine: 2, + endColumn: 12, + sourceText: `const a = 1;\nconst b = 2;\nconst c = 3;`, + }, + { cwd: '/workspace' } + ); + + expect(line).toMatch(/\n$/); + expect(line).not.toContain('\u001b'); + expect(line).not.toContain('sourceText'); + expect(JSON.parse(line)).toEqual({ + type: 'diagnostic', + level: 'warning', + code: 'fixture-warning', + message: 'Check this', + plugin: 'fixture', + file: 'skin.tsx', + range: { + start: { line: 2, column: 3 }, + end: { line: 2, column: 12 }, + }, + frame: [ + { line: 1, text: 'const a = 1;', highlight: false }, + { line: 2, text: 'const b = 2;', highlight: true }, + { line: 3, text: 'const c = 3;', highlight: false }, + ], + }); + }); + + it('renders summary events', () => { + const line = formatDiagnosticSummaryJsonLine([ + { level: 'error', code: 'a', message: 'A' }, + { level: 'warning', code: 'b', message: 'B' }, + { level: 'warning', code: 'c', message: 'C' }, + ]); + + expect(JSON.parse(line)).toEqual({ type: 'summary', errors: 1, warnings: 2 }); + }); +}); + +describe('CompilerError diagnostics', () => { + it('preserves source ranges thrown from transforms', async () => { + const transform = (): ts.TransformerFactory => () => (sourceFile) => { + throw new DiagnosticError('Fixture transform failed', { + ...diagnosticLocationFromNode(sourceFile.statements[0]!), + diagnosticCode: 'fixture-transform', + }); + }; + + try { + await compile(`export function App(){ return ; }`, { + filename: '/workspace/skin.tsx', + config: { target: react({ transforms: [transform()] }) }, + }); + throw new Error('Expected compile to fail'); + } catch (error) { + expect(error).toBeInstanceOf(CompilerError); + const diagnostic = (error as CompilerError).diagnostics[0]!; + expect(diagnostic.code).toBe('fixture-transform'); + expect(diagnostic.file).toBe('/workspace/skin.tsx'); + expect(diagnostic.line).toBe(1); + expect(diagnostic.sourceText).toContain('export function App'); + } + }); +}); diff --git a/packages/compiler/src/tests/integration.test.ts b/packages/compiler/src/tests/integration.test.ts index 7362b874..714d58f6 100644 --- a/packages/compiler/src/tests/integration.test.ts +++ b/packages/compiler/src/tests/integration.test.ts @@ -1,8 +1,8 @@ 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 { beforeAll, describe, expect, it } from 'vitest'; +import { compile, type ImportRule, react } from '..'; import { anyTag, byTag, hasChild } from '../matchers'; import { childAsProp, replace } from '../react'; @@ -19,6 +19,7 @@ const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx'); */ describe('integration: default/video skin → React', () => { const source = readFileSync(skinSource, 'utf8'); + let code = ''; const imports: Record = { '@videojs/core/components': (name) => ({ @@ -29,19 +30,25 @@ describe('integration: default/video skin → 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')) })), + beforeAll(async () => { + const result = await compile(source, { + config: { + target: react({ + imports, + transforms: [ + 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' }), + ], }), - with: { source: './volume-popover', name: 'VolumePopover' }, - mapChildren: () => [], - }), - childAsProp({ match: anyTag(['Tooltip.Trigger', 'Popover.Trigger']), prop: 'render' }), - ], + }, + }); + code = result.code; }); it('rewrites @videojs/core/components imports to per-identifier UI sources', () => { diff --git a/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts b/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts index cbab7b7b..9b5d64bc 100644 --- a/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts +++ b/packages/compiler/src/transforms/tests/drop-unused-locals.test.ts @@ -1,37 +1,39 @@ import { describe, expect, it } from 'vitest'; import { compile } from '../../compile'; +import { react } from '../../config'; import { dropUnusedLocals } from '../drop-unused-locals'; -const wrap = (source: string): string => compile(source, { target: 'react', plugins: [dropUnusedLocals()] }).code; +const wrap = async (source: string): Promise => + (await compile(source, { config: { target: react({ transforms: [dropUnusedLocals()] }) } })).code; describe('dropUnusedLocals', () => { - it('drops an unused cn() local', () => { - const code = wrap(`const x = cn('a', 'b');\nfunction App(){ return ; }`); + it('drops an unused cn() local', async () => { + const code = await 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 ; }`); + it('keeps a referenced cn() local', async () => { + const code = await 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 ; }`); + it('keeps an unused non-cn() local (conservative)', async () => { + const code = await 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 ; }`); + it('keeps an unused cn() with non-pure args (conservative)', async () => { + const code = await 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 ; }`); + it('keeps exported declarations untouched', async () => { + const code = await 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 ; }`); + it('drops nested cn() arg patterns too', async () => { + const code = await wrap(`const x = cn('a', cn('b', 'c'));\nfunction App(){ return ; }`); expect(code).not.toContain('const x ='); }); }); diff --git a/packages/compiler/tsdown.config.ts b/packages/compiler/tsdown.config.ts index 4b483ca9..8ce7de94 100644 --- a/packages/compiler/tsdown.config.ts +++ b/packages/compiler/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'jsx-runtime': './src/jsx-runtime.ts', 'jsx-dev-runtime': './src/jsx-dev-runtime.ts', 'plugins/vite': './src/plugins/vite.ts', + 'ast/index': './src/ast/index.ts', 'matchers/index': './src/matchers/index.ts', 'react/index': './src/react/index.ts', 'styles/index': './src/styles/index.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1ee5166..29f95937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../utils + kleur: + specifier: ^4.1.5 + version: 4.1.5 lightningcss: specifier: ^1.32.0 version: 1.32.0