From 9cbcdb7704ff024787853ba8ba5057b99bc591f6 Mon Sep 17 00:00:00 2001 From: Rahim Date: Wed, 24 Jun 2026 14:30:14 -0700 Subject: [PATCH] refactor(compiler): capture tailwind property rules from ast --- .../src/tailwind/tests/output-modes.test.ts | 29 +++++-- .../src/tailwind/tests/utility-css.test.ts | 6 ++ packages/compiler/src/tailwind/utility-css.ts | 87 ++++++++++++------- 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/packages/compiler/src/tailwind/tests/output-modes.test.ts b/packages/compiler/src/tailwind/tests/output-modes.test.ts index a726c3f5..a9cf1ed9 100644 --- a/packages/compiler/src/tailwind/tests/output-modes.test.ts +++ b/packages/compiler/src/tailwind/tests/output-modes.test.ts @@ -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 ( -
@@ -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%; } diff --git a/packages/compiler/src/tailwind/tests/utility-css.test.ts b/packages/compiler/src/tailwind/tests/utility-css.test.ts index 90a73990..c98d9950 100644 --- a/packages/compiler/src/tailwind/tests/utility-css.test.ts +++ b/packages/compiler/src/tailwind/tests/utility-css.test.ts @@ -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(); diff --git a/packages/compiler/src/tailwind/utility-css.ts b/packages/compiler/src/tailwind/utility-css.ts index 54cbbea5..ab36cd3d 100644 --- a/packages/compiler/src/tailwind/utility-css.ts +++ b/packages/compiler/src/tailwind/utility-css.ts @@ -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[] {