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
4 changes: 2 additions & 2 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
FLAG_HAS_PARENS,
} from './arena'

import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace } from './string-utils'
import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace, str_starts_with } from './string-utils'
import { parse_dimension } from './parse-utils'

// Type name lookup table - maps numeric type to CSSTree-compatible strings
Expand Down Expand Up @@ -239,7 +239,7 @@ export class CSSNode {
// For URL nodes without children (e.g., @import url(...)), extract from text
// Handle both url("...") and url('...') and just "..." or '...'
const text = this.text
if (text.startsWith('url(')) {
if (str_starts_with(text, 'url(')) {
// url("...") or url('...') or url(...) - extract content between parens
const openParen = text.indexOf('(')
const closeParen = text.lastIndexOf(')')
Expand Down
8 changes: 4 additions & 4 deletions src/parse-anplusb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { Lexer } from './lexer'
import { NTH_SELECTOR, CSSDataArena } from './arena'
import { TOKEN_IDENT, TOKEN_NUMBER, TOKEN_DIMENSION, TOKEN_DELIM, type TokenType } from './token-types'
import { CHAR_MINUS_HYPHEN, CHAR_PLUS } from './string-utils'
import { CHAR_MINUS_HYPHEN, CHAR_PLUS, str_equals, str_index_of } from './string-utils'
import { skip_whitespace_forward } from './parse-utils'
import { CSSNode } from './css-node'

Expand Down Expand Up @@ -53,9 +53,9 @@ export class ANplusBParser {

// Handle special keywords: odd, even
if (this.lexer.token_type === TOKEN_IDENT) {
const text = this.source.substring(this.lexer.token_start, this.lexer.token_end).toLowerCase()
const text = this.source.substring(this.lexer.token_start, this.lexer.token_end)

if (text === 'odd' || text === 'even') {
if (str_equals('odd', text) || str_equals('even', text)) {
a_start = this.lexer.token_start
a_end = this.lexer.token_end
return this.create_anplusb_node(node_start, a_start, a_end, 0, 0)
Expand Down Expand Up @@ -168,7 +168,7 @@ export class ANplusBParser {
// Handle dimension tokens: 2n, 3n+1, -5n-2
if (this.lexer.token_type === TOKEN_DIMENSION) {
const token_text = this.source.substring(this.lexer.token_start, this.lexer.token_end)
const n_index = token_text.toLowerCase().indexOf('n')
const n_index = str_index_of(token_text, 'n')

if (n_index !== -1) {
a_start = this.lexer.token_start
Expand Down
60 changes: 60 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,3 +1361,63 @@ describe('parse_atrule_prelude()', () => {
})
})
})

describe('Case-insensitive at-rule keywords', () => {
it('should parse @MEDIA with uppercase', () => {
const root = parse('@MEDIA (min-width: 768px) { body { color: red; } }')
const atrule = root.first_child
expect(atrule?.name).toBe('MEDIA')
})

it('should parse @Media with mixed case', () => {
const root = parse('@Media (min-width: 768px) { body { color: red; } }')
const atrule = root.first_child
expect(atrule?.name).toBe('Media')
})

it('should parse @IMPORT with uppercase', () => {
const root = parse('@IMPORT url("style.css");')
const atrule = root.first_child
expect(atrule?.name).toBe('IMPORT')
})

it('should parse @SUPPORTS with uppercase', () => {
const root = parse('@SUPPORTS (display: grid) { body { display: grid; } }')
const atrule = root.first_child
expect(atrule?.name).toBe('SUPPORTS')
})

it('should parse @LAYER with uppercase', () => {
const root = parse('@LAYER base { }')
const atrule = root.first_child
expect(atrule?.name).toBe('LAYER')
})

it('should parse @CONTAINER with uppercase', () => {
const root = parse('@CONTAINER (min-width: 400px) { }')
const atrule = root.first_child
expect(atrule?.name).toBe('CONTAINER')
})

it('should parse media query operators in uppercase', () => {
const root = parse('@media (min-width: 768px) AND (max-width: 1024px) { }')
const atrule = root.first_child
expect(atrule?.name).toBe('media')
// Verify the prelude was parsed (operators are case-insensitive)
expect(atrule?.children.length).toBeGreaterThan(0)
})

it('should parse OR operator in uppercase', () => {
const root = parse('@supports (display: grid) OR (display: flex) { }')
const atrule = root.first_child
expect(atrule?.name).toBe('supports')
expect(atrule?.children.length).toBeGreaterThan(0)
})

it('should parse NOT operator in uppercase', () => {
const root = parse('@supports NOT (display: grid) { }')
const atrule = root.first_child
expect(atrule?.name).toBe('supports')
expect(atrule?.children.length).toBeGreaterThan(0)
})
})
21 changes: 11 additions & 10 deletions src/parse-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { skip_whitespace_forward, skip_whitespace_and_comments_forward, skip_whi
import {
is_whitespace,
is_vendor_prefixed,
str_equals,
CHAR_PLUS,
CHAR_TILDE,
CHAR_GREATER_THAN,
Expand Down Expand Up @@ -751,16 +752,16 @@ export class SelectorParser {
// Parse the content inside the parentheses
if (content_end > content_start) {
// Check if this is an nth-* pseudo-class
let func_name = this.source.substring(func_name_start, func_name_end).toLowerCase()
let func_name_substr = this.source.substring(func_name_start, func_name_end)

if (this.is_nth_pseudo(func_name)) {
if (this.is_nth_pseudo(func_name_substr)) {
// Parse as An+B expression
let child = this.parse_nth_expression(content_start, content_end)
if (child !== null) {
this.arena.set_first_child(node, child)
this.arena.set_last_child(node, child)
}
} else if (func_name === 'lang') {
} else if (str_equals('lang', func_name_substr)) {
// Parse as :lang() - comma-separated language identifiers
this.parse_lang_identifiers(content_start, content_end, node)
} else {
Expand All @@ -771,7 +772,7 @@ export class SelectorParser {

// Recursively parse the content as a selector
// Only :has() accepts relative selectors (starting with combinator)
let allow_relative = func_name === 'has'
let allow_relative = str_equals('has', func_name_substr)
let child_selector = this.parse_selector(content_start, content_end, this.lexer.line, this.lexer.column, allow_relative)

// Restore lexer state and selector_end
Expand All @@ -792,12 +793,12 @@ export class SelectorParser {
// Check if pseudo-class name is an nth-* pseudo
private is_nth_pseudo(name: string): boolean {
return (
name === 'nth-child' ||
name === 'nth-last-child' ||
name === 'nth-of-type' ||
name === 'nth-last-of-type' ||
name === 'nth-col' ||
name === 'nth-last-col'
str_equals('nth-child', name) ||
str_equals('nth-last-child', name) ||
str_equals('nth-of-type', name) ||
str_equals('nth-last-of-type', name) ||
str_equals('nth-col', name) ||
str_equals('nth-last-col', name)
)
}

Expand Down
58 changes: 58 additions & 0 deletions src/parse-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,4 +659,62 @@ describe('Value Node Types', () => {
})
})
})

describe('Case-insensitive function names', () => {
const getValue = (css: string) => {
const root = parse(css)
const rule = root.first_child
const decl = rule?.first_child?.next_sibling?.first_child
return decl?.values[0]
}

it('should parse URL() with uppercase', () => {
const value = getValue('div { background: URL("image.png"); }')
expect(value?.type).toBe(URL)
expect(value?.text).toBe('URL("image.png")')
})

it('should parse Url() with mixed case', () => {
const value = getValue('div { background: Url("image.png"); }')
expect(value?.type).toBe(URL)
expect(value?.text).toBe('Url("image.png")')
})

it('should parse CALC() with uppercase', () => {
const value = getValue('div { width: CALC(100% - 20px); }')
expect(value?.type).toBe(FUNCTION)
expect(value?.text).toBe('CALC(100% - 20px)')
})

it('should parse Calc() with mixed case', () => {
const value = getValue('div { width: Calc(100% - 20px); }')
expect(value?.type).toBe(FUNCTION)
expect(value?.text).toBe('Calc(100% - 20px)')
})

it('should parse RGB() with uppercase', () => {
const value = getValue('div { color: RGB(255, 0, 0); }')
expect(value?.type).toBe(FUNCTION)
expect(value?.text).toBe('RGB(255, 0, 0)')
})

it('should parse RGBA() with uppercase', () => {
const value = getValue('div { color: RGBA(255, 0, 0, 0.5); }')
expect(value?.type).toBe(FUNCTION)
expect(value?.text).toBe('RGBA(255, 0, 0, 0.5)')
})

it('should parse unquoted URL() with uppercase', () => {
const value = getValue('div { background: URL(image.png); }')
expect(value?.type).toBe(URL)
expect(value?.text).toBe('URL(image.png)')
expect(value?.value).toBe('image.png')
})

it('should handle nested functions with uppercase', () => {
const value = getValue('div { width: CALC(MAX(100%, 50px) - 20px); }')
expect(value?.type).toBe(FUNCTION)
expect(value?.children[0].type).toBe(FUNCTION)
})
})
})
8 changes: 4 additions & 4 deletions src/parse-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
TOKEN_LEFT_PAREN,
TOKEN_RIGHT_PAREN,
} from './token-types'
import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH } from './string-utils'
import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH, str_equals } from './string-utils'
import { CSSNode } from './css-node'

/** @internal */
Expand Down Expand Up @@ -154,11 +154,11 @@ export class ValueParser {
let name_end = end - 1 // Exclude the '('

// Get function name to check for special handling
let func_name = this.source.substring(start, name_end).toLowerCase()
let func_name_substr = this.source.substring(start, name_end)

// Create URL or function node based on function name (length will be set later)
let node = this.arena.create_node(
func_name === 'url' ? URL : FUNCTION,
str_equals('url', func_name_substr) ? URL : FUNCTION,
start,
0, // length unknown yet
this.lexer.line,
Expand All @@ -171,7 +171,7 @@ export class ValueParser {
// Don't parse contents to preserve URLs with dots, base64, inline SVGs, etc.
// Users can extract the full URL from the function's text property
// Note: Quoted urls like url("...") or url('...') parse normally
if (func_name === 'url' || func_name === 'src') {
if (str_equals('url', func_name_substr) || str_equals('src', func_name_substr)) {
// Peek at the next token to see if it's a string
// If it's a string, parse normally. Otherwise, skip parsing children.
let save_pos = this.lexer.save_position()
Expand Down
Loading