Skip to content

Commit 11c7ef4

Browse files
authored
fix: parse property with browser hack (#90)
partially unblocks projectwallace/css-analyzer#488
1 parent 6f04431 commit 11c7ef4

File tree

6 files changed

+184
-15
lines changed

6 files changed

+184
-15
lines changed

API.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ function parse(source: string, options?: ParserOptions): CSSNode
5757
**Flags:**
5858

5959
- `is_important` - Whether declaration has `!important` (DECLARATION only)
60+
- `is_browserhack` - Whether declaration property has a browser hack prefix like `*`, `_`, `!`, etc. (DECLARATION only)
6061
- `is_vendor_prefixed` - Whether node has vendor prefix (checks name/text based on type)
6162
- `has_error` - Whether node has syntax error
6263
- `has_prelude` - Whether at-rule has a prelude
@@ -575,7 +576,7 @@ Parse a CSS declaration string into a detailed AST.
575576
function parse_declaration(source: string): CSSNode
576577
```
577578

578-
**Example:**
579+
**Example 1: Basic Declaration:**
579580

580581
```typescript
581582
import { parse_declaration } from '@projectwallace/css-parser'
@@ -594,6 +595,30 @@ for (const valueNode of decl.children) {
594595
// IDENTIFIER "red"
595596
```
596597

598+
**Example 2: Browser Hacks:**
599+
600+
```typescript
601+
import { parse_declaration } from '@projectwallace/css-parser'
602+
603+
// Browser hack with * prefix (IE 6/7 hack)
604+
const hack = parse_declaration('*width: 100px')
605+
console.log(hack.property) // "*width"
606+
console.log(hack.is_browserhack) // true
607+
608+
// Browser hack with _ prefix (IE 6 hack)
609+
const underscore = parse_declaration('_height: 50px')
610+
console.log(underscore.is_browserhack) // true
611+
612+
// Normal property (not a browser hack)
613+
const normal = parse_declaration('width: 100px')
614+
console.log(normal.is_browserhack) // false
615+
616+
// Vendor prefix (not a browser hack)
617+
const vendor = parse_declaration('-webkit-transform: scale(1)')
618+
console.log(vendor.is_browserhack) // false
619+
console.log(vendor.is_vendor_prefixed) // true
620+
```
621+
597622
---
598623

599624
## `parse_atrule_prelude(at_rule_name, prelude)`

src/arena.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export const FLAG_HAS_BLOCK = 1 << 3 // Has { } block (for style rules and at-ru
9595
export const FLAG_VENDOR_PREFIXED = 1 << 4 // Has vendor prefix (-webkit-, -moz-, -ms-, -o-)
9696
export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has declarations (for style rules)
9797
export const FLAG_HAS_PARENS = 1 << 6 // Has parentheses syntax (for pseudo-class/pseudo-element functions)
98+
export const FLAG_BROWSERHACK = 1 << 7 // Has browser hack prefix (*property, _property, etc.)
9899

99100
// Attribute selector operator constants (stored in 1 byte at offset 2)
100101
export const ATTR_OPERATOR_NONE = 0 // [attr]

src/css-node.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
FLAG_HAS_BLOCK,
4343
FLAG_HAS_DECLARATIONS,
4444
FLAG_HAS_PARENS,
45+
FLAG_BROWSERHACK,
4546
} from './arena'
4647

4748
import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace, is_vendor_prefixed, str_starts_with } from './string-utils'
@@ -160,6 +161,7 @@ export type PlainCSSNode = {
160161
// Flags (only when true)
161162
is_important?: boolean
162163
is_vendor_prefixed?: boolean
164+
is_browserhack?: boolean
163165
has_error?: boolean
164166

165167
// Selector-specific
@@ -323,6 +325,12 @@ export class CSSNode {
323325
return this.arena.has_flag(this.index, FLAG_IMPORTANT)
324326
}
325327

328+
/** Check if this declaration has a browser hack prefix */
329+
get is_browserhack(): boolean | null {
330+
if (this.type !== DECLARATION) return null
331+
return this.arena.has_flag(this.index, FLAG_BROWSERHACK)
332+
}
333+
326334
/** Check if this has a vendor prefix (computed on-demand) */
327335
get is_vendor_prefixed(): boolean {
328336
switch (this.type) {
@@ -730,7 +738,10 @@ export class CSSNode {
730738
}
731739

732740
// 5. Extract flags
733-
if (this.type === DECLARATION) plain.is_important = this.is_important
741+
if (this.type === DECLARATION) {
742+
plain.is_important = this.is_important
743+
plain.is_browserhack = this.is_browserhack
744+
}
734745
plain.is_vendor_prefixed = this.is_vendor_prefixed
735746
plain.has_error = this.has_error
736747

src/parse-declaration.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,32 @@ describe('parse_declaration', () => {
182182
})
183183
})
184184

185+
describe('browser hacks', () => {
186+
const HACK_PREFIXES = '-_!$&*()=%+@,./`[]#~?:<>|'.split('')
187+
188+
test.each(HACK_PREFIXES)('%s property hack', (char) => {
189+
const node = parse_declaration(`${char}property: value;`)
190+
expect(node.property).toBe(`${char}property`)
191+
expect(node.is_browserhack).toBe(true)
192+
})
193+
194+
test('value\\9', () => {
195+
const node = parse_declaration('property: value\\9')
196+
expect(node.value).toBe('value\\9')
197+
expect(node.is_browserhack).toBe(false)
198+
})
199+
200+
test('normal property is not a browserhack', () => {
201+
const node = parse_declaration('color: red')
202+
expect(node.is_browserhack).toBe(false)
203+
})
204+
205+
test('vendor prefixed property is not a browserhack', () => {
206+
const node = parse_declaration('-o-color: red')
207+
expect(node.is_browserhack).toBe(false)
208+
})
209+
})
210+
185211
describe('Value Parsing', () => {
186212
test('identifier value', () => {
187213
const node = parse_declaration('display: flex')

src/parse-declaration.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Declaration Parser - Parses CSS declarations into structured AST nodes
22
import { Lexer } from './tokenize'
3-
import { CSSDataArena, DECLARATION, FLAG_IMPORTANT } from './arena'
3+
import { CSSDataArena, DECLARATION, FLAG_IMPORTANT, FLAG_BROWSERHACK } from './arena'
44
import { ValueParser } from './parse-value'
5+
import { is_vendor_prefixed } from './string-utils'
56
import {
67
TOKEN_IDENT,
78
TOKEN_COLON,
@@ -10,6 +11,13 @@ import {
1011
TOKEN_EOF,
1112
TOKEN_LEFT_BRACE,
1213
TOKEN_RIGHT_BRACE,
14+
TOKEN_LEFT_PAREN,
15+
TOKEN_RIGHT_PAREN,
16+
TOKEN_LEFT_BRACKET,
17+
TOKEN_RIGHT_BRACKET,
18+
TOKEN_COMMA,
19+
TOKEN_HASH,
20+
TOKEN_AT_KEYWORD,
1321
type TokenType,
1422
} from './token-types'
1523
import { trim_boundaries } from './parse-utils'
@@ -41,16 +49,86 @@ export class DeclarationParser {
4149

4250
// Parse a declaration using a provided lexer (used by Parser to avoid re-tokenization)
4351
parse_declaration_with_lexer(lexer: Lexer, end: number): number | null {
44-
// Expect identifier (property name) - whitespace already skipped by caller
45-
if (lexer.token_type !== TOKEN_IDENT) {
52+
// Check for browser hack prefix (single delimiter/special character before identifier)
53+
let has_browser_hack = false
54+
let browser_hack_start = 0
55+
let browser_hack_line = 1
56+
let browser_hack_column = 1
57+
58+
// Handle @property and #property (tokenized as single tokens)
59+
if (lexer.token_type === TOKEN_AT_KEYWORD || lexer.token_type === TOKEN_HASH) {
60+
// These tokens already include the @ or # prefix in their text
61+
// Mark as browser hack since @ and # prefixes are not standard CSS
62+
has_browser_hack = true
63+
browser_hack_start = lexer.token_start
64+
browser_hack_line = lexer.token_line
65+
browser_hack_column = lexer.token_column
66+
} else if (lexer.token_type === TOKEN_IDENT) {
67+
// Check if identifier starts with browser hack character
68+
// Some hacks like -property, _property are tokenized as single identifiers
69+
const first_char = this.source.charCodeAt(lexer.token_start)
70+
if (first_char === 95) {
71+
// '_' - underscore prefix is always a browser hack
72+
has_browser_hack = true
73+
browser_hack_start = lexer.token_start
74+
browser_hack_line = lexer.token_line
75+
browser_hack_column = lexer.token_column
76+
} else if (first_char === 45) {
77+
// '-' - hyphen prefix could be vendor prefix or browser hack
78+
// Use fast vendor prefix check (no allocations)
79+
if (!is_vendor_prefixed(this.source, lexer.token_start, lexer.token_end)) {
80+
// This is a browser hack like -property
81+
has_browser_hack = true
82+
browser_hack_start = lexer.token_start
83+
browser_hack_line = lexer.token_line
84+
browser_hack_column = lexer.token_column
85+
}
86+
}
87+
} else {
88+
// Browser hacks can use various token types as prefixes
89+
const is_browser_hack_token =
90+
lexer.token_type === TOKEN_DELIM ||
91+
lexer.token_type === TOKEN_LEFT_PAREN ||
92+
lexer.token_type === TOKEN_RIGHT_PAREN ||
93+
lexer.token_type === TOKEN_LEFT_BRACKET ||
94+
lexer.token_type === TOKEN_RIGHT_BRACKET ||
95+
lexer.token_type === TOKEN_COMMA ||
96+
lexer.token_type === TOKEN_COLON
97+
98+
if (is_browser_hack_token) {
99+
// Save position in case this isn't a browser hack
100+
const delim_saved = lexer.save_position()
101+
browser_hack_start = lexer.token_start
102+
browser_hack_line = lexer.token_line
103+
browser_hack_column = lexer.token_column
104+
105+
// Consume delimiter and check if next token is identifier
106+
lexer.next_token_fast(true) // skip whitespace
107+
108+
if ((lexer.token_type as TokenType) === TOKEN_IDENT) {
109+
// This is a browser hack!
110+
has_browser_hack = true
111+
} else {
112+
// Not a browser hack, restore position
113+
lexer.restore_position(delim_saved)
114+
}
115+
}
116+
}
117+
118+
// Expect identifier, at-keyword, or hash token (property name) - whitespace already skipped by caller
119+
if (
120+
lexer.token_type !== TOKEN_IDENT &&
121+
lexer.token_type !== TOKEN_AT_KEYWORD &&
122+
lexer.token_type !== TOKEN_HASH
123+
) {
46124
return null
47125
}
48126

49-
let prop_start = lexer.token_start
127+
let prop_start = has_browser_hack ? browser_hack_start : lexer.token_start
50128
let prop_end = lexer.token_end
51129
// CRITICAL: Capture line/column BEFORE consuming property token
52-
let decl_line = lexer.token_line
53-
let decl_column = lexer.token_column
130+
let decl_line = has_browser_hack ? browser_hack_line : lexer.token_line
131+
let decl_column = has_browser_hack ? browser_hack_column : lexer.token_column
54132

55133
// Lookahead: save lexer state before consuming
56134
const saved = lexer.save_position()
@@ -147,6 +225,11 @@ export class DeclarationParser {
147225
this.arena.set_flag(declaration, FLAG_IMPORTANT)
148226
}
149227

228+
// Set browser hack flag if found
229+
if (has_browser_hack) {
230+
this.arena.set_flag(declaration, FLAG_BROWSERHACK)
231+
}
232+
150233
// Consume ';' if present
151234
if ((lexer.token_type as TokenType) === TOKEN_SEMICOLON) {
152235
last_end = lexer.token_end

src/parse.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { CSSNode } from './css-node'
55
import { SelectorParser } from './parse-selector'
66
import { AtRulePreludeParser } from './parse-atrule-prelude'
77
import { DeclarationParser } from './parse-declaration'
8-
import { TOKEN_EOF, TOKEN_LEFT_BRACE, TOKEN_RIGHT_BRACE, TOKEN_SEMICOLON, TOKEN_IDENT, TOKEN_AT_KEYWORD } from './token-types'
8+
import { TOKEN_EOF, TOKEN_LEFT_BRACE, TOKEN_RIGHT_BRACE, TOKEN_SEMICOLON, TOKEN_IDENT, TOKEN_AT_KEYWORD, TOKEN_HASH, TOKEN_DELIM, TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, TOKEN_LEFT_BRACKET, TOKEN_RIGHT_BRACKET, TOKEN_COMMA, TOKEN_COLON } from './token-types'
99
import { trim_boundaries } from './parse-utils'
10+
import { CHAR_PERIOD, CHAR_GREATER_THAN, CHAR_PLUS, CHAR_TILDE, CHAR_AMPERSAND } from './string-utils'
1011

1112
export interface ParserOptions {
1213
skip_comments?: boolean
@@ -252,14 +253,36 @@ export class Parser {
252253

253254
// Parse a declaration: property: value;
254255
private parse_declaration(): number | null {
255-
// Expect identifier (property name)
256-
if (this.peek_type() !== TOKEN_IDENT) {
257-
return null
256+
// Check if this could be a declaration (identifier or browser hack prefix)
257+
const token_type = this.peek_type()
258+
259+
// Accept identifiers, at-keywords, and hash tokens
260+
if (token_type === TOKEN_IDENT || token_type === TOKEN_AT_KEYWORD || token_type === TOKEN_HASH) {
261+
return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length)
262+
}
263+
264+
// For delimiters and special tokens, check if they could be browser hack prefixes
265+
// Only accept single-character prefixes that are not CSS selector syntax
266+
if (
267+
token_type === TOKEN_DELIM ||
268+
token_type === TOKEN_LEFT_PAREN ||
269+
token_type === TOKEN_RIGHT_PAREN ||
270+
token_type === TOKEN_LEFT_BRACKET ||
271+
token_type === TOKEN_RIGHT_BRACKET ||
272+
token_type === TOKEN_COMMA ||
273+
token_type === TOKEN_COLON
274+
) {
275+
// Check if this delimiter could be a browser hack (not a selector combinator)
276+
const char_code = this.source.charCodeAt(this.lexer.token_start)
277+
// Exclude selector-specific delimiters: . (class), > (child), + (adjacent), ~ (general), & (nesting)
278+
if (char_code === CHAR_PERIOD || char_code === CHAR_GREATER_THAN || char_code === CHAR_PLUS || char_code === CHAR_TILDE || char_code === CHAR_AMPERSAND) {
279+
return null
280+
}
281+
// Let DeclarationParser try to parse it and return null if it's not a valid declaration
282+
return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length)
258283
}
259284

260-
// Use DeclarationParser with shared lexer (no re-tokenization)
261-
// DeclarationParser will handle all parsing and advance the lexer to the right position
262-
return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length)
285+
return null
263286
}
264287

265288
// Parse an at-rule: @media, @import, @font-face, etc.

0 commit comments

Comments
 (0)