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
14 changes: 14 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
PRELUDE_OPERATOR,
FEATURE_RANGE,
AT_RULE_PRELUDE,
RATIO,
FLAG_IMPORTANT,
} from './arena'

Expand Down Expand Up @@ -90,6 +91,7 @@ export {
PRELUDE_OPERATOR,
FEATURE_RANGE,
AT_RULE_PRELUDE,
RATIO,
FLAG_IMPORTANT,
}

Expand Down Expand Up @@ -141,4 +143,5 @@ export const NODE_TYPES = {
PRELUDE_OPERATOR,
FEATURE_RANGE,
AT_RULE_PRELUDE,
RATIO,
} as const
23 changes: 22 additions & 1 deletion src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
AT_RULE_PRELUDE,
PRELUDE_SELECTORLIST,
SUPPORTS_DECLARATION,
RATIO,
FLAG_IMPORTANT,
FLAG_HAS_ERROR,
FLAG_HAS_BLOCK,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,6 +270,8 @@ const enumerable_properties = [
'is_vendor_prefixed',
'has_error',
'is_important',
'left',
'right',
] as const

export class CSSNode {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
type Identifier,
type Number,
type Dimension,
type Ratio,
type String,
type Hash,
type Function,
Expand Down Expand Up @@ -94,6 +95,7 @@ export {
is_identifier,
is_number,
is_dimension,
is_ratio,
is_string,
is_hash,
is_function,
Expand Down
18 changes: 17 additions & 1 deletion src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
PRELUDE_SELECTORLIST,
FEATURE_RANGE,
AT_RULE_PRELUDE,
RATIO,
} from './arena'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -262,6 +264,16 @@ export type Identifier = Leaf<typeof IDENTIFIER, 'Identifier', { readonly name:

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

/** 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',
Expand Down Expand Up @@ -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
}
>
Expand Down Expand Up @@ -578,6 +590,7 @@ export type AnyNode =
| Identifier
| Number
| Dimension
| Ratio
| String
| Hash
| Function
Expand Down Expand Up @@ -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
}
84 changes: 83 additions & 1 deletion src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -41,6 +43,7 @@ import {
NUMBER,
SELECTOR_LIST,
VALUE,
RATIO,
} from './arena'

describe('At-Rule Prelude Nodes', () => {
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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)
Expand All @@ -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)', () => {
Expand Down
39 changes: 38 additions & 1 deletion src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
FUNCTION,
STRING,
FEATURE_RANGE,
NUMBER,
OPERATOR,
RATIO,
} from './arena'
import {
TOKEN_IDENT,
Expand All @@ -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'
Expand Down Expand Up @@ -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))
}
}
}
Expand Down Expand Up @@ -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");
Expand Down
Loading