diff --git a/src/arena.ts b/src/arena.ts index 1a75f1e..447e095 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -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 diff --git a/src/constants.ts b/src/constants.ts index 79c9757..449053c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -39,6 +39,7 @@ import { MEDIA_TYPE, CONTAINER_QUERY, SUPPORTS_QUERY, + SUPPORTS_DECLARATION, LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, @@ -84,6 +85,7 @@ export { MEDIA_TYPE, CONTAINER_QUERY, SUPPORTS_QUERY, + SUPPORTS_DECLARATION, LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, @@ -134,6 +136,7 @@ export const NODE_TYPES = { MEDIA_TYPE, CONTAINER_QUERY, SUPPORTS_QUERY, + SUPPORTS_DECLARATION, LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, diff --git a/src/css-node.ts b/src/css-node.ts index 638d4ae..fed7dff 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -43,6 +43,7 @@ import { FEATURE_RANGE, AT_RULE_PRELUDE, PRELUDE_SELECTORLIST, + SUPPORTS_DECLARATION, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -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', @@ -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 { @@ -242,6 +245,8 @@ const nodes_with_children = new Set([ MEDIA_FEATURE, CONTAINER_QUERY, FEATURE_RANGE, + SUPPORTS_QUERY, + SUPPORTS_DECLARATION, ]) const enumerable_properties = [ diff --git a/src/index.ts b/src/index.ts index 1c6974a..3bf0567 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,7 @@ export { type MediaType, type ContainerQuery, type SupportsQuery, + type SupportsDeclaration, type LayerName, type PreludeOperator, type FeatureRange, @@ -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, diff --git a/src/node-types.ts b/src/node-types.ts index adc1e5e..897ee6c 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -55,6 +55,7 @@ import { MEDIA_TYPE, CONTAINER_QUERY, SUPPORTS_QUERY, + SUPPORTS_DECLARATION, LAYER_NAME, PRELUDE_OPERATOR, PRELUDE_SELECTORLIST, @@ -482,12 +483,19 @@ export type ContainerQuery = CSSNode & clone(options?: CloneOptions): ToPlain } -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 -} +export type SupportsQuery = CSSNode & + WithChildren & { + readonly type: typeof SUPPORTS_QUERY + /** The supports condition text, e.g. "display: flex" from "supports(display: flex)" */ + readonly value: string + clone(options?: CloneOptions): ToPlain + } + +export type SupportsDeclaration = CSSNode & + WithChildren & { + readonly type: typeof SUPPORTS_DECLARATION + clone(options?: CloneOptions): ToPlain + } export type LayerName = CSSNode & { readonly type: typeof LAYER_NAME @@ -581,6 +589,7 @@ export type AnyNode = | MediaType | ContainerQuery | SupportsQuery + | SupportsDeclaration | LayerName | PreludeOperator | FeatureRange @@ -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 } diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index e63e011..836ba52 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -6,6 +6,7 @@ import type { AtrulePrelude, Block, ContainerQuery, + Declaration, MediaQuery, MediaType, MediaFeature, @@ -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, @@ -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', () => { diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 04d757c..f0f2264 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -7,6 +7,9 @@ import { MEDIA_TYPE, CONTAINER_QUERY, SUPPORTS_QUERY, + SUPPORTS_DECLARATION, + DECLARATION, + VALUE, LAYER_NAME, IDENTIFIER, PRELUDE_OPERATOR, @@ -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) @@ -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[] = []