Skip to content

Commit 1c1a462

Browse files
authored
feat(vite): add vite plugin (#119)
1 parent 1168424 commit 1c1a462

File tree

13 files changed

+1339
-627
lines changed

13 files changed

+1339
-627
lines changed

packages/beasties/src/index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export default class Beasties {
3636
* Overriding this method requires doing your own URL normalization, so it's generally better to override `readFile()`.
3737
*/
3838
getCssAsset(href: string): Promise<string | undefined> | 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
3943
}
4044

4145
export interface Options {

packages/beasties/src/index.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,7 @@ export default class Beasties {
652652

653653
if (styleInlinedCompletely) {
654654
const percent = (sheetInverse.length / before.length) * 100
655-
afterText = `, reducing non-inlined size ${
656-
percent | 0
657-
}% to ${formatSize(sheetInverse.length)}`
655+
afterText = `, reducing non-inlined size ${percent | 0}% to ${formatSize(sheetInverse.length)}`
658656
}
659657
}
660658

@@ -666,16 +664,7 @@ export default class Beasties {
666664
// output stats
667665
const percent = ((sheet.length / before.length) * 100) | 0
668666
this.logger.info?.(
669-
`\u001B[32mInlined ${
670-
formatSize(sheet.length)
671-
} (${
672-
percent
673-
}% of original ${
674-
formatSize(before.length)
675-
}) of ${
676-
name
677-
}${afterText
678-
}.\u001B[39m`,
667+
`\u001B[32mInlined ${formatSize(sheet.length)} (${percent}% of original ${formatSize(before.length)}) of ${name}${afterText}.\u001B[39m`,
679668
)
680669
}
681670

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
<!-- Badges -->
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
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"name": "vite-plugin-beasties",
3+
"type": "module",
4+
"version": "0.0.0",
5+
"packageManager": "pnpm@10.7.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+
"dev": "vitest dev",
35+
"lint": "eslint . --fix",
36+
"prepare": "simple-git-hooks",
37+
"prepack": "pnpm build",
38+
"prepublishOnly": "pnpm lint && pnpm test",
39+
"release": "bumpp && pnpm publish",
40+
"test": "pnpm test:unit && pnpm test:types",
41+
"test:unit": "vitest",
42+
"test:knip": "knip",
43+
"test:versions": "installed-check -d --no-workspaces",
44+
"test:types": "tsc --noEmit"
45+
},
46+
"dependencies": {
47+
"beasties": "workspace:^"
48+
},
49+
"devDependencies": {
50+
"@antfu/eslint-config": "4.11.0",
51+
"@vitest/coverage-v8": "3.1.1",
52+
"bumpp": "10.1.0",
53+
"changelogithub": "13.13.0",
54+
"eslint": "9.24.0",
55+
"installed-check": "9.3.0",
56+
"knip": "5.47.0",
57+
"lint-staged": "15.5.0",
58+
"rollup": "^4.39.0",
59+
"simple-git-hooks": "2.12.1",
60+
"typescript": "5.8.3",
61+
"unbuild": "3.5.0",
62+
"vite": "^6.2.5",
63+
"vitest": "3.1.1"
64+
},
65+
"resolutions": {
66+
"vite-plugin-beasties": "link:."
67+
},
68+
"simple-git-hooks": {
69+
"pre-commit": "npx lint-staged"
70+
},
71+
"lint-staged": {
72+
"*.{js,ts,mjs,cjs,json,.*rc}": [
73+
"npx eslint --fix"
74+
]
75+
}
76+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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<typeof Beasties>[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+
...options.options,
32+
path: config.build.outDir,
33+
publicPath: config.base,
34+
})
35+
},
36+
async transformIndexHtml(html, ctx) {
37+
const bundle = ctx.bundle
38+
39+
if (!bundle || !filter(ctx.filename)) {
40+
return
41+
}
42+
43+
beastiesInstance.readFile = (filename: string) => {
44+
const path = relative(config.build.outDir, filename).replace(/\\/g, '/')
45+
const chunk = bundle[path] ?? { type: 'asset', source: readFileSync(filename, 'utf-8') }
46+
if (!chunk) {
47+
throw new Error(`Failed to read file: ${filename}`)
48+
}
49+
50+
return chunk.type === 'asset' ? chunk.source.toString() : chunk.code
51+
}
52+
53+
const originalPrune = beastiesInstance.pruneSource.bind(beastiesInstance)
54+
55+
beastiesInstance.pruneSource = function pruneSource(style, before, sheetInverse) {
56+
const isStyleInlined = originalPrune(style, before, sheetInverse)
57+
// @ts-expect-error internal property
58+
const name = style.$$name.replace(/^\//, '') as string
59+
60+
if (name in bundle && bundle[name]!.type === 'asset') {
61+
const minSize = options.options?.minimumExternalSize
62+
if (minSize && sheetInverse.length < minSize) {
63+
delete bundle[name]
64+
return true
65+
}
66+
bundle[name]!.source = sheetInverse
67+
}
68+
else {
69+
console.warn(`pruneSource is enabled, but a style (${name}) has no corresponding asset.`)
70+
}
71+
72+
return isStyleInlined
73+
}
74+
75+
try {
76+
return await beastiesInstance.process(html)
77+
}
78+
catch (error) {
79+
console.error(`vite-plugin-beasties error: ${error}`)
80+
}
81+
},
82+
}
83+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Test Page</title>
5+
<link rel="stylesheet" href="./style.css">
6+
</head>
7+
<body>
8+
<h1>Hello Beasties</h1>
9+
<div class="test-content">This is a test</div>
10+
<script type="module" src="./main.js"></script>
11+
</body>
12+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './style.css'
2+
3+
console.log('Hello from Vite!')
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Other Page</title>
5+
<link rel="stylesheet" href="./style.css">
6+
</head>
7+
<body>
8+
<div class="test-content">Other content</div>
9+
<script type="module" src="./main.js"></script>
10+
</body>
11+
</html>

0 commit comments

Comments
 (0)