refactor(compiler)!: reshape tailwind styling options

This commit is contained in:
Rahim
2026-06-23 15:08:19 -07:00
parent 89f13eb6d7
commit 3010d38d08
7 changed files with 368 additions and 275 deletions
+99 -47
View File
@@ -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 `<bag>.css` file. Ignored in merged mode.
* Optional logical grouping key. `mode: 'split'` writes rules with the same
* group to the same `<group>.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<string, string> };
export type EmittedCss =
| { kind: 'merged'; css: string }
| { kind: 'split'; index: string; groups: Map<string, string> };
/**
* 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<EmittedCss> {
// 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<EmittedCss> {
return { kind: 'merged', css: joinSections(base, properties, theme, body) };
}
// Split mode: group rules by `bag`.
const byBag = new Map<string, CompiledRule[]>();
// Split mode: group rules by resolved group.
const byGroup = new Map<string, CompiledRule[]>();
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<string, string>();
const groups = new Map<string, string>();
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<string, Proper
/** Build the `name → initial-value` fallback map for `mode: 'inline'`. */
function buildFallbackSetters(
captured: Map<string, PropertyDef>,
match: RegExp,
match: VariableMatcher,
resolveDef: (name: string) => PropertyDef | undefined
): Map<string, string> {
const out = new Map<string, string>();
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, string>
): 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<string, EmitUnit & { declarations: Declaration[]; declSet: Set<string> }>,
match: RegExp,
match: VariableMatcher,
hoistRootSelector: string | undefined,
fallbackSetters?: Map<string, string>
): 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<string, string>();
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<string>();
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<string, string>, match: RegExp): void {
function resolveSettersInPlace(setters: Map<string, string>, 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<string, string>, 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<string, string>, match: RegExp): string {
function inlineValue(value: string, setters: Map<string, string>, match: VariableMatcher): string {
let out = '';
let i = 0;
while (i < value.length) {
@@ -684,14 +736,14 @@ function inlineValue(value: string, setters: Map<string, string>, match: RegExp)
* the substituted string. If the property doesn't match or isn't set, returns
* the original `var(<inner>)` text.
*/
function resolveVarRef(inner: string, setters: Map<string, string>, match: RegExp): string {
function resolveVarRef(inner: string, setters: Map<string, string>, 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);
+12 -2
View File
@@ -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';
+69 -77
View File
@@ -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<string, string>;
/** 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' }
);
}
+56 -71
View File
@@ -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<string, string>;
/**
* 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<TailwindOptions, 'input' | 'output'> {
@@ -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<ts.SourceFile> {
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<ts.SourceFile> {
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<string, TokenValue>(),
namespaces: new Set<string>(),
@@ -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<string, TokenValue>;
@@ -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' }
);
}
@@ -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: '"<length>"', inherits: true, initialValue: '0px' }),
variables: [
{
match: /^--tw-/,
resolve: () => ({ syntax: '"<length>"', 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\)/);
@@ -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(`<FooBar/>`) });
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(`<Outer.Inner/>`) });
expect(r.className).toBe('outer-inner');
});
it('honours overrides keyed by tag', () => {
const r = deriveClassName({
element: firstElement(`<XYZWidget/>`),
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(`<div className={styles.fooBar}/>`),
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(`<div className={styles.foo.bar}/>`),
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(`<PlayButton className={styles.button.icon}/>`),
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(`<Menu.Trigger className={styles.menu.item}/>`),
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(`<FooBar/>`) });
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(`<FooBar/>`),
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(`<Foo.Root/>`),
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(`<div className={styles.foo.root}/>`),
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(`<FooBar/>`),
overrides: { FooBar: 'override-wins' },
transformName: () => 'transform-wins',
element: firstElement(`<PlayButton className={styles.button.icon}/>`),
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(`<ChevronIcon className={menu.chevron}/>`),
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(`<FooBar/>`),
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(`<div className={styles.foo}/>`),
segments: [token(['styles', 'foo'])],
transformName: (ctx) => {
resolveName: (ctx) => {
receivedSource = ctx.source;
return ctx.defaultName;
},
});
expect(receivedSource).toBe('token-path');
expect(receivedSource).toBe('token');
});
});
@@ -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 <PlayButton className="flex"/>; }`;
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 <PlayButton className="flex"/>; }`;
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 <PlayButton className={styles.button.icon}/>; }`;
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 <ChevronIcon className={menu.chevron}/>; }`;
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 <Foo className="flex"/>; }`;
let captured: readonly CompiledRule[] | undefined;
@@ -463,7 +494,9 @@ function App(){ return <Foo className={styles.button}/>; }`;
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 <Foo className={styles.button}/>; }`;
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 <PlayButton className="flex"/>; }`;
let captured: readonly CompiledRule[] | undefined;
await compile(source, {
@@ -484,14 +517,16 @@ function App(){ return <Foo className={styles.button}/>; }`;
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 <PlayButton className={iconButton}/>; }`;
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 <PlayButton className={iconButton}/>; }`;
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 <Foo className="after:absolute"/>; }`;
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 <Foo className="shadow-sm"/>; }`;
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[),]/);
});
});