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
8 changes: 7 additions & 1 deletion src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof IDENTIFIER, 'Identifier', { readonly name: string }>

Expand Down Expand Up @@ -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' }
>

Expand Down Expand Up @@ -504,6 +510,10 @@ export type SupportsDeclaration = WithClone<
WithChildren<Declaration> & {
readonly type: typeof SUPPORTS_DECLARATION
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
}
>

Expand Down
73 changes: 73 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
FUNCTION,
OPERATOR,
NUMBER,
SELECTOR_LIST,
VALUE,
} from './arena'

describe('At-Rule Prelude Nodes', () => {
Expand Down Expand Up @@ -770,6 +772,14 @@ 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')
expect((decl as SupportsDeclaration).value?.type).toBe(VALUE)
expect((decl as SupportsDeclaration).value?.text).toBe('1')
})

test('should parse named style container query', () => {
Expand All @@ -786,6 +796,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)', () => {
Expand Down Expand Up @@ -895,6 +906,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)
Expand Down Expand Up @@ -1552,6 +1620,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', () => {
Expand Down
123 changes: 86 additions & 37 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 <complex-selector>, 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 <declaration>, 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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down