mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(compiler)!: reshape tailwind styling options
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[),]/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user