From 3010d38d0815c2117b2ccbcc266ba6c43ba0a555 Mon Sep 17 00:00:00 2001 From: Rahim Date: Tue, 23 Jun 2026 15:08:19 -0700 Subject: [PATCH] refactor(compiler)!: reshape tailwind styling options --- packages/compiler/src/tailwind/emit.ts | 146 ++++++++++++------ packages/compiler/src/tailwind/index.ts | 14 +- packages/compiler/src/tailwind/naming.ts | 146 +++++++++--------- packages/compiler/src/tailwind/plugin.ts | 127 +++++++-------- .../compiler/src/tailwind/tests/emit.test.ts | 36 +++-- .../src/tailwind/tests/naming.test.ts | 77 +++++---- .../src/tailwind/tests/plugin.test.ts | 97 +++++++++--- 7 files changed, 368 insertions(+), 275 deletions(-) diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts index 7f22a34f..035bac7b 100644 --- a/packages/compiler/src/tailwind/emit.ts +++ b/packages/compiler/src/tailwind/emit.ts @@ -9,14 +9,17 @@ export interface CompiledRule { /** Declarations + variants extracted from the utility. */ utility: UtilityCss; /** - * Optional grouping key for `mode: 'split'`. Rules with the same `bag` - * end up in the same `.css` file. Ignored in merged mode. + * Optional logical grouping key. `mode: 'split'` writes rules with the same + * group to the same `.css` file; merged mode preserves the metadata + * but emits one stylesheet. */ - bag?: string; + group?: string; } /** Output of `emitCss`. Discriminated by `kind`. */ -export type EmittedCss = { kind: 'merged'; css: string } | { kind: 'split'; index: string; bags: Map }; +export type EmittedCss = + | { kind: 'merged'; css: string } + | { kind: 'split'; index: string; groups: Map }; /** * Hoist configuration. When provided, every CSS custom property declaration @@ -39,14 +42,14 @@ export interface EmitCssOptions { /** * Layout mode: * - `'merged'` (default): one CSS string with all rules. - * - `'split'`: one string per `bag` plus an `index` string with - * `@import` lines for each bag in stable order. + * - `'split'`: one string per group plus an `index` string with + * `@import` lines for each group in stable order. */ mode?: 'merged' | 'split'; /** * Optional list of CSS files to prepend to the output (verbatim, after * `@import` resolution via Lightning CSS). In `'split'` mode they go into - * `index` only, not duplicated across bags. + * `index` only, not duplicated across groups. */ baseCss?: readonly string[]; /** @@ -120,8 +123,13 @@ export interface RegisteredPropertiesOptions { * matched variables. */ mode: 'emit' | 'inline'; - /** Which property names to handle. Defaults to `inlineVars`'s matcher, else `/^--tw-/`. */ - match?: RegExp; + /** Variable matchers and optional definition resolvers. Defaults to `inlineVars`, else `/^--tw-/`. */ + variables?: readonly RegisteredPropertyVariableOptions[] | undefined; +} + +export interface RegisteredPropertyVariableOptions { + /** Which registered property names this rule handles. */ + match: RegExp; /** * Override or supply a property's definition. Receives the name and the * definition captured from Tailwind's output (if any); return a new @@ -131,6 +139,13 @@ export interface RegisteredPropertiesOptions { resolve?: (name: string, captured: PropertyDef | undefined) => PropertyDef | undefined; } +type VariableMatcher = (name: string) => boolean; + +interface RegisteredPropertyVariable { + match: VariableMatcher; + resolve?: ((name: string, captured: PropertyDef | undefined) => PropertyDef | undefined) | undefined; +} + /** * Compose `CompiledRule[]` into final CSS. Rules sharing the same emit * context (same selector chain + at-rule wrappers) merge their declarations @@ -154,15 +169,18 @@ export async function emitCss(opts: EmitCssOptions): Promise { // place to be emitted as `@property` rules. const propMode = opts.properties?.mode; const captured = collectPropertyDefs(opts.rules); + const propertyVariables = opts.properties ? normalizePropertyVariables(opts.properties.variables, inlineVars) : []; + const propertyMatch = propertyVariables.length > 0 ? matchAny(propertyVariables) : undefined; const resolveDef = (name: string): PropertyDef | undefined => { const cap = captured.get(name); - return opts.properties?.resolve?.(name, cap) ?? cap; + const variable = propertyVariables.find((v) => v.match(name)); + return variable?.resolve?.(name, cap) ?? cap; }; - const propMatch = opts.properties?.match ?? inlineVars ?? /^--tw-/; - const inlineMatch = propMode === 'inline' ? propMatch : inlineVars; - const fallbacks = propMode === 'inline' ? buildFallbackSetters(captured, propMatch, resolveDef) : undefined; + const inlineMatch = propMode === 'inline' ? combineMatchers(inlineVars, propertyMatch) : inlineVars; + const fallbacks = + propMode === 'inline' && propertyMatch ? buildFallbackSetters(captured, propertyMatch, resolveDef) : undefined; const emitProperties = (css: string): string => - propMode === 'emit' ? buildPropertyBlocks(css, propMatch, resolveDef) : ''; + propMode === 'emit' && propertyMatch ? buildPropertyBlocks(css, propertyMatch, resolveDef) : ''; if (mode === 'merged') { const base = await bundleBaseCss(opts.baseCss ?? [], configDir); @@ -172,33 +190,33 @@ export async function emitCss(opts: EmitCssOptions): Promise { return { kind: 'merged', css: joinSections(base, properties, theme, body) }; } - // Split mode: group rules by `bag`. - const byBag = new Map(); + // Split mode: group rules by resolved group. + const byGroup = new Map(); for (const rule of opts.rules) { - const bag = rule.bag ?? ''; - const arr = byBag.get(bag) ?? []; + const group = rule.group ?? ''; + const arr = byGroup.get(group) ?? []; arr.push(rule); - byBag.set(bag, arr); + byGroup.set(group, arr); } - const bags = new Map(); + const groups = new Map(); const importLines: string[] = []; - // Sort bag names for deterministic output. - const sortedBags = [...byBag.keys()].sort(); - for (const bagName of sortedBags) { - const bagRules = byBag.get(bagName)!; - bags.set(bagName, composeRules(bagRules, undefined, inlineMatch, fallbacks)); - importLines.push(`@import "./${bagName || 'index'}.css";`); + // Sort group names for deterministic output. + const sortedGroups = [...byGroup.keys()].sort(); + for (const groupName of sortedGroups) { + const groupRules = byGroup.get(groupName)!; + groups.set(groupName, composeRules(groupRules, undefined, inlineMatch, fallbacks)); + importLines.push(`@import "./${groupName || 'index'}.css";`); } const base = await bundleBaseCss(opts.baseCss ?? [], configDir); // Theme variables and @property rules go in `index` (it loads first), - // resolved against every bag. - const allBagsCss = [...bags.values()].join('\n'); - const theme = buildThemeBlock(allBagsCss, opts.resolveThemeVar, opts.themeSelector); - const properties = emitProperties(allBagsCss); + // resolved against every group. + const allGroupsCss = [...groups.values()].join('\n'); + const theme = buildThemeBlock(allGroupsCss, opts.resolveThemeVar, opts.themeSelector); + const properties = emitProperties(allGroupsCss); const index = joinSections(base, properties, theme, importLines.join('\n')); - return { kind: 'split', index, bags }; + return { kind: 'split', index, groups }; } /** @@ -283,12 +301,12 @@ function collectPropertyDefs(rules: readonly CompiledRule[]): Map, - match: RegExp, + match: VariableMatcher, resolveDef: (name: string) => PropertyDef | undefined ): Map { const out = new Map(); for (const name of captured.keys()) { - if (!match.test(name)) continue; + if (!match(name)) continue; const def = resolveDef(name); if (def?.initialValue !== undefined) out.set(name, def.initialValue); } @@ -302,10 +320,10 @@ function buildFallbackSetters( */ function buildPropertyBlocks( css: string, - match: RegExp, + match: VariableMatcher, resolveDef: (name: string) => PropertyDef | undefined ): string { - const referenced = [...collectReferencedVars(css)].filter((name) => match.test(name)).sort(); + const referenced = [...collectReferencedVars(css)].filter(match).sort(); const blocks: string[] = []; for (const name of referenced) { const def = resolveDef(name); @@ -358,7 +376,7 @@ interface EmitUnit { function composeRules( rules: readonly CompiledRule[], hoist: HoistOptions | undefined, - inlineVars: RegExp | undefined, + inlineVars: VariableMatcher | undefined, fallbackSetters?: Map ): string { // Step 1: turn each CompiledRule into one EmitUnit. @@ -537,10 +555,44 @@ function applyHoist( } } -function normalizeInlineMatcher(opt: true | RegExp | undefined): RegExp | undefined { +function normalizeInlineMatcher(opt: true | RegExp | undefined): VariableMatcher | undefined { if (opt === undefined) return undefined; - if (opt === true) return /^--tw-/; - return opt; + if (opt === true) return regexMatcher(/^--tw-/); + return regexMatcher(opt); +} + +function normalizePropertyVariables( + variables: readonly RegisteredPropertyVariableOptions[] | undefined, + inlineVars: VariableMatcher | undefined +): RegisteredPropertyVariable[] { + if (variables && variables.length > 0) { + return variables.map((variable) => ({ + match: regexMatcher(variable.match), + ...(variable.resolve ? { resolve: variable.resolve } : {}), + })); + } + + return [{ match: inlineVars ?? regexMatcher(/^--tw-/) }]; +} + +function regexMatcher(regex: RegExp): VariableMatcher { + return (name) => { + regex.lastIndex = 0; + return regex.test(name); + }; +} + +function matchAny(variables: readonly RegisteredPropertyVariable[]): VariableMatcher { + return (name) => variables.some((variable) => variable.match(name)); +} + +function combineMatchers( + first: VariableMatcher | undefined, + second: VariableMatcher | undefined +): VariableMatcher | undefined { + if (!first) return second; + if (!second) return first; + return (name) => first(name) || second(name); } /** @@ -560,7 +612,7 @@ function normalizeInlineMatcher(opt: true | RegExp | undefined): RegExp | undefi */ function applyInline( merged: Map }>, - match: RegExp, + match: VariableMatcher, hoistRootSelector: string | undefined, fallbackSetters?: Map ): void { @@ -571,7 +623,7 @@ function applyInline( const rootEntry = merged.get(`\n${hoistRootSelector}`); if (rootEntry) { for (const d of rootEntry.declarations) { - if (d.property.startsWith('--') && match.test(d.property)) { + if (d.property.startsWith('--') && match(d.property)) { rootSetters.set(d.property, d.value); } } @@ -584,7 +636,7 @@ function applyInline( const isRoot = hoistRootSelector !== undefined && entry.selector === hoistRootSelector; const localSetters = new Map(); for (const d of entry.declarations) { - if (d.property.startsWith('--') && match.test(d.property)) { + if (d.property.startsWith('--') && match(d.property)) { localSetters.set(d.property, d.value); } } @@ -600,7 +652,7 @@ function applyInline( const next: Declaration[] = []; const nextSet = new Set(); for (const d of entry.declarations) { - if (d.property.startsWith('--') && match.test(d.property)) { + if (d.property.startsWith('--') && match(d.property)) { // The hoist root is where matching setters live for the rest of the // file to inline — drop it from the root unit too, since by now // every consumer has substituted its value. This leaves the root @@ -623,7 +675,7 @@ function applyInline( * Resolve a `setters` map to a fixed point so values that reference other * matching properties substitute recursively. Mutates the map in place. */ -function resolveSettersInPlace(setters: Map, match: RegExp): void { +function resolveSettersInPlace(setters: Map, match: VariableMatcher): void { if (setters.size === 0) return; for (let pass = 0; pass < 10; pass++) { let changed = false; @@ -645,7 +697,7 @@ function resolveSettersInPlace(setters: Map, match: RegExp): voi * else stay as `var(...)` (the runtime CSS engine will resolve them — or * not — at use time). */ -function inlineValue(value: string, setters: Map, match: RegExp): string { +function inlineValue(value: string, setters: Map, match: VariableMatcher): string { let out = ''; let i = 0; while (i < value.length) { @@ -684,14 +736,14 @@ function inlineValue(value: string, setters: Map, match: RegExp) * the substituted string. If the property doesn't match or isn't set, returns * the original `var()` text. */ -function resolveVarRef(inner: string, setters: Map, match: RegExp): string { +function resolveVarRef(inner: string, setters: Map, match: VariableMatcher): string { const commaIdx = findTopLevelComma(inner); const name = (commaIdx === -1 ? inner : inner.slice(0, commaIdx)).trim(); const fallback = commaIdx === -1 ? undefined : inner.slice(commaIdx + 1).trim(); if (!name.startsWith('--')) return `var(${inner})`; - if (match.test(name)) { + if (match(name)) { if (setters.has(name)) { // Recurse so a value containing further `var(...)` references resolves. return inlineValue(setters.get(name)!, setters, match); diff --git a/packages/compiler/src/tailwind/index.ts b/packages/compiler/src/tailwind/index.ts index 803823b6..e8a766d9 100644 --- a/packages/compiler/src/tailwind/index.ts +++ b/packages/compiler/src/tailwind/index.ts @@ -15,6 +15,7 @@ export { type HoistOptions, type PropertyDef, type RegisteredPropertiesOptions, + type RegisteredPropertyVariableOptions, } from './emit'; export { clearTokenModuleCache, EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; export { @@ -23,6 +24,15 @@ export { DiagnosticError, deriveClassName, type NameContext, - type NameTransform, + type ResolveName, } from './naming'; -export { type BagFor, type TailwindMode, type TailwindOptions, tailwind } from './plugin'; +export { + type ResolveGroup, + type ResolveTokenModule, + type TailwindEmitOptions, + type TailwindMode, + type TailwindOptions, + type TailwindResolveOptions, + type TailwindVarsOptions, + tailwind, +} from './plugin'; diff --git a/packages/compiler/src/tailwind/naming.ts b/packages/compiler/src/tailwind/naming.ts index cfdb9b60..a428121d 100644 --- a/packages/compiler/src/tailwind/naming.ts +++ b/packages/compiler/src/tailwind/naming.ts @@ -8,34 +8,35 @@ export interface DerivedClassName { /** The full class name. */ className: string; /** Which derivation rule produced the name. */ - source: 'tag' | 'token-path' | 'literal' | 'override'; + source: 'component' | 'token' | 'literal' | 'resolved'; } -/** Context passed to a `NameTransform`. */ -export type NameContext = - | { - /** Derivation came from a JSX component tag. */ - source: 'tag'; - /** The original tag (e.g. `'Foo'` or `'Foo.Bar'`). */ - tag: string; - /** Default name the compiler would emit (kebab-cased, dotted parts joined with `-`). */ - defaultName: string; - } - | { - /** Derivation came from a dotted token reference on a bare HTML element. */ - source: 'token-path'; - /** The original token path (e.g. `['styles', 'foo', 'bar']`). */ - tokenPath: readonly string[]; - /** Default name the compiler would emit (leading namespace dropped, kebab-cased, joined with `-`). */ - defaultName: string; - }; +export type DefaultNameSource = 'component' | 'token' | 'literal'; + +/** Context passed to `resolve.name`. */ +export interface NameContext { + /** The default candidate source used when `resolve.name` is omitted. */ + source: DefaultNameSource; + /** The JSX tag (e.g. `'PlayButton'` or `'Tooltip.Trigger'`). */ + tag: string; + /** Parsed `className` segments for the element. */ + segments: readonly StyleSegment[]; + /** Component-derived candidate, if the JSX tag is a component. */ + componentName?: string | undefined; + /** Token-derived candidate, if `className` references a style token. */ + tokenName?: string | undefined; + /** Literal-derived candidate, if the element has one simple literal utility. */ + literalName?: string | undefined; + /** The token path used for `tokenName`, e.g. `['styles', 'slider', 'track']`. */ + tokenPath?: readonly string[] | undefined; + /** The name the compiler would emit without a custom resolver. */ + defaultName: string; +} /** - * Hook for transforming the derived class name. Receives the original input - * (tag or token path) plus the default kebab-cased name; returns the final - * class name. Identity by default. + * Hook for resolving the final class name from the available candidates. */ -export type NameTransform = (context: NameContext) => string; +export type ResolveName = (context: NameContext) => string; export interface DeriveClassNameOptions { /** The element whose class name we're deriving. */ @@ -45,19 +46,8 @@ export interface DeriveClassNameOptions { * Used as a fallback when the element is bare HTML. */ segments?: readonly StyleSegment[]; - /** - * Optional hook for shaping the final class name. Receives both the - * derivation source (tag or token path) and the default name; returns - * whatever class name the consumer wants. Defaults to identity. - */ - transformName?: NameTransform; - /** - * Per-tag or per-token-path overrides. Keyed by JSX tag (`'Foo'`, - * `'Foo.Bar'`) or by a dotted token path joined with `.` - * (`'styles.foo.bar'`); value is the literal class name to emit. - * Overrides win over `transformName`. - */ - overrides?: Record; + /** Optional hook for resolving the final class name from naming candidates. */ + resolveName?: ResolveName | undefined; /** * Local identifiers that are namespace imports for token modules. When * provided, only these leading path segments are dropped from token names. @@ -71,7 +61,7 @@ export interface DeriveClassNameOptions { * Diagnostic thrown when no rule matches — typically a bare HTML element * with arbitrary class strings and no token-path indication of intent. * Resolution is up to the consumer (move classes onto a component, - * extract a token, add an override). + * extract a token, or customize `resolve.name`). */ export class DiagnosticError extends Error { public readonly diagnosticCode: string; @@ -109,61 +99,63 @@ export class DiagnosticError extends Error { /** * Derive a semantic CSS class name for a JSX element. * - * Priority order: - * 1. **Override** — `overrides[tag]` or `overrides[token-path]` if set. - * 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. - * 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`. + * The compiler computes component, token, and literal candidates, then calls + * `resolveName` when provided. Without a resolver it preserves the historical + * default: token names for bare HTML / compound components / known token roots, + * then component names, then single literal utilities. */ 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); + const segments = opts.segments ?? []; + const componentName = isComponent ? tagToDefaultName(tag) : undefined; - // 1. Override hit by tag. - if (overrides[tag]) return { className: overrides[tag]!, source: 'override' }; + const tokenPath = opts.segments ? mostSpecificTokenPath(opts.segments, opts.tokenRoots) : null; + const tokenName = tokenPath ? tokenPathToDefaultName(tokenPath, opts.tokenNamespaces) : null; + const literalName = opts.segments ? singleLiteralUtility(opts.segments) : null; - // 2. Token-path derivation. - if (opts.segments) { - 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, opts.tokenNamespaces); - if (defaultName) { - const className = transform({ source: 'token-path', tokenPath, defaultName }); - return { className, source: 'token-path' }; - } - } + const tokenIsDefault = Boolean( + tokenName && (!isComponent || tag.includes('.') || opts.tokenRoots?.has(tokenPath![0]!)) + ); + + let defaultName: string | undefined; + let defaultSource: DefaultNameSource | undefined; + + if (tokenIsDefault && tokenName) { + defaultName = tokenName; + defaultSource = 'token'; + } else if (componentName) { + defaultName = componentName; + defaultSource = 'component'; + } else if (tokenName) { + defaultName = tokenName; + defaultSource = 'token'; + } else if (literalName) { + defaultName = literalName; + defaultSource = 'literal'; } - // 3. JSX component tag derivation. - if (isComponent) { - const defaultName = tagToDefaultName(tag); - const className = transform({ source: 'tag', tag, defaultName }); - return { className, source: 'tag' }; + if (defaultName && defaultSource) { + const className = + opts.resolveName?.({ + source: defaultSource, + tag, + segments, + ...(componentName ? { componentName } : {}), + ...(tokenName ? { tokenName } : {}), + ...(literalName ? { literalName } : {}), + ...(tokenPath ? { tokenPath } : {}), + defaultName, + }) ?? defaultName; + return { className, source: opts.resolveName ? 'resolved' : defaultSource }; } - 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 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\`.`, + `or (c) customizing \`resolve.name\`.`, { ...diagnosticLocationFromNode(opts.element), diagnosticCode: 'tailwind-class-name' } ); } diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index c0e4c7cc..bbcc147b 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -15,7 +15,7 @@ import { type RegisteredPropertiesOptions, } from './emit'; import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; -import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming'; +import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type ResolveName } from './naming'; /** Styling mode for Tailwind-backed className handling. */ export type TailwindMode = @@ -33,11 +33,38 @@ export type TailwindMode = */ | 'extract'; -/** Per-rule annotation hook; lets the consumer assign a `bag` for split-mode emission. */ -export type BagFor = (info: { className: string; segments: readonly StyleSegment[] }) => string | undefined; +/** Per-rule grouping hook. */ +export type ResolveGroup = (info: { className: string; segments: readonly StyleSegment[] }) => string | undefined; export type ResolveTokenModule = (specifier: string, fromFile: string) => string | null | undefined; +export interface TailwindResolveOptions { + /** Resolve bare token imports in skin sources to token modules on disk. Relative imports use the default resolver. */ + tokenModule?: ResolveTokenModule | undefined; + /** Resolve the final CSS class name from component/token/literal candidates. */ + name?: ResolveName | undefined; + /** Assign an extracted rule to a logical CSS group. */ + group?: ResolveGroup | undefined; +} + +export interface TailwindEmitOptions { + /** CSS emission layout. Defaults to merged output. */ + mode?: 'merged' | 'split'; + /** Base CSS files to prepend to emitted output. */ + baseCss?: readonly string[]; + /** Directory used to resolve relative base CSS paths. */ + configDir?: string; +} + +export interface TailwindVarsOptions { + /** Hoist uniform custom property declarations to a root selector, or disable explicitly. */ + hoist?: false | HoistOptions | undefined; + /** Inline matching custom properties into values that reference them. */ + inline?: true | RegExp | undefined; + /** Handle registered @property variables such as Tailwind's --tw-* vars. */ + properties?: RegisteredPropertiesOptions | undefined; +} + export interface TailwindOptions { /** Styling mode. Defaults to `'preserve'`. */ mode?: TailwindMode | undefined; @@ -47,54 +74,12 @@ 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. - */ - transformName?: NameTransform; - /** - * Per-tag / per-token-path class-name overrides. Only used by `'extract'`. - */ - overrides?: Record; - /** - * Optional helper that decides which split-mode `bag` a rule belongs to. - * Only used by `'extract'`. Returns `undefined` to leave the rule - * unbagged. - */ - bagFor?: BagFor; - /** Options forwarded to `emitCss` for `'extract'`. */ - emit?: { mode?: 'merged' | 'split'; baseCss?: readonly string[]; configDir?: string }; - /** - * Hoist uniform CSS variable declarations to a single root rule. See - * `HoistOptions`. Forwarded to the internal `emitCss` call in extract mode. - * - * Pass `false` to disable. Plugin consumers driving `emitCss` themselves - * should pass the same value through. - */ - hoistVars?: false | HoistOptions; - /** - * Inline matching CSS custom properties into their consumers, dropping - * the matching declarations. Same shape as `EmitCssOptions['inlineVars']`: - * - * - `true` — inline `--tw-*` (Tailwind's internal registered variables). - * - `RegExp` — inline any `--name` matching. - * - omitted — no inlining. - * - * Forwarded to the internal `emitCss` call in extract mode. - */ - inlineVars?: true | RegExp; - /** - * Handle Tailwind's `@property`-registered variables (`--tw-content`, etc.) that - * are referenced but never set, so the output isn't broken (e.g. - * `content: var(--tw-content)`). See `RegisteredPropertiesOptions` — choose - * `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in), - * with an optional `resolve` hook for the per-property config. - * - * Forwarded to the internal `emitCss` call in extract mode. - */ - properties?: RegisteredPropertiesOptions; + /** Resolution hooks for token modules, generated class names, and rule groups. */ + resolve?: TailwindResolveOptions | undefined; + /** CSS asset emission options for extract mode. */ + emit?: TailwindEmitOptions | undefined; + /** CSS custom property handling options for extract mode. */ + vars?: TailwindVarsOptions | undefined; } interface TailwindTransformOptions extends Omit { @@ -132,14 +117,15 @@ export function tailwind(options: TailwindOptions = {}): CompilerPlugin { }), async finish() { if (rules.length === 0) return; + const vars = options.vars; const emitted = await emitCss({ rules, ...(options.emit ?? {}), - ...(options.hoistVars !== undefined ? { hoist: options.hoistVars } : {}), - ...(options.inlineVars !== undefined ? { inlineVars: options.inlineVars } : {}), - ...(options.properties ? { properties: options.properties } : {}), + ...(vars?.hoist !== undefined ? { hoist: vars.hoist } : {}), + ...(vars?.inline !== undefined ? { inlineVars: vars.inline } : {}), + ...(vars?.properties ? { properties: vars.properties } : {}), resolveThemeVar: (name) => design.resolveThemeVar(name), - ...(options.hoistVars ? { themeSelector: options.hoistVars.rootSelector } : {}), + ...(vars?.hoist ? { themeSelector: vars.hoist.rootSelector } : {}), }); addCssAssets(context, options.output, emitted); }, @@ -172,7 +158,7 @@ export function tailwindPlugin(options: TailwindTransformOptions): ts.Transforme * ───────────────────────────────────────────────────────────────────────── */ function inlinedPlugin(options: TailwindTransformOptions): ts.TransformerFactory { - const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule); + const env = buildTokenEnv(options.sourcePath, options.resolve?.tokenModule); return (transformContext) => { return (sourceFile) => { @@ -195,9 +181,9 @@ function inlinedPlugin(options: TailwindTransformOptions): ts.TransformerFactory function vanillaCssPlugin( options: TailwindTransformOptions & { design: DesignSystem } ): ts.TransformerFactory { - const { design, transformName, overrides, bagFor, onRules } = options; + const { design, resolve, onRules } = options; - const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule); + const env = buildTokenEnv(options.sourcePath, resolve?.tokenModule); return (transformContext) => { return (sourceFile) => { @@ -216,8 +202,7 @@ function vanillaCssPlugin( const naming: DeriveClassNameOptions = { element: info.element, segments, - ...(transformName ? { transformName } : {}), - ...(overrides ? { overrides } : {}), + ...(resolve?.name ? { resolveName: resolve.name } : {}), ...(env.hasSource ? { tokenNamespaces: env.namespaces, tokenRoots: env.roots } : {}), }; const derived = deriveClassName(naming); @@ -243,7 +228,7 @@ function vanillaCssPlugin( const css = decompose(utility, design); if (css && css.declarations.length > 0) { ruleUtilities.push(utility); - rules.push(buildCompiledRule(derived.className, css, segments, bagFor)); + rules.push(buildCompiledRule(derived.className, css, segments, resolve?.group)); return; } if (!preserved.includes(utility)) preserved.push(utility); @@ -326,10 +311,10 @@ function addCssAssets(context: CompilerContext, output: string | undefined, emit const indexFile = output ?? defaultCssFileName(context); context.addAsset({ type: 'css', fileName: indexFile, source: emitted.index, sourceFile: context.filename }); const dir = dirname(indexFile); - for (const [bag, source] of emitted.bags) { + for (const [group, source] of emitted.groups) { context.addAsset({ type: 'css', - fileName: join(dir, `${bag || 'index'}.css`), + fileName: join(dir, `${group || 'index'}.css`), source, sourceFile: context.filename, }); @@ -366,7 +351,7 @@ interface TokenEnv { hasSource: boolean; } -function buildTokenEnv(sourcePath: string | undefined, resolveTokenModule?: ResolveTokenModule | undefined): TokenEnv { +function buildTokenEnv(sourcePath: string | undefined, tokenModuleResolver?: ResolveTokenModule | undefined): TokenEnv { const env: TokenEnv = { values: new Map(), namespaces: new Set(), @@ -385,7 +370,7 @@ function buildTokenEnv(sourcePath: string | undefined, resolveTokenModule?: Reso const specifier = stmt.moduleSpecifier; if (!ts.isStringLiteral(specifier)) continue; const id = specifier.text; - const absolutePath = resolveTokenImport(id, sourcePath, resolveTokenModule); + const absolutePath = resolveTokenImport(id, sourcePath, tokenModuleResolver); if (!absolutePath) continue; let exports: Record; @@ -489,10 +474,10 @@ const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const; function resolveTokenImport( specifier: string, fromFile: string, - resolveTokenModule?: ResolveTokenModule | undefined + tokenModuleResolver?: ResolveTokenModule | undefined ): string | null { if (specifier.startsWith('.')) return resolveModulePath(specifier, fromFile); - const resolved = resolveTokenModule?.(specifier, fromFile); + const resolved = tokenModuleResolver?.(specifier, fromFile); if (!resolved) return null; return isAbsolute(resolved) ? resolved : resolvePath(dirname(fromFile), resolved); } @@ -571,10 +556,10 @@ function buildCompiledRule( className: string, utility: UtilityCss, segments: readonly StyleSegment[], - bagFor: BagFor | undefined + resolveGroup: ResolveGroup | undefined ): CompiledRule { - const bag = bagFor?.({ className, segments }); - return bag === undefined ? { className, utility } : { className, utility, bag }; + const group = resolveGroup?.({ className, segments }); + return group === undefined ? { className, utility } : { className, utility, group }; } function collisionError(element: ts.Node, className: string, first: string, next: string): DiagnosticError { @@ -585,7 +570,7 @@ function collisionError(element: ts.Node, className: string, first: string, next ` <${tag}> resolves to: ${next}\n` + ` an earlier element resolved to: ${first}\n` + `Merging these would put conflicting declarations in a single '.${className}' rule. ` + - `Disambiguate with a distinct token, a distinct component, or an \`overrides\` entry.`, + `Disambiguate with a distinct token, a distinct component, or \`resolve.name\`.`, { ...diagnosticLocationFromNode(element), diagnosticCode: 'tailwind-class-collision' } ); } diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts index 6b6fdf65..34e72ece 100644 --- a/packages/compiler/src/tailwind/tests/emit.test.ts +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -11,10 +11,10 @@ function rule( className: string, declarations: { property: string; value: string }[], variants: any[] = [], - bag?: string + group?: string ): CompiledRule { const utility = { utility: 'mock', declarations, variants }; - return bag === undefined ? { className, utility } : { className, utility, bag }; + return group === undefined ? { className, utility } : { className, utility, group }; } describe('emitCss — merged mode', () => { @@ -97,7 +97,7 @@ describe('emitCss — merged mode', () => { }); describe('emitCss — split mode', () => { - it('groups rules by bag', async () => { + it('groups rules by group', async () => { const out = await emitCss({ mode: 'split', rules: [ @@ -107,12 +107,12 @@ describe('emitCss — split mode', () => { }); expect(out.kind).toBe('split'); if (out.kind !== 'split') return; - expect(out.bags.size).toBe(2); - expect(collapse(out.bags.get('one')!)).toContain(collapse('.a{color:red;}')); - expect(collapse(out.bags.get('two')!)).toContain(collapse('.b{color:blue;}')); + expect(out.groups.size).toBe(2); + expect(collapse(out.groups.get('one')!)).toContain(collapse('.a{color:red;}')); + expect(collapse(out.groups.get('two')!)).toContain(collapse('.b{color:blue;}')); }); - it('emits an index with @import lines for each bag in stable order', async () => { + it('emits an index with @import lines for each group in stable order', async () => { const out = await emitCss({ mode: 'split', rules: [ @@ -157,7 +157,7 @@ describe('emitCss — baseCss prepend', () => { expect(out.css).not.toContain('@import'); }); - it('puts baseCss in index only (split mode), not duplicated across bags', async () => { + it('puts baseCss in index only (split mode), not duplicated across groups', async () => { const dir = mkdtempSync(join(tmpdir(), 'emit-css-split-base-')); const basePath = join(dir, 'base.css'); writeFileSync(basePath, '.base { color: green; }', 'utf8'); @@ -171,8 +171,8 @@ describe('emitCss — baseCss prepend', () => { }); if (out.kind !== 'split') throw new Error('expected split'); expect(out.index).toContain('.base'); - expect(out.bags.get('one')!).not.toContain('.base'); - expect(out.bags.get('two')!).not.toContain('.base'); + expect(out.groups.get('one')!).not.toContain('.base'); + expect(out.groups.get('two')!).not.toContain('.base'); }); it('resolves relative baseCss paths against configDir', async () => { @@ -570,7 +570,12 @@ describe('emitCss — registered @property variables', () => { rules: [contentRule()], properties: { mode: 'inline', - resolve: (name, captured) => (name === '--tw-content' ? { ...captured, initialValue: '"!"' } : undefined), + variables: [ + { + match: /^--tw-/, + resolve: (name, captured) => (name === '--tw-content' ? { ...captured, initialValue: '"!"' } : undefined), + }, + ], }, }); if (out.kind !== 'merged') throw new Error('expected merged'); @@ -582,7 +587,12 @@ describe('emitCss — registered @property variables', () => { rules: [contentRule()], properties: { mode: 'emit', - resolve: () => ({ syntax: '""', inherits: true, initialValue: '0px' }), + variables: [ + { + match: /^--tw-/, + resolve: () => ({ syntax: '""', inherits: true, initialValue: '0px' }), + }, + ], }, }); if (out.kind !== 'merged') throw new Error('expected merged'); @@ -594,7 +604,7 @@ describe('emitCss — registered @property variables', () => { it('honours the match filter (leaves non-matching variables alone)', async () => { const out = await emitCss({ rules: [contentRule()], - properties: { mode: 'inline', match: /^--brand-/ }, + properties: { mode: 'inline', variables: [{ match: /^--brand-/ }] }, }); if (out.kind !== 'merged') throw new Error('expected merged'); expect(out.css).toMatch(/var\(--tw-content\)/); diff --git a/packages/compiler/src/tailwind/tests/naming.test.ts b/packages/compiler/src/tailwind/tests/naming.test.ts index 6f71b7dd..8be86bb9 100644 --- a/packages/compiler/src/tailwind/tests/naming.test.ts +++ b/packages/compiler/src/tailwind/tests/naming.test.ts @@ -39,7 +39,7 @@ const opaque = (): StyleSegment => ({ kind: 'opaque', node: null as never }); describe('deriveClassName — tag derivation', () => { it('kebab-cases a simple component tag', () => { const r = deriveClassName({ element: firstElement(``) }); - expect(r.source).toBe('tag'); + expect(r.source).toBe('component'); expect(r.className).toBe('foo-bar'); }); @@ -47,15 +47,6 @@ describe('deriveClassName — tag derivation', () => { const r = deriveClassName({ element: firstElement(``) }); expect(r.className).toBe('outer-inner'); }); - - it('honours overrides keyed by tag', () => { - const r = deriveClassName({ - element: firstElement(``), - overrides: { XYZWidget: 'xyz-widget' }, - }); - expect(r.source).toBe('override'); - expect(r.className).toBe('xyz-widget'); - }); }); describe('deriveClassName — token-path derivation', () => { @@ -64,7 +55,7 @@ describe('deriveClassName — token-path derivation', () => { element: firstElement(`
`), segments: [token(['styles', 'fooBar'])], }); - expect(r.source).toBe('token-path'); + expect(r.source).toBe('token'); expect(r.className).toBe('foo-bar'); }); @@ -126,22 +117,12 @@ describe('deriveClassName — token-path derivation', () => { expect(r.className).toBe('a'); }); - it('honours overrides keyed by dotted token path', () => { - const r = deriveClassName({ - element: firstElement(`
`), - segments: [token(['styles', 'foo', 'bar'])], - overrides: { 'styles.foo.bar': 'special' }, - }); - expect(r.source).toBe('override'); - expect(r.className).toBe('special'); - }); - 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.source).toBe('component'); expect(r.className).toBe('play-button'); }); @@ -152,7 +133,7 @@ describe('deriveClassName — token-path derivation', () => { tokenRoots: new Set(['menu']), tokenNamespaces: new Set(), }); - expect(r.source).toBe('token-path'); + expect(r.source).toBe('token'); expect(r.className).toBe('menu-chevron'); }); @@ -161,12 +142,12 @@ describe('deriveClassName — token-path derivation', () => { element: firstElement(``), segments: [token(['styles', 'menu', 'item'])], }); - expect(r.source).toBe('token-path'); + expect(r.source).toBe('token'); expect(r.className).toBe('menu-item'); }); }); -describe('deriveClassName — transformName', () => { +describe('deriveClassName — resolveName', () => { it('default is identity (returns defaultName as the class)', () => { const r = deriveClassName({ element: firstElement(``) }); expect(r.className).toBe('foo-bar'); @@ -175,7 +156,7 @@ describe('deriveClassName — transformName', () => { it('lets the consumer reshape the name (e.g. add a prefix)', () => { const r = deriveClassName({ element: firstElement(``), - transformName: (ctx) => `app-${ctx.defaultName}`, + resolveName: (ctx) => `app-${ctx.defaultName}`, }); expect(r.className).toBe('app-foo-bar'); }); @@ -183,8 +164,8 @@ describe('deriveClassName — transformName', () => { it('lets the consumer drop a tail segment by inspecting the original tag', () => { const r = deriveClassName({ element: firstElement(``), - transformName: (ctx) => { - if (ctx.source === 'tag' && ctx.tag.endsWith('.Root')) { + resolveName: (ctx) => { + if (ctx.source === 'component' && ctx.tag.endsWith('.Root')) { return ctx.defaultName.replace(/-root$/, ''); } return ctx.defaultName; @@ -197,8 +178,8 @@ describe('deriveClassName — transformName', () => { const r = deriveClassName({ element: firstElement(`
`), segments: [token(['styles', 'foo', 'root'])], - transformName: (ctx) => { - if (ctx.source === 'token-path' && ctx.tokenPath.at(-1) === 'root') { + resolveName: (ctx) => { + if (ctx.source === 'token' && ctx.tokenPath?.at(-1) === 'root') { return ctx.defaultName.replace(/-root$/, ''); } return ctx.defaultName; @@ -207,39 +188,51 @@ describe('deriveClassName — transformName', () => { expect(r.className).toBe('foo'); }); - it('overrides win over transformName', () => { + it('can choose a token name for a regular component', () => { const r = deriveClassName({ - element: firstElement(``), - overrides: { FooBar: 'override-wins' }, - transformName: () => 'transform-wins', + element: firstElement(``), + segments: [token(['styles', 'button', 'icon'])], + resolveName: (ctx) => ctx.tokenName ?? ctx.defaultName, }); - expect(r.className).toBe('override-wins'); - expect(r.source).toBe('override'); + expect(r.className).toBe('button-icon'); + expect(r.source).toBe('resolved'); }); - it('transformName receives source = "tag" for tag derivation', () => { + it('can choose a component name when a known token root would be the default', () => { + const r = deriveClassName({ + element: firstElement(``), + segments: [token(['menu', 'chevron'])], + tokenRoots: new Set(['menu']), + tokenNamespaces: new Set(), + resolveName: (ctx) => ctx.componentName ?? ctx.defaultName, + }); + expect(r.className).toBe('chevron-icon'); + expect(r.source).toBe('resolved'); + }); + + it('resolveName receives source = "component" for component defaults', () => { let receivedSource: string | undefined; deriveClassName({ element: firstElement(``), - transformName: (ctx) => { + resolveName: (ctx) => { receivedSource = ctx.source; return ctx.defaultName; }, }); - expect(receivedSource).toBe('tag'); + expect(receivedSource).toBe('component'); }); - it('transformName receives source = "token-path" for token derivation', () => { + it('resolveName receives source = "token" for token defaults', () => { let receivedSource: string | undefined; deriveClassName({ element: firstElement(`
`), segments: [token(['styles', 'foo'])], - transformName: (ctx) => { + resolveName: (ctx) => { receivedSource = ctx.source; return ctx.defaultName; }, }); - expect(receivedSource).toBe('token-path'); + expect(receivedSource).toBe('token'); }); }); diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index f99eacdc..fee1d152 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -344,7 +344,7 @@ function App({ type, className }){ expect(code).toContain('"grow"'); }); - it('applies component class overrides', async () => { + it('applies resolved generated class names', async () => { const source = `function App(){ return ; }`; const { code } = await compile(source, { target: 'jsx', @@ -352,28 +352,59 @@ function App({ type, className }){ tailwindPlugin({ design, mode: 'extract', - overrides: { PlayButton: 'custom' }, - }), - ], - }); - expect(code).toContain('"custom"'); - }); - - it('applies transformed generated class names', async () => { - const source = `function App(){ return ; }`; - const { code } = await compile(source, { - target: 'jsx', - plugins: [ - tailwindPlugin({ - design, - mode: 'extract', - transformName: (ctx) => `app-${ctx.defaultName}`, + resolve: { + name: (ctx) => `app-${ctx.defaultName}`, + }, }), ], }); expect(code).toContain('"app-play-button"'); }); + it('lets resolve.name choose token names for component elements', async () => { + const source = `function App(){ return ; }`; + const { code } = await compile(source, { + target: 'jsx', + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + resolve: { + name: (ctx) => ctx.tokenName ?? ctx.defaultName, + }, + }), + ], + }); + expect(code).toContain('"button-icon"'); + }); + + it('lets resolve.name choose component names over known token roots', async () => { + writeFixture( + 'tokens.ts', + `export const menu = { chevron: 'size-3' }; +` + ); + const source = `import { menu } from './tokens'; +function App(){ return ; }`; + const sourcePath = writeFixture('skin.tsx', source); + + const { code } = await compile(source, { + target: 'jsx', + filename: sourcePath, + plugins: [ + tailwindPlugin({ + design, + mode: 'extract', + sourcePath, + resolve: { + name: (ctx) => ctx.componentName ?? ctx.defaultName, + }, + }), + ], + }); + expect(code).toContain('"chevron-icon"'); + }); + it('reports extracted rules through onRules', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; @@ -463,7 +494,9 @@ function App(){ return ; }`; design, mode: 'extract', sourcePath, - resolveTokenModule: (specifier) => (specifier === '@fixture/tokens' ? tokenPath : null), + resolve: { + tokenModule: (specifier) => (specifier === '@fixture/tokens' ? tokenPath : null), + }, onRules: (rules) => { captured = rules; }, @@ -475,7 +508,7 @@ function App(){ return ; }`; expect(captured!.length).toBe(2); }); - it('assigns rule bags with bagFor', async () => { + it('assigns rule groups with resolve.group', async () => { const source = `function App(){ return ; }`; let captured: readonly CompiledRule[] | undefined; await compile(source, { @@ -484,14 +517,16 @@ function App(){ return ; }`; tailwindPlugin({ design, mode: 'extract', - bagFor: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined), + resolve: { + group: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined), + }, onRules: (rules) => { captured = rules; }, }), ], }); - expect(captured![0]!.bag).toBe('controls'); + expect(captured![0]!.group).toBe('controls'); }); it('skips dynamic conditional class expressions', async () => { @@ -593,7 +628,7 @@ function App(){ return ; }`; const { assets } = await compileTailwind(source, { mode: 'extract', design, - hoistVars: { rootSelector: '[data-skin="x"]' }, + vars: { hoist: { rootSelector: '[data-skin="x"]' } }, }); const css = assets[0]!.source; expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/); @@ -602,9 +637,25 @@ function App(){ return ; }`; it('forwards the `properties` option (inline) so --tw-content resolves', async () => { // `after:absolute` emits `content: var(--tw-content)` with no setter. const source = `function App(){ return ; }`; - const { assets } = await compileTailwind(source, { mode: 'extract', design, properties: { mode: 'inline' } }); + const { assets } = await compileTailwind(source, { + mode: 'extract', + design, + vars: { properties: { mode: 'inline' } }, + }); const css = assets[0]!.source; expect(css).not.toMatch(/var\(--tw-content\)/); expect(collapse(css)).toContain(collapse('content: "";')); }); + + it('forwards the `vars.inline` option', async () => { + const source = `function App(){ return ; }`; + const { assets } = await compileTailwind(source, { + mode: 'extract', + design, + vars: { inline: true }, + }); + const css = assets[0]!.source; + expect(css).not.toMatch(/--tw-shadow:/); + expect(css).not.toMatch(/var\(--tw-shadow[),]/); + }); });