From 9e7d3da397579947ba21969dc61357906fed9814 Mon Sep 17 00:00:00 2001 From: Rahim Date: Tue, 23 Jun 2026 17:24:05 -0700 Subject: [PATCH] refactor(compiler)!: branch tailwind utility analysis --- .../compiler/src/tailwind/design-system.ts | 2 +- packages/compiler/src/tailwind/emit.ts | 18 +- packages/compiler/src/tailwind/index.ts | 17 +- packages/compiler/src/tailwind/plugin.ts | 6 +- .../compiler/src/tailwind/tests/emit.test.ts | 46 ++- ...{decompose.test.ts => utility-css.test.ts} | 48 +++- .../tailwind/{decompose.ts => utility-css.ts} | 272 ++++++++++++------ 7 files changed, 286 insertions(+), 123 deletions(-) rename packages/compiler/src/tailwind/tests/{decompose.test.ts => utility-css.test.ts} (65%) rename packages/compiler/src/tailwind/{decompose.ts => utility-css.ts} (50%) diff --git a/packages/compiler/src/tailwind/design-system.ts b/packages/compiler/src/tailwind/design-system.ts index 644d68b1..3d35b9e1 100644 --- a/packages/compiler/src/tailwind/design-system.ts +++ b/packages/compiler/src/tailwind/design-system.ts @@ -6,7 +6,7 @@ import { __unstable__loadDesignSystem } from 'tailwindcss'; /** * A loaded Tailwind v4 design system. Wraps Tailwind's * `__unstable__loadDesignSystem` return value with a small surface focused on - * what `decompose` needs: + * what `analyzeUtility` needs: * * - `compileUtility(name)` — compile a single utility class to CSS, or * `null` if Tailwind doesn't recognize it. diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts index bce296ad..3df66d7a 100644 --- a/packages/compiler/src/tailwind/emit.ts +++ b/packages/compiler/src/tailwind/emit.ts @@ -1,6 +1,6 @@ import { isAbsolute, resolve } from 'node:path'; import { bundleAsync } from 'lightningcss'; -import type { Declaration, UtilityCss } from './decompose'; +import type { Declaration, UtilityCss, UtilityCssBranch } from './utility-css'; /** A compiled rule: a class name + the utility's declarations and variants. */ export interface CompiledRule { @@ -421,10 +421,10 @@ function composeRules( inlineVars: VariableMatcher | undefined, fallbackSetters?: Map ): string { - // Step 1: turn each CompiledRule into one EmitUnit. + // Step 1: turn each CompiledRule branch into one EmitUnit. const units: EmitUnit[] = []; for (const rule of rules) { - units.push(buildEmitUnit(rule)); + units.push(...buildEmitUnits(rule)); } // Step 2: merge units by (atRulePath, selector). Dedupe declarations by @@ -815,11 +815,15 @@ function findTopLevelComma(s: string): number { return -1; } -function buildEmitUnit(rule: CompiledRule): EmitUnit { +function buildEmitUnits(rule: CompiledRule): EmitUnit[] { + return rule.utility.branches.map((branch) => buildEmitUnit(rule.className, branch)); +} + +function buildEmitUnit(className: string, branch: UtilityCssBranch): EmitUnit { const atRulePath: string[] = []; let selectorTail = ''; - for (const v of rule.utility.variants) { + for (const v of branch.variants) { if (v.atRule) { atRulePath.push(`@${v.atRule.name} ${v.atRule.params}`.trim()); } else if (v.selector) { @@ -829,8 +833,8 @@ function buildEmitUnit(rule: CompiledRule): EmitUnit { return { atRulePath, - selector: `.${rule.className}${selectorTail}`, - declarations: rule.utility.declarations, + selector: `.${className}${selectorTail}`, + declarations: branch.declarations, }; } diff --git a/packages/compiler/src/tailwind/index.ts b/packages/compiler/src/tailwind/index.ts index e8a766d9..0e9e7ff4 100644 --- a/packages/compiler/src/tailwind/index.ts +++ b/packages/compiler/src/tailwind/index.ts @@ -1,11 +1,3 @@ -export { - type Declaration, - decompose, - type PropertyRule, - type UtilityCss, - type Variant, - type VariantKind, -} from './decompose'; export { type DesignSystem, loadDesignSystem } from './design-system'; export { type CompiledRule, @@ -36,3 +28,12 @@ export { type TailwindVarsOptions, tailwind, } from './plugin'; +export { + analyzeUtility, + type Declaration, + type PropertyRule, + type UtilityCss, + type UtilityCssBranch, + type Variant, + type VariantKind, +} from './utility-css'; diff --git a/packages/compiler/src/tailwind/plugin.ts b/packages/compiler/src/tailwind/plugin.ts index 80c356a1..aa0af71f 100644 --- a/packages/compiler/src/tailwind/plugin.ts +++ b/packages/compiler/src/tailwind/plugin.ts @@ -5,7 +5,6 @@ import type { CompilerContext, CompilerPlugin } from '../config'; import { diagnosticLocationFromNode } from '../diagnostics'; import { tagName } from '../jsx'; import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles'; -import { decompose, type UtilityCss } from './decompose'; import { type DesignSystem, loadDesignSystem } from './design-system'; import { type CompiledRule, @@ -16,6 +15,7 @@ import { } from './emit'; import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator'; import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type ResolveName } from './naming'; +import { analyzeUtility, type UtilityCss } from './utility-css'; /** Styling mode for Tailwind-backed className handling. */ export type TailwindMode = @@ -137,7 +137,7 @@ export function tailwind(options: TailwindOptions = {}): CompilerPlugin { /** * TS transformer that rewrites JSX `className` attributes per the chosen * Tailwind target. Built on top of `analyzeStyles` (generic JSX walker) + - * `decompose` + `deriveClassName` + `emitCss`. Token references are resolved + * `analyzeUtility` + `deriveClassName` + `emitCss`. Token references are resolved * by statically evaluating the imported token module — see `evaluator.ts`. */ export function tailwindPlugin(options: TailwindTransformOptions): ts.TransformerFactory { @@ -225,7 +225,7 @@ function vanillaCssPlugin( // Tailwind doesn't recognize — dropping them would silently break every // descendant `group-*` / `peer-*` variant that targets the marker. const handleUtility = (utility: string): void => { - const css = decompose(utility, design); + const css = analyzeUtility(utility, design); if (css && css.declarations.length > 0) { ruleUtilities.push(utility); rules.push(buildCompiledRule(derived.className, css, segments, resolve?.group)); diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts index 083989a9..e10f4e97 100644 --- a/packages/compiler/src/tailwind/tests/emit.test.ts +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -13,10 +13,25 @@ function rule( variants: any[] = [], group?: string ): CompiledRule { - const utility = { utility: 'mock', declarations, variants }; + const utility = { utility: 'mock', branches: [{ declarations, variants }], declarations, variants }; return group === undefined ? { className, utility } : { className, utility, group }; } +function branchedRule( + className: string, + branches: { declarations: { property: string; value: string }[]; variants: any[] }[] +): CompiledRule { + return { + className, + utility: { + utility: 'mock', + branches, + declarations: branches.flatMap((branch) => branch.declarations), + variants: branches[0]?.variants ?? [], + }, + }; +} + describe('emitCss — merged mode', () => { it('emits a single rule for one CompiledRule', async () => { const out = await emitCss({ @@ -82,6 +97,26 @@ describe('emitCss — merged mode', () => { ); }); + it('emits each utility branch with its own variant path', async () => { + const media = { + kind: 'media' as const, + atRule: { name: 'media', params: '(width >= 40rem)' }, + raw: '@media (width >= 40rem)', + }; + const out = await emitCss({ + rules: [ + branchedRule('container', [ + { declarations: [{ property: 'width', value: '100%' }], variants: [] }, + { declarations: [{ property: 'max-width', value: '40rem' }], variants: [media] }, + ]), + ], + }); + expect(out.kind === 'merged' && collapse(out.css)).toContain(collapse('.container{width:100%;}')); + expect(out.kind === 'merged' && collapse(out.css)).toContain( + collapse('@media (width >= 40rem){.container{max-width:40rem;}}') + ); + }); + it('sorts declarations alphabetically for stable output', async () => { const out = await emitCss({ rules: [ @@ -560,6 +595,15 @@ describe('emitCss — registered @property variables', () => { { property: 'content', value: 'var(--tw-content)' }, { property: 'position', value: 'absolute' }, ], + branches: [ + { + declarations: [ + { property: 'content', value: 'var(--tw-content)' }, + { property: 'position', value: 'absolute' }, + ], + variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }], + }, + ], variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }], properties: [{ name: '--tw-content', syntax: '"*"', inherits: false, initialValue: '""' }], }, diff --git a/packages/compiler/src/tailwind/tests/decompose.test.ts b/packages/compiler/src/tailwind/tests/utility-css.test.ts similarity index 65% rename from packages/compiler/src/tailwind/tests/decompose.test.ts rename to packages/compiler/src/tailwind/tests/utility-css.test.ts index 55662dd6..90a73990 100644 --- a/packages/compiler/src/tailwind/tests/decompose.test.ts +++ b/packages/compiler/src/tailwind/tests/utility-css.test.ts @@ -2,9 +2,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; -import { decompose } from '../decompose'; import type { DesignSystem } from '../design-system'; import { loadDesignSystem } from '../design-system'; +import { analyzeUtility } from '../utility-css'; let design: DesignSystem; @@ -24,17 +24,18 @@ beforeAll(async () => { design = await loadDesignSystem(cssPath); }, 30_000); -describe('decompose — base utilities', () => { +describe('analyzeUtility — base utilities', () => { it('handles a plain utility', () => { - const r = decompose('flex', design); + const r = analyzeUtility('flex', design); expect(r).not.toBeNull(); expect(r!.utility).toBe('flex'); expect(r!.variants).toEqual([]); expect(r!.declarations).toEqual([{ property: 'display', value: 'flex' }]); + expect(r!.branches).toEqual([{ declarations: [{ property: 'display', value: 'flex' }], variants: [] }]); }); it('handles a utility with multiple declarations', () => { - const r = decompose('p-4', design); + const r = analyzeUtility('p-4', design); expect(r).not.toBeNull(); // Tailwind v4 emits `padding: calc(var(--spacing) * 4);`. const props = r!.declarations.map((d) => d.property); @@ -42,13 +43,13 @@ describe('decompose — base utilities', () => { }); it('returns null for unknown utilities', () => { - expect(decompose('not-a-real-utility', design)).toBeNull(); + expect(analyzeUtility('not-a-real-utility', design)).toBeNull(); }); }); -describe('decompose — variants', () => { +describe('analyzeUtility — variants', () => { it('captures :hover as a pseudo variant', () => { - const r = decompose('hover:opacity-100', design); + const r = analyzeUtility('hover:opacity-100', design); expect(r).not.toBeNull(); // Tailwind v4 nests `&:hover` *inside* `@media (hover: hover)` so we // see both — pseudo for the selector tail, media for the hover gate. @@ -62,14 +63,14 @@ describe('decompose — variants', () => { }); it('captures :focus-visible as a pseudo variant', () => { - const r = decompose('focus-visible:outline-current', design); + const r = analyzeUtility('focus-visible:outline-current', design); expect(r).not.toBeNull(); expect(r!.variants[0]!.kind).toBe('pseudo'); expect(r!.variants[0]!.selector).toMatch(/:focus-visible/); }); it('captures @media-style at-rule wrappers', () => { - const r = decompose('motion-reduce:opacity-50', design); + const r = analyzeUtility('motion-reduce:opacity-50', design); expect(r).not.toBeNull(); const media = r!.variants.find((v) => v.kind === 'media'); expect(media).toBeDefined(); @@ -77,7 +78,7 @@ describe('decompose — variants', () => { }); it('captures attribute-selector variants from data-[…]', () => { - const r = decompose('data-[state=open]:opacity-100', design); + const r = analyzeUtility('data-[state=open]:opacity-100', design); expect(r).not.toBeNull(); const attr = r!.variants.find((v) => v.kind === 'attribute'); expect(attr).toBeDefined(); @@ -85,7 +86,7 @@ describe('decompose — variants', () => { }); it('captures group-data variants', () => { - const r = decompose('group-data-paused:opacity-100', design); + const r = analyzeUtility('group-data-paused:opacity-100', design); expect(r).not.toBeNull(); const grp = r!.variants.find((v) => v.kind === 'group'); expect(grp).toBeDefined(); @@ -93,9 +94,26 @@ describe('decompose — variants', () => { }); }); -describe('decompose — @property registrations', () => { +describe('analyzeUtility — branches', () => { + it('preserves sibling declaration branches from one utility', () => { + const r = analyzeUtility('container', design); + expect(r).not.toBeNull(); + expect(r!.branches.length).toBeGreaterThan(1); + expect(r!.branches).toContainEqual({ declarations: [{ property: 'width', value: '100%' }], variants: [] }); + expect(r!.branches.some((branch) => branch.variants.some((variant) => variant.kind === 'media'))).toBe(true); + }); + + it.each(['{', '}'])('handles arbitrary content containing %s', (brace) => { + const r = analyzeUtility(`before:content-["${brace}"]`, design); + expect(r).not.toBeNull(); + expect(r!.declarations.find((declaration) => declaration.property === '--tw-content')?.value).toContain(brace); + expect(r!.declarations).toContainEqual({ property: 'content', value: 'var(--tw-content)' }); + }); +}); + +describe('analyzeUtility — @property registrations', () => { it('captures the @property rule Tailwind appends for a registered variable', () => { - const r = decompose('before:content-["x"]', design); + const r = analyzeUtility('before:content-["x"]', design); expect(r).not.toBeNull(); const content = r!.properties?.find((p) => p.name === '--tw-content'); expect(content).toEqual({ @@ -107,12 +125,12 @@ describe('decompose — @property registrations', () => { }); it('omits `properties` when a utility registers none', () => { - const r = decompose('flex', design); + const r = analyzeUtility('flex', design); expect(r!.properties).toBeUndefined(); }); }); -describe('decompose — caching', () => { +describe('analyzeUtility — caching', () => { it('returns the same compiled CSS on repeat lookups (DesignSystem cache)', () => { const a = design.compileUtility('flex'); const b = design.compileUtility('flex'); diff --git a/packages/compiler/src/tailwind/decompose.ts b/packages/compiler/src/tailwind/utility-css.ts similarity index 50% rename from packages/compiler/src/tailwind/decompose.ts rename to packages/compiler/src/tailwind/utility-css.ts index 40817c33..7a3b9900 100644 --- a/packages/compiler/src/tailwind/decompose.ts +++ b/packages/compiler/src/tailwind/utility-css.ts @@ -6,11 +6,12 @@ export interface Declaration { value: string; } -/** Variant kinds we recognize when decomposing a utility. */ +/** Variant kinds we recognize when analyzing a utility. */ export type VariantKind = | 'media' // @media (...) wrapper | 'container' // @container (...) wrapper | 'supports' // @supports (...) wrapper + | 'at-rule' // any other at-rule wrapper | 'pseudo' // selector tail like `:hover`, `::before`, `:focus-visible` | 'attribute' // `[data-x]`, `[data-x=y]` | 'group' // `:is(:where(.group)... *)` (Tailwind v4 group-* variant) @@ -28,6 +29,13 @@ export interface Variant { raw: string; } +export interface UtilityCssBranch { + /** Declarations emitted together under this branch's variants. */ + declarations: readonly Declaration[]; + /** Variant path for these declarations. */ + variants: readonly Variant[]; +} + /** * A `@property` registration Tailwind appends alongside a utility (e.g. * `@property --tw-content { syntax: "*"; inherits: false; initial-value: "" }`). @@ -42,7 +50,11 @@ export interface PropertyRule { export interface UtilityCss { utility: string; + /** Branches emitted by this utility, preserving sibling rule/at-rule structure. */ + branches: readonly UtilityCssBranch[]; + /** Flattened declarations across every branch. */ declarations: readonly Declaration[]; + /** Variants for the first branch, retained for simple utility inspection. */ variants: readonly Variant[]; /** * `@property` registrations Tailwind emitted for this utility. These supply @@ -53,40 +65,40 @@ export interface UtilityCss { } /** - * Decompose a Tailwind v4 utility into its declarations + variant chain. + * Analyze a Tailwind v4 utility into declarations, variant branches, and + * registered properties. * * Tailwind v4 emits CSS in nested form: the outer rule is the utility class - * selector, and any variants are nested inside using `&:hover`, `@media (...)`, - * `&[data-x]`, etc. Multiple variants on a single utility produce multiply - * nested blocks. We walk the nesting tree, collecting one `Variant` per - * nesting level (outermost → innermost), and read the innermost declarations. + * selector, and variants appear as nested selector rules or at-rule blocks. + * One utility can produce sibling branches (`container` emits root declarations + * plus multiple media branches), so the branch model preserves each declaration + * group with its own variant path. * * Returns `null` for utilities Tailwind doesn't recognize. */ -export function decompose(utility: string, design: DesignSystem): UtilityCss | null { +export function analyzeUtility(utility: string, design: DesignSystem): UtilityCss | null { const css = design.compileUtility(utility); if (!css) return null; const trimmed = css.trim(); - const outerOpen = trimmed.indexOf('{'); + const outerOpen = findBlockOpen(trimmed, 0); if (outerOpen === -1) return null; const outerBlock = readBalancedBlock(trimmed, outerOpen); if (!outerBlock) return null; - // The outer selector is the escaped utility class. We walk the body to - // collect variants from each nesting level + the innermost declarations. - const body = outerBlock.inner; - - const variants: Variant[] = []; - const declarations: Declaration[] = []; - walkNested(body, variants, declarations); + const branches: UtilityCssBranch[] = []; + walkNested(outerBlock.inner, [], branches); // 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); + const declarations = branches.flatMap((branch) => branch.declarations); + const variants = branches[0]?.variants ?? []; - return properties.length > 0 ? { utility, declarations, variants, properties } : { utility, declarations, variants }; + return properties.length > 0 + ? { utility, branches, declarations, variants, properties } + : { utility, branches, declarations, variants }; } /** Parse every `@property --name { ... }` block from a compiled utility. */ @@ -118,46 +130,42 @@ function parseProperties(css: string): PropertyRule[] { return out; } -/** - * Walk a CSS body collecting declarations directly at this level and - * recursing into nested at-rules / `&`-prefixed selector rules. Each - * recursion level pushes one `Variant` describing the nesting it represents. - * - * - Pure declarations (`prop: value;`) at the current level go into `declarations`. - * - Nested `& { ... }` blocks add a selector variant and recurse. - * - Nested `@media (...) { ... }`, `@container (...)`, `@supports (...)` add - * the corresponding at-rule variant and recurse. - */ -function walkNested(body: string, variants: Variant[], declarations: Declaration[]): void { +function walkNested(body: string, variants: readonly Variant[], branches: UtilityCssBranch[]): void { let i = 0; const n = body.length; + let declarations: Declaration[] = []; + + const flushDeclarations = (): void => { + if (declarations.length === 0) return; + branches.push({ declarations, variants }); + declarations = []; + }; while (i < n) { while (i < n && /[\s;]/.test(body[i]!)) i++; if (i >= n) break; if (body[i] === '@') { - const headerEnd = body.indexOf('{', i); + flushDeclarations(); + + const headerEnd = findBlockOpen(body, i); if (headerEnd === -1) break; const header = body.slice(i, headerEnd).trim(); - const m = header.match(/^@([\w-]+)\s*([\s\S]*)$/); - if (!m) break; - const [, name, params] = m; + const match = header.match(/^@([\w-]+)\s*([\s\S]*)$/); + if (!match) break; + const [, name, params] = match; const block = readBalancedBlock(body, headerEnd); if (!block) break; - variants.push({ - kind: name === 'media' ? 'media' : name === 'container' ? 'container' : 'supports', - atRule: { name: name!, params: params!.trim() }, - raw: `@${name} ${params}`, - }); - walkNested(block.inner.trim(), variants, declarations); + walkNested(block.inner.trim(), [...variants, atRuleVariant(name!, params!.trim())], branches); i = block.end + 1; continue; } if (body[i] === '&') { - const headerEnd = body.indexOf('{', i); + flushDeclarations(); + + const headerEnd = findBlockOpen(body, i); if (headerEnd === -1) break; // Preserve the leading character after `&` so `& *` (descendant) doesn't // get folded down to bare `*` (which classifies as 'parent'). @@ -168,64 +176,98 @@ function walkNested(body: string, variants: Variant[], declarations: Declaration const block = readBalancedBlock(body, headerEnd); if (!block) break; - variants.push(classifySelectorTail(selectorTail)); - walkNested(block.inner.trim(), variants, declarations); + walkNested(block.inner.trim(), [...variants, classifySelectorTail(selectorTail)], branches); i = block.end + 1; continue; } - // Plain declaration `prop: value;` — read with bracket-balance awareness - // so `var()`, `calc()`, `oklch(from var(--x) ...)` survive intact. - const propStart = i; - let colonIdx = -1; - while (i < n && body[i] !== ';' && body[i] !== '{') { - if (body[i] === ':' && colonIdx === -1) colonIdx = i; - i++; + const declaration = readDeclaration(body, i); + if (!declaration) break; + if (declaration.declaration) declarations.push(declaration.declaration); + i = declaration.end; + } + + flushDeclarations(); +} + +function atRuleVariant(name: string, params: string): Variant { + return { + kind: + name === 'media' ? 'media' : name === 'container' ? 'container' : name === 'supports' ? 'supports' : 'at-rule', + atRule: { name, params }, + raw: params ? `@${name} ${params}` : `@${name}`, + }; +} + +interface ReadDeclarationResult { + declaration?: Declaration | undefined; + end: number; +} + +function readDeclaration(body: string, start: number): ReadDeclarationResult | null { + const n = body.length; + let i = start; + let colonIdx = -1; + + while (i < n) { + const c = body[i]!; + if (c === ':' && colonIdx === -1) { + colonIdx = i; + break; } - if (body[i] === '{') break; - if (colonIdx === -1) { - if (body[i] === ';') i++; + if (c === ';') return { end: i + 1 }; + if (c === '{') return null; + i++; + } + if (colonIdx === -1) return { end: i }; + + const property = body.slice(start, colonIdx).trim(); + let valueEnd = colonIdx + 1; + let depth = 0; + let quote: string | null = null; + while (valueEnd < n) { + const c = body[valueEnd]!; + if (quote) { + if (c === '\\') { + valueEnd += 2; + continue; + } + if (c === quote) quote = null; + valueEnd++; continue; } - - const property = body.slice(propStart, colonIdx).trim(); - let valueEnd = colonIdx + 1; - let depth = 0; - let quote: string | null = null; - while (valueEnd < n) { - const c = body[valueEnd]!; - if (quote) { - if (c === '\\') { - valueEnd += 2; - continue; - } - if (c === quote) quote = null; - valueEnd++; - continue; - } - if (c === '"' || c === "'") { - quote = c; - valueEnd++; - continue; - } - if (c === '(' || c === '{' || c === '[') { - depth++; - valueEnd++; - continue; - } - if (c === ')' || c === '}' || c === ']') { - depth--; - valueEnd++; - continue; - } - if (c === ';' && depth === 0) break; - valueEnd++; + if (c === '/' && body[valueEnd + 1] === '*') { + const end = body.indexOf('*/', valueEnd + 2); + if (end === -1) return null; + valueEnd = end + 2; + continue; } - const value = body.slice(colonIdx + 1, valueEnd).trim(); - if (property && value) declarations.push({ property, value }); - i = valueEnd; - if (body[i] === ';') i++; + if (c === '"' || c === "'") { + quote = c; + valueEnd++; + continue; + } + if (c === '\\') { + valueEnd += 2; + continue; + } + if (c === '(' || c === '{' || c === '[') { + depth++; + valueEnd++; + continue; + } + if (c === ')' || c === '}' || c === ']') { + depth--; + valueEnd++; + continue; + } + if (c === ';' && depth === 0) break; + valueEnd++; } + + const value = body.slice(colonIdx + 1, valueEnd).trim(); + const end = body[valueEnd] === ';' ? valueEnd + 1 : valueEnd; + return property && value ? { declaration: { property, value }, end } : { end }; } interface BalancedBlock { @@ -233,10 +275,64 @@ interface BalancedBlock { end: number; } +function findBlockOpen(body: string, start: number): number { + let quote: string | null = null; + for (let i = start; i < body.length; i++) { + const c = body[i]!; + if (quote) { + if (c === '\\') { + i++; + continue; + } + if (c === quote) quote = null; + continue; + } + if (c === '\\') { + i++; + continue; + } + if (c === '/' && body[i + 1] === '*') { + const end = body.indexOf('*/', i + 2); + if (end === -1) return -1; + i = end + 1; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + if (c === '{') return i; + } + return -1; +} + function readBalancedBlock(body: string, openIdx: number): BalancedBlock | null { let depth = 0; + let quote: string | null = null; for (let i = openIdx; i < body.length; i++) { - const c = body[i]; + const c = body[i]!; + if (quote) { + if (c === '\\') { + i++; + continue; + } + if (c === quote) quote = null; + continue; + } + if (c === '\\') { + i++; + continue; + } + if (c === '/' && body[i + 1] === '*') { + const end = body.indexOf('*/', i + 2); + if (end === -1) return null; + i = end + 1; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } if (c === '{') depth++; else if (c === '}') { depth--;