Skip to content

Commit 06fbbf5

Browse files
authored
Add Ratio node type for media feature ratio values, fix clone() nesting (#285)
Media features like `aspect-ratio: 16/9` were exposing `.value` as just the numerator, silently dropping the denominator. Add a dedicated `RATIO` node type with `left`/`right` accessors so ratio values are represented as one coherent node, matching how scalar features (`min-width: 768px`) already work. Plain numbers (`aspect-ratio: 1`) are unaffected and still parse as a bare `Number`. Also fix `CSSNode.clone()`, which assigned nested `CSSNode` properties (`value`, `left`, `right`, `selector`) directly instead of recursively cloning them — plain-object output leaked live arena-backed node instances instead of serializable data.
1 parent 01ca611 commit 06fbbf5

8 files changed

Lines changed: 180 additions & 4 deletions

File tree

src/api.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,20 @@ describe('CSSNode', () => {
867867
// Function should have nested children
868868
expect(value.children?.[0].children?.length).toBeGreaterThan(0)
869869
})
870+
871+
test('nested node properties (value, left, right, selector) are plain objects, not live CSSNode instances', () => {
872+
const ast = parse('div { color: red; }')
873+
const decl = (ast.first_child! as Rule).block!.first_child!
874+
875+
const clone = decl.clone()
876+
const value = clone.value as PlainCSSNode
877+
878+
// Must be JSON-serializable plain data, not a wrapper holding an arena/source/index
879+
expect(value).not.toBeInstanceOf(CSSNode)
880+
expect(value.type_name).toBe('Value')
881+
expect(value.children?.[0].type_name).toBe('Identifier')
882+
expect(JSON.stringify(clone)).not.toContain('"arena"')
883+
})
870884
})
871885

872886
describe('Type-specific properties', () => {

src/arena.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const FEATURE_RANGE = 39 // Range syntax: (50px <= width <= 100px)
8989
export const AT_RULE_PRELUDE = 40 // Wrapper for at-rule prelude children
9090
export const PRELUDE_SELECTORLIST = 41 // Parenthesized selector list in at-rule preludes: (.parent), (figure) in @scope
9191
export const SUPPORTS_DECLARATION = 57 // declaration wrapper inside @supports: (display: flex)
92+
export const RATIO = 58 // ratio value: 16/9 in aspect-ratio: 16/9
9293

9394
// Wrapper node types
9495
export const VALUE = 50 // Wrapper for declaration values

src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
PRELUDE_OPERATOR,
4545
FEATURE_RANGE,
4646
AT_RULE_PRELUDE,
47+
RATIO,
4748
FLAG_IMPORTANT,
4849
} from './arena'
4950

@@ -90,6 +91,7 @@ export {
9091
PRELUDE_OPERATOR,
9192
FEATURE_RANGE,
9293
AT_RULE_PRELUDE,
94+
RATIO,
9395
FLAG_IMPORTANT,
9496
}
9597

@@ -141,4 +143,5 @@ export const NODE_TYPES = {
141143
PRELUDE_OPERATOR,
142144
FEATURE_RANGE,
143145
AT_RULE_PRELUDE,
146+
RATIO,
144147
} as const

src/css-node.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
AT_RULE_PRELUDE,
4545
PRELUDE_SELECTORLIST,
4646
SUPPORTS_DECLARATION,
47+
RATIO,
4748
FLAG_IMPORTANT,
4849
FLAG_HAS_ERROR,
4950
FLAG_HAS_BLOCK,
@@ -113,6 +114,7 @@ export const TYPE_NAMES = {
113114
[FEATURE_RANGE]: 'MediaFeatureRange',
114115
[AT_RULE_PRELUDE]: 'AtrulePrelude',
115116
[PRELUDE_SELECTORLIST]: 'PreludeSelectorList',
117+
[RATIO]: 'Ratio',
116118
} as const
117119

118120
export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown'
@@ -162,6 +164,7 @@ export type CSSNodeType =
162164
| typeof AT_RULE_PRELUDE
163165
| typeof PRELUDE_SELECTORLIST
164166
| typeof SUPPORTS_DECLARATION
167+
| typeof RATIO
165168

166169
// Options for cloning nodes
167170
export interface CloneOptions {
@@ -193,6 +196,8 @@ export type PlainCSSNode = {
193196
value?: PlainCSSNode | string | number | null
194197
unit?: string
195198
prelude?: PlainCSSNode | null
199+
left?: PlainCSSNode
200+
right?: PlainCSSNode
196201

197202
// Flags (only when true)
198203
is_important?: boolean
@@ -265,6 +270,8 @@ const enumerable_properties = [
265270
'is_vendor_prefixed',
266271
'has_error',
267272
'is_important',
273+
'left',
274+
'right',
268275
] as const
269276

270277
export class CSSNode {
@@ -499,6 +506,18 @@ export class CSSNode {
499506
return parse_dimension(this.text).unit
500507
}
501508

509+
/** Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` */
510+
get left(): CSSNode | undefined {
511+
if (this.type !== RATIO) return undefined
512+
return this.first_child ?? undefined
513+
}
514+
515+
/** Denominator for ratio values, e.g. the Number "9" in `aspect-ratio: 16/9` */
516+
get right(): CSSNode | undefined {
517+
if (this.type !== RATIO) return undefined
518+
return this.first_child?.next_sibling ?? undefined
519+
}
520+
502521
/** Check if this declaration has !important */
503522
get is_important(): boolean | undefined {
504523
if (this.type !== DECLARATION) return undefined
@@ -786,7 +805,9 @@ export class CSSNode {
786805

787806
for (let key of enumerable_properties) {
788807
let val = this[key]
789-
if (val !== undefined && val !== false) {
808+
if (val instanceof CSSNode) {
809+
plain[key] = val.clone({ deep, locations })
810+
} else if (val !== undefined && val !== false) {
790811
plain[key] = val
791812
}
792813
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export {
5050
type Identifier,
5151
type Number,
5252
type Dimension,
53+
type Ratio,
5354
type String,
5455
type Hash,
5556
type Function,
@@ -94,6 +95,7 @@ export {
9495
is_identifier,
9596
is_number,
9697
is_dimension,
98+
is_ratio,
9799
is_string,
98100
is_hash,
99101
is_function,

src/node-types.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
PRELUDE_SELECTORLIST,
6262
FEATURE_RANGE,
6363
AT_RULE_PRELUDE,
64+
RATIO,
6465
} from './arena'
6566

6667
// ---------------------------------------------------------------------------
@@ -253,6 +254,7 @@ type ValueLike =
253254
| Hash
254255
| Dimension
255256
| Number
257+
| Ratio
256258
// `@supports selector(...)`'s Function node holds its argument as a parsed SelectorList
257259
| SelectorList
258260
// `style(...)`'s Function node holds its argument as a parsed SupportsDeclaration
@@ -262,6 +264,16 @@ export type Identifier = Leaf<typeof IDENTIFIER, 'Identifier', { readonly name:
262264

263265
export type Number = Leaf<typeof NUMBER, 'Number', { readonly value: number }>
264266

267+
/** Ratio value, e.g. "16/9" in `aspect-ratio: 16/9`. A bare number like `aspect-ratio: 1` parses as a plain Number instead. */
268+
export type Ratio = Leaf<
269+
typeof RATIO,
270+
'Ratio',
271+
{
272+
readonly left: Number
273+
readonly right: Number
274+
}
275+
>
276+
265277
export type Dimension = Leaf<
266278
typeof DIMENSION,
267279
'Dimension',
@@ -477,7 +489,7 @@ export type MediaFeature = Leaf<
477489
{
478490
/** Feature name, e.g. "min-width" */
479491
readonly property: string
480-
/** Feature value node, or null for boolean features like (hover) */
492+
/** Feature value node (e.g. Dimension, Number, Identifier, Ratio), or null for boolean features like (hover) */
481493
readonly value: CSSNode | null
482494
}
483495
>
@@ -578,6 +590,7 @@ export type AnyNode =
578590
| Identifier
579591
| Number
580592
| Dimension
593+
| Ratio
581594
| String
582595
| Hash
583596
| Function
@@ -744,6 +757,9 @@ export function is_prelude_operator(node: CSSNode): node is PreludeOperator {
744757
export function is_feature_range(node: CSSNode): node is FeatureRange {
745758
return node.type === FEATURE_RANGE
746759
}
760+
export function is_ratio(node: CSSNode): node is Ratio {
761+
return node.type === RATIO
762+
}
747763
export function is_prelude_selectorlist(node: CSSNode): node is PreludeSelectorList {
748764
return node.type === PRELUDE_SELECTORLIST
749765
}

src/parse-atrule-prelude.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, test, expect } from 'vitest'
22
import { parse } from './parse'
33
import { parse_atrule_prelude } from './parse-atrule-prelude'
4+
import { CSSNode, type PlainCSSNode } from './css-node'
45
import type {
56
Atrule,
67
AtrulePrelude,
@@ -12,12 +13,13 @@ import type {
1213
MediaFeature,
1314
FeatureRange,
1415
Function,
15-
CSSNode,
1616
LayerName,
1717
SupportsQuery,
1818
SupportsDeclaration,
1919
Url,
2020
PreludeSelectorList,
21+
Ratio,
22+
Number as NumberNode,
2123
} from './node-types'
2224
import {
2325
AT_RULE,
@@ -41,6 +43,7 @@ import {
4143
NUMBER,
4244
SELECTOR_LIST,
4345
VALUE,
46+
RATIO,
4447
} from './arena'
4548

4649
describe('At-Rule Prelude Nodes', () => {
@@ -453,6 +456,7 @@ describe('At-Rule Prelude Nodes', () => {
453456
// Feature should have content
454457
const feature = query.first_child as MediaFeature | null
455458
expect(feature?.property).toBe('min-width')
459+
expect(feature?.value?.type_name).toBe('Dimension')
456460
})
457461

458462
test('should parse media feature (hover)', () => {
@@ -636,6 +640,83 @@ describe('At-Rule Prelude Nodes', () => {
636640
expect(feature?.value?.text).toBe('env(safe-area-inset-top)')
637641
})
638642

643+
test('should parse ratio value (aspect-ratio: 16/9)', () => {
644+
const css = '@media (aspect-ratio: 16/9) { }'
645+
const ast = parse(css)
646+
const atRule = ast.first_child! as Atrule
647+
const queryChildren =
648+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
649+
?.children || []
650+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
651+
| MediaFeature
652+
| undefined
653+
654+
expect(feature?.property).toBe('aspect-ratio')
655+
expect(feature?.value?.type).toBe(RATIO)
656+
expect(feature?.value?.text).toBe('16/9')
657+
658+
const ratio = feature?.value as Ratio | undefined
659+
expect(ratio?.left.type).toBe(NUMBER)
660+
expect(ratio?.left.text).toBe('16')
661+
expect(ratio?.left.value).toBe(16)
662+
expect(ratio?.right.type).toBe(NUMBER)
663+
expect(ratio?.right.text).toBe('9')
664+
expect(ratio?.right.value).toBe(9)
665+
})
666+
667+
test('clone() serializes Ratio.left/right as plain objects, not live CSSNode instances', () => {
668+
const css = '@media (aspect-ratio: 16/9) { }'
669+
const ast = parse(css)
670+
const atRule = ast.first_child! as Atrule
671+
const queryChildren =
672+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
673+
?.children || []
674+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
675+
| MediaFeature
676+
| undefined
677+
678+
const clone = feature!.clone()
679+
const ratio = clone.value as PlainCSSNode
680+
681+
expect(ratio.type_name).toBe('Ratio')
682+
expect(ratio.left).not.toBeInstanceOf(CSSNode)
683+
expect((ratio.left as PlainCSSNode).value).toBe(16)
684+
expect((ratio.right as PlainCSSNode).value).toBe(9)
685+
expect(JSON.stringify(clone)).not.toContain('"arena"')
686+
})
687+
688+
test('should parse ratio value with whitespace around the slash', () => {
689+
const css = '@media (aspect-ratio: 16 / 9) { }'
690+
const ast = parse(css)
691+
const atRule = ast.first_child! as Atrule
692+
const queryChildren =
693+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
694+
?.children || []
695+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
696+
| MediaFeature
697+
| undefined
698+
699+
const ratio = feature?.value as Ratio | undefined
700+
expect(ratio?.type).toBe(RATIO)
701+
expect(ratio?.left.text).toBe('16')
702+
expect(ratio?.right.text).toBe('9')
703+
})
704+
705+
test('should parse bare number value (aspect-ratio: 1), not a Ratio', () => {
706+
const css = '@media (aspect-ratio: 1) { }'
707+
const ast = parse(css)
708+
const atRule = ast.first_child! as Atrule
709+
const queryChildren =
710+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
711+
?.children || []
712+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
713+
| MediaFeature
714+
| undefined
715+
716+
expect(feature?.value?.type).toBe(NUMBER)
717+
expect((feature?.value as NumberNode | undefined)?.value).toBe(1)
718+
})
719+
639720
test('should have null value for boolean features', () => {
640721
const css = '@media (hover) { }'
641722
const ast = parse(css)
@@ -648,6 +729,7 @@ describe('At-Rule Prelude Nodes', () => {
648729
| undefined
649730

650731
expect(feature?.value).toBeNull()
732+
expect(feature?.first_child).toBeNull()
651733
})
652734

653735
test('should parse vendor-prefixed media feature (-ms-high-contrast: active)', () => {

src/parse-atrule-prelude.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
FUNCTION,
1919
STRING,
2020
FEATURE_RANGE,
21+
NUMBER,
22+
OPERATOR,
23+
RATIO,
2124
} from './arena'
2225
import {
2326
TOKEN_IDENT,
@@ -40,6 +43,7 @@ import {
4043
CHAR_GREATER_THAN,
4144
CHAR_EQUALS,
4245
CHAR_PERIOD,
46+
CHAR_FORWARD_SLASH,
4347
} from './string-utils'
4448
import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils'
4549
import { CSSNode } from './css-node'
@@ -398,7 +402,7 @@ export class AtRulePreludeParser {
398402
if (value_trimmed) {
399403
let value_first = this.parse_feature_value(value_trimmed[0], value_trimmed[1])
400404
if (value_first !== 0) {
401-
this.arena.set_first_child(feature, value_first)
405+
this.arena.set_first_child(feature, this.wrap_ratio_value(value_first))
402406
}
403407
}
404408
}
@@ -907,6 +911,39 @@ export class AtRulePreludeParser {
907911
return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column)
908912
}
909913

914+
// Detect a ratio value chain (e.g. "16/9" from aspect-ratio: 16/9) and collapse it into
915+
// a single RATIO node, so features like `aspect-ratio: 1` and `aspect-ratio: 16/9` both
916+
// expose one coherent value node instead of `.value` silently returning just the numerator.
917+
private wrap_ratio_value(first_node: number): number {
918+
if (this.arena.get_type(first_node) !== NUMBER) return first_node
919+
920+
let op_node = this.arena.get_next_sibling(first_node)
921+
if (op_node === 0 || this.arena.get_type(op_node) !== OPERATOR) return first_node
922+
if (this.arena.get_length(op_node) !== 1) return first_node
923+
if (this.source.charCodeAt(this.arena.get_start_offset(op_node)) !== CHAR_FORWARD_SLASH) {
924+
return first_node
925+
}
926+
927+
let second_node = this.arena.get_next_sibling(op_node)
928+
if (second_node === 0 || this.arena.get_type(second_node) !== NUMBER) return first_node
929+
if (this.arena.get_next_sibling(second_node) !== 0) return first_node
930+
931+
let start = this.arena.get_start_offset(first_node)
932+
let end = this.arena.get_start_offset(second_node) + this.arena.get_length(second_node)
933+
let ratio_node = this.arena.create_node(
934+
RATIO,
935+
start,
936+
end - start,
937+
this.arena.get_start_line(first_node),
938+
this.arena.get_start_column(first_node),
939+
)
940+
941+
this.arena.set_first_child(ratio_node, first_node)
942+
this.arena.set_next_sibling(first_node, second_node) // drop the "/" operator from the chain
943+
944+
return ratio_node
945+
}
946+
910947
// Parse @namespace prelude: [prefix] url("...") | "..."
911948
// e.g. @namespace url("http://www.w3.org/1999/xhtml");
912949
// e.g. @namespace svg url("http://www.w3.org/2000/svg");

0 commit comments

Comments
 (0)