fix(compiler): harden tailwind emit edge cases

This commit is contained in:
Rahim
2026-06-23 15:35:19 -07:00
parent 3010d38d08
commit d5d2ddffb6
6 changed files with 116 additions and 9 deletions
+49 -7
View File
@@ -19,7 +19,12 @@ export interface CompiledRule {
/** Output of `emitCss`. Discriminated by `kind`. */
export type EmittedCss =
| { kind: 'merged'; css: string }
| { kind: 'split'; index: string; groups: Map<string, string> };
| {
kind: 'split';
index: string;
/** CSS chunks keyed by safe file stem, not raw group name. */
groups: Map<string, string>;
};
/**
* Hoist configuration. When provided, every CSS custom property declaration
@@ -68,8 +73,7 @@ export interface EmitCssOptions {
* Tailwind's internal `--tw-*` registered variables from the final output.
*
* - `true` — inline `--tw-*` (regex `/^--tw-/`).
* - `RegExp` — inline any `--name` whose name (excluding the leading
* `--`) matches.
* - `RegExp` — inline any custom property whose full name matches.
* - omitted — no inlining.
*
* Resolution is per-rule: the setter for a property must live in the
@@ -169,6 +173,7 @@ 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 referenced = collectReferencedVarsFromRules(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 => {
@@ -178,7 +183,9 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
};
const inlineMatch = propMode === 'inline' ? combineMatchers(inlineVars, propertyMatch) : inlineVars;
const fallbacks =
propMode === 'inline' && propertyMatch ? buildFallbackSetters(captured, propertyMatch, resolveDef) : undefined;
propMode === 'inline' && propertyMatch
? buildFallbackSetters(captured, referenced, propertyMatch, resolveDef)
: undefined;
const emitProperties = (css: string): string =>
propMode === 'emit' && propertyMatch ? buildPropertyBlocks(css, propertyMatch, resolveDef) : '';
@@ -201,12 +208,14 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
const groups = new Map<string, string>();
const importLines: string[] = [];
const groupFileNames = new Set<string>();
// 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 fileName = groupCssFileName(groupName, groupFileNames);
groups.set(fileName, composeRules(groupRules, undefined, inlineMatch, fallbacks));
importLines.push(`@import "./${fileName}.css";`);
}
const base = await bundleBaseCss(opts.baseCss ?? [], configDir);
@@ -269,6 +278,16 @@ function collectReferencedVars(css: string): Set<string> {
return out;
}
function collectReferencedVarsFromRules(rules: readonly CompiledRule[]): Set<string> {
const out = new Set<string>();
for (const rule of rules) {
for (const declaration of rule.utility.declarations) {
for (const name of collectReferencedVars(declaration.value)) out.add(name);
}
}
return out;
}
/** Collect custom properties *declared* (`--name:`) in a CSS string. */
function collectDefinedVars(css: string): Set<string> {
const out = new Set<string>();
@@ -301,11 +320,13 @@ function collectPropertyDefs(rules: readonly CompiledRule[]): Map<string, Proper
/** Build the `name → initial-value` fallback map for `mode: 'inline'`. */
function buildFallbackSetters(
captured: Map<string, PropertyDef>,
referenced: Set<string>,
match: VariableMatcher,
resolveDef: (name: string) => PropertyDef | undefined
): Map<string, string> {
const out = new Map<string, string>();
for (const name of captured.keys()) {
const names = new Set([...captured.keys(), ...referenced]);
for (const name of names) {
if (!match(name)) continue;
const def = resolveDef(name);
if (def?.initialValue !== undefined) out.set(name, def.initialValue);
@@ -353,6 +374,27 @@ function joinSections(...sections: string[]): string {
return sections.filter((s) => s.length > 0).join('\n\n');
}
function groupCssFileName(groupName: string, used: Set<string>): string {
const base = sanitizeGroupName(groupName);
let fileName = base;
let suffix = 2;
while (used.has(fileName)) {
fileName = `${base}-${suffix}`;
suffix++;
}
used.add(fileName);
return fileName;
}
function sanitizeGroupName(groupName: string): string {
const trimmed = groupName.trim();
if (!trimmed) return '_default';
const safe = trimmed.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
const fileName = safe || '_group';
return fileName === 'index' ? '_index' : fileName;
}
/* ─────────────────────────────────────────────────────────────────────────
* Rule composition
* ───────────────────────────────────────────────────────────────────────── */
+2 -1
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { dirname, extname, isAbsolute, resolve } from 'node:path';
import ts from 'typescript';
import { type DiagnosticLocation, diagnosticLocationFromNode } from '../diagnostics';
@@ -331,6 +331,7 @@ const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const;
/** Resolve a relative `./foo` / `../foo` specifier from `fromFile`. */
function resolveRelativeModule(specifier: string, fromFile: string): string {
const base = isAbsolute(specifier) ? specifier : resolve(dirname(fromFile), specifier);
if (extname(base) && existsSync(base)) return base;
for (const ext of MODULE_EXTENSIONS) {
const candidate = `${base}${ext}`;
if (existsSync(candidate)) return candidate;
+2 -1
View File
@@ -314,7 +314,7 @@ function addCssAssets(context: CompilerContext, output: string | undefined, emit
for (const [group, source] of emitted.groups) {
context.addAsset({
type: 'css',
fileName: join(dir, `${group || 'index'}.css`),
fileName: join(dir, `${group}.css`),
source,
sourceFile: context.filename,
});
@@ -484,6 +484,7 @@ function resolveTokenImport(
function resolveModulePath(specifier: string, fromFile: string): string | null {
const base = isAbsolute(specifier) ? specifier : resolvePath(dirname(fromFile), specifier);
if (extname(base) && existsSync(base)) return base;
for (const ext of MODULE_EXTENSIONS) {
const candidate = `${base}${ext}`;
if (existsSync(candidate)) return candidate;
@@ -127,6 +127,22 @@ describe('emitCss — split mode', () => {
expect(twoIdx).toBeGreaterThan(-1);
expect(oneIdx).toBeLessThan(twoIdx);
});
it('uses safe file stems for empty, reserved, and path-like groups', async () => {
const out = await emitCss({
mode: 'split',
rules: [
rule('a', [{ property: 'color', value: 'red' }]),
rule('b', [{ property: 'color', value: 'blue' }], [], 'index'),
rule('c', [{ property: 'color', value: 'green' }], [], '../controls'),
rule('d', [{ property: 'color', value: 'purple' }], [], 'controls'),
],
});
if (out.kind !== 'split') throw new Error('expected split');
expect([...out.groups.keys()].sort()).toEqual(['_default', '_index', 'controls', 'controls-2']);
expect(out.index).not.toContain('./index.css');
expect(out.index).not.toContain('..');
});
});
describe('emitCss — baseCss prepend', () => {
@@ -610,6 +626,29 @@ describe('emitCss — registered @property variables', () => {
expect(out.css).toMatch(/var\(--tw-content\)/);
});
it('uses resolver-supplied inline defaults for uncaptured variables', async () => {
const out = await emitCss({
rules: [
rule('card', [
{ property: 'content', value: 'var(--brand-content)' },
{ property: 'display', value: 'block' },
]),
],
properties: {
mode: 'inline',
variables: [
{
match: /^--brand-/,
resolve: (name) => (name === '--brand-content' ? { initialValue: '"brand"' } : undefined),
},
],
},
});
if (out.kind !== 'merged') throw new Error('expected merged');
expect(collapse(out.css)).toContain(collapse('content: "brand";'));
expect(out.css).not.toMatch(/var\(--brand-content\)/);
});
it('leaves variables untouched when no properties option is given (back-compat)', async () => {
const out = await emitCss({ rules: [contentRule()] });
if (out.kind !== 'merged') throw new Error('expected merged');
@@ -140,6 +140,12 @@ describe('loadTokenModule — relative imports', () => {
expect(loadTokenModule(file)).toEqual({ a: 'flex gap-2' });
});
it('resolves a relative import with an explicit extension', () => {
write('base.ts', `export const value = 'flex';\n`);
const file = write('mod.ts', `import { value } from './base.ts';\nexport const a = value;\n`);
expect(loadTokenModule(file)).toEqual({ a: 'flex' });
});
it('resolves an aliased import', () => {
write('base.ts', `export const button = { base: 'rounded' };\n`);
const file = write(
@@ -475,6 +475,24 @@ function App(){ return <Foo className={styles.button}/>; }`;
expect(captured![0]!.className).toBe('button');
});
it('resolves imported tokens with explicit extensions before extraction', async () => {
writeFixture(
'tokens.ts',
`export const tokens = { button: 'flex' };
`
);
const source = `import { tokens as styles } from './tokens.ts';
function App(){ return <Foo className={styles.button}/>; }`;
const sourcePath = writeFixture('skin.tsx', source);
const { code } = await compile(source, {
target: 'jsx',
filename: sourcePath,
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
});
expect(code).toContain('"button"');
});
it('resolves bare token imports through a configured resolver', async () => {
const tokenPath = writeFixture(
'tokens.ts',