diff --git a/src/css-node.ts b/src/css-node.ts index cb84569..1b1c147 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -368,8 +368,7 @@ 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. + // SupportsDeclaration wraps a Declaration; go one hop deeper than MEDIA_FEATURE to reach VALUE if (type === SUPPORTS_DECLARATION) { return first_child?.first_child ?? null } diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 879f55f..c3a75d1 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -497,6 +497,40 @@ describe('At-Rule Prelude Nodes', () => { expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) }) + test.each([ + ['@media only screen { }', 'only'], + ['@media not screen { }', 'not'], + ])( + 'should emit a leading "only"/"not" modifier as a PreludeOperator child (%s)', + (css, modifier) => { + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude | null)?.children || [] + const query = children[0] as MediaQuery + + expect(query.text).toBe(`${modifier} screen`) + expect(query.children.map((c) => c.type)).toEqual([PRELUDE_OPERATOR, MEDIA_TYPE]) + expect(query.children[0].text).toBe(modifier) + expect(query.children[1].text).toBe('screen') + }, + ) + + test('should parse "only" combined with a feature query', () => { + const css = '@media only screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude | null)?.children || [] + const query = children[0] as MediaQuery + + expect(query.children.map((c) => c.type)).toEqual([ + PRELUDE_OPERATOR, + MEDIA_TYPE, + PRELUDE_OPERATOR, + MEDIA_FEATURE, + ]) + expect(query.children[0].text).toBe('only') + }) + test('should parse multiple media features', () => { const css = '@media (min-width: 768px) and (max-width: 1024px) { }' const ast = parse(css) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 815a011..47d64a3 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -53,12 +53,9 @@ export class AtRulePreludeParser { private arena: CSSDataArena private source: string private prelude_end: number - // Shared with declaration-value parsing so feature values (calc(), env(), var(), ...) - // get the same structured Number/Operator/Function tree, not just an opaque text span. - // Runs on its own lexer instance, independent of this.lexer. + // Shared with declaration values so calc()/env()/var() get structured children, not raw text 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. + // Deep-parses selector()'s argument into a real SelectorList private selector_parser: SelectorParser constructor(arena: CSSDataArena, source: string) { @@ -169,10 +166,7 @@ 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. + // Parse a bare function condition: style(...), selector(...), font-tech(...). Current token must be TOKEN_FUNCTION. 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 @@ -208,8 +202,7 @@ export class AtRulePreludeParser { 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. + // selector()'s argument is a — parse it into a real SelectorList if (str_equals('selector', func_name)) { let selector_list = this.selector_parser.parse_selector( content_start, @@ -221,8 +214,7 @@ export class AtRulePreludeParser { 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. + // style()'s argument is a — parse it into the same tree as (property: value) else if (str_equals('style', func_name)) { let colon_pos = this.find_colon_at_depth_zero(content_start, content_end) if (colon_pos !== -1) { @@ -242,14 +234,27 @@ export class AtRulePreludeParser { this.skip_whitespace() if (this.lexer.pos >= this.prelude_end) return null - // Check for modifier (only, not) - // let has_modifier = false + // Parse components (media type, features, operators), chained as siblings without an + // intermediate array — most media queries have exactly one component (a single media + // type or a single feature), so this avoids an allocation for the common case. + let first_component = 0 + let last_component = 0 + + // Leading modifier (only/not) — emit as PRELUDE_OPERATOR like the and/or/not combinators below const saved_token_start = this.lexer.save_position() this.next_token() if (this.lexer.token_type === TOKEN_IDENT) { let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (!str_equals('only', text) && !str_equals('not', text)) { + if (str_equals('only', text) || str_equals('not', text)) { + let modifier = this.create_node( + PRELUDE_OPERATOR, + this.lexer.token_start, + this.lexer.token_end, + ) + first_component = modifier + last_component = modifier + } else { // Reset - this is a media type this.lexer.restore_position(saved_token_start) } @@ -257,12 +262,6 @@ export class AtRulePreludeParser { this.lexer.restore_position(saved_token_start) } - // Parse components (media type, features, operators), chained as siblings without an - // intermediate array — most media queries have exactly one component (a single media - // type or a single feature), so this avoids an allocation for the common case. - let first_component = 0 - let last_component = 0 - while (this.lexer.pos < this.prelude_end) { this.skip_whitespace() if (this.lexer.pos >= this.prelude_end) break @@ -607,8 +606,7 @@ 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. + // Mirror the property name onto the wrapper so .property doesn't need 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) @@ -957,11 +955,8 @@ export class AtRulePreludeParser { return this.lexer.next_token_fast(false) } - // Parse feature value portion into typed nodes (Number, Dimension, Function, Operator, ...), - // chained as siblings without an intermediate array. Delegates to the shared ValueNodeParser - // (also used for declaration values) so calc(), env(), var(), etc. get full structured - // children instead of being treated as opaque text. Runs on its own lexer instance, so it - // doesn't disturb this.lexer's position — no save/restore needed around the call. + // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children. + // Own lexer instance, so it doesn't disturb this.lexer's position — no save/restore needed. private parse_feature_value(start: number, end: number): number { return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column) } diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index e1b9124..58473d9 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -1,8 +1,6 @@ -// Value Node Parser - Shared recursive parsing of value-shaped token streams. -// Used by both ValueParser (declaration values) and AtRulePreludeParser (feature -// values inside media/container/supports conditions), so calc(), env(), var(), -// and other functions get the same structured tree — Number/Operator/Dimension -// children, not just an opaque text span — regardless of where they appear. +// Value Node Parser - shared recursive value-token parsing, used by both ValueParser +// (declaration values) and AtRulePreludeParser (feature values), so calc()/env()/var() +// get the same structured tree everywhere instead of an opaque text span. import { Lexer } from './tokenize' import { CSSDataArena, @@ -47,8 +45,7 @@ export class ValueNodeParser { protected arena: CSSDataArena protected source: string protected end: number = 0 - // Last node produced by parse_chain(), for callers that need the chain's end offset - // (e.g. to size a wrapper node). Avoids returning a tuple/array from the hot path. + // Last node from parse_chain(), for callers sizing a wrapper node. Avoids a tuple/array return. last_chain_node: number = 0 constructor(arena: CSSDataArena, source: string) { @@ -57,10 +54,8 @@ export class ValueNodeParser { this.lexer = new Lexer(source) } - // Parse a run of value tokens in [start, end) into typed nodes, chained as siblings - // without an intermediate array. Returns the first node (0 if none); the last node - // is left in `this.last_chain_node`. Used both for a full declaration value and for - // a sub-range excursion (e.g. a media feature's value) via a shared, independent lexer. + // Parse value tokens in [start, end) into typed nodes, chained as siblings. + // Returns the first node (0 if none); last node left in this.last_chain_node. parse_chain(start: number, end: number, start_line: number, start_column: number): number { this.end = end this.lexer.seek(start, start_line, start_column)