diff --git a/src/api.test.ts b/src/api.test.ts index ac17f59..4adf968 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -867,6 +867,20 @@ describe('CSSNode', () => { // Function should have nested children expect(value.children?.[0].children?.length).toBeGreaterThan(0) }) + + test('nested node properties (value, left, right, selector) are plain objects, not live CSSNode instances', () => { + const ast = parse('div { color: red; }') + const decl = (ast.first_child! as Rule).block!.first_child! + + const clone = decl.clone() + const value = clone.value as PlainCSSNode + + // Must be JSON-serializable plain data, not a wrapper holding an arena/source/index + expect(value).not.toBeInstanceOf(CSSNode) + expect(value.type_name).toBe('Value') + expect(value.children?.[0].type_name).toBe('Identifier') + expect(JSON.stringify(clone)).not.toContain('"arena"') + }) }) describe('Type-specific properties', () => { diff --git a/src/arena.ts b/src/arena.ts index 849eed2..23983df 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -89,6 +89,7 @@ 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) +export const RATIO = 58 // ratio value: 16/9 in aspect-ratio: 16/9 // Wrapper node types export const VALUE = 50 // Wrapper for declaration values diff --git a/src/constants.ts b/src/constants.ts index 449053c..e0db88d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -44,6 +44,7 @@ import { PRELUDE_OPERATOR, FEATURE_RANGE, AT_RULE_PRELUDE, + RATIO, FLAG_IMPORTANT, } from './arena' @@ -90,6 +91,7 @@ export { PRELUDE_OPERATOR, FEATURE_RANGE, AT_RULE_PRELUDE, + RATIO, FLAG_IMPORTANT, } @@ -141,4 +143,5 @@ export const NODE_TYPES = { PRELUDE_OPERATOR, FEATURE_RANGE, AT_RULE_PRELUDE, + RATIO, } as const diff --git a/src/css-node.ts b/src/css-node.ts index 1b1c147..bf50ca8 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -44,6 +44,7 @@ import { AT_RULE_PRELUDE, PRELUDE_SELECTORLIST, SUPPORTS_DECLARATION, + RATIO, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -113,6 +114,7 @@ export const TYPE_NAMES = { [FEATURE_RANGE]: 'MediaFeatureRange', [AT_RULE_PRELUDE]: 'AtrulePrelude', [PRELUDE_SELECTORLIST]: 'PreludeSelectorList', + [RATIO]: 'Ratio', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -162,6 +164,7 @@ export type CSSNodeType = | typeof AT_RULE_PRELUDE | typeof PRELUDE_SELECTORLIST | typeof SUPPORTS_DECLARATION + | typeof RATIO // Options for cloning nodes export interface CloneOptions { @@ -193,6 +196,8 @@ export type PlainCSSNode = { value?: PlainCSSNode | string | number | null unit?: string prelude?: PlainCSSNode | null + left?: PlainCSSNode + right?: PlainCSSNode // Flags (only when true) is_important?: boolean @@ -265,6 +270,8 @@ const enumerable_properties = [ 'is_vendor_prefixed', 'has_error', 'is_important', + 'left', + 'right', ] as const export class CSSNode { @@ -499,6 +506,18 @@ export class CSSNode { return parse_dimension(this.text).unit } + /** Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` */ + get left(): CSSNode | undefined { + if (this.type !== RATIO) return undefined + return this.first_child ?? undefined + } + + /** Denominator for ratio values, e.g. the Number "9" in `aspect-ratio: 16/9` */ + get right(): CSSNode | undefined { + if (this.type !== RATIO) return undefined + return this.first_child?.next_sibling ?? undefined + } + /** Check if this declaration has !important */ get is_important(): boolean | undefined { if (this.type !== DECLARATION) return undefined @@ -786,7 +805,9 @@ export class CSSNode { for (let key of enumerable_properties) { let val = this[key] - if (val !== undefined && val !== false) { + if (val instanceof CSSNode) { + plain[key] = val.clone({ deep, locations }) + } else if (val !== undefined && val !== false) { plain[key] = val } } diff --git a/src/index.ts b/src/index.ts index 37a73a7..832278f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ export { type Identifier, type Number, type Dimension, + type Ratio, type String, type Hash, type Function, @@ -94,6 +95,7 @@ export { is_identifier, is_number, is_dimension, + is_ratio, is_string, is_hash, is_function, diff --git a/src/node-types.ts b/src/node-types.ts index af0b077..e868d8c 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -61,6 +61,7 @@ import { PRELUDE_SELECTORLIST, FEATURE_RANGE, AT_RULE_PRELUDE, + RATIO, } from './arena' // --------------------------------------------------------------------------- @@ -253,6 +254,7 @@ type ValueLike = | Hash | Dimension | Number + | Ratio // `@supports selector(...)`'s Function node holds its argument as a parsed SelectorList | SelectorList // `style(...)`'s Function node holds its argument as a parsed SupportsDeclaration @@ -262,6 +264,16 @@ export type Identifier = Leaf +/** Ratio value, e.g. "16/9" in `aspect-ratio: 16/9`. A bare number like `aspect-ratio: 1` parses as a plain Number instead. */ +export type Ratio = Leaf< + typeof RATIO, + 'Ratio', + { + readonly left: Number + readonly right: Number + } +> + export type Dimension = Leaf< typeof DIMENSION, 'Dimension', @@ -477,7 +489,7 @@ export type MediaFeature = Leaf< { /** Feature name, e.g. "min-width" */ readonly property: string - /** Feature value node, or null for boolean features like (hover) */ + /** Feature value node (e.g. Dimension, Number, Identifier, Ratio), or null for boolean features like (hover) */ readonly value: CSSNode | null } > @@ -578,6 +590,7 @@ export type AnyNode = | Identifier | Number | Dimension + | Ratio | String | Hash | Function @@ -744,6 +757,9 @@ export function is_prelude_operator(node: CSSNode): node is PreludeOperator { export function is_feature_range(node: CSSNode): node is FeatureRange { return node.type === FEATURE_RANGE } +export function is_ratio(node: CSSNode): node is Ratio { + return node.type === RATIO +} export function is_prelude_selectorlist(node: CSSNode): node is PreludeSelectorList { return node.type === PRELUDE_SELECTORLIST } diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 3bdde04..36b9633 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { parse } from './parse' import { parse_atrule_prelude } from './parse-atrule-prelude' +import { CSSNode, type PlainCSSNode } from './css-node' import type { Atrule, AtrulePrelude, @@ -12,12 +13,13 @@ import type { MediaFeature, FeatureRange, Function, - CSSNode, LayerName, SupportsQuery, SupportsDeclaration, Url, PreludeSelectorList, + Ratio, + Number as NumberNode, } from './node-types' import { AT_RULE, @@ -41,6 +43,7 @@ import { NUMBER, SELECTOR_LIST, VALUE, + RATIO, } from './arena' describe('At-Rule Prelude Nodes', () => { @@ -453,6 +456,7 @@ describe('At-Rule Prelude Nodes', () => { // Feature should have content const feature = query.first_child as MediaFeature | null expect(feature?.property).toBe('min-width') + expect(feature?.value?.type_name).toBe('Dimension') }) test('should parse media feature (hover)', () => { @@ -636,6 +640,83 @@ describe('At-Rule Prelude Nodes', () => { expect(feature?.value?.text).toBe('env(safe-area-inset-top)') }) + test('should parse ratio value (aspect-ratio: 16/9)', () => { + const css = '@media (aspect-ratio: 16/9) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + expect(feature?.property).toBe('aspect-ratio') + expect(feature?.value?.type).toBe(RATIO) + expect(feature?.value?.text).toBe('16/9') + + const ratio = feature?.value as Ratio | undefined + expect(ratio?.left.type).toBe(NUMBER) + expect(ratio?.left.text).toBe('16') + expect(ratio?.left.value).toBe(16) + expect(ratio?.right.type).toBe(NUMBER) + expect(ratio?.right.text).toBe('9') + expect(ratio?.right.value).toBe(9) + }) + + test('clone() serializes Ratio.left/right as plain objects, not live CSSNode instances', () => { + const css = '@media (aspect-ratio: 16/9) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + const clone = feature!.clone() + const ratio = clone.value as PlainCSSNode + + expect(ratio.type_name).toBe('Ratio') + expect(ratio.left).not.toBeInstanceOf(CSSNode) + expect((ratio.left as PlainCSSNode).value).toBe(16) + expect((ratio.right as PlainCSSNode).value).toBe(9) + expect(JSON.stringify(clone)).not.toContain('"arena"') + }) + + test('should parse ratio value with whitespace around the slash', () => { + const css = '@media (aspect-ratio: 16 / 9) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + const ratio = feature?.value as Ratio | undefined + expect(ratio?.type).toBe(RATIO) + expect(ratio?.left.text).toBe('16') + expect(ratio?.right.text).toBe('9') + }) + + test('should parse bare number value (aspect-ratio: 1), not a Ratio', () => { + const css = '@media (aspect-ratio: 1) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + expect(feature?.value?.type).toBe(NUMBER) + expect((feature?.value as NumberNode | undefined)?.value).toBe(1) + }) + test('should have null value for boolean features', () => { const css = '@media (hover) { }' const ast = parse(css) @@ -648,6 +729,7 @@ describe('At-Rule Prelude Nodes', () => { | undefined expect(feature?.value).toBeNull() + expect(feature?.first_child).toBeNull() }) test('should parse vendor-prefixed media feature (-ms-high-contrast: active)', () => { diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 5996ab6..be6eb0b 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -18,6 +18,9 @@ import { FUNCTION, STRING, FEATURE_RANGE, + NUMBER, + OPERATOR, + RATIO, } from './arena' import { TOKEN_IDENT, @@ -40,6 +43,7 @@ import { CHAR_GREATER_THAN, CHAR_EQUALS, CHAR_PERIOD, + CHAR_FORWARD_SLASH, } from './string-utils' import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' @@ -398,7 +402,7 @@ export class AtRulePreludeParser { if (value_trimmed) { let value_first = this.parse_feature_value(value_trimmed[0], value_trimmed[1]) if (value_first !== 0) { - this.arena.set_first_child(feature, value_first) + this.arena.set_first_child(feature, this.wrap_ratio_value(value_first)) } } } @@ -907,6 +911,39 @@ export class AtRulePreludeParser { return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column) } + // Detect a ratio value chain (e.g. "16/9" from aspect-ratio: 16/9) and collapse it into + // a single RATIO node, so features like `aspect-ratio: 1` and `aspect-ratio: 16/9` both + // expose one coherent value node instead of `.value` silently returning just the numerator. + private wrap_ratio_value(first_node: number): number { + if (this.arena.get_type(first_node) !== NUMBER) return first_node + + let op_node = this.arena.get_next_sibling(first_node) + if (op_node === 0 || this.arena.get_type(op_node) !== OPERATOR) return first_node + if (this.arena.get_length(op_node) !== 1) return first_node + if (this.source.charCodeAt(this.arena.get_start_offset(op_node)) !== CHAR_FORWARD_SLASH) { + return first_node + } + + let second_node = this.arena.get_next_sibling(op_node) + if (second_node === 0 || this.arena.get_type(second_node) !== NUMBER) return first_node + if (this.arena.get_next_sibling(second_node) !== 0) return first_node + + let start = this.arena.get_start_offset(first_node) + let end = this.arena.get_start_offset(second_node) + this.arena.get_length(second_node) + let ratio_node = this.arena.create_node( + RATIO, + start, + end - start, + this.arena.get_start_line(first_node), + this.arena.get_start_column(first_node), + ) + + this.arena.set_first_child(ratio_node, first_node) + this.arena.set_next_sibling(first_node, second_node) // drop the "/" operator from the chain + + return ratio_node + } + // Parse @namespace prelude: [prefix] url("...") | "..." // e.g. @namespace url("http://www.w3.org/1999/xhtml"); // e.g. @namespace svg url("http://www.w3.org/2000/svg");