UNPKG

39.8 kBJavaScriptView Raw
1// @ts-check
2// Import types
3/** @typedef {import("./typings").HtmlTagObject} HtmlTagObject */
4/** @typedef {import("./typings").Options} HtmlWebpackOptions */
5/** @typedef {import("./typings").ProcessedOptions} ProcessedHtmlWebpackOptions */
6/** @typedef {import("./typings").TemplateParameter} TemplateParameter */
7/** @typedef {import("webpack/lib/Compiler.js")} WebpackCompiler */
8/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
9'use strict';
10
11// use Polyfill for util.promisify in node versions < v8
12const promisify = require('util.promisify');
13
14const vm = require('vm');
15const fs = require('fs');
16const _ = require('lodash');
17const path = require('path');
18const loaderUtils = require('loader-utils');
19const { CachedChildCompilation } = require('./lib/cached-child-compiler');
20
21const { createHtmlTagObject, htmlTagObjectToString, HtmlTagArray } = require('./lib/html-tags');
22
23const prettyError = require('./lib/errors.js');
24const chunkSorter = require('./lib/chunksorter.js');
25const getHtmlWebpackPluginHooks = require('./lib/hooks.js').getHtmlWebpackPluginHooks;
26
27const fsStatAsync = promisify(fs.stat);
28const fsReadFileAsync = promisify(fs.readFile);
29
30const webpackMajorVersion = Number(require('webpack/package.json').version.split('.')[0]);
31
32class HtmlWebpackPlugin {
33 /**
34 * @param {HtmlWebpackOptions} [options]
35 */
36 constructor (options) {
37 /** @type {HtmlWebpackOptions} */
38 const userOptions = options || {};
39
40 // Default options
41 /** @type {ProcessedHtmlWebpackOptions} */
42 const defaultOptions = {
43 template: 'auto',
44 templateContent: false,
45 templateParameters: templateParametersGenerator,
46 filename: 'index.html',
47 hash: false,
48 inject: userOptions.scriptLoading !== 'defer' ? 'body' : 'head',
49 scriptLoading: 'blocking',
50 compile: true,
51 favicon: false,
52 minify: 'auto',
53 cache: true,
54 showErrors: true,
55 chunks: 'all',
56 excludeChunks: [],
57 chunksSortMode: 'auto',
58 meta: {},
59 base: false,
60 title: 'Webpack App',
61 xhtml: false
62 };
63
64 /** @type {ProcessedHtmlWebpackOptions} */
65 this.options = Object.assign(defaultOptions, userOptions);
66
67 // Default metaOptions if no template is provided
68 if (!userOptions.template && this.options.templateContent === false && this.options.meta) {
69 const defaultMeta = {
70 // From https://developer.mozilla.org/en-US/docs/Mozilla/Mobile/Viewport_meta_tag
71 viewport: 'width=device-width, initial-scale=1'
72 };
73 this.options.meta = Object.assign({}, this.options.meta, defaultMeta, userOptions.meta);
74 }
75
76 // Instance variables to keep caching information
77 // for multiple builds
78 this.childCompilerHash = undefined;
79 this.assetJson = undefined;
80 this.hash = undefined;
81 this.version = HtmlWebpackPlugin.version;
82 }
83
84 /**
85 * apply is called by the webpack main compiler during the start phase
86 * @param {WebpackCompiler} compiler
87 */
88 apply (compiler) {
89 const self = this;
90
91 this.options.template = this.getFullTemplatePath(this.options.template, compiler.context);
92
93 // Inject child compiler plugin
94 const childCompilerPlugin = new CachedChildCompilation(compiler);
95 if (!this.options.templateContent) {
96 childCompilerPlugin.addEntry(this.options.template);
97 }
98
99 // convert absolute filename into relative so that webpack can
100 // generate it at correct location
101 const filename = this.options.filename;
102 if (path.resolve(filename) === path.normalize(filename)) {
103 this.options.filename = path.relative(compiler.options.output.path, filename);
104 }
105
106 // `contenthash` is introduced in webpack v4.3
107 // which conflicts with the plugin's existing `contenthash` method,
108 // hence it is renamed to `templatehash` to avoid conflicts
109 this.options.filename = this.options.filename.replace(/\[(?:(\w+):)?contenthash(?::([a-z]+\d*))?(?::(\d+))?\]/ig, (match) => {
110 return match.replace('contenthash', 'templatehash');
111 });
112
113 // Check if webpack is running in production mode
114 // @see https://github.com/webpack/webpack/blob/3366421f1784c449f415cda5930a8e445086f688/lib/WebpackOptionsDefaulter.js#L12-L14
115 const isProductionLikeMode = compiler.options.mode === 'production' || !compiler.options.mode;
116
117 const minify = this.options.minify;
118 if (minify === true || (minify === 'auto' && isProductionLikeMode)) {
119 /** @type { import('html-minifier-terser').Options } */
120 this.options.minify = {
121 // https://www.npmjs.com/package/html-minifier-terser#options-quick-reference
122 collapseWhitespace: true,
123 keepClosingSlash: true,
124 removeComments: true,
125 removeRedundantAttributes: true,
126 removeScriptTypeAttributes: true,
127 removeStyleLinkTypeAttributes: true,
128 useShortDoctype: true
129 };
130 }
131
132 compiler.hooks.emit.tapAsync('HtmlWebpackPlugin',
133 /**
134 * Hook into the webpack emit phase
135 * @param {WebpackCompilation} compilation
136 * @param {(err?: Error) => void} callback
137 */
138 (compilation, callback) => {
139 // Get all entry point names for this html file
140 const entryNames = Array.from(compilation.entrypoints.keys());
141 const filteredEntryNames = self.filterChunks(entryNames, self.options.chunks, self.options.excludeChunks);
142 const sortedEntryNames = self.sortEntryChunks(filteredEntryNames, this.options.chunksSortMode, compilation);
143
144 const templateResult = this.options.templateContent
145 ? { mainCompilationHash: compilation.hash }
146 : childCompilerPlugin.getCompilationEntryResult(this.options.template);
147
148 this.childCompilerHash = templateResult.mainCompilationHash;
149
150 if ('error' in templateResult) {
151 compilation.errors.push(prettyError(templateResult.error, compiler.context).toString());
152 }
153
154 const compiledEntries = 'compiledEntry' in templateResult ? {
155 hash: templateResult.compiledEntry.hash,
156 chunk: templateResult.compiledEntry.entry
157 } : {
158 hash: templateResult.mainCompilationHash
159 };
160
161 const childCompilationOutputName = webpackMajorVersion === 4
162 ? compilation.mainTemplate.getAssetPath(this.options.filename, compiledEntries)
163 : compilation.getAssetPath(this.options.filename, compiledEntries);
164
165 // If the child compilation was not executed during a previous main compile run
166 // it is a cached result
167 const isCompilationCached = templateResult.mainCompilationHash !== compilation.hash;
168
169 // Turn the entry point names into file paths
170 const assets = self.htmlWebpackPluginAssets(compilation, childCompilationOutputName, sortedEntryNames);
171
172 // If the template and the assets did not change we don't have to emit the html
173 const assetJson = JSON.stringify(self.getAssetFiles(assets));
174 if (isCompilationCached && self.options.cache && assetJson === self.assetJson) {
175 return callback();
176 } else {
177 self.assetJson = assetJson;
178 }
179
180 // The html-webpack plugin uses a object representation for the html-tags which will be injected
181 // to allow altering them more easily
182 // Just before they are converted a third-party-plugin author might change the order and content
183 const assetsPromise = this.getFaviconPublicPath(this.options.favicon, compilation, assets.publicPath)
184 .then((faviconPath) => {
185 assets.favicon = faviconPath;
186 return getHtmlWebpackPluginHooks(compilation).beforeAssetTagGeneration.promise({
187 assets: assets,
188 outputName: childCompilationOutputName,
189 plugin: self
190 });
191 });
192
193 // Turn the js and css paths into grouped HtmlTagObjects
194 const assetTagGroupsPromise = assetsPromise
195 // And allow third-party-plugin authors to reorder and change the assetTags before they are grouped
196 .then(({ assets }) => getHtmlWebpackPluginHooks(compilation).alterAssetTags.promise({
197 assetTags: {
198 scripts: self.generatedScriptTags(assets.js),
199 styles: self.generateStyleTags(assets.css),
200 meta: [
201 ...self.generateBaseTag(self.options.base),
202 ...self.generatedMetaTags(self.options.meta),
203 ...self.generateFaviconTags(assets.favicon)
204 ]
205 },
206 outputName: childCompilationOutputName,
207 plugin: self
208 }))
209 .then(({ assetTags }) => {
210 // Inject scripts to body unless it set explicitly to head
211 const scriptTarget = self.options.inject === 'head' ? 'head' : 'body';
212 // Group assets to `head` and `body` tag arrays
213 const assetGroups = this.generateAssetGroups(assetTags, scriptTarget);
214 // Allow third-party-plugin authors to reorder and change the assetTags once they are grouped
215 return getHtmlWebpackPluginHooks(compilation).alterAssetTagGroups.promise({
216 headTags: assetGroups.headTags,
217 bodyTags: assetGroups.bodyTags,
218 outputName: childCompilationOutputName,
219 plugin: self
220 });
221 });
222
223 // Turn the compiled template into a nodejs function or into a nodejs string
224 const templateEvaluationPromise = Promise.resolve()
225 .then(() => {
226 if ('error' in templateResult) {
227 return self.options.showErrors ? prettyError(templateResult.error, compiler.context).toHtml() : 'ERROR';
228 }
229 // Allow to use a custom function / string instead
230 if (self.options.templateContent !== false) {
231 return self.options.templateContent;
232 }
233 // Once everything is compiled evaluate the html factory
234 // and replace it with its content
235 return ('compiledEntry' in templateResult)
236 ? self.evaluateCompilationResult(compilation, templateResult.compiledEntry.content)
237 : Promise.reject(new Error('Child compilation contained no compiledEntry'));
238 });
239
240 const templateExectutionPromise = Promise.all([assetsPromise, assetTagGroupsPromise, templateEvaluationPromise])
241 // Execute the template
242 .then(([assetsHookResult, assetTags, compilationResult]) => typeof compilationResult !== 'function'
243 ? compilationResult
244 : self.executeTemplate(compilationResult, assetsHookResult.assets, { headTags: assetTags.headTags, bodyTags: assetTags.bodyTags }, compilation));
245
246 const injectedHtmlPromise = Promise.all([assetTagGroupsPromise, templateExectutionPromise])
247 // Allow plugins to change the html before assets are injected
248 .then(([assetTags, html]) => {
249 const pluginArgs = { html, headTags: assetTags.headTags, bodyTags: assetTags.bodyTags, plugin: self, outputName: childCompilationOutputName };
250 return getHtmlWebpackPluginHooks(compilation).afterTemplateExecution.promise(pluginArgs);
251 })
252 .then(({ html, headTags, bodyTags }) => {
253 return self.postProcessHtml(html, assets, { headTags, bodyTags });
254 });
255
256 const emitHtmlPromise = injectedHtmlPromise
257 // Allow plugins to change the html after assets are injected
258 .then((html) => {
259 const pluginArgs = { html, plugin: self, outputName: childCompilationOutputName };
260 return getHtmlWebpackPluginHooks(compilation).beforeEmit.promise(pluginArgs)
261 .then(result => result.html);
262 })
263 .catch(err => {
264 // In case anything went wrong the promise is resolved
265 // with the error message and an error is logged
266 compilation.errors.push(prettyError(err, compiler.context).toString());
267 // Prevent caching
268 self.hash = null;
269 return self.options.showErrors ? prettyError(err, compiler.context).toHtml() : 'ERROR';
270 })
271 .then(html => {
272 // Allow to use [templatehash] as placeholder for the html-webpack-plugin name
273 // See also https://survivejs.com/webpack/optimizing/adding-hashes-to-filenames/
274 // From https://github.com/webpack-contrib/extract-text-webpack-plugin/blob/8de6558e33487e7606e7cd7cb2adc2cccafef272/src/index.js#L212-L214
275 const finalOutputName = childCompilationOutputName.replace(/\[(?:(\w+):)?templatehash(?::([a-z]+\d*))?(?::(\d+))?\]/ig, (_, hashType, digestType, maxLength) => {
276 return loaderUtils.getHashDigest(Buffer.from(html, 'utf8'), hashType, digestType, parseInt(maxLength, 10));
277 });
278 // Add the evaluated html code to the webpack assets
279 compilation.assets[finalOutputName] = {
280 source: () => html,
281 size: () => html.length
282 };
283 return finalOutputName;
284 })
285 .then((finalOutputName) => getHtmlWebpackPluginHooks(compilation).afterEmit.promise({
286 outputName: finalOutputName,
287 plugin: self
288 }).catch(err => {
289 console.error(err);
290 return null;
291 }).then(() => null));
292
293 // Once all files are added to the webpack compilation
294 // let the webpack compiler continue
295 emitHtmlPromise.then(() => {
296 callback();
297 });
298 });
299 }
300
301 /**
302 * Evaluates the child compilation result
303 * @param {WebpackCompilation} compilation
304 * @param {string} source
305 * @returns {Promise<string | (() => string | Promise<string>)>}
306 */
307 evaluateCompilationResult (compilation, source) {
308 if (!source) {
309 return Promise.reject(new Error('The child compilation didn\'t provide a result'));
310 }
311 // The LibraryTemplatePlugin stores the template result in a local variable.
312 // To extract the result during the evaluation this part has to be removed.
313 source = source.replace('var HTML_WEBPACK_PLUGIN_RESULT =', '');
314 const template = this.options.template.replace(/^.+!/, '').replace(/\?.+$/, '');
315 const vmContext = vm.createContext(_.extend({ HTML_WEBPACK_PLUGIN: true, require: require, console: console }, global));
316 const vmScript = new vm.Script(source, { filename: template });
317 // Evaluate code and cast to string
318 let newSource;
319 try {
320 newSource = vmScript.runInContext(vmContext);
321 } catch (e) {
322 return Promise.reject(e);
323 }
324 if (typeof newSource === 'object' && newSource.__esModule && newSource.default) {
325 newSource = newSource.default;
326 }
327 return typeof newSource === 'string' || typeof newSource === 'function'
328 ? Promise.resolve(newSource)
329 : Promise.reject(new Error('The loader "' + this.options.template + '" didn\'t return html.'));
330 }
331
332 /**
333 * Generate the template parameters for the template function
334 * @param {WebpackCompilation} compilation
335 * @param {{
336 publicPath: string,
337 js: Array<string>,
338 css: Array<string>,
339 manifest?: string,
340 favicon?: string
341 }} assets
342 * @param {{
343 headTags: HtmlTagObject[],
344 bodyTags: HtmlTagObject[]
345 }} assetTags
346 * @returns {Promise<{[key: any]: any}>}
347 */
348 getTemplateParameters (compilation, assets, assetTags) {
349 const templateParameters = this.options.templateParameters;
350 if (templateParameters === false) {
351 return Promise.resolve({});
352 }
353 if (typeof templateParameters !== 'function' && typeof templateParameters !== 'object') {
354 throw new Error('templateParameters has to be either a function or an object');
355 }
356 const templateParameterFunction = typeof templateParameters === 'function'
357 // A custom function can overwrite the entire template parameter preparation
358 ? templateParameters
359 // If the template parameters is an object merge it with the default values
360 : (compilation, assets, assetTags, options) => Object.assign({},
361 templateParametersGenerator(compilation, assets, assetTags, options),
362 templateParameters
363 );
364 const preparedAssetTags = {
365 headTags: this.prepareAssetTagGroupForRendering(assetTags.headTags),
366 bodyTags: this.prepareAssetTagGroupForRendering(assetTags.bodyTags)
367 };
368 return Promise
369 .resolve()
370 .then(() => templateParameterFunction(compilation, assets, preparedAssetTags, this.options));
371 }
372
373 /**
374 * This function renders the actual html by executing the template function
375 *
376 * @param {(templateParameters) => string | Promise<string>} templateFunction
377 * @param {{
378 publicPath: string,
379 js: Array<string>,
380 css: Array<string>,
381 manifest?: string,
382 favicon?: string
383 }} assets
384 * @param {{
385 headTags: HtmlTagObject[],
386 bodyTags: HtmlTagObject[]
387 }} assetTags
388 * @param {WebpackCompilation} compilation
389 *
390 * @returns Promise<string>
391 */
392 executeTemplate (templateFunction, assets, assetTags, compilation) {
393 // Template processing
394 const templateParamsPromise = this.getTemplateParameters(compilation, assets, assetTags);
395 return templateParamsPromise.then((templateParams) => {
396 try {
397 // If html is a promise return the promise
398 // If html is a string turn it into a promise
399 return templateFunction(templateParams);
400 } catch (e) {
401 compilation.errors.push(new Error('Template execution failed: ' + e));
402 return Promise.reject(e);
403 }
404 });
405 }
406
407 /**
408 * Html Post processing
409 *
410 * @param {any} html
411 * The input html
412 * @param {any} assets
413 * @param {{
414 headTags: HtmlTagObject[],
415 bodyTags: HtmlTagObject[]
416 }} assetTags
417 * The asset tags to inject
418 *
419 * @returns {Promise<string>}
420 */
421 postProcessHtml (html, assets, assetTags) {
422 if (typeof html !== 'string') {
423 return Promise.reject(new Error('Expected html to be a string but got ' + JSON.stringify(html)));
424 }
425 const htmlAfterInjection = this.options.inject
426 ? this.injectAssetsIntoHtml(html, assets, assetTags)
427 : html;
428 const htmlAfterMinification = this.minifyHtml(htmlAfterInjection);
429 return Promise.resolve(htmlAfterMinification);
430 }
431
432 /*
433 * Pushes the content of the given filename to the compilation assets
434 * @param {string} filename
435 * @param {WebpackCompilation} compilation
436 *
437 * @returns {string} file basename
438 */
439 addFileToAssets (filename, compilation) {
440 filename = path.resolve(compilation.compiler.context, filename);
441 return Promise.all([
442 fsStatAsync(filename),
443 fsReadFileAsync(filename)
444 ])
445 .then(([size, source]) => {
446 return {
447 size,
448 source
449 };
450 })
451 .catch(() => Promise.reject(new Error('HtmlWebpackPlugin: could not load file ' + filename)))
452 .then(results => {
453 const basename = path.basename(filename);
454 compilation.fileDependencies.add(filename);
455 compilation.assets[basename] = {
456 source: () => results.source,
457 size: () => results.size.size
458 };
459 return basename;
460 });
461 }
462
463 /**
464 * Helper to sort chunks
465 * @param {string[]} entryNames
466 * @param {string|((entryNameA: string, entryNameB: string) => number)} sortMode
467 * @param {WebpackCompilation} compilation
468 */
469 sortEntryChunks (entryNames, sortMode, compilation) {
470 // Custom function
471 if (typeof sortMode === 'function') {
472 return entryNames.sort(sortMode);
473 }
474 // Check if the given sort mode is a valid chunkSorter sort mode
475 if (typeof chunkSorter[sortMode] !== 'undefined') {
476 return chunkSorter[sortMode](entryNames, compilation, this.options);
477 }
478 throw new Error('"' + sortMode + '" is not a valid chunk sort mode');
479 }
480
481 /**
482 * Return all chunks from the compilation result which match the exclude and include filters
483 * @param {any} chunks
484 * @param {string[]|'all'} includedChunks
485 * @param {string[]} excludedChunks
486 */
487 filterChunks (chunks, includedChunks, excludedChunks) {
488 return chunks.filter(chunkName => {
489 // Skip if the chunks should be filtered and the given chunk was not added explicity
490 if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
491 return false;
492 }
493 // Skip if the chunks should be filtered and the given chunk was excluded explicity
494 if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
495 return false;
496 }
497 // Add otherwise
498 return true;
499 });
500 }
501
502 /**
503 * Check if the given asset object consists only of hot-update.js files
504 *
505 * @param {{
506 publicPath: string,
507 js: Array<string>,
508 css: Array<string>,
509 manifest?: string,
510 favicon?: string
511 }} assets
512 */
513 isHotUpdateCompilation (assets) {
514 return assets.js.length && assets.js.every((assetPath) => /\.hot-update\.js$/.test(assetPath));
515 }
516
517 /**
518 * The htmlWebpackPluginAssets extracts the asset information of a webpack compilation
519 * for all given entry names
520 * @param {WebpackCompilation} compilation
521 * @param {string[]} entryNames
522 * @returns {{
523 publicPath: string,
524 js: Array<string>,
525 css: Array<string>,
526 manifest?: string,
527 favicon?: string
528 }}
529 */
530 htmlWebpackPluginAssets (compilation, childCompilationOutputName, entryNames) {
531 const compilationHash = compilation.hash;
532
533 /**
534 * @type {string} the configured public path to the asset root
535 * if a path publicPath is set in the current webpack config use it otherwise
536 * fallback to a relative path
537 */
538 const webpackPublicPath = webpackMajorVersion === 4
539 ? compilation.mainTemplate.getPublicPath({ hash: compilationHash })
540 : compilation.getAssetPath(compilation.outputOptions.publicPath, { hash: compilationHash });
541
542 const isPublicPathDefined = webpackPublicPath.trim() !== '';
543 let publicPath = isPublicPathDefined
544 // If a hard coded public path exists use it
545 ? webpackPublicPath
546 // If no public path was set get a relative url path
547 : path.relative(path.resolve(compilation.options.output.path, path.dirname(childCompilationOutputName)), compilation.options.output.path)
548 .split(path.sep).join('/');
549
550 if (publicPath.length && publicPath.substr(-1, 1) !== '/') {
551 publicPath += '/';
552 }
553
554 /**
555 * @type {{
556 publicPath: string,
557 js: Array<string>,
558 css: Array<string>,
559 manifest?: string,
560 favicon?: string
561 }}
562 */
563 const assets = {
564 // The public path
565 publicPath: publicPath,
566 // Will contain all js and mjs files
567 js: [],
568 // Will contain all css files
569 css: [],
570 // Will contain the html5 appcache manifest files if it exists
571 manifest: Object.keys(compilation.assets).find(assetFile => path.extname(assetFile) === '.appcache'),
572 // Favicon
573 favicon: undefined
574 };
575
576 // Append a hash for cache busting
577 if (this.options.hash && assets.manifest) {
578 assets.manifest = this.appendHash(assets.manifest, compilationHash);
579 }
580
581 // Extract paths to .js, .mjs and .css files from the current compilation
582 const entryPointPublicPathMap = {};
583 const extensionRegexp = /\.(css|js|mjs)(\?|$)/;
584 for (let i = 0; i < entryNames.length; i++) {
585 const entryName = entryNames[i];
586 /** entryPointUnfilteredFiles - also includes hot module update files */
587 const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName).getFiles();
588
589 const entryPointFiles = entryPointUnfilteredFiles.filter((chunkFile) => {
590 // compilation.getAsset was introduced in webpack 4.4.0
591 // once the support pre webpack 4.4.0 is dropped please
592 // remove the following guard:
593 const asset = compilation.getAsset && compilation.getAsset(chunkFile);
594 if (!asset) {
595 return true;
596 }
597 // Prevent hot-module files from being included:
598 const assetMetaInformation = asset.info || {};
599 return !(assetMetaInformation.hotModuleReplacement || assetMetaInformation.development);
600 });
601
602 // Prepend the publicPath and append the hash depending on the
603 // webpack.output.publicPath and hashOptions
604 // E.g. bundle.js -> /bundle.js?hash
605 const entryPointPublicPaths = entryPointFiles
606 .map(chunkFile => {
607 const entryPointPublicPath = publicPath + this.urlencodePath(chunkFile);
608 return this.options.hash
609 ? this.appendHash(entryPointPublicPath, compilationHash)
610 : entryPointPublicPath;
611 });
612
613 entryPointPublicPaths.forEach((entryPointPublicPath) => {
614 const extMatch = extensionRegexp.exec(entryPointPublicPath);
615 // Skip if the public path is not a .css, .mjs or .js file
616 if (!extMatch) {
617 return;
618 }
619 // Skip if this file is already known
620 // (e.g. because of common chunk optimizations)
621 if (entryPointPublicPathMap[entryPointPublicPath]) {
622 return;
623 }
624 entryPointPublicPathMap[entryPointPublicPath] = true;
625 // ext will contain .js or .css, because .mjs recognizes as .js
626 const ext = extMatch[1] === 'mjs' ? 'js' : extMatch[1];
627 assets[ext].push(entryPointPublicPath);
628 });
629 }
630 return assets;
631 }
632
633 /**
634 * Converts a favicon file from disk to a webpack resource
635 * and returns the url to the resource
636 *
637 * @param {string|false} faviconFilePath
638 * @param {WebpackCompilation} compilation
639 * @param {string} publicPath
640 * @returns {Promise<string|undefined>}
641 */
642 getFaviconPublicPath (faviconFilePath, compilation, publicPath) {
643 if (!faviconFilePath) {
644 return Promise.resolve(undefined);
645 }
646 return this.addFileToAssets(faviconFilePath, compilation)
647 .then((faviconName) => {
648 const faviconPath = publicPath + faviconName;
649 if (this.options.hash) {
650 return this.appendHash(faviconPath, compilation.hash);
651 }
652 return faviconPath;
653 });
654 }
655
656 /**
657 * Generate meta tags
658 * @returns {HtmlTagObject[]}
659 */
660 getMetaTags () {
661 const metaOptions = this.options.meta;
662 if (metaOptions === false) {
663 return [];
664 }
665 // Make tags self-closing in case of xhtml
666 // Turn { "viewport" : "width=500, initial-scale=1" } into
667 // [{ name:"viewport" content:"width=500, initial-scale=1" }]
668 const metaTagAttributeObjects = Object.keys(metaOptions)
669 .map((metaName) => {
670 const metaTagContent = metaOptions[metaName];
671 return (typeof metaTagContent === 'string') ? {
672 name: metaName,
673 content: metaTagContent
674 } : metaTagContent;
675 })
676 .filter((attribute) => attribute !== false);
677 // Turn [{ name:"viewport" content:"width=500, initial-scale=1" }] into
678 // the html-webpack-plugin tag structure
679 return metaTagAttributeObjects.map((metaTagAttributes) => {
680 if (metaTagAttributes === false) {
681 throw new Error('Invalid meta tag');
682 }
683 return {
684 tagName: 'meta',
685 voidTag: true,
686 attributes: metaTagAttributes
687 };
688 });
689 }
690
691 /**
692 * Generate all tags script for the given file paths
693 * @param {Array<string>} jsAssets
694 * @returns {Array<HtmlTagObject>}
695 */
696 generatedScriptTags (jsAssets) {
697 return jsAssets.map(scriptAsset => ({
698 tagName: 'script',
699 voidTag: false,
700 attributes: {
701 defer: this.options.scriptLoading !== 'blocking',
702 src: scriptAsset
703 }
704 }));
705 }
706
707 /**
708 * Generate all style tags for the given file paths
709 * @param {Array<string>} cssAssets
710 * @returns {Array<HtmlTagObject>}
711 */
712 generateStyleTags (cssAssets) {
713 return cssAssets.map(styleAsset => ({
714 tagName: 'link',
715 voidTag: true,
716 attributes: {
717 href: styleAsset,
718 rel: 'stylesheet'
719 }
720 }));
721 }
722
723 /**
724 * Generate an optional base tag
725 * @param { false
726 | string
727 | {[attributeName: string]: string} // attributes e.g. { href:"http://example.com/page.html" target:"_blank" }
728 } baseOption
729 * @returns {Array<HtmlTagObject>}
730 */
731 generateBaseTag (baseOption) {
732 if (baseOption === false) {
733 return [];
734 } else {
735 return [{
736 tagName: 'base',
737 voidTag: true,
738 attributes: (typeof baseOption === 'string') ? {
739 href: baseOption
740 } : baseOption
741 }];
742 }
743 }
744
745 /**
746 * Generate all meta tags for the given meta configuration
747 * @param {false | {
748 [name: string]:
749 false // disabled
750 | string // name content pair e.g. {viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no'}`
751 | {[attributeName: string]: string|boolean} // custom properties e.g. { name:"viewport" content:"width=500, initial-scale=1" }
752 }} metaOptions
753 * @returns {Array<HtmlTagObject>}
754 */
755 generatedMetaTags (metaOptions) {
756 if (metaOptions === false) {
757 return [];
758 }
759 // Make tags self-closing in case of xhtml
760 // Turn { "viewport" : "width=500, initial-scale=1" } into
761 // [{ name:"viewport" content:"width=500, initial-scale=1" }]
762 const metaTagAttributeObjects = Object.keys(metaOptions)
763 .map((metaName) => {
764 const metaTagContent = metaOptions[metaName];
765 return (typeof metaTagContent === 'string') ? {
766 name: metaName,
767 content: metaTagContent
768 } : metaTagContent;
769 })
770 .filter((attribute) => attribute !== false);
771 // Turn [{ name:"viewport" content:"width=500, initial-scale=1" }] into
772 // the html-webpack-plugin tag structure
773 return metaTagAttributeObjects.map((metaTagAttributes) => {
774 if (metaTagAttributes === false) {
775 throw new Error('Invalid meta tag');
776 }
777 return {
778 tagName: 'meta',
779 voidTag: true,
780 attributes: metaTagAttributes
781 };
782 });
783 }
784
785 /**
786 * Generate a favicon tag for the given file path
787 * @param {string| undefined} faviconPath
788 * @returns {Array<HtmlTagObject>}
789 */
790 generateFaviconTags (faviconPath) {
791 if (!faviconPath) {
792 return [];
793 }
794 return [{
795 tagName: 'link',
796 voidTag: true,
797 attributes: {
798 rel: 'icon',
799 href: faviconPath
800 }
801 }];
802 }
803
804 /**
805 * Group assets to head and bottom tags
806 *
807 * @param {{
808 scripts: Array<HtmlTagObject>;
809 styles: Array<HtmlTagObject>;
810 meta: Array<HtmlTagObject>;
811 }} assetTags
812 * @param {"body" | "head"} scriptTarget
813 * @returns {{
814 headTags: Array<HtmlTagObject>;
815 bodyTags: Array<HtmlTagObject>;
816 }}
817 */
818 generateAssetGroups (assetTags, scriptTarget) {
819 /** @type {{ headTags: Array<HtmlTagObject>; bodyTags: Array<HtmlTagObject>; }} */
820 const result = {
821 headTags: [
822 ...assetTags.meta,
823 ...assetTags.styles
824 ],
825 bodyTags: []
826 };
827 // Add script tags to head or body depending on
828 // the htmlPluginOptions
829 if (scriptTarget === 'body') {
830 result.bodyTags.push(...assetTags.scripts);
831 } else {
832 // If script loading is blocking add the scripts to the end of the head
833 // If script loading is non-blocking add the scripts infront of the css files
834 const insertPosition = this.options.scriptLoading === 'blocking' ? result.headTags.length : assetTags.meta.length;
835 result.headTags.splice(insertPosition, 0, ...assetTags.scripts);
836 }
837 return result;
838 }
839
840 /**
841 * Add toString methods for easier rendering
842 * inside the template
843 *
844 * @param {Array<HtmlTagObject>} assetTagGroup
845 * @returns {Array<HtmlTagObject>}
846 */
847 prepareAssetTagGroupForRendering (assetTagGroup) {
848 const xhtml = this.options.xhtml;
849 return HtmlTagArray.from(assetTagGroup.map((assetTag) => {
850 const copiedAssetTag = Object.assign({}, assetTag);
851 copiedAssetTag.toString = function () {
852 return htmlTagObjectToString(this, xhtml);
853 };
854 return copiedAssetTag;
855 }));
856 }
857
858 /**
859 * Injects the assets into the given html string
860 *
861 * @param {string} html
862 * The input html
863 * @param {any} assets
864 * @param {{
865 headTags: HtmlTagObject[],
866 bodyTags: HtmlTagObject[]
867 }} assetTags
868 * The asset tags to inject
869 *
870 * @returns {string}
871 */
872 injectAssetsIntoHtml (html, assets, assetTags) {
873 const htmlRegExp = /(<html[^>]*>)/i;
874 const headRegExp = /(<\/head\s*>)/i;
875 const bodyRegExp = /(<\/body\s*>)/i;
876 const body = assetTags.bodyTags.map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml));
877 const head = assetTags.headTags.map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml));
878
879 if (body.length) {
880 if (bodyRegExp.test(html)) {
881 // Append assets to body element
882 html = html.replace(bodyRegExp, match => body.join('') + match);
883 } else {
884 // Append scripts to the end of the file if no <body> element exists:
885 html += body.join('');
886 }
887 }
888
889 if (head.length) {
890 // Create a head tag if none exists
891 if (!headRegExp.test(html)) {
892 if (!htmlRegExp.test(html)) {
893 html = '<head></head>' + html;
894 } else {
895 html = html.replace(htmlRegExp, match => match + '<head></head>');
896 }
897 }
898
899 // Append assets to head element
900 html = html.replace(headRegExp, match => head.join('') + match);
901 }
902
903 // Inject manifest into the opening html tag
904 if (assets.manifest) {
905 html = html.replace(/(<html[^>]*)(>)/i, (match, start, end) => {
906 // Append the manifest only if no manifest was specified
907 if (/\smanifest\s*=/.test(match)) {
908 return match;
909 }
910 return start + ' manifest="' + assets.manifest + '"' + end;
911 });
912 }
913 return html;
914 }
915
916 /**
917 * Appends a cache busting hash to the query string of the url
918 * E.g. http://localhost:8080/ -> http://localhost:8080/?50c9096ba6183fd728eeb065a26ec175
919 * @param {string} url
920 * @param {string} hash
921 */
922 appendHash (url, hash) {
923 if (!url) {
924 return url;
925 }
926 return url + (url.indexOf('?') === -1 ? '?' : '&') + hash;
927 }
928
929 /**
930 * Encode each path component using `encodeURIComponent` as files can contain characters
931 * which needs special encoding in URLs like `+ `.
932 *
933 * Valid filesystem characters which need to be encoded for urls:
934 *
935 * # pound, % percent, & ampersand, { left curly bracket, } right curly bracket,
936 * \ back slash, < left angle bracket, > right angle bracket, * asterisk, ? question mark,
937 * blank spaces, $ dollar sign, ! exclamation point, ' single quotes, " double quotes,
938 * : colon, @ at sign, + plus sign, ` backtick, | pipe, = equal sign
939 *
940 * However the query string must not be encoded:
941 *
942 * fo:demonstration-path/very fancy+name.js?path=/home?value=abc&value=def#zzz
943 * ^ ^ ^ ^ ^ ^ ^ ^^ ^ ^ ^ ^ ^
944 * | | | | | | | || | | | | |
945 * encoded | | encoded | | || | | | | |
946 * ignored ignored ignored ignored ignored
947 *
948 * @param {string} filePath
949 */
950 urlencodePath (filePath) {
951 // People use the filepath in quite unexpected ways.
952 // Try to extract the first querystring of the url:
953 //
954 // some+path/demo.html?value=abc?def
955 //
956 const queryStringStart = filePath.indexOf('?');
957 const urlPath = queryStringStart === -1 ? filePath : filePath.substr(0, queryStringStart);
958 const queryString = filePath.substr(urlPath.length);
959 // Encode all parts except '/' which are not part of the querystring:
960 const encodedUrlPath = urlPath.split('/').map(encodeURIComponent).join('/');
961 return encodedUrlPath + queryString;
962 }
963
964 /**
965 * Helper to return the absolute template path with a fallback loader
966 * @param {string} template
967 * The path to the template e.g. './index.html'
968 * @param {string} context
969 * The webpack base resolution path for relative paths e.g. process.cwd()
970 */
971 getFullTemplatePath (template, context) {
972 if (template === 'auto') {
973 template = path.resolve(context, 'src/index.ejs');
974 if (!fs.existsSync(template)) {
975 template = path.join(__dirname, 'default_index.ejs');
976 }
977 }
978 // If the template doesn't use a loader use the lodash template loader
979 if (template.indexOf('!') === -1) {
980 template = require.resolve('./lib/loader.js') + '!' + path.resolve(context, template);
981 }
982 // Resolve template path
983 return template.replace(
984 /([!])([^/\\][^!?]+|[^/\\!?])($|\?[^!?\n]+$)/,
985 (match, prefix, filepath, postfix) => prefix + path.resolve(filepath) + postfix);
986 }
987
988 /**
989 * Minify the given string using html-minifier-terser
990 *
991 * As this is a breaking change to html-webpack-plugin 3.x
992 * provide an extended error message to explain how to get back
993 * to the old behaviour
994 *
995 * @param {string} html
996 */
997 minifyHtml (html) {
998 if (typeof this.options.minify !== 'object') {
999 return html;
1000 }
1001 try {
1002 return require('html-minifier-terser').minify(html, this.options.minify);
1003 } catch (e) {
1004 const isParseError = String(e.message).indexOf('Parse Error') === 0;
1005 if (isParseError) {
1006 e.message = 'html-webpack-plugin could not minify the generated output.\n' +
1007 'In production mode the html minifcation is enabled by default.\n' +
1008 'If you are not generating a valid html output please disable it manually.\n' +
1009 'You can do so by adding the following setting to your HtmlWebpackPlugin config:\n|\n|' +
1010 ' minify: false\n|\n' +
1011 'See https://github.com/jantimon/html-webpack-plugin#options for details.\n\n' +
1012 'For parser dedicated bugs please create an issue here:\n' +
1013 'https://danielruf.github.io/html-minifier-terser/' +
1014 '\n' + e.message;
1015 }
1016 throw e;
1017 }
1018 }
1019
1020 /**
1021 * Helper to return a sorted unique array of all asset files out of the
1022 * asset object
1023 */
1024 getAssetFiles (assets) {
1025 const files = _.uniq(Object.keys(assets).filter(assetType => assetType !== 'chunks' && assets[assetType]).reduce((files, assetType) => files.concat(assets[assetType]), []));
1026 files.sort();
1027 return files;
1028 }
1029}
1030
1031/**
1032 * The default for options.templateParameter
1033 * Generate the template parameters
1034 *
1035 * Generate the template parameters for the template function
1036 * @param {WebpackCompilation} compilation
1037 * @param {{
1038 publicPath: string,
1039 js: Array<string>,
1040 css: Array<string>,
1041 manifest?: string,
1042 favicon?: string
1043 }} assets
1044 * @param {{
1045 headTags: HtmlTagObject[],
1046 bodyTags: HtmlTagObject[]
1047 }} assetTags
1048 * @param {ProcessedHtmlWebpackOptions} options
1049 * @returns {TemplateParameter}
1050 */
1051function templateParametersGenerator (compilation, assets, assetTags, options) {
1052 return {
1053 compilation: compilation,
1054 webpackConfig: compilation.options,
1055 htmlWebpackPlugin: {
1056 tags: assetTags,
1057 files: assets,
1058 options: options
1059 }
1060 };
1061}
1062
1063// Statics:
1064/**
1065 * The major version number of this plugin
1066 */
1067HtmlWebpackPlugin.version = 4;
1068
1069/**
1070 * A static helper to get the hooks for this plugin
1071 *
1072 * Usage: HtmlWebpackPlugin.getHooks(compilation).HOOK_NAME.tapAsync('YourPluginName', () => { ... });
1073 */
1074HtmlWebpackPlugin.getHooks = getHtmlWebpackPluginHooks;
1075HtmlWebpackPlugin.createHtmlTagObject = createHtmlTagObject;
1076
1077module.exports = HtmlWebpackPlugin;