From 1af360faf96ef59d679f40931c5808723c51c646 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 3 Mar 2026 11:53:07 +0100 Subject: [PATCH 1/2] fix: support more atrules parsing --- src/parse-atrule-prelude.test.ts | 211 +++++++++++++++++++++++++++++++ src/parse-atrule-prelude.ts | 144 +++++++++++++++++---- src/parse.ts | 4 +- 3 files changed, 332 insertions(+), 27 deletions(-) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 7f5734f..2d84774 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -908,6 +908,217 @@ describe('At-Rule Prelude Nodes', () => { }) }) + describe('@counter-style', () => { + it('should parse identifier name', () => { + const root = parse('@counter-style thumbs { system: cyclic; }') + const atRule = root.first_child! + expect(atRule.name).toBe('counter-style') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('thumbs') + }) + }) + + describe('@color-profile', () => { + it('should parse dashed-ident name', () => { + const root = parse('@color-profile --swop5c { rendering-intent: relative-colorimetric; }') + const atRule = root.first_child! + expect(atRule.name).toBe('color-profile') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('--swop5c') + }) + }) + + describe('@font-palette-values', () => { + it('should parse dashed-ident name', () => { + const root = parse('@font-palette-values --cool-palette { font-family: Bixa; }') + const atRule = root.first_child! + expect(atRule.name).toBe('font-palette-values') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('--cool-palette') + }) + }) + + describe('@position-try', () => { + it('should parse dashed-ident name', () => { + const root = parse('@position-try --custom-bottom { top: anchor(bottom); }') + const atRule = root.first_child! + expect(atRule.name).toBe('position-try') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('--custom-bottom') + }) + }) + + describe('@view-transition', () => { + it('should parse with no prelude children', () => { + const root = parse('@view-transition { navigation: auto; }') + const atRule = root.first_child! + expect(atRule.name).toBe('view-transition') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(0) + }) + + it('should parse declarations in block', () => { + const root = parse('@view-transition { navigation: auto; }') + const atRule = root.first_child! + const block = atRule.first_child + expect(block?.type).toBe(BLOCK) + expect(block?.children.length).toBeGreaterThan(0) + }) + }) + + describe('@starting-style', () => { + it('should parse with no prelude children', () => { + const root = parse('@starting-style { .foo { color: red; } }') + const atRule = root.first_child! + expect(atRule.name).toBe('starting-style') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(0) + }) + + it('should parse nested rules in block', () => { + const root = parse('@starting-style { .foo { color: red; } }') + const atRule = root.first_child! + const block = atRule.first_child + expect(block?.type).toBe(BLOCK) + expect(block?.children.length).toBeGreaterThan(0) + }) + }) + + describe('@page', () => { + it('should parse named page identifier', () => { + const root = parse('@page wide { size: A4 landscape; }') + const atRule = root.first_child! + expect(atRule.name).toBe('page') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('wide') + }) + + it('should have no prelude children for bare @page', () => { + const root = parse('@page { margin: 1cm; }') + const atRule = root.first_child! + expect(atRule.name).toBe('page') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(0) + }) + }) + + describe('@font-feature-values', () => { + it('should parse font family identifier', () => { + const root = parse('@font-feature-values Gentium { @styleset { nice-style: 12; } }') + const atRule = root.first_child! + expect(atRule.name).toBe('font-feature-values') + const ident = atRule.prelude?.first_child + expect(ident?.type).toBe(IDENTIFIER) + expect(ident?.text).toBe('Gentium') + }) + }) + + describe('@namespace', () => { + it('should parse bare URL string', () => { + const root = parse('@namespace "http://www.w3.org/1999/xhtml";') + const atRule = root.first_child! + expect(atRule.name).toBe('namespace') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(URL) + expect(children[0].text).toBe('"http://www.w3.org/1999/xhtml"') + }) + + it('should parse url() form', () => { + const root = parse('@namespace url("http://www.w3.org/1999/xhtml");') + const atRule = root.first_child! + expect(atRule.name).toBe('namespace') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(URL) + }) + + it('should parse prefix + URL', () => { + const root = parse('@namespace svg url("http://www.w3.org/2000/svg");') + const atRule = root.first_child! + expect(atRule.name).toBe('namespace') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[0].text).toBe('svg') + expect(children[1].type).toBe(URL) + }) + + it('should parse prefix + string', () => { + const root = parse('@namespace svg "http://www.w3.org/2000/svg";') + const atRule = root.first_child! + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[1].type).toBe(URL) + }) + }) + + describe('@scope', () => { + it('should parse scope with start selector', () => { + const root = parse('@scope (.parent) { p { color: black; } }') + const atRule = root.first_child! + expect(atRule.name).toBe('scope') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(SUPPORTS_QUERY) + expect(children[0].value).toBe('.parent') + }) + + it('should parse scope with start and end selectors', () => { + const root = parse('@scope (.light) to (.dark) { p { color: black; } }') + const atRule = root.first_child! + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(3) + expect(children[0].type).toBe(SUPPORTS_QUERY) + expect(children[0].value).toBe('.light') + expect(children[1].type).toBe(PRELUDE_OPERATOR) + expect(children[1].text).toBe('to') + expect(children[2].type).toBe(SUPPORTS_QUERY) + expect(children[2].value).toBe('.dark') + }) + + it('should have no prelude children for bare @scope', () => { + const root = parse('@scope { p { color: black; } }') + const atRule = root.first_child! + const children = atRule.prelude?.children ?? [] + expect(children.length).toBe(0) + }) + + it('should parse nested rules in block', () => { + const root = parse('@scope (.parent) { p { color: black; } }') + const atRule = root.first_child! + const block = atRule.children.find((c) => c.type === BLOCK) + expect(block?.type).toBe(BLOCK) + expect(block?.children.length).toBeGreaterThan(0) + }) + }) + + describe('@custom-media', () => { + it('should parse custom media name and condition', () => { + const root = parse('@custom-media --small (max-width: 30em);') + const atRule = root.first_child! + expect(atRule.name).toBe('custom-media') + const children = atRule.prelude?.children ?? [] + expect(children.length).toBeGreaterThan(1) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[0].text).toBe('--small') + expect(children[1].type).toBe(MEDIA_QUERY) + }) + + it('should parse custom media with boolean condition', () => { + const nodes = parse_atrule_prelude('custom-media', '--supports-grid (display: grid)') + expect(nodes.length).toBeGreaterThan(0) + expect(nodes[0].type).toBe(IDENTIFIER) + expect(nodes[0].text).toBe('--supports-grid') + }) + }) + describe('parse_atrule_preludes option', () => { it('should parse preludes when enabled (default)', () => { const css = '@media screen { }' diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 0951c0a..57217cd 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -64,26 +64,37 @@ export class AtRulePreludeParser { // Dispatch to appropriate parser based on at-rule type private parse_prelude_dispatch(at_rule_name: string): number[] { // Strip vendor prefix to treat @-webkit-keyframes same as @keyframes - let normalized_name = strip_vendor_prefix(at_rule_name) - - if (str_equals('media', normalized_name)) { - return this.parse_media_query_list() - } else if (str_equals('container', normalized_name)) { - return this.parse_container_query() - } else if (str_equals('supports', normalized_name)) { - return this.parse_supports_query() - } else if (str_equals('layer', normalized_name)) { - return this.parse_layer_names() - } else if (str_equals('keyframes', normalized_name)) { - return this.parse_identifier() - } else if (str_equals('property', normalized_name)) { - return this.parse_identifier() - } else if (str_equals('import', normalized_name)) { - return this.parse_import_prelude() - } else if (str_equals('charset', normalized_name)) { - return this.parse_charset_prelude() + let normalized_name = strip_vendor_prefix(at_rule_name).toLowerCase() + + switch (normalized_name) { + case 'media': + return this.parse_media_query_list() + case 'container': + return this.parse_container_query() + case 'supports': + return this.parse_supports_query() + case 'layer': + return this.parse_layer_names() + case 'keyframes': + case 'property': + case 'counter-style': + case 'color-profile': + case 'font-palette-values': + case 'position-try': + case 'font-feature-values': + case 'page': + return this.parse_identifier() + case 'import': + return this.parse_import_prelude() + case 'charset': + return this.parse_charset_prelude() + case 'namespace': + return this.parse_namespace_prelude() + case 'scope': + return this.parse_scope_prelude() + case 'custom-media': + return this.parse_custom_media_prelude() } - // For now, @namespace and other at-rules are not parsed return [] } @@ -772,13 +783,96 @@ export class AtRulePreludeParser { return nodes } + // Parse @namespace prelude: [prefix] url("...") | "..." + // e.g. @namespace url("http://www.w3.org/1999/xhtml"); + // e.g. @namespace svg url("http://www.w3.org/2000/svg"); + private parse_namespace_prelude(): number[] { + let nodes: number[] = [] + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return [] + + // Peek: if it's an IDENT it's an optional prefix, otherwise let parse_import_url() take it + const saved = this.lexer.save_position() + this.next_token() + + if (this.lexer.token_type === TOKEN_IDENT) { + nodes.push(this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end)) + this.skip_whitespace() + } else { + this.lexer.restore_position(saved) + } + + const url_node = this.parse_import_url() + if (url_node !== null) nodes.push(url_node) + + return nodes + } + + // Parse @scope prelude: [()] [to ()] + // e.g. @scope (.parent) to (.child) { } + private parse_scope_prelude(): number[] { + let nodes: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + const token_type = this.peek_token_type() + + if (token_type === TOKEN_LEFT_PAREN) { + this.next_token() // consume '(' + let paren_start = this.lexer.token_start + let content_start = this.lexer.pos + let depth = 1 + + while (this.lexer.pos < this.prelude_end && depth > 0) { + this.next_token() + if (this.lexer.token_type === TOKEN_LEFT_PAREN) depth++ + else if (this.lexer.token_type === TOKEN_RIGHT_PAREN) depth-- + } + + let content_end = this.lexer.token_start + let paren_end = this.lexer.token_end + + let scope_node = this.create_node(SUPPORTS_QUERY, paren_start, paren_end) + let trimmed = trim_boundaries(this.source, content_start, content_end) + if (trimmed) { + this.arena.set_value_start_delta(scope_node, trimmed[0] - paren_start) + this.arena.set_value_length(scope_node, trimmed[1] - trimmed[0]) + } + nodes.push(scope_node) + } else if (token_type === TOKEN_IDENT) { + this.next_token() + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + if (str_equals('to', text)) { + nodes.push(this.create_node(PRELUDE_OPERATOR, this.lexer.token_start, this.lexer.token_end)) + } + } else { + this.next_token() + } + } + + return nodes + } + + // Parse @custom-media prelude: --name + // e.g. @custom-media --small (max-width: 30em); + private parse_custom_media_prelude(): number[] { + let nodes: number[] = [] + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return [] + + this.next_token() + if (this.lexer.token_type !== TOKEN_IDENT) return [] + + nodes.push(this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end)) + nodes.push(...this.parse_media_query_list()) + + return nodes + } + // Parse media feature range syntax: (50px <= width <= 100px) - private parse_feature_range( - feature_start: number, - feature_end: number, - content_start: number, - content_end: number - ): number { + private parse_feature_range(feature_start: number, feature_end: number, content_start: number, content_end: number): number { let range_node = this.create_node(FEATURE_RANGE, feature_start, feature_end) let children: number[] = [] let feature_name_start = -1 diff --git a/src/parse.ts b/src/parse.ts index 64808ec..4b60f0d 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -43,8 +43,8 @@ export interface ParserOptions { } // Static at-rule lookup sets for fast classification -let DECLARATION_AT_RULES = new Set(['font-face', 'font-feature-values', 'page', 'property', 'counter-style']) -let CONDITIONAL_AT_RULES = new Set(['media', 'supports', 'container', 'layer', 'nest']) +let DECLARATION_AT_RULES = new Set(['font-face', 'font-feature-values', 'page', 'property', 'counter-style', 'color-profile', 'font-palette-values', 'position-try', 'view-transition']) +let CONDITIONAL_AT_RULES = new Set(['media', 'supports', 'container', 'layer', 'nest', 'scope', 'starting-style']) /** @internal */ export class Parser { From b2323e3474ceb2ccfcf19904836bad79602f6f81 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 3 Mar 2026 12:49:24 +0100 Subject: [PATCH 2/2] add new PRELUDE_SELECTORLIST node which is a selectorlist inside parenthesis --- src/arena.ts | 1 + src/parse-atrule-prelude.test.ts | 7 ++++--- src/parse-atrule-prelude.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/arena.ts b/src/arena.ts index 64541ec..726c02e 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -89,6 +89,7 @@ export const LAYER_NAME = 37 // layer name: base, components export const PRELUDE_OPERATOR = 38 // logical operator: and, or, not export const FEATURE_RANGE = 39 // Range syntax: (50px <= width <= 100px) export const AT_RULE_PRELUDE = 40 // Wrapper for at-rule prelude children +export const PRELUDE_SELECTORLIST = 41 // Parenthesized selector list in at-rule preludes: (.parent), (figure) in @scope // Wrapper node types export const VALUE = 50 // Wrapper for declaration values diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 2d84774..c96781e 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -12,6 +12,7 @@ import { LAYER_NAME, IDENTIFIER, PRELUDE_OPERATOR, + PRELUDE_SELECTORLIST, URL, DIMENSION, FEATURE_RANGE, @@ -1066,7 +1067,7 @@ describe('At-Rule Prelude Nodes', () => { expect(atRule.name).toBe('scope') const children = atRule.prelude?.children ?? [] expect(children.length).toBe(1) - expect(children[0].type).toBe(SUPPORTS_QUERY) + expect(children[0].type).toBe(PRELUDE_SELECTORLIST) expect(children[0].value).toBe('.parent') }) @@ -1075,11 +1076,11 @@ describe('At-Rule Prelude Nodes', () => { const atRule = root.first_child! const children = atRule.prelude?.children ?? [] expect(children.length).toBe(3) - expect(children[0].type).toBe(SUPPORTS_QUERY) + expect(children[0].type).toBe(PRELUDE_SELECTORLIST) expect(children[0].value).toBe('.light') expect(children[1].type).toBe(PRELUDE_OPERATOR) expect(children[1].text).toBe('to') - expect(children[2].type).toBe(SUPPORTS_QUERY) + expect(children[2].type).toBe(PRELUDE_SELECTORLIST) expect(children[2].value).toBe('.dark') }) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 57217cd..5b60462 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -10,6 +10,7 @@ import { LAYER_NAME, IDENTIFIER, PRELUDE_OPERATOR, + PRELUDE_SELECTORLIST, URL, FUNCTION, NUMBER, @@ -834,7 +835,7 @@ export class AtRulePreludeParser { let content_end = this.lexer.token_start let paren_end = this.lexer.token_end - let scope_node = this.create_node(SUPPORTS_QUERY, paren_start, paren_end) + let scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end) let trimmed = trim_boundaries(this.source, content_start, content_end) if (trimmed) { this.arena.set_value_start_delta(scope_node, trimmed[0] - paren_start)