Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
34 changes: 34 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 24 additions & 29 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <complex-selector>, 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 <complex-selector> — parse it into a real SelectorList
if (str_equals('selector', func_name)) {
let selector_list = this.selector_parser.parse_selector(
content_start,
Expand All @@ -221,8 +214,7 @@ export class AtRulePreludeParser {
this.arena.set_first_child(func_node, selector_list)
}
}
// `style()`'s argument is a <declaration>, 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 <declaration> — 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) {
Expand All @@ -242,27 +234,34 @@ 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)
}
} else {
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
17 changes: 6 additions & 11 deletions src/value-node-parser.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down