├── .npmrc ├── README.md ├── packages ├── beasties-webpack-plugin │ ├── test │ │ ├── fixtures │ │ │ ├── fs-access │ │ │ │ ├── index.js │ │ │ │ ├── dist │ │ │ │ │ └── style.css │ │ │ │ └── index.html │ │ │ ├── unused │ │ │ │ ├── index.js │ │ │ │ └── index.html │ │ │ ├── additionalStylesheets │ │ │ │ ├── chunk.js │ │ │ │ ├── style.css │ │ │ │ ├── additional.css │ │ │ │ ├── index.js │ │ │ │ └── index.html │ │ │ ├── raw │ │ │ │ ├── index.js │ │ │ │ └── index.html │ │ │ ├── basic │ │ │ │ ├── index.js │ │ │ │ └── index.html │ │ │ ├── external │ │ │ │ ├── index.js │ │ │ │ ├── index.html │ │ │ │ └── style.css │ │ │ ├── keyframes │ │ │ │ ├── index.js │ │ │ │ ├── index.html │ │ │ │ └── style.css │ │ │ └── inlineThreshold │ │ │ │ ├── index.js │ │ │ │ ├── index.html │ │ │ │ └── style.css │ │ ├── __snapshots__ │ │ │ ├── standalone.test.ts.snap │ │ │ └── index.test.ts.snap │ │ ├── standalone.test.ts │ │ ├── helpers.ts │ │ └── index.test.ts │ ├── src │ │ ├── util.js │ │ └── index.js │ ├── package.json │ └── README.md ├── beasties │ ├── test │ │ ├── src │ │ │ ├── styles2.css │ │ │ ├── index.html │ │ │ ├── prune-source.html │ │ │ ├── subpath-validation.html │ │ │ ├── media-validation.html │ │ │ ├── prune-source.css │ │ │ └── styles.css │ │ ├── serialize.test.ts │ │ ├── security.test.ts │ │ ├── parse.test.ts │ │ ├── __snapshots__ │ │ │ ├── preload.test.ts.snap │ │ │ └── beasties.test.ts.snap │ │ ├── beasties.bench.ts │ │ ├── preload.test.ts │ │ └── beasties.test.ts │ ├── src │ │ ├── util.ts │ │ ├── index.d.ts │ │ ├── types.ts │ │ ├── css.ts │ │ ├── dom.ts │ │ └── index.ts │ ├── package.json │ └── README.md └── vite-plugin-beasties │ ├── test │ ├── fixtures │ │ └── basic │ │ │ ├── main.js │ │ │ ├── style.css │ │ │ ├── other.html │ │ │ └── index.html │ └── index.test.ts │ ├── build.config.ts │ ├── package.json │ ├── LICENCE │ ├── README.md │ └── src │ └── index.ts ├── .gitignore ├── pnpm-workspace.yaml ├── renovate.json ├── eslint.config.mjs ├── .editorconfig ├── vitest.config.mts ├── .github └── workflows │ ├── provenance.yml │ ├── release.yml │ ├── autofix.yml │ └── ci.yml ├── tsconfig.json ├── package.json ├── CODE_OF_CONDUCT.md └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | shell-emulator=true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/beasties/README.md -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/fs-access/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/beasties/test/src/styles2.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_store 4 | next_sites 5 | coverage 6 | .vscode 7 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/unused/index.js: -------------------------------------------------------------------------------- 1 | console.log('empty file') 2 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/fs-access/dist/style.css: -------------------------------------------------------------------------------- 1 | div.foo{color:red} 2 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/additionalStylesheets/chunk.js: -------------------------------------------------------------------------------- 1 | import './additional.css' 2 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/additionalStylesheets/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | padding: 0px; 3 | } 4 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/test/fixtures/basic/main.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | console.log('Hello from Vite!') 4 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/raw/index.js: -------------------------------------------------------------------------------- 1 | import html from './index.html' 2 | 3 | module.exports = html 4 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/test/fixtures/basic/style.css: -------------------------------------------------------------------------------- 1 | .test-content { 2 | color: blue; 3 | font-weight: bold; 4 | } 5 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/additionalStylesheets/additional.css: -------------------------------------------------------------------------------- 1 | .additional-style { 2 | font-size: 200%; 3 | } 4 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/basic/index.js: -------------------------------------------------------------------------------- 1 | document.body.appendChild(document.createTextNode('this counts as SSR')) 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | ignoredBuiltDependencies: 4 | - esbuild 5 | onlyBuiltDependencies: 6 | - simple-git-hooks 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>danielroe/renovate" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/external/index.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | document.body.appendChild(document.createTextNode('this counts as SSR')) 4 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/keyframes/index.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | document.body.appendChild(document.createTextNode('this counts as SSR')) 4 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/inlineThreshold/index.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | document.body.appendChild(document.createTextNode('this counts as SSR')) 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu().append({ 5 | files: ['**/test/**'], 6 | rules: { 7 | 'no-console': 'off', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/additionalStylesheets/index.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | document.body.appendChild(document.createTextNode('this counts as SSR')) 4 | 5 | import('./chunk.js').then() 6 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/keyframes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Keyframes Demo 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | declaration: 'node16', 5 | externals: ['vite'], 6 | rollup: { 7 | dts: { 8 | respectExternal: false, 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/test/fixtures/basic/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Other Page 5 | 6 | 7 | 8 |
Other content
9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/src/util.js: -------------------------------------------------------------------------------- 1 | export function tap(inst, hook, pluginName, async, callback) { 2 | if (inst.hooks) { 3 | const camel = hook.replace(/-([a-z])/g, (s, i) => i.toUpperCase()) 4 | inst.hooks[camel][async ? 'tapAsync' : 'tap'](pluginName, callback) 5 | } 6 | else { 7 | inst.plugin(hook, callback) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/fs-access/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Access external stylesheet from FS 6 | 7 | 8 |
9 |

Access external stylesheet from FS

10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/test/fixtures/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test Page 6 | 7 | 8 | 9 | 10 |

Hello Beasties

11 |
This is a test
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/raw/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Basic Demo 5 | 11 | 16 | 17 | 18 |

Some HTML Here

19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/keyframes/style.css: -------------------------------------------------------------------------------- 1 | .present { 2 | animation: present 100ms ease forwards 1; 3 | } 4 | @keyframes present { 5 | 0% { opacity: 0; } 6 | } 7 | 8 | .not-present { 9 | will-change: transform; 10 | animation-duration: 5s; 11 | animation-name: not-present; 12 | animation-timing-function: ease; 13 | } 14 | @keyframes not-present { 15 | from { transform: scale(0.001); } 16 | to { transform: scale(1); } 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import codspeed from '@codspeed/vitest-plugin' 3 | 4 | import { defineConfig } from 'vitest/config' 5 | 6 | export default defineConfig({ 7 | plugins: [codspeed()], 8 | resolve: { 9 | alias: { 10 | beasties: fileURLToPath(new URL('./packages/beasties/src/index', import.meta.url)), 11 | }, 12 | }, 13 | test: { 14 | coverage: { 15 | include: [ 16 | 'packages/*/src/**/*.[tj]s', 17 | ], 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/external/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | External CSS Demo 5 | 6 | 7 | 15 |

My first styled page

16 |

Welcome to my styled page!

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/inlineThreshold/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | inlineThreshold CSS Demo 5 | 6 | 7 | 15 |

My first styled page

16 |

Welcome to my styled page!

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/provenance.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | permissions: 11 | contents: read 12 | jobs: 13 | check-provenance: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | - name: Check provenance downgrades 20 | uses: danielroe/provenance-action@a5a718233ca12eff67651fcf29a030bbbd5b3ca1 # v0.1.0 21 | with: 22 | fail-on-provenance-change: true 23 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/additionalStylesheets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Additional Stylesheet CSS Demo 5 | 6 | 7 | 15 |

My first styled page

16 |

Welcome to my styled page!

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": [ 5 | "es2022", 6 | "dom" 7 | ], 8 | "moduleDetection": "force", 9 | "module": "preserve", 10 | "resolveJsonModule": true, 11 | "allowJs": true, 12 | "strict": true, 13 | "noImplicitOverride": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noEmit": true, 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "isolatedModules": true, 19 | "verbatimModuleSyntax": true, 20 | "skipLibCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/beasties/test/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Testing 4 | 5 | 6 | 7 |
8 |
9 | 10 |
11 |
12 |
13 |

Hello World!

14 |

This is a paragraph

15 | 16 |
17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - run: corepack enable 19 | - uses: actions/setup-node@v6 20 | with: 21 | node-version: lts/* 22 | cache: pnpm 23 | 24 | - name: 📦 Install dependencies 25 | run: pnpm install 26 | 27 | - run: pnpm changelogithub 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /packages/beasties/test/src/prune-source.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Prune Source 4 | 5 | 6 | 7 |
8 |
9 | 10 |
11 |
12 |
13 |

Hello World!

14 |

This is a paragraph

15 | 16 |
17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/beasties/test/src/subpath-validation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Testing 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 |

Hello World!

18 |

This is a paragraph

19 | 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /packages/beasties/test/src/media-validation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Testing 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 |

Hello World!

18 |

This is a paragraph

19 | 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /packages/beasties/test/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { parseStylesheet, serializeStylesheet } from '../src/css' 3 | 4 | describe('serialize CSS AST', () => { 5 | it('should correctly minify empty property declarations', () => { 6 | const css = ` 7 | * { 8 | --un-backdrop-saturate: ; 9 | --un-backdrop-sepia: ; 10 | } 11 | :not(.test) { 12 | height:inherit; 13 | width:inherit; 14 | } 15 | ` 16 | const ast = parseStylesheet(css) 17 | 18 | expect(serializeStylesheet(ast, { compress: true })).toMatchInlineSnapshot( 19 | `"*{--un-backdrop-saturate: ;--un-backdrop-sepia: }:not(.test){height:inherit;width:inherit}"`, 20 | ) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/unused/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Basic Demo 5 | 11 | 26 | 27 | 28 |

Some HTML Here

29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/beasties/test/src/prune-source.css: -------------------------------------------------------------------------------- 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} -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/inlineThreshold/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-left: 11em; 3 | font-family: "Times New Roman", times, serif; 4 | color: purple; 5 | background-color: #d8da3d; 6 | } 7 | ul.navbar { 8 | list-style-type: none; 9 | padding: 0; 10 | margin: 0; 11 | position: absolute; 12 | top: 2em; 13 | left: 1em; 14 | width: 9em; 15 | } 16 | h1 { 17 | font-family: helvetica, arial, sans-serif; 18 | } 19 | ul.navbar li { 20 | background: white; 21 | margin: 0.5em 0; 22 | padding: 0.3em; 23 | border-right: 1em solid black; 24 | } 25 | ul.navbar a { 26 | text-decoration: none; 27 | } 28 | a:link { 29 | color: blue; 30 | } 31 | a:visited { 32 | color: purple; 33 | } 34 | footer { 35 | margin-top: 1em; 36 | padding-top: 1em; 37 | border-top: thin dotted; 38 | } 39 | .extra-style { 40 | font-size: 200%; 41 | } 42 | 43 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/external/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 10px; 3 | } 4 | html { 5 | height: 100%; 6 | } 7 | body { 8 | padding-left: 11em; 9 | font-family: 'Times New Roman', times, serif; 10 | color: purple; 11 | background-color: #d8da3d; 12 | } 13 | *,:after,:before{ 14 | box-sizing: inherit 15 | } 16 | ul.navbar { 17 | list-style-type: none; 18 | padding: 0; 19 | margin: 0; 20 | position: absolute; 21 | top: 2em; 22 | left: 1em; 23 | width: 9em; 24 | } 25 | h1 { 26 | font-family: helvetica, arial, sans-serif; 27 | } 28 | ul.navbar li { 29 | background: white; 30 | margin: 0.5em 0; 31 | padding: 0.3em; 32 | border-right: 1em solid black; 33 | } 34 | ul.navbar a { 35 | text-decoration: none; 36 | } 37 | a:link { 38 | color: blue; 39 | } 40 | a:visited { 41 | color: purple; 42 | } 43 | footer { 44 | margin-top: 1em; 45 | padding-top: 1em; 46 | border-top: thin dotted; 47 | } 48 | .extra-style { 49 | font-size: 200%; 50 | } 51 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-beasties", 3 | "type": "module", 4 | "version": "0.3.5", 5 | "packageManager": "pnpm@10.16.1", 6 | "description": "", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/danielroe/beasties.git", 11 | "directory": "packages/vite-plugin-beasties" 12 | }, 13 | "sideEffects": false, 14 | "exports": { 15 | ".": "./dist/index.mjs" 16 | }, 17 | "main": "./dist/index.mjs", 18 | "module": "./dist/index.mjs", 19 | "typesVersions": { 20 | "*": { 21 | "*": [ 22 | "./dist/index.d.mts" 23 | ] 24 | } 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "engines": { 30 | "node": ">=14.0.0" 31 | }, 32 | "scripts": { 33 | "build": "unbuild", 34 | "build:stub": "unbuild --stub", 35 | "prepack": "pnpm build" 36 | }, 37 | "dependencies": { 38 | "beasties": "workspace:^" 39 | }, 40 | "devDependencies": { 41 | "rollup": "4.50.2", 42 | "unbuild": "3.6.1", 43 | "vite": "7.3.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.number || github.sha }} 13 | cancel-in-progress: ${{ github.event_name != 'push' }} 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | - run: corepack enable 22 | - uses: actions/setup-node@v6 23 | with: 24 | node-version: lts/* 25 | cache: pnpm 26 | 27 | - name: 📦 Install dependencies 28 | run: pnpm install 29 | 30 | - name: Dedupe dependencies 31 | if: ${{ contains(github.head_ref, 'renovate') }} 32 | run: pnpm dedupe 33 | 34 | - name: 🔠 Lint project 35 | run: pnpm lint --fix 36 | 37 | - name: ⚙️ Check engine ranges 38 | run: pnpm test:versions --fix 39 | 40 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 41 | with: 42 | commit-message: 'chore: apply automated fixes' 43 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Daniel Roe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/__snapshots__/standalone.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`usage without html-webpack-plugin > should process the first html asset 1`] = ` 4 | " 5 | h1 { 6 | color: green; 7 | } 8 | " 9 | `; 10 | 11 | exports[`usage without html-webpack-plugin > should process the first html asset 2`] = ` 12 | " 13 | 14 | 15 | Basic Demo 16 | 17 | 22 | 23 | 24 |

Some HTML Here

25 | 26 | 27 | " 28 | `; 29 | 30 | exports[`webpack compilation 1`] = ` 31 | " 32 | 33 | 34 | Basic Demo 35 | 41 | 46 | 47 | 48 |

Some HTML Here

49 | 50 | 51 | " 52 | `; 53 | -------------------------------------------------------------------------------- /packages/beasties/test/src/styles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | h2.unused { 6 | color: red; 7 | } 8 | 9 | p { 10 | color: purple; 11 | } 12 | 13 | p.unused { 14 | color: orange; 15 | } 16 | 17 | header { 18 | padding: 0 50px; 19 | } 20 | 21 | .banner { 22 | font-family: sans-serif; 23 | } 24 | 25 | .contents { 26 | padding: 50px; 27 | text-align: center; 28 | } 29 | 30 | .input-field { 31 | padding: 10px; 32 | } 33 | 34 | footer { 35 | margin-top: 10px; 36 | } 37 | 38 | /* beasties:exclude */ 39 | .container { 40 | border: 1px solid; 41 | } 42 | 43 | /* beasties:include */ 44 | .custom-element::part(tab) { 45 | color: #0c0dcc; 46 | border-bottom: transparent solid 2px; 47 | } 48 | 49 | /*! beasties:include */ 50 | .other-element::part(tab) { 51 | color: #0c0dcc; 52 | border-bottom: transparent solid 2px; 53 | } 54 | 55 | .custom-element::part(tab):hover { 56 | background-color: #0c0d19; 57 | color: #ffffff; 58 | border-color: #0c0d33; 59 | } 60 | 61 | /* beasties:include start */ 62 | .custom-element::part(tab):hover:active { 63 | background-color: #0c0d33; 64 | color: #ffffff; 65 | } 66 | 67 | .custom-element::part(tab):focus { 68 | box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 69 | 0 0 0 4px rgba(10, 132, 255, 0.3); 70 | } 71 | /* beasties:include end */ 72 | 73 | .custom-element::part(active) { 74 | color: #0060df; 75 | border-color: #0a84ff !important; 76 | } 77 | 78 | div:is(:hover, .active) { 79 | color: #000; 80 | } 81 | 82 | div:is(.selected, :hover) { 83 | color: #fff; 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v6 17 | - run: corepack enable 18 | - uses: actions/setup-node@v6 19 | with: 20 | node-version: lts/* 21 | cache: pnpm 22 | 23 | - name: 📦 Install dependencies 24 | run: pnpm install 25 | 26 | - name: 🔠 Lint project 27 | run: pnpm lint 28 | 29 | test: 30 | strategy: 31 | matrix: 32 | os: [ubuntu-latest, windows-latest] 33 | runs-on: ${{ matrix.os }} 34 | env: 35 | CI: true 36 | 37 | steps: 38 | - uses: actions/checkout@v6 39 | - run: corepack enable 40 | - uses: actions/setup-node@v6 41 | with: 42 | node-version: lts/* 43 | cache: pnpm 44 | 45 | - name: 📦 Install dependencies 46 | run: pnpm install 47 | 48 | - name: 💪 Test types 49 | run: pnpm test:types 50 | 51 | - name: 🛠 Build project 52 | run: pnpm build 53 | 54 | - name: 🧪 Test project 55 | run: pnpm test 56 | 57 | - name: Run benchmarks 58 | if: matrix.os != 'windows-latest' 59 | uses: CodSpeedHQ/action@v3 60 | with: 61 | run: pnpm vitest bench 62 | token: ${{ secrets.CODSPEED_TOKEN }} 63 | 64 | - name: ⚙️ Check engine ranges 65 | run: pnpm test:versions 66 | 67 | - name: 🟩 Coverage 68 | if: matrix.os != 'windows-latest' 69 | uses: codecov/codecov-action@v5 70 | with: 71 | token: ${{ secrets.CODECOV_TOKEN }} 72 | -------------------------------------------------------------------------------- /packages/beasties/src/util.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import pc from 'picocolors' 4 | 5 | const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'silent'] as const 6 | 7 | /** Custom logger interface. */ 8 | export interface Logger { 9 | /** Prints a trace message */ 10 | trace?: (message: string) => void 11 | /** Prints a debug message */ 12 | debug?: (message: string) => void 13 | /** Prints an information message */ 14 | info?: (message: string) => void 15 | /** Prints a warning message */ 16 | warn?: (message: string) => void 17 | /** Prints an error message */ 18 | error?: (message: string) => void 19 | silent?: (message: string) => void 20 | } 21 | 22 | const defaultLogger = { 23 | trace(msg) { 24 | // eslint-disable-next-line no-console 25 | console.trace(msg) 26 | }, 27 | 28 | debug(msg) { 29 | // eslint-disable-next-line no-console 30 | console.debug(msg) 31 | }, 32 | 33 | warn(msg) { 34 | console.warn(pc.yellow(msg)) 35 | }, 36 | 37 | error(msg) { 38 | console.error(pc.bold(pc.red(msg))) 39 | }, 40 | 41 | info(msg) { 42 | // eslint-disable-next-line no-console 43 | console.info(pc.bold(pc.blue(msg))) 44 | }, 45 | 46 | silent() {}, 47 | } satisfies Logger 48 | 49 | export type LogLevel = typeof LOG_LEVELS[number] 50 | 51 | export function createLogger(logLevel: LogLevel) { 52 | const logLevelIdx = LOG_LEVELS.indexOf(logLevel) 53 | 54 | return LOG_LEVELS.reduce((logger: Partial, type, index) => { 55 | if (index >= logLevelIdx) { 56 | logger[type] = defaultLogger[type] 57 | } 58 | else { 59 | logger[type] = defaultLogger.silent 60 | } 61 | return logger 62 | }, {}) 63 | } 64 | 65 | export function isSubpath(basePath: string, currentPath: string) { 66 | return !path.relative(basePath, currentPath).startsWith('..') 67 | } 68 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/fixtures/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Basic Demo 5 | 65 | 66 | 67 | 75 |

My first styled page

76 |

Welcome to my styled page!

77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beasties-root", 3 | "private": true, 4 | "packageManager": "pnpm@10.21.0", 5 | "description": "Inline critical CSS and lazy-load the rest.", 6 | "author": "The Chromium Authors", 7 | "contributors": [ 8 | { 9 | "name": "Jason Miller", 10 | "email": "developit@google.com" 11 | }, 12 | { 13 | "name": "Janicklas Ralph", 14 | "email": "janicklas@google.com" 15 | }, 16 | { 17 | "name": "Daniel Roe", 18 | "email": "daniel@roe.dev", 19 | "url": "https://roe.dev" 20 | } 21 | ], 22 | "license": "Apache-2.0", 23 | "scripts": { 24 | "build": "pnpm -r build", 25 | "build:main": "pnpm --filter beasties run build", 26 | "build:webpack": "pnpm --filter beasties-webpack-plugin run build", 27 | "postinstall": "simple-git-hooks && pnpm -r build:stub", 28 | "docs": "pnpm -r docs", 29 | "lint": "eslint .", 30 | "release": "bumpp -r && pnpm -r publish", 31 | "test": "vitest --coverage", 32 | "test:types": "tsc --noEmit", 33 | "test:knip": "knip", 34 | "test:versions": "installed-check --no-include-workspace-root --ignore-dev" 35 | }, 36 | "devDependencies": { 37 | "@antfu/eslint-config": "6.0.0", 38 | "@codspeed/vitest-plugin": "5.0.1", 39 | "@types/node": "22.18.4", 40 | "@vitest/coverage-v8": "3.2.4", 41 | "bumpp": "10.2.3", 42 | "changelogithub": "14.0.0", 43 | "cheerio": "1.1.2", 44 | "eslint": "9.38.0", 45 | "installed-check": "9.3.0", 46 | "jsdom": "27.3.0", 47 | "knip": "5.63.1", 48 | "lint-staged": "16.1.6", 49 | "simple-git-hooks": "2.13.1", 50 | "typescript": "5.9.2", 51 | "vitest": "3.2.4" 52 | }, 53 | "resolutions": { 54 | "beasties": "workspace:*", 55 | "vite-plugin-beasties": "link:." 56 | }, 57 | "simple-git-hooks": { 58 | "pre-commit": "npx lint-staged" 59 | }, 60 | "lint-staged": { 61 | "*.{js,ts,mjs,cjs,json,.*rc}": [ 62 | "npx eslint --fix" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/beasties/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beasties", 3 | "version": "0.3.5", 4 | "description": "Inline critical CSS and lazy-load the rest.", 5 | "author": "The Chromium Authors", 6 | "contributors": [ 7 | { 8 | "name": "Jason Miller", 9 | "email": "developit@google.com" 10 | }, 11 | { 12 | "name": "Janicklas Ralph", 13 | "email": "janicklas@google.com" 14 | } 15 | ], 16 | "license": "Apache-2.0", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/danielroe/beasties", 20 | "directory": "packages/beasties" 21 | }, 22 | "keywords": [ 23 | "critical css", 24 | "inline css", 25 | "critical", 26 | "beasties", 27 | "webpack plugin", 28 | "performance" 29 | ], 30 | "exports": { 31 | ".": { 32 | "types": "./dist/index.d.ts", 33 | "import": "./dist/index.mjs", 34 | "require": "./dist/index.cjs", 35 | "default": "./dist/index.mjs" 36 | } 37 | }, 38 | "main": "dist/index.cjs", 39 | "module": "dist/index.mjs", 40 | "types": "./dist/index.d.ts", 41 | "files": [ 42 | "dist" 43 | ], 44 | "engines": { 45 | "node": ">=14.0.0" 46 | }, 47 | "scripts": { 48 | "build": "unbuild && node -e \"require('fs/promises').cp('src/index.d.ts', 'dist/index.d.ts')\"", 49 | "build:stub": "unbuild --stub && node -e \"require('fs/promises').cp('src/index.d.ts', 'dist/index.d.ts')\"", 50 | "docs": "documentation readme src -q --no-markdown-toc -a public -s Usage --sort-order alpha", 51 | "prepack": "npm run -s build" 52 | }, 53 | "dependencies": { 54 | "css-select": "^6.0.0", 55 | "css-what": "^7.0.0", 56 | "dom-serializer": "^2.0.0", 57 | "domhandler": "^5.0.3", 58 | "htmlparser2": "^10.0.0", 59 | "picocolors": "^1.1.1", 60 | "postcss": "^8.4.49", 61 | "postcss-media-query-parser": "^0.2.3" 62 | }, 63 | "devDependencies": { 64 | "@types/postcss-media-query-parser": "0.2.4", 65 | "documentation": "14.0.3", 66 | "unbuild": "3.6.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/standalone.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import type webpack from 'webpack' 18 | import { beforeAll, describe, expect, it } from 'vitest' 19 | 20 | import { compile, compileToHtml, readFile } from './helpers' 21 | 22 | function configure(config: webpack.Configuration) { 23 | config.module!.rules!.push( 24 | { 25 | test: /\.css$/, 26 | loader: 'css-loader', 27 | }, 28 | { 29 | test: /\.html$/, 30 | loader: 'file-loader', 31 | options: { 32 | name: '[name].[ext]', 33 | }, 34 | }, 35 | ) 36 | } 37 | 38 | it('webpack compilation', async () => { 39 | const info = await compile('fixtures/raw/index.js', configure) 40 | expect(info.assets).toHaveLength(2) 41 | expect(await readFile('fixtures/raw/dist/index.html')).toMatchSnapshot() 42 | }) 43 | 44 | describe('usage without html-webpack-plugin', () => { 45 | let output: Awaited> 46 | beforeAll(async () => { 47 | output = await compileToHtml('raw', configure) 48 | }) 49 | 50 | it('should process the first html asset', () => { 51 | const { html, document } = output 52 | expect(document.querySelectorAll('style')).toHaveLength(1) 53 | expect(document.getElementById('unused')).toBeNull() 54 | expect(document.getElementById('used')).not.toBeNull() 55 | expect(document.getElementById('used')!.textContent).toMatchSnapshot() 56 | expect(html).toMatchSnapshot() 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beasties-webpack-plugin", 3 | "version": "0.3.5", 4 | "description": "Webpack plugin to inline critical CSS and lazy-load the rest.", 5 | "author": "The Chromium Authors", 6 | "contributors": [ 7 | { 8 | "name": "Jason Miller", 9 | "email": "developit@google.com" 10 | }, 11 | { 12 | "name": "Janicklas Ralph", 13 | "email": "janicklas@google.com" 14 | }, 15 | { 16 | "name": "Daniel Roe", 17 | "email": "daniel@roe.dev", 18 | "url": "https://roe.dev" 19 | } 20 | ], 21 | "license": "Apache-2.0", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/danielroe/beasties", 25 | "directory": "packages/beasties-webpack-plugin" 26 | }, 27 | "keywords": [ 28 | "critical css", 29 | "inline css", 30 | "critical", 31 | "beasties", 32 | "webpack plugin", 33 | "performance" 34 | ], 35 | "exports": { 36 | "import": "./dist/beasties-webpack-plugin.mjs", 37 | "require": "./dist/beasties-webpack-plugin.js", 38 | "default": "./dist/beasties-webpack-plugin.mjs" 39 | }, 40 | "main": "dist/beasties-webpack-plugin.js", 41 | "module": "dist/beasties-webpack-plugin.mjs", 42 | "source": "src/index.js", 43 | "files": [ 44 | "dist", 45 | "src" 46 | ], 47 | "engines": { 48 | "node": "^20.0.0 || >=22.0.0" 49 | }, 50 | "scripts": { 51 | "build": "microbundle --target node --no-sourcemap -f cjs,esm", 52 | "docs": "documentation readme src -q --no-markdown-toc -a public -s Usage --sort-order alpha", 53 | "prepack": "npm run -s build" 54 | }, 55 | "peerDependencies": { 56 | "html-webpack-plugin": "^5.0.0" 57 | }, 58 | "peerDependenciesMeta": { 59 | "html-webpack-plugin": { 60 | "optional": true 61 | } 62 | }, 63 | "dependencies": { 64 | "beasties": "workspace:*", 65 | "minimatch": "^10.0.1", 66 | "webpack-log": "^3.0.2", 67 | "webpack-sources": "^3.2.3" 68 | }, 69 | "devDependencies": { 70 | "@types/jsdom": "27.0.0", 71 | "@types/webpack-sources": "3.2.3", 72 | "css-loader": "7.1.2", 73 | "documentation": "14.0.3", 74 | "file-loader": "6.2.0", 75 | "html-webpack-plugin": "5.6.4", 76 | "microbundle": "0.15.1", 77 | "mini-css-extract-plugin": "2.9.4", 78 | "webpack": "5.101.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/beasties/test/security.test.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio' 2 | import { describe, expect, it } from 'vitest' 3 | import Beasties from '../src/index' 4 | 5 | // function hasEvilOnload(html) { 6 | // const $ = cheerio.load(html, { scriptingEnabled: true }) 7 | // return $('[onload]').attr('onload').includes(`''-alert(1)-''`) 8 | // } 9 | 10 | function hasEvilScript(html: string) { 11 | const $ = cheerio.load(html, { scriptingEnabled: true }) 12 | const scripts = Array.from($('script')) 13 | return scripts.some(s => (s as unknown as HTMLScriptElement).textContent?.trim() === 'alert(1)') 14 | } 15 | 16 | describe('beasties', () => { 17 | it('should not decode entities', async () => { 18 | const beasties = new Beasties({}) 19 | const html = await beasties.process(` 20 | 21 | 22 | <script>alert(1)</script> 23 | `) 24 | expect(hasEvilScript(html)).toBeFalsy() 25 | }) 26 | it('should not create a new script tag from embedding linked stylesheets', async () => { 27 | const beasties = new Beasties({}) 28 | beasties.readFile = () => `* { background: url('') }` 29 | const html = await beasties.process(` 30 | 31 | 32 | 33 | 34 | 35 | 36 | `) 37 | expect(hasEvilScript(html)).toBeFalsy() 38 | }) 39 | it('should not create a new script tag from embedding additional stylesheets', async () => { 40 | const beasties = new Beasties({ 41 | additionalStylesheets: ['/style.css'], 42 | }) 43 | beasties.readFile = () => `* { background: url('') }` 44 | const html = await beasties.process(` 45 | 46 | 47 | 48 | 49 | 50 | 51 | `) 52 | expect(hasEvilScript(html)).toBeFalsy() 53 | }) 54 | 55 | it('should not create a new script tag by ending from href', async () => { 56 | const beasties = new Beasties({ preload: 'js' }) 57 | beasties.readFile = () => `* { background: red }` 58 | const html = await beasties.process(` 59 | 60 | 61 | 62 | 63 | 64 | 65 | `) 66 | expect(hasEvilScript(html)).toBeFalsy() 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /packages/beasties/test/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import Beasties from '../src/index' 3 | 4 | describe('selector normalisation', () => { 5 | it('should handle complex selectors', async () => { 6 | vi.spyOn(console, 'warn') 7 | const beasties = new Beasties() 8 | const result = await beasties.process(` 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | 17 | 18 | `) 19 | 20 | expect(result.replace(/^ {4}/gm, '')).toMatchInlineSnapshot(` 21 | " 22 | 23 | 24 | 25 | 26 |
27 |
28 |
29 | 30 | 31 | " 32 | `) 33 | expect(console.warn).not.toBeCalled() 34 | }) 35 | 36 | it('should preserve valid combinator chains', async () => { 37 | const warnSpy = vi.spyOn(console, 'warn') 38 | const beasties = new Beasties() 39 | const html = ` 40 | 41 | 55 | 56 |
57 |
58 |
59 |
60 |
61 |

Header

62 |

Para

63 | 64 | ` 65 | const out = await beasties.process(html) 66 | 67 | expect(out).toContain('.form-floating>~label{color:red}') 68 | expect(out).toContain('.btn-group>+.btn{color:blue}') 69 | expect(out).toContain('.lobot>*+*{margin:0}') 70 | expect(out).toContain('.foo~+span{color:aqua}') 71 | expect(out).toContain('.bar+~div{color:salmon}') 72 | expect(out).toContain('.baz~>h1{font-weight:bold}') 73 | expect(out).toContain('.qux+>p{text-align:center}') 74 | expect(warnSpy).not.toHaveBeenCalled() 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /packages/beasties/src/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | export default class Beasties { 18 | /** 19 | * Create an instance of Beasties with custom options. 20 | * The `.process()` method can be called repeatedly to re-use this instance and its cache. 21 | */ 22 | constructor(options?: Options) 23 | /** 24 | * Process an HTML document to inline critical CSS from its stylesheets. 25 | * @param html String containing a full HTML document to be parsed. 26 | * @returns A modified copy of the provided HTML with critical CSS inlined. 27 | */ 28 | process(html: string): Promise 29 | /** 30 | * Read the contents of a file from the specified filesystem or disk. 31 | * Override this method to customize how stylesheets are loaded. 32 | */ 33 | readFile(filename: string): Promise | string 34 | /** 35 | * Given a stylesheet URL, returns the corresponding CSS asset. 36 | * Overriding this method requires doing your own URL normalization, so it's generally better to override `readFile()`. 37 | */ 38 | getCssAsset(href: string): Promise | string | undefined 39 | /** 40 | * Override this method to customise how beasties prunes the content of source files. 41 | */ 42 | pruneSource(style: Node, before: string, sheetInverse: string): boolean 43 | /** 44 | * Override this method to customise how beasties prunes the content of source files. 45 | */ 46 | checkInlineThreshold(link: Node, style: Node, sheet: string): boolean 47 | } 48 | 49 | export interface Options { 50 | path?: string 51 | publicPath?: string 52 | external?: boolean 53 | inlineThreshold?: number 54 | minimumExternalSize?: number 55 | pruneSource?: boolean 56 | mergeStylesheets?: boolean 57 | additionalStylesheets?: string[] 58 | preload?: 'body' | 'media' | 'swap' | 'swap-high' | 'swap-low' | 'js' | 'js-lazy' 59 | noscriptFallback?: boolean 60 | inlineFonts?: boolean 61 | preloadFonts?: boolean 62 | allowRules?: Array 63 | fonts?: boolean 64 | keyframes?: string 65 | compress?: boolean 66 | logLevel?: 'info' | 'warn' | 'error' | 'trace' | 'debug' | 'silent' 67 | reduceInlineStyles?: boolean 68 | logger?: Logger 69 | } 70 | 71 | export interface Logger { 72 | trace?: (message: string) => void 73 | debug?: (message: string) => void 74 | info?: (message: string) => void 75 | warn?: (message: string) => void 76 | error?: (message: string) => void 77 | } 78 | -------------------------------------------------------------------------------- /packages/beasties/test/__snapshots__/preload.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`preload modes > should handle "false" preload mode correctly 1`] = ` 4 | " 5 | 6 | 7 | 8 | 9 | 10 |

Hello World!

11 | 12 | 13 | " 14 | `; 15 | 16 | exports[`preload modes > should use "js" preload mode correctly 1`] = ` 17 | " 18 | 19 | 20 | 21 | 22 | 23 |

Hello World!

24 | 25 | 26 | " 27 | `; 28 | 29 | exports[`preload modes > should use "media" preload mode correctly 1`] = ` 30 | " 31 | 32 | 33 | 34 | 35 | 36 |

Hello World!

37 | 38 | 39 | " 40 | `; 41 | 42 | exports[`preload modes > should use "swap" preload mode correctly 1`] = ` 43 | " 44 | 45 | 46 | 47 | 48 | 49 |

Hello World!

50 | 51 | 52 | " 53 | `; 54 | 55 | exports[`preload modes > should use "swap-high" preload mode correctly 1`] = ` 56 | " 57 | 58 | 59 | 60 | 61 | 62 |

Hello World!

63 | 64 | 65 | " 66 | `; 67 | 68 | exports[`preload modes > should use "swap-low" preload mode correctly 1`] = ` 69 | " 70 | 71 | 72 | 73 | 74 | 75 |

Hello World!

76 | 77 | 78 | " 79 | `; 80 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/README.md: -------------------------------------------------------------------------------- 1 | # vite-plugin-beasties 2 | 3 | A Vite plugin that uses [beasties](https://github.com/danielroe/beasties) to embed critical CSS in your HTML pages. 4 | 5 | ## Features 6 | 7 | - 🚀 Automatically identifies and inlines critical CSS 8 | - 🧹 Supports pruning the CSS files to remove inlined styles from external stylesheets 9 | - 🔄 Works with Vite's build process using the `transformIndexHtml` hook 10 | - ⚙️ Full access to beasties configuration options 11 | 12 | ## Installation 13 | 14 | ```bash 15 | # npm 16 | npm install -D vite-plugin-beasties 17 | 18 | # yarn 19 | yarn add -D vite-plugin-beasties 20 | 21 | # pnpm 22 | pnpm add -D vite-plugin-beasties 23 | ``` 24 | 25 | ## Usage 26 | 27 | Add the plugin to your `vite.config.js/ts`: 28 | 29 | ```js 30 | // vite.config.js 31 | import { defineConfig } from 'vite' 32 | import { beasties } from 'vite-plugin-beasties' 33 | 34 | export default defineConfig({ 35 | plugins: [ 36 | beasties({ 37 | // Plugin options 38 | options: { 39 | // Beasties library options 40 | preload: 'swap', 41 | pruneSource: true, // Enable pruning CSS files 42 | inlineThreshold: 4000, // Inline stylesheets smaller than 4kb 43 | }, 44 | // Filter to apply beasties only to specific HTML files 45 | filter: path => path.endsWith('.html'), 46 | }), 47 | ], 48 | }) 49 | ``` 50 | 51 | ## Options 52 | 53 | ### Plugin Options 54 | 55 | | Option | Type | Default | Description | 56 | |--------|------|---------|-------------| 57 | | `options` | `Object` | `{}` | Options passed to the beasties constructor | 58 | | `filter` | `Function` | `(path) => path.endsWith('.html')` | Filter function to determine which HTML files to process | 59 | 60 | ### Beasties Options 61 | 62 | See the [beasties documentation](https://github.com/danielroe/beasties) for all available options. 63 | 64 | Common options include: 65 | 66 | - `preload`: Strategy for loading non-critical CSS (`'js'`, `'js-lazy'`, `'media'`, `'swap'`, `'swap-high'`, `'swap-low'`, `false`) 67 | - `pruneSource`: Whether to update external CSS files to remove inlined styles 68 | - `inlineThreshold`: Size limit in bytes to inline an entire stylesheet 69 | - `minimumExternalSize`: If the non-critical part of a CSS file is smaller than this, the entire file will be inlined 70 | - `additionalStylesheets`: Additional stylesheets to consider for critical CSS 71 | 72 | ## 💻 Development 73 | 74 | - Clone this repository 75 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 76 | - Install dependencies using `pnpm install` 77 | - Run interactive tests using `pnpm dev` 78 | 79 | ## License 80 | 81 | MIT 82 | 83 | Published under [MIT License](./LICENCE). 84 | 85 | 86 | 87 | [npm-version-src]: https://img.shields.io/npm/v/vite-plugin-beasties?style=flat-square 88 | [npm-version-href]: https://npmjs.com/package/vite-plugin-beasties 89 | [npm-downloads-src]: https://img.shields.io/npm/dm/vite-plugin-beasties?style=flat-square 90 | [npm-downloads-href]: https://npm.chart.dev/vite-plugin-beasties 91 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/vite-plugin-beasties/ci.yml?branch=main&style=flat-square 92 | [github-actions-href]: https://github.com/danielroe/vite-plugin-beasties/actions?query=workflow%3Aci 93 | [codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/vite-plugin-beasties/main?style=flat-square 94 | [codecov-href]: https://codecov.io/gh/danielroe/vite-plugin-beasties 95 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 44 | 45 | [homepage]: https://www.contributor-covenant.org 46 | 47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 48 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/test/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import type { Options } from 'beasties' 18 | 19 | import fs from 'node:fs' 20 | import path from 'node:path' 21 | import { fileURLToPath } from 'node:url' 22 | import { promisify } from 'node:util' 23 | 24 | import { JSDOM } from 'jsdom' 25 | import webpack from 'webpack' 26 | 27 | import BeastiesWebpackPlugin from '../src/index' 28 | 29 | const cwd = fileURLToPath(new URL('.', import.meta.url)) 30 | 31 | const { window } = new JSDOM() 32 | 33 | // parse a string into a JSDOM Document 34 | function parseDom(html: string) { 35 | return new window.DOMParser().parseFromString(html, 'text/html') 36 | } 37 | 38 | // returns a promise resolving to the contents of a file 39 | export function readFile(file: string) { 40 | return promisify(fs.readFile)(path.resolve(cwd, file), 'utf-8') 41 | } 42 | 43 | // invoke webpack on a given entry module, optionally mutating the default configuration 44 | export function compile(entry: string, configDecorator: (config: webpack.Configuration) => webpack.Configuration | void) { 45 | return new Promise((resolve, reject) => { 46 | const context = path.dirname(path.resolve(cwd, entry)) 47 | entry = path.basename(entry) 48 | let config: webpack.Configuration = { 49 | context, 50 | entry: path.resolve(context, entry), 51 | output: { 52 | path: path.resolve(cwd, path.resolve(context, 'dist')), 53 | filename: 'bundle.js', 54 | chunkFilename: '[name].chunk.js', 55 | }, 56 | resolveLoader: { 57 | modules: [path.resolve(cwd, '../node_modules')], 58 | }, 59 | module: { 60 | rules: [], 61 | }, 62 | plugins: [], 63 | } 64 | if (configDecorator) { 65 | config = configDecorator(config) || config 66 | } 67 | 68 | webpack(config, (err, stats) => { 69 | if (err) 70 | return reject(err) 71 | const info = stats!.toJson() 72 | if (stats?.hasErrors()) { 73 | return reject(info.errors?.join('\n')) 74 | } 75 | resolve(info) 76 | }) 77 | }) 78 | } 79 | 80 | // invoke webpack via compile(), applying Beasties to inline CSS and injecting `html` and `document` properties into the webpack build info. 81 | export async function compileToHtml( 82 | fixture: string, 83 | configDecorator: (config: webpack.Configuration) => webpack.Configuration | void, 84 | beastiesOptions: Options = {}, 85 | ) { 86 | const info = await compile(`fixtures/${fixture}/index.js`, (config) => { 87 | config = configDecorator(config) || config 88 | config.plugins!.push( 89 | new BeastiesWebpackPlugin({ 90 | pruneSource: true, 91 | compress: false, 92 | logLevel: 'silent', 93 | ...beastiesOptions, 94 | }), 95 | ) 96 | }) 97 | const html = await readFile(`fixtures/${fixture}/dist/index.html`) 98 | return Object.assign(info, { 99 | html, 100 | document: parseDom(html), 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /packages/beasties-webpack-plugin/README.md: -------------------------------------------------------------------------------- 1 |

2 | beasties-webpack-plugin 3 |

Beasties Webpack plugin

4 |

5 | 6 | > beasties-webpack-plugin inlines your app's [critical CSS] and lazy-loads the rest. 7 | 8 | ## beasties-webpack-plugin [![npm](https://img.shields.io/npm/v/beasties-webpack-plugin.svg?style=flat)](https://www.npmjs.org/package/beasties-webpack-plugin) 9 | 10 | It's a little different from [other options](#similar-libraries), because it **doesn't use a headless browser** to render content. This tradeoff allows Beasties to be very **fast and lightweight**. It also means Beasties inlines all CSS rules used by your document, rather than only those needed for above-the-fold content. For alternatives, see [Similar Libraries](#similar-libraries). 11 | 12 | Beasties' design makes it a good fit when inlining critical CSS for prerendered/SSR'd Single Page Applications. It was developed to be an excellent compliment to [prerender-loader](https://github.com/GoogleChromeLabs/prerender-loader), combining to dramatically improve first paint time for most Single Page Applications. 13 | 14 | ## Features 15 | 16 | * Fast - no browser, few dependencies 17 | * Integrates with [html-webpack-plugin] 18 | * Works with `webpack-dev-server` / `webpack serve` 19 | * Supports preloading and/or inlining critical fonts 20 | * Prunes unused CSS keyframes and media queries 21 | * Removes inlined CSS rules from lazy-loaded stylesheets 22 | 23 | ## Installation 24 | 25 | First, install Beasties as a development dependency: 26 | 27 | ```sh 28 | npm i -D beasties-webpack-plugin 29 | ``` 30 | 31 | Then, import Beasties into your Webpack configuration and add it to your list of plugins: 32 | 33 | ```diff 34 | // webpack.config.js 35 | +const Beasties = require('beasties-webpack-plugin'); 36 | 37 | module.exports = { 38 | plugins: [ 39 | + new Beasties({ 40 | + // optional configuration (see below) 41 | + }) 42 | ] 43 | } 44 | ``` 45 | 46 | That's it! Now when you run Webpack, the CSS used by your HTML will be inlined and the imports for your full CSS will be converted to load asynchronously. 47 | 48 | ## Usage 49 | 50 | 51 | 52 | ### BeastiesWebpackPlugin 53 | 54 | **Extends Beasties** 55 | 56 | Create a Beasties plugin instance with the given options. 57 | 58 | #### Parameters 59 | 60 | * `options` **Options** Options to control how Beasties inlines CSS. See 61 | 62 | #### Examples 63 | 64 | ```javascript 65 | // webpack.config.js 66 | module.exports = { 67 | plugins: [ 68 | new Beasties({ 69 | // Outputs: 70 | preload: 'swap', 71 | 72 | // Don't inline critical font-face rules, but preload the font URLs: 73 | preloadFonts: true 74 | }) 75 | ] 76 | } 77 | ``` 78 | 79 | ## Similar Libraries 80 | 81 | There are a number of other libraries that can inline Critical CSS, each with a slightly different approach. Here are a few great options: 82 | 83 | * [Critical](https://github.com/addyosmani/critical) 84 | * [Penthouse](https://github.com/pocketjoso/penthouse) 85 | * [webpack-critical](https://github.com/lukeed/webpack-critical) 86 | * [webpack-plugin-critical](https://github.com/nrwl/webpack-plugin-critical) 87 | * [html-critical-webpack-plugin](https://github.com/anthonygore/html-critical-webpack-plugin) 88 | * [react-snap](https://github.com/stereobooster/react-snap) 89 | 90 | ## License 91 | 92 | [Apache 2.0](LICENSE) 93 | 94 | This is not an official Google product. 95 | 96 | [critical css]: https://www.smashingmagazine.com/2015/08/understanding-critical-css/ 97 | 98 | [html-webpack-plugin]: https://github.com/jantimon/html-webpack-plugin 99 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin, ResolvedConfig } from 'vite' 2 | 3 | import { readFileSync } from 'node:fs' 4 | import { relative } from 'node:path' 5 | 6 | import Beasties from 'beasties' 7 | 8 | export interface ViteBeastiesOptions { 9 | /** 10 | * Options passed directly through to beasties 11 | */ 12 | options?: ConstructorParameters[0] 13 | /** 14 | * Filter for HTML files to process 15 | * @default (path) => path.endsWith('.html') 16 | */ 17 | filter?: (path: string) => boolean 18 | } 19 | 20 | export function beasties(options: ViteBeastiesOptions = {}): Plugin { 21 | let config: ResolvedConfig 22 | let beastiesInstance: Beasties 23 | 24 | const filter = options.filter || (path => path.endsWith('.html')) 25 | 26 | return { 27 | name: 'beasties', 28 | configResolved(resolvedConfig) { 29 | config = resolvedConfig 30 | beastiesInstance = new Beasties({ 31 | pruneSource: true, 32 | ...options.options, 33 | path: config.build.outDir, 34 | publicPath: config.base, 35 | }) 36 | }, 37 | async transformIndexHtml(html, ctx) { 38 | const bundle = ctx.bundle 39 | 40 | if (!bundle || !filter(ctx.filename)) { 41 | return 42 | } 43 | 44 | beastiesInstance.readFile = (filename: string) => { 45 | const path = relative(config.build.outDir, filename).replace(/\\/g, '/') 46 | const chunk = bundle[path] ?? { type: 'asset', source: readFileSync(filename, 'utf-8') } 47 | if (!chunk) { 48 | throw new Error(`Failed to read file: ${filename}`) 49 | } 50 | 51 | return chunk.type === 'asset' ? chunk.source.toString() : chunk.code 52 | } 53 | 54 | const originalPrune = beastiesInstance.pruneSource.bind(beastiesInstance) 55 | 56 | beastiesInstance.pruneSource = function pruneSource(style, before, sheetInverse) { 57 | const isStyleInlined = originalPrune(style, before, sheetInverse) 58 | // @ts-expect-error internal property 59 | const name = style.$$name.replace(/^\//, '') as string 60 | 61 | if (name in bundle && bundle[name]!.type === 'asset') { 62 | const minSize = options.options?.minimumExternalSize 63 | if (minSize && sheetInverse.length < minSize) { 64 | delete bundle[name] 65 | return true 66 | } 67 | else if (!sheetInverse.length) { 68 | delete bundle[name] 69 | return true 70 | } 71 | else { 72 | bundle[name]!.source = sheetInverse 73 | } 74 | } 75 | else { 76 | console.warn(`pruneSource is enabled, but a style (${name}) has no corresponding asset.`) 77 | } 78 | 79 | return isStyleInlined 80 | } 81 | 82 | const originalCheckInline = beastiesInstance.checkInlineThreshold.bind(beastiesInstance) 83 | beastiesInstance.checkInlineThreshold = function checkInlineThreshold(style, before, sheetInverse) { 84 | const isStyleInlined = originalCheckInline(style, before, sheetInverse) 85 | 86 | if (isStyleInlined || !sheetInverse.length) { 87 | // @ts-expect-error internal property 88 | const name = style.$$name.replace(/^\//, '') as string 89 | if (name in bundle && bundle[name]!.type === 'asset') { 90 | delete bundle[name] 91 | } 92 | else { 93 | console.warn( 94 | `${name} was not found in assets. the resource may still be emitted but will be unreferenced.`, 95 | ) 96 | } 97 | } 98 | 99 | return isStyleInlined 100 | } 101 | 102 | try { 103 | return await beastiesInstance.process(html) 104 | } 105 | catch (error) { 106 | console.error(`vite-plugin-beasties error: ${error}`) 107 | } 108 | }, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /packages/vite-plugin-beasties/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import type { RollupOutput } from 'rollup' 2 | import type { UserConfig } from 'vite' 3 | import type { ViteBeastiesOptions } from '../src' 4 | 5 | import { join } from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | 8 | import Beasties from 'beasties' 9 | import { build } from 'vite' 10 | import { describe, expect, it, vi } from 'vitest' 11 | 12 | import { beasties } from '../src' 13 | 14 | const root = fileURLToPath(new URL('fixtures/basic', import.meta.url)) 15 | 16 | describe('vite-plugin-beasties', () => { 17 | async function runViteBuild(options: ViteBeastiesOptions = {}, viteConfig: Partial = {}) { 18 | const { output } = await build({ 19 | root, 20 | logLevel: 'silent', 21 | ...viteConfig, 22 | build: { 23 | ...viteConfig.build, 24 | write: false, 25 | }, 26 | plugins: [ 27 | beasties(options), 28 | ], 29 | }) as RollupOutput 30 | 31 | return { 32 | readOutput(filename: string) { 33 | const file = output.find(f => f.fileName === filename) 34 | if (file?.type === 'asset') { 35 | return file.source.toString() 36 | } 37 | return file?.code 38 | }, 39 | output, 40 | } 41 | } 42 | 43 | it('processes HTML files during the build', async () => { 44 | const { readOutput, output } = await runViteBuild() 45 | const html = readOutput('index.html') 46 | 47 | expect(html).toContain('') 26 | expect(result).toContain('') 27 | expect(result).toContain(``) 28 | expect(result).toMatchSnapshot() 29 | }) 30 | 31 | it('should use "media" preload mode correctly', async () => { 32 | const beasties = new Beasties({ 33 | reduceInlineStyles: false, 34 | path: '/', 35 | preload: 'media', 36 | }) 37 | const assets: Record = { 38 | '/style.css': 'h1 { color: blue; }', 39 | } 40 | beasties.readFile = filename => assets[filename.replace(/^\w:/, '').replace(/\\/g, '/')]! 41 | const result = await beasties.process(` 42 | 43 | 44 | 45 | 46 | 47 |

Hello World!

48 | 49 | 50 | `) 51 | expect(result).toContain('') 52 | expect(result).toContain('') 53 | expect(result).toContain('') 54 | expect(result).toMatchSnapshot() 55 | }) 56 | 57 | it('should use "swap" preload mode correctly', async () => { 58 | const beasties = new Beasties({ 59 | reduceInlineStyles: false, 60 | path: '/', 61 | preload: 'swap', 62 | }) 63 | const assets: Record = { 64 | '/style.css': 'h1 { color: blue; }', 65 | } 66 | beasties.readFile = filename => assets[filename.replace(/^\w:/, '').replace(/\\/g, '/')]! 67 | const result = await beasties.process(` 68 | 69 | 70 | 71 | 72 | 73 |

Hello World!

74 | 75 | 76 | `) 77 | expect(result).toContain('') 78 | expect(result).toContain('') 79 | expect(result).toContain('') 80 | expect(result).toMatchSnapshot() 81 | }) 82 | 83 | it('should handle "false" preload mode correctly', async () => { 84 | const beasties = new Beasties({ 85 | reduceInlineStyles: false, 86 | path: '/', 87 | preload: false, 88 | }) 89 | const assets: Record = { 90 | '/style.css': 'h1 { color: blue; }', 91 | } 92 | beasties.readFile = filename => assets[filename.replace(/^\w:/, '').replace(/\\/g, '/')]! 93 | const result = await beasties.process(` 94 | 95 | 96 | 97 | 98 | 99 |

Hello World!

100 | 101 | 102 | `) 103 | expect(result).toContain('') 104 | expect(result).toContain('') 105 | expect(result).not.toContain('onload=') 106 | expect(result).not.toContain('