Skip to content

Instantly share code, notes, and snippets.

@vralle
Last active April 29, 2024 20:49
Show Gist options
  • Save vralle/d394e32a0f9d30798e60b3670c654fd1 to your computer and use it in GitHub Desktop.
Save vralle/d394e32a0f9d30798e60b3670c654fd1 to your computer and use it in GitHub Desktop.
Webpack plugin using PurgeCSS for each html file separately
/* eslint-disable class-methods-use-this */
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-continue */
/**
* @typedef {import('webpack').WebpackPluginInstance} WebpackPluginInstance
* @typedef {import('webpack').Compiler} Compiler
* @typedef {import('webpack').Compilation} Compilation
* @typedef {import('webpack').Asset} Asset
* @typedef {Record<string, import('webpack').sources.Source>} CompilationAssets
* @typedef {Object} CssOutput
* @property {string|undefined} pathname
* @property {string} source
* @property {string[]|undefined} rejected
* @property {string|undefined} rejectedCss
* @property {string | undefined} sourceMap
* @typedef {Object} EntryResult
* @property {Asset} entry
* @property {Asset[]} input
* @property {CssOutput[]} [output]
* @property {any} [error]
*/
const path = require('node:path');
const { PurgeCSS, defaultOptions } = require('purgecss');
const purgeCssGeneralOptions = require('../../config/purgecssGeneral.config.cjs');
/** @implements {WebpackPluginInstance} */
class PurgeCssMinimizer {
pluginName = 'PurgeCssMinimizer';
/** @todo options */
constructor(options = {}) {
this.options = {
strategy: 'each', // css for each entry or one css for 'all' entries
entries: ['html'], // entries' extensions
};
const minimizerOptions = options?.minimizerOptions ? options.minimizerOptions : {};
this.minimizer = {
options: {
...defaultOptions,
...minimizerOptions,
},
};
}
/** load purgecss.config.js */
async loadConfigFile() {
let configFileOptions;
try {
const configPath = path.resolve(process.cwd(), 'purgecss.config.js');
configFileOptions = await import(configPath);
} catch {
// no config file present
} finally {
this.minimizerOptions = {
...(configFileOptions || {}),
};
}
}
/**
* @param {Asset} entryAsset
* @param {Asset[]} cssAssets
* @return {Promise<EntryResult>}
*/
async purgeForEntry(entryAsset, cssAssets) {
const PurgeCssInstance = new PurgeCSS();
const entrySource = entryAsset.source.source();
/** @type {import('purgecss').RawContent} */
const rawContent = {
raw: Buffer.isBuffer(entrySource) ? entrySource.toString() : entrySource,
extension: path.extname(entryAsset.name).substring(1),
};
const css = cssAssets.map((cssAsset) => {
const cssCode = cssAsset.source.source();
const rawCssCode = Buffer.isBuffer(cssCode) ? cssCode.toString() : cssCode;
/** @type {import('purgecss').RawCSS} */
return {
name: cssAsset.name,
raw: rawCssCode,
};
});
const report = {
entry: entryAsset,
input: cssAssets,
};
try {
const purged = await PurgeCssInstance.purge({
...purgeCssGeneralOptions,
content: [rawContent],
css,
});
const output = purged.map((resultPurge) => ({
pathname: resultPurge.file,
source: resultPurge.css,
rejected: resultPurge.rejected,
rejectedCss: resultPurge.rejectedCss,
sourceMap: resultPurge.sourceMap,
}));
return { ...report, output };
} catch (error) {
return {
...report,
error,
};
}
}
/**
* @param {Compiler} compiler
* @param {Compilation} compilation
* @param {CompilationAssets} assets
*/
async processAssets(compiler, compilation, assets) {
await this.loadConfigFile();
const { RawSource } = compiler.webpack.sources;
const entryPathRegex = /.html(\?.*)?$/i;
const cssPathRegex = /\.css(\?.*)?$/i;
const pathnames = Object.keys(assets);
const entryPathnames = pathnames.filter((pathname) => entryPathRegex.test(pathname));
const cssPathnames = pathnames.filter((pathname) => cssPathRegex.test(pathname));
const purgeResults = [];
for (const entryPathname of entryPathnames) {
const entryAsset = compilation.getAsset(entryPathname);
if (!entryAsset) {
/** @todo logging */
continue;
}
const { name: entryName } = path.parse(entryPathname);
// Strategy.
// We look for CSS files with names that contain the entry name
const entryNameRegex = new RegExp(`${entryName}.+`, 'i');
const entryCssPathnames = cssPathnames.filter((cssPath) => entryNameRegex.test(cssPath));
/**
* Gets compiled CSS data for entry.
* @type {Asset[]}
*/
const entryCssAssets = [];
for (const cssPath of entryCssPathnames) {
const asset = compilation.getAsset(cssPath);
if (!asset) {
/** @todo logging */
continue;
}
entryCssAssets.push(asset);
}
if (entryCssAssets.length === 0) {
return;
}
purgeResults.push(this.purgeForEntry(entryAsset, entryCssAssets));
}
for await (const purgeResult of purgeResults) {
const {
entry, output, error,
} = purgeResult;
if (output) {
for (const cssFileResult of output) {
const {
pathname, source,
} = cssFileResult;
/**
* Can't resolve name?
* @todo save to new file?
*/
if (!pathname) { continue; }
const outputSource = new RawSource(source);
compilation.updateAsset(pathname, outputSource, { purged: true });
}
}
/** @todo Create report */
if (error) {
console.error(`PurgeCSS returned unexpected result for entry '${entry.name}'`);
console.error(error);
}
}
}
/** @param {import('webpack').Compiler} compiler */
apply(compiler) {
const { pluginName } = this;
compiler.hooks.compilation.tap(pluginName, (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: pluginName,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
additionalAssets: true,
},
(assets) => this.processAssets(compiler, compilation, assets),
);
compilation.hooks.statsPrinter.tap(pluginName, (stats) => {
stats.hooks.print
.for('asset.info.purged')
.tap(
pluginName,
(purged, { green, formatFlag }) => (purged
? /** @type {Function} */ (green)(
/** @type {Function} */(formatFlag)('purged'),
)
: ''),
);
});
});
}
}
module.exports = PurgeCssMinimizer;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment