diff --git a/packages/compiler/src/tailwind/evaluator.ts b/packages/compiler/src/tailwind/evaluator.ts index 5c8b4723..0c58cd7f 100644 --- a/packages/compiler/src/tailwind/evaluator.ts +++ b/packages/compiler/src/tailwind/evaluator.ts @@ -205,16 +205,7 @@ function evaluate(node: ts.Expression, env: Map, fromFile: s if (ts.isArrayLiteralExpression(node)) { // Arrays only appear as `cn(...)` arguments. We model them as the // space-join of their elements (matching `cn`'s `.flat()` semantics). - const parts: string[] = []; - for (const el of node.elements) { - const v = evaluate(el, env, fromFile); - if (typeof v === 'string') { - if (v) parts.push(v); - } else { - throw evalError(node, fromFile, 'Arrays in token expressions must contain strings only'); - } - } - return parts.join(' '); + return evaluateArrayParts(node, env, fromFile).join(' '); } if (ts.isParenthesizedExpression(node)) { return evaluate(node.expression, env, fromFile); @@ -279,6 +270,10 @@ function readPropertyKey(name: ts.PropertyName, fromFile: string): string { } function evaluateCall(node: ts.CallExpression, env: Map, fromFile: string): TokenValue { + if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'join') { + return evaluateArrayJoin(node, env, fromFile); + } + if (!ts.isIdentifier(node.expression) || node.expression.text !== 'cn') { throw evalError(node, fromFile, 'Only `cn(...)` calls are supported in token expressions'); } @@ -294,6 +289,40 @@ function evaluateCall(node: ts.CallExpression, env: Map, fro return parts.join(' '); } +function evaluateArrayJoin(node: ts.CallExpression, env: Map, fromFile: string): TokenValue { + const callee = node.expression; + if (!ts.isPropertyAccessExpression(callee) || !ts.isArrayLiteralExpression(callee.expression)) { + throw evalError(node, fromFile, 'Only array-literal `.join()` calls are supported in token expressions'); + } + if (node.arguments.length > 1) { + throw evalError(node, fromFile, 'Array `.join()` in token expressions accepts at most one separator'); + } + + let separator = ','; + const arg = node.arguments[0]; + if (arg) { + if (!ts.isStringLiteral(arg) && !ts.isNoSubstitutionTemplateLiteral(arg)) { + throw evalError(arg, fromFile, 'Array `.join()` separator must be a string literal'); + } + separator = arg.text; + } + + return evaluateArrayParts(callee.expression, env, fromFile).join(separator); +} + +function evaluateArrayParts(node: ts.ArrayLiteralExpression, env: Map, fromFile: string): string[] { + const parts: string[] = []; + for (const el of node.elements) { + const v = evaluate(el, env, fromFile); + if (typeof v === 'string') { + if (v) parts.push(v); + continue; + } + throw evalError(node, fromFile, 'Arrays in token expressions must contain strings only'); + } + return parts; +} + /* ───────────────────────────────────────────────────────────────────────── * Module resolution * ───────────────────────────────────────────────────────────────────────── */ diff --git a/packages/compiler/src/tailwind/naming.ts b/packages/compiler/src/tailwind/naming.ts index 1c5642d6..a61f8729 100644 --- a/packages/compiler/src/tailwind/naming.ts +++ b/packages/compiler/src/tailwind/naming.ts @@ -9,7 +9,7 @@ export interface DerivedClassName { /** The full class name. */ className: string; /** Which derivation rule produced the name. */ - source: 'tag' | 'token-path' | 'override'; + source: 'tag' | 'token-path' | 'literal' | 'override'; } /** Context passed to a `NameTransform`. */ @@ -59,6 +59,13 @@ export interface DeriveClassNameOptions { * Overrides win over `transformName`. */ overrides?: Record; + /** + * Local identifiers that are namespace imports for token modules. When + * provided, only these leading path segments are dropped from token names. + */ + tokenNamespaces?: ReadonlySet; + /** Local identifiers known to resolve to style tokens. */ + tokenRoots?: ReadonlySet; } /** @@ -105,36 +112,33 @@ export class DiagnosticError extends Error { * * Priority order: * 1. **Override** — `overrides[tag]` or `overrides[token-path]` if set. - * 2. **JSX component tag** — kebab-cased, dotted parts joined with `-`. + * 2. **Token path** — when className references style tokens. The + * most specific dotted token path names the class. Leading namespace + * identifier is dropped; remaining parts kebab-cased and joined with `-`. + * Result passed through `transformName`. + * 3. **JSX component tag** — kebab-cased, dotted parts joined with `-`. * Result passed through `transformName` for final shaping. - * 3. **Token path** — for bare HTML elements with a single dotted token - * reference. Leading namespace identifier is dropped; remaining parts - * kebab-cased and joined with `-`. Result passed through `transformName`. - * 4. **Diagnostic** — no rule matched; throws `DiagnosticError`. + * 4. **Literal utility** — for bare HTML elements with one simple literal + * utility, reuse that utility as the class name. + * 5. **Diagnostic** — no rule matched; throws `DiagnosticError`. */ export function deriveClassName(opts: DeriveClassNameOptions): DerivedClassName { const overrides = opts.overrides ?? {}; const transform: NameTransform = opts.transformName ?? ((ctx) => ctx.defaultName); const tag = tagName(opts.element); + const isComponent = isComponentTag(tag); // 1. Override hit by tag. if (overrides[tag]) return { className: overrides[tag]!, source: 'override' }; - // 2. JSX component tag derivation. - if (isComponentTag(tag)) { - const defaultName = tagToDefaultName(tag); - const className = transform({ source: 'tag', tag, defaultName }); - return { className, source: 'tag' }; - } - - // 3. Token-path derivation. + // 2. Token-path derivation. if (opts.segments) { - const tokenPath = singleTokenPath(opts.segments); - if (tokenPath) { + const tokenPath = mostSpecificTokenPath(opts.segments, opts.tokenRoots); + if (tokenPath && (!isComponent || tag.includes('.') || opts.tokenRoots?.has(tokenPath[0]!))) { const overrideKey = tokenPath.join('.'); if (overrides[overrideKey]) return { className: overrides[overrideKey]!, source: 'override' }; - const defaultName = tokenPathToDefaultName(tokenPath); + const defaultName = tokenPathToDefaultName(tokenPath, opts.tokenNamespaces); if (defaultName) { const className = transform({ source: 'token-path', tokenPath, defaultName }); return { className, source: 'token-path' }; @@ -142,10 +146,22 @@ export function deriveClassName(opts: DeriveClassNameOptions): DerivedClassName } } - // 4. Diagnostic — no rule matched. + // 3. JSX component tag derivation. + if (isComponent) { + const defaultName = tagToDefaultName(tag); + const className = transform({ source: 'tag', tag, defaultName }); + return { className, source: 'tag' }; + } + + if (opts.segments) { + const literal = singleLiteralUtility(opts.segments); + if (literal) return { className: literal, source: 'literal' }; + } + + // 5. Diagnostic — no rule matched. throw new DiagnosticError( `Cannot derive a CSS class name for <${tag}>.\n` + - `Tag is bare HTML and the className doesn't reference a single token. ` + + `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) adding an entry to \`overrides\`.`, @@ -167,26 +183,51 @@ function tagToDefaultName(tag: string): string { .join('-'); } -function singleTokenPath(segments: readonly StyleSegment[]): readonly string[] | null { - // Accept a single token segment plus any number of literal segments - // (literals contribute utilities, the token names the class). Reject - // multiple token segments (ambiguous) or any opaque expression. +function mostSpecificTokenPath( + segments: readonly StyleSegment[], + tokenRoots?: ReadonlySet | undefined +): readonly string[] | null { + // Accept token segments plus any number of literal or opaque segments. Opaque + // runtime values are preserved by the style transform, but a static token can + // still name the generated class. let tokenSegment: readonly string[] | null = null; for (const seg of segments) { if (seg.kind === 'literal') continue; - if (seg.kind === 'opaque') return null; + if (seg.kind === 'opaque') continue; if (seg.kind === 'token') { - if (tokenSegment) return null; - tokenSegment = seg.path; + if (tokenRoots && !tokenRoots.has(seg.path[0]!)) continue; + if (!tokenSegment || seg.path.length >= tokenSegment.length) tokenSegment = seg.path; } } return tokenSegment; } -function tokenPathToDefaultName(path: readonly string[]): string | null { - // Drop the leading identifier (the namespace under which the tokens are - // imported) — it's not semantic. - if (path.length < 2) return null; - const meaningful = path.slice(1); +function tokenPathToDefaultName( + path: readonly string[], + tokenNamespaces?: ReadonlySet | undefined +): string | null { + const meaningful = tokenPathMeaningfulSegments(path, tokenNamespaces); return meaningful.map((p) => kebabCase(p).replace(/^-/, '')).join('-'); } + +function tokenPathMeaningfulSegments( + path: readonly string[], + tokenNamespaces?: ReadonlySet | undefined +): readonly string[] { + if (path.length === 1) return path; + if (!tokenNamespaces) return path.slice(1); + return tokenNamespaces.has(path[0]!) ? path.slice(1) : path; +} + +function singleLiteralUtility(segments: readonly StyleSegment[]): string | null { + let utility: string | null = null; + for (const seg of segments) { + if (seg.kind !== 'literal') return null; + const parts = seg.value.split(/\s+/).filter(Boolean); + for (const part of parts) { + if (utility || !/^[a-z0-9_-]+$/.test(part)) return null; + utility = part; + } + } + return utility; +} diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index b93d278a..aae4902d 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -36,6 +36,8 @@ export type TailwindMode = /** Per-rule annotation hook; lets the consumer assign a `bag` for split-mode emission. */ export type BagFor = (info: { className: string; segments: readonly StyleSegment[] }) => string | undefined; +export type ResolveTokenModule = (specifier: string, fromFile: string) => string | null | undefined; + export interface TailwindOptions { /** Styling mode. Defaults to `'preserve'`. */ mode?: TailwindMode | undefined; @@ -45,6 +47,8 @@ export interface TailwindOptions { input?: string | undefined; /** CSS asset name for `'extract'`. Defaults to the compiled source basename with `.css`. */ output?: string | undefined; + /** Resolve bare token imports in skin sources to token modules on disk. Relative imports use the default resolver. */ + resolveTokenModule?: ResolveTokenModule | undefined; /** * Hook for shaping the final class name (see `NameTransform`). Only used * by `'extract'`. Identity by default. @@ -168,13 +172,13 @@ export function tailwindPlugin(options: TailwindTransformOptions): ts.Transforme * ───────────────────────────────────────────────────────────────────────── */ function inlinedPlugin(options: TailwindTransformOptions): ts.TransformerFactory { - const env = buildTokenEnv(options.sourcePath); + const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule); return (transformContext) => { return (sourceFile) => { const visit: StyleVisitor = (info, factory) => { if (info.kind !== 'segments' || !info.segments) return undefined; - const flat = flattenToLiteral(info.segments, env); + const flat = flattenToLiteral(info.segments, env.values); if (flat === null) return undefined; return factory.createStringLiteral(flat); }; @@ -193,7 +197,7 @@ function vanillaCssPlugin( ): ts.TransformerFactory { const { design, transformName, overrides, bagFor, onRules } = options; - const env = buildTokenEnv(options.sourcePath); + const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule); return (transformContext) => { return (sourceFile) => { @@ -214,6 +218,7 @@ function vanillaCssPlugin( segments, ...(transformName ? { transformName } : {}), ...(overrides ? { overrides } : {}), + ...(env.hasSource ? { tokenNamespaces: env.namespaces, tokenRoots: env.roots } : {}), }; const derived = deriveClassName(naming); @@ -225,9 +230,9 @@ function vanillaCssPlugin( // a `cn(...)` call so composition is preserved. const passThrough: ts.Expression[] = []; const preserved: string[] = []; - // Every utility this element resolves to (rule-producing or preserved), - // for the collision signature below. - const utilities: string[] = []; + // Rule-producing utilities only. Preserved marker classes stay on the + // element and don't participate in generated CSS rule merging. + const ruleUtilities: string[] = []; // Compile one utility: emit a rule when it produces declarations, // otherwise *preserve* it as a literal class. Utilities that yield no @@ -235,9 +240,9 @@ function vanillaCssPlugin( // Tailwind doesn't recognize — dropping them would silently break every // descendant `group-*` / `peer-*` variant that targets the marker. const handleUtility = (utility: string): void => { - utilities.push(utility); const css = decompose(utility, design); if (css && css.declarations.length > 0) { + ruleUtilities.push(utility); rules.push(buildCompiledRule(derived.className, css, segments, bagFor)); return; } @@ -252,7 +257,7 @@ function vanillaCssPlugin( continue; } if (seg.kind === 'token') { - const literal = resolveTokenPath(seg.path, env); + const literal = resolveTokenPath(seg.path, env.values); if (literal !== null) { for (const utility of literal.split(/\s+/)) { if (utility) handleUtility(utility); @@ -269,8 +274,8 @@ function vanillaCssPlugin( // many `` with the same token) share a signature and are // fine; differing ones would merge conflicting declarations into one // rule, so we fail loudly with a fixable diagnostic. - if (utilities.length > 0) { - const signature = [...utilities].sort().join(' '); + if (ruleUtilities.length > 0) { + const signature = [...ruleUtilities].sort().join(' '); const previous = signatures.get(derived.className); if (previous === undefined) { signatures.set(derived.className, signature); @@ -357,9 +362,22 @@ function defaultCssFileName(context: CompilerContext): string { * If `sourcePath` is undefined or unreadable, returns an empty map; the plugin * then leaves token-bearing className expressions alone. */ -function buildTokenEnv(sourcePath: string | undefined): Map { - const env = new Map(); +interface TokenEnv { + values: Map; + namespaces: Set; + roots: Set; + hasSource: boolean; +} + +function buildTokenEnv(sourcePath: string | undefined, resolveTokenModule?: ResolveTokenModule | undefined): TokenEnv { + const env: TokenEnv = { + values: new Map(), + namespaces: new Set(), + roots: new Set(), + hasSource: false, + }; if (!sourcePath || !existsSync(sourcePath)) return env; + env.hasSource = true; const source = readFileSync(sourcePath, 'utf8'); const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -370,9 +388,7 @@ function buildTokenEnv(sourcePath: string | undefined): Map const specifier = stmt.moduleSpecifier; if (!ts.isStringLiteral(specifier)) continue; const id = specifier.text; - if (!id.startsWith('.')) continue; - - const absolutePath = resolveModulePath(id, sourcePath); + const absolutePath = resolveTokenImport(id, sourcePath, resolveTokenModule); if (!absolutePath) continue; let exports: Record; @@ -395,13 +411,17 @@ function buildTokenEnv(sourcePath: string | undefined): Map const sourceName = spec.propertyName?.text ?? spec.name.text; const localName = spec.name.text; const value = exports[sourceName]; - if (value !== undefined) env.set(localName, value); + if (value !== undefined) { + setTokenValue(env, localName, value); + if (isTokenNamespaceImport(sourceName, localName, value)) env.namespaces.add(localName); + } } continue; } if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { - env.set(clause.namedBindings.name.text, exports as TokenValue); + env.namespaces.add(clause.namedBindings.name.text); + setTokenValue(env, clause.namedBindings.name.text, exports as TokenValue); } } @@ -414,14 +434,24 @@ function buildTokenEnv(sourcePath: string | undefined): Map if (!ts.isVariableStatement(stmt)) continue; for (const decl of stmt.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; - const value = tryEvaluateLocal(decl.initializer, env); - if (value !== null) env.set(decl.name.text, value); + const value = tryEvaluateLocal(decl.initializer, env.values); + if (value !== null) setTokenValue(env, decl.name.text, value); } } return env; } +function setTokenValue(env: TokenEnv, name: string, value: TokenValue): void { + env.values.set(name, value); + env.roots.add(name); +} + +function isTokenNamespaceImport(sourceName: string, localName: string, value: TokenValue): boolean { + if (typeof value === 'string') return false; + return sourceName === 'tokens' || sourceName === 'styles' || localName === 'tokens' || localName === 'styles'; +} + /** * Evaluate a local declaration's RHS against `env`. Supports `cn(...)` calls, * dotted access, identifier lookup, and string literals — same surface as the @@ -458,6 +488,17 @@ function tryEvaluateLocal(node: ts.Expression, env: Map): To const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const; +function resolveTokenImport( + specifier: string, + fromFile: string, + resolveTokenModule?: ResolveTokenModule | undefined +): string | null { + if (specifier.startsWith('.')) return resolveModulePath(specifier, fromFile); + const resolved = resolveTokenModule?.(specifier, fromFile); + if (!resolved) return null; + return isAbsolute(resolved) ? resolved : resolvePath(dirname(fromFile), resolved); +} + function resolveModulePath(specifier: string, fromFile: string): string | null { const base = isAbsolute(specifier) ? specifier : resolvePath(dirname(fromFile), specifier); for (const ext of MODULE_EXTENSIONS) { diff --git a/packages/compiler/src/tailwind/tests/evaluator.test.ts b/packages/compiler/src/tailwind/tests/evaluator.test.ts index 0ed2f0a4..7985afa1 100644 --- a/packages/compiler/src/tailwind/tests/evaluator.test.ts +++ b/packages/compiler/src/tailwind/tests/evaluator.test.ts @@ -109,6 +109,27 @@ describe('loadTokenModule — cn() calls', () => { }); }); +describe('loadTokenModule — array joins', () => { + it('joins static array literals with a string separator', () => { + const file = write( + 'mod.ts', + `export const a = ['flex', 'items-center'].join(' '); +` + ); + expect(loadTokenModule(file)).toEqual({ a: 'flex items-center' }); + }); + + it('rejects non-literal join separators', () => { + const file = write( + 'mod.ts', + `const sep = ' '; +export const a = ['flex', 'items-center'].join(sep); +` + ); + expect(() => loadTokenModule(file)).toThrow(/separator must be a string literal/); + }); +}); + describe('loadTokenModule — relative imports', () => { it('resolves a relative .ts import', () => { write('base.ts', `export const value = 'flex';\n`); diff --git a/packages/compiler/src/tailwind/tests/naming.test.ts b/packages/compiler/src/tailwind/tests/naming.test.ts index 02866074..020423ba 100644 --- a/packages/compiler/src/tailwind/tests/naming.test.ts +++ b/packages/compiler/src/tailwind/tests/naming.test.ts @@ -84,6 +84,24 @@ describe('deriveClassName — token-path derivation', () => { expect(r.className).toBe('foo'); }); + it('drops leading identifiers that are known token namespaces', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'foo'])], + tokenNamespaces: new Set(['styles']), + }); + expect(r.className).toBe('foo'); + }); + + it('keeps leading identifiers that are named token imports', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['slider', 'root'])], + tokenNamespaces: new Set(), + }); + expect(r.className).toBe('slider-root'); + }); + it('combines literal segments with a single token (token names the class)', () => { const r = deriveClassName({ element: firstElement(`
`), @@ -92,22 +110,20 @@ describe('deriveClassName — token-path derivation', () => { expect(r.className).toBe('foo'); }); - it('throws on multiple tokens (ambiguous)', () => { - expect(() => - deriveClassName({ - element: firstElement(`
`), - segments: [token(['styles', 'a']), token(['styles', 'b'])], - }) - ).toThrow(DiagnosticError); + it('uses the last equal-depth token when multiple tokens are present', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'a']), token(['styles', 'b'])], + }); + expect(r.className).toBe('b'); }); - it('throws on an opaque expression next to a token', () => { - expect(() => - deriveClassName({ - element: firstElement(`
`), - segments: [token(['styles', 'a']), opaque()], - }) - ).toThrow(DiagnosticError); + it('derives from a token when opaque runtime segments are present', () => { + const r = deriveClassName({ + element: firstElement(`
`), + segments: [token(['styles', 'a']), opaque()], + }); + expect(r.className).toBe('a'); }); it('honours overrides keyed by dotted token path', () => { @@ -119,6 +135,35 @@ describe('deriveClassName — token-path derivation', () => { expect(r.source).toBe('override'); expect(r.className).toBe('special'); }); + + it('keeps regular components tag-derived when token segments are present', () => { + const r = deriveClassName({ + element: firstElement(``), + segments: [token(['styles', 'button', 'icon'])], + }); + expect(r.source).toBe('tag'); + expect(r.className).toBe('play-button'); + }); + + it('derives regular components from known token roots', () => { + const r = deriveClassName({ + element: firstElement(``), + segments: [token(['menu', 'chevron'])], + tokenRoots: new Set(['menu']), + tokenNamespaces: new Set(), + }); + expect(r.source).toBe('token-path'); + expect(r.className).toBe('menu-chevron'); + }); + + it('derives compound components from token segments when present', () => { + const r = deriveClassName({ + element: firstElement(``), + segments: [token(['styles', 'menu', 'item'])], + }); + expect(r.source).toBe('token-path'); + expect(r.className).toBe('menu-item'); + }); }); describe('deriveClassName — transformName', () => { @@ -215,7 +260,7 @@ describe('deriveClassName — diagnostics', () => { it('includes the tag name in the error message', () => { let caught: DiagnosticError | null = null; try { - deriveClassName({ element: firstElement(`
`), segments: [literal('x')] }); + deriveClassName({ element: firstElement(`
`), segments: [literal('x y')] }); } catch (e) { caught = e as DiagnosticError; } diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index 232a1cb5..ceb41b03 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -210,6 +210,16 @@ describe('tailwindPlugin — mode: extract', () => { ).rejects.toThrow(/class name 'seek-icon' is derived from elements with different styles/); }); + it('allows preserved marker classes next to matching generated styles', async () => { + const source = `function App(){ return
; }`; + const { code } = await compile(source, { + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract' })], + }); + expect(code).toContain('"menu-item legacy-submenu"'); + expect(code).toContain('"menu-item"'); + }); + it('handles duplicate component styles', async () => { const source = `function App(){ return
; }`; const { code } = await compile(source, { @@ -228,6 +238,113 @@ describe('tailwindPlugin — mode: extract', () => { expect(code).toContain('"buffering-indicator"'); }); + it('preserves named token import roots in class names', async () => { + writeFixture( + 'tokens.ts', + `export const slider = { root: 'flex' }; +` + ); + const source = `import { slider } from './tokens'; +function App(){ return
; }`; + const sourcePath = writeFixture('skin.tsx', source); + + const { code } = await compile(source, { + filename: sourcePath, + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })], + }); + expect(code).toContain('"slider-root"'); + }); + + it('uses known token roots to disambiguate reused component tags', async () => { + writeFixture( + 'tokens.ts', + `export const icon = 'inline-block'; +export const menu = { chevron: 'size-3' }; +export const inputFeedback = { bubble: { shownSeek: 'block' } }; +` + ); + const source = `import { icon, inputFeedback, menu } from './tokens'; +function App(){ return
; }`; + const sourcePath = writeFixture('skin.tsx', source); + + const { code } = await compile(source, { + filename: sourcePath, + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })], + }); + expect(code).toContain('"menu-chevron"'); + expect(code).toContain('"input-feedback-bubble-shown-seek"'); + }); + + it('derives class names from single imported token identifiers', async () => { + const source = `function App(){ return
; }`; + const { code } = await compile(source, { + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract' })], + }); + expect(code).toContain('"button-group-start"'); + }); + + it('prefers style token names over reusable component tag names', async () => { + const source = `function App(){ return ; }`; + const { code } = await compile(source, { + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract' })], + }); + expect(code).toContain('"menu-item"'); + }); + + it('derives bare HTML class names from the most specific token path', async () => { + writeFixture( + 'tokens.ts', + `export const tokens = { seek: { label: 'text-xs', labelBackward: 'left-0' } }; +` + ); + const source = `import { tokens as styles } from './tokens'; +function App(){ return ; }`; + const sourcePath = writeFixture('skin.tsx', source); + + const { code } = await compile(source, { + target: 'react', + filename: sourcePath, + plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })], + }); + + expect(code).toContain('"seek-label-backward"'); + }); + + it('derives bare HTML class names from tokens when runtime segments are present', async () => { + writeFixture( + 'tokens.ts', + `export const tokens = { slider: { fill: { base: 'absolute', fill: 'bg-white', buffer: 'bg-white/40' } } }; +` + ); + const source = `import { tokens as styles } from './tokens'; +function App({ type, className }){ + return
; +}`; + const sourcePath = writeFixture('skin.tsx', source); + + const { code } = await compile(source, { + target: 'react', + filename: sourcePath, + plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })], + }); + + expect(code).toContain('"slider-fill-base"'); + }); + + it('keeps a single simple literal utility as the class name for bare HTML', async () => { + const source = `function App(){ return
; }`; + const { code } = await compile(source, { + target: 'react', + plugins: [tailwindPlugin({ design, mode: 'extract' })], + }); + + expect(code).toContain('"grow"'); + }); + it('applies component class overrides', async () => { const source = `function App(){ return ; }`; const { code } = await compile(source, { @@ -326,7 +443,39 @@ function App(){ return ; }`; ], }); expect(captured!.length).toBe(2); - expect(captured![0]!.className).toBe('foo'); + expect(captured![0]!.className).toBe('button'); + }); + + 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') }; +` + ); + const source = `import { tokens as styles } from '@fixture/tokens'; +function App(){ return ; }`; + const sourcePath = writeFixture('skin.tsx', source); + + let captured: readonly CompiledRule[] | undefined; + const { code } = await compile(source, { + target: 'react', + filename: sourcePath, + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + sourcePath, + resolveTokenModule: (specifier) => (specifier === '@fixture/tokens' ? tokenPath : null), + onRules: (rules) => { + captured = rules; + }, + }), + ], + }); + + expect(code).toContain('"button"'); + expect(captured!.length).toBe(2); }); it('assigns rule bags with bagFor', async () => { @@ -395,7 +544,7 @@ function App(){ return ; }`; }), ], }); - expect(code).toContain('"play-button"'); + expect(code).toContain('"icon-button"'); expect(captured!.length).toBe(3); const utilities = captured!.map((r) => r.utility.utility).sort(); expect(utilities).toEqual(['flex', 'h-4', 'w-4']); diff --git a/packages/compiler/src/transforms/drop-unused-imports.ts b/packages/compiler/src/transforms/drop-unused-imports.ts index ceb28c65..bfed22aa 100644 --- a/packages/compiler/src/transforms/drop-unused-imports.ts +++ b/packages/compiler/src/transforms/drop-unused-imports.ts @@ -39,12 +39,44 @@ function collectReferencedIdentifiers(sourceFile: ts.SourceFile): Set { // not references. (Module specifier is a string literal, no identifiers.) return; } + if (ts.isJsxOpeningElement(node)) { + collectFromTagName(node.tagName, used); + ts.forEachChild(node.attributes, (c) => visit(c, inImport)); + return; + } + if (ts.isJsxSelfClosingElement(node)) { + collectFromTagName(node.tagName, used); + ts.forEachChild(node.attributes, (c) => visit(c, inImport)); + return; + } + if (ts.isJsxClosingElement(node)) { + collectFromTagName(node.tagName, used); + return; + } + if (ts.isJsxAttribute(node)) { + if (node.initializer) visit(node.initializer, inImport); + return; + } + if (ts.isPropertyAccessExpression(node)) { + visit(node.expression, inImport); + return; + } + if (ts.isBindingElement(node)) { + if (node.initializer) visit(node.initializer, inImport); + return; + } + if (ts.isVariableDeclaration(node)) { + if (node.initializer) visit(node.initializer, inImport); + return; + } + if (ts.isPropertyAssignment(node)) { + if (ts.isComputedPropertyName(node.name)) visit(node.name.expression, inImport); + visit(node.initializer, inImport); + return; + } if (ts.isIdentifier(node) && !inImport) { used.add(node.text); } - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxClosingElement(node)) { - collectFromTagName(node.tagName, used); - } ts.forEachChild(node, (c) => visit(c, inImport)); }; @@ -54,7 +86,7 @@ function collectReferencedIdentifiers(sourceFile: ts.SourceFile): Set { function collectFromTagName(name: ts.JsxTagNameExpression, into: Set): void { if (ts.isIdentifier(name)) { - into.add(name.text); + if (isComponentIdentifier(name.text)) into.add(name.text); return; } if (ts.isPropertyAccessExpression(name)) { @@ -62,6 +94,10 @@ function collectFromTagName(name: ts.JsxTagNameExpression, into: Set): v } } +function isComponentIdentifier(name: string): boolean { + return /^[A-Z]/.test(name); +} + function trimImport( stmt: ts.ImportDeclaration, used: Set, diff --git a/packages/compiler/src/transforms/tests/drop-unused-imports.test.ts b/packages/compiler/src/transforms/tests/drop-unused-imports.test.ts new file mode 100644 index 00000000..85d626b9 --- /dev/null +++ b/packages/compiler/src/transforms/tests/drop-unused-imports.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { compile } from '../../compile'; +import { react } from '../../config'; +import { dropUnusedImports } from '../drop-unused-imports'; + +const wrap = async (source: string): Promise => + (await compile(source, { config: { target: react({ transforms: [dropUnusedImports()] }) } })).code; + +describe('dropUnusedImports', () => { + it('does not count intrinsic JSX tag names as import references', async () => { + const code = await wrap(`import { button } from './tokens'; +function App(){ return