Skip to content

Commit 7dc4979

Browse files
authored
feat!: safely parse mangled CSS with postcss-safe-parser (#225)
1 parent cb36d6e commit 7dc4979

10 files changed

Lines changed: 113 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@antfu/eslint-config": "7.1.0",
3838
"@codspeed/vitest-plugin": "5.0.1",
3939
"@types/node": "24.10.9",
40+
"@types/postcss-safe-parser": "^5.0.4",
4041
"@vitest/coverage-v8": "3.2.4",
4142
"bumpp": "10.4.0",
4243
"changelogithub": "14.0.0",

packages/beasties/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ All optional. Pass them to `new Beasties({ ... })`.
140140
- `"all"` inline all keyframes rules
141141
- `"none"` remove all keyframes rules
142142
- `compress` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Compress resulting critical CSS _(default: `true`)_
143+
- `safeParser` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Use PostCSS safe parser for fault-tolerant CSS parsing. Handles legacy code with syntax errors _(default: `true`)_
143144
- `logLevel` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Controls [log level](#loglevel) of the plugin _(default: `"info"`)_
144145
- `logger` **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Provide a custom logger interface [logger](#logger)
145146

packages/beasties/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"dist"
4343
],
4444
"engines": {
45-
"node": ">=14.0.0"
45+
"node": ">=18.0.0"
4646
},
4747
"scripts": {
4848
"build": "unbuild && node -e \"require('fs/promises').cp('src/index.d.ts', 'dist/index.d.ts')\"",
@@ -58,7 +58,8 @@
5858
"htmlparser2": "^10.0.0",
5959
"picocolors": "^1.1.1",
6060
"postcss": "^8.4.49",
61-
"postcss-media-query-parser": "^0.2.3"
61+
"postcss-media-query-parser": "^0.2.3",
62+
"postcss-safe-parser": "^7.0.1"
6263
},
6364
"devDependencies": {
6465
"@types/postcss-media-query-parser": "0.2.4",

packages/beasties/src/css.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ import type { Child, Root } from 'postcss-media-query-parser'
1919
import type Root_ from 'postcss/lib/root'
2020
import { parse, stringify } from 'postcss'
2121
import mediaParser from 'postcss-media-query-parser'
22+
import safeParser from 'postcss-safe-parser'
2223

2324
/**
2425
* Parse a textual CSS Stylesheet into a Stylesheet instance.
2526
* Stylesheet is a mutable postcss AST with format similar to CSSOM.
2627
* @see https://github.com/postcss/postcss/
2728
* @private
2829
*/
29-
export function parseStylesheet(stylesheet: string) {
30+
export function parseStylesheet(stylesheet: string, options?: { safeParser?: boolean }) {
31+
if (options?.safeParser) {
32+
return safeParser(stylesheet)
33+
}
3034
return parse(stylesheet)
3135
}
3236

packages/beasties/src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface Options {
6464
fonts?: boolean
6565
keyframes?: string
6666
compress?: boolean
67+
safeParser?: boolean
6768
logLevel?: 'info' | 'warn' | 'error' | 'trace' | 'debug' | 'silent'
6869
reduceInlineStyles?: boolean
6970
logger?: Logger

packages/beasties/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,8 @@ export default class Beasties {
494494
if (!sheet)
495495
return
496496

497-
const ast = parseStylesheet(sheet)
498-
const astInverse = options.pruneSource ? parseStylesheet(sheet) : null
497+
const ast = parseStylesheet(sheet, { safeParser: this.options.safeParser !== false })
498+
const astInverse = options.pruneSource ? parseStylesheet(sheet, { safeParser: this.options.safeParser !== false }) : null
499499

500500
// a string to search for font names (very loose)
501501
let criticalFonts = ''

packages/beasties/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ export interface Options {
100100
* Compress resulting critical CSS _(default: `true`)_
101101
*/
102102
compress?: boolean
103+
/**
104+
* Use PostCSS safe parser for fault-tolerant CSS parsing _(default: `true`)_
105+
*/
106+
safeParser?: boolean
103107
/**
104108
* Controls {@link LogLevel log level} of the plugin _(default: `"info"`)_
105109
*/

packages/beasties/test/parse.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,75 @@ describe('selector normalisation', () => {
7474
expect(warnSpy).not.toHaveBeenCalled()
7575
})
7676
})
77+
78+
describe('safe parser', () => {
79+
it('should handle malformed CSS by default', async () => {
80+
const beasties = new Beasties()
81+
const html = `
82+
<html>
83+
<head>
84+
<style>
85+
.test { color: red
86+
</style>
87+
</head>
88+
<body>
89+
<div class="test">Test</div>
90+
</body>
91+
</html>
92+
`
93+
94+
// Should not throw an error with malformed CSS
95+
const result = await beasties.process(html)
96+
97+
// Verify the HTML was processed without throwing
98+
expect(result).toContain('Test')
99+
expect(result).toContain('data-beasties-container')
100+
// Verify the CSS was recovered and applied
101+
expect(result).toContain('color')
102+
})
103+
104+
it('should throw with malformed CSS when safeParser is disabled', async () => {
105+
const beasties = new Beasties({ safeParser: false })
106+
const html = `
107+
<html>
108+
<head>
109+
<style>
110+
.test { color: red
111+
</style>
112+
</head>
113+
<body>
114+
<div class="test">Test</div>
115+
</body>
116+
</html>
117+
`
118+
119+
// Should throw an error with malformed CSS when safeParser is disabled
120+
await expect(beasties.process(html)).rejects.toThrow()
121+
})
122+
123+
it('should parse valid CSS correctly with safeParser disabled', async () => {
124+
const beasties = new Beasties({ safeParser: false })
125+
const html = `
126+
<html>
127+
<head>
128+
<style>
129+
.test { color: red; }
130+
.unused { color: blue; }
131+
</style>
132+
</head>
133+
<body>
134+
<div class="test">Test</div>
135+
</body>
136+
</html>
137+
`
138+
139+
const result = await beasties.process(html)
140+
141+
// Verify the HTML was processed correctly
142+
expect(result).toContain('Test')
143+
expect(result).toContain('data-beasties-container')
144+
// Verify only the used CSS is included
145+
expect(result).toContain('color:red')
146+
expect(result).not.toContain('color:blue')
147+
})
148+
})

packages/vite-plugin-beasties/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"dist"
2828
],
2929
"engines": {
30-
"node": ">=14.0.0"
30+
"node": ">=18.0.0"
3131
},
3232
"scripts": {
3333
"build": "unbuild",

pnpm-lock.yaml

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)