UNPKG

html-bundler-webpack-plugin

Version:

Generates complete single-page or multi-page website from source assets. Built-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.

1,286 lines (1,089 loc) 36.8 kB
const path = require('path'); const Compilation = require('webpack/lib/Compilation'); const { isWin, isFunction, testRegExpArray, pathToPosix, deepMerge, getQueryParam } = require('../Common/Helpers'); const { postprocessException, beforeEmitException } = require('./Messages/Exception'); const { optionSplitChunksChunksAllWarning } = require('./Messages/Warnings'); const Preprocessor = require('../Loader/Preprocessor'); const PluginService = require('../Plugin/PluginService'); const { bold, yellow, magenta, cyan } = require('ansis'); 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, }; // default options for both the plugin.svg options and a URL query svg = { test: /\.svg/i, enabled: undefined, inline: { embed: false, encoding: 'base64', }, }; /** * Option for resolving inner pages. */ router = { enabled: true, test: null, resolve: null, rewriteIndex: false, }; /** * Experimental options. */ #experiments = { // reserved for experiments in beta versions }; #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; // original plugin options specified by user, // used to detect `undefined` plugin options, // in this case will be used the webpack option // TODO: save all user options, currently occurs DataCloneError //this.originalOptions = deepMerge(options, {}); this.originalOptions = { // currently is used only for svg svg: deepMerge(options.svg || {}, {}), }; // the plugin defined by used merged with defaults options this.options = options; this.testEntry = null; this.entryFilter = options.entryFilter; this.options.css = { ...this.css, ...this.options.css }; this.options.js = { ...this.js, ...this.options.js }; const hasSvgOptions = this.options?.svg != null; this.options.svg = deepMerge(this.svg, this.options?.svg || {}); if (hasSvgOptions) { // auto enable if the svg option is defined this.options.svg.enabled = this.options.svg?.enabled !== false; } const loaderOptions = this.options.loaderOptions; // remove cached data from previous webpack running this.#process.clear(); // add references for loader options to plugin options if (loaderOptions) { // reference to preprocessor if (loaderOptions.preprocessor != null) { options.preprocessor = loaderOptions.preprocessor; if (loaderOptions.preprocessorOptions != null) { options.preprocessorOptions = loaderOptions.preprocessorOptions; } } } // reference to sources if (options.sources == null && loaderOptions?.sources != null) { options.sources = loaderOptions.sources; } 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; if ('experiments' in options) { this.#experiments = { ...this.#experiments, ...options.experiments }; } } /** * Get custom page url resolver. * * @return {Function | undefined} */ getCustomRouteResolver() { return this.router.resolve; } /** * 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 if (js.filename && typeof js.filename === 'string') { // Webpack behaviour: `chunkFilename` should default to `filename` when `filename` is specified as a string options.output.chunkFilename = js.filename; js.chunkFilename = js.filename; } else { // defaults is '[id].js' 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(); // init router must be after initEntry() this.initRouter(); 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]; this.normalizedEntryFilter = this.normalizeAdvancedFiler(this.testEntry, this.entryFilter); } /** * Normalize router options. */ initRouter() { if ('router' in this.options) { let routerOptions = this.options.router; if (typeof routerOptions === 'boolean') { this.router.enabled = routerOptions; } else if (typeof routerOptions === 'object') { // for invalid value, set default value if (routerOptions.rewriteIndex !== false && typeof routerOptions.rewriteIndex !== 'string') { routerOptions.rewriteIndex = this.router.rewriteIndex; } this.router = { ...this.router, ...routerOptions }; if (typeof routerOptions.resolve !== 'function') { this.router.resolve = null; } if (typeof routerOptions.rewriteIndex === 'string') { // TODO: research whether we need `auto` value let rewriteIndex = routerOptions.rewriteIndex === 'auto' ? '.' : routerOptions.rewriteIndex; this.router.rewriteIndex = !this.isAutoPublicPath() && this.getPublicPath() ? '' : rewriteIndex; } } if (this.isRouterEnabled()) { this.addDefaultsRouterOptionsToSources(); } } if (this.router.test instanceof RegExp) { this.router.test = [this.router.test]; } else if (!Array.isArray(this.router.test)) { this.router.test = [...this.testEntry]; } } /** * If router option is defined and is not false, then add default options to sources, * to resolve route in attributes. */ addDefaultsRouterOptionsToSources() { if (!Array.isArray(this.options.sources)) { this.options.sources = []; } const { sources } = this.options; let tagA = sources.find(({ tag }) => tag === 'a'); let hasHref = false; if (tagA) { if (Array.isArray(tagA.attributes)) { hasHref = tagA.attributes.some((attr) => attr === 'href'); } else { tagA.attributes = []; } if (!hasHref) { tagA.attributes.push('href'); } } else { sources.push({ tag: 'a', attributes: ['href'] }); } } 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.urlPublicPath = false; this.rootPublicPath = false; this.isRelativePublicPath = false; this.webpackPublicPath = publicPath; if (publicPath === 'auto') { this.autoPublicPath = true; } else if (/^(\/\/|https?:\/\/)/i.test(publicPath)) { this.urlPublicPath = 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 matches a template file. * * @param {string} resource The resource file, including a query. * @return {boolean} */ isEntry(resource) { return resource && testRegExpArray(resource, this.testEntry); } /** * Whether the file matches a inner route. * Defaults should be a template defined in entry option. * * @param {string} resource The resource file, including a query. * @return {boolean} */ isRoute(resource) { return resource && testRegExpArray(resource, this.router.test); } /** * Whether the router is force disabled. * Defaults, when sources options matches a template, then it will be resolved. * * @return {boolean} */ isRouterEnabled() { return this.router.enabled !== false; } /** * @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 value = getQueryParam(resource, 'inline'); const { inline } = this.options.js; if (value != null) { 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 value = getQueryParam(resource, 'inline'); const hasQueryInline = value != null; const isInlinedByQuery = this.toBool(value, false, false); return (this.options.css.inline && (isInlinedByQuery || !hasQueryInline)) || isInlinedByQuery; } /** * Get SVG option to inline by resource regards URL query options. * * @param {string} resource The path of the resource, including queries. * @param {NormalModule} module The Webpack module of the resource. * @return {{encoding: string|boolean, embed: boolean} | null} */ getInlineSvgOptions(resource, module = null) { const meta = module?.resourceResolveData?._bundlerPluginMeta; // get data from cache if (meta && 'inlineSvg' in meta) { return meta.inlineSvg; } const issuer = module?.resourceResolveData?.context?.issuer; const isIssuerEntry = this.isEntry(issuer); const inline = getQueryParam(resource, 'inline'); const embed = getQueryParam(resource, 'embed') != null; const svgOptionsOriginal = this.originalOptions.svg; /** @type {'base64' | false | undefined } webpackModuleEncoding */ const webpackModuleEncoding = module?.generatorOptions?.dataUrl?.encoding; const isDataUrlFunction = typeof module?.generatorOptions?.dataUrl === 'function'; let result = { isDataUrlFunction, encoding: null, inline: true, embed: this.svg.inline.embed, }; if (issuer && !isIssuerEntry && embed) { result.warning = `The ${bold.greenBright`embed`} URL query for an ${cyan`SVG file`} takes effect only in an ${magenta`HTML context`}.\n${magenta`Context:`} ${issuer}\n${cyan`Resource:`} ${resource}`; } if (svgOptionsOriginal?.inline?.encoding == null && webpackModuleEncoding != null) { result.encoding = webpackModuleEncoding; } if (this.options.svg.enabled === true && this.options.svg.test.test(resource)) { result.embed = this.options.svg.inline.embed === true; if (svgOptionsOriginal?.inline?.encoding != null) { result.encoding = this.options.svg.inline.encoding; } } if (inline != null) { // if exists `?inline` query then use image as data URL result.embed = false; switch (inline) { // `?inline` - force inlines using default encoding case '': break; // `?inline=false` - force disables inline case 'false': result.inline = false; break; // `?inline=embed` - force replaces <img> with <svg> case 'embed': result.embed = true; break; // `?inline=escape` - force inlines svg as escaped data URL case 'escape': result.encoding = false; break; // `?inline=base64|any` - force inlines svg as base64 encoded data URL case 'base64': // through default: result.encoding = 'base64'; break; } } else if (this.options.svg.enabled !== true) { result.inline = false; } if (!isDataUrlFunction && result.encoding == null) { // TODO: test this case result.encoding = 'base64'; } if (isIssuerEntry && embed) { // embedded is always inlined, but in HTML only result.embed = true; result.inline = true; } // save detected option into module if (meta && !('inlineSvg' in meta)) { meta.inlineSvg = result; } return result; } /** * Determines whether an SVG resource can be embedded by replacing the `<img>` tag with an inline `<svg>`. * * The inlining behavior is enabled via plugin options or URL query parameters. * * @param {string} resource * @param {string} issuer * @return {boolean} */ isEmbedSvg(resource, issuer) { /** @type {NormalModule} */ let mockModule = { resourceResolveData: { context: { issuer, }, }, }; let svgOptions = this.getInlineSvgOptions(resource, mockModule); return svgOptions?.embed === true; } /** * 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; } isUrlPublicPath() { return this.urlPublicPath === 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; } /** * Get all plugin options including default values if not specified. * * @return {PluginOptions} */ get() { return this.options; } /** * Get only options specified by user. * * @return {PluginOptions} */ getOriginal() { return this.originalOptions; } /** * 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; } /** * @return {{includes: RegExp[], excludes: RegExp[], fn: Function}} */ getEntryFilter() { return this.normalizedEntryFilter; } /** * 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; } /** * Get router option for resolving a page URL. * * @return {Object|null} */ getRouter() { return this.router; } /** * @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; } /** * @return {string} */ getCrossorigin() { return this.webpackOptions.output.crossOriginLoading || 'anonymous'; } /** * Get the output path of the asset regards the publicPath. * * @param {string | null} assetFile The output asset filename relative by output path. * @return {string} */ getOutputPath(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 filename 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} */ getOutputFilename(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 // TODO: research a really minimal possible stage, // because, e.g., html-minimizer-webpack-plugin uses the PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE stage, // and render must be called before this minimizer. // if (renderStage < Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE) { // renderStage = Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE; // } return renderStage; } /** * Normalize the filter option defined by user and create inner structure of one. * * @param {RegExp} test * @param {PreloadFilter} filter * @return {{includes: RegExp[], excludes: RegExp[], fn: function}} */ normalizeAdvancedFiler(test, filter) { const isFunction = typeof filter === 'function'; let fn = isFunction ? filter : () => true; let includes = []; let excludes = []; if (filter && !isFunction) { if (filter instanceof RegExp) { includes = [filter]; } else { if (Array.isArray(filter)) { includes = filter; } else { if ('includes' in filter && Array.isArray(filter.includes)) { includes = filter.includes; } if ('excludes' in filter && Array.isArray(filter.excludes)) { excludes = filter.excludes; } } } } return { includes, excludes, fn, }; } /** * Apply the advanced filter to a value. * * @param {string | {sourceFiles: Array<string>, outputFile: string}} value * @param {NormalizedAdvancedFilter} filter * @return {boolean} */ applyAdvancedFiler(value, filter) { const { includes, excludes, fn } = filter; const hasIncludes = includes.length > 0; const hasExcludes = excludes.length > 0; const values = []; if (typeof filter === 'string') { values.push(value); } else { if ('sourceFiles' in value) values.push(...value.sourceFiles); if ('outputFile' in value) values.push(value.outputFile); } const isIncluded = !hasIncludes || includes.some((regex) => values.some((value) => regex.test(value))); const isExcluded = hasExcludes && excludes.some((regex) => values.some((value) => regex.test(value))); return isIncluded && !isExcluded && fn(value) !== false; } /** * 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;