mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(compiler)!: branch tailwind utility analysis
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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, string>
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ts.SourceFile> {
|
||||
@@ -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));
|
||||
|
||||
@@ -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: '""' }],
|
||||
},
|
||||
|
||||
+33
-15
@@ -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');
|
||||
+184
-88
@@ -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 `&<selector> { ... }` 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--;
|
||||
Reference in New Issue
Block a user