From fcee71e1eb7b414471ef42c542ea951154d40909 Mon Sep 17 00:00:00 2001 From: Rahim Date: Sun, 21 Jun 2026 17:36:49 -0700 Subject: [PATCH] feat(compiler): add skin project compilation --- .claude/plans/skin-jsx-migration.md | 149 ++++- packages/compiler/package.json | 16 - .../compiler/src/bundlers/tests/vite.test.ts | 50 +- packages/compiler/src/cli.ts | 23 +- packages/compiler/src/compile.ts | 40 +- packages/compiler/src/config.ts | 20 +- packages/compiler/src/index.ts | 99 ++- packages/compiler/src/project.ts | 136 +++++ packages/compiler/src/styles/analyze.ts | 41 +- .../compiler/src/styles/tests/analyze.test.ts | 28 +- packages/compiler/src/tailwind/evaluator.ts | 7 +- packages/compiler/src/tailwind/plugin.ts | 28 +- .../src/tailwind/tests/naming.test.ts | 6 +- .../src/tailwind/tests/plugin.test.ts | 63 +- packages/compiler/src/tests/cli.test.ts | 90 ++- packages/compiler/src/tests/transform.test.ts | 106 ++++ packages/compiler/src/transform.ts | 567 ++++++++++++++++++ .../compiler/src/transforms/add-import.ts | 10 +- .../src/transforms/drop-unused-locals.ts | 51 +- packages/compiler/src/transforms/imports.ts | 4 +- .../tests/drop-unused-locals.test.ts | 20 +- packages/compiler/tsdown.config.ts | 4 - .../src/core/ui/tests/jsx-runtime.test-d.tsx | 5 +- packages/core/src/jsx-runtime.ts | 4 +- packages/react/package.json | 4 + packages/react/skins.compiler.config.ts | 66 ++ .../tests/skins-compiler-config.test.ts | 37 ++ .../default-video.generated.tailwind.tsx | 218 +++++++ packages/react/tsconfig.json | 2 +- packages/skins/src/default/video.skin.tsx | 51 +- pnpm-lock.yaml | 9 + 31 files changed, 1693 insertions(+), 261 deletions(-) create mode 100644 packages/compiler/src/project.ts create mode 100644 packages/compiler/src/tests/transform.test.ts create mode 100644 packages/compiler/src/transform.ts create mode 100644 packages/react/skins.compiler.config.ts create mode 100644 packages/react/src/presets/tests/skins-compiler-config.test.ts create mode 100644 packages/react/src/presets/video/default-video.generated.tailwind.tsx diff --git a/.claude/plans/skin-jsx-migration.md b/.claude/plans/skin-jsx-migration.md index 35254aff..0f1edf5b 100644 --- a/.claude/plans/skin-jsx-migration.md +++ b/.claude/plans/skin-jsx-migration.md @@ -145,9 +145,150 @@ Gate question: Purpose: compile the Phase 1 source skin into a React Tailwind module that matches the current manual React output closely enough for focused tests/review. +Status: +- Replaced the temporary React-owned compiler module/script with `packages/react/skins.compiler.config.ts`. +- The compiler now has an additive project path: config `input` / `output` / `plugins`, `compileProject(config, options)`, and CLI project mode via `vjs compile --config ...` when no file is passed. +- The compiler exposes a generic `transform(({ ref, match, create, edit }) => [...])` plugin helper. React lowering is expressed with generic import, JSX, and interface/type edits rather than React-specific compiler internals. +- The compiler config is plugins-only; the temporary `config.styles` compatibility slot was removed to avoid competing extension paths. +- Current generated output is checked in at `packages/react/src/presets/video/default-video.generated.tailwind.tsx` as an unexported temporary generated artifact. +- Current lowering covers the represented `default/video` source slice only: component/icon import rewrites, Tailwind token import rewrite, `Tooltip.Trigger` child-as-`render`, `Controls.Root` `data-controls` marker, `className` array to React `cn(...)`, and source `children?: unknown` to React `ReactNode`. +- Existing manual React presets remain untouched and still provide the runtime/exported implementation. + +Remaining prototype gaps before replacing a manual preset: +- Generated output does not yet add the manual React `Button`/slider render wrappers; it relies on current React component default elements plus source-authored classes. +- The source skin still omits known structural gaps from Phase 1: poster, buffering indicator, error dialog internals, overlay, volume popover, settings menu, thumbnail shell, and input feedback shells. +- `compile:skins` emits TypeScript-printer formatting; checked-in output is formatted with Biome after generation. + +Compiler config direction: +- `packages/react/skins.compiler.config.ts` is now the React lowering entrypoint. +- Shape the compiler config like a small Vite/Rollup-style source generator, not a bundler. The core job is deterministic source generation from constrained JSX to generated TSX/CSS assets. +- The compiler config should point at source/output paths directly, not require a React-owned script to call `compile()` manually. +- Prefer Rollup-like `input`/`output` plus plugins for the project shape: + +```ts +input: { + 'default-video': '../skins/src/default/video.skin.tsx', +}, +output: { + dir: 'src/presets/video', + entryFileNames: '[name].generated.tailwind.tsx', + banner: '// Generated by @videojs/compiler. Do not edit.\n', +} +``` + +- Keep exact per-entry outputs as a possible later escape hatch if generated source files need paths that cannot be expressed cleanly with `output.dir` and `entryFileNames`. +- Replace `pipeline([...])` with a `transform(...)` plugin factory. The callback receives transform-context helpers and returns one ordered declarative pipeline. +- `ref.import(...)` creates lazy symbol references used by transforms/builders. It should not emit imports immediately; imports are emitted only when a transform actually uses the reference. +- Keep matching, creation, and mutation separate: + - `match.*` selects nodes and composes predicates. + - `create.*` builds expressions, types, JSX values, and declarations. + - `edit.*` applies visitors/mutations. +- Prefer top-level `match` with domain subnamespaces so composition has one mental model: + - `match.jsx.tag('Controls.Root')` + - `match.jsx.attribute('className')` + - `match.interface.name(/Props$/)` + - `match.interface.property('children')` + - `match.import.source('@videojs/core/components')` +- Keep edit domains explicit: + - `edit.import.rewrite(...)` + - `edit.jsx.element(...)` + - `edit.jsx.attribute(...)` + - `edit.interface.property(...)` +- Do not add a one-off compiler API such as `jsx.arrayAttributeToCall(...)`. Express that as generic composition: match a JSX attribute whose value is an array, then replace the value with a call expression built from `create.expr.call(cn, create.jsx.arrayElements(value))`. +- React config sketch: + +```ts +import { defineConfig, transform } from '@videojs/compiler'; +import { tailwind } from '@videojs/compiler/tailwind'; + +export default defineConfig({ + input: { + 'default-video': '../skins/src/default/video.skin.tsx', + }, + output: { + dir: 'src/presets/video', + entryFileNames: '[name].generated.tailwind.tsx', + banner: '// Generated by @videojs/compiler. Do not edit.\n', + }, + plugins: [ + transform(({ ref, match, create, edit }) => { + const cn = ref.import('@videojs/utils/style', 'cn'); + const ReactNode = ref.import('react', 'ReactNode', { type: true }); + + return [ + edit.import.rewrite({ + '@videojs/core/components': coreComponentImport, + '@videojs/icons/components': '@/icons', + './tailwind/video.tailwind': '@videojs/skins/default/tailwind/video.tailwind', + }), + tailwind({ mode: 'preserve' }), + edit.jsx.element({ + match: match.jsx.tag('Tooltip.Trigger'), + transform: edit.jsx.childAsProp('render'), + }), + edit.jsx.element({ + match: match.jsx.tag('Controls.Root'), + transform: edit.jsx.addAttribute('data-controls', ''), + }), + edit.jsx.attribute({ + match: match.all(match.jsx.attribute('className'), match.jsx.value.array()), + transform: ({ value }) => create.expr.call(cn, create.jsx.arrayElements(value)), + }), + edit.interface.property({ + match: match.all(match.interface.name(/Props$/), match.interface.property('children')), + transform: edit.interface.setType(() => create.type.union(create.type.ref(ReactNode), create.type.undefined())), + }), + ]; + }), + ], +}); +``` + +Compiler plugin/lifecycle direction: +- Internally, the lifecycle can be Vite/Rollup-shaped: + +```text +config -> buildStart -> resolve -> load -> transform -> render -> write -> writeBundle +``` + +- Publicly expose only the hooks that are pulling their weight. Today that means `transform` first, plus project config/output handling. +- Initial public plugin API can stay minimal: + +```ts +interface CompilerPlugin { + name: string; + enforce?: 'pre' | 'post'; + config?(config: CompilerConfig): CompilerConfig | void | Promise; + buildStart?(ctx: BuildContext): void | Promise; + transform?(module: ModuleTransform, ctx: TransformContext): TransformResult | null | void | Promise; + render?(module: RenderModule, ctx: RenderContext): RenderResult | null | void | Promise; + writeBundle?(bundle: OutputBundle, ctx: WriteContext): void | Promise; +} +``` + +- Keep `resolve` and `load` internal at first unless Tailwind/source resolution immediately needs plugin participation. +- Hook responsibilities: + - `config`: normalize `input`/`output`, expand entries, validate output path collisions, and fail early. + - `buildStart`: initialize shared plugin state, load design systems once, and validate required files. + - `resolve`: locate source entries, token modules, virtual modules, and config-relative paths consistently. Do not use this for generated import rewrites. + - `load`: read source text, with a later path for virtual skins or composed variants. + - `transform`: parse/edit source, rewrite generated imports, lower JSX, update interfaces/types, process className, and run Tailwind transforms. + - `render`: final per-file shaping such as generated banners, source pragma removal, comment normalization, or formatter integration. + - `writeBundle`: whole-output validation, stale output checks, drift reporting, and summaries. +- Import rewriting remains a transform, not `resolve`. Rollup `resolveId` answers "what file should this import load?"; React lowering answers "what import should generated source contain?" and belongs in `edit.import.rewrite(...)`. +- `tailwind({ mode: 'preserve' })` should also be a compiler plugin so Tailwind work participates in the same lifecycle and can emit CSS assets through plugin context when running in extract mode. + +Implemented compiler surface: +- Project compilation for config input/output via `compileProject(config, options)` and CLI project mode. +- Generic AST primitives in `@videojs/compiler`: `match`, `create`, `edit`, and transform-context `ref` helpers. +- Lazy value/type import materialization through `ref.import(...)` and `create.*` builders. +- Generic interface/type transforms for `children?: unknown` to `ReactNode` without React importing `typescript`. +- Generic JSX element/attribute transforms for child-as-prop, adding attributes, and replacing attribute values. +- The source-file pragma was removed from `packages/skins/src/default/video.skin.tsx`; skins rely on `packages/skins/tsconfig.json` `jsxImportSource`. + Steps: -- Add a React-owned generation script, likely `packages/react/scripts/compile-skins.ts`. -- Call `compile()` from `@videojs/compiler` programmatically. +- Replace the React-owned generation script with a compiler config and project compilation entrypoint. +- Call the compiler CLI/project API against that config rather than calling `compile()` directly from React code. - Configure import rewrites: - `@videojs/core/components` to React UI modules or package-local barrel targets. - `@videojs/icons/components` to React icon modules. @@ -164,6 +305,10 @@ Verification: - Generated `default/video` React Tailwind skin typechecks. - Compare generated output against `packages/react/src/presets/video/skin.tailwind.tsx` for structural parity. - Run focused React package typecheck/build once hooked into the package. +- Completed checks for the config path: + - `pnpm -F @videojs/react compile:skins` + - `pnpm -F @videojs/react test src/presets/tests/skins-compiler-config.test.ts` + - `pnpm -F @videojs/react build` Gate question: - Should generated files be committed into `packages/react/src/presets/**`, generated into `__generated__` and re-exported, or generated only at build time? diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 50fb73ec..05668c25 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -19,22 +19,6 @@ "types": "./dist/bundlers/vite.d.ts", "default": "./dist/bundlers/vite.js" }, - "./jsx": { - "types": "./dist/jsx/index.d.ts", - "default": "./dist/jsx/index.js" - }, - "./diagnostics": { - "types": "./dist/diagnostics.d.ts", - "default": "./dist/diagnostics.js" - }, - "./transforms": { - "types": "./dist/transforms/index.d.ts", - "default": "./dist/transforms/index.js" - }, - "./styles": { - "types": "./dist/styles/index.d.ts", - "default": "./dist/styles/index.js" - }, "./tailwind": { "types": "./dist/tailwind/index.d.ts", "default": "./dist/tailwind/index.js" diff --git a/packages/compiler/src/bundlers/tests/vite.test.ts b/packages/compiler/src/bundlers/tests/vite.test.ts index bc2b4264..782d40eb 100644 --- a/packages/compiler/src/bundlers/tests/vite.test.ts +++ b/packages/compiler/src/bundlers/tests/vite.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { CompilerConfig } from '../../config'; +import type { CompilerPlugin } from '../../config'; import { vjsCompiler } from '../vite'; type TestPlugin = { @@ -15,7 +15,7 @@ type TestPlugin = { const createPlugin = (...args: Parameters): TestPlugin => vjsCompiler(...args) as unknown as TestPlugin; -const createCssStyle = (source: string): NonNullable => ({ +const createCssPlugin = (source: string): CompilerPlugin => ({ name: 'fixture', setup(context) { return { @@ -29,7 +29,7 @@ const createCssStyle = (source: string): NonNullable = describe('vjsCompiler', () => { it('imports emitted CSS assets as virtual modules', async () => { - const plugin = createPlugin({ config: { styles: createCssStyle('.foo{display:flex;}') } }); + const plugin = createPlugin({ config: { plugins: [createCssPlugin('.foo{display:flex;}')] } }); const result = await plugin.transform.call( { warn: () => {} }, @@ -52,13 +52,15 @@ describe('vjsCompiler', () => { const warn = vi.fn(); const plugin = createPlugin({ config: { - styles: { - name: 'fixture', - setup(context) { - context.report({ level: 'warning', code: 'fixture-warning', message: 'Check this', plugin: 'fixture' }); - return { transform: () => (sourceFile) => sourceFile }; + plugins: [ + { + name: 'fixture', + setup(context) { + context.report({ level: 'warning', code: 'fixture-warning', message: 'Check this', plugin: 'fixture' }); + return { transform: () => (sourceFile) => sourceFile }; + }, }, - }, + ], }, }); @@ -71,21 +73,23 @@ describe('vjsCompiler', () => { const warn = vi.fn(); const plugin = createPlugin({ 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 }; + plugins: [ + { + 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 }; + }, }, - }, + ], }, }); diff --git a/packages/compiler/src/cli.ts b/packages/compiler/src/cli.ts index 83318e63..35fb68ff 100644 --- a/packages/compiler/src/cli.ts +++ b/packages/compiler/src/cli.ts @@ -11,6 +11,7 @@ import { formatDiagnosticSummaryJsonLine, } from './diagnostics'; import { loadConfig } from './load-config'; +import { compileProject } from './project'; interface ParsedArgs { command: string | undefined; @@ -53,7 +54,7 @@ function printHelp(): void { 'Usage: vjs [options]', '', 'Commands:', - ' compile Compile a JSX file', + ' compile [file] Compile a JSX file, or compile config.input when file is omitted', '', 'Options:', ' -c, --config Path to a compiler config (default: compiler.config.ts in cwd)', @@ -72,12 +73,26 @@ async function runCompile( diagnosticsFormat: DiagnosticFormat ): Promise { const file = positional[0]; - if (!file) throw new Error('Usage: vjs compile '); - 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); + + if (!file) { + if (!loaded?.config.input) throw new Error('Usage: vjs compile or configure `input`.'); + if (outputPath) throw new Error('`--out` is only supported when compiling a single file. Use `output` in config.'); + + const result = await compileProject(loaded.config, { configDir: loaded.configDir, cwd }); + writeDiagnostics(result.diagnostics, diagnosticsFormat, { summary: diagnosticsFormat === 'jsonl' }); + + for (const file of result.files) { + mkdirSync(dirname(file.fileName), { recursive: true }); + writeFileSync(file.fileName, file.source, 'utf8'); + process.stdout.write(`Wrote ${file.fileName}\n`); + } + return; + } + + const inputPath = isAbsolute(file) ? file : resolve(cwd, file); const source = readFileSync(inputPath, 'utf8'); const result = await compile(source, { filename: inputPath, diff --git a/packages/compiler/src/compile.ts b/packages/compiler/src/compile.ts index 7073c617..823f45a0 100644 --- a/packages/compiler/src/compile.ts +++ b/packages/compiler/src/compile.ts @@ -5,6 +5,7 @@ import { type CompilerContext, type CompilerDiagnostic, type CompilerPipelineStep, + type CompilerPlugin, type CompilerTransform, jsx, } from './config'; @@ -76,7 +77,7 @@ export async function compile(source: string, options: CompileOptions = {}): Pro const { ast } = parse(source, { filename }); const transformers: CompilerTransform[] = []; - let styleStep: CompilerPipelineStep | undefined; + const finishers: Array<() => void | Promise> = []; if (target.imports) { transformers.push( @@ -88,14 +89,10 @@ export async function compile(source: string, options: CompileOptions = {}): Pro ); } - 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 } - ); + for (const plugin of orderPlugins(config.plugins ?? [])) { + const step = await setupPipelineStep(plugin.name, () => plugin.setup?.(context), filename, source); + if (step?.transform) transformers.push(step.transform); + if (step?.finish) finishers.push(step.finish); } if (target.transforms) transformers.push(...target.transforms); @@ -109,6 +106,7 @@ export async function compile(source: string, options: CompileOptions = {}): Pro } if (transformers.length === 0) { + for (const finish of finishers) await finish(); return { code: separateTopLevel(printer.printFile(ast)), map: null, assets, diagnostics }; } @@ -118,7 +116,7 @@ export async function compile(source: string, options: CompileOptions = {}): Pro const transformed = result.transformed[0]!; const code = separateTopLevel(printer.printFile(transformed)); - await styleStep?.finish?.(); + for (const finish of finishers) await finish(); return { code, map: null, assets, diagnostics }; } catch (error) { @@ -129,6 +127,28 @@ export async function compile(source: string, options: CompileOptions = {}): Pro } } +function orderPlugins(plugins: readonly CompilerPlugin[]): CompilerPlugin[] { + const pre = plugins.filter((plugin) => plugin.enforce === 'pre'); + const normal = plugins.filter((plugin) => plugin.enforce === undefined); + const post = plugins.filter((plugin) => plugin.enforce === 'post'); + return [...pre, ...normal, ...post]; +} + +async function setupPipelineStep( + plugin: string, + setup: () => CompilerPipelineStep | Promise | undefined, + filename: string, + source: string +): Promise { + try { + return await setup(); + } catch (error) { + throw new CompilerError([fatalDiagnosticFromError(error, { filename, sourceText: source, plugin })], { + cause: error, + }); + } +} + /** * Insert blank lines between top-level statements so the printer's dense * output is at least readable. Biome will normalize quote/indent/import diff --git a/packages/compiler/src/config.ts b/packages/compiler/src/config.ts index 81be0dea..4b0efa25 100644 --- a/packages/compiler/src/config.ts +++ b/packages/compiler/src/config.ts @@ -36,9 +36,12 @@ export interface CompilerPipelineStep { finish?: (() => void | Promise) | undefined; } -export interface StylePipeline { +export type CompilerPluginEnforce = 'pre' | 'post'; + +export interface CompilerPlugin { name: string; - setup(context: CompilerContext): CompilerPipelineStep | Promise; + enforce?: CompilerPluginEnforce | undefined; + setup?(context: CompilerContext): CompilerPipelineStep | Promise; } /** Per-target compile configuration for JSX transforms. */ @@ -57,8 +60,19 @@ export interface CompilerTarget { export interface CompilerConfig { files?: readonly string[] | undefined; + input?: CompilerInput | undefined; + output?: CompilerOutputOptions | undefined; + plugins?: readonly CompilerPlugin[] | undefined; target?: CompilerTarget | undefined; - styles?: StylePipeline | undefined; +} + +export type CompilerInput = string | readonly string[] | Record; + +export interface CompilerOutputOptions { + dir?: string | undefined; + file?: string | undefined; + entryFileNames?: string | undefined; + banner?: string | undefined; } export function defineConfig(config: Config): Config { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 56ebd0ff..4c6b67dc 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -4,9 +4,106 @@ export { type CompilerConfig, type CompilerContext, type CompilerDiagnostic, + type CompilerInput, + type CompilerOutputOptions, type CompilerPipelineStep, + type CompilerPlugin, + type CompilerPluginEnforce, type CompilerTarget, type CompilerTransform, defineConfig, - type StylePipeline, } from './config'; +export { + compilerDiagnosticToJsonEvent, + type DiagnosticFormat, + type DiagnosticJsonEvent, + type DiagnosticJsonFrameLine, + type DiagnosticLocation, + type DiagnosticSummaryJsonEvent, + diagnosticLocationFromNode, + diagnosticSummaryToJsonEvent, + type FormatDiagnosticOptions, + fatalDiagnosticFromError, + formatCompilerDiagnostic, + formatCompilerDiagnosticJsonLine, + formatDiagnosticSummaryJsonLine, + LogLevel, + type LogLevelName, + mapLogLevelStringToNumber, + mapLogLevelToString, + shouldUseColor, + withDiagnosticSource, +} from './diagnostics'; +export { + type AddPropImportRef, + type AddPropOptions, + accessPath, + addProp, + anyTag, + byTag, + type ChildAsPropOptions, + childAsProp, + hasChild, + type JsxChildReplacement, + type JsxElementLike, + type JsxTargetOptions, + jsx, + jsxExpression, + type Matcher, + propertyAccess, + type ReplaceJsxChildOptions, + type ReplaceOptions, + readStringAttribute, + replace, + replaceJsxChild, + tagName, + type WrapOptions, + wrap, +} from './jsx'; +export { + type CompileProjectOptions, + type CompileProjectResult, + compileProject, + type ProjectOutputFile, +} from './project'; +export { + type AnalyzeStylesOptions, + analyzeStyles, + type StyleAttributeInfo, + type StyleSegment, + type StyleVisitor, + type StyleVisitorResult, +} from './styles'; +export { + type CreateHelpers, + type EditHelpers, + type ImportReference, + type InterfacePropertyContext, + type InterfacePropertyEdit, + type InterfacePropertyEditOptions, + type JsxAttributeContext, + type JsxAttributeEditOptions, + type JsxElementContext, + type JsxElementEdit, + type JsxElementEditOptions, + type MatchHelpers, + type MatchPredicate, + type RefHelpers, + type TransformCallback, + type TransformHelpers, + type TransformOptions, + type TransformStep, + transform, +} from './transform'; +export { + type AddImportContext, + type AddImportRef, + addNamedImport, + dropUnusedImports, + dropUnusedLocals, + type ImportRef, + type ImportRewriteOptions, + type ImportRule, + resolveRelative, + transformImports, +} from './transforms'; diff --git a/packages/compiler/src/project.ts b/packages/compiler/src/project.ts new file mode 100644 index 00000000..3a0e8d4c --- /dev/null +++ b/packages/compiler/src/project.ts @@ -0,0 +1,136 @@ +import { readFile } from 'node:fs/promises'; +import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; +import { compile } from './compile'; +import type { CompilerAsset, CompilerConfig, CompilerDiagnostic, CompilerInput } from './config'; + +export interface CompileProjectOptions { + configDir?: string | undefined; + cwd?: string | undefined; +} + +export interface ProjectOutputFile { + type: 'chunk' | 'asset'; + fileName: string; + source: string; +} + +export interface CompileProjectResult { + files: readonly ProjectOutputFile[]; + diagnostics: readonly CompilerDiagnostic[]; +} + +interface ProjectEntry { + name: string; + inputFile: string; + outputFile: string; +} + +export async function compileProject( + config: CompilerConfig, + options: CompileProjectOptions = {} +): Promise { + if (!config.input) { + throw new Error('Compiler project config requires `input`.'); + } + + const configDir = options.configDir ?? options.cwd ?? process.cwd(); + const entries = normalizeEntries( + config.input, + configDir, + config.output?.entryFileNames, + config.output?.dir, + config.output?.file + ); + const files: ProjectOutputFile[] = []; + const diagnostics: CompilerDiagnostic[] = []; + + for (const entry of entries) { + const source = await readFile(entry.inputFile, 'utf8'); + const result = await compile(source, { + filename: entry.inputFile, + config, + configDir, + outputFile: entry.outputFile, + }); + + diagnostics.push(...result.diagnostics); + files.push({ + type: 'chunk', + fileName: entry.outputFile, + source: `${config.output?.banner ?? ''}${result.code}`, + }); + + for (const asset of result.assets) { + files.push(outputFromAsset(asset, entry.outputFile)); + } + } + + return { files, diagnostics }; +} + +function normalizeEntries( + input: CompilerInput, + configDir: string, + entryFileNames = '[name].js', + outputDir = 'dist', + outputFile: string | undefined +): ProjectEntry[] { + const entries = inputEntries(input, configDir); + if (outputFile && entries.length !== 1) { + throw new Error('Compiler project config can only use `output.file` with one input entry.'); + } + + const seenOutputs = new Set(); + return entries.map((entry) => { + const file = outputFile + ? resolveConfigPath(outputFile, configDir) + : resolveConfigPath(join(outputDir, renderEntryFileName(entryFileNames, entry.name, entry.inputFile)), configDir); + if (seenOutputs.has(file)) throw new Error(`Compiler project output collision: ${file}`); + seenOutputs.add(file); + return { ...entry, outputFile: file }; + }); +} + +function inputEntries(input: CompilerInput, configDir: string): Array> { + if (typeof input === 'string') { + const inputFile = resolveConfigPath(input, configDir); + return [{ name: entryNameFromPath(inputFile), inputFile }]; + } + + if (Array.isArray(input)) { + const seen = new Set(); + return input.map((file) => { + const inputFile = resolveConfigPath(file, configDir); + const name = entryNameFromPath(inputFile); + if (seen.has(name)) throw new Error(`Compiler project input name collision: ${name}`); + seen.add(name); + return { name, inputFile }; + }); + } + + return Object.entries(input).map(([name, file]) => ({ name, inputFile: resolveConfigPath(file, configDir) })); +} + +function outputFromAsset(asset: CompilerAsset, entryOutputFile: string): ProjectOutputFile { + const fileName = isAbsolute(asset.fileName) ? asset.fileName : resolve(dirname(entryOutputFile), asset.fileName); + return { type: 'asset', fileName, source: asset.source }; +} + +function resolveConfigPath(path: string, configDir: string): string { + return isAbsolute(path) ? path : resolve(configDir, path); +} + +function entryNameFromPath(path: string): string { + const base = basename(path); + const ext = extname(base); + return ext ? base.slice(0, -ext.length) : base; +} + +function renderEntryFileName(pattern: string, name: string, inputFile: string): string { + const base = basename(inputFile); + const ext = extname(base); + return pattern + .replaceAll('[name]', name) + .replaceAll('[base]', base) + .replaceAll('[ext]', ext.startsWith('.') ? ext.slice(1) : ext); +} diff --git a/packages/compiler/src/styles/analyze.ts b/packages/compiler/src/styles/analyze.ts index 634c0616..905c1793 100644 --- a/packages/compiler/src/styles/analyze.ts +++ b/packages/compiler/src/styles/analyze.ts @@ -16,11 +16,11 @@ export type StyleSegment = * The `className` attribute on a JSX element, plus everything the visitor * needs to inspect or rewrite it. Two shapes: * - * - `kind: 'segments'` — the value is either a literal string, a `cn(...)` - * call, or a single dotted token reference. We can decompose it into - * ordered `StyleSegment`s. + * - `kind: 'segments'` — the value is a literal string, className array, or + * single dotted token reference. We can decompose it into ordered + * `StyleSegment`s. * - `kind: 'opaque'` — anything else (computed expressions, ternaries that - * don't reduce, function calls other than `cn`). Visitors should pass. + * don't reduce, function calls). Visitors should pass. */ export type StyleAttributeInfo = StyleAttributeSegmentsInfo | StyleAttributeOpaqueInfo; @@ -55,11 +55,6 @@ export type StyleVisitor = (info: StyleAttributeInfo, factory: ts.NodeFactory) = export interface AnalyzeStylesOptions { visit: StyleVisitor; - /** - * Name of the helper call we treat as a class-merge (default: `'cn'`). - * Override if your skin module uses a different name. - */ - mergeFn?: string; } /** @@ -72,7 +67,7 @@ export interface AnalyzeStylesOptions { * compose it. */ export function analyzeStyles(options: AnalyzeStylesOptions): ts.TransformerFactory { - const { visit, mergeFn = 'cn' } = options; + const { visit } = options; return (transformContext) => { const factory = transformContext.factory; @@ -80,7 +75,7 @@ export function analyzeStyles(options: AnalyzeStylesOptions): ts.TransformerFact return (sourceFile) => { const visitNode = (node: ts.Node): ts.Node => { if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - const visited = visitJsxElement(node as JsxElementLike, factory, visit, mergeFn, transformContext); + const visited = visitJsxElement(node as JsxElementLike, factory, visit, transformContext); // Continue descending into the (possibly transformed) element. return ts.visitEachChild(visited, visitNode, transformContext); } @@ -96,7 +91,6 @@ function visitJsxElement( element: JsxElementLike, factory: ts.NodeFactory, visit: StyleVisitor, - mergeFn: string, context: ts.TransformationContext ): JsxElementLike { const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes; @@ -106,7 +100,7 @@ function visitJsxElement( const expression = readAttributeExpression(classNameAttr); if (!expression) return element; - const info: StyleAttributeInfo = decompose(element, classNameAttr, expression, mergeFn); + const info: StyleAttributeInfo = decompose(element, classNameAttr, expression); const replacement = visit(info, factory); if (replacement === undefined) return element; @@ -131,12 +125,7 @@ function readAttributeExpression(attr: ts.JsxAttribute): ts.Expression | undefin return undefined; } -function decompose( - element: JsxElementLike, - attribute: ts.JsxAttribute, - expression: ts.Expression, - mergeFn: string -): StyleAttributeInfo { +function decompose(element: JsxElementLike, attribute: ts.JsxAttribute, expression: ts.Expression): StyleAttributeInfo { // Literal string: `className="foo bar"` or `className={'foo bar'}`. if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { return { @@ -162,11 +151,12 @@ function decompose( } } - // `cn(...)` call: decompose each argument. - if (ts.isCallExpression(expression) && isMergeCall(expression, mergeFn)) { + // Array literal: decompose each element. + if (ts.isArrayLiteralExpression(expression)) { const segments: StyleSegment[] = []; - for (const arg of expression.arguments) { - segments.push(classifySegment(arg)); + for (const item of expression.elements) { + if (ts.isSpreadElement(item)) return { element, attribute, expression, kind: 'opaque' }; + segments.push(classifySegment(item)); } return { element, @@ -181,11 +171,6 @@ function decompose( return { element, attribute, expression, kind: 'opaque' }; } -function isMergeCall(call: ts.CallExpression, mergeFn: string): boolean { - const callee = call.expression; - return ts.isIdentifier(callee) && callee.text === mergeFn; -} - function classifySegment(arg: ts.Expression): StyleSegment { if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { return { kind: 'literal', value: arg.text, node: arg }; diff --git a/packages/compiler/src/styles/tests/analyze.test.ts b/packages/compiler/src/styles/tests/analyze.test.ts index 656e4c86..e1f18f79 100644 --- a/packages/compiler/src/styles/tests/analyze.test.ts +++ b/packages/compiler/src/styles/tests/analyze.test.ts @@ -60,9 +60,9 @@ describe('analyzeStyles — decomposition', () => { expect(info.segments[0]).toMatchObject({ kind: 'token', path: ['styles', 'button', 'icon'] }); }); - it('decomposes a `cn(...)` call into mixed segments', async () => { + it('decomposes a className array into mixed segments', async () => { const source = `function App(){ - return
; + return
; }`; const infos = await collectSegments(source); expect(infos).toHaveLength(1); @@ -87,7 +87,7 @@ describe('analyzeStyles — decomposition', () => { it('walks nested elements', async () => { const source = `function App(){ - return
; + return
; }`; const infos = await collectSegments(source); expect(infos).toHaveLength(3); @@ -104,27 +104,11 @@ describe('analyzeStyles — decomposition', () => { expect(info.segments[0]).toMatchObject({ value: 'b' }); }); - it('honours custom mergeFn name', async () => { + it('marks helper calls as opaque', async () => { const source = `function App(){ return
; }`; - const infos: StyleAttributeInfo[] = []; - await compile(source, { - config: { - target: jsx({ - transforms: [ - analyzeStyles({ - mergeFn: 'twMerge', - visit: (info) => { - infos.push(info); - return undefined; - }, - }), - ], - }), - }, - }); + const infos = await collectSegments(source); const info = infos[0]!; - expectSegments(info); - expect(info.segments).toHaveLength(2); + expect(info.kind).toBe('opaque'); }); }); diff --git a/packages/compiler/src/tailwind/evaluator.ts b/packages/compiler/src/tailwind/evaluator.ts index 0c58cd7f..d4435869 100644 --- a/packages/compiler/src/tailwind/evaluator.ts +++ b/packages/compiler/src/tailwind/evaluator.ts @@ -8,8 +8,8 @@ import { type DiagnosticLocation, diagnosticLocationFromNode } from '../diagnost * branch is a plain object whose keys are property names. * * Token sources are constrained to a small grammar (imports + plain object - * literals + spreads + `cn(...)` of string-literal args + dotted access) so we - * can statically resolve them without running JS — see `loadTokenModule`. + * literals + arrays of strings + spreads + dotted access) so we can statically + * resolve them without running JS — see `loadTokenModule`. */ export type TokenValue = string | { readonly [key: string]: TokenValue }; @@ -203,8 +203,7 @@ function evaluate(node: ts.Expression, env: Map, fromFile: s return evaluateCall(node, env, fromFile); } if (ts.isArrayLiteralExpression(node)) { - // Arrays only appear as `cn(...)` arguments. We model them as the - // space-join of their elements (matching `cn`'s `.flat()` semantics). + // Token arrays model a static class list and resolve to a space-joined string. return evaluateArrayParts(node, env, fromFile).join(' '); } if (ts.isParenthesizedExpression(node)) { diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index dadaa121..c0e4c7cc 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from 'node:path'; import ts from 'typescript'; -import type { CompilerContext, StylePipeline } from '../config'; +import type { CompilerContext, CompilerPlugin } from '../config'; import { diagnosticLocationFromNode } from '../diagnostics'; import { tagName } from '../jsx'; import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles'; @@ -22,7 +22,7 @@ export type TailwindMode = /** Pass-through. JSX `className` values stay as authored. No CSS emitted. */ | 'preserve' /** - * Flatten every `cn(...)` call and dotted token reference to a single + * Flatten every className array and dotted token reference to a single * literal utility string on each `className` prop. No CSS emitted; token * imports become unused (handled by `dropUnusedImports`). */ @@ -103,7 +103,7 @@ interface TailwindTransformOptions extends Omit void) | undefined; } -export function tailwind(options: TailwindOptions = {}): StylePipeline { +export function tailwind(options: TailwindOptions = {}): CompilerPlugin { return { name: 'tailwind', async setup(context) { @@ -226,8 +226,8 @@ function vanillaCssPlugin( // tokens resolve via path walking; opaques and unresolved tokens are // *passed through* — those are runtime expressions (e.g. a `className` // prop the consumer composes onto the element). We rewrite the - // classname to the derived semantic name and wrap any pass-throughs in - // a `cn(...)` call so composition is preserved. + // className to the derived semantic name and preserve pass-throughs in + // an array so target generators can choose how to merge. const passThrough: ts.Expression[] = []; const preserved: string[] = []; // Rule-producing utilities only. Preserved marker classes stay on the @@ -289,10 +289,7 @@ function vanillaCssPlugin( if (passThrough.length === 0) { return factory.createStringLiteral(baseName); } - return factory.createCallExpression(factory.createIdentifier('cn'), undefined, [ - factory.createStringLiteral(baseName), - ...passThrough, - ]); + return factory.createArrayLiteralExpression([factory.createStringLiteral(baseName), ...passThrough]); }; const transformed = analyzeStyles({ visit })(transformContext)(sourceFile); @@ -351,7 +348,7 @@ function defaultCssFileName(context: CompilerContext): string { /** * Discover the token-namespace imports in the skin source and evaluate each - * referenced module on disk. Also folds local `const X = cn()` + * referenced module on disk. Also folds local `const X = []` * declarations into the env so JSX `className={X}` references resolve. * * Reads + reparses the source file from disk rather than walking the in-flight @@ -427,7 +424,7 @@ function buildTokenEnv(sourcePath: string | undefined, resolveTokenModule?: Reso // Second pass: top-level `const X = ` declarations whose RHS resolves // statically against the env. Lets skins write - // const iconButton = cn(styles.button.base, styles.button.icon); + // const iconButton = [styles.button.base, styles.button.icon]; // and reference `iconButton` in `className={iconButton}` without losing // the resolution. for (const stmt of sourceFile.statements) { @@ -453,7 +450,7 @@ function isTokenNamespaceImport(sourceName: string, localName: string, value: To } /** - * Evaluate a local declaration's RHS against `env`. Supports `cn(...)` calls, + * Evaluate a local declaration's RHS against `env`. Supports className arrays, * dotted access, identifier lookup, and string literals — same surface as the * token-module evaluator, but without nested object literals (skins don't * declare those locally) and without recursion across files. @@ -473,10 +470,11 @@ function tryEvaluateLocal(node: ts.Expression, env: Map): To const next = root[node.name.text]; return next ?? null; } - if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'cn') { + if (ts.isArrayLiteralExpression(node)) { const parts: string[] = []; - for (const arg of node.arguments) { - const v = tryEvaluateLocal(arg, env); + for (const item of node.elements) { + if (ts.isSpreadElement(item)) return null; + const v = tryEvaluateLocal(item, env); if (v === null || typeof v !== 'string') return null; if (v) parts.push(v); } diff --git a/packages/compiler/src/tailwind/tests/naming.test.ts b/packages/compiler/src/tailwind/tests/naming.test.ts index 8a8ee6ea..6f71b7dd 100644 --- a/packages/compiler/src/tailwind/tests/naming.test.ts +++ b/packages/compiler/src/tailwind/tests/naming.test.ts @@ -104,7 +104,7 @@ describe('deriveClassName — token-path derivation', () => { it('combines literal segments with a single token (token names the class)', () => { const r = deriveClassName({ - element: firstElement(`
`), + element: firstElement(`
`), segments: [literal('flex'), token(['styles', 'foo'])], }); expect(r.className).toBe('foo'); @@ -112,7 +112,7 @@ describe('deriveClassName — token-path derivation', () => { it('uses the last equal-depth token when multiple tokens are present', () => { const r = deriveClassName({ - element: firstElement(`
`), + element: firstElement(`
`), segments: [token(['styles', 'a']), token(['styles', 'b'])], }); expect(r.className).toBe('b'); @@ -120,7 +120,7 @@ describe('deriveClassName — token-path derivation', () => { it('derives from a token when opaque runtime segments are present', () => { const r = deriveClassName({ - element: firstElement(`
`), + element: firstElement(`
`), segments: [token(['styles', 'a']), opaque()], }); expect(r.className).toBe('a'); diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index d829b461..f99eacdc 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -56,7 +56,7 @@ const compile = ( ) => compileSource(source, { filename: options.filename, config: { target: jsx({ transforms: options.plugins }) } }); const compileTailwind = (source: string, options: Parameters[0], filename?: string) => - compileSource(source, { filename, config: { styles: tailwind(options) } }); + compileSource(source, { filename, config: { plugins: [tailwind(options)] } }); describe('tailwindPlugin — mode: preserve', () => { it('preserves static className values', async () => { @@ -97,25 +97,24 @@ describe('tailwindPlugin — mode: inline', () => { expect(code).toContain('"flex items-center"'); }); - it('folds static cn calls', async () => { - const source = `function App(){ return ; }`; + it('folds static className arrays', async () => { + const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'inline' })], }); expect(code).toContain('"flex items-center gap-2"'); - expect(code).not.toMatch(/cn\(/); + expect(code).not.toMatch(/className=\{\[/); }); it('resolves imported token objects', async () => { writeFixture( 'tokens.ts', - `import { cn } from '@videojs/utils/style'; -export const tokens = { button: { base: cn('rounded', 'p-2') } }; + `export const tokens = { button: { base: ['rounded', 'p-2'] } }; ` ); const source = `import { tokens as styles } from './tokens'; -function App(){ return ; }`; +function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); const { code } = await compile(source, { @@ -128,23 +127,23 @@ function App(){ return ; }`; it('leaves unresolved imports untouched', async () => { const source = `import { tokens as styles } from './missing'; -function App(){ return ; }`; +function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); const { code } = await compile(source, { target: 'jsx', filename: sourcePath, plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })], }); - expect(code).toMatch(/cn\(/); + expect(code).toMatch(/className=\{\[/); }); - it('leaves dynamic cn calls untouched', async () => { - const source = `function App(){ return ; }`; + it('leaves dynamic className arrays untouched', async () => { + const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'inline' })], }); - expect(code).toMatch(/cn\(/); + expect(code).toMatch(/className=\{\[/); }); }); @@ -170,8 +169,8 @@ describe('tailwindPlugin — mode: extract', () => { expect(code).toContain('"play-button group"'); }); - it('extracts cn utilities and preserves group marker classes', async () => { - const source = `function App(){ return ; }`; + it('extracts className array utilities and preserves group marker classes', async () => { + const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; const { code } = await compile(source, { target: 'jsx', @@ -191,13 +190,13 @@ describe('tailwindPlugin — mode: extract', () => { expect(captured!.flatMap((r) => r.utility.declarations)).toContainEqual({ property: 'display', value: 'flex' }); }); - it('keeps dynamic cn expressions', async () => { - const source = `function App(){ return ; }`; + it('keeps dynamic className array expressions', async () => { + const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'extract' })], }); - expect(code).toMatch(/cn\("play-button group",\s*extra\)/); + expect(code).toMatch(/className=\{\["play-button group",\s*extra\]\}/); }); it('throws on generated class style collisions', async () => { @@ -211,7 +210,7 @@ describe('tailwindPlugin — mode: extract', () => { }); it('allows preserved marker classes next to matching generated styles', async () => { - const source = `function App(){ return
; }`; + const source = `function App(){ return
; }`; const { code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'extract' })], @@ -265,7 +264,7 @@ export const inputFeedback = { bubble: { shownSeek: 'block' } }; ` ); const source = `import { icon, inputFeedback, menu } from './tokens'; -function App(){ return
; }`; +function App(){ return
; }`; const sourcePath = writeFixture('skin.tsx', source); const { code } = await compile(source, { @@ -302,7 +301,7 @@ function App(){ return
; }`; +function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); const { code } = await compile(source, { @@ -322,7 +321,7 @@ function App(){ return ; + return
; }`; const sourcePath = writeFixture('skin.tsx', source); @@ -397,7 +396,7 @@ function App({ type, className }){ }); it('emits one rule per extracted utility', async () => { - const source = `function App(){ return ; }`; + const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; await compile(source, { target: 'jsx', @@ -419,8 +418,7 @@ function App({ type, className }){ it('resolves imported tokens before extraction', async () => { writeFixture( 'tokens.ts', - `import { cn } from '@videojs/utils/style'; -export const tokens = { button: cn('flex', 'gap-2') }; + `export const tokens = { button: ['flex', 'gap-2'] }; ` ); const source = `import { tokens as styles } from './tokens'; @@ -449,8 +447,7 @@ function App(){ return ; }`; it('resolves bare token imports through a configured resolver', async () => { const tokenPath = writeFixture( 'tokens.ts', - `import { cn } from '@videojs/utils/style'; -export const tokens = { button: cn('flex', 'gap-2') }; + `export const tokens = { button: ['flex', 'gap-2'] }; ` ); const source = `import { tokens as styles } from '@fixture/tokens'; @@ -516,16 +513,14 @@ function App(){ return ; }`; expect(code).toContain('isOn'); }); - it('resolves local cn constants and imported token members', async () => { + it('resolves local className arrays and imported token members', async () => { writeFixture( 'tokens.ts', - `import { cn } from '@videojs/utils/style'; -export const tokens = { button: { base: 'flex', icon: 'w-4 h-4' } }; + `export const tokens = { button: { base: 'flex', icon: 'w-4 h-4' } }; ` ); const source = `import { tokens as styles } from './tokens'; -import { cn } from '@videojs/utils/style'; -const iconButton = cn(styles.button.base, styles.button.icon); +const iconButton = [styles.button.base, styles.button.icon]; function App(){ return ; }`; const sourcePath = writeFixture('skin.tsx', source); @@ -550,13 +545,13 @@ function App(){ return ; }`; expect(utilities).toEqual(['flex', 'h-4', 'w-4']); }); - it('preserves dynamic cn suffixes after extraction', async () => { - const source = `function App({ extra }){ return ; }`; + it('preserves dynamic className suffixes after extraction', async () => { + const source = `function App({ extra }){ return ; }`; const { code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'extract' })], }); - expect(code).toMatch(/cn\("play-button",\s*extra\)/); + expect(code).toMatch(/className=\{\["play-button",\s*extra\]\}/); }); it('extracts parent and child element class names', async () => { diff --git a/packages/compiler/src/tests/cli.test.ts b/packages/compiler/src/tests/cli.test.ts index 52c197a8..dcdd7040 100644 --- a/packages/compiler/src/tests/cli.test.ts +++ b/packages/compiler/src/tests/cli.test.ts @@ -30,17 +30,19 @@ describe('vjs compile', () => { 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;}' }); - }, - }; + plugins: [ + { + name: 'fixture', + setup(context) { + return { + transform: () => (sourceFile) => sourceFile, + finish() { + context.addAsset({ type: 'css', fileName: 'skin.css', source: '.foo{display:flex;}' }); + }, + }; + }, }, - }, + ], }; `, 'utf8' @@ -58,6 +60,30 @@ describe('vjs compile', () => { expect(readFileSync(join(workDir, 'dist', 'skin.css'), 'utf8')).toBe('.foo{display:flex;}'); }); + it('compiles configured project inputs when no file is passed', () => { + 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, + `export default { + input: { skin: 'src/skin.tsx' }, + output: { dir: 'dist', entryFileNames: '[name].tsx', banner: '// Generated\\n' }, +}; +`, + 'utf8' + ); + + execFileSync(process.execPath, ['--import', tsxPath, cliPath, 'compile', '--config', configPath], { + encoding: 'utf8', + }); + + const output = readFileSync(join(workDir, 'dist', 'skin.tsx'), 'utf8'); + expect(output).toContain('// Generated'); + expect(output).toContain('function App'); + }); + it('prints compiler diagnostics with code frames', () => { const inputPath = join(workDir, 'src', 'skin.tsx'); const configPath = join(workDir, 'compiler.config.mjs'); @@ -68,17 +94,19 @@ describe('vjs compile', () => { `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; + plugins: [ + { + 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' @@ -110,17 +138,19 @@ export default { `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; + plugins: [ + { + 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' diff --git a/packages/compiler/src/tests/transform.test.ts b/packages/compiler/src/tests/transform.test.ts new file mode 100644 index 00000000..373ef320 --- /dev/null +++ b/packages/compiler/src/tests/transform.test.ts @@ -0,0 +1,106 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { compile, compileProject, transform } from '..'; + +const compact = (value: string): string => value.replace(/\s+/g, ''); + +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'compiler-transform-')); +}); + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('transform', () => { + it('composes generic import, JSX attribute, JSX element, and interface edits', async () => { + const source = `import { Container, Controls, Tooltip } from '@fixture/core'; +import { styles } from './tokens'; + +export interface SkinProps { + children?: unknown; +} + +export function Skin({ children, className }: SkinProps) { + return {children}