refactor(compiler)!: branch tailwind utility analysis

This commit is contained in:
Rahim
2026-06-23 17:24:05 -07:00
parent d5d2ddffb6
commit 9e7d3da397
7 changed files with 286 additions and 123 deletions
@@ -6,7 +6,7 @@ import { __unstable__loadDesignSystem } from 'tailwindcss';
/**
* A loaded Tailwind v4 design system. Wraps Tailwind's
* `__unstable__loadDesignSystem` return value with a small surface focused on
* what `decompose` needs:
* what `analyzeUtility` needs:
*
* - `compileUtility(name)` — compile a single utility class to CSS, or
* `null` if Tailwind doesn't recognize it.
+11 -7
View File
@@ -1,6 +1,6 @@
import { isAbsolute, resolve } from 'node:path';
import { bundleAsync } from 'lightningcss';
import type { Declaration, UtilityCss } from './decompose';
import type { Declaration, UtilityCss, UtilityCssBranch } from './utility-css';
/** A compiled rule: a class name + the utility's declarations and variants. */
export interface CompiledRule {
@@ -421,10 +421,10 @@ function composeRules(
inlineVars: VariableMatcher | undefined,
fallbackSetters?: Map<string, string>
): string {
// Step 1: turn each CompiledRule into one EmitUnit.
// Step 1: turn each CompiledRule branch into one EmitUnit.
const units: EmitUnit[] = [];
for (const rule of rules) {
units.push(buildEmitUnit(rule));
units.push(...buildEmitUnits(rule));
}
// Step 2: merge units by (atRulePath, selector). Dedupe declarations by
@@ -815,11 +815,15 @@ function findTopLevelComma(s: string): number {
return -1;
}
function buildEmitUnit(rule: CompiledRule): EmitUnit {
function buildEmitUnits(rule: CompiledRule): EmitUnit[] {
return rule.utility.branches.map((branch) => buildEmitUnit(rule.className, branch));
}
function buildEmitUnit(className: string, branch: UtilityCssBranch): EmitUnit {
const atRulePath: string[] = [];
let selectorTail = '';
for (const v of rule.utility.variants) {
for (const v of branch.variants) {
if (v.atRule) {
atRulePath.push(`@${v.atRule.name} ${v.atRule.params}`.trim());
} else if (v.selector) {
@@ -829,8 +833,8 @@ function buildEmitUnit(rule: CompiledRule): EmitUnit {
return {
atRulePath,
selector: `.${rule.className}${selectorTail}`,
declarations: rule.utility.declarations,
selector: `.${className}${selectorTail}`,
declarations: branch.declarations,
};
}
+9 -8
View File
@@ -1,11 +1,3 @@
export {
type Declaration,
decompose,
type PropertyRule,
type UtilityCss,
type Variant,
type VariantKind,
} from './decompose';
export { type DesignSystem, loadDesignSystem } from './design-system';
export {
type CompiledRule,
@@ -36,3 +28,12 @@ export {
type TailwindVarsOptions,
tailwind,
} from './plugin';
export {
analyzeUtility,
type Declaration,
type PropertyRule,
type UtilityCss,
type UtilityCssBranch,
type Variant,
type VariantKind,
} from './utility-css';
+3 -3
View File
@@ -5,7 +5,6 @@ import type { CompilerContext, CompilerPlugin } from '../config';
import { diagnosticLocationFromNode } from '../diagnostics';
import { tagName } from '../jsx';
import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles';
import { decompose, type UtilityCss } from './decompose';
import { type DesignSystem, loadDesignSystem } from './design-system';
import {
type CompiledRule,
@@ -16,6 +15,7 @@ import {
} from './emit';
import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type ResolveName } from './naming';
import { analyzeUtility, type UtilityCss } from './utility-css';
/** Styling mode for Tailwind-backed className handling. */
export type TailwindMode =
@@ -137,7 +137,7 @@ export function tailwind(options: TailwindOptions = {}): CompilerPlugin {
/**
* TS transformer that rewrites JSX `className` attributes per the chosen
* Tailwind target. Built on top of `analyzeStyles` (generic JSX walker) +
* `decompose` + `deriveClassName` + `emitCss`. Token references are resolved
* `analyzeUtility` + `deriveClassName` + `emitCss`. Token references are resolved
* by statically evaluating the imported token module — see `evaluator.ts`.
*/
export function tailwindPlugin(options: TailwindTransformOptions): ts.TransformerFactory<ts.SourceFile> {
@@ -225,7 +225,7 @@ function vanillaCssPlugin(
// Tailwind doesn't recognize — dropping them would silently break every
// descendant `group-*` / `peer-*` variant that targets the marker.
const handleUtility = (utility: string): void => {
const css = decompose(utility, design);
const css = analyzeUtility(utility, design);
if (css && css.declarations.length > 0) {
ruleUtilities.push(utility);
rules.push(buildCompiledRule(derived.className, css, segments, resolve?.group));
@@ -13,10 +13,25 @@ function rule(
variants: any[] = [],
group?: string
): CompiledRule {
const utility = { utility: 'mock', declarations, variants };
const utility = { utility: 'mock', branches: [{ declarations, variants }], declarations, variants };
return group === undefined ? { className, utility } : { className, utility, group };
}
function branchedRule(
className: string,
branches: { declarations: { property: string; value: string }[]; variants: any[] }[]
): CompiledRule {
return {
className,
utility: {
utility: 'mock',
branches,
declarations: branches.flatMap((branch) => branch.declarations),
variants: branches[0]?.variants ?? [],
},
};
}
describe('emitCss — merged mode', () => {
it('emits a single rule for one CompiledRule', async () => {
const out = await emitCss({
@@ -82,6 +97,26 @@ describe('emitCss — merged mode', () => {
);
});
it('emits each utility branch with its own variant path', async () => {
const media = {
kind: 'media' as const,
atRule: { name: 'media', params: '(width >= 40rem)' },
raw: '@media (width >= 40rem)',
};
const out = await emitCss({
rules: [
branchedRule('container', [
{ declarations: [{ property: 'width', value: '100%' }], variants: [] },
{ declarations: [{ property: 'max-width', value: '40rem' }], variants: [media] },
]),
],
});
expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.container{width:100%;}'));
expect(out.kind === 'merged' && collapse(out.css)).toContain(
collapse('@media (width >= 40rem){.container{max-width:40rem;}}')
);
});
it('sorts declarations alphabetically for stable output', async () => {
const out = await emitCss({
rules: [
@@ -560,6 +595,15 @@ describe('emitCss — registered @property variables', () => {
{ property: 'content', value: 'var(--tw-content)' },
{ property: 'position', value: 'absolute' },
],
branches: [
{
declarations: [
{ property: 'content', value: 'var(--tw-content)' },
{ property: 'position', value: 'absolute' },
],
variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }],
},
],
variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }],
properties: [{ name: '--tw-content', syntax: '"*"', inherits: false, initialValue: '""' }],
},
@@ -2,9 +2,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { decompose } from '../decompose';
import type { DesignSystem } from '../design-system';
import { loadDesignSystem } from '../design-system';
import { analyzeUtility } from '../utility-css';
let design: DesignSystem;
@@ -24,17 +24,18 @@ beforeAll(async () => {
design = await loadDesignSystem(cssPath);
}, 30_000);
describe('decompose — base utilities', () => {
describe('analyzeUtility — base utilities', () => {
it('handles a plain utility', () => {
const r = decompose('flex', design);
const r = analyzeUtility('flex', design);
expect(r).not.toBeNull();
expect(r!.utility).toBe('flex');
expect(r!.variants).toEqual([]);
expect(r!.declarations).toEqual([{ property: 'display', value: 'flex' }]);
expect(r!.branches).toEqual([{ declarations: [{ property: 'display', value: 'flex' }], variants: [] }]);
});
it('handles a utility with multiple declarations', () => {
const r = decompose('p-4', design);
const r = analyzeUtility('p-4', design);
expect(r).not.toBeNull();
// Tailwind v4 emits `padding: calc(var(--spacing) * 4);`.
const props = r!.declarations.map((d) => d.property);
@@ -42,13 +43,13 @@ describe('decompose — base utilities', () => {
});
it('returns null for unknown utilities', () => {
expect(decompose('not-a-real-utility', design)).toBeNull();
expect(analyzeUtility('not-a-real-utility', design)).toBeNull();
});
});
describe('decompose — variants', () => {
describe('analyzeUtility — variants', () => {
it('captures :hover as a pseudo variant', () => {
const r = decompose('hover:opacity-100', design);
const r = analyzeUtility('hover:opacity-100', design);
expect(r).not.toBeNull();
// Tailwind v4 nests `&:hover` *inside* `@media (hover: hover)` so we
// see both — pseudo for the selector tail, media for the hover gate.
@@ -62,14 +63,14 @@ describe('decompose — variants', () => {
});
it('captures :focus-visible as a pseudo variant', () => {
const r = decompose('focus-visible:outline-current', design);
const r = analyzeUtility('focus-visible:outline-current', design);
expect(r).not.toBeNull();
expect(r!.variants[0]!.kind).toBe('pseudo');
expect(r!.variants[0]!.selector).toMatch(/:focus-visible/);
});
it('captures @media-style at-rule wrappers', () => {
const r = decompose('motion-reduce:opacity-50', design);
const r = analyzeUtility('motion-reduce:opacity-50', design);
expect(r).not.toBeNull();
const media = r!.variants.find((v) => v.kind === 'media');
expect(media).toBeDefined();
@@ -77,7 +78,7 @@ describe('decompose — variants', () => {
});
it('captures attribute-selector variants from data-[…]', () => {
const r = decompose('data-[state=open]:opacity-100', design);
const r = analyzeUtility('data-[state=open]:opacity-100', design);
expect(r).not.toBeNull();
const attr = r!.variants.find((v) => v.kind === 'attribute');
expect(attr).toBeDefined();
@@ -85,7 +86,7 @@ describe('decompose — variants', () => {
});
it('captures group-data variants', () => {
const r = decompose('group-data-paused:opacity-100', design);
const r = analyzeUtility('group-data-paused:opacity-100', design);
expect(r).not.toBeNull();
const grp = r!.variants.find((v) => v.kind === 'group');
expect(grp).toBeDefined();
@@ -93,9 +94,26 @@ describe('decompose — variants', () => {
});
});
describe('decompose — @property registrations', () => {
describe('analyzeUtility — branches', () => {
it('preserves sibling declaration branches from one utility', () => {
const r = analyzeUtility('container', design);
expect(r).not.toBeNull();
expect(r!.branches.length).toBeGreaterThan(1);
expect(r!.branches).toContainEqual({ declarations: [{ property: 'width', value: '100%' }], variants: [] });
expect(r!.branches.some((branch) => branch.variants.some((variant) => variant.kind === 'media'))).toBe(true);
});
it.each(['{', '}'])('handles arbitrary content containing %s', (brace) => {
const r = analyzeUtility(`before:content-["${brace}"]`, design);
expect(r).not.toBeNull();
expect(r!.declarations.find((declaration) => declaration.property === '--tw-content')?.value).toContain(brace);
expect(r!.declarations).toContainEqual({ property: 'content', value: 'var(--tw-content)' });
});
});
describe('analyzeUtility — @property registrations', () => {
it('captures the @property rule Tailwind appends for a registered variable', () => {
const r = decompose('before:content-["x"]', design);
const r = analyzeUtility('before:content-["x"]', design);
expect(r).not.toBeNull();
const content = r!.properties?.find((p) => p.name === '--tw-content');
expect(content).toEqual({
@@ -107,12 +125,12 @@ describe('decompose — @property registrations', () => {
});
it('omits `properties` when a utility registers none', () => {
const r = decompose('flex', design);
const r = analyzeUtility('flex', design);
expect(r!.properties).toBeUndefined();
});
});
describe('decompose — caching', () => {
describe('analyzeUtility — caching', () => {
it('returns the same compiled CSS on repeat lookups (DesignSystem cache)', () => {
const a = design.compileUtility('flex');
const b = design.compileUtility('flex');
@@ -6,11 +6,12 @@ export interface Declaration {
value: string;
}
/** Variant kinds we recognize when decomposing a utility. */
/** Variant kinds we recognize when analyzing a utility. */
export type VariantKind =
| 'media' // @media (...) wrapper
| 'container' // @container (...) wrapper
| 'supports' // @supports (...) wrapper
| 'at-rule' // any other at-rule wrapper
| 'pseudo' // selector tail like `:hover`, `::before`, `:focus-visible`
| 'attribute' // `[data-x]`, `[data-x=y]`
| 'group' // `:is(:where(.group)... *)` (Tailwind v4 group-* variant)
@@ -28,6 +29,13 @@ export interface Variant {
raw: string;
}
export interface UtilityCssBranch {
/** Declarations emitted together under this branch's variants. */
declarations: readonly Declaration[];
/** Variant path for these declarations. */
variants: readonly Variant[];
}
/**
* A `@property` registration Tailwind appends alongside a utility (e.g.
* `@property --tw-content { syntax: "*"; inherits: false; initial-value: "" }`).
@@ -42,7 +50,11 @@ export interface PropertyRule {
export interface UtilityCss {
utility: string;
/** Branches emitted by this utility, preserving sibling rule/at-rule structure. */
branches: readonly UtilityCssBranch[];
/** Flattened declarations across every branch. */
declarations: readonly Declaration[];
/** Variants for the first branch, retained for simple utility inspection. */
variants: readonly Variant[];
/**
* `@property` registrations Tailwind emitted for this utility. These supply
@@ -53,40 +65,40 @@ export interface UtilityCss {
}
/**
* Decompose a Tailwind v4 utility into its declarations + variant chain.
* Analyze a Tailwind v4 utility into declarations, variant branches, and
* registered properties.
*
* Tailwind v4 emits CSS in nested form: the outer rule is the utility class
* selector, and any variants are nested inside using `&:hover`, `@media (...)`,
* `&[data-x]`, etc. Multiple variants on a single utility produce multiply
* nested blocks. We walk the nesting tree, collecting one `Variant` per
* nesting level (outermost innermost), and read the innermost declarations.
* selector, and variants appear as nested selector rules or at-rule blocks.
* One utility can produce sibling branches (`container` emits root declarations
* plus multiple media branches), so the branch model preserves each declaration
* group with its own variant path.
*
* Returns `null` for utilities Tailwind doesn't recognize.
*/
export function decompose(utility: string, design: DesignSystem): UtilityCss | null {
export function analyzeUtility(utility: string, design: DesignSystem): UtilityCss | null {
const css = design.compileUtility(utility);
if (!css) return null;
const trimmed = css.trim();
const outerOpen = trimmed.indexOf('{');
const outerOpen = findBlockOpen(trimmed, 0);
if (outerOpen === -1) return null;
const outerBlock = readBalancedBlock(trimmed, outerOpen);
if (!outerBlock) return null;
// The outer selector is the escaped utility class. We walk the body to
// collect variants from each nesting level + the innermost declarations.
const body = outerBlock.inner;
const variants: Variant[] = [];
const declarations: Declaration[] = [];
walkNested(body, variants, declarations);
const branches: UtilityCssBranch[] = [];
walkNested(outerBlock.inner, [], branches);
// Tailwind appends `@property --tw-* { ... }` registrations after the utility
// rule. They live at the top level of the compiled output (siblings of the
// utility class), so we scan the whole string rather than the rule body.
const properties = parseProperties(trimmed);
const declarations = branches.flatMap((branch) => branch.declarations);
const variants = branches[0]?.variants ?? [];
return properties.length > 0 ? { utility, declarations, variants, properties } : { utility, declarations, variants };
return properties.length > 0
? { utility, branches, declarations, variants, properties }
: { utility, branches, declarations, variants };
}
/** Parse every `@property --name { ... }` block from a compiled utility. */
@@ -118,46 +130,42 @@ function parseProperties(css: string): PropertyRule[] {
return out;
}
/**
* Walk a CSS body collecting declarations directly at this level and
* recursing into nested at-rules / `&`-prefixed selector rules. Each
* recursion level pushes one `Variant` describing the nesting it represents.
*
* - Pure declarations (`prop: value;`) at the current level go into `declarations`.
* - Nested `&<selector> { ... }` blocks add a selector variant and recurse.
* - Nested `@media (...) { ... }`, `@container (...)`, `@supports (...)` add
* the corresponding at-rule variant and recurse.
*/
function walkNested(body: string, variants: Variant[], declarations: Declaration[]): void {
function walkNested(body: string, variants: readonly Variant[], branches: UtilityCssBranch[]): void {
let i = 0;
const n = body.length;
let declarations: Declaration[] = [];
const flushDeclarations = (): void => {
if (declarations.length === 0) return;
branches.push({ declarations, variants });
declarations = [];
};
while (i < n) {
while (i < n && /[\s;]/.test(body[i]!)) i++;
if (i >= n) break;
if (body[i] === '@') {
const headerEnd = body.indexOf('{', i);
flushDeclarations();
const headerEnd = findBlockOpen(body, i);
if (headerEnd === -1) break;
const header = body.slice(i, headerEnd).trim();
const m = header.match(/^@([\w-]+)\s*([\s\S]*)$/);
if (!m) break;
const [, name, params] = m;
const match = header.match(/^@([\w-]+)\s*([\s\S]*)$/);
if (!match) break;
const [, name, params] = match;
const block = readBalancedBlock(body, headerEnd);
if (!block) break;
variants.push({
kind: name === 'media' ? 'media' : name === 'container' ? 'container' : 'supports',
atRule: { name: name!, params: params!.trim() },
raw: `@${name} ${params}`,
});
walkNested(block.inner.trim(), variants, declarations);
walkNested(block.inner.trim(), [...variants, atRuleVariant(name!, params!.trim())], branches);
i = block.end + 1;
continue;
}
if (body[i] === '&') {
const headerEnd = body.indexOf('{', i);
flushDeclarations();
const headerEnd = findBlockOpen(body, i);
if (headerEnd === -1) break;
// Preserve the leading character after `&` so `& *` (descendant) doesn't
// get folded down to bare `*` (which classifies as 'parent').
@@ -168,64 +176,98 @@ function walkNested(body: string, variants: Variant[], declarations: Declaration
const block = readBalancedBlock(body, headerEnd);
if (!block) break;
variants.push(classifySelectorTail(selectorTail));
walkNested(block.inner.trim(), variants, declarations);
walkNested(block.inner.trim(), [...variants, classifySelectorTail(selectorTail)], branches);
i = block.end + 1;
continue;
}
// Plain declaration `prop: value;` — read with bracket-balance awareness
// so `var()`, `calc()`, `oklch(from var(--x) ...)` survive intact.
const propStart = i;
let colonIdx = -1;
while (i < n && body[i] !== ';' && body[i] !== '{') {
if (body[i] === ':' && colonIdx === -1) colonIdx = i;
i++;
const declaration = readDeclaration(body, i);
if (!declaration) break;
if (declaration.declaration) declarations.push(declaration.declaration);
i = declaration.end;
}
flushDeclarations();
}
function atRuleVariant(name: string, params: string): Variant {
return {
kind:
name === 'media' ? 'media' : name === 'container' ? 'container' : name === 'supports' ? 'supports' : 'at-rule',
atRule: { name, params },
raw: params ? `@${name} ${params}` : `@${name}`,
};
}
interface ReadDeclarationResult {
declaration?: Declaration | undefined;
end: number;
}
function readDeclaration(body: string, start: number): ReadDeclarationResult | null {
const n = body.length;
let i = start;
let colonIdx = -1;
while (i < n) {
const c = body[i]!;
if (c === ':' && colonIdx === -1) {
colonIdx = i;
break;
}
if (body[i] === '{') break;
if (colonIdx === -1) {
if (body[i] === ';') i++;
if (c === ';') return { end: i + 1 };
if (c === '{') return null;
i++;
}
if (colonIdx === -1) return { end: i };
const property = body.slice(start, colonIdx).trim();
let valueEnd = colonIdx + 1;
let depth = 0;
let quote: string | null = null;
while (valueEnd < n) {
const c = body[valueEnd]!;
if (quote) {
if (c === '\\') {
valueEnd += 2;
continue;
}
if (c === quote) quote = null;
valueEnd++;
continue;
}
const property = body.slice(propStart, colonIdx).trim();
let valueEnd = colonIdx + 1;
let depth = 0;
let quote: string | null = null;
while (valueEnd < n) {
const c = body[valueEnd]!;
if (quote) {
if (c === '\\') {
valueEnd += 2;
continue;
}
if (c === quote) quote = null;
valueEnd++;
continue;
}
if (c === '"' || c === "'") {
quote = c;
valueEnd++;
continue;
}
if (c === '(' || c === '{' || c === '[') {
depth++;
valueEnd++;
continue;
}
if (c === ')' || c === '}' || c === ']') {
depth--;
valueEnd++;
continue;
}
if (c === ';' && depth === 0) break;
valueEnd++;
if (c === '/' && body[valueEnd + 1] === '*') {
const end = body.indexOf('*/', valueEnd + 2);
if (end === -1) return null;
valueEnd = end + 2;
continue;
}
const value = body.slice(colonIdx + 1, valueEnd).trim();
if (property && value) declarations.push({ property, value });
i = valueEnd;
if (body[i] === ';') i++;
if (c === '"' || c === "'") {
quote = c;
valueEnd++;
continue;
}
if (c === '\\') {
valueEnd += 2;
continue;
}
if (c === '(' || c === '{' || c === '[') {
depth++;
valueEnd++;
continue;
}
if (c === ')' || c === '}' || c === ']') {
depth--;
valueEnd++;
continue;
}
if (c === ';' && depth === 0) break;
valueEnd++;
}
const value = body.slice(colonIdx + 1, valueEnd).trim();
const end = body[valueEnd] === ';' ? valueEnd + 1 : valueEnd;
return property && value ? { declaration: { property, value }, end } : { end };
}
interface BalancedBlock {
@@ -233,10 +275,64 @@ interface BalancedBlock {
end: number;
}
function findBlockOpen(body: string, start: number): number {
let quote: string | null = null;
for (let i = start; i < body.length; i++) {
const c = body[i]!;
if (quote) {
if (c === '\\') {
i++;
continue;
}
if (c === quote) quote = null;
continue;
}
if (c === '\\') {
i++;
continue;
}
if (c === '/' && body[i + 1] === '*') {
const end = body.indexOf('*/', i + 2);
if (end === -1) return -1;
i = end + 1;
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === '{') return i;
}
return -1;
}
function readBalancedBlock(body: string, openIdx: number): BalancedBlock | null {
let depth = 0;
let quote: string | null = null;
for (let i = openIdx; i < body.length; i++) {
const c = body[i];
const c = body[i]!;
if (quote) {
if (c === '\\') {
i++;
continue;
}
if (c === quote) quote = null;
continue;
}
if (c === '\\') {
i++;
continue;
}
if (c === '/' && body[i + 1] === '*') {
const end = body.indexOf('*/', i + 2);
if (end === -1) return null;
i = end + 1;
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === '{') depth++;
else if (c === '}') {
depth--;