/** * `@quantize` CSS block parser and compiler. * * Parses custom `@quantize boundaryName { state { prop: value } }` blocks * from CSS source and compiles them into native `@container` queries using * resolved `BoundaryDef` thresholds. * * @module */ import { Diagnostics, inputToSource, type Boundary } from '@czap/core'; import { CSSCompiler, type CSSAtRuleGroup, type CSSRule, type CSSStateInput } from '@czap/compiler'; import { normalizeCssLineEndings } from './normalize-css-eol.js'; import { blankCssCommentsAndStrings, braceDepthDelta, lineOfOffset, parseFlatDeclarations, skipSegment, skipWsAndComments, } from './css-scan.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * True when `marker` names a conditional at-rule we recurse into (#110). * Word-boundary match — a malformed `@supportsfoo` / `@mediaquery` prelude is * NOT a conditional group and must not be parsed as one. */ function isConditionalAtRule(marker: string): boolean { return /^@(supports|media)\b/i.test(marker.trim()); } /** * A nested `@supports` / `@media` group inside a `@quantize` state body. * Serialized inside the state's `@container` block as a real at-rule group. * Nested at-rule groups are preserved (depth ≥ 2); silent drop is forbidden (#110). */ export interface QuantizeAtRuleGroup { /** The at-rule prelude exactly as authored (e.g. `@supports (display: grid)`). */ readonly prelude: string; /** Declarations authored directly inside the at-rule (no nested selector). */ readonly bareProps: Record; /** Nested selector rules inside the at-rule. */ readonly rules: readonly QuantizeNestedRule[]; /** Nested `@supports` / `@media` groups inside this at-rule (#110 depth ≥ 2). */ readonly atRuleGroups?: readonly QuantizeAtRuleGroup[]; } /** * A nested rule inside a `@quantize` state: a CSS selector plus the * property map applied to it when the state is active. */ export interface QuantizeNestedRule { /** CSS selector exactly as authored (e.g. `.grid`, `.hero__title`). */ readonly selector: string; /** `{ cssProp: value }` declarations inside the nested rule. */ readonly props: Record; } /** * The non-CSS cast targets authored as nested `@ { … }` segments * inside a `@quantize` state. Each is a sibling of the CSS body * (`bareProps` / `rules`) and routes through its own compiler arm in the * build cast loop: * * - `aria` — `aria-*` / `role` attributes → `ARIACompiler`. * - `glsl` — numeric GLSL uniforms → `GLSLCompiler`. * - `wgsl` — numeric WGSL uniforms → `WGSLCompiler`. * * The marker name (`@aria` / `@glsl` / `@wgsl`) names the target; the nested * declarations are that target's per-state attribute/uniform map. */ export type CastTarget = 'aria' | 'glsl' | 'wgsl'; /** Ordered cast targets parsed from `@ { … }` segments. */ export const CAST_TARGETS: readonly CastTarget[] = ['aria', 'glsl', 'wgsl']; /** * Accepted attribute/uniform key pattern inside a cast segment. Broader than * the CSS property-name pattern: allows underscores (GLSL/WGSL uniform names * are snake_case) alongside the hyphenated `aria-*` keys. The target's compiler * arm validates/coerces the keys it actually accepts downstream. */ const CAST_PROP_PATTERN = /^[a-zA-Z_-][a-zA-Z0-9_-]*$/; /** True when `marker` (e.g. `@glsl`) names a cast target; narrows to it. */ function castTargetOf(marker: string): CastTarget | null { const name = marker.slice(1); return (CAST_TARGETS as readonly string[]).includes(name) ? (name as CastTarget) : null; } /** * The parsed body of one `@quantize` state: bare declarations that apply * to the boundary element selector (the documented flat form) plus * nested per-selector rules (the adaptive per-element form). */ export interface QuantizeStateBody { /** Declarations written directly inside the state (flat form). */ readonly bareProps: Record; /** Nested ` { ... }` rules written inside the state. */ readonly rules: readonly QuantizeNestedRule[]; /** Nested `@supports` / `@media` groups authored inside the state (#110). */ readonly atRuleGroups?: readonly QuantizeAtRuleGroup[]; /** * Authored per-state non-CSS cast attributes, keyed by cast target. Each * entry holds the raw `{ key: value }` declarations from a nested * `@ { … }` segment (quotes stripped). Generalized from the * original `@aria`-only form so adding a cast target is a registration in * {@link CAST_TARGETS}, not a new field. Targets the state did not author * are absent; the field itself is absent when no cast segment was authored. * * Downstream each target routes through its compiler arm via `dispatch` * (ARIA → `ARIACompiler`, GLSL → `GLSLCompiler`, WGSL → `WGSLCompiler`). */ readonly castAttrs?: Partial>>; /** * Authored per-state ARIA/data attributes from a nested `@aria { … }` * segment (e.g. `aria-expanded: false; role: button`). Quotes are stripped. * Validated downstream by `ARIACompiler` against `BoundaryAttribute.isAllowedKey` * (`aria-*` / `role`). Absent when the state declares no `@aria` block. * * Derived from `castAttrs.aria` and kept as a parallel field so existing * ARIA consumers/tests read it unchanged. */ readonly ariaAttrs?: Record; } /** * A single parsed `@quantize` block: the boundary being quantised, the * per-state bodies, and provenance info so HMR can emit * source-mapped warnings. */ export interface QuantizeBlock { /** Boundary name referenced in the at-rule preamble. */ readonly boundaryName: string; /** `{ stateName: { bareProps, rules } }` mapping. */ readonly states: Record; /** Absolute path of the CSS source file. */ readonly sourceFile: string; /** 1-based source line where the block begins. */ readonly line: number; } // --------------------------------------------------------------------------- // Parser helpers // --------------------------------------------------------------------------- /** * Parse the full body of a state block starting at `pos` (the character * immediately after the opening `{` of the state block). * * The body may interleave two segment kinds: * * - bare declarations (`prop: value;`) collected into `bareProps` * - nested rules (` { prop: value; }`) collected into `rules`, * their inner declarations parsed with the shared flat-declaration scanner * * Segments are gathered character-by-character until a `{` (nested rule * opens), `;` (declaration ends), or `}` (state closes) at paren depth 0; * the trailing `{` is what disambiguates a selector from a malformed * declaration. Quoted strings, block comments, and functional notation * (`var()`, `calc()`, ...) are skipped so delimiters inside them never * terminate a segment. * * Returns the parsed body and the position immediately after the closing * `}` of the state block. */ function parseStateBody(css: string, pos: number): { body: QuantizeStateBody; end: number } { const bareProps: Record = {}; const rules: QuantizeNestedRule[] = []; const atRuleGroups: QuantizeAtRuleGroup[] = []; // Per-target cast attribute maps, populated lazily as `@ { … }` // segments are parsed. `aria` is mirrored onto the parallel `ariaAttrs` // field below so existing ARIA consumers stay unchanged. const castAttrs: Partial>> = {}; // Assemble the body, omitting `castAttrs` / `ariaAttrs` entirely when no // cast segment was authored (keeps the common shape minimal and stable for // snapshots). `ariaAttrs` is derived from `castAttrs.aria`. const makeBody = (): QuantizeStateBody => { const base: QuantizeStateBody = { bareProps, rules, ...(atRuleGroups.length > 0 ? { atRuleGroups } : {}), }; const hasCasts = Object.keys(castAttrs).length > 0; if (!hasCasts) return base; return castAttrs.aria ? { ...base, castAttrs, ariaAttrs: castAttrs.aria } : { ...base, castAttrs }; }; while (pos < css.length) { // Skip whitespace between segments while (pos < css.length && /\s/.test(css[pos]!)) pos++; if (pos >= css.length) break; const ch = css[pos]!; // Skip block comments if (ch === '/' && css[pos + 1] === '*') { pos += 2; while (pos < css.length - 1 && !(css[pos] === '*' && css[pos + 1] === '/')) pos++; pos += 2; continue; } // Closing brace of the state block if (ch === '}') { pos++; return { body: makeBody(), end: pos }; } // Collect one segment until `{`, `;`, or `}` at paren depth 0. let buf = ''; let parenDepth = 0; let terminator = ''; while (pos < css.length) { const sc = css[pos]!; // A block comment inside the segment is WHITESPACE per CSS — // dropping it outright would fuse adjacent value tokens. if (sc === '/' && css[pos + 1] === '*') { pos += 2; while (pos < css.length - 1 && !(css[pos] === '*' && css[pos + 1] === '/')) pos++; pos += 2; buf += ' '; continue; } // Skip quoted strings if (sc === '"' || sc === "'") { const quote = sc; buf += sc; pos++; while (pos < css.length && css[pos] !== quote) { if (css[pos] === '\\') { buf += css[pos]!; pos++; } buf += css[pos] ?? ''; pos++; } if (pos < css.length) { buf += css[pos]!; pos++; } continue; } if (sc === '(') { parenDepth++; buf += sc; pos++; continue; } if (sc === ')') { parenDepth--; buf += sc; pos++; continue; } if (parenDepth === 0 && sc === '{' && /^\s*--[^:{};]*:/.test(buf)) { // A custom-property declaration taking a block-token value // (`--theme: { color: red; };`) — only `--*` properties may hold // block values in CSS, while selectors (which can contain `:` via // pseudo-classes) never start with `--`. Consume the balanced // block into the declaration instead of opening a nested rule, // skipping braces inside comments and quoted strings (a literal // `content: "}"` must not close the block early). let blockDepth = 0; while (pos < css.length) { const bc = css[pos]!; if (bc === '/' && css[pos + 1] === '*') { while (pos < css.length - 1 && !(css[pos] === '*' && css[pos + 1] === '/')) { buf += css[pos]!; pos++; } buf += css[pos] ?? ''; buf += css[pos + 1] ?? ''; pos += 2; continue; } if (bc === '"' || bc === "'") { buf += bc; pos++; while (pos < css.length && css[pos] !== bc) { if (css[pos] === '\\') { buf += css[pos]!; pos++; } buf += css[pos] ?? ''; pos++; } buf += css[pos] ?? ''; pos++; continue; } buf += bc; if (bc === '{') blockDepth++; if (bc === '}') { blockDepth--; if (blockDepth === 0) { pos++; break; } } pos++; } continue; } if (parenDepth === 0 && (sc === '{' || sc === ';' || sc === '}')) { terminator = sc; break; } buf += sc; pos++; } // The scan ran off the end of the sheet without a terminator — an // unbalanced paren in a prelude (e.g. `@supports (display: grid {`) keeps // parenDepth > 0 so no delimiter ever fires, and everything after the typo // is consumed. Silently dropping the swallowed tail is forbidden (#110): // warn with the offending segment head so the author can find the typo. if (terminator === '' && pos >= css.length && buf.trim().length > 0) { Diagnostics.warn({ source: 'czap/vite.css-quantize', code: 'unterminated-quantize-segment', message: `A segment inside a @quantize state never terminated (unbalanced parenthesis or missing brace?): ` + `everything after ${JSON.stringify(buf.trim().slice(0, 60))} was consumed and DROPPED. ` + `Fix the segment's delimiters — the compiled output is missing every rule after this point.`, detail: { segmentHead: buf.trim().slice(0, 200) }, }); } // ` {` opens a nested rule whose body holds flat declarations. if (terminator === '{') { const selector = buf.trim(); pos++; // consume '{' const target = selector.startsWith('@') ? castTargetOf(selector) : null; if (target) { // Cast segments carry attribute/uniform keys, not CSS properties. const { props, end } = parseFlatDeclarations(css, pos, CAST_PROP_PATTERN); pos = end; const bucket = (castAttrs[target] ??= {}); for (const [k, v] of Object.entries(props)) { bucket[k] = v.replace(/^["']|["']$/g, ''); } } else if (isConditionalAtRule(selector)) { const inner = parseStateBody(css, pos); pos = inner.end; atRuleGroups.push({ prelude: selector, bareProps: inner.body.bareProps, rules: inner.body.rules, ...(inner.body.atRuleGroups?.length ? { atRuleGroups: inner.body.atRuleGroups } : {}), }); } else { const { props, end } = parseFlatDeclarations(css, pos); pos = end; if (selector.length > 0) { rules.push({ selector, props }); } } continue; } if (terminator === ';') pos++; // consume ';' (`}` is handled at the loop top) const decl = buf.trim(); if (decl.length === 0) continue; // Match `property-name: value` (property names are [a-zA-Z-][a-zA-Z0-9-]*) const colonIdx = decl.indexOf(':'); if (colonIdx > 0) { const propName = decl.slice(0, colonIdx).trim(); const propValue = decl.slice(colonIdx + 1).trim(); if (/^[a-zA-Z-][a-zA-Z0-9-]*$/.test(propName) && propValue.length > 0) { bareProps[propName] = propValue; } } } return { body: makeBody(), end: pos }; } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /** * Parse every `@quantize` block from CSS source text. * * Grammar (states accept bare declarations, nested selector rules, or * both): * * ```css * @quantize boundaryName { * stateName { * property: value; * .selector { * property: value; * } * } * } * ``` * * Parsing is fully character-level: upstream compilers (e.g. the Astro * compiler re-serializing a `