mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(compiler): vanilla-css correctness — markers, collisions, theme vars
Three correctness fixes for the `vanilla-css` Tailwind target, each previously untested: 1. Preserve marker utilities. Utilities that produce no declarations (`group`, `peer`, `group/<name>`) were silently dropped when rewriting `className` to the semantic name, breaking every descendant `group-*` / `peer-*` variant. They're now kept on the element (`"play-button group"`), alongside any other class Tailwind doesn't recognize. 2. Flag class-name collisions. Two distinct elements that derive the same class name but resolve to different utilities would have their conflicting declarations silently merged into one rule (e.g. the forward/back `seek-icon`, buffering vs preview `spinner-icon`, current/duration `time-value`). The plugin now tracks a per-name utility signature and throws a fixable DiagnosticError on mismatch, while identical recurrences (repeated `Tooltip.Popup`) pass cleanly. 3. Emit referenced theme variables. The generated CSS referenced `var(--spacing)`, `var(--color-*)`, `var(--ease-*)` etc. with nothing defining them. `emitCss` now accepts a `resolveThemeVar` resolver (backed by `design.resolveThemeVar`) and emits a leading, transitively resolved theme block — scoped to the skin's hoist root when set — so the output resolves without a separate Tailwind theme on the page. Adds 11 tests (markers, collisions, theme emission incl. transitive, already-declared, scoped, and back-compat). Compiler suite: 169 passing. Note: `@property`-registered `--tw-*` slots (e.g. `--tw-content`) resolve to undefined and are left for a follow-up.
This commit is contained in:
@@ -19,6 +19,13 @@ export interface DesignSystem {
|
||||
readonly cssPath: string;
|
||||
/** Compile a single utility class to CSS. Returns `null` for unknown candidates. */
|
||||
compileUtility(utility: string): string | null;
|
||||
/**
|
||||
* Resolve a `@theme` variable (e.g. `--spacing`, `--color-white`) to its
|
||||
* value, or `undefined` if the theme doesn't define it. Used to emit a
|
||||
* self-contained theme block for the variables compiled rules reference.
|
||||
* Returns `undefined` for `@property`-registered slots like `--tw-*`.
|
||||
*/
|
||||
resolveThemeVar(name: string): string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +50,7 @@ export async function loadDesignSystem(cssPath: string): Promise<DesignSystem> {
|
||||
});
|
||||
|
||||
const cache = new Map<string, string | null>();
|
||||
const themeCache = new Map<string, string | undefined>();
|
||||
|
||||
return {
|
||||
cssPath: absolute,
|
||||
@@ -54,6 +62,17 @@ export async function loadDesignSystem(cssPath: string): Promise<DesignSystem> {
|
||||
cache.set(utility, value);
|
||||
return value;
|
||||
},
|
||||
resolveThemeVar(name: string): string | undefined {
|
||||
if (themeCache.has(name)) return themeCache.get(name);
|
||||
let value: string | undefined;
|
||||
try {
|
||||
value = ds.resolveThemeValue?.(name);
|
||||
} catch {
|
||||
value = undefined;
|
||||
}
|
||||
themeCache.set(name, value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,20 @@ export interface EmitCssOptions {
|
||||
* fallback if present, otherwise they're left alone.
|
||||
*/
|
||||
inlineVars?: true | RegExp;
|
||||
/**
|
||||
* Resolve a referenced `@theme` variable (e.g. `--spacing`) to its value.
|
||||
* When set, `emitCss` emits a leading rule defining every theme variable the
|
||||
* output references but doesn't itself declare, so the CSS resolves without a
|
||||
* separate Tailwind theme/preflight on the page. Typically
|
||||
* `design.resolveThemeVar`. Returns `undefined` to leave a variable alone
|
||||
* (e.g. `@property`-registered `--tw-*` slots).
|
||||
*/
|
||||
resolveThemeVar?: (name: string) => string | undefined;
|
||||
/**
|
||||
* Selector the emitted theme block attaches to. Defaults to `:root`. Pass a
|
||||
* skin selector (e.g. `[data-skin="default-video"]`) to scope the variables.
|
||||
*/
|
||||
themeSelector?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +111,8 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
|
||||
if (mode === 'merged') {
|
||||
const base = await bundleBaseCss(opts.baseCss ?? [], configDir);
|
||||
const body = composeRules(opts.rules, hoist, inlineVars);
|
||||
return { kind: 'merged', css: joinSections(base, body) };
|
||||
const theme = buildThemeBlock(body, opts.resolveThemeVar, opts.themeSelector);
|
||||
return { kind: 'merged', css: joinSections(base, theme, body) };
|
||||
}
|
||||
|
||||
// Split mode: group rules by `bag`.
|
||||
@@ -120,10 +135,74 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
|
||||
}
|
||||
|
||||
const base = await bundleBaseCss(opts.baseCss ?? [], configDir);
|
||||
const index = joinSections(base, importLines.join('\n'));
|
||||
// Theme variables go in `index` (it loads first), resolved against every bag.
|
||||
const theme = buildThemeBlock([...bags.values()].join('\n'), opts.resolveThemeVar, opts.themeSelector);
|
||||
const index = joinSections(base, theme, importLines.join('\n'));
|
||||
return { kind: 'split', index, bags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a leading rule that defines every `@theme` variable the emitted CSS
|
||||
* references but doesn't itself declare. Resolves transitively (a theme value
|
||||
* may reference further variables). Returns `''` when there's nothing to emit
|
||||
* or no resolver was supplied.
|
||||
*/
|
||||
function buildThemeBlock(
|
||||
css: string,
|
||||
resolveThemeVar: ((name: string) => string | undefined) | undefined,
|
||||
themeSelector: string | undefined
|
||||
): string {
|
||||
if (!resolveThemeVar) return '';
|
||||
|
||||
const defined = collectDefinedVars(css);
|
||||
const resolved = new Map<string, string>();
|
||||
const queue = [...collectReferencedVars(css)].filter((name) => !defined.has(name));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const name = queue.shift()!;
|
||||
if (resolved.has(name) || defined.has(name)) continue;
|
||||
const value = resolveThemeVar(name);
|
||||
if (value === undefined) continue;
|
||||
resolved.set(name, value);
|
||||
for (const ref of collectReferencedVars(value)) {
|
||||
if (!resolved.has(ref) && !defined.has(ref)) queue.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.size === 0) return '';
|
||||
|
||||
const selector = themeSelector ?? ':root';
|
||||
const decls = [...resolved.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([name, value]) => ` ${name}: ${value};`)
|
||||
.join('\n');
|
||||
return `${selector} {\n${decls}\n}`;
|
||||
}
|
||||
|
||||
/** Collect `var(--name)` references in a CSS string. */
|
||||
function collectReferencedVars(css: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const re = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
||||
let m: RegExpExecArray | null = re.exec(css);
|
||||
while (m !== null) {
|
||||
out.add(m[1]!);
|
||||
m = re.exec(css);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collect custom properties *declared* (`--name:`) in a CSS string. */
|
||||
function collectDefinedVars(css: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const re = /(?:^|[{;\s])(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
let m: RegExpExecArray | null = re.exec(css);
|
||||
while (m !== null) {
|
||||
out.add(m[1]!);
|
||||
m = re.exec(css);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Internal: read each `baseCss` file via Lightning CSS, return concatenated string. */
|
||||
async function bundleBaseCss(paths: readonly string[], configDir: string): Promise<string> {
|
||||
if (paths.length === 0) return '';
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import { tagName } from '../matchers';
|
||||
import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles';
|
||||
import { decompose, type UtilityCss } from './decompose';
|
||||
import type { DesignSystem } from './design-system';
|
||||
import { type CompiledRule, type EmittedCss, emitCss, type HoistOptions } from './emit';
|
||||
import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
|
||||
import { type DeriveClassNameOptions, deriveClassName, type NameTransform } from './naming';
|
||||
import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming';
|
||||
|
||||
/** Output target for `tailwindPlugin`. */
|
||||
export type TailwindTarget =
|
||||
@@ -133,13 +134,20 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
return (transformContext) => {
|
||||
return (sourceFile) => {
|
||||
const rules: CompiledRule[] = [];
|
||||
// Per derived class name, the sorted utility signature of the first
|
||||
// element that produced it. Lets us detect when two *different* source
|
||||
// elements collapse onto the same class name with *different* styles —
|
||||
// emitCss would silently merge their declarations into one rule.
|
||||
const signatures = new Map<string, string>();
|
||||
|
||||
const visit: StyleVisitor = (info, factory) => {
|
||||
if (info.kind !== 'segments' || !info.segments) return undefined;
|
||||
// Capture the narrowed segments so the closures below keep the type.
|
||||
const segments = info.segments;
|
||||
|
||||
const naming: DeriveClassNameOptions = {
|
||||
element: info.element,
|
||||
segments: info.segments,
|
||||
segments,
|
||||
...(transformName ? { transformName } : {}),
|
||||
...(overrides ? { overrides } : {}),
|
||||
};
|
||||
@@ -152,12 +160,30 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
// classname to the derived semantic name and wrap any pass-throughs in
|
||||
// a `cn(...)` call so composition is preserved.
|
||||
const passThrough: ts.Expression[] = [];
|
||||
for (const seg of info.segments) {
|
||||
const preserved: string[] = [];
|
||||
// Every utility this element resolves to (rule-producing or preserved),
|
||||
// for the collision signature below.
|
||||
const utilities: string[] = [];
|
||||
|
||||
// Compile one utility: emit a rule when it produces declarations,
|
||||
// otherwise *preserve* it as a literal class. Utilities that yield no
|
||||
// declarations are markers (`group`, `peer`, `group/<name>`) or classes
|
||||
// Tailwind doesn't recognize — dropping them would silently break every
|
||||
// descendant `group-*` / `peer-*` variant that targets the marker.
|
||||
const handleUtility = (utility: string): void => {
|
||||
utilities.push(utility);
|
||||
const css = decompose(utility, design);
|
||||
if (css && css.declarations.length > 0) {
|
||||
rules.push(buildCompiledRule(derived.className, css, segments, bagFor));
|
||||
return;
|
||||
}
|
||||
if (!preserved.includes(utility)) preserved.push(utility);
|
||||
};
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.kind === 'literal') {
|
||||
for (const utility of seg.value.split(/\s+/)) {
|
||||
if (!utility) continue;
|
||||
const css = decompose(utility, design);
|
||||
if (css) rules.push(buildCompiledRule(derived.className, css, info.segments, bagFor));
|
||||
if (utility) handleUtility(utility);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -165,9 +191,7 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
const literal = resolveTokenPath(seg.path, env);
|
||||
if (literal !== null) {
|
||||
for (const utility of literal.split(/\s+/)) {
|
||||
if (!utility) continue;
|
||||
const css = decompose(utility, design);
|
||||
if (css) rules.push(buildCompiledRule(derived.className, css, info.segments, bagFor));
|
||||
if (utility) handleUtility(utility);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -176,11 +200,28 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
passThrough.push(seg.node);
|
||||
}
|
||||
|
||||
// Collision guard: two distinct elements that derive the same class
|
||||
// name must resolve to the same utilities. Identical recurrences (e.g.
|
||||
// many `<Tooltip.Popup>` with the same token) share a signature and are
|
||||
// fine; differing ones would merge conflicting declarations into one
|
||||
// rule, so we fail loudly with a fixable diagnostic.
|
||||
if (utilities.length > 0) {
|
||||
const signature = [...utilities].sort().join(' ');
|
||||
const previous = signatures.get(derived.className);
|
||||
if (previous === undefined) {
|
||||
signatures.set(derived.className, signature);
|
||||
} else if (previous !== signature) {
|
||||
throw collisionError(info.element, derived.className, previous, signature);
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = preserved.length > 0 ? `${derived.className} ${preserved.join(' ')}` : derived.className;
|
||||
|
||||
if (passThrough.length === 0) {
|
||||
return factory.createStringLiteral(derived.className);
|
||||
return factory.createStringLiteral(baseName);
|
||||
}
|
||||
return factory.createCallExpression(factory.createIdentifier('cn'), undefined, [
|
||||
factory.createStringLiteral(derived.className),
|
||||
factory.createStringLiteral(baseName),
|
||||
...passThrough,
|
||||
]);
|
||||
};
|
||||
@@ -192,11 +233,16 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
onRules?.(rules);
|
||||
|
||||
if (onCss) {
|
||||
// Scope the emitted theme variables to the skin's hoist root when one
|
||||
// is configured, so they don't leak to a global `:root`.
|
||||
const themeSelector = hoistVars ? hoistVars.rootSelector : undefined;
|
||||
emitCss({
|
||||
rules,
|
||||
...(emit ?? {}),
|
||||
...(hoistVars !== undefined ? { hoist: hoistVars } : {}),
|
||||
...(inlineVars !== undefined ? { inlineVars } : {}),
|
||||
resolveThemeVar: (name) => design.resolveThemeVar(name),
|
||||
...(themeSelector ? { themeSelector } : {}),
|
||||
})
|
||||
.then(onCss)
|
||||
.catch(() => {
|
||||
@@ -406,3 +452,21 @@ function buildCompiledRule(
|
||||
const bag = bagFor?.({ className, segments });
|
||||
return bag === undefined ? { className, utility } : { className, utility, bag };
|
||||
}
|
||||
|
||||
function collisionError(element: ts.Node, className: string, first: string, next: string): DiagnosticError {
|
||||
const tag = tagName(element as Parameters<typeof tagName>[0]);
|
||||
const sourceFile = element.getSourceFile?.();
|
||||
const loc = sourceFile ? sourceFile.getLineAndCharacterOfPosition(element.pos) : undefined;
|
||||
const fileName = sourceFile?.fileName;
|
||||
const line = loc ? loc.line + 1 : undefined;
|
||||
return new DiagnosticError(
|
||||
`vanilla-css: class name '${className}' is derived from elements with different styles` +
|
||||
`${fileName ? ` (this one at ${fileName}:${line})` : ''}.\n` +
|
||||
` <${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.`,
|
||||
fileName,
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
@@ -475,3 +475,60 @@ describe('emitCss — inlineVars', () => {
|
||||
expect(out.css).toMatch(/\.a\s*{[^}]*color:\s*var\(--media-color\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitCss — theme variables', () => {
|
||||
it('emits a :root block defining referenced theme variables', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [rule('box', [{ property: 'padding', value: 'calc(var(--spacing) * 1)' }])],
|
||||
resolveThemeVar: (name) => (name === '--spacing' ? '0.25rem' : undefined),
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(collapse(':root {\n --spacing: 0.25rem;\n}'));
|
||||
// Theme block precedes the rules that consume it.
|
||||
expect(out.css.indexOf('--spacing: 0.25rem')).toBeLessThan(out.css.indexOf('.box'));
|
||||
});
|
||||
|
||||
it('resolves theme variables transitively', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [rule('box', [{ property: 'color', value: 'var(--brand)' }])],
|
||||
resolveThemeVar: (name) =>
|
||||
name === '--brand' ? 'var(--brand-500)' : name === '--brand-500' ? '#09f' : undefined,
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(collapse('--brand: var(--brand-500);'));
|
||||
expect(collapse(out.css)).toContain(collapse('--brand-500: #09f;'));
|
||||
});
|
||||
|
||||
it('does not redeclare variables the rules already define', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [
|
||||
rule('box', [
|
||||
{ property: '--spacing', value: '1rem' },
|
||||
{ property: 'padding', value: 'var(--spacing)' },
|
||||
]),
|
||||
],
|
||||
resolveThemeVar: () => '0.25rem',
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
// The locally-declared --spacing wins; no :root override is emitted.
|
||||
expect(out.css).not.toMatch(/:root/);
|
||||
});
|
||||
|
||||
it('scopes the theme block to a custom selector', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [rule('box', [{ property: 'gap', value: 'var(--spacing)' }])],
|
||||
resolveThemeVar: (name) => (name === '--spacing' ? '0.25rem' : undefined),
|
||||
themeSelector: '[data-skin="x"]',
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(collapse('[data-skin="x"] {\n --spacing: 0.25rem;\n}'));
|
||||
});
|
||||
|
||||
it('omits the theme block when no resolver is provided (back-compat)', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [rule('box', [{ property: 'gap', value: 'var(--spacing)' }])],
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(out.css).not.toMatch(/:root/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,66 @@ describe('tailwindPlugin — target: vanilla-css', () => {
|
||||
expect(code).not.toContain('"flex items-center"');
|
||||
});
|
||||
|
||||
it('preserves marker utilities (group/peer) alongside the derived name', () => {
|
||||
const source = `function App(){ return <PlayButton className="group"/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
// `group` produces no declarations but is required by descendant
|
||||
// `group-*` variants, so it must survive on the element.
|
||||
expect(code).toContain('"play-button group"');
|
||||
});
|
||||
|
||||
it('keeps markers and still emits rules for declaration-producing utilities', () => {
|
||||
const source = `function App(){ return <PlayButton className={cn('flex', 'group')}/>; }`;
|
||||
let captured: readonly CompiledRule[] | undefined;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
onRules: (rules) => {
|
||||
captured = rules;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(code).toContain('"play-button group"');
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured!.map((r) => r.className)).toContain('play-button');
|
||||
expect(captured!.flatMap((r) => r.utility.declarations)).toContainEqual({ property: 'display', value: 'flex' });
|
||||
});
|
||||
|
||||
it('preserves a marker while wrapping pass-through expressions in cn()', () => {
|
||||
const source = `function App(){ return <PlayButton className={cn('group', extra)}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
expect(code).toMatch(/cn\("play-button group",\s*extra\)/);
|
||||
});
|
||||
|
||||
it('throws a diagnostic when two elements derive the same name with different styles', () => {
|
||||
const source = `function App(){ return <div><SeekIcon className="flex"/><SeekIcon className="block"/></div>; }`;
|
||||
expect(() =>
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
})
|
||||
).toThrow(/class name 'seek-icon' is derived from elements with different styles/);
|
||||
});
|
||||
|
||||
it('does not flag identical recurrences of the same derived name', () => {
|
||||
const source = `function App(){ return <div><PlayButton className="flex"/><PlayButton className="flex"/></div>; }`;
|
||||
const { code } = compile(source, {
|
||||
target: 'react',
|
||||
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
|
||||
});
|
||||
expect(code).toContain('"play-button"');
|
||||
});
|
||||
|
||||
it('rewrites className to a token-path-derived name on a bare HTML element', () => {
|
||||
const source = `function App(){ return <div className={styles.bufferingIndicator}/>; }`;
|
||||
const { code } = compile(source, {
|
||||
@@ -381,4 +441,27 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
|
||||
const css = await cssPromise;
|
||||
expect(collapse(css)).toContain(collapse('.foo{display:flex;}'));
|
||||
});
|
||||
|
||||
it('emits referenced theme variables in the onCss output', async () => {
|
||||
// `p-4` lowers to `padding: calc(var(--spacing) * 4)` — the output must
|
||||
// define `--spacing` so it resolves without a separate Tailwind theme.
|
||||
const source = `function App(){ return <Foo className="p-4"/>; }`;
|
||||
const cssPromise = new Promise<string>((resolve) => {
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
hoistVars: { rootSelector: '[data-skin="x"]' },
|
||||
onCss: (out) => {
|
||||
if (out.kind === 'merged') resolve(out.css);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const css = await cssPromise;
|
||||
expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user