UNPKG

html-bundler-webpack-plugin

Version:

Generates complete single-page or multi-page website from source assets. Build-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.

1,229 lines (1,035 loc) 39.9 kB
const path = require('path'); const { minify } = require('html-minifier-terser'); const { HtmlParser, comparePos } = require('../Common/HtmlParser'); const { getFixedUrlWithParams } = require('../Common/Helpers'); const Integrity = require('./Extras/Integrity'); const Preload = require('./Preload'); const { noHeadException } = require('./Messages/Exception'); /** @typedef {import('webpack').Compilation} Compilation */ /** * @typedef {Object} CollectionData * @property {string} type The type of resource. * @property {boolean} inline Whether should be inlined into HTML. * @property {boolean|undefined?} imported Whether the style is imported in JavaScript, if the type is 'style'. * @property {{resource: string}} issuer The resource of issuer, only if imported is true. * @property {string|Array<{resource: string, assets: Array<CollectionData>}>} resource The resource file, including a query, only if imported is false. * If imported is true, then the resource is the array of imported source files. * @property {string|null} assetFile The output filename, only if imported is false. * @property {Array<CollectionData>?} assets The assets containing in style or script. */ /** * Collection of script and style files parsed in a template. */ class Collection { /** * Resource types. * * @type {{resource: string, inlineSvg: string, style: string, script: string}} */ static type = { style: 'style', script: 'script', resource: 'resource', inlineSvg: 'inline/svg', template: 'template', }; /** @type {Compilation} */ compilation = null; /** @type {AssetEntry} */ assetEntry = null; /** @type {AssetInline} */ assetInline = null; /** @type {Option} */ pluginOption = null; /** @type {CssExtractModule} */ cssExtractModule = null; /** @type {AssetTrash} */ assetTrash = null; /** @type {Preload} */ preload = null; /** @type {Dependency} */ dependency = null; assets = new Map(); /** @type {Map<string, {entry: AssetEntryOptions, assets: Array<{}>} >} Entries data */ data = new Map(); // TODO: implement /** @type {Map<string, string | Array<string>>} The map of source file to output file */ manifest = new Map(); /** * Unique last index for each file with the same name. * @type {Object<file: string, index: number>} */ index = {}; importStyleEsModule = true; orderedResources = new Map(); importStyleRootIssuers = new Set(); importStyleSources = new Map(); importStyleIdx = 1000; deserialized = false; ScriptOrStyleType = new Set(['script', 'style']); /** * @param {Object} pluginContext Reference to object containing all required dependencies. * The dependencies will be created and initialized later. */ constructor(pluginContext) { this.pluginContext = pluginContext; } /** * @param {HtmlBundlerPlugin.Hooks} hooks */ init({ hooks }) { // only here are created all dependencies this.compilation = this.pluginContext.compilation; this.assetEntry = this.pluginContext.assetEntry; this.assetInline = this.pluginContext.assetInline; this.pluginOption = this.pluginContext.pluginOption; this.assetTrash = this.pluginContext.assetTrash; this.cssExtractModule = this.pluginContext.cssExtractModule; this.dependency = this.pluginContext.loaderDependency; this.preload = new Preload(this.pluginContext.pluginOption); this.hooks = hooks; } /** * @return {Map<string, {entry: AssetEntryOptions, assets: Array<{}>}>} */ getData() { return this.data; } /** * @param {string} name * @param {string} resource * @param {string} issuer */ #addToCompilation({ name, resource, issuer }) { const entry = { name, importFile: resource, filenameTemplate: this.pluginOption.getJs().filename, context: path.dirname(issuer), issuer, }; this.assetEntry.addToCompilation(entry); } /** * Binding of compiled styles imported in JavaScript to generated HTML. * * The CSS will be inlined or an output filename will be injected in content. * * @param {string} content The content of the template. * @param {AssetEntryOptions} entry * @param {Array<Object>} styles * @param {string} LF The new line feed in depends on the minification option. * @return {string|undefined} */ #bindImportedStyles(content, entry, styles, LF) { const insertPos = this.findStyleInsertPos(content); if (insertPos < 0) { noHeadException(entry.resource); } let linkTags = ''; let styleTags = ''; for (const asset of styles) { if (asset.inline) { const source = this.cssExtractModule.getInlineSource(asset.assetFile); // note: in inlined style must be no LF character after the open tag, otherwise the mapping will not work styleTags += `<style>` + source + `</style>${LF}`; } else { // note: void elements don't need the closing // https://html.spec.whatwg.org/multipage/syntax.html#void-elements linkTags += `<link href="${asset.assetFile}" rel="stylesheet">${LF}`; } } return content.slice(0, insertPos) + linkTags + styleTags + content.slice(insertPos); } /** * Inline CSS from a style file specified in a template. * * @param {string} content The content of the template. * @param {string} search The original request of a style in the content. * @param {Object} asset The object of the style. * @param {string} LF The new line feed in depends on the minification option. * @return {string|boolean} Return content with inlined CSS or false if the content was not modified. */ #inlineStyle(content, search, asset, LF) { const pos = content.indexOf(search); if (pos < 0) return false; const source = this.cssExtractModule.getInlineSource(asset.assetFile); const tagEnd = '>'; const openTag = '<style>'; let closeTag = '</style>'; let tagStartPos = pos; let tagEndPos = pos + search.length; while (tagStartPos >= 0 && content.charAt(--tagStartPos) !== '<') {} tagEndPos = content.indexOf(tagEnd, tagEndPos) + tagEnd.length; // add LF after injected scripts when next char is not a new line if (LF && !'\n\r'.includes(content[tagEndPos])) closeTag += LF; // note: in inlined style must be no LF character after the open tag, otherwise the mapping will not work return content.slice(0, tagStartPos) + openTag + source + closeTag + content.slice(tagEndPos); } /** * Binding of compiled JavaScript into generated HTML. * * The JS will be inlined or source script file will be replaced with output filename in content. * * @param {string} content The content of the template. * @param {string} resource The resource file containing in the content. * @param {Object} asset The object of the script. * @param {string} LF The new line feed in depends on the minification option. * @return {string|boolean} Return content with inlined JS or false if the content was not modified. */ #bindScript(content, resource, asset, LF) { let pos = content.indexOf(resource); if (pos < 0) return false; const { attributeFilter } = this.pluginOption.getJs().inline; const sources = this.compilation.assets; const { chunks } = asset; let openTag = '<script>'; const closeTag = '</script>'; const tagStartCode = '<'.charCodeAt(0); const attrPos = openTag.length; let srcStartPos = pos; let srcEndPos = srcStartPos + resource.length; let tagStartPos = srcStartPos; let tagEndPos = srcEndPos; let replacement = ''; if (chunks.length === 1 && chunks[0].inline !== true) { // replace the single chunk file for preload in the `link` tag and in the `script` tag return content.replaceAll(resource, chunks[0].assetFile); } // find the starting position of the tag to the left of the `src` attribute while (tagStartPos >= 0 && content.charCodeAt(--tagStartPos) !== tagStartCode) {} tagEndPos = content.indexOf(closeTag, tagEndPos) + closeTag.length; let beforeTagSrc = content.slice(tagStartPos, srcStartPos); let afterTagSrc = content.slice(srcEndPos, tagEndPos); let isCreatedOpenTag = false; for (let { inline, chunkFile, assetFile } of chunks) { if (LF && replacement) replacement += LF; if (inline) { const code = sources[chunkFile].source(); if (!isCreatedOpenTag && attributeFilter) { const { attrs: attributes } = HtmlParser.parseTagAttributes(content, 'script', tagStartPos, attrPos); let attrsStr = ''; for (const [attribute, value] of Object.entries(attributes)) { if (attributeFilter({ attributes, attribute, value }) === true) { if (attrsStr) attrsStr += ' '; attrsStr += attribute; if (value != null) attrsStr += `="${value}"`; } } if (attrsStr) { openTag = `<script ${attrsStr}>`; } isCreatedOpenTag = true; } replacement += openTag + code + closeTag; this.assetTrash.add(chunkFile); } else { replacement += beforeTagSrc + assetFile + afterTagSrc; } } // add LF after injected scripts when next char is not a new line if (LF && !'\n\r'.includes(content[tagEndPos])) replacement += LF; return content.slice(0, tagStartPos) + replacement + content.slice(tagEndPos); } /** * Prepare data for script rendering. */ #prepareScriptData() { const compilation = this.compilation; const { assets, assetsInfo, chunks, chunkGraph, namedChunkGroups } = compilation; const splitChunkFiles = new Set(); const splitChunkIds = new Set(); const chunkCache = new Map(); for (let [resource, { type, name, entries }] of this.assets) { if (type !== Collection.type.script) continue; const entrypoint = namedChunkGroups.get(name); // prevent error when in watch mode after removing a script in the template if (!entrypoint) continue; const chunkFiles = new Set(); for (const { id, files, auxiliaryFiles } of entrypoint.chunks) { for (const file of files) { const info = assetsInfo.get(file); // when is used dynamic entry in serve/watch mode and // after renaming of an entry file the webpack generate additional needles entry file with the `.js` extension // in this case, the entrypoint.chunks.files contains a wrong file with the `.html` extension // for what we check the asset info, whether the chunk is a javascript const isJavascript = 'javascriptModule' in info; if (isJavascript && info.hotModuleReplacement !== true) chunkFiles.add(file); } splitChunkIds.add(id); } const hasSplitChunks = chunkFiles.size > 1; // do flat the Map<string, Set> const entryFilenames = new Set(); for (const value of entries.values()) { value.forEach(entryFilenames.add, entryFilenames); } for (let entryFile of entryFilenames) { // let's show an original error if (!assets.hasOwnProperty(entryFile)) continue; const data = { type, resource, chunks: [] }; let injectedChunks; if (hasSplitChunks) { if (!chunkCache.has(entryFile)) chunkCache.set(entryFile, new Set()); injectedChunks = chunkCache.get(entryFile); } for (let chunkFile of chunkFiles) { if (hasSplitChunks) { if (injectedChunks.has(chunkFile)) continue; injectedChunks.add(chunkFile); } const assetFile = this.pluginOption.getAssetOutputFile(chunkFile, entryFile); const inline = this.pluginOption.isInlineJs(resource, chunkFile); splitChunkFiles.add(chunkFile); data.chunks.push({ inline, chunkFile, assetFile }); } const entryData = this.data.get(entryFile); if (entryData) { entryData.assets.push(data); } } } const chunkIds = Array.from(splitChunkIds); // remove generated unused split chunks for (let { ids, files, chunkReason } of chunks) { const isSplitChunk = chunkReason != null && chunkReason.indexOf('split') > -1; if (ids.length === 0 || !isSplitChunk) continue; for (let file of files) { if (splitChunkFiles.has(file)) continue; if (chunkIds.find((id) => ids.indexOf(id) > -1)) { this.assetTrash.add(file); } } } } /** * Whether the output filename is a template entrypoint. * * @param {string} assetFile The asset output file. * @return {boolean} */ isTemplate(assetFile) { const data = this.data.get(assetFile); return data?.entry.isTemplate === true; } /** * Whether the collection contains the script file. * * @param {string} resource The resource file, including a query. * @return {boolean} */ hasScript(resource) { return this.assets.get(resource)?.type === Collection.type.script; } /** * Whether the collection contains the style file. * * @param {string} resource The resource file, including a query. * @return {boolean} */ hasStyle(resource) { return this.assets.get(resource)?.type === Collection.type.style; } /** * Whether resource is an inlined style. * * @param {string} resource The resource file, including a query. * @return {boolean} */ isInlineStyle(resource) { const item = this.assets.get(resource); return item != null && item.inline && item.type === Collection.type.style; } /** * @param {string} entryId The entry id where can be used imported styles. * @return {boolean} */ hasImportedStyle(entryId) { return this.importStyleRootIssuers.size > 0 && this.orderedResources.has(entryId); } /** * @returns {boolean} */ isImportStyleEsModule() { return this.importStyleEsModule; } /** * @param {boolean} state Whether the style is imported as ESM. */ setImportStyleEsModule(state) { this.importStyleEsModule = state === true; } /** * Get entry data by output asset filename. * * Reserved for future. * * @param {string} assetFile The output asset filename. * @return {{entry: AssetEntryOptions, assets: Array<{}>}} */ // getEntry(assetFile) { // const data = this.data.get(assetFile); // // return data?.entry.isTemplate === true ? data : null; // } /** * @param {string} resource * @return {Object|null} */ getGraphModule(resource) { const { moduleGraph } = this.compilation; const moduleMap = moduleGraph._moduleMap; for (let [module] of moduleMap.entries()) { if (module.resource === resource) { return module; } } return null; } /** * @param {string} file The source file of script. * @return {string } Return unique assetFile */ createUniqueName(file) { const { name } = path.parse(file); let uniqueName = name; // the entrypoint name must be unique, if already exists then add an index: `main` => `main.1`, etc. if (!this.assetEntry.isUnique(name, file)) { // create unique name if (!this.index[name]) { this.index[name] = 1; } uniqueName = name + '.' + this.index[name]++; } return uniqueName; } /** * Find styles from all nested JS files. * * @param {number} entryId The entry id of the template where is the root issuer. * Note: the same issuer can be used in many entries. * @param {string} rootIssuer The root JS file loaded in template. * @param {Object} chunk * @return {Object[]} */ findImportedModules(entryId, rootIssuer, chunk) { const issuerModule = this.getGraphModule(rootIssuer); const modules = this.findModuleDependencies(issuerModule); // reserved for debug; // the modules are already sorted //modules.sort((a, b) => (a.order < b.order ? -1 : 1)); return modules; } /** * @param {Module} module The Webpack compilation module. * @returns {Array<{order: string, module: Module}>} */ findModuleDependencies(module) { const { moduleGraph } = this.compilation; const circularDependencyIds = new Set(); const orderStack = []; let order = ''; const walk = (module) => { // dependencies contains modules from normal imports, e.g. import './main.js' // blocks contains modules from dynamic imports, e.g. import('./main.js') const { dependencies, blocks } = module; const result = []; let allDependencies = dependencies; // avoid an infinity walk by circular dependency if (circularDependencyIds.has(module.debugId)) { return result; } circularDependencyIds.add(module.debugId); // add dynamic imports if (blocks.length > 0) { for (const block of blocks) { if (block.dependencies.length > 0) { allDependencies = allDependencies.concat(block.dependencies); } } } for (const dependency of allDependencies) { // TODO: detect whether the userRequest is a file, not a runtime, e.g. of vue if ( !dependency.userRequest || // skip vue runtime dependencies dependency.userRequest === 'vue' ) { continue; } let depModule = moduleGraph.getModule(dependency); if (!depModule) { // prevent a potential error in as yet unknown use cases to find the location of the bug /* istanbul ignore next */ continue; } // use the original NormalModule instead of ConcatenatedModule if (!depModule.resource && depModule.rootModule) { depModule = depModule.rootModule; } const index = moduleGraph.getParentBlockIndex(dependency); if (depModule.resourceResolveData?._bundlerPluginMeta.isImportedStyle === true) { result.push({ resource: depModule.resource, order: order + (order ? '.' : '') + index, module: depModule, }); } else if (depModule.dependencies.length > 0 || depModule.blocks.length > 0) { // save current order before recursive walking orderStack.push(order); order += (order ? '.' : '') + index; result.push(...walk(depModule)); } } // recovery order order = orderStack.pop(); return result; }; return walk(module); } /** * Find insert position for styles in the HTML head. * * <head> * <title></title> * <link rel="icon"> * <link rel="stylesheet"> * <style></style> * <-- inject styles here * <script></script> * </head> * * @param {string} content * @return {number} */ findStyleInsertPos(content) { let headStartPos = content.indexOf('<head'); if (headStartPos < 0) { return -1; } let headEndPos = content.indexOf('</head>', headStartPos); if (headEndPos < 0) { return -1; } let startPos = content.indexOf('<script', headStartPos); if (startPos < 0 || startPos > headEndPos) { startPos = headEndPos; } return startPos; } /** * @param {AssetEntryOptions} entry The entry point object. */ addEntry(entry) { const { isTemplate, resource, filename } = entry; if (isTemplate && !this.data.has(filename)) { this.setData(entry, null, {}); } // set entry dependencies for (const item of this.assets.values()) { const entryFilenames = item.entries.get(resource); if (entryFilenames) { entryFilenames.add(filename); } } } /** * Add the resource. * Called in loader by parsing scripts and styles. * * @param {'script'|'style'} type The type of resource. * @param {string} resource The resource file, including a query. * @param {string} issuer The issuer resource, including a query. * @param {string|number|null} entryId The entry id where is loaded the resource. * Note: if the entryId is undefined, then the resource may be imported in JavaScript, (e.g. template partials). */ addResource({ type, resource, issuer, entryId = null }) { // note: the same source file can be either as file or as inlined, // but can't be in one place as file and in another place as inlined let item = this.assets.get(resource); let inline = false; let name; issuer = getFixedUrlWithParams(issuer); switch (type) { case Collection.type.script: // Save resources by entry points in the order their location in the source code. // Note: the order of script resources is important to inject the style files imported in JS into HTML. if (entryId) { let orderedResources = this.orderedResources.get(entryId); if (!orderedResources) { orderedResources = new Set(); this.orderedResources.set(entryId, orderedResources); } orderedResources.add(resource); } // get unique entry name name = this.assets.get(resource)?.name; if (!name) { name = this.createUniqueName(resource); this.#addToCompilation({ name, resource, issuer }); } inline = undefined; break; case Collection.type.style: inline = this.pluginOption.isInlineCss(resource); break; default: // do nothing } if (!item) { item = { // type of resource, 'script' or 'style' type: type, // whether resource should be inlined in HTML inline, // entry name, defined only if resource is specified in Webpack entry name, // the key is an entry source request where the resource is loaded // the value are entry output filenames to match entry in compilation.assets entries: new Map(), assets: [], }; this.assets.set(resource, item); } item.entries.set(issuer, new Set()); } /** * Save info of resolved data. * * Note[1]: resolve the collision when the same issuer, e.g., a style file, is used in many entries. * If an issuer is not inlined, then only by first usage is set the data. * In this case, we use the unique reference object to save commonly used assets. * * If an issuer is an inlined file, then for each usage is set the data. * In this case, we save assets in the local data object, because asset output filenames can be different * by entries with different output paths, e.g.: 'home/' -> 'img/fig.png', 'home/en/' -> '../img/fig.png'. * * @param {AssetEntryOptions} entry The entry where is specified the resource. * @param {FileInfo|null} issuer The issuer of the resource can be a template, style or script. * @param {CollectionData|{}} data The collection data. */ setData(entry, issuer, data = {}) { // skip when the resource is defined in the entry option, not in the entry template if (!entry) return; const { filename } = entry; const entryPoint = this.data.get(filename); // 1. create entry point if (!entryPoint) { this.data.set(filename, { entry, assets: [] }); return; } const entryAssets = entryPoint.assets; if (issuer) data.issuer = issuer; // 2. create a style or script if (this.ScriptOrStyleType.has(data.type)) { // set reference to an original object, because the same resource can be used in many entries let ref = this.assets.get(data.resource) || { assets: [] }; data.refAssets = ref.assets; entryAssets.push(data); return; } // 3.1. add assets used in html entry if (issuer.resource === entry.resource) { entryAssets.push(data); return; } // find a parent asset e.g. style const parent = entryAssets.find((item) => item.resource === issuer.resource); if (parent) { // see the Note[1] in docBlock if (!parent.assets) parent.assets = []; // 3.2. add assets used in a style const assets = parent.inline ? parent.assets : parent.refAssets; assets.push(data); } } /** * Normalize style assets defined in html assets. * * When output CSS is a file, then is used a reference to the assets for later adding the resources. * We use the reference, because the same asset can be used in many pages, but the Webpack generates only one module. * So, using the reference, we have access to all copies of the asset in all pages. * When CSS is inlined into HTML, then is used local copy of assets. * * This method renames referenced property with the `asset` name. */ #normalizeData() { for (const [, { assets }] of this.data) { for (const item of assets) { if (item.refAssets != null) { if (item.refAssets?.length > 0) { item.assets = item.refAssets; } delete item.refAssets; } } } } /** * Set resolved output filename of asset. * * @param {AssetEntryOptions} entry The entry where is specified the resource. * @param {FileInfo} assetInfo The asset file info. */ setResourceFilename(entry, assetInfo) { const entryPoint = this.data.get(entry.filename); if (!entryPoint) return; const item = entryPoint.assets.find(({ resource }) => resource === assetInfo.resource); if (item) { item.assetFile = assetInfo.filename; } } /** * Delete entry data. * * Called when used the dynamic entry in serve/watch mode. * * @param {string} resource */ deleteData(resource) { this.assets.delete(resource); // find all keys, the same entry file can be used as a template for many entries let dataKey = []; for (const [key, item] of this.data) { if (item.entry.resource === resource) { dataKey.push({ key, filename: item.entry.filename, resource, }); } } dataKey.forEach(({ key, filename, resource }) => { this.data.delete(key); this.assets.forEach((item, file) => { item.entries.delete(resource); }); this.assetTrash.add(filename); }); // TODO: check it this.dependency.removeFile(resource); } /** * Disconnect entry in all assets. * * @param {string} resource The file of entry. */ disconnectEntry(resource) { for (const [, item] of this.assets) { item.entries.delete(resource); } } /** * Render all resolved assets in contents. * Inline JS, CSS, substitute output JS filenames. * * @param {Object} assets * * @return {Promise<Awaited<unknown>[]>|Promise<unknown>} */ render(assets) { const compilation = this.compilation; const { RawSource } = compilation.compiler.webpack.sources; const hasIntegrity = this.pluginOption.isIntegrityEnabled(); const isHtmlMinify = this.pluginOption.isMinify(); const { minifyOptions } = this.pluginOption.get(); const LF = this.pluginOption.getLF(); const hooks = this.hooks; const promises = []; this.#normalizeData(); this.#prepareScriptData(); // TODO: update this.data.assets[].asset.resource after change the filename in a template // - e.g. src="./main.js?v=1" => ./main.js?v=123 => WRONG filename is replaced for (const [entryFilename, { entry, assets }] of this.data) { const rawSource = compilation.assets[entryFilename]; if (!rawSource) { // the asset in which the compilation error occurred is missing in the compilation.assets // in this case return resolved promise to keep the original error message return Promise.resolve(); } const entryDirname = path.dirname(entryFilename); const importedStyles = []; const parseOptions = new Map(); const assetIntegrity = new Map(); let hasInlineSvg = false; let content = rawSource.source(); /** @type {CompileEntry} */ const compileEntry = { name: entry.originalName, assetFile: entry.filename, sourceFile: entry.sourceFile, resource: entry.resource, outputPath: entry.outputPath, assets, }; /** @type {TemplateInfo} */ const templateInfo = { name: entry.originalName, assetFile: entry.filename, resource: entry.resource, sourceFile: entry.sourceFile, outputPath: entry.outputPath, }; // 1. postprocess hook let promise = Promise.resolve(content).then((value) => hooks.postprocess.promise(value, templateInfo) || value); // 2. postprocess callback if (this.pluginOption.hasPostprocess()) { // TODO: update readme for postprocess promise = promise.then((value) => this.pluginOption.postprocess(value, templateInfo, compilation) || value); } // 3. minify HTML before inlining JS and CSS to avoid: // - needles minification already minified assets in production mode // - issues by parsing the inlined JS/CSS code with the html minification module if (isHtmlMinify) { promise = promise.then((value) => minify(value, minifyOptions)); } // 4. inline JS and CSS promise = promise.then((content) => { // TODO: // - style: rename output filename `assetFile` into filename or assetFilename // - style: add additional filed - assetFile as asset path relative to output.path, not to issuer // - script: rename assetFile -> assetFilename; chunkFile -> assetFile for (const asset of assets) { const { type, inline, imported, resource } = asset; if (inline && type === Collection.type.inlineSvg) { hasInlineSvg = true; continue; } switch (type) { case Collection.type.style: if (imported) { importedStyles.push(asset); } else if (inline) { content = this.#inlineStyle(content, resource, asset, LF) || content; } else { // special use case for Pug only e.g.: style(scope='some')=require('./component.css?include') const [, query] = resource.split('?'); const isIncluded = query?.includes('include'); if (isIncluded) { const startPos = content.indexOf(asset.assetFile); if (startPos > 0) { const source = this.cssExtractModule.getInlineSource(asset.assetFile); content = content.slice(0, startPos) + source + content.slice(startPos + asset.assetFile.length); } } } // 1.1 compute CSS integrity if (hasIntegrity && !inline) { // path to asset relative by output.path let pathname = asset.assetFile; if (this.pluginOption.isAutoPublicPath()) { pathname = path.join(entryDirname, pathname); } else if (this.pluginOption.isRootPublicPath()) { pathname = pathname.slice(1); } const assetContent = compilation.assets[pathname].source(); asset.integrity = Integrity.getIntegrity(compilation, assetContent, pathname); assetIntegrity.set(asset.assetFile, asset.integrity); if (!parseOptions.has(type)) { parseOptions.set(type, { tag: 'link', attributes: ['href'], filter: ({ attribute, attributes }) => !attributes.hasOwnProperty('integrity') && attribute === 'href' && attributes.rel === 'stylesheet', }); } } break; case Collection.type.script: // 1.2 compute JS integrity if (hasIntegrity) { for (const chunk of asset.chunks) { if (!chunk.inline) { const assetContent = compilation.assets[chunk.chunkFile].source(); chunk.integrity = Integrity.getIntegrity(compilation, assetContent, chunk.chunkFile); assetIntegrity.set(chunk.assetFile, chunk.integrity); if (!parseOptions.has(type)) { parseOptions.set(type, { tag: 'script', attributes: ['src'], filter: ({ attribute, attributes }) => !attributes.hasOwnProperty('integrity') && attribute === 'src', }); } } } } content = this.#bindScript(content, resource, asset, LF) || content; break; } } return content; }); // 5. inject styles imported in JS promise = promise.then((content) => importedStyles.length > 0 ? this.#bindImportedStyles(content, entry, importedStyles, LF) || content : content ); // 6. inline SVG promise = promise.then((content) => hasInlineSvg ? this.assetInline.inlineSvg(content, entryFilename) : content ); // 7. inject preloads if (this.pluginOption.isPreload()) { promise = promise.then( (content) => this.preload.insertPreloadAssets(content, entry.filename, this.data) || content ); } // 8. inject integrity if (hasIntegrity) { promise = promise.then((content) => { // 2. parse generated html for `link` and `script` tags const parsedResults = []; for (const opts of parseOptions.values()) { parsedResults.push(...HtmlParser.parseTag(content, opts)); } parsedResults.sort(comparePos); // 3. include the integrity attributes in the parsed tags let pos = 0; let output = ''; for (const { tag, parsedAttrs, attrs, startPos, endPos } of parsedResults) { if (!attrs || parsedAttrs.length < 1) continue; const assetFile = attrs.href || attrs.src; const integrity = assetIntegrity.get(assetFile); if (integrity) { attrs.integrity = integrity; attrs.crossorigin = this.pluginOption.webpackOptions.output.crossOriginLoading || 'anonymous'; let attrsStr = ''; for (const attrName in attrs) { let value = attrs[attrName]; attrsStr += value == null ? ` ${attrName}` : ` ${attrName}="${value}"`; } output += content.slice(pos, startPos) + `<${tag}${attrsStr}>`; pos = endPos; } } return output + content.slice(pos); }); } // 9. beforeEmit hook allows plugins to change the html after chunks and inlined assets are injected promise = promise.then((content) => hooks.beforeEmit.promise(content, compileEntry) || content); // 10. beforeEmit callback if (this.pluginOption.hasBeforeEmit()) { promise = promise.then( (content) => this.pluginOption.beforeEmit(content, compileEntry, compilation) || content ); } // update HTML content promise = promise.then((content) => { if (typeof content === 'string') { compilation.updateAsset(entryFilename, new RawSource(content), (assetInfo) => { // update assetInfo for stats tags assetInfo.minimized = isHtmlMinify; return assetInfo; }); } }); promises.push(promise); } return Promise.all(promises); } /** * Called right before an entry template will be processed. * * This is used to reset cached data before the processing of the entry template. * * @param {Number} entryId */ beforeProcessTemplate(entryId) { // clear cache only if a template is changed, but not a style or others assets, // when changing the style, the data must be got from the cache this.orderedResources.get(entryId)?.clear(); } /** * Clear cache. * Called only once when the plugin is applied. */ clear() { this.index = {}; this.data.clear(); this.assets.clear(); this.orderedResources.clear(); this.importStyleRootIssuers.clear(); this.importStyleSources.clear(); this.importStyleIdx = 1000; } /** * Reset settings. * Called before each new compilation after changes, in the serve/watch mode. */ reset() { // don't clear the index // test case: // there are 3 entries: home.html, news.html and about.html // 1. add the `script.js` to the home.html => script.js // 2. add the `script.js` to the news.html => script.1.js // 3. add the `script.js` to the about.html => script.2.js // but when the index is cleared, then after adding the file with the same name will be not unique // and is generated a js file having a wrong content //this.index = {}; // don't delete entry data, clear only assets this.data.forEach((item, key) => { if (item.assets != null) item.assets = []; }); // don't delete files, clear only assets this.assets.forEach((item, key) => { if (item.assets != null) item.assets = []; }); this.importStyleRootIssuers.clear(); this.importStyleSources.clear(); } /* istanbul ignore next: test it manual using `cache.type` as `filesystem` after 2nd run the same project */ /** * Called by first start or after changes. * * @param {Function} write The serialize function. */ serialize({ write }) { for (let [, { entry }] of this.data) { // note: set the function properties as null to able the serialization of the entry object, // the original functions will be recovered by deserialization from the cached object `AssetEntry` entry.filenameFn = null; entry.filenameTemplate = null; } write(this.assets); write(this.data); } /* istanbul ignore next: test it manual using `cache.type` as `filesystem` after 2nd run the same project */ /** * @param {Function} read The deserialize function. */ deserialize({ read }) { this.assets = read(); this.data = read(); for (let [, { entry }] of this.data) { const cachedEntry = this.assetEntry.entriesById.get(entry.id); // recovery original not serializable functions from the object cached in the memory entry.filenameFn = cachedEntry.filenameFn; entry.filenameTemplate = cachedEntry.filenameTemplate; } this.deserialized = true; } isDeserialized() { return this.deserialized; } /* istanbul ignore next: test it manual using `cache.type` as `filesystem` after 2nd run the same project */ /** * Add the script files loaded in the template to the compilation after deserialization. * * Called after deserialization when the template module is created. * At this stage, missing scripts can be added to the compilation after deserialization, * as if they were added to the compilation after parsing the template. * * @param {string} issuer The template entry file where are loaded deserialized files. */ addToCompilationDeserializedFiles(issuer) { for (const [resource, item] of this.assets) { const { type, name, entries } = item; if (type === Collection.type.script && entries.has(issuer)) { this.#addToCompilation({ name, resource, issuer }); } } } } module.exports = Collection;