From 0486765848e9521821e23f06feb5e092e1027041 Mon Sep 17 00:00:00 2001 From: Rahim Date: Tue, 16 Jun 2026 19:34:22 -0700 Subject: [PATCH] feat(compiler): configurable @property handling for --tw-* slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind registers internal slots (`--tw-content`, `--tw-shadow`, …) via `@property` rules that supply their defaults. `decompose` dropped those rules, so utilities that reference a slot without setting it locally — notably `after:*`/`before:*` emitting `content: var(--tw-content)` — left a dangling reference that resolves to nothing and suppresses the pseudo-element. `decompose` now captures the `@property` registrations, and `emitCss` gains a `properties` option to handle them, configurably: - `mode: 'emit'` — emit `@property` rules for referenced slots, preserving Tailwind's typed defaults. - `mode: 'inline'` — substitute each slot's `initial-value` into the values that reference it (a superset of `inlineVars` for the matched slots), so the output is fully self-contained. - `resolve(name, captured)` hook — override or supply a slot's config (initial-value / syntax / inherits), or register one Tailwind didn't. - `match` — restrict handling to matching names (default `/^--tw-/`). `tailwindPlugin` forwards `properties` through its `onCss` path. Adds 9 tests (capture, emit, inline, resolve override, match, back-compat, plugin e2e). Compiler suite: 178 passing. --- .claude/plans/compiler-rebase-audit.md | 6 +- packages/compiler/src/tailwind/decompose.ts | 54 ++++++- packages/compiler/src/tailwind/emit.ts | 141 ++++++++++++++++-- packages/compiler/src/tailwind/index.ts | 19 ++- packages/compiler/src/tailwind/plugin.ts | 22 ++- .../src/tailwind/tests/decompose.test.ts | 19 +++ .../compiler/src/tailwind/tests/emit.test.ts | 75 ++++++++++ .../src/tailwind/tests/plugin.test.ts | 23 +++ 8 files changed, 341 insertions(+), 18 deletions(-) diff --git a/.claude/plans/compiler-rebase-audit.md b/.claude/plans/compiler-rebase-audit.md index 589de0ab..92a7da24 100644 --- a/.claude/plans/compiler-rebase-audit.md +++ b/.claude/plans/compiler-rebase-audit.md @@ -83,8 +83,10 @@ Unrelated (drop from our set — belongs to main or a separate change): - ✅ Marker-class drop (`group`/`peer`) — preserved on the element. - ✅ Class-name collisions — `DiagnosticError` thrown on same-name/different-styles. - ✅ Undefined theme vars — `emitCss` emits a resolved theme block via `resolveThemeVar`. -- ⬜ Follow-up: `@property`-registered `--tw-*` slots (e.g. `--tw-content`) still - resolve to `undefined`; emit their `@property` initial values or inline them. +- ✅ `@property`-registered `--tw-*` slots (e.g. `--tw-content`) — `decompose` + now captures the `@property` rules; `emitCss`'s `properties` option handles + them in `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values) + mode, with a `resolve` hook + `match` filter for per-property config. ## Deferred porting backlog (NOT this pass) - Constrained-JSX for airplay/live/menu/playback-rate components + manifests. diff --git a/packages/compiler/src/tailwind/decompose.ts b/packages/compiler/src/tailwind/decompose.ts index 98fcb3bf..9450a185 100644 --- a/packages/compiler/src/tailwind/decompose.ts +++ b/packages/compiler/src/tailwind/decompose.ts @@ -28,10 +28,28 @@ export interface Variant { raw: string; } +/** + * A `@property` registration Tailwind appends alongside a utility (e.g. + * `@property --tw-content { syntax: "*"; inherits: false; initial-value: "" }`). + * Values are kept verbatim so they can be re-emitted or inlined as authored. + */ +export interface PropertyRule { + name: string; + syntax?: string; + inherits?: boolean; + initialValue?: string; +} + export interface UtilityCss { utility: string; declarations: readonly Declaration[]; variants: readonly Variant[]; + /** + * `@property` registrations Tailwind emitted for this utility. These supply + * the typed defaults for `--tw-*` slots referenced (but not set) by the + * declarations — see `emitCss`'s `properties` option. + */ + properties?: readonly PropertyRule[]; } /** @@ -63,7 +81,41 @@ export function decompose(utility: string, design: DesignSystem): UtilityCss | n const declarations: Declaration[] = []; walkNested(body, variants, declarations); - return { utility, declarations, variants }; + // Tailwind appends `@property --tw-* { ... }` registrations after the utility + // rule. They live at the top level of the compiled output (siblings of the + // utility class), so we scan the whole string rather than the rule body. + const properties = parseProperties(trimmed); + + return properties.length > 0 ? { utility, declarations, variants, properties } : { utility, declarations, variants }; +} + +/** 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; } /** diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts index 27ee968a..139134c7 100644 --- a/packages/compiler/src/tailwind/emit.ts +++ b/packages/compiler/src/tailwind/emit.ts @@ -89,6 +89,46 @@ export interface EmitCssOptions { * skin selector (e.g. `[data-skin="default-video"]`) to scope the variables. */ themeSelector?: string; + /** + * How to handle Tailwind's `@property`-registered slots (e.g. `--tw-content`, + * `--tw-shadow`) that the compiled rules reference but never set locally. + * Without this they resolve to nothing and break — e.g. `content: + * var(--tw-content)` suppresses the `::after`/`::before` box. + * + * Omit to leave them alone (current behavior). See `RegisteredPropertiesOptions`. + */ + properties?: RegisteredPropertiesOptions; +} + +/** A `@property` definition (sans name), as captured or overridden. */ +export interface PropertyDef { + /** `@property` syntax descriptor, e.g. `"*"`. Defaults to `"*"` when emitted. */ + syntax?: string; + /** Whether the property inherits. Defaults to `false` when emitted. */ + inherits?: boolean; + /** The registered default, e.g. `""` for `--tw-content`. */ + initialValue?: string; +} + +export interface RegisteredPropertiesOptions { + /** + * - `'emit'` — emit `@property` rules for referenced slots, preserving + * Tailwind's typed defaults (relies on browser `@property` support). + * - `'inline'` — substitute each slot's `initial-value` into the values that + * reference it (and drop any `--tw-*` setter declarations), so the output + * is fully self-contained. This is a superset of `inlineVars` for the + * matched slots. + */ + mode: 'emit' | 'inline'; + /** Which property names to handle. Defaults to `inlineVars`'s matcher, else `/^--tw-/`. */ + match?: RegExp; + /** + * Override or supply a property's definition. Receives the name and the + * definition captured from Tailwind's output (if any); return a new + * definition (merged in), or `undefined` to keep the captured one. Lets you + * fix an initial-value or register a slot Tailwind didn't. + */ + resolve?: (name: string, captured: PropertyDef | undefined) => PropertyDef | undefined; } /** @@ -108,11 +148,28 @@ export async function emitCss(opts: EmitCssOptions): Promise { const hoist = opts.hoist === false ? undefined : opts.hoist; const inlineVars = normalizeInlineMatcher(opts.inlineVars); + // Registered `@property` (--tw-*) handling. In 'inline' mode the slots' + // initial-values seed the inline pass as fallbacks (and the matcher widens to + // cover them, even when `inlineVars` was off). In 'emit' mode they're left in + // place to be emitted as `@property` rules. + const propMode = opts.properties?.mode; + const captured = collectPropertyDefs(opts.rules); + const resolveDef = (name: string): PropertyDef | undefined => { + const cap = captured.get(name); + return opts.properties?.resolve?.(name, cap) ?? cap; + }; + const propMatch = opts.properties?.match ?? inlineVars ?? /^--tw-/; + const inlineMatch = propMode === 'inline' ? propMatch : inlineVars; + const fallbacks = propMode === 'inline' ? buildFallbackSetters(captured, propMatch, resolveDef) : undefined; + const emitProperties = (css: string): string => + propMode === 'emit' ? buildPropertyBlocks(css, propMatch, resolveDef) : ''; + if (mode === 'merged') { const base = await bundleBaseCss(opts.baseCss ?? [], configDir); - const body = composeRules(opts.rules, hoist, inlineVars); + const body = composeRules(opts.rules, hoist, inlineMatch, fallbacks); const theme = buildThemeBlock(body, opts.resolveThemeVar, opts.themeSelector); - return { kind: 'merged', css: joinSections(base, theme, body) }; + const properties = emitProperties(body); + return { kind: 'merged', css: joinSections(base, properties, theme, body) }; } // Split mode: group rules by `bag`. @@ -130,14 +187,17 @@ export async function emitCss(opts: EmitCssOptions): Promise { const sortedBags = [...byBag.keys()].sort(); for (const bagName of sortedBags) { const bagRules = byBag.get(bagName)!; - bags.set(bagName, composeRules(bagRules, undefined, inlineVars)); + bags.set(bagName, composeRules(bagRules, undefined, inlineMatch, fallbacks)); importLines.push(`@import "./${bagName || 'index'}.css";`); } const base = await bundleBaseCss(opts.baseCss ?? [], configDir); - // 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')); + // Theme variables and @property rules go in `index` (it loads first), + // resolved against every bag. + const allBagsCss = [...bags.values()].join('\n'); + const theme = buildThemeBlock(allBagsCss, opts.resolveThemeVar, opts.themeSelector); + const properties = emitProperties(allBagsCss); + const index = joinSections(base, properties, theme, importLines.join('\n')); return { kind: 'split', index, bags }; } @@ -203,6 +263,60 @@ function collectDefinedVars(css: string): Set { return out; } +/* ───────────────────────────────────────────────────────────────────────── + * Registered `@property` slots + * ───────────────────────────────────────────────────────────────────────── */ + +/** Aggregate the `@property` defs captured across every rule (first wins). */ +function collectPropertyDefs(rules: readonly CompiledRule[]): Map { + const out = new Map(); + for (const rule of rules) { + for (const p of rule.utility.properties ?? []) { + if (out.has(p.name)) continue; + const { name, ...def } = p; + out.set(name, def); + } + } + return out; +} + +/** Build the `name → initial-value` fallback map for `mode: 'inline'`. */ +function buildFallbackSetters( + captured: Map, + match: RegExp, + resolveDef: (name: string) => PropertyDef | undefined +): Map { + const out = new Map(); + for (const name of captured.keys()) { + if (!match.test(name)) continue; + const def = resolveDef(name); + if (def?.initialValue !== undefined) out.set(name, def.initialValue); + } + return out; +} + +/** + * Build `@property` rules for every matching slot the CSS references but + * doesn't itself declare. Descriptors default to `syntax: "*"` / `inherits: + * false` when a resolved def omits them. + */ +function buildPropertyBlocks( + css: string, + match: RegExp, + resolveDef: (name: string) => PropertyDef | undefined +): string { + const referenced = [...collectReferencedVars(css)].filter((name) => match.test(name)).sort(); + const blocks: string[] = []; + for (const name of referenced) { + const def = resolveDef(name); + if (!def) continue; + const lines = [` syntax: ${def.syntax ?? '"*"'};`, ` inherits: ${def.inherits ?? false};`]; + if (def.initialValue !== undefined) lines.push(` initial-value: ${def.initialValue};`); + blocks.push(`@property ${name} {\n${lines.join('\n')}\n}`); + } + return blocks.join('\n\n'); +} + /** Internal: read each `baseCss` file via Lightning CSS, return concatenated string. */ async function bundleBaseCss(paths: readonly string[], configDir: string): Promise { if (paths.length === 0) return ''; @@ -244,7 +358,8 @@ interface EmitUnit { function composeRules( rules: readonly CompiledRule[], hoist: HoistOptions | undefined, - inlineVars: RegExp | undefined + inlineVars: RegExp | undefined, + fallbackSetters?: Map ): string { // Step 1: turn each CompiledRule into one EmitUnit. const units: EmitUnit[] = []; @@ -284,7 +399,7 @@ function composeRules( // consumers, then drop the matching declarations. Pulls setters from the // consumer's own rule plus the hoist root (when set) so consumers in // separate rules from the original setter still resolve. - if (inlineVars) applyInline(merged, inlineVars, hoist?.rootSelector); + if (inlineVars) applyInline(merged, inlineVars, hoist?.rootSelector, fallbackSetters); // Step 3: collapse units that share the same (atRulePath, declarations) into // a comma-separated selector list. Sort the declaration set to make the @@ -446,7 +561,8 @@ function normalizeInlineMatcher(opt: true | RegExp | undefined): RegExp | undefi function applyInline( merged: Map }>, match: RegExp, - hoistRootSelector: string | undefined + hoistRootSelector: string | undefined, + fallbackSetters?: Map ): void { // Pull root-scope setters once. They serve as fallback when a consumer // rule doesn't declare the property locally. @@ -473,8 +589,11 @@ function applyInline( } } - // Effective setters: rule-local first, hoist root as fallback. - const setters = new Map(rootSetters); + // Effective setters, narrowest last: registered `@property` initial-values + // (lowest), then hoist root, then rule-local. Initial-values resolve + // references to slots no rule ever sets (e.g. `content: var(--tw-content)`). + const setters = new Map(fallbackSetters); + for (const [name, value] of rootSetters) setters.set(name, value); for (const [name, value] of localSetters) setters.set(name, value); resolveSettersInPlace(setters, match); diff --git a/packages/compiler/src/tailwind/index.ts b/packages/compiler/src/tailwind/index.ts index 6056de0a..ad3d0c3c 100644 --- a/packages/compiler/src/tailwind/index.ts +++ b/packages/compiler/src/tailwind/index.ts @@ -1,6 +1,21 @@ -export { type Declaration, decompose, type UtilityCss, type Variant, type VariantKind } from './decompose'; +export { + type Declaration, + decompose, + type PropertyRule, + type UtilityCss, + type Variant, + type VariantKind, +} from './decompose'; export { type DesignSystem, loadDesignSystem } from './design-system'; -export { type CompiledRule, type EmitCssOptions, type EmittedCss, emitCss, type HoistOptions } from './emit'; +export { + type CompiledRule, + type EmitCssOptions, + type EmittedCss, + emitCss, + type HoistOptions, + type PropertyDef, + type RegisteredPropertiesOptions, +} from './emit'; export { clearTokenModuleCache, EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; export { type DeriveClassNameOptions, diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index a767f20d..70b8ef11 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -5,7 +5,13 @@ 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 { + type CompiledRule, + type EmittedCss, + emitCss, + type HoistOptions, + type RegisteredPropertiesOptions, +} from './emit'; import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming'; @@ -83,6 +89,17 @@ export interface TailwindPluginOptions { * driving `emitCss` themselves should pass the same value through. */ inlineVars?: true | RegExp; + /** + * Handle Tailwind's `@property`-registered slots (`--tw-content`, etc.) that + * are referenced but never set, so the output isn't broken (e.g. + * `content: var(--tw-content)`). See `RegisteredPropertiesOptions` — choose + * `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in), + * with an optional `resolve` hook for the per-property config. + * + * Forwarded to the internal `emitCss` call when `onCss` is set; consumers + * driving `emitCss` themselves should pass the same value through. + */ + properties?: RegisteredPropertiesOptions; } /** @@ -127,7 +144,7 @@ function inlinedPlugin(options: TailwindPluginOptions): ts.TransformerFactory { - const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars } = options; + const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars, properties } = options; const env = buildTokenEnv(options.sourcePath); @@ -241,6 +258,7 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory ...(emit ?? {}), ...(hoistVars !== undefined ? { hoist: hoistVars } : {}), ...(inlineVars !== undefined ? { inlineVars } : {}), + ...(properties ? { properties } : {}), resolveThemeVar: (name) => design.resolveThemeVar(name), ...(themeSelector ? { themeSelector } : {}), }) diff --git a/packages/compiler/src/tailwind/tests/decompose.test.ts b/packages/compiler/src/tailwind/tests/decompose.test.ts index 04eacbd3..ebac3328 100644 --- a/packages/compiler/src/tailwind/tests/decompose.test.ts +++ b/packages/compiler/src/tailwind/tests/decompose.test.ts @@ -93,6 +93,25 @@ describe('decompose — variants', () => { }); }); +describe('decompose — @property registrations', () => { + it('captures the @property rule Tailwind appends for a slot', () => { + const r = decompose('before:content-["x"]', design); + expect(r).not.toBeNull(); + const content = r!.properties?.find((p) => p.name === '--tw-content'); + expect(content).toEqual({ + name: '--tw-content', + syntax: '"*"', + inherits: false, + initialValue: '""', + }); + }); + + it('omits `properties` when a utility registers none', () => { + const r = decompose('flex', design); + expect(r!.properties).toBeUndefined(); + }); +}); + describe('decompose — caching', () => { it('returns the same compiled CSS on repeat lookups (DesignSystem cache)', () => { const a = design.compileUtility('flex'); diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts index cbbf95b9..6f76b6be 100644 --- a/packages/compiler/src/tailwind/tests/emit.test.ts +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -532,3 +532,78 @@ describe('emitCss — theme variables', () => { expect(out.css).not.toMatch(/:root/); }); }); + +describe('emitCss — registered @property slots', () => { + // The `after:absolute` pattern: an `::after` rule that references + // `--tw-content` but never sets it, relying on Tailwind's @property default. + const contentRule = (): CompiledRule => ({ + className: 'card', + utility: { + utility: 'after:absolute', + declarations: [ + { property: 'content', value: 'var(--tw-content)' }, + { property: 'position', value: 'absolute' }, + ], + variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }], + properties: [{ name: '--tw-content', syntax: '"*"', inherits: false, initialValue: '""' }], + }, + }); + + it("mode 'inline' substitutes the initial-value and drops the dangling reference", async () => { + const out = await emitCss({ rules: [contentRule()], properties: { mode: 'inline' } }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain(collapse('content: "";')); + expect(out.css).not.toMatch(/var\(--tw-content\)/); + }); + + it("mode 'emit' emits an @property rule and keeps the reference", async () => { + const out = await emitCss({ rules: [contentRule()], properties: { mode: 'emit' } }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain( + collapse('@property --tw-content {\n syntax: "*";\n inherits: false;\n initial-value: "";\n}') + ); + expect(out.css).toMatch(/var\(--tw-content\)/); + }); + + it('lets the resolve hook override the initial-value (inline)', async () => { + const out = await emitCss({ + rules: [contentRule()], + properties: { + mode: 'inline', + resolve: (name, captured) => (name === '--tw-content' ? { ...captured, initialValue: '"!"' } : undefined), + }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain(collapse('content: "!";')); + }); + + it('lets the resolve hook override descriptors (emit)', async () => { + const out = await emitCss({ + rules: [contentRule()], + properties: { + mode: 'emit', + resolve: () => ({ syntax: '""', inherits: true, initialValue: '0px' }), + }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(collapse(out.css)).toContain( + collapse('@property --tw-content {\n syntax: "";\n inherits: true;\n initial-value: 0px;\n}') + ); + }); + + it('honours the match filter (leaves non-matching slots alone)', async () => { + const out = await emitCss({ + rules: [contentRule()], + properties: { mode: 'inline', match: /^--brand-/ }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/var\(--tw-content\)/); + }); + + it('leaves slots 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'); + expect(out.css).toMatch(/var\(--tw-content\)/); + expect(out.css).not.toMatch(/@property/); + }); +}); diff --git a/packages/compiler/src/tailwind/tests/plugin.test.ts b/packages/compiler/src/tailwind/tests/plugin.test.ts index 04b38f4b..6790d8d7 100644 --- a/packages/compiler/src/tailwind/tests/plugin.test.ts +++ b/packages/compiler/src/tailwind/tests/plugin.test.ts @@ -464,4 +464,27 @@ function App(){ return ; }`; const css = await cssPromise; expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/); }); + + it('forwards the `properties` option (inline) so --tw-content resolves', async () => { + // `after:absolute` emits `content: var(--tw-content)` with no setter. + const source = `function App(){ return ; }`; + const cssPromise = new Promise((resolve) => { + compile(source, { + target: 'react', + plugins: [ + tailwindPlugin({ + design, + target: 'vanilla-css', + properties: { mode: 'inline' }, + onCss: (out) => { + if (out.kind === 'merged') resolve(out.css); + }, + }), + ], + }); + }); + const css = await cssPromise; + expect(css).not.toMatch(/var\(--tw-content\)/); + expect(collapse(css)).toContain(collapse('content: "";')); + }); });