diff --git a/index.js b/index.js index b2b07278..27015d8a 100644 --- a/index.js +++ b/index.js @@ -18,9 +18,11 @@ const getHtmlWebpackPluginHooks = require('./lib/hooks.js').getHtmlWebpackPlugin /** @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 */ +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {ReturnType} Logger */ +/** @typedef {import("webpack/lib/Compilation.js")} Compilation */ /** @typedef {Array<{ source: import('webpack').sources.Source, name: string }>} PreviousEmittedAssets */ +/** @typedef {{ publicPath: string, js: Array, css: Array, manifest?: string, favicon?: string }} AssetsInformationByGroups */ class HtmlWebpackPlugin { /** @@ -28,67 +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 if (options.scriptLoading !== 'defer' && options.scriptLoading !== 'blocking' && options.scriptLoading !== 'module') { - this.logger.error('The "scriptLoading" option need to be set to "defer", "blocking" or "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') { - this.logger.error('The `inject` option needs to be set to true, false, "head" or "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 @@ -98,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} + */ + let 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 @@ -119,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)>} */ @@ -126,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, @@ -188,317 +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 {PreviousEmittedAssets} + * 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, source }) => { - compilation.emitAsset(name, source); - }); - 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 = generateFavicon(options.favicon, compilation, assets.publicPath, previousEmittedAssets) - .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); - const source = new 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: plugin - }).catch(err => { - plugin.logger.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 @@ -514,243 +620,138 @@ 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); - } - /** - * 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 + 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; } - } - ); - } - /** - * 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 true; + }).map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml)); - /** - * 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; + 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(''); + } } - // 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'; + 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 + ''); + } + } - 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('/') - ); + // Append assets to head element + html = html.replace(headRegExp, match => head.join('') + match); + } - if (publicPath.length && publicPath.substr(-1, 1) !== '/') { - publicPath += '/'; + // 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 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 - }; + // 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); - // Append a hash for cache busting - if (options.hash && assets.manifest) { - assets.manifest = appendHash(assets.manifest, compilationHash); + if (!needMinify) { + return Promise.resolve(html); } - // 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 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 + }; - 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); - }); + try { + html = require('html-minifier-terser').minify(html, minifyOptions); + } catch (e) { + const isParseError = String(e.message).indexOf('Parse Error') === 0; - // 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 (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; + } - 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 Promise.reject(e); } - return assets; + + 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 * + * @private + * @param {Compiler} compiler * @param {string|false} favicon - * @param {WebpackCompilation} compilation + * @param {Compilation} compilation * @param {string} publicPath * @param {PreviousEmittedAssets} previousEmittedAssets * @returns {Promise} */ - function generateFavicon (favicon, compilation, publicPath, previousEmittedAssets) { + generateFavicon (compiler, favicon, compilation, publicPath, previousEmittedAssets) { if (!favicon) { return Promise.resolve(undefined); } @@ -759,7 +760,7 @@ function hookIntoCompiler (compiler, options, plugin) { return promisify(compilation.inputFileSystem.readFile)(filename) .then((buf) => { - const source = new webpack.sources.RawSource(buf, false); + const source = new compiler.webpack.sources.RawSource(/** @type {string | Buffer} */ (buf), false); const name = path.basename(filename); compilation.fileDependencies.add(filename); @@ -768,8 +769,8 @@ function hookIntoCompiler (compiler, options, plugin) { const faviconPath = publicPath + name; - if (options.hash) { - return appendHash(faviconPath, compilation.hash); + if (this.options.hash) { + return this.appendHash(faviconPath, /** @type {string} */ (compilation.hash)); } return faviconPath; @@ -779,17 +780,19 @@ function hookIntoCompiler (compiler, options, plugin) { /** * Generate all tags script for the given file paths + * + * @private * @param {Array} jsAssets * @returns {Array} */ - function generatedScriptTags (jsAssets) { + 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, + defer: this.options.scriptLoading === 'defer', + type: this.options.scriptLoading === 'module' ? 'module' : undefined, src: scriptAsset } })); @@ -797,10 +800,12 @@ function hookIntoCompiler (compiler, options, plugin) { /** * 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, @@ -814,13 +819,11 @@ 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) { + * + * @param {false | string | {[attributeName: string]: string}} baseOption + * @returns {Array} + */ + generateBaseTag (baseOption) { if (baseOption === false) { return []; } else { @@ -828,6 +831,7 @@ function hookIntoCompiler (compiler, options, plugin) { tagName: 'base', voidTag: true, meta: { plugin: 'html-webpack-plugin' }, + // attributes e.g. { href:"http://example.com/page.html" target:"_blank" } attributes: (typeof baseOption === 'string') ? { href: baseOption } : baseOption @@ -837,18 +841,16 @@ function hookIntoCompiler (compiler, options, plugin) { /** * 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" }] @@ -861,8 +863,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'); @@ -878,13 +881,16 @@ function hookIntoCompiler (compiler, options, plugin) { /** * Generate a favicon tag for the given file path + * + * @private * @param {string| undefined} faviconPath * @returns {Array} */ - function generateFaviconTags (faviconPath) { + generateFaviconTag (faviconPath) { if (!faviconPath) { return []; } + return [{ tagName: 'link', voidTag: true, @@ -897,20 +903,20 @@ function hookIntoCompiler (compiler, options, plugin) { } /** - * 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: [ @@ -919,214 +925,231 @@ 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 }) => { + compilation.emitAsset(name, source); + }); + return callback(); + } else { + previousEmittedAssets = []; + 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.generateBaseTag(this.options.base), + ...this.generatedMetaTags(this.options.meta), + ...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 + return ('compiledEntry' in templateResult) + ? this.evaluateCompilationResult(templateResult.compiledEntry.content, assetsInformationByGroups.publicPath, this.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 + : 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(); + }); } } @@ -1135,14 +1158,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 eac51af1..36ecabf6 100644 --- a/lib/cached-child-compiler.js +++ b/lib/cached-child-compiler.js @@ -24,8 +24,8 @@ 'use strict'; // Import types -/** @typedef {import("webpack/lib/Compiler.js")} Compiler */ -/** @typedef {import("webpack/lib/Compilation.js")} Compilation */ +/** @typedef {import("webpack").Compiler} Compiler */ +/** @typedef {import("webpack").Compilation} Compilation */ /** @typedef {import("webpack/lib/FileSystemInfo").Snapshot} Snapshot */ /** @typedef {{hash: string, entry: any, content: string }} ChildCompilationResultEntry */ /** @typedef {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} FileDependencies */ @@ -126,6 +126,7 @@ class PersistentChildCompilerSingletonPlugin { fileDependencies.fileDependencies, fileDependencies.contextDependencies, fileDependencies.missingDependencies, + // @ts-ignore null, (err, snapshot) => { if (err) { @@ -143,7 +144,7 @@ class PersistentChildCompilerSingletonPlugin { * * @param {Snapshot} snapshot * @param {Compilation} mainCompilation - * @returns {Promise} + * @returns {Promise} */ static isSnapshotValid (snapshot, mainCompilation) { return new Promise((resolve, reject) => { @@ -227,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(); @@ -287,6 +289,7 @@ class PersistentChildCompilerSingletonPlugin { childCompilationResult.dependencies ); }); + // @ts-ignore handleCompilationDonePromise.then(() => callback(null, chunks, modules), callback); } ); @@ -306,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, @@ -363,7 +366,7 @@ class PersistentChildCompilerSingletonPlugin { * @private * @param {Snapshot | undefined} snapshot * @param {Compilation} mainCompilation - * @returns {Promise} + * @returns {Promise} */ isCacheValid (snapshot, mainCompilation) { if (!this.compilationState.isVerifyingCache) { @@ -381,6 +384,7 @@ class PersistentChildCompilerSingletonPlugin { if (!snapshot) { return Promise.resolve(false); } + return PersistentChildCompilerSingletonPlugin.isSnapshotValid(snapshot, mainCompilation); } diff --git a/lib/child-compiler.js b/lib/child-compiler.js index 8f50f0f9..e5d433b0 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,9 @@ * */ +/** @typedef {import("webpack").Chunk} Chunk */ +/** @typedef {import("webpack").sources.Source} Source */ + let instanceId = 0; /** * The HtmlWebpackChildCompiler is a helper to allow reusing one childCompiler @@ -31,7 +31,7 @@ class HtmlWebpackChildCompiler { */ this.templates = templates; /** - * @type {Promise<{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}>} + * @type {Promise<{[templatePath: string]: { content: string, hash: string, entry: Chunk }}>} */ this.compilationPromise; // eslint-disable-line /** @@ -69,7 +69,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]: { content: string, hash: string, entry: Chunk }}>} */ compileTemplates (mainCompilation) { const webpack = mainCompilation.compiler.webpack; @@ -130,6 +130,7 @@ class HtmlWebpackChildCompiler { this.compilationStartedTimestamp = new Date().getTime(); this.compilationPromise = new Promise((resolve, reject) => { + /** @type {Source[]} */ const extractedAssets = []; childCompiler.hooks.thisCompilation.tap('HtmlWebpackPlugin', (compilation) => { compilation.hooks.processAssets.tap( @@ -179,7 +180,7 @@ class HtmlWebpackChildCompiler { return; } /** - * @type {{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}} + * @type {{[templatePath: string]: { content: string, hash: string, entry: Chunk }}} */ const result = {}; compiledTemplates.forEach((templateSource, entryIndex) => { @@ -187,7 +188,8 @@ class HtmlWebpackChildCompiler { // the addTemplate function. // 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] }; 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/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/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[];