diff --git a/.changeset/yellow-garlics-poke.md b/.changeset/yellow-garlics-poke.md new file mode 100644 index 000000000..f8f74796a --- /dev/null +++ b/.changeset/yellow-garlics-poke.md @@ -0,0 +1,5 @@ +--- +'@web/rollup-plugin-html': minor +--- + +add CSS bundling and minification diff --git a/docs/docs/building/rollup-plugin-html.md b/docs/docs/building/rollup-plugin-html.md index e202486c0..9f5c1d7dd 100644 --- a/docs/docs/building/rollup-plugin-html.md +++ b/docs/docs/building/rollup-plugin-html.md @@ -273,7 +273,7 @@ export default { }; ``` -### Minification +### HTML minification Set the minify option to do default HTML minification. If you need custom options, you can implement your own minifier using the `transformHtml` option. @@ -292,6 +292,31 @@ export default { }; ``` +### CSS bundling and minification + +It's implemented via [Lightning CSS](https://lightningcss.dev/). +It works when `extractAssets: true` and CSS files are extracted. + +CSS bundling is enabled by default. +Set `bundleCss: false` to disable it. + +CSS minification can be enabled by setting `minifyCss: true`. + +```js +import { rollupPluginHTML as html } from '@web/rollup-plugin-html'; + +export default { + input: 'index.html', + output: { dir: 'dist' }, + plugins: [ + // add HTML plugin + html({ + minifyCss: true, + }), + ], +}; +``` + ### Social Media Tags Some social media tags require full absolute URLs (e.g. https://domain.com/guide/). @@ -379,11 +404,11 @@ export interface InputHTMLOptions { export interface RollupPluginHTMLOptions { /** HTML file(s) to use as input. If not set, uses rollup input option. */ input?: string | InputHTMLOptions | (string | InputHTMLOptions)[]; - /** HTML file glob patterns or patterns to ignore */ + /** HTML file glob pattern or patterns to ignore */ exclude?: string | string[]; - /** Whether to minify the output HTML. */ + /** Whether to minify the output HTML. Defaults to false. */ minify?: boolean; - /** Whether to preserve or flatten the directory structure of the HTML file. */ + /** Whether to preserve or flatten the directory structure of the HTML file. Defaults to true. */ flattenOutput?: boolean; /** Directory to resolve absolute paths relative to, and to use as base for non-flatted filename output. */ rootDir?: string; @@ -393,13 +418,17 @@ export interface RollupPluginHTMLOptions { transformAsset?: TransformAssetFunction | TransformAssetFunction[]; /** Transform HTML file before output. */ transformHtml?: TransformHtmlFunction | TransformHtmlFunction[]; - /** Whether to extract and bundle assets referenced in HTML. Defaults to true. */ + /** Whether to extract and bundle assets referenced in HTML and CSS. Defaults to true. */ extractAssets?: boolean | 'legacy-html' | 'legacy-html-and-css'; + /** Whether to bundle extracted CSS assets. Bundling is done via Lightning CSS. Defaults to true. */ + bundleCss?: boolean; + /** Whether to minify extracted CSS assets. Minificaiton is done via Lightning CSS. Defaults to false. */ + minifyCss?: boolean; /** Whether to ignore assets referenced in HTML and CSS with glob patterns. */ externalAssets?: string | string[]; /** Define a full absolute url to your site (e.g. https://domain.com) */ absoluteBaseUrl?: string; - /** Whether to set full absolute urls for ['meta[property=og:image]', 'link[rel=canonical]', 'meta[property=og:url]'] or not. Requires a absoluteBaseUrl to be set. Default to true. */ + /** Whether to set full absolute urls for ['meta[property=og:image]', 'link[rel=canonical]', 'meta[property=og:url]'] or not. Requires a absoluteBaseUrl to be set. Defaults to true. */ absoluteSocialMediaUrls?: boolean; /** Should a service worker registration script be injected. Defaults to false. */ injectServiceWorker?: boolean; diff --git a/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts b/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts index 26a34fb6a..884f669fb 100644 --- a/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts +++ b/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts @@ -15,9 +15,9 @@ export interface RollupPluginHTMLOptions { input?: string | InputHTMLOptions | (string | InputHTMLOptions)[]; /** HTML file glob pattern or patterns to ignore */ exclude?: string | string[]; - /** Whether to minify the output HTML. */ + /** Whether to minify the output HTML. Defaults to false. */ minify?: boolean; - /** Whether to preserve or flatten the directory structure of the HTML file. */ + /** Whether to preserve or flatten the directory structure of the HTML file. Defaults to true. */ flattenOutput?: boolean; /** Directory to resolve absolute paths relative to, and to use as base for non-flatted filename output. */ rootDir?: string; @@ -29,11 +29,15 @@ export interface RollupPluginHTMLOptions { transformHtml?: TransformHtmlFunction | TransformHtmlFunction[]; /** Whether to extract and bundle assets referenced in HTML and CSS. Defaults to true. */ extractAssets?: boolean | 'legacy-html' | 'legacy-html-and-css'; + /** Whether to bundle extracted CSS assets. Bundling is done via Lightning CSS. Defaults to true. */ + bundleCss?: boolean; + /** Whether to minify extracted CSS assets. Minificaiton is done via Lightning CSS. Defaults to false. */ + minifyCss?: boolean; /** Whether to ignore assets referenced in HTML and CSS with glob patterns. */ externalAssets?: string | string[]; /** Define a full absolute url to your site (e.g. https://domain.com) */ absoluteBaseUrl?: string; - /** Whether to set full absolute urls for ['meta[property=og:image]', 'link[rel=canonical]', 'meta[property=og:url]'] or not. Requires a absoluteBaseUrl to be set. Default to true. */ + /** Whether to set full absolute urls for ['meta[property=og:image]', 'link[rel=canonical]', 'meta[property=og:url]'] or not. Requires a absoluteBaseUrl to be set. Defaults to true. */ absoluteSocialMediaUrls?: boolean; /** Should a service worker registration script be injected. Defaults to false. */ injectServiceWorker?: boolean; diff --git a/packages/rollup-plugin-html/src/output/emitAssets.ts b/packages/rollup-plugin-html/src/output/emitAssets.ts index 91c51df57..377db1f25 100644 --- a/packages/rollup-plugin-html/src/output/emitAssets.ts +++ b/packages/rollup-plugin-html/src/output/emitAssets.ts @@ -1,6 +1,6 @@ import { PluginContext } from 'rollup'; import path from 'path'; -import { transform } from 'lightningcss'; +import { bundleAsync, transform } from 'lightningcss'; import fs from 'fs'; import { InputAsset, InputData } from '../input/InputData'; @@ -41,6 +41,8 @@ export async function emitAssets( options: RollupPluginHTMLOptions, ) { const extractAssets = options.extractAssets ?? true; + const bundleCss = options.bundleCss ?? true; + const minifyCss = options.minifyCss ?? false; const extractAssetsLegacyCss = options.extractAssets === 'legacy-html-and-css'; const emittedStaticAssets = new Map(); const emittedHashedAssets = new Map(); @@ -89,11 +91,10 @@ export async function emitAssets( const emittedExternalAssets = new Map(); if (asset.hashed) { if (basename.endsWith('.css') && extractAssets) { - let updatedCssSource = false; - const { code } = await transform({ - filename: basename, + const { code } = await (bundleCss ? bundleAsync : transform)({ + filename: asset.filePath, code: asset.content, - minify: false, + minify: minifyCss, visitor: { Url: url => { // Support foo.svg#bar @@ -150,13 +151,13 @@ export async function emitAssets( } } } - updatedCssSource = true; return url; }, }, }); - if (updatedCssSource) { - source = Buffer.from(code); + const codeBuffer = Buffer.from(code); + if (!asset.content.equals(codeBuffer)) { + source = codeBuffer; } } diff --git a/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts b/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts index 799e39099..564cb96c2 100644 --- a/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts +++ b/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts @@ -1288,7 +1288,7 @@ describe('rollup-plugin-html', () => { 'assets/image-a-BCCvKrTe.svg', 'assets/image-b-C4stzVZW.svg', 'assets/image-c-DPeYetg3.svg', - 'assets/styles-CF2Iy5n1.css', + 'assets/styles-Bh7Pnjui.css', 'assets/x-DDGg8O6h.css', 'assets/y-DJTrnPH3.css', 'assets/webmanifest-BkrOR1WG.json', @@ -1302,7 +1302,7 @@ describe('rollup-plugin-html', () => { - + @@ -1384,7 +1384,7 @@ describe('rollup-plugin-html', () => { 'assets/image-c-C4yLPiIL.png', 'assets/image-a.svg', 'assets/image-b-C4stzVZW.svg', - 'assets/styles-CF2Iy5n1.css', + 'assets/styles-Bh7Pnjui.css', 'assets/x-DDGg8O6h.css', 'assets/y-DJTrnPH3.css', 'assets/webmanifest.json', @@ -1398,7 +1398,7 @@ describe('rollup-plugin-html', () => { - + @@ -2046,7 +2046,7 @@ describe('rollup-plugin-html', () => { expect(Object.keys(assets)).to.have.lengthOf(4); expect(assets).to.have.keys([ - 'assets/styles-CF2Iy5n1.css', + 'assets/styles-Bh7Pnjui.css', 'assets/foo-CxmWeBHm.svg', 'assets/image-b-C4stzVZW.svg', 'x/index.html', @@ -2055,7 +2055,7 @@ describe('rollup-plugin-html', () => { expect(assets['x/index.html']).to.equal(html` - + @@ -3189,4 +3189,121 @@ describe('rollup-plugin-html', () => { } `); }); + + it('can minify extracted CSS', async () => { + const rootDir = createApp({ + 'styles.css': css` + p { + font-weight: bold; + } + `, + }); + + const config = { + plugins: [ + rollupPluginHTML({ + rootDir, + input: { + html: html` + + + + + + + `, + }, + minifyCss: true, + }), + ], + }; + + const build = await rollup(config); + const { chunks, assets, assetsUnformatted } = await generateTestBundle(build, outputConfig); + + expect(Object.keys(chunks)).to.have.lengthOf(1); + expect(Object.keys(assets)).to.have.lengthOf(2); + + expect(assets).to.have.keys(['assets/styles-DPU2l-t7.css', 'index.html']); + + expect(assets['index.html']).to.equal(html` + + + + + + + `); + + expect(assetsUnformatted['assets/styles-DPU2l-t7.css']).to.equal('p{font-weight:700}'); + }); + + it('can bundle extracted CSS', async () => { + const rootDir = createApp({ + 'themes/theme-light.css': css` + body { + background-color: #fff; + } + `, + 'themes/theme-dark.css': css` + @media (prefers-color-scheme: dark) { + body { + background-color: #000; + } + } + `, + 'styles.css': css` + @import './themes/theme-light.css'; + @import './themes/theme-dark.css'; + `, + }); + + const config = { + plugins: [ + rollupPluginHTML({ + rootDir, + input: { + html: html` + + + + + + + `, + }, + bundleCss: true, + }), + ], + }; + + const build = await rollup(config); + const { chunks, assets } = await generateTestBundle(build, outputConfig); + + expect(Object.keys(chunks)).to.have.lengthOf(1); + expect(Object.keys(assets)).to.have.lengthOf(2); + + expect(assets).to.have.keys(['assets/styles-Dc1pq4Qu.css', 'index.html']); + + expect(assets['index.html']).to.equal(html` + + + + + + + `); + + expect(assets['assets/styles-Dc1pq4Qu.css']).to.equal(css` + body { + background-color: #fff; + } + + @media (prefers-color-scheme: dark) { + body { + background-color: #000; + } + } + `); + }); }); diff --git a/packages/rollup-plugin-html/test/utils.ts b/packages/rollup-plugin-html/test/utils.ts index e34ab607b..14c060c5d 100644 --- a/packages/rollup-plugin-html/test/utils.ts +++ b/packages/rollup-plugin-html/test/utils.ts @@ -49,6 +49,7 @@ export async function generateTestBundle(build: RollupBuild, outputConfig: Outpu const { output } = await build.generate(outputConfig); const chunks: Record = {}; const assets: Record = {}; + const assetsUnformatted: Record = {}; for (const file of output) { const filename = file.fileName; @@ -57,17 +58,20 @@ export async function generateTestBundle(build: RollupBuild, outputConfig: Outpu chunks[filename] = formatter ? formatter(file.code) : file.code; } else if (file.type === 'asset') { let code = file.source; + let codeUnformatted = file.source; if (typeof code !== 'string' && filename.endsWith('.css')) { code = Buffer.from(code).toString('utf8'); + codeUnformatted = Buffer.from(codeUnformatted).toString('utf8'); } if (typeof code === 'string' && formatter) { code = formatter(code); } assets[filename] = code; + assetsUnformatted[filename] = codeUnformatted; } } - return { output, chunks, assets }; + return { output, chunks, assets, assetsUnformatted }; } export function createApp(structure: Record) {