From be61656bc1b47bc98b91f84534795f4643c77b6b Mon Sep 17 00:00:00 2001 From: Josh Fester Date: Mon, 10 Nov 2025 20:51:54 +0000 Subject: [PATCH 1/2] Add safeParser option to use postcss-safe-parser --- .../test/fixtures/fs-access/dist/style.css | 2 +- packages/beasties/README.md | 1 + packages/beasties/package.json | 3 +- packages/beasties/src/css.ts | 6 +++- packages/beasties/src/index.d.ts | 1 + packages/beasties/src/index.ts | 4 +-- packages/beasties/src/types.ts | 4 +++ packages/beasties/test/parse.test.ts | 25 +++++++++++++++ packages/beasties/test/src/prune-source.css | 2 +- pnpm-lock.yaml | 32 +++++++++++++++++++ 10 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css b/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css index 7a75bb8..8b13789 100644 --- a/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css +++ b/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css @@ -1 +1 @@ -div.foo{color:red} + diff --git a/packages/beasties/README.md b/packages/beasties/README.md index f7fa9d1..213b992 100644 --- a/packages/beasties/README.md +++ b/packages/beasties/README.md @@ -139,6 +139,7 @@ All optional. Pass them to `new Beasties({ ... })`. - `"all"` inline all keyframes rules - `"none"` remove all keyframes rules - `compress` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Compress resulting critical CSS _(default: `true`)_ +- `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: `false`)_ - `logLevel` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Controls [log level](#loglevel) of the plugin _(default: `"info"`)_ - `logger` **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Provide a custom logger interface [logger](#logger) diff --git a/packages/beasties/package.json b/packages/beasties/package.json index 3b98c6f..5ad8254 100644 --- a/packages/beasties/package.json +++ b/packages/beasties/package.json @@ -58,7 +58,8 @@ "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3" + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" }, "devDependencies": { "@types/postcss-media-query-parser": "0.2.4", diff --git a/packages/beasties/src/css.ts b/packages/beasties/src/css.ts index 73de9aa..448bb21 100644 --- a/packages/beasties/src/css.ts +++ b/packages/beasties/src/css.ts @@ -19,6 +19,7 @@ import type { Child, Root } from 'postcss-media-query-parser' import type Root_ from 'postcss/lib/root' import { parse, stringify } from 'postcss' import mediaParser from 'postcss-media-query-parser' +import safeParser from 'postcss-safe-parser' /** * Parse a textual CSS Stylesheet into a Stylesheet instance. @@ -26,7 +27,10 @@ import mediaParser from 'postcss-media-query-parser' * @see https://github.com/postcss/postcss/ * @private */ -export function parseStylesheet(stylesheet: string) { +export function parseStylesheet(stylesheet: string, options?: { safeParser?: boolean }) { + if (options?.safeParser) { + return safeParser(stylesheet) + } return parse(stylesheet) } diff --git a/packages/beasties/src/index.d.ts b/packages/beasties/src/index.d.ts index 2a28a03..52cc7c0 100644 --- a/packages/beasties/src/index.d.ts +++ b/packages/beasties/src/index.d.ts @@ -63,6 +63,7 @@ export interface Options { fonts?: boolean keyframes?: string compress?: boolean + safeParser?: boolean logLevel?: 'info' | 'warn' | 'error' | 'trace' | 'debug' | 'silent' reduceInlineStyles?: boolean logger?: Logger diff --git a/packages/beasties/src/index.ts b/packages/beasties/src/index.ts index 7060f61..c13f668 100644 --- a/packages/beasties/src/index.ts +++ b/packages/beasties/src/index.ts @@ -426,8 +426,8 @@ export default class Beasties { if (!sheet) return - const ast = parseStylesheet(sheet) - const astInverse = options.pruneSource ? parseStylesheet(sheet) : null + const ast = parseStylesheet(sheet, { safeParser: this.options.safeParser }) + const astInverse = options.pruneSource ? parseStylesheet(sheet, { safeParser: this.options.safeParser }) : null // a string to search for font names (very loose) let criticalFonts = '' diff --git a/packages/beasties/src/types.ts b/packages/beasties/src/types.ts index a52bdc0..1db5269 100644 --- a/packages/beasties/src/types.ts +++ b/packages/beasties/src/types.ts @@ -96,6 +96,10 @@ export interface Options { * Compress resulting critical CSS _(default: `true`)_ */ compress?: boolean + /** + * Use PostCSS safe parser for fault-tolerant CSS parsing _(default: `false`)_ + */ + safeParser?: boolean /** * Controls {@link LogLevel log level} of the plugin _(default: `"info"`)_ */ diff --git a/packages/beasties/test/parse.test.ts b/packages/beasties/test/parse.test.ts index 807cf2f..9eeeafa 100644 --- a/packages/beasties/test/parse.test.ts +++ b/packages/beasties/test/parse.test.ts @@ -74,3 +74,28 @@ describe('selector normalisation', () => { expect(warnSpy).not.toHaveBeenCalled() }) }) + +describe('safe parser', () => { + it('should handle malformed CSS with safeParser enabled', async () => { + const beasties = new Beasties({ safeParser: true }) + const html = ` + + + + + +
Test
+ + + ` + + // Should not throw an error with malformed CSS + const result = await beasties.process(html) + + // Verify the HTML was processed without throwing + expect(result).toContain('Test') + expect(result).toContain('data-beasties-container') + }) +}) diff --git a/packages/beasties/test/src/prune-source.css b/packages/beasties/test/src/prune-source.css index dc0360c..ad0edee 100644 --- a/packages/beasties/test/src/prune-source.css +++ b/packages/beasties/test/src/prune-source.css @@ -1 +1 @@ -h1{color:blue}h2.unused{color:red}p{color:purple}p.unused{color:orange}header{padding:0 50px}.banner{font-family:sans-serif}.contents{padding:50px;text-align:center}.input-field{padding:10px}footer{margin-top:10px}.container{border:1px solid}.custom-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.other-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.custom-element::part(tab):hover{background-color:#0c0d19;color:#ffffff;border-color:#0c0d33}.custom-element::part(tab):hover:active{background-color:#0c0d33;color:#ffffff}.custom-element::part(tab):focus{box-shadow:0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3)}.custom-element::part(active){color:#0060df;border-color:#0a84ff !important}div:is(:hover,.active){color:#000}div:is(.selected,:hover){color:#fff} \ No newline at end of file +h2.unused{color:red}p.unused{color:orange}header{padding:0 50px}.banner{font-family:sans-serif}footer{margin-top:10px}.container{border:1px solid}.custom-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.other-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.custom-element::part(tab):hover{background-color:#0c0d19;color:#ffffff;border-color:#0c0d33}.custom-element::part(tab):hover:active{background-color:#0c0d33;color:#ffffff}.custom-element::part(tab):focus{box-shadow:0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3)}.custom-element::part(active){color:#0060df;border-color:#0a84ff !important} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cee2a94..31d88c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: postcss-media-query-parser: specifier: ^0.2.3 version: 0.2.3 + postcss-safe-parser: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.5.6) devDependencies: '@types/postcss-media-query-parser': specifier: 0.2.4 @@ -1192,41 +1195,49 @@ packages: resolution: {integrity: sha512-Fk8BrFBfKzUveCEAXZ6kDhyc5RLWIWOI0+UZGp1G3WQIFo9HDEqnYtsOtUbzLtjifbyMhtaTteElRSGNKTJ3nA==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.11.1': resolution: {integrity: sha512-jbsO1/VTDRb5FAvWnxEIFOnFHA7dALBn5HPdxdoAbnuvjgjIPYMVvTFEBPNLz3BSFxWdounZasZDYYFhBhFzmQ==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.11.1': resolution: {integrity: sha512-+aY2AjUQkByiOtKUU0RyqB7VV7HIh3SMBh54/9nzUbHN5RiF0As5DApV/IwbQjB2oKc0VywQZzE+/Wj/Ijvd/Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.11.1': resolution: {integrity: sha512-HqBogCmIl344en3EAhC9vSm/h52fb5BA0eFxsgsH9HgwYY6qH4th4msBqBAiMRCKcC6hVwjh0fmzHgST2rx4Cw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.11.1': resolution: {integrity: sha512-9dXyIMQMrh76WyMtNDJhsRYqc6KDsQe3/ja9fAPBk28p7kltEvZvHpivq1Xa8Ca/JCa8QgTROgLInChNEF27bQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.11.1': resolution: {integrity: sha512-Ybp/bSJmnl0sr8zh+nIz0cpU077tDZDYRYDhZiWN+f7rcWF7D8Z/pKD9zPxRocvJieZGfzrIwmHiHf9eY47P9w==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.11.1': resolution: {integrity: sha512-uVWj/UI6+l5/CeV2d4XpjycJNDkk/JfxNzQLAFCsVl5ZbrIfWQ9TzEzAi7xsDgVZYLBuL7iSowQ7YYRp1LQZlA==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.11.1': resolution: {integrity: sha512-Q9kQmiZn4bNnCOqPHvdF4bHdKXBa7Ow6yfeKTWPNOHyoZXdyxIu5C+3jSjo+SJiFNhmnh0hEAN8om6GEuJEYCA==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-wasm32-wasi@11.11.1': resolution: {integrity: sha512-skGIwjoRwEh2qFIaG/wwa74i5KcoWNTEy1ycB6qdRV+OOSlkosVFIzXPYzjcuwtIL1M6pC7+M+56TgQQEzOUmw==} @@ -1400,56 +1411,67 @@ packages: resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.2': resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.2': resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.2': resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.50.2': resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.2': resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.2': resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.2': resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.2': resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.2': resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.2': resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.2': resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==} @@ -4817,6 +4839,12 @@ packages: peerDependencies: postcss: ^8.4.32 + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -11530,6 +11558,10 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 From ea920d2e8d1ebf83f5b95266257cb16882da799f Mon Sep 17 00:00:00 2001 From: Josh Fester Date: Mon, 10 Nov 2025 22:14:32 +0000 Subject: [PATCH 2/2] Restore original CSS fixtures --- .../test/fixtures/fs-access/dist/style.css | 2 +- packages/beasties/test/src/prune-source.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css b/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css index 8b13789..65b2db4 100644 --- a/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css +++ b/packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css @@ -1 +1 @@ - +div.foo{color:red} \ No newline at end of file diff --git a/packages/beasties/test/src/prune-source.css b/packages/beasties/test/src/prune-source.css index ad0edee..dc0360c 100644 --- a/packages/beasties/test/src/prune-source.css +++ b/packages/beasties/test/src/prune-source.css @@ -1 +1 @@ -h2.unused{color:red}p.unused{color:orange}header{padding:0 50px}.banner{font-family:sans-serif}footer{margin-top:10px}.container{border:1px solid}.custom-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.other-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.custom-element::part(tab):hover{background-color:#0c0d19;color:#ffffff;border-color:#0c0d33}.custom-element::part(tab):hover:active{background-color:#0c0d33;color:#ffffff}.custom-element::part(tab):focus{box-shadow:0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3)}.custom-element::part(active){color:#0060df;border-color:#0a84ff !important} \ No newline at end of file +h1{color:blue}h2.unused{color:red}p{color:purple}p.unused{color:orange}header{padding:0 50px}.banner{font-family:sans-serif}.contents{padding:50px;text-align:center}.input-field{padding:10px}footer{margin-top:10px}.container{border:1px solid}.custom-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.other-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.custom-element::part(tab):hover{background-color:#0c0d19;color:#ffffff;border-color:#0c0d33}.custom-element::part(tab):hover:active{background-color:#0c0d33;color:#ffffff}.custom-element::part(tab):focus{box-shadow:0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3)}.custom-element::part(active){color:#0060df;border-color:#0a84ff !important}div:is(:hover,.active){color:#000}div:is(.selected,:hover){color:#fff} \ No newline at end of file