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 @@ -62,6 +62,7 @@ export const PARENTHESIS = 17 // parenthesized expression: (100% - 50px)
export const URL = 18 // URL: url("file.css"), url(image.png), used in values and @import
export const UNICODE_RANGE = 19 // unicode range: u+0025-00ff, u+4??
export const IF_BRANCH = 59 // Branch inside an if() function: <condition>: <value>
export const IF_CONDITION = 60 // Compound if()-branch condition: not style(...), style(...) and media(...), etc.

// Selector node type constants (for detailed selector parsing)
export const SELECTOR_LIST = 20 // comma-separated selectors
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
URL,
UNICODE_RANGE,
IF_BRANCH,
IF_CONDITION,
VALUE,
SELECTOR_LIST,
TYPE_SELECTOR,
Expand Down Expand Up @@ -69,6 +70,7 @@ export {
URL,
UNICODE_RANGE,
IF_BRANCH,
IF_CONDITION,
VALUE,
SELECTOR_LIST,
TYPE_SELECTOR,
Expand Down Expand Up @@ -120,6 +122,7 @@ export const NODE_TYPES = {
URL,
UNICODE_RANGE,
IF_BRANCH,
IF_CONDITION,
VALUE,
// Selector nodes
SELECTOR_LIST,
Expand Down
4 changes: 4 additions & 0 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
SUPPORTS_DECLARATION,
RATIO,
IF_BRANCH,
IF_CONDITION,
FLAG_IMPORTANT,
FLAG_HAS_ERROR,
FLAG_HAS_BLOCK,
Expand Down Expand Up @@ -118,6 +119,7 @@ export const TYPE_NAMES = {
[PRELUDE_SELECTORLIST]: 'PreludeSelectorList',
[RATIO]: 'Ratio',
[IF_BRANCH]: 'IfBranch',
[IF_CONDITION]: 'IfCondition',
} as const

export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown'
Expand Down Expand Up @@ -169,6 +171,7 @@ export type CSSNodeType =
| typeof SUPPORTS_DECLARATION
| typeof RATIO
| typeof IF_BRANCH
| typeof IF_CONDITION

// Options for cloning nodes
export interface CloneOptions {
Expand Down Expand Up @@ -262,6 +265,7 @@ const nodes_with_children = new Set<number>([
SUPPORTS_QUERY,
SUPPORTS_DECLARATION,
IF_BRANCH,
IF_CONDITION,
])

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 @@ -59,6 +59,7 @@ export {
type Url,
type UnicodeRange,
type IfBranch,
type IfCondition,
type Value,
type SelectorNode,
type TypeSelector,
Expand Down Expand Up @@ -105,6 +106,7 @@ export {
is_url,
is_unicode_range,
is_if_branch,
is_if_condition,
is_value,
is_type_selector,
is_class_selector,
Expand Down
31 changes: 27 additions & 4 deletions src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
URL,
UNICODE_RANGE,
IF_BRANCH,
IF_CONDITION,
VALUE,
SELECTOR_LIST,
TYPE_SELECTOR,
Expand Down Expand Up @@ -342,24 +343,42 @@ export type Value = WithClone<
CSSNode & WithChildren<ValueLike> & { readonly type: typeof VALUE; readonly type_name: 'Value' }
>

/**
* A compound `if()`-branch condition combining multiple tests with `not`/`and`/`or`,
* e.g. `not style(--scheme: light)` or `style(--a: 1) and media(width > 600px)`.
*
* Children are the flat sequence of PreludeOperator ("not"/"and"/"or") and
* Function (`style()`/`media()`/`supports()`) nodes, in source order — the same
* shape `@supports`'s own compound condition uses.
*/
export type IfCondition = WithClone<
CSSNode &
WithChildren<Function | PreludeOperator> & {
readonly type: typeof IF_CONDITION
readonly type_name: 'IfCondition'
}
>

/**
* One branch inside a CSS `if()` inline conditional function.
*
* Each branch corresponds to a `<condition>: <value>` pair in:
* `if( <condition>: <value>; … else: <fallback> )`
*
* - `condition` — the parsed condition node (Function, e.g. `style(--x: 1)`, or Identifier `else`)
* - `condition` — the parsed condition node: a Function (e.g. `style(--x: 1)`), an
* Identifier (`else`), or an IfCondition for compound `not`/`and`/`or` conditions
* (e.g. `not style(--x: 1)`)
* - `value` — the value text, e.g. `"green"`; `null` when omitted
* - `is_else` — `true` for the `else` branch
* - `first_child` — same node as `condition`
* - `children` — condition node followed by parsed value nodes
*/
export type IfBranch = CSSNode &
WithChildren<Function | Identifier | Value> & {
WithChildren<Function | Identifier | IfCondition | Value> & {
readonly type: typeof IF_BRANCH
readonly type_name: 'IfBranch'
/** The parsed condition node, e.g. the Function "style(--active: 1)" or the Identifier "else" */
readonly condition: Function | Identifier
/** The parsed condition node, e.g. the Function "style(--active: 1)", the Identifier "else", or an IfCondition for compound conditions */
readonly condition: Function | Identifier | IfCondition
/** The parsed value as a VALUE node, or null when the branch value is empty */
readonly value: Value | null
/** True when this is the else branch */
Expand Down Expand Up @@ -638,6 +657,7 @@ export type AnyNode =
| Url
| UnicodeRange
| IfBranch
| IfCondition
| Value
| TypeSelector
| ClassSelector
Expand Down Expand Up @@ -731,6 +751,9 @@ export function is_unicode_range(node: CSSNode): node is UnicodeRange {
export function is_if_branch(node: CSSNode): node is IfBranch {
return node.type === IF_BRANCH
}
export function is_if_condition(node: CSSNode): node is IfCondition {
return node.type === IF_CONDITION
}
export function is_value(node: CSSNode): node is Value {
return node.type === VALUE
}
Expand Down
134 changes: 134 additions & 0 deletions src/parse-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
URL,
UNICODE_RANGE,
IF_BRANCH,
IF_CONDITION,
VALUE,
DECLARATION,
MEDIA_FEATURE,
Expand All @@ -28,6 +29,7 @@ import type {
FeatureRange,
Function,
IfBranch,
IfCondition,
MediaFeature,
Number,
Operator,
Expand Down Expand Up @@ -1328,6 +1330,138 @@ describe('Value Node Types', () => {
expect((secondDecl.value as Value).text).toBe('1rem')
})

test('style() condition supports the full compound and/or/not grammar', () => {
// MDN if() example: style((--scheme: dark) or (--scheme: very-dark))
const func = getFunc(
'div { background-color: if(style((--scheme: dark) or (--scheme: very-dark)): black;) }',
)
const styleFunc = getBranch(func, 0)?.first_child as Function
expect(styleFunc.name).toBe('style')
// children: SupportsQuery, PreludeOperator("or"), SupportsQuery
expect(styleFunc.children).toHaveLength(3)

const first = styleFunc.children[0] as SupportsQuery
expect(first.type).toBe(SUPPORTS_QUERY)
expect((first.first_child as SupportsDeclaration).property).toBe('--scheme')
expect(((first.first_child as SupportsDeclaration).value as Value).text).toBe('dark')

expect(styleFunc.children[1].type).toBe(PRELUDE_OPERATOR)
expect(styleFunc.children[1].text).toBe('or')

const second = styleFunc.children[2] as SupportsQuery
expect(second.type).toBe(SUPPORTS_QUERY)
expect((second.first_child as SupportsDeclaration).property).toBe('--scheme')
expect(((second.first_child as SupportsDeclaration).value as Value).text).toBe('very-dark')
})

test('style() condition supports "and" combining two parenthesized declarations', () => {
// MDN if() example: style((--scheme: dark) and (--contrast: hi))
const func = getFunc(
'div { background-color: if(style((--scheme: dark) and (--contrast: hi)): black;) }',
)
const styleFunc = getBranch(func, 0)?.first_child as Function
expect(styleFunc.children).toHaveLength(3)
expect(styleFunc.children[1].type).toBe(PRELUDE_OPERATOR)
expect(styleFunc.children[1].text).toBe('and')
expect(
((styleFunc.children[0] as SupportsQuery).first_child as SupportsDeclaration).property,
).toBe('--scheme')
expect(
((styleFunc.children[2] as SupportsQuery).first_child as SupportsDeclaration).property,
).toBe('--contrast')
})

// ── Compound if-condition: not/and/or combining test functions ─────────

describe('compound if-condition (not/and/or)', () => {
test('"not style(...)" produces an IfCondition wrapping Operator + Function', () => {
// The reported bug: parsing dropped style() entirely, keeping only "not".
const func = getFunc('div { background-color: if(not style(--scheme: light): black;) }')
expect(func?.children).toHaveLength(1)

const b0 = getBranch(func, 0)!
expect(b0.condition.type).toBe(IF_CONDITION)
expect(b0.condition.type_name).toBe('IfCondition')
expect(b0.condition.text).toBe('not style(--scheme: light)')
expect((b0.value as Value).text).toBe('black')

const condition = b0.condition as IfCondition
expect(condition.children).toHaveLength(2)
expect(condition.children[0].type).toBe(PRELUDE_OPERATOR)
expect(condition.children[0].text).toBe('not')

const styleFunc = condition.children[1] as Function
expect(styleFunc.type).toBe(FUNCTION)
expect(styleFunc.name).toBe('style')
const decl = styleFunc.children[0] as SupportsDeclaration
expect(decl.property).toBe('--scheme')
expect((decl.value as Value).text).toBe('light')
})

test('MDN example: multi-line whitespace and trailing semicolon around "not style(...)"', () => {
const func = getFunc(
'div { background-color: if(\n\t\t\t\tnot style(--scheme: light): black;\n\t\t\t); }',
)
const b0 = getBranch(func, 0)!
expect(b0.condition.type_name).toBe('IfCondition')
expect(b0.condition.text).toBe('not style(--scheme: light)')
expect((b0.value as Value).text).toBe('black')
})

test('branch.first_child is the IfCondition wrapper for compound conditions', () => {
const func = getFunc('div { color: if(not style(--x: 1): red; else: blue) }')
const b0 = getBranch(func, 0)!
expect(b0.first_child?.type).toBe(IF_CONDITION)
// children: IfCondition, VALUE — same shape as the simple-condition case
expect(b0.children).toHaveLength(2)
expect(b0.children[0].type).toBe(IF_CONDITION)
expect(b0.children[1].type).toBe(VALUE)
})

test('"style(...) and media(...)" combines two test functions', () => {
const func = getFunc(
'div { color: if(style(--dark: 1) and media(min-width: 600px): black; else: white) }',
)
const b0 = getBranch(func, 0)!
const condition = b0.condition as IfCondition
expect(condition.type).toBe(IF_CONDITION)
expect(condition.children).toHaveLength(3)

expect((condition.children[0] as Function).name).toBe('style')
expect(condition.children[1].type).toBe(PRELUDE_OPERATOR)
expect(condition.children[1].text).toBe('and')
expect((condition.children[2] as Function).name).toBe('media')

expect((b0.value as Value).text).toBe('black')
})

test('"style(...) or supports(...)" combines two test functions', () => {
const func = getFunc(
'div { color: if(style(--x: 1) or supports(display: grid): black; else: white) }',
)
const condition = getBranch(func, 0)?.condition as IfCondition
expect(condition.children).toHaveLength(3)
expect(condition.children[1].text).toBe('or')
})

test('a simple single-function condition stays unwrapped (no IfCondition)', () => {
// Guards against regressing the common case while fixing the compound one
const func = getFunc('div { color: if(style(--active: 1): green; else: red) }')
const b0 = getBranch(func, 0)!
expect(b0.condition.type).toBe(FUNCTION)
expect(b0.condition.type_name).toBe('Function')
})

test('unterminated "not style(" does not throw or run past the value', () => {
const func = getFunc('div { color: if(not style(--x: 1 }')
expect(func?.name).toBe('if')
const b0 = getBranch(func, 0)!
// style( is unterminated, so the compound condition can't close either —
// falls back to whatever was parsed without throwing.
expect(b0.condition).toBeDefined()
})
})

// ── Multiple branches ─────────────────────────────────────────────────

test('should parse if() with three branches', () => {
Expand Down
Loading
Loading