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

+
+ Logo +
+ + 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[];