From 580aacb20d149d62348202598713191f4fd06013 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 19 Jul 2026 23:02:01 +0200 Subject: [PATCH 1/2] more deep parsing --- src/css-node.ts | 2 +- src/node-types.ts | 8 ++ src/parse-atrule-prelude.test.ts | 70 ++++++++++++++++++ src/parse-atrule-prelude.ts | 123 +++++++++++++++++++++---------- 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/css-node.ts b/src/css-node.ts index e121d3f..b7da6a0 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -343,7 +343,7 @@ export class CSSNode { /** Alias for `name` on declarations ("color" in "color: blue"), more semantic than `name` there. */ get property(): string | undefined { let { type } = this - if (type !== DECLARATION && type !== MEDIA_FEATURE) return + if (type !== DECLARATION && type !== MEDIA_FEATURE && type !== SUPPORTS_DECLARATION) return return this.get_content() } diff --git a/src/node-types.ts b/src/node-types.ts index e4c2e8a..7bc6b14 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -253,6 +253,10 @@ type ValueLike = | Hash | Dimension | Number + // `@supports selector(...)`'s Function node holds its argument as a parsed SelectorList + | SelectorList + // `style(...)`'s Function node holds its argument as a parsed SupportsDeclaration + | SupportsDeclaration export type Identifier = Leaf @@ -450,6 +454,8 @@ export type AtrulePrelude = WithClone< | PreludeSelectorList | Parenthesis | Url + // `@supports style(...)` / `selector(...)` function conditions + | Function > & { readonly type: typeof AT_RULE_PRELUDE; readonly type_name: 'AtrulePrelude' } > @@ -504,6 +510,8 @@ export type SupportsDeclaration = WithClone< WithChildren & { readonly type: typeof SUPPORTS_DECLARATION readonly type_name: 'SupportsDeclaration' + /** Property name, e.g. "display" from "(display: flex)" */ + readonly property: string } > diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 21fa244..9e2338d 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -39,6 +39,7 @@ import { FUNCTION, OPERATOR, NUMBER, + SELECTOR_LIST, } from './arena' describe('At-Rule Prelude Nodes', () => { @@ -770,6 +771,12 @@ describe('At-Rule Prelude Nodes', () => { expect(fn.type_name).toBe('Function') expect(fn.text).toBe('style(--custom: 1)') expect(fn.value).toBe('--custom: 1') + + // style()'s argument should be deep-parsed into a SupportsDeclaration + const decl = fn.children[0] + expect(decl.type).toBe(SUPPORTS_DECLARATION) + expect(decl.text).toBe('--custom: 1') + expect((decl as SupportsDeclaration).property).toBe('--custom') }) test('should parse named style container query', () => { @@ -786,6 +793,7 @@ describe('At-Rule Prelude Nodes', () => { expect(fn.type_name).toBe('Function') expect(fn.text).toBe('style(--custom: 1)') expect(fn.value).toBe('--custom: 1') + expect(fn.children[0].type).toBe(SUPPORTS_DECLARATION) }) test('should parse container query with name only (no conditions)', () => { @@ -895,6 +903,63 @@ describe('At-Rule Prelude Nodes', () => { expect(operators.length).toBe(1) }) + test('should parse a function condition, e.g. selector()', () => { + const css = '@supports selector([popover]:open) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + + expect(children.length).toBe(1) + expect(children[0].type).toBe(FUNCTION) + expect(children[0].text).toBe('selector([popover]:open)') + }) + + test("should deep-parse selector()'s argument into a SelectorList", () => { + const css = '@supports selector(:has(> a)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + const fn = children[0] as Function + + expect(fn.type).toBe(FUNCTION) + const selectorList = fn.children[0] + expect(selectorList.type).toBe(SELECTOR_LIST) + expect(selectorList.text).toBe(':has(> a)') + }) + + test("should deep-parse style()'s argument into a SupportsDeclaration, not a selector", () => { + const css = '@supports style(--custom: 1) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + const fn = children[0] as Function + + expect(fn.type).toBe(FUNCTION) + const decl = fn.children[0] + expect(decl.type).toBe(SUPPORTS_DECLARATION) + expect(decl.text).toBe('--custom: 1') + }) + + test('should parse a function condition combined with a feature query', () => { + const css = '@supports selector(:has(a)) and (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + + expect(children.map((c) => c.type)).toEqual([FUNCTION, PRELUDE_OPERATOR, SUPPORTS_QUERY]) + expect(children[0].text).toBe('selector(:has(a))') + }) + + test('should parse "not" before a function condition', () => { + const css = '@supports not selector([popover]:open) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + + expect(children.map((c) => c.type)).toEqual([PRELUDE_OPERATOR, FUNCTION]) + expect(children[1].text).toBe('selector([popover]:open)') + }) + test('should have a SupportsDeclaration as direct child of SupportsQuery', () => { const css = '@supports (display: flex) { }' const ast = parse(css) @@ -1552,6 +1617,11 @@ describe('At-Rule Prelude Nodes', () => { expect(children[0].type).toBe(URL) expect(children[1].type).toBe(SUPPORTS_QUERY) expect(children[1].text).toBe('supports(display: grid)') + + // supports()'s argument should be deep-parsed into a SupportsDeclaration + const decl = (children[1] as SupportsQuery).children[0] + expect(decl.type).toBe(SUPPORTS_DECLARATION) + expect(decl.text).toBe('display: grid') }) test('should parse with media query', () => { diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 9fd9942..815a011 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -45,6 +45,7 @@ import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-u import { CSSNode } from './css-node' import type { AnyNode } from './node-types' import { ValueNodeParser } from './value-node-parser' +import { SelectorParser } from './parse-selector' /** @internal */ export class AtRulePreludeParser { @@ -56,6 +57,9 @@ export class AtRulePreludeParser { // get the same structured Number/Operator/Function tree, not just an opaque text span. // Runs on its own lexer instance, independent of this.lexer. private value_node_parser: ValueNodeParser + // Used to deep-parse `selector()`'s argument (e.g. `@supports selector(:has(a))`) into a + // real SelectorList instead of leaving it as opaque text. Own lexer instance, like above. + private selector_parser: SelectorParser constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -64,6 +68,7 @@ export class AtRulePreludeParser { this.lexer = new Lexer(source) this.prelude_end = 0 this.value_node_parser = new ValueNodeParser(arena, source) + this.selector_parser = new SelectorParser(arena, source) } // Parse an at-rule prelude into nodes (standalone use) @@ -164,6 +169,71 @@ export class AtRulePreludeParser { return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) } + // Parse a bare function condition: style(--custom: 1), selector([popover]:open), + // font-tech(color-COLRv1), font-format(woff2), ... The lexer's current token must + // already be the TOKEN_FUNCTION. Content isn't a CSS value (it may be a selector or + // an arbitrary declaration), so it's captured as raw text rather than deep-parsed. + private parse_function_condition(): number { + let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' + let func_start = this.lexer.token_start + let content_start = this.lexer.token_end // After '(' + + // Find matching closing paren + let paren_depth = 1 + let func_end = this.lexer.token_end + let content_end = content_start + + while (this.lexer.pos < this.prelude_end && paren_depth > 0) { + this.next_token() + let inner_token = this.lexer.token_type + if (inner_token === TOKEN_LEFT_PAREN || inner_token === TOKEN_FUNCTION) { + paren_depth++ + } else if (inner_token === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + content_end = this.lexer.token_start + func_end = this.lexer.token_end + } + } else if (inner_token === TOKEN_EOF) { + break + } + } + + // Create function node + let func_node = this.create_node(FUNCTION, func_start, func_end) + // Set content fields to function name + this.arena.set_content_start_delta(func_node, 0) + this.arena.set_content_length(func_node, func_name.length) + // Set value fields to content inside parentheses + this.arena.set_value_start_delta(func_node, content_start - func_start) + this.arena.set_value_length(func_node, content_end - content_start) + + // `selector()`'s argument is a , e.g. `selector(:has(a))` — parse it + // with the selector parser so consumers get a real SelectorList instead of raw text. + if (str_equals('selector', func_name)) { + let selector_list = this.selector_parser.parse_selector( + content_start, + content_end, + this.lexer.line, + this.lexer.column, + ) + if (selector_list !== null) { + this.arena.set_first_child(func_node, selector_list) + } + } + // `style()`'s argument is a , e.g. `style(--custom: 1)` — parse it into the + // same SupportsDeclaration → Declaration → Value tree as a plain `(property: value)` query. + else if (str_equals('style', func_name)) { + let colon_pos = this.find_colon_at_depth_zero(content_start, content_end) + if (colon_pos !== -1) { + let decl_child = this.create_supports_declaration(content_start, content_end, colon_pos) + this.arena.set_first_child(func_node, decl_child) + } + } + + return func_node + } + // Parse a single media query: screen and (min-width: 768px) private parse_single_media_query(): number | null { let query_start = this.lexer.pos @@ -363,43 +433,7 @@ export class AtRulePreludeParser { } // Function: style(--custom: 1) else if (token_type === TOKEN_FUNCTION) { - let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' - - // For now, treat all functions (like style()) as FUNCTION nodes - let func_start = this.lexer.token_start - let content_start = this.lexer.token_end // After '(' - - // Find matching closing paren - let paren_depth = 1 - let func_end = this.lexer.token_end - let content_end = content_start - - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - this.next_token() - let inner_token = this.lexer.token_type - if (inner_token === TOKEN_LEFT_PAREN || inner_token === TOKEN_FUNCTION) { - paren_depth++ - } else if (inner_token === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start - func_end = this.lexer.token_end - } - } else if (inner_token === TOKEN_EOF) { - break - } - } - - // Create function node - let func_node = this.create_node(FUNCTION, func_start, func_end) - // Set content fields to function name - this.arena.set_content_start_delta(func_node, 0) - this.arena.set_content_length(func_node, func_name.length) - // Set value fields to content inside parentheses - this.arena.set_value_start_delta(func_node, content_start - func_start) - this.arena.set_value_length(func_node, content_end - content_start) - - component = func_node + component = this.parse_function_condition() } // Identifier: operator (and, or, not) or container name else if (token_type === TOKEN_IDENT) { @@ -501,6 +535,10 @@ export class AtRulePreludeParser { nodes.push(op) } } + // Function condition: selector([popover]:open), font-tech(color-COLRv1), ... + else if (token_type === TOKEN_FUNCTION) { + nodes.push(this.parse_function_condition()) + } } return nodes @@ -569,6 +607,10 @@ export class AtRulePreludeParser { } let supports_decl = this.create_node(SUPPORTS_DECLARATION, content_start, content_end) + // Mirror the property name onto the wrapper too, so `.property` works without + // having to reach into the inner Declaration. + this.arena.set_content_start_delta(supports_decl, prop_trimmed[0] - content_start) + this.arena.set_content_length(supports_decl, prop_trimmed[1] - prop_trimmed[0]) this.arena.set_first_child(supports_decl, decl) return supports_decl } @@ -871,6 +913,13 @@ export class AtRulePreludeParser { if (trimmed) { this.arena.set_value_start_delta(supports_node, trimmed[0] - supports_start) this.arena.set_value_length(supports_node, trimmed[1] - trimmed[0]) + + // Check for simple declaration: supports(property: value) + let colon_pos = this.find_colon_at_depth_zero(trimmed[0], trimmed[1]) + if (colon_pos !== -1) { + let decl_child = this.create_supports_declaration(trimmed[0], trimmed[1], colon_pos) + this.arena.set_first_child(supports_node, decl_child) + } } return supports_node From b43015b553bf15175beeaa1c42948f17d127d2cc Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 19 Jul 2026 23:06:09 +0200 Subject: [PATCH 2/2] implement SupportsDeclaration.value --- src/css-node.ts | 6 ++++++ src/node-types.ts | 2 ++ src/parse-atrule-prelude.test.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/src/css-node.ts b/src/css-node.ts index b7da6a0..cb84569 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -368,6 +368,12 @@ export class CSSNode { return first_child ?? null } + // SupportsDeclaration wraps a Declaration, whose first_child is the VALUE node — + // one hop deeper than MEDIA_FEATURE, where first_child is already the value. + if (type === SUPPORTS_DECLARATION) { + return first_child?.first_child ?? null + } + if (type === DIMENSION) { return parse_dimension(text).value } diff --git a/src/node-types.ts b/src/node-types.ts index 7bc6b14..bfab44b 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -512,6 +512,8 @@ export type SupportsDeclaration = WithClone< readonly type_name: 'SupportsDeclaration' /** Property name, e.g. "display" from "(display: flex)" */ readonly property: string + /** VALUE node for the declaration's value, e.g. "flex" from "(display: flex)" */ + readonly value: Value | null } > diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 9e2338d..879f55f 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -40,6 +40,7 @@ import { OPERATOR, NUMBER, SELECTOR_LIST, + VALUE, } from './arena' describe('At-Rule Prelude Nodes', () => { @@ -777,6 +778,8 @@ describe('At-Rule Prelude Nodes', () => { expect(decl.type).toBe(SUPPORTS_DECLARATION) expect(decl.text).toBe('--custom: 1') expect((decl as SupportsDeclaration).property).toBe('--custom') + expect((decl as SupportsDeclaration).value?.type).toBe(VALUE) + expect((decl as SupportsDeclaration).value?.text).toBe('1') }) test('should parse named style container query', () => {