Skip to content

Commit d180fdb

Browse files
committed
feat: more is_vendor_prefixed support
1 parent 7dedafd commit d180fdb

File tree

5 files changed

+309
-11
lines changed

5 files changed

+309
-11
lines changed

src/parser.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,173 @@ describe('Parser', () => {
838838
})
839839
})
840840

841+
describe('vendor prefix detection for selectors', () => {
842+
test('should detect -webkit- vendor prefix in pseudo-class', () => {
843+
let source = 'input:-webkit-autofill { color: black; }'
844+
let parser = new Parser(source)
845+
let root = parser.parse()
846+
847+
let rule = root.first_child!
848+
let selector = rule.first_child!
849+
// Selector has detailed parsing enabled by default
850+
expect(selector.has_children).toBe(true)
851+
// Navigate: selector -> type selector (input) -> pseudo-class (next sibling)
852+
let typeSelector = selector.first_child!
853+
let pseudoClass = typeSelector.next_sibling!
854+
expect(pseudoClass.name).toBe('-webkit-autofill')
855+
expect(pseudoClass.is_vendor_prefixed).toBe(true)
856+
})
857+
858+
test('should detect -moz- vendor prefix in pseudo-class', () => {
859+
let source = 'button:-moz-focusring { outline: 2px solid blue; }'
860+
let parser = new Parser(source)
861+
let root = parser.parse()
862+
863+
let rule = root.first_child!
864+
let selector = rule.first_child!
865+
let typeSelector = selector.first_child!
866+
let pseudoClass = typeSelector.next_sibling!
867+
expect(pseudoClass.name).toBe('-moz-focusring')
868+
expect(pseudoClass.is_vendor_prefixed).toBe(true)
869+
})
870+
871+
test('should detect -ms- vendor prefix in pseudo-class', () => {
872+
let source = 'input:-ms-input-placeholder { color: gray; }'
873+
let parser = new Parser(source)
874+
let root = parser.parse()
875+
876+
let rule = root.first_child!
877+
let selector = rule.first_child!
878+
let typeSelector = selector.first_child!
879+
let pseudoClass = typeSelector.next_sibling!
880+
expect(pseudoClass.name).toBe('-ms-input-placeholder')
881+
expect(pseudoClass.is_vendor_prefixed).toBe(true)
882+
})
883+
884+
test('should detect -webkit- vendor prefix in pseudo-element', () => {
885+
let source = 'div::-webkit-scrollbar { width: 10px; }'
886+
let parser = new Parser(source)
887+
let root = parser.parse()
888+
889+
let rule = root.first_child!
890+
let selector = rule.first_child!
891+
let typeSelector = selector.first_child!
892+
let pseudoElement = typeSelector.next_sibling!
893+
expect(pseudoElement.name).toBe('-webkit-scrollbar')
894+
expect(pseudoElement.is_vendor_prefixed).toBe(true)
895+
})
896+
897+
test('should detect -moz- vendor prefix in pseudo-element', () => {
898+
let source = 'div::-moz-selection { background: yellow; }'
899+
let parser = new Parser(source)
900+
let root = parser.parse()
901+
902+
let rule = root.first_child!
903+
let selector = rule.first_child!
904+
let typeSelector = selector.first_child!
905+
let pseudoElement = typeSelector.next_sibling!
906+
expect(pseudoElement.name).toBe('-moz-selection')
907+
expect(pseudoElement.is_vendor_prefixed).toBe(true)
908+
})
909+
910+
test('should detect -webkit- vendor prefix in pseudo-element with multiple parts', () => {
911+
let source = 'input::-webkit-input-placeholder { color: gray; }'
912+
let parser = new Parser(source)
913+
let root = parser.parse()
914+
915+
let rule = root.first_child!
916+
let selector = rule.first_child!
917+
let typeSelector = selector.first_child!
918+
let pseudoElement = typeSelector.next_sibling!
919+
expect(pseudoElement.name).toBe('-webkit-input-placeholder')
920+
expect(pseudoElement.is_vendor_prefixed).toBe(true)
921+
})
922+
923+
test('should detect -webkit- vendor prefix in pseudo-class function', () => {
924+
let source = 'input:-webkit-any(input, button) { margin: 0; }'
925+
let parser = new Parser(source)
926+
let root = parser.parse()
927+
928+
let rule = root.first_child!
929+
let selector = rule.first_child!
930+
let typeSelector = selector.first_child!
931+
let pseudoClass = typeSelector.next_sibling!
932+
expect(pseudoClass.name).toBe('-webkit-any')
933+
expect(pseudoClass.is_vendor_prefixed).toBe(true)
934+
})
935+
936+
test('should not detect vendor prefix for standard pseudo-classes', () => {
937+
let source = 'a:hover { color: blue; }'
938+
let parser = new Parser(source)
939+
let root = parser.parse()
940+
941+
let rule = root.first_child!
942+
let selector = rule.first_child!
943+
let typeSelector = selector.first_child!
944+
let pseudoClass = typeSelector.next_sibling!
945+
expect(pseudoClass.name).toBe('hover')
946+
expect(pseudoClass.is_vendor_prefixed).toBe(false)
947+
})
948+
949+
test('should not detect vendor prefix for standard pseudo-elements', () => {
950+
let source = 'div::before { content: ""; }'
951+
let parser = new Parser(source)
952+
let root = parser.parse()
953+
954+
let rule = root.first_child!
955+
let selector = rule.first_child!
956+
let typeSelector = selector.first_child!
957+
let pseudoElement = typeSelector.next_sibling!
958+
expect(pseudoElement.name).toBe('before')
959+
expect(pseudoElement.is_vendor_prefixed).toBe(false)
960+
})
961+
962+
test('should detect vendor prefix with multiple vendor-prefixed pseudo-elements', () => {
963+
let source = 'div::-webkit-scrollbar { } div::-webkit-scrollbar-thumb { } div::after { }'
964+
let parser = new Parser(source)
965+
let root = parser.parse()
966+
967+
let [rule1, rule2, rule3] = root.children
968+
969+
let selector1 = rule1.first_child!
970+
let typeSelector1 = selector1.first_child!
971+
let pseudo1 = typeSelector1.next_sibling!
972+
expect(pseudo1.name).toBe('-webkit-scrollbar')
973+
expect(pseudo1.is_vendor_prefixed).toBe(true)
974+
975+
let selector2 = rule2.first_child!
976+
let typeSelector2 = selector2.first_child!
977+
let pseudo2 = typeSelector2.next_sibling!
978+
expect(pseudo2.name).toBe('-webkit-scrollbar-thumb')
979+
expect(pseudo2.is_vendor_prefixed).toBe(true)
980+
981+
let selector3 = rule3.first_child!
982+
let typeSelector3 = selector3.first_child!
983+
let pseudo3 = typeSelector3.next_sibling!
984+
expect(pseudo3.name).toBe('after')
985+
expect(pseudo3.is_vendor_prefixed).toBe(false)
986+
})
987+
988+
test('should detect vendor prefix in complex selector', () => {
989+
let source = 'input:-webkit-autofill:focus { color: black; }'
990+
let parser = new Parser(source)
991+
let root = parser.parse()
992+
993+
let rule = root.first_child!
994+
let selector = rule.first_child!
995+
// Navigate through compound selector: input (type) -> -webkit-autofill (pseudo) -> :focus (pseudo)
996+
let typeSelector = selector.first_child!
997+
let webkitPseudo = typeSelector.next_sibling!
998+
expect(webkitPseudo.name).toBe('-webkit-autofill')
999+
expect(webkitPseudo.is_vendor_prefixed).toBe(true)
1000+
1001+
// Check the :focus pseudo-class is not vendor prefixed
1002+
let focusPseudo = webkitPseudo.next_sibling!
1003+
expect(focusPseudo.name).toBe('focus')
1004+
expect(focusPseudo.is_vendor_prefixed).toBe(false)
1005+
})
1006+
})
1007+
8411008
describe('complex real-world scenarios', () => {
8421009
test('should parse complex nested structure', () => {
8431010
let source = `

src/parser.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ import {
2525
TOKEN_DELIM,
2626
TOKEN_AT_KEYWORD,
2727
} from './token-types'
28-
import { trim_boundaries } from './string-utils'
29-
30-
const MINUS_HYPHEN = 45
28+
import { trim_boundaries, is_vendor_prefixed } from './string-utils'
3129

3230
export interface ParserOptions {
3331
skip_comments?: boolean
@@ -285,12 +283,8 @@ export class Parser {
285283
this.arena.set_content_length(declaration, prop_end - prop_start)
286284

287285
// Check for vendor prefix and set flag if detected
288-
if (this.source.charCodeAt(prop_start) === MINUS_HYPHEN && this.source.charCodeAt(prop_start + 1) !== MINUS_HYPHEN) {
289-
let prop_length = prop_end - prop_start
290-
// Check for -webkit- (8 chars min), -moz- (5 chars), -ms- (4 chars), -o- (3 chars)
291-
if (prop_length >= 3 && this.source.indexOf('-', prop_start + 2) !== -1) {
292-
this.arena.set_flag(declaration, FLAG_VENDOR_PREFIXED)
293-
}
286+
if (is_vendor_prefixed(this.source, prop_start, prop_end)) {
287+
this.arena.set_flag(declaration, FLAG_VENDOR_PREFIXED)
294288
}
295289

296290
// Track value start (after colon, skipping whitespace)

src/selector-parser.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
NODE_SELECTOR_COMBINATOR,
1414
NODE_SELECTOR_UNIVERSAL,
1515
NODE_SELECTOR_NESTING,
16+
FLAG_VENDOR_PREFIXED,
1617
} from './arena'
1718
import {
1819
TOKEN_IDENT,
@@ -28,7 +29,7 @@ import {
2829
TOKEN_EOF,
2930
TOKEN_WHITESPACE,
3031
} from './token-types'
31-
import { trim_boundaries, is_whitespace as is_whitespace_char } from './string-utils'
32+
import { trim_boundaries, is_whitespace as is_whitespace_char, is_vendor_prefixed } from './string-utils'
3233

3334
export class SelectorParser {
3435
private lexer: Lexer
@@ -402,6 +403,10 @@ export class SelectorParser {
402403
// Content is the pseudo name (without colons)
403404
this.arena.set_content_start(node, this.lexer.token_start)
404405
this.arena.set_content_length(node, this.lexer.token_end - this.lexer.token_start)
406+
// Check for vendor prefix and set flag if detected
407+
if (is_vendor_prefixed(this.source, this.lexer.token_start, this.lexer.token_end)) {
408+
this.arena.set_flag(node, FLAG_VENDOR_PREFIXED)
409+
}
405410
return node
406411
} else if (token_type === TOKEN_FUNCTION) {
407412
// Pseudo-class function like :nth-child()
@@ -449,6 +454,10 @@ export class SelectorParser {
449454
// Content is the function name (without colons and parentheses)
450455
this.arena.set_content_start(node, func_name_start)
451456
this.arena.set_content_length(node, func_name_end - func_name_start)
457+
// Check for vendor prefix and set flag if detected
458+
if (is_vendor_prefixed(this.source, func_name_start, func_name_end)) {
459+
this.arena.set_flag(node, FLAG_VENDOR_PREFIXED)
460+
}
452461
return node
453462
}
454463

src/string-utils.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { is_whitespace, trim_boundaries, str_equals, CHAR_SPACE, CHAR_TAB, CHAR_NEWLINE, CHAR_CARRIAGE_RETURN, CHAR_FORM_FEED } from './string-utils'
2+
import { is_whitespace, trim_boundaries, str_equals, is_vendor_prefixed, CHAR_SPACE, CHAR_TAB, CHAR_NEWLINE, CHAR_CARRIAGE_RETURN, CHAR_FORM_FEED } from './string-utils'
33

44
describe('string-utils', () => {
55
describe('is_whitespace', () => {
@@ -192,4 +192,87 @@ describe('string-utils', () => {
192192
expect(str_equals('', '')).toBe(true)
193193
})
194194
})
195+
196+
describe('is_vendor_prefixed', () => {
197+
it('should detect -webkit- vendor prefix', () => {
198+
const source = '-webkit-transform'
199+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
200+
})
201+
202+
it('should detect -moz- vendor prefix', () => {
203+
const source = '-moz-appearance'
204+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
205+
})
206+
207+
it('should detect -ms- vendor prefix', () => {
208+
const source = '-ms-filter'
209+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
210+
})
211+
212+
it('should detect -o- vendor prefix', () => {
213+
const source = '-o-transform'
214+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
215+
})
216+
217+
it('should detect vendor prefix with complex property names', () => {
218+
const source = '-webkit-border-top-left-radius'
219+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
220+
})
221+
222+
it('should detect vendor prefix in substring', () => {
223+
const source = 'div { -webkit-transform: scale(1); }'
224+
expect(is_vendor_prefixed(source, 6, 23)).toBe(true)
225+
})
226+
227+
it('should not detect vendor prefix for CSS custom properties', () => {
228+
const source = '--custom-property'
229+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(false)
230+
})
231+
232+
it('should not detect vendor prefix for standard properties', () => {
233+
const source = 'transform'
234+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(false)
235+
})
236+
237+
it('should not detect vendor prefix for properties with hyphens', () => {
238+
const source = 'border-radius'
239+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(false)
240+
})
241+
242+
it('should not detect vendor prefix for properties starting with hyphen but too short', () => {
243+
const source = '-x'
244+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(false)
245+
})
246+
247+
it('should not detect vendor prefix without second hyphen', () => {
248+
const source = '-webkit'
249+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(false)
250+
})
251+
252+
it('should not detect vendor prefix when second hyphen is outside range', () => {
253+
const source = '-webkit-transform'
254+
// Only check "-webkit" without the trailing hyphen
255+
expect(is_vendor_prefixed(source, 0, 7)).toBe(false)
256+
})
257+
258+
it('should detect vendor prefix for pseudo-classes', () => {
259+
const source = '-webkit-autofill'
260+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
261+
})
262+
263+
it('should detect vendor prefix for pseudo-elements', () => {
264+
const source = '-webkit-scrollbar'
265+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
266+
})
267+
268+
it('should detect vendor prefix with minimal length (-o-)', () => {
269+
const source = '-o-'
270+
expect(is_vendor_prefixed(source, 0, source.length)).toBe(true)
271+
})
272+
273+
it('should work with various offsets', () => {
274+
const source = 'prefix-webkit-suffix-rest'
275+
expect(is_vendor_prefixed(source, 6, 20)).toBe(true) // "-webkit-suffix"
276+
})
277+
})
195278
})

src/string-utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const CHAR_CARRIAGE_RETURN = 0x0d // '\r'
88
export const CHAR_FORM_FEED = 0x0c // '\f'
99
export const CHAR_FORWARD_SLASH = 0x2f // '/'
1010
export const CHAR_ASTERISK = 0x2a // '*'
11+
export const CHAR_MINUS_HYPHEN = 0x2d // '-'
1112

1213
/**
1314
* Check if a character code is whitespace (space, tab, newline, CR, or FF)
@@ -112,3 +113,47 @@ export function str_equals(a: string, b: string): boolean {
112113

113114
return true
114115
}
116+
117+
/**
118+
* Check if a string range has a vendor prefix
119+
*
120+
* @param source - The source string
121+
* @param start - Start offset in source
122+
* @param end - End offset in source
123+
* @returns true if the range starts with a vendor prefix (-webkit-, -moz-, -ms-, -o-)
124+
*
125+
* Detects vendor prefixes by checking:
126+
* 1. Starts with a single hyphen (not --)
127+
* 2. Contains at least 3 characters (shortest is -o-)
128+
* 3. Has a second hyphen after the vendor name
129+
*
130+
* Examples:
131+
* - `-webkit-transform` → true
132+
* - `-moz-appearance` → true
133+
* - `-ms-filter` → true
134+
* - `-o-border-image` → true
135+
* - `--custom-property` → false (CSS custom property)
136+
* - `border-radius` → false (doesn't start with hyphen)
137+
*/
138+
export function is_vendor_prefixed(source: string, start: number, end: number): boolean {
139+
// Must start with a hyphen
140+
if (source.charCodeAt(start) !== CHAR_MINUS_HYPHEN) {
141+
return false
142+
}
143+
144+
// Second char must not be a hyphen (to exclude CSS custom properties like --var)
145+
if (source.charCodeAt(start + 1) === CHAR_MINUS_HYPHEN) {
146+
return false
147+
}
148+
149+
// Must be at least 3 chars (-o- is shortest vendor prefix)
150+
let length = end - start
151+
if (length < 3) {
152+
return false
153+
}
154+
155+
// Must have another hyphen after the vendor name
156+
// This identifies: -webkit-, -moz-, -ms-, -o-
157+
let secondHyphenPos = source.indexOf('-', start + 2)
158+
return secondHyphenPos !== -1 && secondHyphenPos < end
159+
}

0 commit comments

Comments
 (0)