Skip to content

Commit ad93e09

Browse files
authored
fix: make sure all string comparisons are case-insensitive (#52)
closes #50
1 parent e028636 commit ad93e09

File tree

8 files changed

+340
-20
lines changed

8 files changed

+340
-20
lines changed

src/css-node.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
FLAG_HAS_PARENS,
4646
} from './arena'
4747

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

5151
// Type name lookup table - maps numeric type to CSSTree-compatible strings
@@ -239,7 +239,7 @@ export class CSSNode {
239239
// For URL nodes without children (e.g., @import url(...)), extract from text
240240
// Handle both url("...") and url('...') and just "..." or '...'
241241
const text = this.text
242-
if (text.startsWith('url(')) {
242+
if (str_starts_with(text, 'url(')) {
243243
// url("...") or url('...') or url(...) - extract content between parens
244244
const openParen = text.indexOf('(')
245245
const closeParen = text.lastIndexOf(')')

src/parse-anplusb.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { Lexer } from './lexer'
88
import { NTH_SELECTOR, CSSDataArena } from './arena'
99
import { TOKEN_IDENT, TOKEN_NUMBER, TOKEN_DIMENSION, TOKEN_DELIM, type TokenType } from './token-types'
10-
import { CHAR_MINUS_HYPHEN, CHAR_PLUS } from './string-utils'
10+
import { CHAR_MINUS_HYPHEN, CHAR_PLUS, str_equals, str_index_of } from './string-utils'
1111
import { skip_whitespace_forward } from './parse-utils'
1212
import { CSSNode } from './css-node'
1313

@@ -53,9 +53,9 @@ export class ANplusBParser {
5353

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

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

173173
if (n_index !== -1) {
174174
a_start = this.lexer.token_start

src/parse-atrule-prelude.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,3 +1361,63 @@ describe('parse_atrule_prelude()', () => {
13611361
})
13621362
})
13631363
})
1364+
1365+
describe('Case-insensitive at-rule keywords', () => {
1366+
it('should parse @MEDIA with uppercase', () => {
1367+
const root = parse('@MEDIA (min-width: 768px) { body { color: red; } }')
1368+
const atrule = root.first_child
1369+
expect(atrule?.name).toBe('MEDIA')
1370+
})
1371+
1372+
it('should parse @Media with mixed case', () => {
1373+
const root = parse('@Media (min-width: 768px) { body { color: red; } }')
1374+
const atrule = root.first_child
1375+
expect(atrule?.name).toBe('Media')
1376+
})
1377+
1378+
it('should parse @IMPORT with uppercase', () => {
1379+
const root = parse('@IMPORT url("style.css");')
1380+
const atrule = root.first_child
1381+
expect(atrule?.name).toBe('IMPORT')
1382+
})
1383+
1384+
it('should parse @SUPPORTS with uppercase', () => {
1385+
const root = parse('@SUPPORTS (display: grid) { body { display: grid; } }')
1386+
const atrule = root.first_child
1387+
expect(atrule?.name).toBe('SUPPORTS')
1388+
})
1389+
1390+
it('should parse @LAYER with uppercase', () => {
1391+
const root = parse('@LAYER base { }')
1392+
const atrule = root.first_child
1393+
expect(atrule?.name).toBe('LAYER')
1394+
})
1395+
1396+
it('should parse @CONTAINER with uppercase', () => {
1397+
const root = parse('@CONTAINER (min-width: 400px) { }')
1398+
const atrule = root.first_child
1399+
expect(atrule?.name).toBe('CONTAINER')
1400+
})
1401+
1402+
it('should parse media query operators in uppercase', () => {
1403+
const root = parse('@media (min-width: 768px) AND (max-width: 1024px) { }')
1404+
const atrule = root.first_child
1405+
expect(atrule?.name).toBe('media')
1406+
// Verify the prelude was parsed (operators are case-insensitive)
1407+
expect(atrule?.children.length).toBeGreaterThan(0)
1408+
})
1409+
1410+
it('should parse OR operator in uppercase', () => {
1411+
const root = parse('@supports (display: grid) OR (display: flex) { }')
1412+
const atrule = root.first_child
1413+
expect(atrule?.name).toBe('supports')
1414+
expect(atrule?.children.length).toBeGreaterThan(0)
1415+
})
1416+
1417+
it('should parse NOT operator in uppercase', () => {
1418+
const root = parse('@supports NOT (display: grid) { }')
1419+
const atrule = root.first_child
1420+
expect(atrule?.name).toBe('supports')
1421+
expect(atrule?.children.length).toBeGreaterThan(0)
1422+
})
1423+
})

src/parse-selector.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { skip_whitespace_forward, skip_whitespace_and_comments_forward, skip_whi
4747
import {
4848
is_whitespace,
4949
is_vendor_prefixed,
50+
str_equals,
5051
CHAR_PLUS,
5152
CHAR_TILDE,
5253
CHAR_GREATER_THAN,
@@ -751,16 +752,16 @@ export class SelectorParser {
751752
// Parse the content inside the parentheses
752753
if (content_end > content_start) {
753754
// Check if this is an nth-* pseudo-class
754-
let func_name = this.source.substring(func_name_start, func_name_end).toLowerCase()
755+
let func_name_substr = this.source.substring(func_name_start, func_name_end)
755756

756-
if (this.is_nth_pseudo(func_name)) {
757+
if (this.is_nth_pseudo(func_name_substr)) {
757758
// Parse as An+B expression
758759
let child = this.parse_nth_expression(content_start, content_end)
759760
if (child !== null) {
760761
this.arena.set_first_child(node, child)
761762
this.arena.set_last_child(node, child)
762763
}
763-
} else if (func_name === 'lang') {
764+
} else if (str_equals('lang', func_name_substr)) {
764765
// Parse as :lang() - comma-separated language identifiers
765766
this.parse_lang_identifiers(content_start, content_end, node)
766767
} else {
@@ -771,7 +772,7 @@ export class SelectorParser {
771772

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

777778
// Restore lexer state and selector_end
@@ -792,12 +793,12 @@ export class SelectorParser {
792793
// Check if pseudo-class name is an nth-* pseudo
793794
private is_nth_pseudo(name: string): boolean {
794795
return (
795-
name === 'nth-child' ||
796-
name === 'nth-last-child' ||
797-
name === 'nth-of-type' ||
798-
name === 'nth-last-of-type' ||
799-
name === 'nth-col' ||
800-
name === 'nth-last-col'
796+
str_equals('nth-child', name) ||
797+
str_equals('nth-last-child', name) ||
798+
str_equals('nth-of-type', name) ||
799+
str_equals('nth-last-of-type', name) ||
800+
str_equals('nth-col', name) ||
801+
str_equals('nth-last-col', name)
801802
)
802803
}
803804

src/parse-value.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,4 +659,62 @@ describe('Value Node Types', () => {
659659
})
660660
})
661661
})
662+
663+
describe('Case-insensitive function names', () => {
664+
const getValue = (css: string) => {
665+
const root = parse(css)
666+
const rule = root.first_child
667+
const decl = rule?.first_child?.next_sibling?.first_child
668+
return decl?.values[0]
669+
}
670+
671+
it('should parse URL() with uppercase', () => {
672+
const value = getValue('div { background: URL("image.png"); }')
673+
expect(value?.type).toBe(URL)
674+
expect(value?.text).toBe('URL("image.png")')
675+
})
676+
677+
it('should parse Url() with mixed case', () => {
678+
const value = getValue('div { background: Url("image.png"); }')
679+
expect(value?.type).toBe(URL)
680+
expect(value?.text).toBe('Url("image.png")')
681+
})
682+
683+
it('should parse CALC() with uppercase', () => {
684+
const value = getValue('div { width: CALC(100% - 20px); }')
685+
expect(value?.type).toBe(FUNCTION)
686+
expect(value?.text).toBe('CALC(100% - 20px)')
687+
})
688+
689+
it('should parse Calc() with mixed case', () => {
690+
const value = getValue('div { width: Calc(100% - 20px); }')
691+
expect(value?.type).toBe(FUNCTION)
692+
expect(value?.text).toBe('Calc(100% - 20px)')
693+
})
694+
695+
it('should parse RGB() with uppercase', () => {
696+
const value = getValue('div { color: RGB(255, 0, 0); }')
697+
expect(value?.type).toBe(FUNCTION)
698+
expect(value?.text).toBe('RGB(255, 0, 0)')
699+
})
700+
701+
it('should parse RGBA() with uppercase', () => {
702+
const value = getValue('div { color: RGBA(255, 0, 0, 0.5); }')
703+
expect(value?.type).toBe(FUNCTION)
704+
expect(value?.text).toBe('RGBA(255, 0, 0, 0.5)')
705+
})
706+
707+
it('should parse unquoted URL() with uppercase', () => {
708+
const value = getValue('div { background: URL(image.png); }')
709+
expect(value?.type).toBe(URL)
710+
expect(value?.text).toBe('URL(image.png)')
711+
expect(value?.value).toBe('image.png')
712+
})
713+
714+
it('should handle nested functions with uppercase', () => {
715+
const value = getValue('div { width: CALC(MAX(100%, 50px) - 20px); }')
716+
expect(value?.type).toBe(FUNCTION)
717+
expect(value?.children[0].type).toBe(FUNCTION)
718+
})
719+
})
662720
})

src/parse-value.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
TOKEN_LEFT_PAREN,
1616
TOKEN_RIGHT_PAREN,
1717
} from './token-types'
18-
import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH } from './string-utils'
18+
import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH, str_equals } from './string-utils'
1919
import { CSSNode } from './css-node'
2020

2121
/** @internal */
@@ -154,11 +154,11 @@ export class ValueParser {
154154
let name_end = end - 1 // Exclude the '('
155155

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

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

0 commit comments

Comments
 (0)