html-bundler-webpack-plugin
Version:
Generates complete single-page or multi-page website from source assets. Build-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.
896 lines (755 loc) • 25.6 kB
JavaScript
const path = require('path');
const Compilation = require('webpack/lib/Compilation');
const { isWin, isFunction, pathToPosix } = require('../Common/Helpers');
const { postprocessException, beforeEmitException } = require('./Messages/Exception');
const { optionSplitChunksChunksAllWarning } = require('./Messages/Warnings');
const Preprocessor = require('../Loader/Preprocessor');
const PluginService = require('../Plugin/PluginService');
class Option {
/** @type {HtmlBundlerPlugin.PluginOptions} */
options = {};
/** @type {AssetEntry} */
assetEntry = null;
webpackOptions = {};
productionMode = true;
dynamicEntry = false;
cacheable = false;
context = '';
testEntry = null;
compiler = null;
devServerHot = false;
js = {
test: /\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/,
enabled: true,
filename: undefined, // used output.filename
chunkFilename: undefined, // used output.chunkFilename
outputPath: undefined,
inline: false,
};
css = {
test: /\.(css|scss|sass|less|styl)$/,
enabled: true,
filename: '[name].css',
chunkFilename: undefined,
outputPath: undefined,
inline: false,
hot: false,
};
#entryLibrary = {
name: 'return',
type: 'jsonp', // compiles JS from source into HTML string via Function()
};
/**
* The pipeline of processes.
* The result of one will be passed into next.
*
* @type {Map<string, Array<Function>>}
*/
#process = new Map();
/**
* Initialize plugin options.
*
* @param {Object} pluginContext
* @param {HtmlBundlerPlugin.PluginOptions} options The plugin options.
* @param {string} loaderPath The absolute path of the loader.
* Note: this file cannot be imported due to a circular dependency, therefore, this dependency is injected.
*/
constructor(pluginContext, { options, loaderPath }) {
this.pluginContext = pluginContext;
this.loaderPath = loaderPath;
this.options = options;
this.testEntry = null;
this.options.css = { ...this.css, ...this.options.css };
this.options.js = { ...this.js, ...this.options.js };
const loaderOptions = this.options.loaderOptions;
// remove cached data from previous webpack running
this.#process.clear();
// add reference for the preprocessor option into the plugin options
if (loaderOptions && loaderOptions.preprocessor != null) {
options.preprocessor = loaderOptions.preprocessor;
if (loaderOptions.preprocessorOptions != null) {
options.preprocessorOptions = loaderOptions.preprocessorOptions;
}
}
if (!isFunction(options.postprocess)) this.options.postprocess = null;
if (!isFunction(options.beforeEmit)) this.options.beforeEmit = null;
if (!isFunction(options.afterEmit)) this.options.afterEmit = null;
if (this.options.postprocess != null) {
this.addProcess('postprocess', this.options.postprocess);
}
if (!options.watchFiles) this.options.watchFiles = {};
this.options.hotUpdate = this.options.hotUpdate === true;
}
/**
* Initialize Webpack options.
*
* @param {Object} compiler The Webpack compiler.
*/
initWebpack(compiler) {
const { entry, js, css } = this.options;
const options = compiler.options;
const splitChunks = options?.optimization?.splitChunks?.chunks;
if (splitChunks && splitChunks === 'all') {
delete options.optimization.splitChunks.chunks;
optionSplitChunksChunksAllWarning();
}
this.compiler = compiler;
this.assetEntry = this.pluginContext.assetEntry;
this.#initWebpackOutput(options.output);
this.webpackOptions = options;
this.productionMode = options.mode == null || options.mode === 'production';
this.options.verbose = this.toBool(this.options.verbose, false, false);
this.context = options.context;
this.cacheable = options.cache?.type === 'filesystem';
if (!this.options.sourcePath) this.options.sourcePath = this.context;
if (!this.options.outputPath) this.options.outputPath = options.output.path;
else if (!path.isAbsolute(this.options.outputPath))
this.options.outputPath = path.resolve(options.output.path, this.options.outputPath);
// set the absolute path for dynamic entry
this.dynamicEntry = typeof entry === 'string';
if (this.dynamicEntry && !path.isAbsolute(entry)) {
this.options.entry = path.join(this.context, entry);
}
if (Object.keys(options.entry).length === 1 && Object.keys(Object.entries(options.entry)[0][1]).length === 0) {
// set the empty object to avoid Webpack error, defaults the structure is `{ main: {} }`,
this.webpackOptions.entry = {};
}
css.enabled = this.toBool(css.enabled, true, this.css.enabled);
css.inline = this.toBool(css.inline, false, this.css.inline);
if (!css.outputPath) css.outputPath = options.output.path;
if (!css.chunkFilename) {
css.chunkFilename = css.filename;
}
js.enabled = this.toBool(js.enabled, true, this.js.enabled);
if (js.inline && typeof js.inline === 'object') {
js.inline.enabled = this.toBool(js.inline.enabled, false, true);
if (js.inline.chunk && !Array.isArray(js.inline.chunk)) {
js.inline.chunk = [js.inline.chunk];
}
if (js.inline.source && !Array.isArray(js.inline.source)) {
js.inline.source = [js.inline.source];
}
if (typeof js.inline.attributeFilter !== 'function') {
js.inline.attributeFilter = undefined;
}
} else {
js.inline = {
enabled: this.toBool(js.inline, false, this.js.inline),
chunk: undefined,
source: undefined,
attributeFilter: undefined,
};
}
if (js.filename) {
options.output.filename = js.filename;
} else {
js.filename = options.output.filename;
}
if (js.chunkFilename) {
options.output.chunkFilename = js.chunkFilename;
} else {
js.chunkFilename = options.output.chunkFilename;
}
// resolve js filename by outputPath
if (js.outputPath) {
const { filename, chunkFilename } = js;
js.filename = isFunction(filename)
? (pathData, assetInfo) => this.resolveOutputFilename(filename(pathData, assetInfo), js.outputPath)
: this.resolveOutputFilename(js.filename, js.outputPath);
js.chunkFilename = isFunction(chunkFilename)
? (pathData, assetInfo) => this.resolveOutputFilename(chunkFilename(pathData, assetInfo), js.outputPath)
: this.resolveOutputFilename(js.chunkFilename, js.outputPath);
options.output.filename = js.filename;
options.output.chunkFilename = js.chunkFilename;
} else {
js.outputPath = options.output.path;
}
// normalize integrity options
const integrity =
this.options.integrity == null
? {}
: typeof this.options.integrity === 'object'
? { enabled: 'auto', ...this.options.integrity }
: { enabled: this.options.integrity };
integrity.enabled = this.toBool(integrity.enabled, true, false);
if (this.options.integrity?.hashFunctions != null) {
if (!Array.isArray(this.options.integrity.hashFunctions)) {
integrity.hashFunctions = [this.options.integrity.hashFunctions];
}
} else {
integrity.hashFunctions = ['sha384'];
}
this.options.integrity = integrity;
// https://github.com/terser/html-minifier-terser#options-quick-reference
const defaultMinifyOptions = {
collapseWhitespace: true,
keepClosingSlash: true,
removeComments: true,
removeRedundantAttributes: false, // prevents styling bug when input "type=text" is removed
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyCSS: true,
minifyJS: true,
};
// normalize minify options
if (this.options.minify != null && typeof this.options.minify === 'object') {
this.options.minifyOptions = this.options.minify;
this.options.minify = true;
} else {
if (this.options.minifyOptions == null) {
this.options.minifyOptions = {};
}
this.options.minify = this.toBool(this.options.minify, true, false);
}
this.options.minifyOptions = { ...defaultMinifyOptions, ...this.options.minifyOptions };
this.initEntry(this.loaderPath);
this.enableLibraryType();
if (options.devServer) {
// default value of the `hot` is `true`
// https://webpack.js.org/configuration/dev-server/#devserverhot
const hot = options.devServer?.hot;
this.devServerHot = (hot == null || hot === true || hot === 'only') && !this.isProduction();
}
}
/**
* Init detection of entry files.
*
* @param {string} loaderPath The absolute path to loader file.
*/
initEntry(loaderPath) {
const preprocessorTest = Preprocessor.getTest(this.options.preprocessor);
const loaderTests = new Set();
// detect tests defined in rules
this.webpackOptions.module.rules.forEach((rule) => {
let ruleStr = JSON.stringify(rule);
if (isWin) ruleStr = ruleStr.replaceAll(/\\\\/g, '\\');
if (ruleStr.indexOf(loaderPath) > -1) {
loaderTests.add(rule.test);
}
});
if (!this.options.test) {
// set preprocessor test for default loader if the test plugin option is undefined
// fallback: if the test option is not defined anywhere
this.options.test = preprocessorTest ? preprocessorTest : /\.html$/;
}
// loader tests from rules have the highest priority, over defined in plugin options
this.testEntry = loaderTests.size > 0 ? [...loaderTests] : [this.options.test];
}
initWatchMode() {
const { publicPath } = this.webpackOptions.output;
if (publicPath == null || publicPath === 'auto') {
// Using watch/serve, browsers not support an automatic publicPath in the 'hot update' script injected into inlined JS,
// the output.publicPath must be an empty string.
this.webpackOptions.output.publicPath = '';
}
}
/**
* @param {WebpackOutputOptions} output
*/
#initWebpackOutput(output) {
let { publicPath } = output;
if (!output.path) output.path = path.join(process.cwd(), 'dist');
// define js output filename
if (!output.filename) {
output.filename = '[name].js';
}
if (!output.chunkFilename) {
output.chunkFilename = '[id].js';
}
if (typeof publicPath === 'function') {
publicPath = publicPath.call(null, {});
}
if (publicPath === undefined) {
publicPath = 'auto';
}
this.autoPublicPath = false;
this.isUrlPublicPath = false;
this.rootPublicPath = false;
this.isRelativePublicPath = false;
this.webpackPublicPath = publicPath;
if (publicPath === 'auto') {
this.autoPublicPath = true;
} else if (/^(\/\/|https?:\/\/)/i.test(publicPath)) {
this.isUrlPublicPath = true;
} else if (!publicPath.startsWith('/')) {
this.isRelativePublicPath = true;
} else if (publicPath.startsWith('/')) {
this.rootPublicPath = true;
}
}
/**
* @return {boolean}
*/
isProduction() {
return this.productionMode;
}
/**
* Returns the value of the `devServer.hot` webpack option.
* @return {boolean}
*/
isDevServerHot() {
return this.devServerHot;
}
/**
* Whether HMR for CSS is available.
*
* @return {boolean}
*/
isCssHot() {
return this.options.css.hot && this.devServerHot;
}
/**
* @return {boolean}
*/
isDynamicEntry() {
return this.dynamicEntry;
}
/**
* @return {boolean}
*/
isEnabled() {
return this.options.enabled !== false;
}
/**
* @return {boolean}
*/
isMinify() {
return this.options.minify === true;
}
/**
* @return {boolean}
*/
isVerbose() {
return this.options.verbose === true;
}
/**
* @return {boolean}
*/
isExtractComments() {
return this.options.extractComments === true;
}
/**
* @return {boolean}
*/
isIntegrityEnabled() {
return this.options.integrity.enabled !== false;
}
/**
* Whether the file is a template entry file.
*
* @param {string} resource The resource file, including a query.
* @return {boolean}
*/
isEntry(resource) {
if (resource == null) return false;
const [file] = resource.split('?', 1);
return this.testEntry.find((test) => test.test(file)) != null;
}
/**
* @param {string} resource The resource file, including a query.
* @return {boolean}
*/
isStyle(resource) {
const [file] = resource.split('?', 1);
return this.options.css.enabled && this.options.css.test.test(file);
}
/**
* @param {string} resource The resource file, including a query.
* @return {boolean}
*/
isScript(resource) {
const [file] = resource.split('?', 1);
return this.options.js.enabled && this.options.js.test.test(file);
}
/**
* @return {boolean}
*/
isRealContentHash() {
return this.webpackOptions.optimization.realContentHash === true;
}
/**
* @return {boolean}
*/
isCacheable() {
return this.cacheable;
}
/**
* Whether the JS chunk should be inlined.
*
* @param {string} resource The resource file, including a query.
* @param {string} chunk The chunk filename.
* @return {boolean}
*/
isInlineJs(resource, chunk) {
const [, query] = resource.split('?', 2);
const urlParams = new URLSearchParams(query);
const { inline } = this.options.js;
if (urlParams.has('inline')) {
const value = urlParams.get('inline');
return this.toBool(value, false, inline.enabled);
}
if (!inline.source && !inline.chunk) return inline.enabled;
// regard filter only if the source or chunk is defined
const inlineSource = inline.source && inline.source.some((test) => resource.match(test));
const inlineChunk = inline.chunk && inline.chunk.some((test) => chunk.match(test));
return inline.enabled && (inlineSource || inlineChunk);
}
/**
* Whether the CSS resource should be inlined, regard of the global css.inline option and the file query.
*
* @param {string} resource The resource file, including a query.
* @return {boolean}
*/
isInlineCss(resource) {
const [, query] = resource.split('?', 2);
const urlParams = new URLSearchParams(query);
const value = urlParams.get('inline');
const hasQueryInline = value != null;
const isInlinedByQuery = this.toBool(value, false, false);
return (this.options.css.inline && (isInlinedByQuery || !hasQueryInline)) || isInlinedByQuery;
}
/**
* Whether the preload option is defined.
*
* @return {boolean}
*/
isPreload() {
return this.options.preload != null && this.options.preload !== false;
}
isAutoPublicPath() {
return this.autoPublicPath === true;
}
isRootPublicPath() {
return this.rootPublicPath === true;
}
hasPostprocess() {
return this.#process.has('postprocess');
}
hasBeforeEmit() {
return this.options.beforeEmit != null;
}
hasAfterEmit() {
return this.options.afterEmit != null;
}
/**
* Resolve undefined|true|false|''|'auto' value depend on current Webpack mode dev/prod.
*
* @param {boolean|string|undefined} value The value one of the values: true, false, 'auto'.
* @param {boolean} autoValue Returns the autoValue in prod mode when value is 'auto'.
* @param {boolean|string} defaultValue Returns default value when value is undefined.
* @return {boolean}
*/
toBool(value, autoValue, defaultValue) {
if (value == null) value = defaultValue;
// note: if a parameter is defined without a value or value is empty, then the value is true
if (value === '' || value === 'true') return true;
if (value === 'false') return false;
if (value === true || value === false) return value;
return value === 'auto' && this.productionMode === autoValue;
}
/**
* @return {PluginOptions}
*/
get() {
return this.options;
}
/**
* Return LF when minify is disabled and return empty string when minify is enabled.
*
* @return {string}
*/
getLF() {
return this.isMinify() ? '' : '\n';
}
/**
* @return {Array<RegExp>}
*/
getEntryTest() {
return this.testEntry;
}
/**
* Get entry library type.
* @return {{name: string, type: string}}
*/
getEntryLibrary() {
return this.#entryLibrary;
}
/**
* @return {JsOptions}
*/
getJs() {
return this.options.js;
}
/**
* @return {CssOptions}
*/
getCss() {
return this.options.css;
}
/**
* @return {WatchFiles}
*/
getWatchFiles() {
return this.options.watchFiles;
}
/**
* @return {boolean|Preload}
*/
getPreload() {
return this.options.preload == null ? false : this.options.preload;
}
/**
* @return {"auto" | boolean | IntegrityOptions}
*/
getIntegrity() {
return this.options.integrity;
}
/**
* @param {string} sourceFile
* @return {CssOptions|null}
*/
getStyleOptions(sourceFile) {
if (!this.isStyle(sourceFile)) return null;
return this.options.css;
}
/**
* @param {string} sourceFile
* @return {{filename: FilenameTemplate, outputPath: string, sourcePath: (*|string), verbose: boolean}|null}
*/
getEntryOptions(sourceFile) {
const isEntry = this.isEntry(sourceFile);
const isStyle = this.isStyle(sourceFile);
if (!isEntry && !isStyle) return null;
let { filename, sourcePath, outputPath } = this.options;
let verbose = this.isVerbose();
if (isStyle) {
const cssOptions = this.options.css;
if (cssOptions.filename) filename = cssOptions.filename;
if (cssOptions.sourcePath) sourcePath = cssOptions.sourcePath;
if (cssOptions.outputPath) outputPath = cssOptions.outputPath;
}
return { verbose, filename, sourcePath, outputPath };
}
/**
* @return {string}
*/
getWebpackOutputPath() {
return this.webpackOptions.output.path;
}
/**
* @return {string}
*/
getPublicPath() {
return this.webpackPublicPath;
}
/**
* Get the output path of the asset.
*
* @param {string | null} assetFile The output asset filename relative by output path.
* @return {string}
*/
getAssetOutputPath(assetFile = null) {
const isAutoRelative = assetFile && this.isRelativePublicPath && !this.assetEntry.isEntryFilename(assetFile);
if (this.autoPublicPath || isAutoRelative) {
if (!assetFile) return '';
const fullFilename = path.resolve(this.webpackOptions.output.path, assetFile);
const context = path.dirname(fullFilename);
const publicPath = path.relative(context, this.webpackOptions.output.path) + '/';
return isWin ? pathToPosix(publicPath) : publicPath;
}
return this.webpackPublicPath;
}
/**
* Get the output asset file regards the publicPath.
*
* @param {string} assetFile The output asset filename relative by output path.
* @param {string} issuer The output issuer filename relative by output path.
* @return {string}
*/
getAssetOutputFile(assetFile, issuer) {
const isAutoRelative = issuer && this.isRelativePublicPath && !this.assetEntry.isEntryFilename(issuer);
// if the public path is relative, then a resource using not in the template file must be auto resolved
if (this.autoPublicPath || isAutoRelative) {
if (!issuer) return assetFile;
const issuerFullFilename = path.resolve(this.webpackOptions.output.path, issuer);
const context = path.dirname(issuerFullFilename);
const file = path.posix.join(this.webpackOptions.output.path, assetFile);
const outputFilename = path.relative(context, file);
return isWin ? pathToPosix(outputFilename) : outputFilename;
}
if (this.isUrlPublicPath) {
const url = new URL(assetFile, this.webpackPublicPath);
return url.href;
}
return path.posix.join(this.webpackPublicPath, assetFile);
}
/**
* Get the top level paths containing source files.
*
* Called after compilation, because watch dirs are defined in loader options.
*
* The source files can be in many root paths, e.g.:
* - ./packages/
* - ./src/
* - ./vendor/
*
* @return {Array<string>}
*/
getRootSourcePaths() {
const loaderOption = PluginService.getLoaderOption(this.compiler);
return loaderOption ? loaderOption.getWatchPaths() : [];
}
/**
* Get the entry path.
*
* @return {string|null}
*/
getEntryPath() {
return this.dynamicEntry ? this.options.entry : null;
}
/**
* Get stage to render final HTML in the `processAssets` Webpack hook.
* @return {number|number}
*/
getRenderStage() {
// defaults render stage should be earlier then PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER,
// because at this stage can be used other plugin which requires already rendered HTML,
// e.g. `compression-webpack-plugin` will save rendered and minified HTML into gzip
// NOTE: in specific use cases can be set the `renderStage: Infinity + 1` option
// to be ensures that the rendering process will be run after all optimizations and other plugins
let renderStage = this.options.renderStage || Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER - 1;
// minimal possible stage for the rendering
if (renderStage < Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE) {
renderStage = Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE;
}
return renderStage;
}
/**
* Add default loader if it yet not defined.
*
* @param {{test: RegExp, loader: string}} loader
*/
addLoader(loader) {
const loaderPath = loader.loader;
const existsLoader = this.webpackOptions.module.rules.find((rule) => {
let ruleStr = JSON.stringify(rule);
if (isWin) ruleStr = ruleStr.replaceAll(/\\\\/g, '\\');
return ruleStr.indexOf(loaderPath) > -1;
});
if (existsLoader == null) {
this.webpackOptions.module.rules.unshift(loader);
}
}
/**
* EnableLibraryPlugin need to be used to enable this type of library.
* This usually happens through the "output.enabledLibraryTypes" option.
* If you are using a function as entry which sets "library",
* you need to add all potential library types to "output.enabledLibraryTypes".
*/
enableLibraryType() {
const { type } = this.#entryLibrary;
if (this.webpackOptions.output.enabledLibraryTypes.indexOf(type) < 0) {
this.webpackOptions.output.enabledLibraryTypes.push(type);
}
}
/**
* Add the process to pipeline.
*
* @param {string} name The name of process. Currently supported only `postprocess` pipeline.
* @param {Function: (content: string) => string} fn The process function to modify the generated content.
*/
addProcess(name, fn) {
let processes = this.#process.get(name);
if (!processes) {
processes = [];
this.#process.set(name, processes);
}
processes.push(fn);
}
/**
* @param {string} name The name of a process.
* @param {Array<*>} args The arguments of a process.
* @return {*|null} The result of passed through all processes.
*/
#runProcesses(name, args) {
let processPipe = this.#process.get(name);
let i = 0;
let result;
for (; i < processPipe.length; i++) {
const postprocess = processPipe[i];
result = postprocess.apply(null, args);
// the result will be pipelined in the next function as the first argument
if (result != null) {
args[0] = result;
}
}
return result;
}
/**
* Called after js template is compiled into html string.
*
* @param {string} content A content of processed file.
* @param {TemplateInfo} info The resource info object.
* @param {Compilation} compilation The Webpack compilation object.
* @return {string}
* @throws
*/
postprocess(content, info, compilation) {
try {
return this.#runProcesses('postprocess', [content, info, compilation]);
} catch (error) {
postprocessException(error, info);
}
}
/**
* Called after processing all plugins, before emit.
*
* @param {string} content
* @param {CompileEntry} entry
* @param {Compilation} compilation
* @return {string|null}
* @throws
*/
beforeEmit(content, entry, compilation) {
const { resource } = entry;
if (!this.options.beforeEmit || !this.isEntry(resource)) return null;
try {
return this.options.beforeEmit(content, entry, compilation);
} catch (error) {
return beforeEmitException(error, resource);
}
}
/**
* Called after emitting.
*
* TODO: test not yet documented experimental feature
*
* @param {CompileEntries} entries
* @param {Compilation} compilation
* @return {Promise}
* @async
*/
afterEmit(entries, compilation) {
return new Promise((resolve) => {
resolve(this.options.afterEmit(entries, compilation));
});
}
/**
* @param {string} filename The output filename.
* @param {string | null} outputPath The output path.
* @return {string}
*/
resolveOutputFilename(filename, outputPath) {
if (!outputPath) return filename;
let relativeOutputPath = path.isAbsolute(outputPath)
? path.relative(this.webpackOptions.output.path, outputPath)
: outputPath;
if (relativeOutputPath) {
if (isWin) relativeOutputPath = pathToPosix(relativeOutputPath);
filename = path.posix.join(relativeOutputPath, filename);
}
return filename;
}
}
module.exports = Option;