diff --git a/packages/compiler/src/bundlers/vite.ts b/packages/compiler/src/bundlers/vite.ts index 3b1f9c12..93e3cbfb 100644 --- a/packages/compiler/src/bundlers/vite.ts +++ b/packages/compiler/src/bundlers/vite.ts @@ -31,9 +31,8 @@ export function vjsCompiler(options: VideojsCompilerPluginOptions = {}): Plugin 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 }; + if (!loadedConfig) return { config: {}, configDir: root }; + return { config: loadedConfig.config, configDir: loadedConfig.configDir }; }; return { diff --git a/packages/compiler/src/cli.ts b/packages/compiler/src/cli.ts index 35fb68ff..a14e0545 100644 --- a/packages/compiler/src/cli.ts +++ b/packages/compiler/src/cli.ts @@ -3,14 +3,14 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, resolve } from 'node:path'; import { CompilerError, compile } from './compile'; -import type { CompilerDiagnostic } from './config'; +import type { CompilerDiagnostic, CompilerProjectConfig } from './config'; import { type DiagnosticFormat, formatCompilerDiagnostic, formatCompilerDiagnosticJsonLine, formatDiagnosticSummaryJsonLine, } from './diagnostics'; -import { loadConfig } from './load-config'; +import { loadConfig, loadProjectConfig } from './load-config'; import { compileProject } from './project'; interface ParsedArgs { @@ -75,10 +75,12 @@ async function runCompile( const file = positional[0]; const cwd = process.cwd(); 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`.'); + const loaded = await loadProjectConfig(cwd, configOverride); + if (!loaded || !hasProjectInput(loaded.config)) { + 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 }); @@ -94,6 +96,7 @@ async function runCompile( const inputPath = isAbsolute(file) ? file : resolve(cwd, file); const source = readFileSync(inputPath, 'utf8'); + const loaded = await loadConfig(cwd, configOverride); const result = await compile(source, { filename: inputPath, config: loaded?.config, @@ -120,6 +123,10 @@ async function runCompile( } } +function hasProjectInput(config: CompilerProjectConfig): boolean { + return Array.isArray(config) ? config.some((entry) => Boolean(entry.input)) : Boolean(config.input); +} + async function main(): Promise { const { command, positional, configOverride, outFile, diagnosticsFormat } = parseArgs(process.argv.slice(2)); currentDiagnosticsFormat = diagnosticsFormat; diff --git a/packages/compiler/src/config.ts b/packages/compiler/src/config.ts index 4b0efa25..b281407d 100644 --- a/packages/compiler/src/config.ts +++ b/packages/compiler/src/config.ts @@ -66,6 +66,8 @@ export interface CompilerConfig { target?: CompilerTarget | undefined; } +export type CompilerProjectConfig = CompilerConfig | CompilerConfig[]; + export type CompilerInput = string | readonly string[] | Record; export interface CompilerOutputOptions { @@ -75,7 +77,7 @@ export interface CompilerOutputOptions { banner?: string | undefined; } -export function defineConfig(config: Config): Config { +export function defineConfig(config: Config): Config { return config; } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 3c1b1498..aac049d1 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -9,6 +9,7 @@ export { type CompilerPipelineStep, type CompilerPlugin, type CompilerPluginEnforce, + type CompilerProjectConfig, type CompilerTarget, type CompilerTransform, defineConfig, diff --git a/packages/compiler/src/load-config.ts b/packages/compiler/src/load-config.ts index f9f0c4f9..cb28d86e 100644 --- a/packages/compiler/src/load-config.ts +++ b/packages/compiler/src/load-config.ts @@ -2,11 +2,11 @@ import { existsSync } from 'node:fs'; import { dirname, isAbsolute, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { CompilerConfig } from './config'; +import type { CompilerConfig, CompilerProjectConfig } from './config'; interface ConfigModule { - default?: CompilerConfig; - config?: CompilerConfig; + default?: CompilerProjectConfig; + config?: CompilerProjectConfig; } export interface LoadedCompilerConfig { @@ -15,6 +15,12 @@ export interface LoadedCompilerConfig { configDir: string; } +export interface LoadedCompilerProjectConfig { + config: CompilerProjectConfig; + configPath: string; + configDir: string; +} + export const CONFIG_FILENAMES = [ 'compiler.config.js', 'compiler.config.mjs', @@ -37,7 +43,7 @@ export function findConfig(cwd: string, override: string | undefined): string | return null; } -export async function loadConfigFile(configPath: string): Promise { +export async function loadProjectConfigFile(configPath: string): Promise { const mod = (await import(pathToFileURL(configPath).href)) as ConfigModule; const config = mod.default ?? mod.config; if (!config) { @@ -46,6 +52,22 @@ export async function loadConfigFile(configPath: string): Promise { + const loaded = await loadProjectConfigFile(configPath); + if (Array.isArray(loaded.config)) { + throw new Error(`Config file ${configPath} must export a single compiler config.`); + } + return { ...loaded, config: loaded.config }; +} + +export async function loadProjectConfig( + cwd: string, + override: string | undefined +): Promise { + const configPath = findConfig(cwd, override); + return configPath ? loadProjectConfigFile(configPath) : null; +} + export async function loadConfig(cwd: string, override: string | undefined): Promise { const configPath = findConfig(cwd, override); return configPath ? loadConfigFile(configPath) : null; diff --git a/packages/compiler/src/project.ts b/packages/compiler/src/project.ts index 3a0e8d4c..43223822 100644 --- a/packages/compiler/src/project.ts +++ b/packages/compiler/src/project.ts @@ -1,7 +1,7 @@ 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'; +import type { CompilerAsset, CompilerConfig, CompilerDiagnostic, CompilerInput, CompilerProjectConfig } from './config'; export interface CompileProjectOptions { configDir?: string | undefined; @@ -26,6 +26,27 @@ interface ProjectEntry { } export async function compileProject( + config: CompilerProjectConfig, + options: CompileProjectOptions = {} +): Promise { + const files: ProjectOutputFile[] = []; + const diagnostics: CompilerDiagnostic[] = []; + const seenFiles = new Set(); + + for (const entry of Array.isArray(config) ? config : [config]) { + const result = await compileProjectConfig(entry, options); + diagnostics.push(...result.diagnostics); + for (const file of result.files) { + if (seenFiles.has(file.fileName)) throw new Error(`Compiler project output collision: ${file.fileName}`); + seenFiles.add(file.fileName); + files.push(file); + } + } + + return { files, diagnostics }; +} + +async function compileProjectConfig( config: CompilerConfig, options: CompileProjectOptions = {} ): Promise { diff --git a/packages/compiler/src/styles/analyze.ts b/packages/compiler/src/styles/analyze.ts index 66e8ec53..65431611 100644 --- a/packages/compiler/src/styles/analyze.ts +++ b/packages/compiler/src/styles/analyze.ts @@ -59,6 +59,7 @@ export interface AnalyzeStylesOptions { export function readStyleAttribute(element: JsxElementLike): StyleAttributeInfo | undefined { const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes; + const classNameAttr = findClassNameAttribute(attrs); if (!classNameAttr) return undefined; diff --git a/packages/compiler/src/styles/index.ts b/packages/compiler/src/styles/index.ts index b086d8d1..de7ab8a1 100644 --- a/packages/compiler/src/styles/index.ts +++ b/packages/compiler/src/styles/index.ts @@ -24,30 +24,6 @@ export { type NameContext, type ResolveName, } from './naming'; -export { - type ClassNameReferenceData, - type ClassNameStyleReference, - type CssBundle, - classNameScanner, - defineStylingPlugin, - isClassNameStyleReference, - type StyleReference, - type StyleResolution, - type StyleTransformResult, - type StylingAsset, - type StylingConfigContext, - type StylingContextBase, - type StylingEmitContext, - type StylingGenerateContext, - type StylingOptions, - type StylingPlugin, - type StylingPluginEnforce, - type StylingRenderContext, - type StylingResolveContext, - type StylingScanContext, - type StylingTransformContext, - styling, -} from './pipeline'; export { buildTokenEnv, type ResolveTokenModule, diff --git a/packages/compiler/src/styles/naming.ts b/packages/compiler/src/styles/naming.ts index 16482f29..b9696e21 100644 --- a/packages/compiler/src/styles/naming.ts +++ b/packages/compiler/src/styles/naming.ts @@ -13,9 +13,9 @@ export interface DerivedClassName { export type DefaultNameSource = 'component' | 'token' | 'literal'; -/** Context passed to `resolve.name`. */ +/** Context passed to a style name resolver. */ export interface NameContext { - /** The default candidate source used when `resolve.name` is omitted. */ + /** The default candidate source used when no name resolver is provided. */ source: DefaultNameSource; /** The JSX tag (e.g. `'PlayButton'` or `'Tooltip.Trigger'`). */ tag: string; @@ -116,7 +116,7 @@ export function deriveClassName(opts: DeriveClassNameOptions): DerivedClassName `Tag is bare HTML and the className doesn't reference a token path. ` + `Resolve by: (a) using a JSX component instead of <${tag}>, ` + `(b) extracting the classes into a single token reference, ` + - `or (c) customizing \`resolve.name\`.`, + `or (c) customizing the style name resolver.`, { ...diagnosticLocationFromNode(opts.element), diagnosticCode: 'style-class-name' } ); } diff --git a/packages/compiler/src/styles/pipeline.ts b/packages/compiler/src/styles/pipeline.ts deleted file mode 100644 index a3d2e4aa..00000000 --- a/packages/compiler/src/styles/pipeline.ts +++ /dev/null @@ -1,253 +0,0 @@ -import ts from 'typescript'; -import type { CompilerAsset, CompilerContext, CompilerPipelineStep, CompilerPlugin } from '../config'; -import type { JsxElementLike } from '../jsx'; -import { readStyleAttribute, type StyleAttributeInfo } from './analyze'; - -export type StylingPluginEnforce = 'pre' | 'post'; - -export interface StyleReference { - kind: string; - element: JsxElementLike; - node?: ts.Node | undefined; - data: Data; -} - -export interface StyleResolution { - kind: string; - reference: StyleReference; - data: Data; -} - -export interface StyleTransformResult { - element?: JsxElementLike | undefined; -} - -export type StylingAsset = CompilerAsset; - -export interface CssBundle { - assets: StylingAsset[]; - meta: Map; -} - -export interface StylingContextBase { - compiler: CompilerContext; - plugins: readonly StylingPlugin[]; - meta: Map; -} - -export interface StylingConfigContext extends StylingContextBase {} - -export interface StylingScanContext extends StylingContextBase { - sourceFile: ts.SourceFile; - factory: ts.NodeFactory; -} - -export interface StylingResolveContext extends StylingContextBase { - sourceFile: ts.SourceFile; - factory: ts.NodeFactory; -} - -export interface StylingTransformContext extends StylingContextBase { - sourceFile: ts.SourceFile; - factory: ts.NodeFactory; - currentElement: JsxElementLike; -} - -export interface StylingGenerateContext extends StylingContextBase {} - -export interface StylingRenderContext extends StylingContextBase {} - -export interface StylingEmitContext extends StylingContextBase {} - -export interface StylingPlugin { - name: string; - enforce?: StylingPluginEnforce | undefined; - config?(context: StylingConfigContext): void | Promise; - scan?( - element: JsxElementLike, - context: StylingScanContext - ): StyleReference | readonly StyleReference[] | null | undefined; - resolve?(reference: StyleReference, context: StylingResolveContext): StyleResolution | null | undefined; - transform?(resolution: StyleResolution, context: StylingTransformContext): StyleTransformResult | null | undefined; - generate?(bundle: CssBundle, context: StylingGenerateContext): void | Promise; - render?( - bundle: CssBundle, - context: StylingRenderContext - ): readonly StylingAsset[] | null | undefined | Promise; - emit?(assets: readonly StylingAsset[], context: StylingEmitContext): void | Promise; -} - -export interface StylingOptions { - plugins?: readonly StylingPlugin[] | undefined; -} - -export interface ClassNameReferenceData { - info: StyleAttributeInfo; -} - -export interface ClassNameStyleReference extends StyleReference { - kind: 'className'; -} - -export function defineStylingPlugin(plugin: Plugin): Plugin { - return plugin; -} - -export function classNameScanner(): StylingPlugin { - return defineStylingPlugin({ - name: 'class-name-scanner', - scan(element) { - const info = readStyleAttribute(element); - if (!info) return null; - return { - kind: 'className', - element, - node: info.attribute, - data: { info }, - } satisfies ClassNameStyleReference; - }, - }); -} - -export function isClassNameStyleReference(reference: StyleReference): reference is ClassNameStyleReference { - return reference.kind === 'className' && isClassNameReferenceData(reference.data); -} - -export function styling(options: StylingOptions = {}): CompilerPlugin { - return { - name: 'styling', - async setup(compiler): Promise { - const plugins = orderStylingPlugins(options.plugins ?? []); - const meta = new Map(); - const base = { compiler, plugins, meta } satisfies StylingContextBase; - const bundle: CssBundle = { assets: [], meta }; - - for (const plugin of plugins) { - await plugin.config?.(base); - } - - return { - transform: createStylingTransform(plugins, base), - async finish() { - for (const plugin of plugins) { - await plugin.generate?.(bundle, base); - } - - let assets: readonly StylingAsset[] | null | undefined; - for (const plugin of plugins) { - assets = await plugin.render?.(bundle, base); - if (assets != null) break; - } - - const finalAssets = assets ?? bundle.assets; - for (const plugin of plugins) { - await plugin.emit?.(finalAssets, base); - } - for (const asset of finalAssets) { - compiler.addAsset(asset); - } - }, - }; - }, - }; -} - -function createStylingTransform( - plugins: readonly StylingPlugin[], - base: StylingContextBase -): ts.TransformerFactory { - return (transformContext) => { - const factory = transformContext.factory; - - return (sourceFile) => { - const scanContext = { ...base, sourceFile, factory } satisfies StylingScanContext; - const resolveContext = { ...base, sourceFile, factory } satisfies StylingResolveContext; - - const visitNode = (node: ts.Node): ts.Node => { - if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - const visited = visitJsxElement(node as JsxElementLike, plugins, scanContext, resolveContext, base); - return ts.visitEachChild(visited, visitNode, transformContext); - } - return ts.visitEachChild(node, visitNode, transformContext); - }; - - return ts.visitEachChild(sourceFile, visitNode, transformContext); - }; - }; -} - -function visitJsxElement( - element: JsxElementLike, - plugins: readonly StylingPlugin[], - scanContext: StylingScanContext, - resolveContext: StylingResolveContext, - base: StylingContextBase -): JsxElementLike { - let current = element; - const references: StyleReference[] = []; - - for (const plugin of plugins) { - const scanned = plugin.scan?.(current, scanContext); - references.push(...normalizeReferences(scanned)); - } - - for (const reference of references) { - const resolution = resolveReference(reference, plugins, resolveContext); - if (!resolution) continue; - - const transformContext = { - ...base, - sourceFile: scanContext.sourceFile, - factory: scanContext.factory, - currentElement: current, - } satisfies StylingTransformContext; - const result = transformResolution(resolution, plugins, transformContext); - if (result?.element) current = result.element; - } - - return current; -} - -function resolveReference( - reference: StyleReference, - plugins: readonly StylingPlugin[], - context: StylingResolveContext -): StyleResolution | null { - for (const plugin of plugins) { - const resolution = plugin.resolve?.(reference, context); - if (resolution != null) return resolution; - } - return null; -} - -function transformResolution( - resolution: StyleResolution, - plugins: readonly StylingPlugin[], - context: StylingTransformContext -): StyleTransformResult | null { - for (const plugin of plugins) { - const result = plugin.transform?.(resolution, context); - if (result != null) return result; - } - return null; -} - -function normalizeReferences(value: StyleReference | readonly StyleReference[] | null | undefined): StyleReference[] { - if (value == null) return []; - return isStyleReferenceArray(value) ? [...value] : [value]; -} - -function isStyleReferenceArray(value: StyleReference | readonly StyleReference[]): value is readonly StyleReference[] { - return Array.isArray(value); -} - -function orderStylingPlugins(plugins: readonly StylingPlugin[]): StylingPlugin[] { - 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]; -} - -function isClassNameReferenceData(value: unknown): value is ClassNameReferenceData { - return typeof value === 'object' && value !== null && 'info' in value; -} diff --git a/packages/compiler/src/styles/tests/pipeline.test.ts b/packages/compiler/src/styles/tests/pipeline.test.ts deleted file mode 100644 index 5e04546a..00000000 --- a/packages/compiler/src/styles/tests/pipeline.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { compile } from '../../compile'; -import { rewriteStyleAttribute, type StyleAttributeInfo } from '../analyze'; -import { classNameScanner, defineStylingPlugin, isClassNameStyleReference, styling } from '../pipeline'; - -describe('styling pipeline', () => { - it('scans, resolves, and transforms style references', async () => { - const { code } = await compile(`function App(){ return
@@ -164,12 +190,12 @@ describe('tailwind output modes', () => { position: relative; } - .button::after { + .button:after { content: ""; position: absolute; } - .button::before { + .button:before { content: 'x'; position: absolute; } @@ -215,11 +241,11 @@ describe('tailwind output modes', () => { opacity: 0%; } - .icon:is(:where(.group):not(*[data-paused]) *) { + .icon:is(:where(.button):not([data-paused]) *) { opacity: 0%; } - .icon:is(:where(.group)[data-paused] *) { + .icon:is(:where(.button)[data-paused] *) { display: block; opacity: 100%; } @@ -229,7 +255,7 @@ describe('tailwind output modes', () => { position: absolute; } - .thumbnail:has(*:is([role=img]:not([data-hidden]))) { + .thumbnail:has([role="img"]:not([data-hidden])) { opacity: 100%; } diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index 27e1cec6..a64e1fe8 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -149,24 +149,22 @@ describe('tailwindPlugin — mode: extract', () => { expect(code).not.toContain('"flex items-center"'); }); - it('preserves group marker classes', async () => { + it('removes inferred group marker classes', async () => { const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', 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"'); + expect(code).toContain('"play-button"'); }); - it('extracts className array utilities and preserves group marker classes', async () => { + it('extracts className array utilities and removes inferred group marker classes', async () => { const source = `function App(){ return ; }`; const { assets, code } = await compile(source, { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'extract' })], }); - expect(code).toContain('"play-button group"'); + expect(code).toContain('"play-button"'); expect(collapse(assets[0]!.source)).toContain(collapse('.play-button{display:flex;}')); }); @@ -176,7 +174,78 @@ describe('tailwindPlugin — mode: extract', () => { target: 'jsx', plugins: [tailwindPlugin({ design, mode: 'extract' })], }); - expect(code).toMatch(/className=\{\["play-button group",\s*extra\]\}/); + expect(code).toMatch(/className=\{\["play-button",\s*extra\]\}/); + }); + + it('resolves elements and rewrites inferred marker selectors', async () => { + const source = `function App(){ return ; }`; + const { assets, code } = await compile(source, { + target: 'jsx', + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + resolve: { + element({ tag }) { + if (tag === 'PlayButton') return { className: 'media-button', chunk: 'button' }; + if (tag === 'PlayIcon') return { className: 'media-play-icon', chunk: 'button' }; + return undefined; + }, + }, + }), + ], + }); + + expect(code).toContain(''); + expect(code).toContain(''); + expect(code).not.toContain('group/button'); + expect(collapse(assets[0]!.source)).toContain( + collapse('.media-play-icon:is(:where(.media-button)[data-paused] *){display:block;}') + ); + }); + + it('lets resolve.classList customize final static class lists', async () => { + const source = `function App(){ return ; }`; + const { code } = await compile(source, { + target: 'jsx', + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + resolve: { + classList({ classes }) { + return classes.filter((name) => name !== 'legacy-marker'); + }, + }, + }), + ], + }); + + expect(code).toContain(''); + }); + + it('uses selector resolution chunks for split CSS assets', async () => { + const source = `function App(){ return ; }`; + const { assets } = await compile(source, { + target: 'jsx', + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + emit: { mode: 'split' }, + resolve: { + element() { + return { className: 'media-button', chunk: 'button' }; + }, + }, + }), + ], + }); + + expect(assets.map((asset) => asset.fileName).sort()).toEqual(['button.css', 'input.css']); + expect(collapse(assets.find((asset) => asset.fileName === 'button.css')!.source)).toContain( + collapse('.media-button{display:flex;}') + ); }); it('throws on generated class style collisions', async () => { @@ -189,6 +258,28 @@ describe('tailwindPlugin — mode: extract', () => { ).rejects.toThrow(/class name 'seek-icon' is derived from elements with different styles/); }); + it('allows selector-owned class merges', async () => { + const source = `function App(){ return
; }`; + const { assets, code } = await compile(source, { + target: 'jsx', + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + resolve: { + element() { + return 'media-button'; + }, + }, + }), + ], + }); + + expect(code).toContain(''); + expect(code).toContain(''); + expect(collapse(assets[0]!.source)).toContain(collapse('.media-button{display:flex;position:relative;}')); + }); + it('allows preserved marker classes next to matching generated styles', async () => { const source = `function App(){ return
; }`; const { code } = await compile(source, { @@ -333,7 +424,7 @@ function App({ type, className }){ design, mode: 'extract', resolve: { - name: (ctx) => `app-${ctx.defaultName}`, + element: (ctx) => `app-${ctx.defaultName}`, }, }), ], @@ -341,7 +432,7 @@ function App({ type, className }){ expect(code).toContain('"app-play-button"'); }); - it('lets resolve.name choose token names for component elements', async () => { + it('lets resolve.element choose token names for component elements', async () => { const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', @@ -350,7 +441,7 @@ function App({ type, className }){ design, mode: 'extract', resolve: { - name: (ctx) => ctx.tokenName ?? ctx.defaultName, + element: (ctx) => ctx.tokenName ?? ctx.defaultName, }, }), ], @@ -358,7 +449,7 @@ function App({ type, className }){ expect(code).toContain('"button-icon"'); }); - it('lets resolve.name choose component names over known token roots', async () => { + it('lets resolve.element choose component names over known token roots', async () => { writeFixture( 'tokens.ts', `export const menu = { chevron: 'size-3' }; @@ -376,7 +467,7 @@ function App(){ return ; }`; design, mode: 'extract', resolve: { - name: (ctx) => ctx.componentName ?? ctx.defaultName, + element: (ctx) => ctx.componentName ?? ctx.defaultName, }, }), ], @@ -468,7 +559,7 @@ function App(){ return ; }`; expect(collapse(assets[0]!.source)).toContain(collapse('.button{display:flex;gap:calc(var(--spacing) * 2);}')); }); - it('assigns rule groups with resolve.group', async () => { + it('assigns split chunks with resolve.element', async () => { const source = `function App(){ return ; }`; const { assets } = await compile(source, { target: 'jsx', @@ -478,7 +569,9 @@ function App(){ return ; }`; mode: 'extract', emit: { mode: 'split' }, resolve: { - group: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined), + element({ defaultName }) { + return defaultName.startsWith('play-') ? { className: defaultName, chunk: 'controls' } : defaultName; + }, }, }), ], diff --git a/packages/compiler/src/tailwind/tests/utility-css.test.ts b/packages/compiler/src/tailwind/tests/utility-css.test.ts index f134b084..61ec2bf5 100644 --- a/packages/compiler/src/tailwind/tests/utility-css.test.ts +++ b/packages/compiler/src/tailwind/tests/utility-css.test.ts @@ -108,6 +108,18 @@ describe('analyzeUtility — variants', () => { expect(peer).toBeDefined(); expect(peer!.selector).toContain('peer'); }); + + it('captures child and descendant variants', () => { + const child = analyzeUtility('*:opacity-50', design); + const descendant = analyzeUtility('**:mix-blend-difference', design); + + expect(child).not.toBeNull(); + expect(child!.variants[0]).toMatchObject({ kind: 'descendant' }); + expect(child!.variants[0]!.selector).toContain('&'); + expect(descendant).not.toBeNull(); + expect(descendant!.variants[0]).toMatchObject({ kind: 'descendant' }); + expect(descendant!.variants[0]!.selector).toContain('&'); + }); }); describe('analyzeUtility — branches', () => { diff --git a/packages/compiler/src/tailwind/utility-css.ts b/packages/compiler/src/tailwind/utility-css.ts index 66c2e91d..5f4b5033 100644 --- a/packages/compiler/src/tailwind/utility-css.ts +++ b/packages/compiler/src/tailwind/utility-css.ts @@ -41,6 +41,8 @@ export interface Variant { kind: VariantKind; /** Selector segment this variant adds, if any. */ selector?: string; + /** Lightning CSS selector AST for this variant, if any. */ + selectorAst?: CssSelectorList; /** At-rule wrapper, if any. */ atRule?: { name: string; params: string }; /** Original raw form (for diagnostics + emit). */ @@ -100,6 +102,7 @@ export function analyzeUtility(utility: string, design: DesignSystem): UtilityCs const stylesheet = parseStyleSheet(css); if (!stylesheet) return null; + const context = createAnalysisContext(css); const branches: UtilityCssBranch[] = []; @@ -117,6 +120,7 @@ export function analyzeUtility(utility: string, design: DesignSystem): UtilityCs function parseStyleSheet(css: string): CssStyleSheet | null { let stylesheet: CssStyleSheet | undefined; + try { transform({ filename: 'tailwind-utility.css', @@ -130,6 +134,7 @@ function parseStyleSheet(css: string): CssStyleSheet | null { } catch { return null; } + return stylesheet ?? null; } @@ -289,7 +294,7 @@ function selectorTailFromStyleRule(rule: CssStyleRule, context: AnalysisContext) function selectorVariantFromStyleRule(rule: CssStyleRule, context: AnalysisContext): Variant { const tail = selectorTailFromStyleRule(rule, context); - return classifySelector(rule.selectors, tail); + return classifySelector(rule.selectors, tail, rule.selectors); } function collectProperties(rules: readonly CssRule[], context: AnalysisContext): PropertyRule[] { @@ -571,21 +576,26 @@ function readBalancedBlock(body: string, openIdx: number): BalancedBlock | null return null; } -function classifySelector(selectors: CssSelectorList, tail: string): Variant { +function classifySelector(selectors: CssSelectorList, tail: string, selectorAst: CssSelectorList): Variant { const components = selectorTailComponents(selectors[0] ?? []); - if (selectorContainsClass(components, 'group')) return { kind: 'group', selector: tail, raw: tail }; - if (selectorContainsClass(components, 'peer')) return { kind: 'peer', selector: tail, raw: tail }; - if (components.some((component) => component.type === 'attribute')) { - return { kind: 'attribute', selector: tail, raw: tail }; + if (selectorContainsClass(components, 'group')) return { kind: 'group', selector: tail, selectorAst, raw: tail }; + if (selectorContainsClass(components, 'peer')) return { kind: 'peer', selector: tail, selectorAst, raw: tail }; + if (selectorContainsComponent(components, (component) => component.type === 'attribute')) { + return { kind: 'attribute', selector: tail, selectorAst, raw: tail }; } - if (components.some((component) => component.type === 'pseudo-class' || component.type === 'pseudo-element')) { - return { kind: 'pseudo', selector: tail, raw: tail }; + if (selectorContainsComponent(components, (component) => component.type === 'combinator')) { + return { kind: 'descendant', selector: tail, selectorAst, raw: tail }; } - if (components.some((component) => component.type === 'combinator')) { - return { kind: 'descendant', selector: tail, raw: tail }; + if ( + selectorContainsComponent( + components, + (component) => component.type === 'pseudo-class' || component.type === 'pseudo-element' + ) + ) { + return { kind: 'pseudo', selector: tail, selectorAst, raw: tail }; } - return { kind: 'parent', selector: tail, raw: tail }; + return { kind: 'parent', selector: tail, selectorAst, raw: tail }; } function selectorTailComponents(selector: CssSelector): CssSelector { @@ -593,10 +603,20 @@ function selectorTailComponents(selector: CssSelector): CssSelector { } function selectorContainsClass(selector: CssSelector, className: string): boolean { + return selectorContainsComponent( + selector, + (component) => component.type === 'class' && classNameMatches(component.name, className) + ); +} + +function selectorContainsComponent( + selector: CssSelector, + predicate: (component: CssSelector[number]) => boolean +): boolean { for (const component of selector) { - if (component.type === 'class' && classNameMatches(component.name, className)) return true; + if (predicate(component)) return true; for (const nested of selectorLists(component)) { - if (selectorContainsClass(nested, className)) return true; + if (selectorContainsComponent(nested, predicate)) return true; } } return false; diff --git a/packages/compiler/src/tests/transform.test.ts b/packages/compiler/src/tests/transform.test.ts index ee19fdd7..f82bbb47 100644 --- a/packages/compiler/src/tests/transform.test.ts +++ b/packages/compiler/src/tests/transform.test.ts @@ -180,4 +180,33 @@ describe('compileProject', () => { expect(result.files[0]!.source).toContain('// Generated'); expect(result.files[0]!.source).toContain('data-root=""'); }); + + it('compiles multiple project configs from one config array', async () => { + const inputFile = join(workDir, 'src', 'skin.tsx'); + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(inputFile, `export function App(){ return ; }\n`, 'utf8'); + + const result = await compileProject( + [ + { + input: { skin: 'src/skin.tsx' }, + output: { dir: 'dist/one', entryFileNames: '[name].tsx' }, + plugins: [transform((code) => [code.jsx.element('Root').addProp('data-one', '')])], + }, + { + input: { skin: 'src/skin.tsx' }, + output: { dir: 'dist/two', entryFileNames: '[name].tsx' }, + plugins: [transform((code) => [code.jsx.element('Root').addProp('data-two', '')])], + }, + ], + { configDir: workDir } + ); + + expect(result.diagnostics).toEqual([]); + expect(result.files).toHaveLength(2); + expect(result.files[0]).toMatchObject({ type: 'chunk', fileName: join(workDir, 'dist', 'one', 'skin.tsx') }); + expect(result.files[0]!.source).toContain('data-one=""'); + expect(result.files[1]).toMatchObject({ type: 'chunk', fileName: join(workDir, 'dist', 'two', 'skin.tsx') }); + expect(result.files[1]!.source).toContain('data-two=""'); + }); }); diff --git a/packages/compiler/tsdown.config.ts b/packages/compiler/tsdown.config.ts index e03acfdd..f0e2b50f 100644 --- a/packages/compiler/tsdown.config.ts +++ b/packages/compiler/tsdown.config.ts @@ -15,4 +15,7 @@ export default defineConfig({ hash: false, unbundle: true, dts: true, + deps: { + neverBundle: [/^node:/], + }, }); diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 941e2f27..20977000 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -5,17 +5,15 @@ import { container, controls, error, - icon, - iconContainer, - iconFlipped, + icons, menu, - muteIcon, playbackRate, - playIcon, - popup, + popover, seek, slider, time, + tooltip, + volumePopover, } from '@videojs/skins/default/tailwind/audio.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; @@ -49,36 +47,36 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: cn(icon, playIcon.restart) })} - ${renderIcon('play', { class: cn(icon, playIcon.play) })} - ${renderIcon('pause', { class: cn(icon, playIcon.pause) })} + + ${renderIcon('restart', { class: cn(icons.root, icons.restartIcon) })} + ${renderIcon('play', { class: cn(icons.root, icons.playIcon) })} + ${renderIcon('pause', { class: cn(icons.root, icons.pauseIcon) })} - + - + - - ${renderIcon('seek', { class: cn(icon, iconFlipped) })} + + ${renderIcon('seek', { class: cn(icons.root, icons.flipped) })} ${SEEK_TIME} - + - + - - ${renderIcon('seek', { class: icon })} + + ${renderIcon('seek', { class: icons.root })} ${SEEK_TIME} - + - +
@@ -86,10 +84,10 @@ function getTemplateHTML() { - - + + - + @@ -99,31 +97,31 @@ function getTemplateHTML() {
- + - - ${renderIcon('volume-off', { class: cn(icon, muteIcon.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, muteIcon.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, muteIcon.volumeHigh) })} + + ${renderIcon('volume-off', { class: cn(icons.root, icons.volumeOffIcon) })} + ${renderIcon('volume-low', { class: cn(icons.root, icons.volumeLowIcon) })} + ${renderIcon('volume-high', { class: cn(icons.root, icons.volumeHighIcon) })} - + - + - +
diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 82295c62..29a11b44 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -5,11 +5,11 @@ import { container, controls, error, - icon, - muteIcon, - playIcon, - popup, + icons, + popover, slider, + tooltip, + volumePopover, } from '@videojs/skins/default/tailwind/audio.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; @@ -41,14 +41,14 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: cn(icon, playIcon.restart) })} - ${renderIcon('play', { class: cn(icon, playIcon.play) })} - ${renderIcon('pause', { class: cn(icon, playIcon.pause) })} + + ${renderIcon('restart', { class: cn(icons.root, icons.restartIcon) })} + ${renderIcon('play', { class: cn(icons.root, icons.playIcon) })} + ${renderIcon('pause', { class: cn(icons.root, icons.pauseIcon) })} - + - + @@ -57,18 +57,18 @@ function getTemplateHTML() {
- - ${renderIcon('volume-off', { class: cn(icon, muteIcon.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, muteIcon.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, muteIcon.volumeHigh) })} + + ${renderIcon('volume-off', { class: cn(icons.root, icons.volumeOffIcon) })} + ${renderIcon('volume-low', { class: cn(icons.root, icons.volumeLowIcon) })} + ${renderIcon('volume-high', { class: cn(icons.root, icons.volumeHighIcon) })} - + - + - +
diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index 4c1a6769..b14c70e7 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -1,26 +1,22 @@ import { renderIcon } from '@videojs/icons/render'; import { - airplayIcon, - bufferingIndicator, + buffering, button, - buttonGroupEnd, - buttonGroupStart, - captionsIcon, - castIcon, container, controls, + controlsGroup, error, - fullscreenIcon, - icon, - inputFeedback, + icons, + indicator, menu, - muteIcon, overlay, - pipIcon, - playIcon, - popup, + popover, poster, slider, + statusIndicator, + tooltip, + volumeIndicator, + volumePopover, } from '@videojs/skins/default/tailwind/video.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; @@ -41,7 +37,7 @@ function getTemplateHTML() { - + ${renderIcon('spinner')} @@ -59,15 +55,15 @@ function getTemplateHTML() { -
- - ${renderIcon('restart', { class: cn(icon, playIcon.restart) })} - ${renderIcon('play', { class: cn(icon, playIcon.play) })} - ${renderIcon('pause', { class: cn(icon, playIcon.pause) })} +
+ + ${renderIcon('restart', { class: cn(icons.root, icons.restartIcon) })} + ${renderIcon('play', { class: cn(icons.root, icons.playIcon) })} + ${renderIcon('pause', { class: cn(icons.root, icons.pauseIcon) })} - + - + @@ -75,72 +71,72 @@ function getTemplateHTML() { -
- - ${renderIcon('volume-off', { class: cn(icon, muteIcon.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, muteIcon.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, muteIcon.volumeHigh) })} +
+ + ${renderIcon('volume-off', { class: cn(icons.root, icons.volumeOffIcon) })} + ${renderIcon('volume-low', { class: cn(icons.root, icons.volumeLowIcon) })} + ${renderIcon('volume-high', { class: cn(icons.root, icons.volumeHighIcon) })} - + - + - + - - ${renderIcon('captions-off', { class: cn(icon, captionsIcon.off) })} - ${renderIcon('captions-on', { class: cn(icon, captionsIcon.on) })} + + ${renderIcon('captions-off', { class: cn(icons.root, icons.captionsOffIcon) })} + ${renderIcon('captions-on', { class: cn(icons.root, icons.captionsOnIcon) })} - + - + - + - - ${renderIcon('cast-enter', { class: cn(icon, castIcon.enter) })} - ${renderIcon('cast-exit', { class: cn(icon, castIcon.exit) })} + + ${renderIcon('cast-enter', { class: cn(icons.root, icons.castEnterIcon) })} + ${renderIcon('cast-exit', { class: cn(icons.root, icons.castExitIcon) })} - + - + - - ${renderIcon('airplay-enter', { class: cn(icon, airplayIcon.enter) })} - ${renderIcon('airplay-exit', { class: cn(icon, airplayIcon.exit) })} + + ${renderIcon('airplay-enter', { class: cn(icons.root, icons.airplayEnterIcon) })} + ${renderIcon('airplay-exit', { class: cn(icons.root, icons.airplayExitIcon) })} - + - + - - ${renderIcon('pip-enter', { class: cn(icon, pipIcon.off) })} - ${renderIcon('pip-exit', { class: cn(icon, pipIcon.on) })} + + ${renderIcon('pip-enter', { class: cn(icons.root, icons.pipEnterIcon) })} + ${renderIcon('pip-exit', { class: cn(icons.root, icons.pipExitIcon) })} - + - + - - ${renderIcon('fullscreen-enter', { class: cn(icon, fullscreenIcon.enter) })} - ${renderIcon('fullscreen-exit', { class: cn(icon, fullscreenIcon.exit) })} + + ${renderIcon('fullscreen-enter', { class: cn(icons.root, icons.fullscreenEnterIcon) })} + ${renderIcon('fullscreen-exit', { class: cn(icons.root, icons.fullscreenExitIcon) })} - + - +
@@ -165,35 +161,27 @@ function getTemplateHTML() { -
- - - -
+ + + `; } diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 8fd36e77..05364c50 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -1,31 +1,27 @@ import { renderIcon } from '@videojs/icons/render'; import { - airplayIcon, badge, - bufferingIndicator, + buffering, button, - buttonGroupEnd, - buttonGroupStart, - castIcon, container, controls, + controlsGroup, error, - fullscreenIcon, - icon, - iconContainer, - iconFlipped, - inputFeedback, + icons, + indicator, menu, - muteIcon, overlay, - pipIcon, - playIcon, - popup, + popover, poster, seek, + seekIndicator, slider, + statusIndicator, thumbnail, time, + tooltip, + volumeIndicator, + volumePopover, } from '@videojs/skins/default/tailwind/video.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; @@ -48,7 +44,7 @@ function getTemplateHTML() { - + ${renderIcon('spinner')} @@ -66,37 +62,37 @@ function getTemplateHTML() { -
- - ${renderIcon('restart', { class: cn(icon, playIcon.restart) })} - ${renderIcon('play', { class: cn(icon, playIcon.play) })} - ${renderIcon('pause', { class: cn(icon, playIcon.pause) })} +
+ + ${renderIcon('restart', { class: cn(icons.root, icons.restartIcon) })} + ${renderIcon('play', { class: cn(icons.root, icons.playIcon) })} + ${renderIcon('pause', { class: cn(icons.root, icons.pauseIcon) })} - + - + - - ${renderIcon('seek', { class: cn(icon, iconFlipped) })} + + ${renderIcon('seek', { class: cn(icons.root, icons.flipped) })} ${SEEK_TIME} - + - + - - ${renderIcon('seek', { class: icon })} + + ${renderIcon('seek', { class: icons.root })} ${SEEK_TIME} - + - +
@@ -104,15 +100,15 @@ function getTemplateHTML() { - - + + - +
- ${renderIcon('spinner', { class: cn(icon, thumbnail.spinner) })} + ${renderIcon('spinner', { class: cn(icons.root, thumbnail.spinner) })}
@@ -121,50 +117,50 @@ function getTemplateHTML() {
-