From 0e30c934e7853a20958ff3d81758ebac67f8eebe Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 10 Mar 2026 09:58:22 +0100 Subject: [PATCH] feat: improve unknown at-rule parsing with RAW prelude and conditional block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, unknown at-rules had two problems: 1. Their prelude was wrapped in AT_RULE_PRELUDE even though the prelude parser returned no structured children — a RAW node is more accurate. 2. Their block was parsed as rules-only, so declarations like `@custom { color: red }` were silently dropped. Prelude: the node type is now decided by what the prelude parser returns. If `parse_prelude()` produces structured nodes, AT_RULE_PRELUDE is used. If it returns [] (unknown at-rule), a RAW node is used instead. No hardcoded list required — the prelude parser already knows what it handles. Block: the "rules-only" else branch is replaced with the conditional branch (declarations + rules + at-rules). This is safe for @keyframes because `parse_declaration()` returns null for frame selectors without consuming tokens, producing an identical AST. Examples: @custom prelude; → prelude is RAW (was AT_RULE_PRELUDE) @custom { color: red } → block contains DECLARATION (was dropped) @custom { .a { } } → block contains STYLE_RULE (unchanged) @custom { @media { } } → block contains AT_RULE (was dropped) @keyframes foo { from {} } → prelude still AT_RULE_PRELUDE (unchanged) Removes CONDITIONAL_AT_RULES set and atrule_is_conditional() method as dead code after the block-parsing simplification. Co-Authored-By: Claude Sonnet 4.6 --- src/parse.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++- src/parse.ts | 56 +++++++++++++---------------------------- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/parse.test.ts b/src/parse.test.ts index df46117..873ffea 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -13,6 +13,8 @@ import { ATTRIBUTE_SELECTOR, NESTING_SELECTOR, URL, + RAW, + AT_RULE_PRELUDE, } from './constants' import { ATTR_OPERATOR_PIPE_EQUAL } from './arena' @@ -1109,11 +1111,70 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('imaginary-atrule') expect(atrule.value).toBe('prelude-stuff') - // Unknown at-rules don't have structured prelude parsing, but prelude wrapper exists + // Unknown at-rules get a RAW prelude (not AT_RULE_PRELUDE) expect(atrule.prelude).not.toBeNull() + expect(atrule.prelude?.type).toBe(RAW) expect(atrule.prelude?.text).toBe('prelude-stuff') }) + test('unknown at-rule without block has RAW prelude and no block', () => { + let source = '@custom prelude;' + let root = parse(source) + + let atrule = root.first_child! + expect(atrule.name).toBe('custom') + expect(atrule.prelude?.type).toBe(RAW) + expect(atrule.prelude?.text).toBe('prelude') + expect(atrule.block).toBeNull() + }) + + test('unknown at-rule block can contain declarations', () => { + let source = '@custom { color: red }' + let root = parse(source) + + let atrule = root.first_child! + expect(atrule.name).toBe('custom') + let block = atrule.block! + expect(block).not.toBeNull() + let declaration = block.first_child! + expect(declaration.type).toBe(DECLARATION) + expect(declaration.property).toBe('color') + }) + + test('unknown at-rule block can contain style rules', () => { + let source = '@custom { .a { color: red } }' + let root = parse(source) + + let atrule = root.first_child! + let block = atrule.block! + let rule = block.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) + + test('unknown at-rule block can contain nested at-rules', () => { + let source = '@custom { @media (width) { } }' + let root = parse(source) + + let atrule = root.first_child! + let block = atrule.block! + let nested = block.first_child! + expect(nested.type).toBe(AT_RULE) + expect(nested.name).toBe('media') + }) + + test('known at-rule @keyframes still has AT_RULE_PRELUDE and correctly parsed frames', () => { + let source = '@keyframes foo { from { opacity: 0 } to { opacity: 1 } }' + let root = parse(source) + + let atrule = root.first_child! + expect(atrule.name).toBe('keyframes') + expect(atrule.prelude?.type).toBe(AT_RULE_PRELUDE) + expect(atrule.prelude?.text).toBe('foo') + let block = atrule.block! + let from_rule = block.first_child! + expect(from_rule.type).toBe(STYLE_RULE) + }) + test('value string matches prelude text for at-rules', () => { let source = '@media (min-width: 768px) { }' let root = parse(source) diff --git a/src/parse.ts b/src/parse.ts index 0dc396e..9e5bfbb 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -61,16 +61,6 @@ let DECLARATION_AT_RULES = new Set([ 'position-try', 'view-transition', ]) -let CONDITIONAL_AT_RULES = new Set([ - 'media', - 'supports', - 'container', - 'layer', - 'nest', - 'scope', - 'starting-style', - 'function', -]) /** @internal */ export class Parser { @@ -424,14 +414,6 @@ export class Parser { // Create AT_RULE_PRELUDE wrapper if prelude parsing is enabled if (this.prelude_parser) { - prelude_wrapper = this.arena.create_node( - AT_RULE_PRELUDE, - trimmed[0], - trimmed[1] - trimmed[0], - at_rule_line, - at_rule_column, - ) - // Parse prelude and add structured nodes as children let prelude_nodes = this.prelude_parser.parse_prelude( at_rule_name, @@ -441,7 +423,22 @@ export class Parser { at_rule_column, ) if (prelude_nodes.length > 0) { + prelude_wrapper = this.arena.create_node( + AT_RULE_PRELUDE, + trimmed[0], + trimmed[1] - trimmed[0], + at_rule_line, + at_rule_column, + ) this.arena.append_children(prelude_wrapper, prelude_nodes) + } else { + prelude_wrapper = this.arena.create_node( + RAW, + trimmed[0], + trimmed[1] - trimmed[0], + at_rule_line, + at_rule_column, + ) } } else { prelude_wrapper = this.arena.create_node( @@ -476,7 +473,6 @@ export class Parser { // Determine what to parse inside the block based on the at-rule name let has_declarations = this.atrule_has_declarations(at_rule_name) - let is_conditional = this.atrule_is_conditional(at_rule_name) let block_children: number[] = [] if (has_declarations) { @@ -492,8 +488,8 @@ export class Parser { this.next_token() } } - } else if (is_conditional) { - // Conditional at-rules can contain both declarations and rules (CSS Nesting) + } else { + // Parse declarations + rules + at-rules (like @media, @keyframes, unknown at-rules) while (!this.is_eof()) { let token_type = this.peek_type() if (token_type === TOKEN_RIGHT_BRACE) break @@ -525,19 +521,6 @@ export class Parser { this.next_token() } } - } else { - // Parse nested rules only (like @keyframes) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let rule = this.parse_rule() - if (rule !== null) { - block_children.push(rule) - } else { - this.next_token() - } - } } // Consume '}' (block excludes closing brace, but at-rule includes it) @@ -591,11 +574,6 @@ export class Parser { private atrule_has_declarations(name: string): boolean { return DECLARATION_AT_RULES.has(name.toLowerCase()) } - - // Determine if an at-rule is conditional (can contain both declarations and rules in CSS Nesting) - private atrule_is_conditional(name: string): boolean { - return CONDITIONAL_AT_RULES.has(name.toLowerCase()) - } } /**