diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f9b39fd..85791c1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+### [5.5.4](https://github.com/jantimon/html-webpack-plugin/compare/v5.5.3...v5.5.4) (2023-12-06)
+
+
+### Bug Fixes
+
+* avoid have undefined `type` for script tags ([#1809](https://github.com/jantimon/html-webpack-plugin/issues/1809)) ([9959484](https://github.com/jantimon/html-webpack-plugin/commit/9959484f5337872f5af2a2f738228f5348a93901))
+* reemit assets from loaders ([#1811](https://github.com/jantimon/html-webpack-plugin/issues/1811)) ([a214736](https://github.com/jantimon/html-webpack-plugin/commit/a21473675c81dc4ac2ec8112741cbd52a2756dcc))
+* reemit favicon in serve/watch mode ([#1804](https://github.com/jantimon/html-webpack-plugin/issues/1804)) ([57c5a4e](https://github.com/jantimon/html-webpack-plugin/commit/57c5a4ebcfc4008686ae233f0c94434757c02329))
+
### [5.5.3](https://github.com/jantimon/html-webpack-plugin/compare/v5.5.2...v5.5.3) (2023-06-10)
diff --git a/README.md b/README.md
index 7797f5cd..5efe232a 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,7 @@ The `html-webpack-plugin` provides [hooks](https://github.com/jantimon/html-webp
* [html-webpack-inject-preload](https://github.com/principalstudio/html-webpack-inject-preload) allows to add preload links <link rel='preload'> anywhere you want.
* [inject-body-webpack-plugin](https://github.com/Jaid/inject-body-webpack-plugin) is a simple method of injecting a custom HTML string into the body.
* [html-webpack-plugin-django](https://github.com/TommasoAmici/html-webpack-plugin-django) a Webpack plugin to inject Django static tags.
+ * [js-entry-webpack-plugin](https://github.com/liam61/html-webpack-plugin) creates webpack bundles into your js entry
Usage
@@ -155,7 +156,7 @@ Allowed values are as follows:
|**`meta`**|`{Object}`|`{}`|Allows to inject `meta`-tags. E.g. `meta: {viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no'}`|
|**`base`**|`{Object\|String\|false}`|`false`|Inject a [`base`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base) tag. E.g. `base: "https://example.com/path/page.html`|
|**`minify`**|`{Boolean\|Object}`|`true` if `mode` is `'production'`, otherwise `false`|Controls if and in what ways the output should be minified. See [minification](#minification) below for more details.|
-|**`hash`**|`{Boolean}`|`false`|If `true` then append a unique `webpack` compilation hash to all included scripts and CSS files. This is useful for cache busting|
+|**`hash`**|`{Boolean}`|`false`|If `true` then append a unique `webpack` compilation hash to all included scripts and CSS files (i.e. `main.js?hash=compilation_hash`). This is useful for cache busting|
|**`cache`**|`{Boolean}`|`true`|Emit the file only if it was changed|
|**`showErrors`**|`{Boolean}`|`true`|Errors details will be written into the HTML page|
|**`chunks`**|`{?}`|`?`|Allows you to add only some chunks (e.g only the unit-test chunk)|
diff --git a/index.js b/index.js
index 4452f636..2e98833f 100644
--- a/index.js
+++ b/index.js
@@ -1,11 +1,4 @@
// @ts-check
-// Import types
-/** @typedef {import("./typings").HtmlTagObject} HtmlTagObject */
-/** @typedef {import("./typings").Options} HtmlWebpackOptions */
-/** @typedef {import("./typings").ProcessedOptions} ProcessedHtmlWebpackOptions */
-/** @typedef {import("./typings").TemplateParameter} TemplateParameter */
-/** @typedef {import("webpack/lib/Compiler.js")} WebpackCompiler */
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
'use strict';
const promisify = require('util').promisify;
@@ -17,13 +10,19 @@ const path = require('path');
const { CachedChildCompilation } = require('./lib/cached-child-compiler');
const { createHtmlTagObject, htmlTagObjectToString, HtmlTagArray } = require('./lib/html-tags');
-
const prettyError = require('./lib/errors.js');
const chunkSorter = require('./lib/chunksorter.js');
const getHtmlWebpackPluginHooks = require('./lib/hooks.js').getHtmlWebpackPluginHooks;
-const { assert } = require('console');
-const fsReadFileAsync = promisify(fs.readFile);
+/** @typedef {import("./typings").HtmlTagObject} HtmlTagObject */
+/** @typedef {import("./typings").Options} HtmlWebpackOptions */
+/** @typedef {import("./typings").ProcessedOptions} ProcessedHtmlWebpackOptions */
+/** @typedef {import("./typings").TemplateParameter} TemplateParameter */
+/** @typedef {import("webpack").Compiler} Compiler */
+/** @typedef {ReturnType} Logger */
+/** @typedef {import("webpack/lib/Compilation.js")} Compilation */
+/** @typedef {Array<{ name: string, source: import('webpack').sources.Source, info?: import('webpack').AssetInfo }>} PreviousEmittedAssets */
+/** @typedef {{ publicPath: string, js: Array, css: Array, manifest?: string, favicon?: string }} AssetsInformationByGroups */
class HtmlWebpackPlugin {
/**
@@ -31,60 +30,89 @@ class HtmlWebpackPlugin {
*/
constructor (options) {
/** @type {HtmlWebpackOptions} */
+ // TODO remove me in the next major release
this.userOptions = options || {};
this.version = HtmlWebpackPlugin.version;
+
+ // Default options
+ /** @type {ProcessedHtmlWebpackOptions} */
+ const defaultOptions = {
+ template: 'auto',
+ templateContent: false,
+ templateParameters: templateParametersGenerator,
+ filename: 'index.html',
+ publicPath: this.userOptions.publicPath === undefined ? 'auto' : this.userOptions.publicPath,
+ hash: false,
+ inject: this.userOptions.scriptLoading === 'blocking' ? 'body' : 'head',
+ scriptLoading: 'defer',
+ compile: true,
+ favicon: false,
+ minify: 'auto',
+ cache: true,
+ showErrors: true,
+ chunks: 'all',
+ excludeChunks: [],
+ chunksSortMode: 'auto',
+ meta: {},
+ base: false,
+ title: 'Webpack App',
+ xhtml: false
+ };
+
+ /** @type {ProcessedHtmlWebpackOptions} */
+ this.options = Object.assign(defaultOptions, this.userOptions);
}
+ /**
+ *
+ * @param {Compiler} compiler
+ * @returns {void}
+ */
apply (compiler) {
+ this.logger = compiler.getInfrastructureLogger('HtmlWebpackPlugin');
+
// Wait for configuration preset plugions to apply all configure webpack defaults
compiler.hooks.initialize.tap('HtmlWebpackPlugin', () => {
- const userOptions = this.userOptions;
-
- // Default options
- /** @type {ProcessedHtmlWebpackOptions} */
- const defaultOptions = {
- template: 'auto',
- templateContent: false,
- templateParameters: templateParametersGenerator,
- filename: 'index.html',
- publicPath: userOptions.publicPath === undefined ? 'auto' : userOptions.publicPath,
- hash: false,
- inject: userOptions.scriptLoading === 'blocking' ? 'body' : 'head',
- scriptLoading: 'defer',
- compile: true,
- favicon: false,
- minify: 'auto',
- cache: true,
- showErrors: true,
- chunks: 'all',
- excludeChunks: [],
- chunksSortMode: 'auto',
- meta: {},
- base: false,
- title: 'Webpack App',
- xhtml: false
- };
+ const options = this.options;
- /** @type {ProcessedHtmlWebpackOptions} */
- const options = Object.assign(defaultOptions, userOptions);
- this.options = options;
+ options.template = this.getTemplatePath(this.options.template, compiler.context);
// Assert correct option spelling
- assert(options.scriptLoading === 'defer' || options.scriptLoading === 'blocking' || options.scriptLoading === 'module', 'scriptLoading needs to be set to "defer", "blocking" or "module"');
- assert(options.inject === true || options.inject === false || options.inject === 'head' || options.inject === 'body', 'inject needs to be set to true, false, "head" or "body');
+ if (options.scriptLoading !== 'defer' && options.scriptLoading !== 'blocking' && options.scriptLoading !== 'module') {
+ /** @type {Logger} */
+ (this.logger).error('The "scriptLoading" option need to be set to "defer", "blocking" or "module"');
+ }
+
+ if (options.inject !== true && options.inject !== false && options.inject !== 'head' && options.inject !== 'body') {
+ /** @type {Logger} */
+ (this.logger).error('The `inject` option needs to be set to true, false, "head" or "body');
+ }
+
+ if (
+ this.options.templateParameters !== false &&
+ typeof this.options.templateParameters !== 'function' &&
+ typeof this.options.templateParameters !== 'object'
+ ) {
+ /** @type {Logger} */
+ (this.logger).error('The `templateParameters` has to be either a function or an object or false');
+ }
// Default metaOptions if no template is provided
- if (!userOptions.template && options.templateContent === false && options.meta) {
- const defaultMeta = {
- // TODO remove in the next major release
- // From https://developer.mozilla.org/en-US/docs/Mozilla/Mobile/Viewport_meta_tag
- viewport: 'width=device-width, initial-scale=1'
- };
- options.meta = Object.assign({}, options.meta, defaultMeta, userOptions.meta);
+ if (!this.userOptions.template && options.templateContent === false && options.meta) {
+ options.meta = Object.assign(
+ {},
+ options.meta,
+ {
+ // TODO remove in the next major release
+ // From https://developer.mozilla.org/en-US/docs/Mozilla/Mobile/Viewport_meta_tag
+ viewport: 'width=device-width, initial-scale=1'
+ },
+ this.userOptions.meta
+ );
}
// entryName to fileName conversion function
- const userOptionFilename = userOptions.filename || defaultOptions.filename;
+ const userOptionFilename = this.userOptions.filename || this.options.filename;
const filenameFunction = typeof userOptionFilename === 'function'
? userOptionFilename
// Replace '[name]' with entry name
@@ -94,19 +122,313 @@ class HtmlWebpackPlugin {
const entryNames = Object.keys(compiler.options.entry);
const outputFileNames = new Set((entryNames.length ? entryNames : ['main']).map(filenameFunction));
- /** Option for every entry point */
- const entryOptions = Array.from(outputFileNames).map((filename) => ({
- ...options,
- filename
- }));
-
// Hook all options into the webpack compiler
- entryOptions.forEach((instanceOptions) => {
- hookIntoCompiler(compiler, instanceOptions, this);
+ outputFileNames.forEach((outputFileName) => {
+ // Instance variables to keep caching information for multiple builds
+ const assetJson = { value: undefined };
+ /**
+ * store the previous generated asset to emit them even if the content did not change
+ * to support watch mode for third party plugins like the clean-webpack-plugin or the compression plugin
+ * @type {PreviousEmittedAssets}
+ */
+ const previousEmittedAssets = [];
+
+ // Inject child compiler plugin
+ const childCompilerPlugin = new CachedChildCompilation(compiler);
+
+ if (!this.options.templateContent) {
+ childCompilerPlugin.addEntry(this.options.template);
+ }
+
+ // convert absolute filename into relative so that webpack can
+ // generate it at correct location
+ let filename = outputFileName;
+
+ if (path.resolve(filename) === path.normalize(filename)) {
+ const outputPath = /** @type {string} - Once initialized the path is always a string */(compiler.options.output.path);
+
+ filename = path.relative(outputPath, filename);
+ }
+
+ compiler.hooks.thisCompilation.tap('HtmlWebpackPlugin',
+ /**
+ * Hook into the webpack compilation
+ * @param {Compilation} compilation
+ */
+ (compilation) => {
+ compilation.hooks.processAssets.tapAsync(
+ {
+ name: 'HtmlWebpackPlugin',
+ stage:
+ /**
+ * Generate the html after minification and dev tooling is done
+ */
+ compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE
+ },
+ /**
+ * Hook into the process assets hook
+ * @param {any} _
+ * @param {(err?: Error) => void} callback
+ */
+ (_, callback) => {
+ this.generateHTML(compiler, compilation, filename, childCompilerPlugin, previousEmittedAssets, assetJson, callback);
+ });
+ });
});
});
}
+ /**
+ * Helper to return the absolute template path with a fallback loader
+ *
+ * @private
+ * @param {string} template The path to the template e.g. './index.html'
+ * @param {string} context The webpack base resolution path for relative paths e.g. process.cwd()
+ */
+ getTemplatePath (template, context) {
+ if (template === 'auto') {
+ template = path.resolve(context, 'src/index.ejs');
+ if (!fs.existsSync(template)) {
+ template = path.join(__dirname, 'default_index.ejs');
+ }
+ }
+
+ // If the template doesn't use a loader use the lodash template loader
+ if (template.indexOf('!') === -1) {
+ template = require.resolve('./lib/loader.js') + '!' + path.resolve(context, template);
+ }
+
+ // Resolve template path
+ return template.replace(
+ /([!])([^/\\][^!?]+|[^/\\!?])($|\?[^!?\n]+$)/,
+ (match, prefix, filepath, postfix) => prefix + path.resolve(filepath) + postfix);
+ }
+
+ /**
+ * Return all chunks from the compilation result which match the exclude and include filters
+ *
+ * @private
+ * @param {any} chunks
+ * @param {string[]|'all'} includedChunks
+ * @param {string[]} excludedChunks
+ */
+ filterEntryChunks (chunks, includedChunks, excludedChunks) {
+ return chunks.filter(chunkName => {
+ // Skip if the chunks should be filtered and the given chunk was not added explicity
+ if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
+ return false;
+ }
+
+ // Skip if the chunks should be filtered and the given chunk was excluded explicity
+ if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
+ return false;
+ }
+
+ // Add otherwise
+ return true;
+ });
+ }
+
+ /**
+ * Helper to sort chunks
+ *
+ * @private
+ * @param {string[]} entryNames
+ * @param {string|((entryNameA: string, entryNameB: string) => number)} sortMode
+ * @param {Compilation} compilation
+ */
+ sortEntryChunks (entryNames, sortMode, compilation) {
+ // Custom function
+ if (typeof sortMode === 'function') {
+ return entryNames.sort(sortMode);
+ }
+ // Check if the given sort mode is a valid chunkSorter sort mode
+ if (typeof chunkSorter[sortMode] !== 'undefined') {
+ return chunkSorter[sortMode](entryNames, compilation, this.options);
+ }
+ throw new Error('"' + sortMode + '" is not a valid chunk sort mode');
+ }
+
+ /**
+ * Encode each path component using `encodeURIComponent` as files can contain characters
+ * which needs special encoding in URLs like `+ `.
+ *
+ * Valid filesystem characters which need to be encoded for urls:
+ *
+ * # pound, % percent, & ampersand, { left curly bracket, } right curly bracket,
+ * \ back slash, < left angle bracket, > right angle bracket, * asterisk, ? question mark,
+ * blank spaces, $ dollar sign, ! exclamation point, ' single quotes, " double quotes,
+ * : colon, @ at sign, + plus sign, ` backtick, | pipe, = equal sign
+ *
+ * However the query string must not be encoded:
+ *
+ * fo:demonstration-path/very fancy+name.js?path=/home?value=abc&value=def#zzz
+ * ^ ^ ^ ^ ^ ^ ^ ^^ ^ ^ ^ ^ ^
+ * | | | | | | | || | | | | |
+ * encoded | | encoded | | || | | | | |
+ * ignored ignored ignored ignored ignored
+ *
+ * @private
+ * @param {string} filePath
+ */
+ urlencodePath (filePath) {
+ // People use the filepath in quite unexpected ways.
+ // Try to extract the first querystring of the url:
+ //
+ // some+path/demo.html?value=abc?def
+ //
+ const queryStringStart = filePath.indexOf('?');
+ const urlPath = queryStringStart === -1 ? filePath : filePath.substr(0, queryStringStart);
+ const queryString = filePath.substr(urlPath.length);
+ // Encode all parts except '/' which are not part of the querystring:
+ const encodedUrlPath = urlPath.split('/').map(encodeURIComponent).join('/');
+ return encodedUrlPath + queryString;
+ }
+
+ /**
+ * Appends a cache busting hash to the query string of the url
+ * E.g. http://localhost:8080/ -> http://localhost:8080/?50c9096ba6183fd728eeb065a26ec175
+ *
+ * @private
+ * @param {string} url
+ * @param {string} hash
+ */
+ appendHash (url, hash) {
+ if (!url) {
+ return url;
+ }
+ return url + (url.indexOf('?') === -1 ? '?' : '&') + hash;
+ }
+
+ /**
+ * Generate the relative or absolute base url to reference images, css, and javascript files
+ * from within the html file - the publicPath
+ *
+ * @private
+ * @param {Compilation} compilation
+ * @param {string} filename
+ * @param {string | 'auto'} customPublicPath
+ * @returns {string}
+ */
+ getPublicPath (compilation, filename, customPublicPath) {
+ /**
+ * @type {string} the configured public path to the asset root
+ * if a path publicPath is set in the current webpack config use it otherwise
+ * fallback to a relative path
+ */
+ const webpackPublicPath = compilation.getAssetPath(compilation.outputOptions.publicPath, { hash: compilation.hash });
+ // Webpack 5 introduced "auto" as default value
+ const isPublicPathDefined = webpackPublicPath !== 'auto';
+
+ let publicPath =
+ // If the html-webpack-plugin options contain a custom public path uset it
+ customPublicPath !== 'auto'
+ ? customPublicPath
+ : (isPublicPathDefined
+ // If a hard coded public path exists use it
+ ? webpackPublicPath
+ // If no public path was set get a relative url path
+ : path.relative(path.resolve(compilation.options.output.path, path.dirname(filename)), compilation.options.output.path)
+ .split(path.sep).join('/')
+ );
+
+ if (publicPath.length && publicPath.substr(-1, 1) !== '/') {
+ publicPath += '/';
+ }
+
+ return publicPath;
+ }
+
+ /**
+ * The getAssetsForHTML extracts the asset information of a webpack compilation for all given entry names.
+ *
+ * @private
+ * @param {Compilation} compilation
+ * @param {string} outputName
+ * @param {string[]} entryNames
+ * @returns {AssetsInformationByGroups}
+ */
+ getAssetsInformationByGroups (compilation, outputName, entryNames) {
+ /** The public path used inside the html file */
+ const publicPath = this.getPublicPath(compilation, outputName, this.options.publicPath);
+ /**
+ * @type {AssetsInformationByGroups}
+ */
+ const assets = {
+ // The public path
+ publicPath,
+ // Will contain all js and mjs files
+ js: [],
+ // Will contain all css files
+ css: [],
+ // Will contain the html5 appcache manifest files if it exists
+ manifest: Object.keys(compilation.assets).find(assetFile => path.extname(assetFile) === '.appcache'),
+ // Favicon
+ favicon: undefined
+ };
+
+ // Append a hash for cache busting
+ if (this.options.hash && assets.manifest) {
+ assets.manifest = this.appendHash(assets.manifest, /** @type {string} */ (compilation.hash));
+ }
+
+ // Extract paths to .js, .mjs and .css files from the current compilation
+ const entryPointPublicPathMap = {};
+ const extensionRegexp = /\.(css|js|mjs)(\?|$)/;
+
+ for (let i = 0; i < entryNames.length; i++) {
+ const entryName = entryNames[i];
+ /** entryPointUnfilteredFiles - also includes hot module update files */
+ const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName).getFiles();
+ const entryPointFiles = entryPointUnfilteredFiles.filter((chunkFile) => {
+ const asset = compilation.getAsset(chunkFile);
+
+ if (!asset) {
+ return true;
+ }
+
+ // Prevent hot-module files from being included:
+ const assetMetaInformation = asset.info || {};
+
+ return !(assetMetaInformation.hotModuleReplacement || assetMetaInformation.development);
+ });
+ // Prepend the publicPath and append the hash depending on the
+ // webpack.output.publicPath and hashOptions
+ // E.g. bundle.js -> /bundle.js?hash
+ const entryPointPublicPaths = entryPointFiles
+ .map(chunkFile => {
+ const entryPointPublicPath = publicPath + this.urlencodePath(chunkFile);
+ return this.options.hash
+ ? this.appendHash(entryPointPublicPath, compilation.hash)
+ : entryPointPublicPath;
+ });
+
+ entryPointPublicPaths.forEach((entryPointPublicPath) => {
+ const extMatch = extensionRegexp.exec(entryPointPublicPath);
+
+ // Skip if the public path is not a .css, .mjs or .js file
+ if (!extMatch) {
+ return;
+ }
+
+ // Skip if this file is already known
+ // (e.g. because of common chunk optimizations)
+ if (entryPointPublicPathMap[entryPointPublicPath]) {
+ return;
+ }
+
+ entryPointPublicPathMap[entryPointPublicPath] = true;
+
+ // ext will contain .js or .css, because .mjs recognizes as .js
+ const ext = extMatch[1] === 'mjs' ? 'js' : extMatch[1];
+
+ assets[ext].push(entryPointPublicPath);
+ });
+ }
+
+ return assets;
+ }
+
/**
* Once webpack is done with compiling the template into a NodeJS code this function
* evaluates it to generate the html result
@@ -115,6 +437,7 @@ class HtmlWebpackPlugin {
* Please change that in a further refactoring
*
* @param {string} source
+ * @param {string} publicPath
* @param {string} templateFilename
* @returns {Promise string | Promise)>}
*/
@@ -122,11 +445,13 @@ class HtmlWebpackPlugin {
if (!source) {
return Promise.reject(new Error('The child compilation didn\'t provide a result'));
}
+
// The LibraryTemplatePlugin stores the template result in a local variable.
// By adding it to the end the value gets extracted during evaluation
if (source.indexOf('HTML_WEBPACK_PLUGIN_RESULT') >= 0) {
source += ';\nHTML_WEBPACK_PLUGIN_RESULT';
}
+
const templateWithoutLoaders = templateFilename.replace(/^.+!/, '').replace(/\?.+$/, '');
const vmContext = vm.createContext({
...global,
@@ -184,314 +509,102 @@ class HtmlWebpackPlugin {
WritableStreamDefaultController: global.WritableStreamDefaultController,
WritableStreamDefaultWriter: global.WritableStreamDefaultWriter
});
+
const vmScript = new vm.Script(source, { filename: templateWithoutLoaders });
+
// Evaluate code and cast to string
let newSource;
+
try {
newSource = vmScript.runInContext(vmContext);
} catch (e) {
return Promise.reject(e);
}
+
if (typeof newSource === 'object' && newSource.__esModule && newSource.default) {
newSource = newSource.default;
}
+
return typeof newSource === 'string' || typeof newSource === 'function'
? Promise.resolve(newSource)
: Promise.reject(new Error('The loader "' + templateWithoutLoaders + '" didn\'t return html.'));
}
-}
-/**
- * connect the html-webpack-plugin to the webpack compiler lifecycle hooks
- *
- * @param {import('webpack').Compiler} compiler
- * @param {ProcessedHtmlWebpackOptions} options
- * @param {HtmlWebpackPlugin} plugin
- */
-function hookIntoCompiler (compiler, options, plugin) {
- const webpack = compiler.webpack;
- // Instance variables to keep caching information
- // for multiple builds
- let assetJson;
/**
- * store the previous generated asset to emit them even if the content did not change
- * to support watch mode for third party plugins like the clean-webpack-plugin or the compression plugin
- * @type {Array<{html: string, name: string}>}
+ * Add toString methods for easier rendering inside the template
+ *
+ * @private
+ * @param {Array} assetTagGroup
+ * @returns {Array}
*/
- let previousEmittedAssets = [];
-
- options.template = getFullTemplatePath(options.template, compiler.context);
-
- // Inject child compiler plugin
- const childCompilerPlugin = new CachedChildCompilation(compiler);
- if (!options.templateContent) {
- childCompilerPlugin.addEntry(options.template);
- }
-
- // convert absolute filename into relative so that webpack can
- // generate it at correct location
- const filename = options.filename;
- if (path.resolve(filename) === path.normalize(filename)) {
- const outputPath = /** @type {string} - Once initialized the path is always a string */(compiler.options.output.path);
- options.filename = path.relative(outputPath, filename);
- }
-
- // Check if webpack is running in production mode
- // @see https://github.com/webpack/webpack/blob/3366421f1784c449f415cda5930a8e445086f688/lib/WebpackOptionsDefaulter.js#L12-L14
- const isProductionLikeMode = compiler.options.mode === 'production' || !compiler.options.mode;
-
- const minify = options.minify;
- if (minify === true || (minify === 'auto' && isProductionLikeMode)) {
- /** @type { import('html-minifier-terser').Options } */
- options.minify = {
- // https://www.npmjs.com/package/html-minifier-terser#options-quick-reference
- collapseWhitespace: true,
- keepClosingSlash: true,
- removeComments: true,
- removeRedundantAttributes: true,
- removeScriptTypeAttributes: true,
- removeStyleLinkTypeAttributes: true,
- useShortDoctype: true
- };
+ prepareAssetTagGroupForRendering (assetTagGroup) {
+ const xhtml = this.options.xhtml;
+ return HtmlTagArray.from(assetTagGroup.map((assetTag) => {
+ const copiedAssetTag = Object.assign({}, assetTag);
+ copiedAssetTag.toString = function () {
+ return htmlTagObjectToString(this, xhtml);
+ };
+ return copiedAssetTag;
+ }));
}
- compiler.hooks.thisCompilation.tap('HtmlWebpackPlugin',
- /**
- * Hook into the webpack compilation
- * @param {WebpackCompilation} compilation
- */
- (compilation) => {
- compilation.hooks.processAssets.tapAsync(
- {
- name: 'HtmlWebpackPlugin',
- stage:
- /**
- * Generate the html after minification and dev tooling is done
- */
- webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE
- },
- /**
- * Hook into the process assets hook
- * @param {WebpackCompilation} compilationAssets
- * @param {(err?: Error) => void} callback
- */
- (compilationAssets, callback) => {
- // Get all entry point names for this html file
- const entryNames = Array.from(compilation.entrypoints.keys());
- const filteredEntryNames = filterChunks(entryNames, options.chunks, options.excludeChunks);
- const sortedEntryNames = sortEntryChunks(filteredEntryNames, options.chunksSortMode, compilation);
-
- const templateResult = options.templateContent
- ? { mainCompilationHash: compilation.hash }
- : childCompilerPlugin.getCompilationEntryResult(options.template);
-
- if ('error' in templateResult) {
- compilation.errors.push(prettyError(templateResult.error, compiler.context).toString());
- }
-
- // If the child compilation was not executed during a previous main compile run
- // it is a cached result
- const isCompilationCached = templateResult.mainCompilationHash !== compilation.hash;
-
- /** The public path used inside the html file */
- const htmlPublicPath = getPublicPath(compilation, options.filename, options.publicPath);
-
- /** Generated file paths from the entry point names */
- const assets = htmlWebpackPluginAssets(compilation, sortedEntryNames, htmlPublicPath);
-
- // If the template and the assets did not change we don't have to emit the html
- const newAssetJson = JSON.stringify(getAssetFiles(assets));
- if (isCompilationCached && options.cache && assetJson === newAssetJson) {
- previousEmittedAssets.forEach(({ name, html }) => {
- compilation.emitAsset(name, new webpack.sources.RawSource(html, false));
- });
- return callback();
- } else {
- previousEmittedAssets = [];
- assetJson = newAssetJson;
- }
-
- // The html-webpack plugin uses a object representation for the html-tags which will be injected
- // to allow altering them more easily
- // Just before they are converted a third-party-plugin author might change the order and content
- const assetsPromise = getFaviconPublicPath(options.favicon, compilation, assets.publicPath)
- .then((faviconPath) => {
- assets.favicon = faviconPath;
- return getHtmlWebpackPluginHooks(compilation).beforeAssetTagGeneration.promise({
- assets: assets,
- outputName: options.filename,
- plugin: plugin
- });
- });
-
- // Turn the js and css paths into grouped HtmlTagObjects
- const assetTagGroupsPromise = assetsPromise
- // And allow third-party-plugin authors to reorder and change the assetTags before they are grouped
- .then(({ assets }) => getHtmlWebpackPluginHooks(compilation).alterAssetTags.promise({
- assetTags: {
- scripts: generatedScriptTags(assets.js),
- styles: generateStyleTags(assets.css),
- meta: [
- ...generateBaseTag(options.base),
- ...generatedMetaTags(options.meta),
- ...generateFaviconTags(assets.favicon)
- ]
- },
- outputName: options.filename,
- publicPath: htmlPublicPath,
- plugin: plugin
- }))
- .then(({ assetTags }) => {
- // Inject scripts to body unless it set explicitly to head
- const scriptTarget = options.inject === 'head' ||
- (options.inject !== 'body' && options.scriptLoading !== 'blocking') ? 'head' : 'body';
- // Group assets to `head` and `body` tag arrays
- const assetGroups = generateAssetGroups(assetTags, scriptTarget);
- // Allow third-party-plugin authors to reorder and change the assetTags once they are grouped
- return getHtmlWebpackPluginHooks(compilation).alterAssetTagGroups.promise({
- headTags: assetGroups.headTags,
- bodyTags: assetGroups.bodyTags,
- outputName: options.filename,
- publicPath: htmlPublicPath,
- plugin: plugin
- });
- });
-
- // Turn the compiled template into a nodejs function or into a nodejs string
- const templateEvaluationPromise = Promise.resolve()
- .then(() => {
- if ('error' in templateResult) {
- return options.showErrors ? prettyError(templateResult.error, compiler.context).toHtml() : 'ERROR';
- }
- // Allow to use a custom function / string instead
- if (options.templateContent !== false) {
- return options.templateContent;
- }
- // Once everything is compiled evaluate the html factory
- // and replace it with its content
- return ('compiledEntry' in templateResult)
- ? plugin.evaluateCompilationResult(templateResult.compiledEntry.content, htmlPublicPath, options.template)
- : Promise.reject(new Error('Child compilation contained no compiledEntry'));
- });
- const templateExectutionPromise = Promise.all([assetsPromise, assetTagGroupsPromise, templateEvaluationPromise])
- // Execute the template
- .then(([assetsHookResult, assetTags, compilationResult]) => typeof compilationResult !== 'function'
- ? compilationResult
- : executeTemplate(compilationResult, assetsHookResult.assets, { headTags: assetTags.headTags, bodyTags: assetTags.bodyTags }, compilation));
-
- const injectedHtmlPromise = Promise.all([assetTagGroupsPromise, templateExectutionPromise])
- // Allow plugins to change the html before assets are injected
- .then(([assetTags, html]) => {
- const pluginArgs = { html, headTags: assetTags.headTags, bodyTags: assetTags.bodyTags, plugin: plugin, outputName: options.filename };
- return getHtmlWebpackPluginHooks(compilation).afterTemplateExecution.promise(pluginArgs);
- })
- .then(({ html, headTags, bodyTags }) => {
- return postProcessHtml(html, assets, { headTags, bodyTags });
- });
-
- const emitHtmlPromise = injectedHtmlPromise
- // Allow plugins to change the html after assets are injected
- .then((html) => {
- const pluginArgs = { html, plugin: plugin, outputName: options.filename };
- return getHtmlWebpackPluginHooks(compilation).beforeEmit.promise(pluginArgs)
- .then(result => result.html);
- })
- .catch(err => {
- // In case anything went wrong the promise is resolved
- // with the error message and an error is logged
- compilation.errors.push(prettyError(err, compiler.context).toString());
- return options.showErrors ? prettyError(err, compiler.context).toHtml() : 'ERROR';
- })
- .then(html => {
- const filename = options.filename.replace(/\[templatehash([^\]]*)\]/g, require('util').deprecate(
- (match, options) => `[contenthash${options}]`,
- '[templatehash] is now [contenthash]')
- );
- const replacedFilename = replacePlaceholdersInFilename(filename, html, compilation);
- // Add the evaluated html code to the webpack assets
- compilation.emitAsset(replacedFilename.path, new webpack.sources.RawSource(html, false), replacedFilename.info);
- previousEmittedAssets.push({ name: replacedFilename.path, html });
- return replacedFilename.path;
- })
- .then((finalOutputName) => getHtmlWebpackPluginHooks(compilation).afterEmit.promise({
- outputName: finalOutputName,
- plugin: plugin
- }).catch(err => {
- console.error(err);
- return null;
- }).then(() => null));
-
- // Once all files are added to the webpack compilation
- // let the webpack compiler continue
- emitHtmlPromise.then(() => {
- callback();
- });
- });
- });
-
/**
* Generate the template parameters for the template function
- * @param {WebpackCompilation} compilation
- * @param {{
- publicPath: string,
- js: Array,
- css: Array,
- manifest?: string,
- favicon?: string
- }} assets
+ *
+ * @private
+ * @param {Compilation} compilation
+ * @param {AssetsInformationByGroups} assetsInformationByGroups
* @param {{
headTags: HtmlTagObject[],
bodyTags: HtmlTagObject[]
}} assetTags
* @returns {Promise<{[key: any]: any}>}
*/
- function getTemplateParameters (compilation, assets, assetTags) {
- const templateParameters = options.templateParameters;
+ getTemplateParameters (compilation, assetsInformationByGroups, assetTags) {
+ const templateParameters = this.options.templateParameters;
+
if (templateParameters === false) {
return Promise.resolve({});
}
+
if (typeof templateParameters !== 'function' && typeof templateParameters !== 'object') {
throw new Error('templateParameters has to be either a function or an object');
}
+
const templateParameterFunction = typeof templateParameters === 'function'
// A custom function can overwrite the entire template parameter preparation
? templateParameters
// If the template parameters is an object merge it with the default values
- : (compilation, assets, assetTags, options) => Object.assign({},
- templateParametersGenerator(compilation, assets, assetTags, options),
+ : (compilation, assetsInformationByGroups, assetTags, options) => Object.assign({},
+ templateParametersGenerator(compilation, assetsInformationByGroups, assetTags, options),
templateParameters
);
const preparedAssetTags = {
- headTags: prepareAssetTagGroupForRendering(assetTags.headTags),
- bodyTags: prepareAssetTagGroupForRendering(assetTags.bodyTags)
+ headTags: this.prepareAssetTagGroupForRendering(assetTags.headTags),
+ bodyTags: this.prepareAssetTagGroupForRendering(assetTags.bodyTags)
};
return Promise
.resolve()
- .then(() => templateParameterFunction(compilation, assets, preparedAssetTags, options));
+ .then(() => templateParameterFunction(compilation, assetsInformationByGroups, preparedAssetTags, this.options));
}
/**
* This function renders the actual html by executing the template function
*
+ * @private
* @param {(templateParameters) => string | Promise} templateFunction
- * @param {{
- publicPath: string,
- js: Array,
- css: Array,
- manifest?: string,
- favicon?: string
- }} assets
+ * @param {AssetsInformationByGroups} assetsInformationByGroups
* @param {{
headTags: HtmlTagObject[],
bodyTags: HtmlTagObject[]
}} assetTags
- * @param {WebpackCompilation} compilation
- *
+ * @param {Compilation} compilation
* @returns Promise
*/
- function executeTemplate (templateFunction, assets, assetTags, compilation) {
+ executeTemplate (templateFunction, assetsInformationByGroups, assetTags, compilation) {
// Template processing
- const templateParamsPromise = getTemplateParameters(compilation, assets, assetTags);
+ const templateParamsPromise = this.getTemplateParameters(compilation, assetsInformationByGroups, assetTags);
+
return templateParamsPromise.then((templateParams) => {
try {
// If html is a promise return the promise
@@ -507,299 +620,201 @@ function hookIntoCompiler (compiler, options, plugin) {
/**
* Html Post processing
*
- * @param {any} html
- * The input html
- * @param {any} assets
- * @param {{
- headTags: HtmlTagObject[],
- bodyTags: HtmlTagObject[]
- }} assetTags
- * The asset tags to inject
- *
+ * @private
+ * @param {Compiler} compiler The compiler instance
+ * @param {any} originalHtml The input html
+ * @param {AssetsInformationByGroups} assetsInformationByGroups
+ * @param {{headTags: HtmlTagObject[], bodyTags: HtmlTagObject[]}} assetTags The asset tags to inject
* @returns {Promise}
*/
- function postProcessHtml (html, assets, assetTags) {
+ postProcessHtml (compiler, originalHtml, assetsInformationByGroups, assetTags) {
+ let html = originalHtml;
+
if (typeof html !== 'string') {
return Promise.reject(new Error('Expected html to be a string but got ' + JSON.stringify(html)));
}
- const htmlAfterInjection = options.inject
- ? injectAssetsIntoHtml(html, assets, assetTags)
- : html;
- const htmlAfterMinification = minifyHtml(htmlAfterInjection);
- return Promise.resolve(htmlAfterMinification);
- }
-
- /*
- * Pushes the content of the given filename to the compilation assets
- * @param {string} filename
- * @param {WebpackCompilation} compilation
- *
- * @returns {string} file basename
- */
- function addFileToAssets (filename, compilation) {
- filename = path.resolve(compilation.compiler.context, filename);
- return fsReadFileAsync(filename)
- .then(source => new webpack.sources.RawSource(source, false))
- .catch(() => Promise.reject(new Error('HtmlWebpackPlugin: could not load file ' + filename)))
- .then(rawSource => {
- const basename = path.basename(filename);
- compilation.fileDependencies.add(filename);
- compilation.emitAsset(basename, rawSource);
- return basename;
- });
- }
- /**
- * Replace [contenthash] in filename
- *
- * @see https://survivejs.com/webpack/optimizing/adding-hashes-to-filenames/
- *
- * @param {string} filename
- * @param {string|Buffer} fileContent
- * @param {WebpackCompilation} compilation
- * @returns {{ path: string, info: {} }}
- */
- function replacePlaceholdersInFilename (filename, fileContent, compilation) {
- if (/\[\\*([\w:]+)\\*\]/i.test(filename) === false) {
- return { path: filename, info: {} };
- }
- const hash = compiler.webpack.util.createHash(compilation.outputOptions.hashFunction);
- hash.update(fileContent);
- if (compilation.outputOptions.hashSalt) {
- hash.update(compilation.outputOptions.hashSalt);
- }
- const contentHash = hash.digest(compilation.outputOptions.hashDigest).slice(0, compilation.outputOptions.hashDigestLength);
- return compilation.getPathWithInfo(
- filename,
- {
- contentHash,
- chunk: {
- hash: contentHash,
- contentHash
- }
- }
- );
- }
-
- /**
- * Helper to sort chunks
- * @param {string[]} entryNames
- * @param {string|((entryNameA: string, entryNameB: string) => number)} sortMode
- * @param {WebpackCompilation} compilation
- */
- function sortEntryChunks (entryNames, sortMode, compilation) {
- // Custom function
- if (typeof sortMode === 'function') {
- return entryNames.sort(sortMode);
- }
- // Check if the given sort mode is a valid chunkSorter sort mode
- if (typeof chunkSorter[sortMode] !== 'undefined') {
- return chunkSorter[sortMode](entryNames, compilation, options);
- }
- throw new Error('"' + sortMode + '" is not a valid chunk sort mode');
- }
-
- /**
- * Return all chunks from the compilation result which match the exclude and include filters
- * @param {any} chunks
- * @param {string[]|'all'} includedChunks
- * @param {string[]} excludedChunks
- */
- function filterChunks (chunks, includedChunks, excludedChunks) {
- return chunks.filter(chunkName => {
- // Skip if the chunks should be filtered and the given chunk was not added explicity
- if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
- return false;
- }
- // Skip if the chunks should be filtered and the given chunk was excluded explicity
- if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
- return false;
- }
- // Add otherwise
- return true;
- });
- }
-
- /**
- * Generate the relative or absolute base url to reference images, css, and javascript files
- * from within the html file - the publicPath
- *
- * @param {WebpackCompilation} compilation
- * @param {string} childCompilationOutputName
- * @param {string | 'auto'} customPublicPath
- * @returns {string}
- */
- function getPublicPath (compilation, childCompilationOutputName, customPublicPath) {
- const compilationHash = compilation.hash;
-
- /**
- * @type {string} the configured public path to the asset root
- * if a path publicPath is set in the current webpack config use it otherwise
- * fallback to a relative path
- */
- const webpackPublicPath = compilation.getAssetPath(compilation.outputOptions.publicPath, { hash: compilationHash });
-
- // Webpack 5 introduced "auto" as default value
- const isPublicPathDefined = webpackPublicPath !== 'auto';
-
- let publicPath =
- // If the html-webpack-plugin options contain a custom public path uset it
- customPublicPath !== 'auto'
- ? customPublicPath
- : (isPublicPathDefined
- // If a hard coded public path exists use it
- ? webpackPublicPath
- // If no public path was set get a relative url path
- : path.relative(path.resolve(compilation.options.output.path, path.dirname(childCompilationOutputName)), compilation.options.output.path)
- .split(path.sep).join('/')
- );
-
- if (publicPath.length && publicPath.substr(-1, 1) !== '/') {
- publicPath += '/';
- }
-
- return publicPath;
- }
-
- /**
- * The htmlWebpackPluginAssets extracts the asset information of a webpack compilation
- * for all given entry names
- * @param {WebpackCompilation} compilation
- * @param {string[]} entryNames
- * @param {string | 'auto'} publicPath
- * @returns {{
- publicPath: string,
- js: Array,
- css: Array,
- manifest?: string,
- favicon?: string
- }}
- */
- function htmlWebpackPluginAssets (compilation, entryNames, publicPath) {
- const compilationHash = compilation.hash;
- /**
- * @type {{
- publicPath: string,
- js: Array,
- css: Array,
- manifest?: string,
- favicon?: string
- }}
- */
- const assets = {
- // The public path
- publicPath,
- // Will contain all js and mjs files
- js: [],
- // Will contain all css files
- css: [],
- // Will contain the html5 appcache manifest files if it exists
- manifest: Object.keys(compilation.assets).find(assetFile => path.extname(assetFile) === '.appcache'),
- // Favicon
- favicon: undefined
- };
-
- // Append a hash for cache busting
- if (options.hash && assets.manifest) {
- assets.manifest = appendHash(assets.manifest, compilationHash);
- }
-
- // Extract paths to .js, .mjs and .css files from the current compilation
- const entryPointPublicPathMap = {};
- const extensionRegexp = /\.(css|js|mjs)(\?|$)/;
- for (let i = 0; i < entryNames.length; i++) {
- const entryName = entryNames[i];
- /** entryPointUnfilteredFiles - also includes hot module update files */
- const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName).getFiles();
-
- const entryPointFiles = entryPointUnfilteredFiles.filter((chunkFile) => {
- const asset = compilation.getAsset(chunkFile);
- if (!asset) {
- return true;
- }
- // Prevent hot-module files from being included:
- const assetMetaInformation = asset.info || {};
- return !(assetMetaInformation.hotModuleReplacement || assetMetaInformation.development);
- });
-
- // Prepend the publicPath and append the hash depending on the
- // webpack.output.publicPath and hashOptions
- // E.g. bundle.js -> /bundle.js?hash
- const entryPointPublicPaths = entryPointFiles
- .map(chunkFile => {
- const entryPointPublicPath = publicPath + urlencodePath(chunkFile);
- return options.hash
- ? appendHash(entryPointPublicPath, compilationHash)
- : entryPointPublicPath;
- });
+ if (this.options.inject) {
+ const htmlRegExp = /(]*>)/i;
+ const headRegExp = /(<\/head\s*>)/i;
+ const bodyRegExp = /(<\/body\s*>)/i;
+ const metaViewportRegExp = /]+name=["']viewport["'][^>]*>/i;
+ const body = assetTags.bodyTags.map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml));
+ const head = assetTags.headTags.filter((item) => {
+ if (item.tagName === 'meta' && item.attributes && item.attributes.name === 'viewport' && metaViewportRegExp.test(html)) {
+ return false;
+ }
- entryPointPublicPaths.forEach((entryPointPublicPath) => {
- const extMatch = extensionRegexp.exec(entryPointPublicPath);
- // Skip if the public path is not a .css, .mjs or .js file
- if (!extMatch) {
- return;
+ return true;
+ }).map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml));
+
+ if (body.length) {
+ if (bodyRegExp.test(html)) {
+ // Append assets to body element
+ html = html.replace(bodyRegExp, match => body.join('') + match);
+ } else {
+ // Append scripts to the end of the file if no element exists:
+ html += body.join('');
}
- // Skip if this file is already known
- // (e.g. because of common chunk optimizations)
- if (entryPointPublicPathMap[entryPointPublicPath]) {
- return;
+ }
+
+ if (head.length) {
+ // Create a head tag if none exists
+ if (!headRegExp.test(html)) {
+ if (!htmlRegExp.test(html)) {
+ html = '' + html;
+ } else {
+ html = html.replace(htmlRegExp, match => match + '');
+ }
}
- entryPointPublicPathMap[entryPointPublicPath] = true;
- // ext will contain .js or .css, because .mjs recognizes as .js
- const ext = extMatch[1] === 'mjs' ? 'js' : extMatch[1];
- assets[ext].push(entryPointPublicPath);
- });
+
+ // Append assets to head element
+ html = html.replace(headRegExp, match => head.join('') + match);
+ }
+
+ // Inject manifest into the opening html tag
+ if (assetsInformationByGroups.manifest) {
+ html = html.replace(/(]*)(>)/i, (match, start, end) => {
+ // Append the manifest only if no manifest was specified
+ if (/\smanifest\s*=/.test(match)) {
+ return match;
+ }
+ return start + ' manifest="' + assetsInformationByGroups.manifest + '"' + end;
+ });
+ }
}
- return assets;
+
+ // TODO avoid this logic and use https://github.com/webpack-contrib/html-minimizer-webpack-plugin under the hood in the next major version
+ // Check if webpack is running in production mode
+ // @see https://github.com/webpack/webpack/blob/3366421f1784c449f415cda5930a8e445086f688/lib/WebpackOptionsDefaulter.js#L12-L14
+ const isProductionLikeMode = compiler.options.mode === 'production' || !compiler.options.mode;
+ const needMinify = this.options.minify === true || typeof this.options.minify === 'object' || (this.options.minify === 'auto' && isProductionLikeMode);
+
+ if (!needMinify) {
+ return Promise.resolve(html);
+ }
+
+ const minifyOptions = typeof this.options.minify === 'object'
+ ? this.options.minify
+ : {
+ // https://www.npmjs.com/package/html-minifier-terser#options-quick-reference
+ collapseWhitespace: true,
+ keepClosingSlash: true,
+ removeComments: true,
+ removeRedundantAttributes: true,
+ removeScriptTypeAttributes: true,
+ removeStyleLinkTypeAttributes: true,
+ useShortDoctype: true
+ };
+
+ try {
+ html = require('html-minifier-terser').minify(html, minifyOptions);
+ } catch (e) {
+ const isParseError = String(e.message).indexOf('Parse Error') === 0;
+
+ if (isParseError) {
+ e.message = 'html-webpack-plugin could not minify the generated output.\n' +
+ 'In production mode the html minifcation is enabled by default.\n' +
+ 'If you are not generating a valid html output please disable it manually.\n' +
+ 'You can do so by adding the following setting to your HtmlWebpackPlugin config:\n|\n|' +
+ ' minify: false\n|\n' +
+ 'See https://github.com/jantimon/html-webpack-plugin#options for details.\n\n' +
+ 'For parser dedicated bugs please create an issue here:\n' +
+ 'https://danielruf.github.io/html-minifier-terser/' +
+ '\n' + e.message;
+ }
+
+ return Promise.reject(e);
+ }
+
+ return Promise.resolve(html);
+ }
+
+ /**
+ * Helper to return a sorted unique array of all asset files out of the asset object
+ * @private
+ */
+ getAssetFiles (assets) {
+ const files = _.uniq(Object.keys(assets).filter(assetType => assetType !== 'chunks' && assets[assetType]).reduce((files, assetType) => files.concat(assets[assetType]), []));
+ files.sort();
+ return files;
}
/**
- * Converts a favicon file from disk to a webpack resource
- * and returns the url to the resource
+ * Converts a favicon file from disk to a webpack resource and returns the url to the resource
*
- * @param {string|false} faviconFilePath
- * @param {WebpackCompilation} compilation
+ * @private
+ * @param {Compiler} compiler
+ * @param {string|false} favicon
+ * @param {Compilation} compilation
* @param {string} publicPath
+ * @param {PreviousEmittedAssets} previousEmittedAssets
* @returns {Promise}
*/
- function getFaviconPublicPath (faviconFilePath, compilation, publicPath) {
- if (!faviconFilePath) {
+ generateFavicon (compiler, favicon, compilation, publicPath, previousEmittedAssets) {
+ if (!favicon) {
return Promise.resolve(undefined);
}
- return addFileToAssets(faviconFilePath, compilation)
- .then((faviconName) => {
- const faviconPath = publicPath + faviconName;
- if (options.hash) {
- return appendHash(faviconPath, compilation.hash);
+
+ const filename = path.resolve(compilation.compiler.context, favicon);
+
+ return promisify(compilation.inputFileSystem.readFile)(filename)
+ .then((buf) => {
+ const source = new compiler.webpack.sources.RawSource(/** @type {string | Buffer} */ (buf), false);
+ const name = path.basename(filename);
+
+ compilation.fileDependencies.add(filename);
+ compilation.emitAsset(name, source);
+ previousEmittedAssets.push({ name, source });
+
+ const faviconPath = publicPath + name;
+
+ if (this.options.hash) {
+ return this.appendHash(faviconPath, /** @type {string} */ (compilation.hash));
}
+
return faviconPath;
- });
+ })
+ .catch(() => Promise.reject(new Error('HtmlWebpackPlugin: could not load file ' + filename)));
}
/**
* Generate all tags script for the given file paths
+ *
+ * @private
* @param {Array} jsAssets
* @returns {Array}
*/
- function generatedScriptTags (jsAssets) {
- return jsAssets.map(scriptAsset => ({
- tagName: 'script',
- voidTag: false,
- meta: { plugin: 'html-webpack-plugin' },
- attributes: {
- defer: options.scriptLoading === 'defer',
- type: options.scriptLoading === 'module' ? 'module' : undefined,
- src: scriptAsset
+ generatedScriptTags (jsAssets) {
+ // @ts-ignore
+ return jsAssets.map(src => {
+ const attributes = {};
+
+ if (this.options.scriptLoading === 'defer') {
+ attributes.defer = true;
+ } else if (this.options.scriptLoading === 'module') {
+ attributes.type = 'module';
}
- }));
+
+ attributes.src = src;
+
+ return {
+ tagName: 'script',
+ voidTag: false,
+ meta: { plugin: 'html-webpack-plugin' },
+ attributes
+ };
+ });
}
/**
* Generate all style tags for the given file paths
+ *
+ * @private
* @param {Array} cssAssets
* @returns {Array}
*/
- function generateStyleTags (cssAssets) {
+ generateStyleTags (cssAssets) {
return cssAssets.map(styleAsset => ({
tagName: 'link',
voidTag: true,
@@ -813,41 +828,34 @@ function hookIntoCompiler (compiler, options, plugin) {
/**
* Generate an optional base tag
- * @param { false
- | string
- | {[attributeName: string]: string} // attributes e.g. { href:"http://example.com/page.html" target:"_blank" }
- } baseOption
- * @returns {Array}
- */
- function generateBaseTag (baseOption) {
- if (baseOption === false) {
- return [];
- } else {
- return [{
- tagName: 'base',
- voidTag: true,
- meta: { plugin: 'html-webpack-plugin' },
- attributes: (typeof baseOption === 'string') ? {
- href: baseOption
- } : baseOption
- }];
- }
+ *
+ * @param {string | {[attributeName: string]: string}} base
+ * @returns {Array}
+ */
+ generateBaseTag (base) {
+ return [{
+ tagName: 'base',
+ voidTag: true,
+ meta: { plugin: 'html-webpack-plugin' },
+ // attributes e.g. { href:"http://example.com/page.html" target:"_blank" }
+ attributes: typeof base === 'string' ? {
+ href: base
+ } : base
+ }];
}
/**
* Generate all meta tags for the given meta configuration
- * @param {false | {
- [name: string]:
- false // disabled
- | string // name content pair e.g. {viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no'}`
- | {[attributeName: string]: string|boolean} // custom properties e.g. { name:"viewport" content:"width=500, initial-scale=1" }
- }} metaOptions
- * @returns {Array}
- */
- function generatedMetaTags (metaOptions) {
+ *
+ * @private
+ * @param {false | {[name: string]: false | string | {[attributeName: string]: string|boolean}}} metaOptions
+ * @returns {Array}
+ */
+ generatedMetaTags (metaOptions) {
if (metaOptions === false) {
return [];
}
+
// Make tags self-closing in case of xhtml
// Turn { "viewport" : "width=500, initial-scale=1" } into
// [{ name:"viewport" content:"width=500, initial-scale=1" }]
@@ -860,8 +868,9 @@ function hookIntoCompiler (compiler, options, plugin) {
} : metaTagContent;
})
.filter((attribute) => attribute !== false);
- // Turn [{ name:"viewport" content:"width=500, initial-scale=1" }] into
- // the html-webpack-plugin tag structure
+
+ // Turn [{ name:"viewport" content:"width=500, initial-scale=1" }] into
+ // the html-webpack-plugin tag structure
return metaTagAttributeObjects.map((metaTagAttributes) => {
if (metaTagAttributes === false) {
throw new Error('Invalid meta tag');
@@ -877,39 +886,38 @@ function hookIntoCompiler (compiler, options, plugin) {
/**
* Generate a favicon tag for the given file path
- * @param {string| undefined} faviconPath
+ *
+ * @private
+ * @param {string} favicon
* @returns {Array}
*/
- function generateFaviconTags (faviconPath) {
- if (!faviconPath) {
- return [];
- }
+ generateFaviconTag (favicon) {
return [{
tagName: 'link',
voidTag: true,
meta: { plugin: 'html-webpack-plugin' },
attributes: {
rel: 'icon',
- href: faviconPath
+ href: favicon
}
}];
}
/**
- * Group assets to head and bottom tags
+ * Group assets to head and body tags
*
* @param {{
scripts: Array;
styles: Array;
meta: Array;
}} assetTags
- * @param {"body" | "head"} scriptTarget
- * @returns {{
+ * @param {"body" | "head"} scriptTarget
+ * @returns {{
headTags: Array;
bodyTags: Array;
}}
- */
- function generateAssetGroups (assetTags, scriptTarget) {
+ */
+ groupAssetsByElements (assetTags, scriptTarget) {
/** @type {{ headTags: Array; bodyTags: Array; }} */
const result = {
headTags: [
@@ -918,214 +926,242 @@ function hookIntoCompiler (compiler, options, plugin) {
],
bodyTags: []
};
+
// Add script tags to head or body depending on
// the htmlPluginOptions
if (scriptTarget === 'body') {
result.bodyTags.push(...assetTags.scripts);
} else {
// If script loading is blocking add the scripts to the end of the head
- // If script loading is non-blocking add the scripts infront of the css files
- const insertPosition = options.scriptLoading === 'blocking' ? result.headTags.length : assetTags.meta.length;
+ // If script loading is non-blocking add the scripts in front of the css files
+ const insertPosition = this.options.scriptLoading === 'blocking' ? result.headTags.length : assetTags.meta.length;
+
result.headTags.splice(insertPosition, 0, ...assetTags.scripts);
}
- return result;
- }
- /**
- * Add toString methods for easier rendering
- * inside the template
- *
- * @param {Array} assetTagGroup
- * @returns {Array}
- */
- function prepareAssetTagGroupForRendering (assetTagGroup) {
- const xhtml = options.xhtml;
- return HtmlTagArray.from(assetTagGroup.map((assetTag) => {
- const copiedAssetTag = Object.assign({}, assetTag);
- copiedAssetTag.toString = function () {
- return htmlTagObjectToString(this, xhtml);
- };
- return copiedAssetTag;
- }));
+ return result;
}
/**
- * Injects the assets into the given html string
+ * Replace [contenthash] in filename
*
- * @param {string} html
- * The input html
- * @param {any} assets
- * @param {{
- headTags: HtmlTagObject[],
- bodyTags: HtmlTagObject[]
- }} assetTags
- * The asset tags to inject
+ * @see https://survivejs.com/webpack/optimizing/adding-hashes-to-filenames/
*
- * @returns {string}
+ * @private
+ * @param {Compiler} compiler
+ * @param {string} filename
+ * @param {string|Buffer} fileContent
+ * @param {Compilation} compilation
+ * @returns {{ path: string, info: {} }}
*/
- function injectAssetsIntoHtml (html, assets, assetTags) {
- const htmlRegExp = /(]*>)/i;
- const headRegExp = /(<\/head\s*>)/i;
- const bodyRegExp = /(<\/body\s*>)/i;
- const metaViewportRegExp = /]+name=["']viewport["'][^>]*>/i;
- const body = assetTags.bodyTags.map((assetTagObject) => htmlTagObjectToString(assetTagObject, options.xhtml));
- const head = assetTags.headTags.filter((item) => {
- if (item.tagName === 'meta' && item.attributes && item.attributes.name === 'viewport' && metaViewportRegExp.test(html)) {
- return false;
- }
-
- return true;
- }).map((assetTagObject) => htmlTagObjectToString(assetTagObject, options.xhtml));
-
- if (body.length) {
- if (bodyRegExp.test(html)) {
- // Append assets to body element
- html = html.replace(bodyRegExp, match => body.join('') + match);
- } else {
- // Append scripts to the end of the file if no element exists:
- html += body.join('');
- }
+ replacePlaceholdersInFilename (compiler, filename, fileContent, compilation) {
+ if (/\[\\*([\w:]+)\\*\]/i.test(filename) === false) {
+ return { path: filename, info: {} };
}
- if (head.length) {
- // Create a head tag if none exists
- if (!headRegExp.test(html)) {
- if (!htmlRegExp.test(html)) {
- html = '' + html;
- } else {
- html = html.replace(htmlRegExp, match => match + '');
- }
- }
-
- // Append assets to head element
- html = html.replace(headRegExp, match => head.join('') + match);
- }
+ const hash = compiler.webpack.util.createHash(compilation.outputOptions.hashFunction);
- // Inject manifest into the opening html tag
- if (assets.manifest) {
- html = html.replace(/(]*)(>)/i, (match, start, end) => {
- // Append the manifest only if no manifest was specified
- if (/\smanifest\s*=/.test(match)) {
- return match;
- }
- return start + ' manifest="' + assets.manifest + '"' + end;
- });
- }
- return html;
- }
+ hash.update(fileContent);
- /**
- * Appends a cache busting hash to the query string of the url
- * E.g. http://localhost:8080/ -> http://localhost:8080/?50c9096ba6183fd728eeb065a26ec175
- * @param {string} url
- * @param {string} hash
- */
- function appendHash (url, hash) {
- if (!url) {
- return url;
+ if (compilation.outputOptions.hashSalt) {
+ hash.update(compilation.outputOptions.hashSalt);
}
- return url + (url.indexOf('?') === -1 ? '?' : '&') + hash;
- }
- /**
- * Encode each path component using `encodeURIComponent` as files can contain characters
- * which needs special encoding in URLs like `+ `.
- *
- * Valid filesystem characters which need to be encoded for urls:
- *
- * # pound, % percent, & ampersand, { left curly bracket, } right curly bracket,
- * \ back slash, < left angle bracket, > right angle bracket, * asterisk, ? question mark,
- * blank spaces, $ dollar sign, ! exclamation point, ' single quotes, " double quotes,
- * : colon, @ at sign, + plus sign, ` backtick, | pipe, = equal sign
- *
- * However the query string must not be encoded:
- *
- * fo:demonstration-path/very fancy+name.js?path=/home?value=abc&value=def#zzz
- * ^ ^ ^ ^ ^ ^ ^ ^^ ^ ^ ^ ^ ^
- * | | | | | | | || | | | | |
- * encoded | | encoded | | || | | | | |
- * ignored ignored ignored ignored ignored
- *
- * @param {string} filePath
- */
- function urlencodePath (filePath) {
- // People use the filepath in quite unexpected ways.
- // Try to extract the first querystring of the url:
- //
- // some+path/demo.html?value=abc?def
- //
- const queryStringStart = filePath.indexOf('?');
- const urlPath = queryStringStart === -1 ? filePath : filePath.substr(0, queryStringStart);
- const queryString = filePath.substr(urlPath.length);
- // Encode all parts except '/' which are not part of the querystring:
- const encodedUrlPath = urlPath.split('/').map(encodeURIComponent).join('/');
- return encodedUrlPath + queryString;
- }
+ const contentHash = hash.digest(compilation.outputOptions.hashDigest).slice(0, compilation.outputOptions.hashDigestLength);
- /**
- * Helper to return the absolute template path with a fallback loader
- * @param {string} template
- * The path to the template e.g. './index.html'
- * @param {string} context
- * The webpack base resolution path for relative paths e.g. process.cwd()
- */
- function getFullTemplatePath (template, context) {
- if (template === 'auto') {
- template = path.resolve(context, 'src/index.ejs');
- if (!fs.existsSync(template)) {
- template = path.join(__dirname, 'default_index.ejs');
+ return compilation.getPathWithInfo(
+ filename,
+ {
+ contentHash,
+ chunk: {
+ hash: contentHash,
+ contentHash
+ }
}
- }
- // If the template doesn't use a loader use the lodash template loader
- if (template.indexOf('!') === -1) {
- template = require.resolve('./lib/loader.js') + '!' + path.resolve(context, template);
- }
- // Resolve template path
- return template.replace(
- /([!])([^/\\][^!?]+|[^/\\!?])($|\?[^!?\n]+$)/,
- (match, prefix, filepath, postfix) => prefix + path.resolve(filepath) + postfix);
+ );
}
/**
- * Minify the given string using html-minifier-terser
- *
- * As this is a breaking change to html-webpack-plugin 3.x
- * provide an extended error message to explain how to get back
- * to the old behaviour
+ * Function to generate HTML file.
*
- * @param {string} html
+ * @private
+ * @param {Compiler} compiler
+ * @param {Compilation} compilation
+ * @param {string} outputName
+ * @param {CachedChildCompilation} childCompilerPlugin
+ * @param {PreviousEmittedAssets} previousEmittedAssets
+ * @param {{ value: string | undefined }} assetJson
+ * @param {(err?: Error) => void} callback
*/
- function minifyHtml (html) {
- if (typeof options.minify !== 'object') {
- return html;
+ generateHTML (
+ compiler,
+ compilation,
+ outputName,
+ childCompilerPlugin,
+ previousEmittedAssets,
+ assetJson,
+ callback
+ ) {
+ // Get all entry point names for this html file
+ const entryNames = Array.from(compilation.entrypoints.keys());
+ const filteredEntryNames = this.filterEntryChunks(entryNames, this.options.chunks, this.options.excludeChunks);
+ const sortedEntryNames = this.sortEntryChunks(filteredEntryNames, this.options.chunksSortMode, compilation);
+ const templateResult = this.options.templateContent
+ ? { mainCompilationHash: compilation.hash }
+ : childCompilerPlugin.getCompilationEntryResult(this.options.template);
+
+ if ('error' in templateResult) {
+ compilation.errors.push(prettyError(templateResult.error, compiler.context).toString());
}
- try {
- return require('html-minifier-terser').minify(html, options.minify);
- } catch (e) {
- const isParseError = String(e.message).indexOf('Parse Error') === 0;
- if (isParseError) {
- e.message = 'html-webpack-plugin could not minify the generated output.\n' +
- 'In production mode the html minifcation is enabled by default.\n' +
- 'If you are not generating a valid html output please disable it manually.\n' +
- 'You can do so by adding the following setting to your HtmlWebpackPlugin config:\n|\n|' +
- ' minify: false\n|\n' +
- 'See https://github.com/jantimon/html-webpack-plugin#options for details.\n\n' +
- 'For parser dedicated bugs please create an issue here:\n' +
- 'https://danielruf.github.io/html-minifier-terser/' +
- '\n' + e.message;
- }
- throw e;
+
+ // If the child compilation was not executed during a previous main compile run
+ // it is a cached result
+ const isCompilationCached = templateResult.mainCompilationHash !== compilation.hash;
+ /** Generated file paths from the entry point names */
+ const assetsInformationByGroups = this.getAssetsInformationByGroups(compilation, outputName, sortedEntryNames);
+ // If the template and the assets did not change we don't have to emit the html
+ const newAssetJson = JSON.stringify(this.getAssetFiles(assetsInformationByGroups));
+
+ if (isCompilationCached && this.options.cache && assetJson.value === newAssetJson) {
+ previousEmittedAssets.forEach(({ name, source, info }) => {
+ compilation.emitAsset(name, source, info);
+ });
+ return callback();
+ } else {
+ previousEmittedAssets.length = 0;
+ assetJson.value = newAssetJson;
}
- }
- /**
- * Helper to return a sorted unique array of all asset files out of the
- * asset object
- */
- function getAssetFiles (assets) {
- const files = _.uniq(Object.keys(assets).filter(assetType => assetType !== 'chunks' && assets[assetType]).reduce((files, assetType) => files.concat(assets[assetType]), []));
- files.sort();
- return files;
+ // The html-webpack plugin uses a object representation for the html-tags which will be injected
+ // to allow altering them more easily
+ // Just before they are converted a third-party-plugin author might change the order and content
+ const assetsPromise = this.generateFavicon(compiler, this.options.favicon, compilation, assetsInformationByGroups.publicPath, previousEmittedAssets)
+ .then((faviconPath) => {
+ assetsInformationByGroups.favicon = faviconPath;
+ return getHtmlWebpackPluginHooks(compilation).beforeAssetTagGeneration.promise({
+ assets: assetsInformationByGroups,
+ outputName,
+ plugin: this
+ });
+ });
+
+ // Turn the js and css paths into grouped HtmlTagObjects
+ const assetTagGroupsPromise = assetsPromise
+ // And allow third-party-plugin authors to reorder and change the assetTags before they are grouped
+ .then(({ assets }) => getHtmlWebpackPluginHooks(compilation).alterAssetTags.promise({
+ assetTags: {
+ scripts: this.generatedScriptTags(assets.js),
+ styles: this.generateStyleTags(assets.css),
+ meta: [
+ ...(this.options.base !== false ? this.generateBaseTag(this.options.base) : []),
+ ...this.generatedMetaTags(this.options.meta),
+ ...(assets.favicon ? this.generateFaviconTag(assets.favicon) : [])
+ ]
+ },
+ outputName,
+ publicPath: assetsInformationByGroups.publicPath,
+ plugin: this
+ }))
+ .then(({ assetTags }) => {
+ // Inject scripts to body unless it set explicitly to head
+ const scriptTarget = this.options.inject === 'head' ||
+ (this.options.inject !== 'body' && this.options.scriptLoading !== 'blocking') ? 'head' : 'body';
+ // Group assets to `head` and `body` tag arrays
+ const assetGroups = this.groupAssetsByElements(assetTags, scriptTarget);
+ // Allow third-party-plugin authors to reorder and change the assetTags once they are grouped
+ return getHtmlWebpackPluginHooks(compilation).alterAssetTagGroups.promise({
+ headTags: assetGroups.headTags,
+ bodyTags: assetGroups.bodyTags,
+ outputName,
+ publicPath: assetsInformationByGroups.publicPath,
+ plugin: this
+ });
+ });
+
+ // Turn the compiled template into a nodejs function or into a nodejs string
+ const templateEvaluationPromise = Promise.resolve()
+ .then(() => {
+ if ('error' in templateResult) {
+ return this.options.showErrors ? prettyError(templateResult.error, compiler.context).toHtml() : 'ERROR';
+ }
+
+ // Allow to use a custom function / string instead
+ if (this.options.templateContent !== false) {
+ return this.options.templateContent;
+ }
+
+ // Once everything is compiled evaluate the html factory and replace it with its content
+ if ('compiledEntry' in templateResult) {
+ const compiledEntry = templateResult.compiledEntry;
+ const assets = compiledEntry.assets;
+
+ // Store assets from child compiler to reemit them later
+ for (const name in assets) {
+ previousEmittedAssets.push({ name, source: assets[name].source, info: assets[name].info });
+ }
+
+ return this.evaluateCompilationResult(compiledEntry.content, assetsInformationByGroups.publicPath, this.options.template);
+ }
+
+ return Promise.reject(new Error('Child compilation contained no compiledEntry'));
+ });
+ const templateExectutionPromise = Promise.all([assetsPromise, assetTagGroupsPromise, templateEvaluationPromise])
+ // Execute the template
+ .then(([assetsHookResult, assetTags, compilationResult]) => typeof compilationResult !== 'function'
+ ? compilationResult
+ : this.executeTemplate(compilationResult, assetsHookResult.assets, { headTags: assetTags.headTags, bodyTags: assetTags.bodyTags }, compilation));
+
+ const injectedHtmlPromise = Promise.all([assetTagGroupsPromise, templateExectutionPromise])
+ // Allow plugins to change the html before assets are injected
+ .then(([assetTags, html]) => {
+ const pluginArgs = { html, headTags: assetTags.headTags, bodyTags: assetTags.bodyTags, plugin: this, outputName };
+ return getHtmlWebpackPluginHooks(compilation).afterTemplateExecution.promise(pluginArgs);
+ })
+ .then(({ html, headTags, bodyTags }) => {
+ return this.postProcessHtml(compiler, html, assetsInformationByGroups, { headTags, bodyTags });
+ });
+
+ const emitHtmlPromise = injectedHtmlPromise
+ // Allow plugins to change the html after assets are injected
+ .then((html) => {
+ const pluginArgs = { html, plugin: this, outputName };
+ return getHtmlWebpackPluginHooks(compilation).beforeEmit.promise(pluginArgs)
+ .then(result => result.html);
+ })
+ .catch(err => {
+ // In case anything went wrong the promise is resolved
+ // with the error message and an error is logged
+ compilation.errors.push(prettyError(err, compiler.context).toString());
+ return this.options.showErrors ? prettyError(err, compiler.context).toHtml() : 'ERROR';
+ })
+ .then(html => {
+ const filename = outputName.replace(/\[templatehash([^\]]*)\]/g, require('util').deprecate(
+ (match, options) => `[contenthash${options}]`,
+ '[templatehash] is now [contenthash]')
+ );
+ const replacedFilename = this.replacePlaceholdersInFilename(compiler, filename, html, compilation);
+ const source = new compiler.webpack.sources.RawSource(html, false);
+
+ // Add the evaluated html code to the webpack assets
+ compilation.emitAsset(replacedFilename.path, source, replacedFilename.info);
+ previousEmittedAssets.push({ name: replacedFilename.path, source });
+
+ return replacedFilename.path;
+ })
+ .then((finalOutputName) => getHtmlWebpackPluginHooks(compilation).afterEmit.promise({
+ outputName: finalOutputName,
+ plugin: this
+ }).catch(err => {
+ /** @type {Logger} */
+ (this.logger).error(err);
+ return null;
+ }).then(() => null));
+
+ // Once all files are added to the webpack compilation
+ // let the webpack compiler continue
+ emitHtmlPromise.then(() => {
+ callback();
+ });
}
}
@@ -1134,14 +1170,8 @@ function hookIntoCompiler (compiler, options, plugin) {
* Generate the template parameters
*
* Generate the template parameters for the template function
- * @param {WebpackCompilation} compilation
- * @param {{
- publicPath: string,
- js: Array,
- css: Array,
- manifest?: string,
- favicon?: string
- }} assets
+ * @param {Compilation} compilation
+ * @param {AssetsInformationByGroups} assets
* @param {{
headTags: HtmlTagObject[],
bodyTags: HtmlTagObject[]
diff --git a/lib/cached-child-compiler.js b/lib/cached-child-compiler.js
index ad5b67e0..f6e4c733 100644
--- a/lib/cached-child-compiler.js
+++ b/lib/cached-child-compiler.js
@@ -21,41 +21,40 @@
});
* ```
*/
+'use strict';
// Import types
-/** @typedef {import("webpack/lib/Compiler.js")} WebpackCompiler */
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
-/** @typedef {{hash: string, entry: any, content: string }} ChildCompilationResultEntry */
-/** @typedef {import("./file-watcher-api").Snapshot} Snapshot */
+/** @typedef {import("webpack").Compiler} Compiler */
+/** @typedef {import("webpack").Compilation} Compilation */
+/** @typedef {import("webpack/lib/FileSystemInfo").Snapshot} Snapshot */
+/** @typedef {import("./child-compiler").ChildCompilationTemplateResult} ChildCompilationTemplateResult */
/** @typedef {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} FileDependencies */
/** @typedef {{
dependencies: FileDependencies,
- compiledEntries: {[entryName: string]: ChildCompilationResultEntry}
+ compiledEntries: {[entryName: string]: ChildCompilationTemplateResult}
} | {
dependencies: FileDependencies,
error: Error
}} ChildCompilationResult */
-'use strict';
const { HtmlWebpackChildCompiler } = require('./child-compiler');
-const fileWatcherApi = require('./file-watcher-api');
/**
* This plugin is a singleton for performance reasons.
* To keep track if a plugin does already exist for the compiler they are cached
* in this map
- * @type {WeakMap}}
+ * @type {WeakMap}}
*/
const compilerMap = new WeakMap();
class CachedChildCompilation {
/**
- * @param {WebpackCompiler} compiler
+ * @param {Compiler} compiler
*/
constructor (compiler) {
/**
* @private
- * @type {WebpackCompiler}
+ * @type {Compiler}
*/
this.compiler = compiler;
// Create a singleton instance for the compiler
@@ -97,7 +96,7 @@ class CachedChildCompilation {
* @param {string} entry
* @returns {
| { mainCompilationHash: string, error: Error }
- | { mainCompilationHash: string, compiledEntry: ChildCompilationResultEntry }
+ | { mainCompilationHash: string, compiledEntry: ChildCompilationTemplateResult }
}
*/
getCompilationEntryResult (entry) {
@@ -114,6 +113,61 @@ class CachedChildCompilation {
}
class PersistentChildCompilerSingletonPlugin {
+ /**
+ *
+ * @param {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} fileDependencies
+ * @param {Compilation} mainCompilation
+ * @param {number} startTime
+ */
+ static createSnapshot (fileDependencies, mainCompilation, startTime) {
+ return new Promise((resolve, reject) => {
+ mainCompilation.fileSystemInfo.createSnapshot(
+ startTime,
+ fileDependencies.fileDependencies,
+ fileDependencies.contextDependencies,
+ fileDependencies.missingDependencies,
+ // @ts-ignore
+ null,
+ (err, snapshot) => {
+ if (err) {
+ return reject(err);
+ }
+ resolve(snapshot);
+ }
+ );
+ });
+ }
+
+ /**
+ * Returns true if the files inside this snapshot
+ * have not been changed
+ *
+ * @param {Snapshot} snapshot
+ * @param {Compilation} mainCompilation
+ * @returns {Promise}
+ */
+ static isSnapshotValid (snapshot, mainCompilation) {
+ return new Promise((resolve, reject) => {
+ mainCompilation.fileSystemInfo.checkSnapshotValid(
+ snapshot,
+ (err, isValid) => {
+ if (err) {
+ reject(err);
+ }
+ resolve(isValid);
+ }
+ );
+ });
+ }
+
+ static watchFiles (mainCompilation, fileDependencies) {
+ Object.keys(fileDependencies).forEach((depencyTypes) => {
+ fileDependencies[depencyTypes].forEach(fileDependency => {
+ mainCompilation[depencyTypes].add(fileDependency);
+ });
+ });
+ }
+
constructor () {
/**
* @private
@@ -158,7 +212,7 @@ class PersistentChildCompilerSingletonPlugin {
/**
* apply is called by the webpack main compiler during the start phase
- * @param {WebpackCompiler} compiler
+ * @param {Compiler} compiler
*/
apply (compiler) {
/** @type Promise */
@@ -174,8 +228,9 @@ class PersistentChildCompilerSingletonPlugin {
* The main compilation hash which will only be updated
* if the childCompiler changes
*/
+ /** @type {string} */
let mainCompilationHashOfLastChildRecompile = '';
- /** @typedef{Snapshot|undefined} */
+ /** @type {Snapshot | undefined} */
let previousFileSystemSnapshot;
let compilationStartTime = new Date().getTime();
@@ -216,7 +271,7 @@ class PersistentChildCompilerSingletonPlugin {
// this might possibly cause bugs if files were changed inbetween
// compilation start and snapshot creation
compiledEntriesPromise.then((childCompilationResult) => {
- return fileWatcherApi.createSnapshot(childCompilationResult.dependencies, mainCompilation, compilationStartTime);
+ return PersistentChildCompilerSingletonPlugin.createSnapshot(childCompilationResult.dependencies, mainCompilation, compilationStartTime);
}).then((snapshot) => {
previousFileSystemSnapshot = snapshot;
});
@@ -234,6 +289,7 @@ class PersistentChildCompilerSingletonPlugin {
childCompilationResult.dependencies
);
});
+ // @ts-ignore
handleCompilationDonePromise.then(() => callback(null, chunks, modules), callback);
}
);
@@ -253,7 +309,7 @@ class PersistentChildCompilerSingletonPlugin {
([childCompilationResult, didRecompile]) => {
// Update hash and snapshot if childCompilation changed
if (didRecompile) {
- mainCompilationHashOfLastChildRecompile = mainCompilation.hash;
+ mainCompilationHashOfLastChildRecompile = /** @type {string} */ (mainCompilation.hash);
}
this.compilationState = {
isCompiling: false,
@@ -309,8 +365,8 @@ class PersistentChildCompilerSingletonPlugin {
* Verify that the cache is still valid
* @private
* @param {Snapshot | undefined} snapshot
- * @param {WebpackCompilation} mainCompilation
- * @returns {Promise}
+ * @param {Compilation} mainCompilation
+ * @returns {Promise}
*/
isCacheValid (snapshot, mainCompilation) {
if (!this.compilationState.isVerifyingCache) {
@@ -328,14 +384,15 @@ class PersistentChildCompilerSingletonPlugin {
if (!snapshot) {
return Promise.resolve(false);
}
- return fileWatcherApi.isSnapShotValid(snapshot, mainCompilation);
+
+ return PersistentChildCompilerSingletonPlugin.isSnapshotValid(snapshot, mainCompilation);
}
/**
* Start to compile all templates
*
* @private
- * @param {WebpackCompilation} mainCompilation
+ * @param {Compilation} mainCompilation
* @param {string[]} entries
* @returns {Promise}
*/
@@ -366,11 +423,11 @@ class PersistentChildCompilerSingletonPlugin {
/**
* @private
- * @param {WebpackCompilation} mainCompilation
+ * @param {Compilation} mainCompilation
* @param {FileDependencies} files
*/
watchFiles (mainCompilation, files) {
- fileWatcherApi.watchFiles(mainCompilation, files);
+ PersistentChildCompilerSingletonPlugin.watchFiles(mainCompilation, files);
}
}
diff --git a/lib/child-compiler.js b/lib/child-compiler.js
index 8f50f0f9..8bea9bec 100644
--- a/lib/child-compiler.js
+++ b/lib/child-compiler.js
@@ -1,7 +1,4 @@
// @ts-check
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
-/** @typedef {import("webpack/lib/Compiler.js")} WebpackCompiler */
-/** @typedef {import("webpack/lib/Chunk.js")} WebpackChunk */
'use strict';
/**
@@ -12,6 +9,10 @@
*
*/
+/** @typedef {import("webpack").Chunk} Chunk */
+/** @typedef {import("webpack").sources.Source} Source */
+/** @typedef {{hash: string, entry: Chunk, content: string, assets: {[name: string]: { source: Source, info: import("webpack").AssetInfo }}}} ChildCompilationTemplateResult */
+
let instanceId = 0;
/**
* The HtmlWebpackChildCompiler is a helper to allow reusing one childCompiler
@@ -30,17 +31,11 @@ class HtmlWebpackChildCompiler {
* The template array will allow us to keep track which input generated which output
*/
this.templates = templates;
- /**
- * @type {Promise<{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}>}
- */
+ /** @type {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>} */
this.compilationPromise; // eslint-disable-line
- /**
- * @type {number}
- */
+ /** @type {number | undefined} */
this.compilationStartedTimestamp; // eslint-disable-line
- /**
- * @type {number}
- */
+ /** @type {number | undefined} */
this.compilationEndedTimestamp; // eslint-disable-line
/**
* All file dependencies of the child compiler
@@ -51,6 +46,7 @@ class HtmlWebpackChildCompiler {
/**
* Returns true if the childCompiler is currently compiling
+ *
* @returns {boolean}
*/
isCompiling () {
@@ -59,6 +55,8 @@ class HtmlWebpackChildCompiler {
/**
* Returns true if the childCompiler is done compiling
+ *
+ * @returns {boolean}
*/
didCompile () {
return this.compilationEndedTimestamp !== undefined;
@@ -69,7 +67,7 @@ class HtmlWebpackChildCompiler {
* once it is started no more templates can be added
*
* @param {import('webpack').Compilation} mainCompilation
- * @returns {Promise<{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}>}
+ * @returns {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>}
*/
compileTemplates (mainCompilation) {
const webpack = mainCompilation.compiler.webpack;
@@ -125,12 +123,17 @@ class HtmlWebpackChildCompiler {
// The following config enables relative URL support for the child compiler
childCompiler.options.module = { ...childCompiler.options.module };
childCompiler.options.module.parser = { ...childCompiler.options.module.parser };
- childCompiler.options.module.parser.javascript = { ...childCompiler.options.module.parser.javascript,
- url: 'relative' };
+ childCompiler.options.module.parser.javascript = {
+ ...childCompiler.options.module.parser.javascript,
+ url: 'relative'
+ };
this.compilationStartedTimestamp = new Date().getTime();
+ /** @type {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>} */
this.compilationPromise = new Promise((resolve, reject) => {
+ /** @type {Source[]} */
const extractedAssets = [];
+
childCompiler.hooks.thisCompilation.tap('HtmlWebpackPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
@@ -141,6 +144,7 @@ class HtmlWebpackChildCompiler {
temporaryTemplateNames.forEach((temporaryTemplateName) => {
if (assets[temporaryTemplateName]) {
extractedAssets.push(assets[temporaryTemplateName]);
+
compilation.deleteAsset(temporaryTemplateName);
}
});
@@ -150,13 +154,16 @@ class HtmlWebpackChildCompiler {
childCompiler.runAsChild((err, entries, childCompilation) => {
// Extract templates
+ // TODO fine a better way to store entries and results, to avoid duplicate chunks and assets
const compiledTemplates = entries
? extractedAssets.map((asset) => asset.source())
: [];
+
// Extract file dependencies
if (entries && childCompilation) {
this.fileDependencies = { fileDependencies: Array.from(childCompilation.fileDependencies), contextDependencies: Array.from(childCompilation.contextDependencies), missingDependencies: Array.from(childCompilation.missingDependencies) };
}
+
// Reject the promise if the childCompilation contains error
if (childCompilation && childCompilation.errors && childCompilation.errors.length) {
const errorDetails = childCompilation.errors.map(error => {
@@ -166,33 +173,50 @@ class HtmlWebpackChildCompiler {
}
return message;
}).join('\n');
+
reject(new Error('Child compilation failed:\n' + errorDetails));
+
return;
}
+
// Reject if the error object contains errors
if (err) {
reject(err);
return;
}
+
if (!childCompilation || !entries) {
reject(new Error('Empty child compilation'));
return;
}
+
/**
- * @type {{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}}
+ * @type {{[templatePath: string]: ChildCompilationTemplateResult}}
*/
const result = {};
+
+ /** @type {{[name: string]: { source: Source, info: import("webpack").AssetInfo }}} */
+ const assets = {};
+
+ for (const asset of childCompilation.getAssets()) {
+ assets[asset.name] = { source: asset.source, info: asset.info };
+ }
+
compiledTemplates.forEach((templateSource, entryIndex) => {
// The compiledTemplates are generated from the entries added in
// the addTemplate function.
- // Therefore the array index of this.templates should be the as entryIndex.
+ // Therefore, the array index of this.templates should be the as entryIndex.
result[this.templates[entryIndex]] = {
- content: templateSource,
+ // TODO, can we have Buffer here?
+ content: /** @type {string} */ (templateSource),
hash: childCompilation.hash || 'XXXX',
- entry: entries[entryIndex]
+ entry: entries[entryIndex],
+ assets
};
});
+
this.compilationEndedTimestamp = new Date().getTime();
+
resolve(result);
});
});
diff --git a/lib/chunksorter.js b/lib/chunksorter.js
index 40d9909d..689abecc 100644
--- a/lib/chunksorter.js
+++ b/lib/chunksorter.js
@@ -1,25 +1,26 @@
// @ts-check
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
'use strict';
+/** @typedef {import("webpack").Compilation} Compilation */
+
/**
- * @type {{[sortmode: string] : (entryPointNames: Array, compilation, htmlWebpackPluginOptions) => Array }}
+ * @type {{[sortmode: string] : (entryPointNames: Array, compilation: Compilation, htmlWebpackPluginOptions: any) => Array }}
* This file contains different sort methods for the entry chunks names
*/
module.exports = {};
/**
* Performs identity mapping (no-sort).
- * @param {Array} chunks the chunks to sort
- * @return {Array} The sorted chunks
+ * @param {Array} chunks the chunks to sort
+ * @return {Array} The sorted chunks
*/
module.exports.none = chunks => chunks;
/**
* Sort manually by the chunks
- * @param {string[]} entryPointNames the chunks to sort
- * @param {WebpackCompilation} compilation the webpack compilation
- * @param htmlWebpackPluginOptions the plugin options
+ * @param {string[]} entryPointNames the chunks to sort
+ * @param {Compilation} compilation the webpack compilation
+ * @param {any} htmlWebpackPluginOptions the plugin options
* @return {string[]} The sorted chunks
*/
module.exports.manual = (entryPointNames, compilation, htmlWebpackPluginOptions) => {
diff --git a/lib/file-watcher-api.js b/lib/file-watcher-api.js
deleted file mode 100644
index 2c1e923d..00000000
--- a/lib/file-watcher-api.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// @ts-check
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
-/** @typedef {import("webpack/lib/FileSystemInfo").Snapshot} Snapshot */
-'use strict';
-
-/**
- *
- * @param {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} fileDependencies
- * @param {WebpackCompilation} mainCompilation
- * @param {number} startTime
- */
-function createSnapshot (fileDependencies, mainCompilation, startTime) {
- return new Promise((resolve, reject) => {
- mainCompilation.fileSystemInfo.createSnapshot(
- startTime,
- fileDependencies.fileDependencies,
- fileDependencies.contextDependencies,
- fileDependencies.missingDependencies,
- null,
- (err, snapshot) => {
- if (err) {
- return reject(err);
- }
- resolve(snapshot);
- }
- );
- });
-}
-
-/**
- * Returns true if the files inside this snapshot
- * have not been changed
- *
- * @param {Snapshot} snapshot
- * @param {WebpackCompilation} mainCompilation
- * @returns {Promise}
- */
-function isSnapShotValid (snapshot, mainCompilation) {
- return new Promise((resolve, reject) => {
- mainCompilation.fileSystemInfo.checkSnapshotValid(
- snapshot,
- (err, isValid) => {
- if (err) {
- reject(err);
- }
- resolve(isValid);
- }
- );
- });
-}
-
-/**
- * Ensure that the files keep watched for changes
- * and will trigger a recompile
- *
- * @param {WebpackCompilation} mainCompilation
- * @param {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} fileDependencies
- */
-function watchFiles (mainCompilation, fileDependencies) {
- Object.keys(fileDependencies).forEach((depencyTypes) => {
- fileDependencies[depencyTypes].forEach(fileDependency => {
- mainCompilation[depencyTypes].add(fileDependency);
- });
- });
-}
-
-module.exports = {
- createSnapshot,
- isSnapShotValid,
- watchFiles
-};
diff --git a/lib/hooks.js b/lib/hooks.js
index 62c36070..a8f436e0 100644
--- a/lib/hooks.js
+++ b/lib/hooks.js
@@ -1,12 +1,12 @@
// @ts-check
-/** @typedef {import("../typings").Hooks} HtmlWebpackPluginHooks */
'use strict';
/**
* This file provides access to all public htmlWebpackPlugin hooks
*/
-/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
+/** @typedef {import("webpack").Compilation} WebpackCompilation */
/** @typedef {import("../index.js")} HtmlWebpackPlugin */
+/** @typedef {import("../typings").Hooks} HtmlWebpackPluginHooks */
const AsyncSeriesWaterfallHook = require('tapable').AsyncSeriesWaterfallHook;
diff --git a/package.json b/package.json
index e89e1a63..d529290f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "html-webpack-plugin",
- "version": "5.5.3",
+ "version": "5.5.4",
"license": "MIT",
"description": "Simplifies creation of HTML files to serve your webpack bundles",
"author": "Jan Nicklas (https://github.com/jantimon)",
diff --git a/spec/fixtures/html-template-with-image.html b/spec/fixtures/html-template-with-image.html
new file mode 100644
index 00000000..5e55a3d4
--- /dev/null
+++ b/spec/fixtures/html-template-with-image.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Test
+
+
+Some unique text
+
+
+
+
+
diff --git a/spec/hot.spec.js b/spec/hot.spec.js
index a2d2c7f9..2a99be6e 100644
--- a/spec/hot.spec.js
+++ b/spec/hot.spec.js
@@ -20,6 +20,7 @@ const DEFAULT_LOADER = require.resolve('../lib/loader.js') + '?force';
const DEFAULT_TEMPLATE = DEFAULT_LOADER + '!' + require.resolve('../default_index.ejs');
jest.setTimeout(30000);
+
process.on('unhandledRejection', r => console.log(r));
describe('HtmlWebpackPluginHMR', () => {
@@ -85,4 +86,140 @@ describe('HtmlWebpackPluginHMR', () => {
})
.then(() => compiler.stopWatching());
});
+
+ it('should re-emit favicon and assets from a loader if watch is active', () => {
+ const template = path.join(__dirname, './fixtures/html-template-with-image.html');
+ const config = {
+ mode: 'development',
+ entry: path.join(__dirname, 'fixtures/index.js'),
+ output: {
+ assetModuleFilename: '[name][ext]',
+ path: OUTPUT_DIR
+ },
+ module: {
+ rules: [
+ {
+ test: /\.html$/,
+ loader: 'html-loader'
+ }
+ ]
+ },
+ plugins: [
+ new HtmlWebpackPlugin({
+ favicon: path.join(__dirname, './fixtures/favicon.ico'),
+ template
+ })
+ ]
+ };
+
+ const templateContent = fs.readFileSync(template, 'utf-8');
+ const compiler = new WebpackRecompilationSimulator(webpack(config));
+ const jsFileTempPath = compiler.addTestFile(path.join(__dirname, 'fixtures/index.js'));
+ const expected = ['logo.png', 'main.js', 'favicon.ico', 'index.html'];
+
+ return compiler.startWatching()
+ // Change the template file and compile again
+ .then((stats) => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(jsFileTempPath, 'module.exports = function calc(a, b){ return a - b };');
+
+ return compiler.waitForWatchRunComplete();
+ })
+ .then(stats => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(template, templateContent.replace(/Some unique text/, 'Some other unique text'));
+
+ return compiler.waitForWatchRunComplete();
+ })
+ .then((stats) => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(template, templateContent);
+ })
+ .then(() => compiler.stopWatching());
+ });
+
+ it('should re-emit favicon and assets from a loader if watch is active and clean enabled', () => {
+ const expected = ['logo.png', 'main.js', 'favicon.ico', 'index.html'];
+
+ class MyPlugin {
+ apply (compiler) {
+ compiler.hooks.thisCompilation.tap({ name: this.constructor.name }, (compilation) => {
+ return compilation.hooks.processAssets.tap(
+ { name: this.constructor.name, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE },
+ (assets) => {
+ expect(expected.every(val => Object.keys(assets).includes(val))).toBe(true);
+ }
+ );
+ });
+ }
+ }
+
+ const template = path.join(__dirname, './fixtures/html-template-with-image.html');
+ const config = {
+ mode: 'development',
+ entry: path.join(__dirname, 'fixtures/index.js'),
+ output: {
+ clean: true,
+ assetModuleFilename: '[name][ext]',
+ path: OUTPUT_DIR
+ },
+ module: {
+ rules: [
+ {
+ test: /\.html$/,
+ loader: 'html-loader'
+ }
+ ]
+ },
+ plugins: [
+ new HtmlWebpackPlugin({
+ favicon: path.join(__dirname, './fixtures/favicon.ico'),
+ template
+ }),
+ new MyPlugin()
+ ]
+ };
+
+ const templateContent = fs.readFileSync(template, 'utf-8');
+ const compiler = new WebpackRecompilationSimulator(webpack(config));
+ const jsFileTempPath = compiler.addTestFile(path.join(__dirname, 'fixtures/index.js'));
+
+ return compiler.startWatching()
+ // Change the template file and compile again
+ .then((stats) => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(jsFileTempPath, 'module.exports = function calc(a, b){ return a - b };');
+
+ return compiler.waitForWatchRunComplete();
+ })
+ .then(stats => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(template, templateContent.replace(/Some unique text/, 'Some other unique text'));
+
+ return compiler.waitForWatchRunComplete();
+ })
+ .then((stats) => {
+ expect(expected.every(val => Object.keys(stats.compilation.assets).includes(val))).toBe(true);
+ expect(stats.compilation.errors).toEqual([]);
+ expect(stats.compilation.warnings).toEqual([]);
+
+ fs.writeFileSync(template, templateContent);
+ })
+ .then(() => compiler.stopWatching());
+ });
});
diff --git a/typings.d.ts b/typings.d.ts
index 8440fe8e..f707c108 100644
--- a/typings.d.ts
+++ b/typings.d.ts
@@ -143,7 +143,7 @@ declare namespace HtmlWebpackPlugin {
templateParameters?:
| false // Pass an empty object to the template function
| ((
- compilation: any,
+ compilation: Compilation,
assets: {
publicPath: string;
js: Array;
@@ -186,7 +186,7 @@ declare namespace HtmlWebpackPlugin {
* Please keep in mind that the `templateParameter` options allows to change them
*/
interface TemplateParameter {
- compilation: any;
+ compilation: Compilation;
htmlWebpackPlugin: {
tags: {
headTags: HtmlTagObject[];