// @ts-check 'use strict'; /** * @file * This file uses webpack to compile a template with a child compiler. * * [TEMPLATE] -> [JAVASCRIPT] * */ /** @typedef {import("webpack").Chunk} Chunk */ /** @typedef {import("webpack").sources.Source} Source */ /** @typedef {{hash: string, entry: Chunk, content: string, assets: {[name: string]: { source: Source, info: import("webpack").AssetInfo }}}} ChildCompilationTemplateResult */ /** * The HtmlWebpackChildCompiler is a helper to allow reusing one childCompiler * for multiple HtmlWebpackPlugin instances to improve the compilation performance. */ class HtmlWebpackChildCompiler { /** * * @param {string[]} templates */ constructor (templates) { /** * @type {string[]} templateIds * The template array will allow us to keep track which input generated which output */ this.templates = templates; /** @type {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>} */ this.compilationPromise; // eslint-disable-line /** @type {number | undefined} */ this.compilationStartedTimestamp; // eslint-disable-line /** @type {number | undefined} */ this.compilationEndedTimestamp; // eslint-disable-line /** * All file dependencies of the child compiler * @type {{fileDependencies: string[], contextDependencies: string[], missingDependencies: string[]}} */ this.fileDependencies = { fileDependencies: [], contextDependencies: [], missingDependencies: [] }; } /** * Returns true if the childCompiler is currently compiling * * @returns {boolean} */ isCompiling () { return !this.didCompile() && this.compilationStartedTimestamp !== undefined; } /** * Returns true if the childCompiler is done compiling * * @returns {boolean} */ didCompile () { return this.compilationEndedTimestamp !== undefined; } /** * This function will start the template compilation * once it is started no more templates can be added * * @param {import('webpack').Compilation} mainCompilation * @returns {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>} */ compileTemplates (mainCompilation) { const webpack = mainCompilation.compiler.webpack; const Compilation = webpack.Compilation; const NodeTemplatePlugin = webpack.node.NodeTemplatePlugin; const NodeTargetPlugin = webpack.node.NodeTargetPlugin; const LoaderTargetPlugin = webpack.LoaderTargetPlugin; const EntryPlugin = webpack.EntryPlugin; // To prevent multiple compilations for the same template // the compilation is cached in a promise. // If it already exists return if (this.compilationPromise) { return this.compilationPromise; } const outputOptions = { filename: '__child-[name]', publicPath: '', library: { type: 'var', name: 'HTML_WEBPACK_PLUGIN_RESULT' }, scriptType: /** @type {'text/javascript'} */('text/javascript'), iife: true }; const compilerName = 'HtmlWebpackCompiler'; // Create an additional child compiler which takes the template // and turns it into an Node.JS html factory. // This allows us to use loaders during the compilation const childCompiler = mainCompilation.createChildCompiler(compilerName, outputOptions, [ // Compile the template to nodejs javascript new NodeTargetPlugin(), new NodeTemplatePlugin(), new LoaderTargetPlugin('node'), new webpack.library.EnableLibraryPlugin('var') ]); // The file path context which webpack uses to resolve all relative files to childCompiler.context = mainCompilation.compiler.context; // Generate output file names const temporaryTemplateNames = this.templates.map((template, index) => `__child-HtmlWebpackPlugin_${index}-${template}`); // Add all templates this.templates.forEach((template, index) => { new EntryPlugin(childCompiler.context, 'data:text/javascript,__webpack_public_path__ = __webpack_base_uri__ = htmlWebpackPluginPublicPath;', `HtmlWebpackPlugin_${index}-${template}`).apply(childCompiler); new EntryPlugin(childCompiler.context, template, `HtmlWebpackPlugin_${index}-${template}`).apply(childCompiler); }); // The templates are compiled and executed by NodeJS - similar to server side rendering // Unfortunately this causes issues as some loaders require an absolute URL to support ES Modules // The following config enables relative URL support for the child compiler childCompiler.options.module = { ...childCompiler.options.module }; childCompiler.options.module.parser = { ...childCompiler.options.module.parser }; childCompiler.options.module.parser.javascript = { ...childCompiler.options.module.parser.javascript, url: 'relative' }; this.compilationStartedTimestamp = new Date().getTime(); /** @type {Promise<{[templatePath: string]: ChildCompilationTemplateResult}>} */ this.compilationPromise = new Promise((resolve, reject) => { /** @type {Source[]} */ const extractedAssets = []; childCompiler.hooks.thisCompilation.tap('HtmlWebpackPlugin', (compilation) => { compilation.hooks.processAssets.tap( { name: 'HtmlWebpackPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS }, (assets) => { temporaryTemplateNames.forEach((temporaryTemplateName) => { if (assets[temporaryTemplateName]) { extractedAssets.push(assets[temporaryTemplateName]); compilation.deleteAsset(temporaryTemplateName); } }); } ); }); childCompiler.runAsChild((err, entries, childCompilation) => { // Extract templates // TODO fine a better way to store entries and results, to avoid duplicate chunks and assets const compiledTemplates = entries ? extractedAssets.map((asset) => asset.source()) : []; // Extract file dependencies if (entries && childCompilation) { this.fileDependencies = { fileDependencies: Array.from(childCompilation.fileDependencies), contextDependencies: Array.from(childCompilation.contextDependencies), missingDependencies: Array.from(childCompilation.missingDependencies) }; } // Reject the promise if the childCompilation contains error if (childCompilation && childCompilation.errors && childCompilation.errors.length) { const errorDetails = childCompilation.errors.map(error => { let message = error.message; if (error.stack) { message += '\n' + error.stack; } return message; }).join('\n'); reject(new Error('Child compilation failed:\n' + errorDetails)); return; } // Reject if the error object contains errors if (err) { reject(err); return; } if (!childCompilation || !entries) { reject(new Error('Empty child compilation')); return; } /** * @type {{[templatePath: string]: ChildCompilationTemplateResult}} */ const result = {}; /** @type {{[name: string]: { source: Source, info: import("webpack").AssetInfo }}} */ const assets = {}; for (const asset of childCompilation.getAssets()) { assets[asset.name] = { source: asset.source, info: asset.info }; } compiledTemplates.forEach((templateSource, entryIndex) => { // The compiledTemplates are generated from the entries added in // the addTemplate function. // Therefore, the array index of this.templates should be the as entryIndex. result[this.templates[entryIndex]] = { // TODO, can we have Buffer here? content: /** @type {string} */ (templateSource), hash: childCompilation.hash || 'XXXX', entry: entries[entryIndex], assets }; }); this.compilationEndedTimestamp = new Date().getTime(); resolve(result); }); }); return this.compilationPromise; } } module.exports = { HtmlWebpackChildCompiler };