UNPKG

24.9 kBJavaScriptView Raw
1'use strict';
2
3// use Polyfill for util.promisify in node versions < v8
4const promisify = require('util.promisify');
5
6const vm = require('vm');
7const fs = require('fs');
8const _ = require('lodash');
9const path = require('path');
10const childCompiler = require('./lib/compiler.js');
11const prettyError = require('./lib/errors.js');
12const chunkSorter = require('./lib/chunksorter.js');
13
14const fsStatAsync = promisify(fs.stat);
15const fsReadFileAsync = promisify(fs.readFile);
16
17class HtmlWebpackPlugin {
18 constructor (options) {
19 // Default options
20 this.options = _.extend({
21 template: path.join(__dirname, 'default_index.ejs'),
22 filename: 'index.html',
23 hash: false,
24 inject: true,
25 compile: true,
26 favicon: false,
27 minify: false,
28 cache: true,
29 showErrors: true,
30 chunks: 'all',
31 excludeChunks: [],
32 title: 'Webpack App',
33 xhtml: false
34 }, options);
35 }
36
37 apply (compiler) {
38 const self = this;
39 let isCompilationCached = false;
40 let compilationPromise;
41
42 this.options.template = this.getFullTemplatePath(this.options.template, compiler.context);
43
44 // convert absolute filename into relative so that webpack can
45 // generate it at correct location
46 const filename = this.options.filename;
47 if (path.resolve(filename) === path.normalize(filename)) {
48 this.options.filename = path.relative(compiler.options.output.path, filename);
49 }
50
51 // setup hooks for webpack 4
52 if (compiler.hooks) {
53 compiler.hooks.compilation.tap('HtmlWebpackPluginHooks', compilation => {
54 const SyncWaterfallHook = require('tapable').SyncWaterfallHook;
55 const AsyncSeriesWaterfallHook = require('tapable').AsyncSeriesWaterfallHook;
56 compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook(['chunks', 'objectWithPluginRef']);
57 compilation.hooks.htmlWebpackPluginBeforeHtmlGeneration = new AsyncSeriesWaterfallHook(['pluginArgs']);
58 compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing = new AsyncSeriesWaterfallHook(['pluginArgs']);
59 compilation.hooks.htmlWebpackPluginAlterAssetTags = new AsyncSeriesWaterfallHook(['pluginArgs']);
60 compilation.hooks.htmlWebpackPluginAfterHtmlProcessing = new AsyncSeriesWaterfallHook(['pluginArgs']);
61 compilation.hooks.htmlWebpackPluginAfterEmit = new AsyncSeriesWaterfallHook(['pluginArgs']);
62 });
63 }
64
65 // Backwards compatible version of: compiler.hooks.make.tapAsync()
66 (compiler.hooks ? compiler.hooks.make.tapAsync.bind(compiler.hooks.make, 'HtmlWebpackPlugin') : compiler.plugin.bind(compiler, 'make'))((compilation, callback) => {
67 // Compile the template (queued)
68 compilationPromise = childCompiler.compileTemplate(self.options.template, compiler.context, self.options.filename, compilation)
69 .catch(err => {
70 compilation.errors.push(prettyError(err, compiler.context).toString());
71 return {
72 content: self.options.showErrors ? prettyError(err, compiler.context).toJsonHtml() : 'ERROR',
73 outputName: self.options.filename
74 };
75 })
76 .then(compilationResult => {
77 // If the compilation change didnt change the cache is valid
78 isCompilationCached = compilationResult.hash && self.childCompilerHash === compilationResult.hash;
79 self.childCompilerHash = compilationResult.hash;
80 self.childCompilationOutputName = compilationResult.outputName;
81 callback();
82 return compilationResult.content;
83 });
84 });
85
86 // Backwards compatible version of: compiler.plugin.emit.tapAsync()
87 (compiler.hooks ? compiler.hooks.emit.tapAsync.bind(compiler.hooks.emit, 'HtmlWebpackPlugin') : compiler.plugin.bind(compiler, 'emit'))((compilation, callback) => {
88 const applyPluginsAsyncWaterfall = self.applyPluginsAsyncWaterfall(compilation);
89 // Get chunks info as json
90 // Note: we're excluding stuff that we don't need to improve toJson serialization speed.
91 const chunkOnlyConfig = {
92 assets: false,
93 cached: false,
94 children: false,
95 chunks: true,
96 chunkModules: false,
97 chunkOrigins: false,
98 errorDetails: false,
99 hash: false,
100 modules: false,
101 reasons: false,
102 source: false,
103 timings: false,
104 version: false
105 };
106 const allChunks = compilation.getStats().toJson(chunkOnlyConfig).chunks;
107 // Filter chunks (options.chunks and options.excludeCHunks)
108 let chunks = self.filterChunks(allChunks, self.options.chunks, self.options.excludeChunks);
109 // Sort chunks
110 chunks = self.sortChunks(chunks, self.options.chunksSortMode, compilation.chunkGroups);
111 // Let plugins alter the chunks and the chunk sorting
112 if (compilation.hooks) {
113 chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, { plugin: self });
114 } else {
115 // Before Webpack 4
116 chunks = compilation.applyPluginsWaterfall('html-webpack-plugin-alter-chunks', chunks, { plugin: self });
117 }
118 // Get assets
119 const assets = self.htmlWebpackPluginAssets(compilation, chunks);
120 // If this is a hot update compilation, move on!
121 // This solves a problem where an `index.html` file is generated for hot-update js files
122 // It only happens in Webpack 2, where hot updates are emitted separately before the full bundle
123 if (self.isHotUpdateCompilation(assets)) {
124 return callback();
125 }
126
127 // If the template and the assets did not change we don't have to emit the html
128 const assetJson = JSON.stringify(self.getAssetFiles(assets));
129 if (isCompilationCached && self.options.cache && assetJson === self.assetJson) {
130 return callback();
131 } else {
132 self.assetJson = assetJson;
133 }
134
135 Promise.resolve()
136 // Favicon
137 .then(() => {
138 if (self.options.favicon) {
139 return self.addFileToAssets(self.options.favicon, compilation)
140 .then(faviconBasename => {
141 let publicPath = compilation.mainTemplate.getPublicPath({hash: compilation.hash}) || '';
142 if (publicPath && publicPath.substr(-1) !== '/') {
143 publicPath += '/';
144 }
145 assets.favicon = publicPath + faviconBasename;
146 });
147 }
148 })
149 // Wait for the compilation to finish
150 .then(() => compilationPromise)
151 .then(compiledTemplate => {
152 // Allow to use a custom function / string instead
153 if (self.options.templateContent !== undefined) {
154 return self.options.templateContent;
155 }
156 // Once everything is compiled evaluate the html factory
157 // and replace it with its content
158 return self.evaluateCompilationResult(compilation, compiledTemplate);
159 })
160 // Allow plugins to make changes to the assets before invoking the template
161 // This only makes sense to use if `inject` is `false`
162 .then(compilationResult => applyPluginsAsyncWaterfall('html-webpack-plugin-before-html-generation', false, {
163 assets: assets,
164 outputName: self.childCompilationOutputName,
165 plugin: self
166 })
167 .then(() => compilationResult))
168 // Execute the template
169 .then(compilationResult => typeof compilationResult !== 'function'
170 ? compilationResult
171 : self.executeTemplate(compilationResult, chunks, assets, compilation))
172 // Allow plugins to change the html before assets are injected
173 .then(html => {
174 const pluginArgs = {html: html, assets: assets, plugin: self, outputName: self.childCompilationOutputName};
175 return applyPluginsAsyncWaterfall('html-webpack-plugin-before-html-processing', true, pluginArgs);
176 })
177 .then(result => {
178 const html = result.html;
179 const assets = result.assets;
180 // Prepare script and link tags
181 const assetTags = self.generateAssetTags(assets);
182 const pluginArgs = {head: assetTags.head, body: assetTags.body, plugin: self, chunks: chunks, outputName: self.childCompilationOutputName};
183 // Allow plugins to change the assetTag definitions
184 return applyPluginsAsyncWaterfall('html-webpack-plugin-alter-asset-tags', true, pluginArgs)
185 .then(result => self.postProcessHtml(html, assets, { body: result.body, head: result.head })
186 .then(html => _.extend(result, {html: html, assets: assets})));
187 })
188 // Allow plugins to change the html after assets are injected
189 .then(result => {
190 const html = result.html;
191 const assets = result.assets;
192 const pluginArgs = {html: html, assets: assets, plugin: self, outputName: self.childCompilationOutputName};
193 return applyPluginsAsyncWaterfall('html-webpack-plugin-after-html-processing', true, pluginArgs)
194 .then(result => result.html);
195 })
196 .catch(err => {
197 // In case anything went wrong the promise is resolved
198 // with the error message and an error is logged
199 compilation.errors.push(prettyError(err, compiler.context).toString());
200 // Prevent caching
201 self.hash = null;
202 return self.options.showErrors ? prettyError(err, compiler.context).toHtml() : 'ERROR';
203 })
204 .then(html => {
205 // Replace the compilation result with the evaluated html code
206 compilation.assets[self.childCompilationOutputName] = {
207 source: () => html,
208 size: () => html.length
209 };
210 })
211 .then(() => applyPluginsAsyncWaterfall('html-webpack-plugin-after-emit', false, {
212 html: compilation.assets[self.childCompilationOutputName],
213 outputName: self.childCompilationOutputName,
214 plugin: self
215 }).catch(err => {
216 console.error(err);
217 return null;
218 }).then(() => null))
219 // Let webpack continue with it
220 .then(() => {
221 callback();
222 });
223 });
224 }
225
226 /**
227 * Evaluates the child compilation result
228 * Returns a promise
229 */
230 evaluateCompilationResult (compilation, source) {
231 if (!source) {
232 return Promise.reject('The child compilation didn\'t provide a result');
233 }
234
235 // The LibraryTemplatePlugin stores the template result in a local variable.
236 // To extract the result during the evaluation this part has to be removed.
237 source = source.replace('var HTML_WEBPACK_PLUGIN_RESULT =', '');
238 const template = this.options.template.replace(/^.+!/, '').replace(/\?.+$/, '');
239 const vmContext = vm.createContext(_.extend({HTML_WEBPACK_PLUGIN: true, require: require}, global));
240 const vmScript = new vm.Script(source, {filename: template});
241 // Evaluate code and cast to string
242 let newSource;
243 try {
244 newSource = vmScript.runInContext(vmContext);
245 } catch (e) {
246 return Promise.reject(e);
247 }
248 if (typeof newSource === 'object' && newSource.__esModule && newSource.default) {
249 newSource = newSource.default;
250 }
251 return typeof newSource === 'string' || typeof newSource === 'function'
252 ? Promise.resolve(newSource)
253 : Promise.reject('The loader "' + this.options.template + '" didn\'t return html.');
254 }
255
256 /**
257 * Html post processing
258 *
259 * Returns a promise
260 */
261 executeTemplate (templateFunction, chunks, assets, compilation) {
262 const self = this;
263 return Promise.resolve()
264 // Template processing
265 .then(() => {
266 const templateParams = {
267 compilation: compilation,
268 webpack: compilation.getStats().toJson(),
269 webpackConfig: compilation.options,
270 htmlWebpackPlugin: {
271 files: assets,
272 options: self.options
273 }
274 };
275 let html = '';
276 try {
277 html = templateFunction(templateParams);
278 } catch (e) {
279 compilation.errors.push(new Error('Template execution failed: ' + e));
280 return Promise.reject(e);
281 }
282 return html;
283 });
284 }
285
286 /**
287 * Html post processing
288 *
289 * Returns a promise
290 */
291 postProcessHtml (html, assets, assetTags) {
292 const self = this;
293 if (typeof html !== 'string') {
294 return Promise.reject('Expected html to be a string but got ' + JSON.stringify(html));
295 }
296 return Promise.resolve()
297 // Inject
298 .then(() => {
299 if (self.options.inject) {
300 return self.injectAssetsIntoHtml(html, assets, assetTags);
301 } else {
302 return html;
303 }
304 })
305 // Minify
306 .then(html => {
307 if (self.options.minify) {
308 const minify = require('html-minifier').minify;
309 return minify(html, self.options.minify);
310 }
311 return html;
312 });
313 }
314
315 /*
316 * Pushes the content of the given filename to the compilation assets
317 */
318 addFileToAssets (filename, compilation) {
319 filename = path.resolve(compilation.compiler.context, filename);
320 return Promise.all([
321 fsStatAsync(filename),
322 fsReadFileAsync(filename)
323 ])
324 .then(([size, source]) => {
325 return {
326 size,
327 source
328 };
329 })
330 .catch(() => Promise.reject(new Error('HtmlWebpackPlugin: could not load file ' + filename)))
331 .then(results => {
332 const basename = path.basename(filename);
333 if (compilation.fileDependencies.add) {
334 compilation.fileDependencies.add(filename);
335 } else {
336 // Before Webpack 4 - fileDepenencies was an array
337 compilation.fileDependencies.push(filename);
338 }
339 compilation.assets[basename] = {
340 source: () => results.source,
341 size: () => results.size.size
342 };
343 return basename;
344 });
345 }
346
347 /**
348 * Helper to sort chunks
349 */
350 sortChunks (chunks, sortMode, chunkGroups) {
351 // Sort mode auto by default:
352 if (typeof sortMode === 'undefined') {
353 sortMode = 'auto';
354 }
355 // Custom function
356 if (typeof sortMode === 'function') {
357 return chunks.sort(sortMode);
358 }
359 // Disabled sorting:
360 if (sortMode === 'none') {
361 return chunkSorter.none(chunks);
362 }
363 if (sortMode === 'manual') {
364 return chunkSorter.manual(chunks, this.options.chunks);
365 }
366 // Check if the given sort mode is a valid chunkSorter sort mode
367 if (typeof chunkSorter[sortMode] !== 'undefined') {
368 return chunkSorter[sortMode](chunks, chunkGroups);
369 }
370 throw new Error('"' + sortMode + '" is not a valid chunk sort mode');
371 }
372
373 /**
374 * Return all chunks from the compilation result which match the exclude and include filters
375 */
376 filterChunks (chunks, includedChunks, excludedChunks) {
377 return chunks.filter(chunk => {
378 const chunkName = chunk.names[0];
379 // This chunk doesn't have a name. This script can't handled it.
380 if (chunkName === undefined) {
381 return false;
382 }
383 // Skip if the chunk should be lazy loaded
384 if (typeof chunk.isInitial === 'function') {
385 if (!chunk.isInitial()) {
386 return false;
387 }
388 } else if (!chunk.initial) {
389 return false;
390 }
391 // Skip if the chunks should be filtered and the given chunk was not added explicity
392 if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
393 return false;
394 }
395 // Skip if the chunks should be filtered and the given chunk was excluded explicity
396 if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
397 return false;
398 }
399 // Add otherwise
400 return true;
401 });
402 }
403
404 isHotUpdateCompilation (assets) {
405 return assets.js.length && assets.js.every(name => /\.hot-update\.js$/.test(name));
406 }
407
408 htmlWebpackPluginAssets (compilation, chunks) {
409 const self = this;
410 const compilationHash = compilation.hash;
411
412 // Use the configured public path or build a relative path
413 let publicPath = typeof compilation.options.output.publicPath !== 'undefined'
414 // If a hard coded public path exists use it
415 ? compilation.mainTemplate.getPublicPath({hash: compilationHash})
416 // If no public path was set get a relative url path
417 : path.relative(path.resolve(compilation.options.output.path, path.dirname(self.childCompilationOutputName)), compilation.options.output.path)
418 .split(path.sep).join('/');
419
420 if (publicPath.length && publicPath.substr(-1, 1) !== '/') {
421 publicPath += '/';
422 }
423
424 const assets = {
425 // The public path
426 publicPath: publicPath,
427 // Will contain all js & css files by chunk
428 chunks: {},
429 // Will contain all js files
430 js: [],
431 // Will contain all css files
432 css: [],
433 // Will contain the html5 appcache manifest files if it exists
434 manifest: Object.keys(compilation.assets).filter(assetFile => path.extname(assetFile) === '.appcache')[0]
435 };
436
437 // Append a hash for cache busting
438 if (this.options.hash) {
439 assets.manifest = self.appendHash(assets.manifest, compilationHash);
440 assets.favicon = self.appendHash(assets.favicon, compilationHash);
441 }
442
443 for (let i = 0; i < chunks.length; i++) {
444 const chunk = chunks[i];
445 const chunkName = chunk.names[0];
446
447 assets.chunks[chunkName] = {};
448
449 // Prepend the public path to all chunk files
450 let chunkFiles = [].concat(chunk.files).map(chunkFile => publicPath + chunkFile);
451
452 // Append a hash for cache busting
453 if (this.options.hash) {
454 chunkFiles = chunkFiles.map(chunkFile => self.appendHash(chunkFile, compilationHash));
455 }
456
457 // Webpack outputs an array for each chunk when using sourcemaps
458 // or when one chunk hosts js and css simultaneously
459 const js = chunkFiles.find(chunkFile => /.js($|\?)/.test(chunkFile));
460 if (js) {
461 assets.chunks[chunkName].size = chunk.size;
462 assets.chunks[chunkName].entry = js;
463 assets.chunks[chunkName].hash = chunk.hash;
464 assets.js.push(js);
465 }
466
467 // Gather all css files
468 const css = chunkFiles.filter(chunkFile => /.css($|\?)/.test(chunkFile));
469 assets.chunks[chunkName].css = css;
470 assets.css = assets.css.concat(css);
471 }
472
473 // Duplicate css assets can occur on occasion if more than one chunk
474 // requires the same css.
475 assets.css = _.uniq(assets.css);
476
477 return assets;
478 }
479
480 /**
481 * Injects the assets into the given html string
482 */
483 generateAssetTags (assets) {
484 // Turn script files into script tags
485 const scripts = assets.js.map(scriptPath => ({
486 tagName: 'script',
487 closeTag: true,
488
489 attributes: {
490 type: 'text/javascript',
491 src: scriptPath
492 }
493 }));
494 // Make tags self-closing in case of xhtml
495 const selfClosingTag = !!this.options.xhtml;
496 // Turn css files into link tags
497 const styles = assets.css.map(stylePath => ({
498 tagName: 'link',
499 selfClosingTag: selfClosingTag,
500
501 attributes: {
502 href: stylePath,
503 rel: 'stylesheet'
504 }
505 }));
506 // Injection targets
507 let head = [];
508 let body = [];
509
510 // If there is a favicon present, add it to the head
511 if (assets.favicon) {
512 head.push({
513 tagName: 'link',
514 selfClosingTag: selfClosingTag,
515 attributes: {
516 rel: 'shortcut icon',
517 href: assets.favicon
518 }
519 });
520 }
521 // Add styles to the head
522 head = head.concat(styles);
523 // Add scripts to body or head
524 if (this.options.inject === 'head') {
525 head = head.concat(scripts);
526 } else {
527 body = body.concat(scripts);
528 }
529 return {head: head, body: body};
530 }
531
532 /**
533 * Injects the assets into the given html string
534 */
535 injectAssetsIntoHtml (html, assets, assetTags) {
536 const htmlRegExp = /(<html[^>]*>)/i;
537 const headRegExp = /(<\/head\s*>)/i;
538 const bodyRegExp = /(<\/body\s*>)/i;
539 const body = assetTags.body.map(this.createHtmlTag);
540 const head = assetTags.head.map(this.createHtmlTag);
541
542 if (body.length) {
543 if (bodyRegExp.test(html)) {
544 // Append assets to body element
545 html = html.replace(bodyRegExp, match => body.join('') + match);
546 } else {
547 // Append scripts to the end of the file if no <body> element exists:
548 html += body.join('');
549 }
550 }
551
552 if (head.length) {
553 // Create a head tag if none exists
554 if (!headRegExp.test(html)) {
555 if (!htmlRegExp.test(html)) {
556 html = '<head></head>' + html;
557 } else {
558 html = html.replace(htmlRegExp, match => match + '<head></head>');
559 }
560 }
561
562 // Append assets to head element
563 html = html.replace(headRegExp, match => head.join('') + match);
564 }
565
566 // Inject manifest into the opening html tag
567 if (assets.manifest) {
568 html = html.replace(/(<html[^>]*)(>)/i, (match, start, end) => {
569 // Append the manifest only if no manifest was specified
570 if (/\smanifest\s*=/.test(match)) {
571 return match;
572 }
573 return start + ' manifest="' + assets.manifest + '"' + end;
574 });
575 }
576 return html;
577 }
578
579 /**
580 * Appends a cache busting hash
581 */
582 appendHash (url, hash) {
583 if (!url) {
584 return url;
585 }
586 return url + (url.indexOf('?') === -1 ? '?' : '&') + hash;
587 }
588
589 /**
590 * Turn a tag definition into a html string
591 */
592 createHtmlTag (tagDefinition) {
593 const attributes = Object.keys(tagDefinition.attributes || {})
594 .filter(attributeName => tagDefinition.attributes[attributeName] !== false)
595 .map(attributeName => {
596 if (tagDefinition.attributes[attributeName] === true) {
597 return attributeName;
598 }
599 return attributeName + '="' + tagDefinition.attributes[attributeName] + '"';
600 });
601 // Backport of 3.x void tag definition
602 const voidTag = tagDefinition.voidTag !== undefined ? tagDefinition.voidTag : !tagDefinition.closeTag;
603 const selfClosingTag = tagDefinition.voidTag !== undefined ? tagDefinition.voidTag && this.options.xhtml : tagDefinition.selfClosingTag;
604 return '<' + [tagDefinition.tagName].concat(attributes).join(' ') + (selfClosingTag ? '/' : '') + '>' +
605 (tagDefinition.innerHTML || '') +
606 (voidTag ? '' : '</' + tagDefinition.tagName + '>');
607 }
608
609 /**
610 * Helper to return the absolute template path with a fallback loader
611 */
612 getFullTemplatePath (template, context) {
613 // If the template doesn't use a loader use the lodash template loader
614 if (template.indexOf('!') === -1) {
615 template = require.resolve('./lib/loader.js') + '!' + path.resolve(context, template);
616 }
617 // Resolve template path
618 return template.replace(
619 /([!])([^/\\][^!?]+|[^/\\!?])($|\?[^!?\n]+$)/,
620 (match, prefix, filepath, postfix) => prefix + path.resolve(filepath) + postfix);
621 }
622
623 /**
624 * Helper to return a sorted unique array of all asset files out of the
625 * asset object
626 */
627 getAssetFiles (assets) {
628 const files = _.uniq(Object.keys(assets).filter(assetType => assetType !== 'chunks' && assets[assetType]).reduce((files, assetType) => files.concat(assets[assetType]), []));
629 files.sort();
630 return files;
631 }
632
633 /**
634 * Helper to promisify compilation.applyPluginsAsyncWaterfall that returns
635 * a function that helps to merge given plugin arguments with processed ones
636 */
637 applyPluginsAsyncWaterfall (compilation) {
638 if (compilation.hooks) {
639 return (eventName, requiresResult, pluginArgs) => {
640 const ccEventName = trainCaseToCamelCase(eventName);
641 if (!compilation.hooks[ccEventName]) {
642 compilation.errors.push(
643 new Error('No hook found for ' + eventName)
644 );
645 }
646
647 return compilation.hooks[ccEventName].promise(pluginArgs);
648 };
649 }
650
651 // Before Webpack 4
652 const promisedApplyPluginsAsyncWaterfall = function (name, init) {
653 return new Promise((resolve, reject) => {
654 const callback = function (err, result) {
655 if (err) {
656 return reject(err);
657 }
658 resolve(result);
659 };
660 compilation.applyPluginsAsyncWaterfall(name, init, callback);
661 });
662 };
663
664 return (eventName, requiresResult, pluginArgs) => promisedApplyPluginsAsyncWaterfall(eventName, pluginArgs)
665 .then(result => {
666 if (requiresResult && !result) {
667 compilation.warnings.push(
668 new Error('Using ' + eventName + ' without returning a result is deprecated.')
669 );
670 }
671 return _.extend(pluginArgs, result);
672 });
673 }
674}
675
676/**
677 * Takes a string in train case and transforms it to camel case
678 *
679 * Example: 'hello-my-world' to 'helloMyWorld'
680 *
681 * @param {string} word
682 */
683function trainCaseToCamelCase (word) {
684 return word.replace(/-([\w])/g, (match, p1) => p1.toUpperCase());
685}
686
687module.exports = HtmlWebpackPlugin;