refactor(compiler): capture tailwind property rules from ast

This commit is contained in:
Rahim
2026-06-24 14:30:14 -07:00
parent aadedca538
commit 9cbcdb7704
3 changed files with 85 additions and 37 deletions
@@ -29,11 +29,14 @@ export const styles = {
),
button: cn(
'group',
'flex items-center',
'relative flex items-center',
'disabled:opacity-50',
'data-[availability=unsupported]:hidden',
'aria-expanded:bg-current/10',
'focus-visible:outline-current'
'focus-visible:outline-current',
'before:absolute',
"before:content-['x']",
'after:absolute'
),
icon: cn(
'hidden opacity-0',
@@ -95,7 +98,7 @@ describe('tailwind output modes', () => {
"export function Fixture() {
return (<FixtureRoot className="container p-4 [--media-controls-transition-duration:100ms] [color:var(--media-color-primary,oklch(1_0_0))] [&_video]:block [&:fullscreen]:[--media-border-radius:0]">
<Controls className="flex flex-wrap [--media-popover-side-offset:0.5rem] pointer-fine:transition-[scale,filter,opacity] motion-reduce:[--media-controls-transition-duration:50ms] contrast-more:[--media-surface-background-color:oklch(0_0_0)] [@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)] @2xl/media-root:flex-nowrap">
<Button className="group flex items-center disabled:opacity-50 data-[availability=unsupported]:hidden aria-expanded:bg-current/10 focus-visible:outline-current">
<Button className="group relative flex items-center disabled:opacity-50 data-[availability=unsupported]:hidden aria-expanded:bg-current/10 focus-visible:outline-current before:absolute before:content-['x'] after:absolute">
<Icon className="hidden opacity-0 group-data-paused:block group-data-paused:opacity-100 group-not-data-paused:opacity-0"/>
</Button>
<div className="absolute [left:var(--media-slider-pointer)] has-[[role=img]:not([data-hidden])]:opacity-100"/>
@@ -115,7 +118,10 @@ describe('tailwind output modes', () => {
tailwind({
mode: 'extract',
input: cssPath,
vars: { hoist: { rootSelector: '.media-test-skin' } },
vars: {
hoist: { rootSelector: '.media-test-skin' },
properties: { mode: 'inline', variables: [{ match: /^--tw-content$/ }] },
},
}),
],
},
@@ -139,6 +145,8 @@ describe('tailwind output modes', () => {
const css = result.assets[0]!.source;
expect(css).not.toMatch(/\.media-test-skin\s*{[^}]*--media-border-radius/);
expect(css).not.toContain('--tw-content');
expect(css).not.toContain('var(--tw-content)');
expect(css).toMatchInlineSnapshot(`
".media-test-skin {
--default-transition-duration: 150ms;
@@ -153,6 +161,17 @@ describe('tailwind output modes', () => {
.button {
align-items: center;
display: flex;
position: relative;
}
.button::after {
content: "";
position: absolute;
}
.button::before {
content: 'x';
position: absolute;
}
.button:disabled {
@@ -178,7 +197,7 @@ describe('tailwind output modes', () => {
.fixture-root {
--media-controls-transition-duration: 100ms;
color: var(--media-color-primary,oklch(1 0 0));
color: var(--media-color-primary, oklch(1 0 0));
padding: calc(var(--spacing) * 4);
width: 100%;
}
@@ -124,6 +124,12 @@ describe('analyzeUtility — @property registrations', () => {
});
});
it('does not capture @property text from declaration values', () => {
const r = analyzeUtility("before:content-['@property_--fake_{}']", design);
expect(r).not.toBeNull();
expect(r!.properties?.map((p) => p.name)).toEqual(['--tw-content']);
});
it('omits `properties` when a utility registers none', () => {
const r = analyzeUtility('flex', design);
expect(r!.properties).toBeUndefined();
+55 -32
View File
@@ -1,6 +1,7 @@
import {
type DeclarationBlock as CssDeclarationBlock,
type Location2 as CssLocation,
type PropertyRule as CssPropertyRule,
type Rule as CssRule,
type StyleRule as CssStyleRule,
type StyleSheet as CssStyleSheet,
@@ -98,7 +99,7 @@ export function analyzeUtility(utility: string, design: DesignSystem): UtilityCs
collectRuleBranches(stylesheet.rules, [], branches, context);
// Tailwind appends `@property --tw-* { ... }` registrations after the utility rule.
const properties = collectProperties(context);
const properties = collectProperties(stylesheet.rules, context);
const declarations = branches.flatMap((branch) => branch.declarations);
const variants = branches[0]?.variants ?? [];
@@ -138,35 +139,6 @@ function createAnalysisContext(css: string): AnalysisContext {
return { css, lineStarts };
}
/** Parse every `@property --name { ... }` block from a compiled utility. */
function parseProperties(css: string): PropertyRule[] {
const out: PropertyRule[] = [];
const re = /@property\s+(--[A-Za-z0-9_-]+)\s*\{/g;
let match = re.exec(css);
while (match !== null) {
const name = match[1]!;
const openIdx = match.index + match[0].length - 1;
const block = readBalancedBlock(css, openIdx);
if (!block) break;
const rule: PropertyRule = { name };
for (const decl of block.inner.split(';')) {
const colon = decl.indexOf(':');
if (colon === -1) continue;
const prop = decl.slice(0, colon).trim();
const value = decl.slice(colon + 1).trim();
if (!value) continue;
if (prop === 'syntax') rule.syntax = value;
else if (prop === 'inherits') rule.inherits = value === 'true';
else if (prop === 'initial-value') rule.initialValue = value;
}
out.push(rule);
re.lastIndex = block.end + 1;
match = re.exec(css);
}
return out;
}
function collectRuleBranches(
rules: readonly CssRule[],
variants: readonly Variant[],
@@ -275,8 +247,59 @@ function selectorTailFromStyleRule(rule: CssStyleRule, context: AnalysisContext)
return /^\s/.test(rawTail) ? ` ${trimmedRight.trim()}` : trimmedRight.trim();
}
function collectProperties(context: AnalysisContext): PropertyRule[] {
return parseProperties(context.css);
function collectProperties(rules: readonly CssRule[], context: AnalysisContext): PropertyRule[] {
const properties: PropertyRule[] = [];
for (const rule of rules) collectPropertyRule(rule, properties, context);
return properties;
}
function collectPropertyRule(rule: CssRule, properties: PropertyRule[], context: AnalysisContext): void {
switch (rule.type) {
case 'property':
properties.push(propertyRuleFromAst(rule.value, context));
return;
case 'media':
case 'container':
case 'supports':
case 'layer-block':
case 'moz-document':
case 'scope':
case 'starting-style':
for (const child of rule.value.rules) collectPropertyRule(child, properties, context);
return;
default:
return;
}
}
function propertyRuleFromAst(rule: CssPropertyRule, context: AnalysisContext): PropertyRule {
// Use Lightning's AST for rule identity, but source text for descriptors so
// emitted CSS preserves Tailwind's authored values instead of serializer output.
const descriptors = propertyDescriptorsFromSource(rule, context);
return {
name: rule.name,
...(descriptors.syntax ? { syntax: descriptors.syntax } : {}),
inherits: rule.inherits,
...(descriptors.initialValue ? { initialValue: descriptors.initialValue } : {}),
};
}
function propertyDescriptorsFromSource(
rule: CssPropertyRule,
context: AnalysisContext
): { syntax?: string; initialValue?: string } {
const open = findBlockOpen(context.css, indexFromLocation(context, rule.loc));
if (open === -1) return {};
const block = readBalancedBlock(context.css, open);
if (!block) return {};
const descriptors: { syntax?: string; initialValue?: string } = {};
for (const declaration of parseLocalDeclarations(block.inner)) {
if (declaration.property === 'syntax') descriptors.syntax = declaration.value;
else if (declaration.property === 'initial-value') descriptors.initialValue = declaration.value;
}
return descriptors;
}
function declarationsForBlock(block: CssDeclarationBlock, loc: CssLocation, context: AnalysisContext): Declaration[] {