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
1 change: 1 addition & 0 deletions src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ 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
export const SUPPORTS_DECLARATION = 57 // declaration wrapper inside @supports: (display: flex)

// Wrapper node types
export const VALUE = 50 // Wrapper for declaration values
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
LAYER_NAME,
PRELUDE_OPERATOR,
FEATURE_RANGE,
Expand Down Expand Up @@ -84,6 +85,7 @@ export {
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
LAYER_NAME,
PRELUDE_OPERATOR,
FEATURE_RANGE,
Expand Down Expand Up @@ -134,6 +136,7 @@ export const NODE_TYPES = {
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
LAYER_NAME,
PRELUDE_OPERATOR,
FEATURE_RANGE,
Expand Down
5 changes: 5 additions & 0 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
FEATURE_RANGE,
AT_RULE_PRELUDE,
PRELUDE_SELECTORLIST,
SUPPORTS_DECLARATION,
FLAG_IMPORTANT,
FLAG_HAS_ERROR,
FLAG_HAS_BLOCK,
Expand Down Expand Up @@ -105,6 +106,7 @@ export const TYPE_NAMES = {
[MEDIA_TYPE]: 'MediaType',
[CONTAINER_QUERY]: 'ContainerQuery',
[SUPPORTS_QUERY]: 'SupportsQuery',
[SUPPORTS_DECLARATION]: 'SupportsDeclaration',
[LAYER_NAME]: 'Layer',
[PRELUDE_OPERATOR]: 'Operator',
[FEATURE_RANGE]: 'MediaFeatureRange',
Expand Down Expand Up @@ -158,6 +160,7 @@ export type CSSNodeType =
| typeof FEATURE_RANGE
| typeof AT_RULE_PRELUDE
| typeof PRELUDE_SELECTORLIST
| typeof SUPPORTS_DECLARATION

// Options for cloning nodes
export interface CloneOptions {
Expand Down Expand Up @@ -242,6 +245,8 @@ const nodes_with_children = new Set<number>([
MEDIA_FEATURE,
CONTAINER_QUERY,
FEATURE_RANGE,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
])

const enumerable_properties = [
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
type MediaType,
type ContainerQuery,
type SupportsQuery,
type SupportsDeclaration,
type LayerName,
type PreludeOperator,
type FeatureRange,
Expand Down Expand Up @@ -118,6 +119,7 @@ export {
is_media_type,
is_container_query,
is_supports_query,
is_supports_declaration,
is_layer_name,
is_prelude_operator,
is_feature_range,
Expand Down
24 changes: 18 additions & 6 deletions src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
LAYER_NAME,
PRELUDE_OPERATOR,
PRELUDE_SELECTORLIST,
Expand Down Expand Up @@ -482,12 +483,19 @@ export type ContainerQuery = CSSNode &
clone(options?: CloneOptions): ToPlain<ContainerQuery>
}

export type SupportsQuery = CSSNode & {
readonly type: typeof SUPPORTS_QUERY
/** The supports condition text, e.g. "display: flex" from "supports(display: flex)" */
readonly value: string
clone(options?: CloneOptions): ToPlain<SupportsQuery>
}
export type SupportsQuery = CSSNode &
WithChildren<SupportsDeclaration> & {
readonly type: typeof SUPPORTS_QUERY
/** The supports condition text, e.g. "display: flex" from "supports(display: flex)" */
readonly value: string
clone(options?: CloneOptions): ToPlain<SupportsQuery>
}

export type SupportsDeclaration = CSSNode &
WithChildren<Declaration> & {
readonly type: typeof SUPPORTS_DECLARATION
clone(options?: CloneOptions): ToPlain<SupportsDeclaration>
}

export type LayerName = CSSNode & {
readonly type: typeof LAYER_NAME
Expand Down Expand Up @@ -581,6 +589,7 @@ export type AnyNode =
| MediaType
| ContainerQuery
| SupportsQuery
| SupportsDeclaration
| LayerName
| PreludeOperator
| FeatureRange
Expand Down Expand Up @@ -708,6 +717,9 @@ export function is_container_query(node: CSSNode): node is ContainerQuery {
export function is_supports_query(node: CSSNode): node is SupportsQuery {
return node.type === SUPPORTS_QUERY
}
export function is_supports_declaration(node: CSSNode): node is SupportsDeclaration {
return node.type === SUPPORTS_DECLARATION
}
export function is_layer_name(node: CSSNode): node is LayerName {
return node.type === LAYER_NAME
}
Expand Down
84 changes: 84 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AtrulePrelude,
Block,
ContainerQuery,
Declaration,
MediaQuery,
MediaType,
MediaFeature,
Expand All @@ -14,17 +15,20 @@ import type {
CSSNode,
LayerName,
SupportsQuery,
SupportsDeclaration,
Url,
PreludeSelectorList,
} from './node-types'
import {
AT_RULE,
BLOCK,
DECLARATION,
MEDIA_QUERY,
MEDIA_FEATURE,
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
LAYER_NAME,
IDENTIFIER,
PRELUDE_OPERATOR,
Expand Down Expand Up @@ -834,6 +838,86 @@ describe('At-Rule Prelude Nodes', () => {
expect(queries.length).toBe(2)
expect(operators.length).toBe(1)
})

it('should have a SupportsDeclaration as direct child of SupportsQuery', () => {
const css = '@supports (display: flex) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery

expect(query.has_children).toBe(true)
expect(query.children.length).toBe(1)
expect(query.children[0].type).toBe(SUPPORTS_DECLARATION)
})

it('should have a Declaration with property inside SupportsDeclaration', () => {
const css = '@supports (display: flex) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery
const supportsDecl = query.children[0] as SupportsDeclaration
const decl = supportsDecl.children[0] as Declaration

expect(supportsDecl.type).toBe(SUPPORTS_DECLARATION)
expect(decl.type).toBe(DECLARATION)
expect(decl.property).toBe('display')
expect(decl.is_vendor_prefixed).toBe(false)
})

it('should detect vendor-prefixed property via Declaration.is_vendor_prefixed', () => {
const css = '@supports (-webkit-appearance: none) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery
const supportsDecl = query.children[0] as SupportsDeclaration
const decl = supportsDecl.children[0] as Declaration

expect(decl.property).toBe('-webkit-appearance')
expect(decl.is_vendor_prefixed).toBe(true)
})

it('should have a Value child on the Declaration inside SupportsDeclaration', () => {
const css = '@supports (display: flex) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery
const supportsDecl = query.children[0] as SupportsDeclaration
const decl = supportsDecl.children[0] as Declaration

expect(decl.value).not.toBeNull()
expect((decl.value as CSSNode).text).toBe('flex')
})

it('should build SupportsDeclaration for each query in a multi-condition supports', () => {
const css = '@supports (display: flex) and (gap: 1rem) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []

expect(children.map((child) => child.type_name)).toEqual([
'SupportsQuery',
'Operator',
'SupportsQuery',
])

const queries = children.filter((c) => c.type === SUPPORTS_QUERY) as SupportsQuery[]

expect(queries.length).toBe(2)
for (const query of queries) {
expect(query.children.length).toBe(1)
expect(query.children[0].type).toBe(SUPPORTS_DECLARATION)
}

const firstDecl = (queries[0].children[0] as SupportsDeclaration).children[0] as Declaration
const secondDecl = (queries[1].children[0] as SupportsDeclaration)
.children[0] as Declaration
expect(firstDecl.property).toBe('display')
expect(secondDecl.property).toBe('gap')
})
})

describe('@layer', () => {
Expand Down
77 changes: 77 additions & 0 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
MEDIA_TYPE,
CONTAINER_QUERY,
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
DECLARATION,
VALUE,
LAYER_NAME,
IDENTIFIER,
PRELUDE_OPERATOR,
Expand Down Expand Up @@ -456,6 +459,13 @@ export class AtRulePreludeParser {
if (trimmed) {
this.arena.set_value_start_delta(query, trimmed[0] - feature_start)
this.arena.set_value_length(query, trimmed[1] - trimmed[0])

// Check for simple declaration: (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.append_children(query, [decl_child])
}
}

nodes.push(query)
Expand All @@ -475,6 +485,73 @@ export class AtRulePreludeParser {
return nodes
}

// Find the position of a colon that is not inside nested parentheses
private find_colon_at_depth_zero(start: number, end: number): number {
let depth = 0
for (let i = start; i < end; i++) {
let ch = this.source.charCodeAt(i)
if (ch === 0x28 /* ( */) {
depth++
} else if (ch === 0x29 /* ) */) {
depth--
} else if (ch === CHAR_COLON && depth === 0) {
return i
}
}
return -1
}

// Build SUPPORTS_DECLARATION → DECLARATION → VALUE tree for a simple (property: value) condition
private create_supports_declaration(
content_start: number,
content_end: number,
colon_pos: number,
): number {
let prop_trimmed = trim_boundaries(this.source, content_start, colon_pos)
let val_trimmed = trim_boundaries(this.source, colon_pos + 1, content_end)

if (!prop_trimmed) {
// No property name — degenerate input, return a bare SUPPORTS_DECLARATION
let bare = this.create_node(SUPPORTS_DECLARATION, content_start, content_end)
return bare
}

// DECLARATION spans from property start to value end (or colon if no value)
let decl_start = prop_trimmed[0]
let decl_end = val_trimmed ? val_trimmed[1] : colon_pos + 1
let decl = this.create_node(DECLARATION, decl_start, decl_end)
this.arena.set_content_start_delta(decl, 0) // property starts at node start
this.arena.set_content_length(decl, prop_trimmed[1] - prop_trimmed[0])

if (val_trimmed) {
let value_nodes = this.parse_feature_value(val_trimmed[0], val_trimmed[1])
let value_node: number
if (value_nodes.length === 0) {
value_node = this.arena.create_node(
VALUE,
val_trimmed[0],
0,
this.lexer.token_line,
this.lexer.token_column,
)
} else {
value_node = this.arena.create_node(
VALUE,
val_trimmed[0],
val_trimmed[1] - val_trimmed[0],
this.lexer.token_line,
this.lexer.token_column,
)
this.arena.append_children(value_node, value_nodes)
}
this.arena.append_children(decl, [value_node])
}

let supports_decl = this.create_node(SUPPORTS_DECLARATION, content_start, content_end)
this.arena.append_children(supports_decl, [decl])
return supports_decl
}

// Parse layer names: base, components, utilities
private parse_layer_names(): number[] {
let nodes: number[] = []
Expand Down
Loading