From d5d2ddffb6b603493c3db6cefd07496f5db6f06d Mon Sep 17 00:00:00 2001 From: Rahim Date: Tue, 23 Jun 2026 15:35:19 -0700 Subject: [PATCH] fix(compiler): harden tailwind emit edge cases --- packages/compiler/src/tailwind/emit.ts | 56 ++++++++++++++++--- packages/compiler/src/tailwind/evaluator.ts | 3 +- packages/compiler/src/tailwind/plugin.ts | 3 +- .../compiler/src/tailwind/tests/emit.test.ts | 39 +++++++++++++ .../src/tailwind/tests/evaluator.test.ts | 6 ++ .../src/tailwind/tests/plugin.test.ts | 18 ++++++ 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts index 035bac7b..bce296ad 100644 --- a/packages/compiler/src/tailwind/emit.ts +++ b/packages/compiler/src/tailwind/emit.ts @@ -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 }; + | { + kind: 'split'; + index: string; + /** CSS chunks keyed by safe file stem, not raw group name. */ + groups: Map; + }; /** * 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 { // 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 { }; 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 { const groups = new Map(); const importLines: string[] = []; + const groupFileNames = new Set(); // 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 { return out; } +function collectReferencedVarsFromRules(rules: readonly CompiledRule[]): Set { + const out = new Set(); + 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 { const out = new Set(); @@ -301,11 +320,13 @@ function collectPropertyDefs(rules: readonly CompiledRule[]): Map, + referenced: Set, match: VariableMatcher, resolveDef: (name: string) => PropertyDef | undefined ): Map { const out = new Map(); - 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 { + 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 * ───────────────────────────────────────────────────────────────────────── */ diff --git a/packages/compiler/src/tailwind/evaluator.ts b/packages/compiler/src/tailwind/evaluator.ts index d4435869..f02f1aab 100644 --- a/packages/compiler/src/tailwind/evaluator.ts +++ b/packages/compiler/src/tailwind/evaluator.ts @@ -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; diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index bbcc147b..80c356a1 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -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; diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts index 34e72ece..083989a9 100644 --- a/packages/compiler/src/tailwind/tests/emit.test.ts +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -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'); diff --git a/packages/compiler/src/tailwind/tests/evaluator.test.ts b/packages/compiler/src/tailwind/tests/evaluator.test.ts index 7985afa1..6353682f 100644 --- a/packages/compiler/src/tailwind/tests/evaluator.test.ts +++ b/packages/compiler/src/tailwind/tests/evaluator.test.ts @@ -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( diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index fee1d152..6c050990 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -475,6 +475,24 @@ function App(){ return ; }`; 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 ; }`; + 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',