mirror of
https://github.com/zoriya/v10.git
synced 2026-08-08 15:19:25 +00:00
fix(compiler): harden tailwind emit edge cases
This commit is contained in:
@@ -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
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user