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,397 lines (1,180 loc) 58.6 kB
const path = require('path'); const { AsyncSeriesHook, AsyncSeriesWaterfallHook, SyncBailHook, SyncWaterfallHook } = require('tapable'); const Compiler = require('webpack/lib/Compiler'); const Compilation = require('webpack/lib/Compilation'); const Cache = require('webpack/lib/Cache'); const AssetParser = require('webpack/lib/asset/AssetParser'); const AssetGenerator = require('webpack/lib/asset/AssetGenerator'); //const JavascriptParser = require('webpack/lib/javascript/JavascriptParser'); //const JavascriptGenerator = require('webpack/lib/javascript/JavascriptGenerator'); const { JAVASCRIPT_MODULE_TYPE_AUTO, ASSET_MODULE_TYPE, ASSET_MODULE_TYPE_INLINE, ASSET_MODULE_TYPE_RESOURCE, ASSET_MODULE_TYPE_SOURCE, } = require('webpack/lib//ModuleTypeConstants'); const { yellowBright, cyanBright, green, greenBright } = require('ansis'); const Config = require('../Common/Config'); const { baseUri, urlPathPrefix, cssLoaderName } = require('../Loader/Utils'); const { findRootIssuer } = require('../Common/CompilationHelpers'); const { isDir } = require('../Common/FileUtils'); const { parseVersion, compareVersions } = require('../Common/Helpers'); const createPersistentCache = require('./createPersistentCache')(); const CssExtractModule = require('./Modules/CssExtractModule'); const Option = require('./Option'); const PluginService = require('./PluginService'); const Collection = require('./Collection'); const Resolver = require('./Resolver'); const Snapshot = require('./Snapshot'); const UrlDependency = require('./UrlDependency'); const Asset = require('./Asset'); const AssetEntry = require('./AssetEntry'); const AssetResource = require('./AssetResource'); const AssetInline = require('./AssetInline'); const AssetTrash = require('./AssetTrash'); const VMScript = require('../Common/VMScript'); const Integrity = require('./Extras/Integrity'); const { compilationName, verbose } = require('./Messages/Info'); const { PluginError, afterEmitException } = require('./Messages/Exception'); const loaderPath = require.resolve('../Loader'); const LoaderFactory = require('../Loader/LoaderFactory'); const { pluginName } = Config.get(); /** * The CSS loader. * * @type {{loader: string, ident: undefined, options: undefined, type: undefined}} */ const cssLoader = { loader: require.resolve('../Loader/cssLoader.js'), type: undefined, options: undefined, ident: undefined, }; /** @typedef {import('webpack/declarations/WebpackOptions').Output} WebpackOutputOptions */ /** @typedef {import('webpack').Compiler} Compiler */ /** @typedef {import('webpack').Compilation} Compilation */ /** @typedef {import('webpack/lib/FileSystemInfo')} FileSystemInfo */ /** @typedef {import('webpack/lib/FileSystemInfo').Snapshot} FileSystemSnapshot */ /** @typedef {import('webpack').ChunkGraph} ChunkGraph */ /** @typedef {import('webpack').Chunk} Chunk */ /** @typedef {import('webpack').Module} Module */ /** @typedef {import('webpack').sources.Source} Source */ /** @typedef {import('webpack-sources').RawSource} RawSource */ /** @typedef {import('webpack').Configuration} Configuration */ /** @typedef {import('webpack').PathData} PathData */ /** @typedef {import('webpack').AssetInfo} AssetInfo */ /** * @typedef {Module} PluginModuleMeta Meta information for module generated by the plugin. * @property {boolean} isTemplate * @property {boolean} isScript * @property {boolean} isStyle * @property {boolean} isImportedStyle * @property {boolean} isLoaderImport * @property {boolean} isDependencyUrl */ /** * @typedef {Object} FileInfo * @property {string} resource The resource file, including a query. * @property {string|undefined} filename The output filename. */ /** @type {WeakMap<Compilation, HtmlBundlerPlugin.Hooks>} */ const compilationHooksMap = new WeakMap(); let HotUpdateChunk; let RawSource; class AssetCompiler { static processAssetsPromises = []; /** Whether the installed Webpack version < 5.96.0 */ IS_WEBPACK_VERSION_LOWER_5_96_0 = true; /** @type {Array<Promise>} */ promises = []; /** @type AssetEntryOptions The current entry point during dependency compilation. */ currentEntryPoint; /** @type Set<Error> Buffered exceptions thrown in hooks. */ exceptions = new Set(); isSnapshotInitialized = false; /** @type {Compilation} */ compilation = null; /** @type {Option} The alias to pluginOption for 3rd party plugins */ option = null; pluginOption = null; pluginContext = { compilation: null, asset: null, assetEntry: null, assetInline: null, assetResource: null, assetTrash: null, collection: null, cssExtractModule: null, resolver: null, /** @type Option */ pluginOption: null, urlDependency: null, loaderDependency: null, }; // data file => entry files, uses for watching changes in data file, then recompile entries where it used dataFileEntryMap = new Map(); /** @type {FileSystem} */ fs = null; /** * @param {Compilation} compilation The compilation. * @returns {HtmlBundlerPlugin.Hooks} The attached hooks. */ static getHooks(compilation) { if (!(compilation instanceof Compilation)) { throw new TypeError(`The 'compilation' argument must be an instance of Compilation`); } let hooks = compilationHooksMap.get(compilation); if (hooks == null) { hooks = { // use a bail or waterfall hook when the hook returns something beforePreprocessor: new AsyncSeriesWaterfallHook(['content', 'loaderContext']), preprocessor: new AsyncSeriesWaterfallHook(['content', 'loaderContext']), // TODO: implement afterPreprocessor when will be required the feature //afterPreprocessor: new AsyncSeriesWaterfallHook(['content', 'loaderContext']), resolveSource: new SyncWaterfallHook(['source', 'info']), postprocess: new AsyncSeriesWaterfallHook(['content', 'info']), beforeEmit: new AsyncSeriesWaterfallHook(['content', 'entry']), afterEmit: new AsyncSeriesHook(['entries']), integrityHashes: new AsyncSeriesHook(['hashes']), }; compilationHooksMap.set(compilation, hooks); } return hooks; } /** * @param {PluginOptions|{}} options */ constructor(options = {}) { this.pluginOption = new Option(this.pluginContext, { options, loaderPath: loaderPath }); this.option = this.pluginOption; // TODO: refactor replace all usages this.pluginOption > this.pluginContext.pluginOption this.pluginContext.pluginOption = this.pluginOption; this.assetTrash = new AssetTrash({ compilation: this.compilation }); this.pluginContext.assetTrash = this.assetTrash; this.collection = new Collection(this.pluginContext); this.pluginContext.collection = this.collection; this.asset = new Asset(); this.pluginContext.asset = this.asset; this.assetInline = new AssetInline(); this.pluginContext.assetInline = this.assetInline; this.assetEntry = new AssetEntry({ ...this.pluginContext, entryLibrary: this.pluginOption.getEntryLibrary() }); this.pluginContext.assetEntry = this.assetEntry; this.resolver = new Resolver(this.pluginContext); this.pluginContext.resolver = this.resolver; this.cssExtractModule = new CssExtractModule(this.pluginContext); this.pluginContext.cssExtractModule = this.cssExtractModule; this.assetResource = new AssetResource(this.pluginContext); this.pluginContext.assetResource = this.assetResource; this.urlDependency = new UrlDependency(this.pluginContext); this.pluginContext.urlDependency = this.urlDependency; // bind the instance context for using these methods as references in Webpack hooks this.compile = this.compile.bind(this); this.invalidate = this.invalidate.bind(this); this.afterEntry = this.afterEntry.bind(this); this.beforeResolve = this.beforeResolve.bind(this); this.afterResolve = this.afterResolve.bind(this); this.beforeModule = this.beforeModule.bind(this); this.afterCreateModule = this.afterCreateModule.bind(this); this.beforeLoader = this.beforeLoader.bind(this); this.afterBuildModule = this.afterBuildModule.bind(this); this.renderManifest = this.renderManifest.bind(this); this.processAssetsOptimizeSize = this.processAssetsOptimizeSize.bind(this); this.processAssetsFinalAsync = this.processAssetsFinalAsync.bind(this); this.filterAlternativeRequests = this.filterAlternativeRequests.bind(this); this.afterEmit = this.afterEmit.bind(this); this.done = this.done.bind(this); this.shutdown = this.shutdown.bind(this); this.watch = this.watch.bind(this); } /** * Called when a compiler object is initialized. * Abstract method should be overridden in an extended class. * * @api * * @param {Compiler} compiler The instance of the webpack compiler. * @abstract */ init(compiler) {} /** * Add default loader for entry files. */ addLoader() { const defaultLoader = { test: this.pluginOption.get().test, // ignore 'asset/source' with the '?raw' query // see https://webpack.js.org/guides/asset-modules/#replacing-inline-loader-syntax resourceQuery: { not: [/raw/] }, loader: loaderPath, }; this.pluginOption.addLoader(defaultLoader); } /** * Add the process to pipeline. * * @api The public method can be used in an extended plugin. * * @param {string} name The name of process. Currently supported only `postprocess` pipeline. * @param {Function: (content: string) => string} fn The process function to modify the generated content. */ addProcess(name, fn) { this.pluginOption.addProcess(name, fn); } /** * Apply plugin. * * @param {Compiler} compiler */ apply(compiler) { if (!this.pluginOption.isEnabled()) return; const { webpack } = compiler; HotUpdateChunk = webpack.HotUpdateChunk; RawSource = webpack.sources.RawSource; this.promises = []; this.fs = compiler.inputFileSystem.fileSystem; this.webpack = webpack; LoaderFactory.init(compiler); this.pluginContext.loaderDependency = LoaderFactory.createDependency(compiler); this.assetEntry.setCompiler(compiler); this.pluginOption.initWebpack(compiler); this.assetResource.init(compiler); this.init(compiler); this.addLoader(); // must be called after all initialisations of the pluginOption this.resolver.init({ fs: this.fs }); // initialize integrity plugin this.integrityPlugin = new Integrity(this.pluginOption); // clear caches for tests in serve/watch mode this.assetEntry.clear(); this.assetInline.clear(); this.collection.clear(); this.resolver.clear(); this.dataFileEntryMap.clear(); Snapshot.clear(); PluginError.clear(); // let know the loader that the plugin is being used // TODO: init by PluginIndex, for each instance create own PluginService instance for pluginOption PluginService.init(compiler, this.pluginContext, AssetCompiler); if (this.pluginOption.isCacheable()) { const collectionCache = createPersistentCache(this.collection); const cache = compiler.getCache(pluginName).getItemCache('PersistentCache', null); let isCached = false; compiler.hooks.beforeCompile.tap(pluginName, () => { cache.get((error, data) => { if (error) { throw new Error(error); } isCached = !!data; }); }); // note: if used `tapAsync` then no webpack statistics or errors will be displayed // then use in the `done` hook the output of `stats.compilation.options.stats` in Promise.finally //compiler.cache.hooks.shutdown.tapAsync({ name: pluginName, stage: Cache.STAGE_DISK }, () => { compiler.cache.hooks.shutdown.tap({ name: pluginName, stage: Cache.STAGE_DISK }, () => { if (!isCached) { const cacheData = collectionCache.getData(); cache.store(cacheData, (error) => { if (error) { throw new Error(error); } }); } }); } // entry option this.assetEntry.init({ fs: this.fs, }); compiler.hooks.watchRun.tap(pluginName, this.watch); compiler.hooks.entryOption.tap(pluginName, this.afterEntry); compiler.hooks.invalid.tap(pluginName, this.invalidate); compiler.hooks.thisCompilation.tap(pluginName, this.compile); compiler.hooks.afterEmit.tapPromise(pluginName, this.afterEmit); compiler.hooks.done.tapPromise(pluginName, this.done); compiler.hooks.shutdown.tap(pluginName, this.shutdown); compiler.hooks.watchClose.tap(pluginName, this.shutdown); // run integrity plugin if (this.pluginOption.isIntegrityEnabled()) this.integrityPlugin.apply(compiler); } /** * Called in watch mode after a new compilation is triggered * but before the compilation is actually started. * * @param {Compiler} compiler */ watch(compiler) { // create dependencies map of the entry templates by data file if (this.dataFileEntryMap.size === 0) { const pluginOption = this.pluginOption.get(); const globalData = pluginOption.data; this.assetEntry.entriesById.forEach((item) => { if (item.dataFile) { this.dataFileEntryMap.set(item.dataFile, [item.sourceFile]); } }); if (typeof globalData === 'string') { const revolvedDataFile = PluginService.resolveFile(compiler, globalData); const entryFiles = Array.from(this.assetEntry.getEntryFiles()); this.dataFileEntryMap.set(revolvedDataFile, entryFiles); } } // TODO: avoid double calling by multi-config //console.log('===> hooks.watchRun.tap', { id: compiler.name }); this.pluginOption.initWatchMode(); PluginService.setWatchMode(compiler, true); PluginService.watchRun(compiler); } /** * Compile modules. * * @param {Compilation} compilation * @param {NormalModuleFactory} normalModuleFactory * @param {ContextModuleFactory} contextModuleFactory */ compile(compilation, { normalModuleFactory, contextModuleFactory }) { const fs = this.fs; const { NormalModule, Compilation } = compilation.compiler.webpack; const normalModuleHooks = NormalModule.getCompilationHooks(compilation); const renderStage = this.pluginOption.getRenderStage(); this.IS_WEBPACK_VERSION_LOWER_5_96_0 = compareVersions(compilation.compiler.webpack.version, '<', '5.96.0'); this.compilation = compilation; this.pluginContext.compilation = compilation; this.assetEntry.setCompilation(compilation); this.assetTrash.init(compilation); this.cssExtractModule.init(compilation); this.urlDependency.init({ compilation, fs }); this.collection.init({ hooks: AssetCompiler.getHooks(compilation), }); // resolve modules normalModuleFactory.hooks.beforeResolve.tap(pluginName, this.beforeResolve); normalModuleFactory.hooks.afterResolve.tap(pluginName, this.afterResolve); contextModuleFactory.hooks.alternativeRequests.tap(pluginName, this.filterAlternativeRequests); // build modules // createModuleClass requires v5.81+ normalModuleFactory.hooks.createModuleClass.for(JAVASCRIPT_MODULE_TYPE_AUTO).tap(pluginName, this.beforeModule); normalModuleFactory.hooks.module.tap(pluginName, this.afterCreateModule); compilation.hooks.buildModule.tap(pluginName, this.beforeBuildModule); compilation.hooks.succeedModule.tap(pluginName, this.afterBuildModule); // called when a module build has failed compilation.hooks.failedModule.tap(pluginName, (module, error) => { // TODO: collect errors }); // called after the succeedModule hook but right before the execution of a loader normalModuleHooks.loader.tap(pluginName, this.beforeLoader); // render source code of modules compilation.hooks.renderManifest.tap(pluginName, this.renderManifest); // Notes: // - the TerserPlugin creates a `.LICENSE.txt` file at the PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE stage // - the integrity hash will be created at the next stage, therefore, // the license file and the license banner must be removed before PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE compilation.hooks.processAssets.tap( { name: pluginName, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE + 1 }, this.processAssetsOptimizeSize ); // after render module's sources // only in the processAssets hook is possible to modify an asset content via async function compilation.hooks.processAssets.tapPromise({ name: pluginName, stage: renderStage }, this.processAssetsFinalAsync); // output asset info tags in console statistics compilation.hooks.statsPrinter.tap(pluginName, (stats) => { stats.hooks.print.for('asset.info.minimized').tap(pluginName, (minimized, { green, formatFlag }) => { if (!minimized) { return ''; } if (!green || !formatFlag) { return 'minimized'; } return green(formatFlag('minimized')); }); }); } /* istanbul ignore next: this method is called in watch mode after changes */ /** * Invalidate changed file. * Called in serve/watch mode. * * Limitation: currently supports for change only a single file. * * TODO: add supports to add/remove many files. * The problem: if added/removed many files, * then webpack calls the 'invalid' hook many times, for each file separately. * Research: find the hook, what called once, before the 'invalid' hook, * to create the snapshot of files after change. * * @param {string} fileName The old filename before change. * @param {Number|null} changeTime */ invalidate(fileName, changeTime) { const fs = this.fs; const entryDir = this.pluginOption.getEntryPath(); const isDirectory = isDir({ fs, file: fileName }); Snapshot.create(); if (this.dataFileEntryMap.has(fileName)) { const entryFiles = this.dataFileEntryMap.get(fileName); if (this.pluginOption.isVerbose()) { console.log(yellowBright`Modified data file: ${cyanBright(fileName)}`); } for (const module of this.compilation.modules) { const moduleResource = module.resource || ''; if (moduleResource && entryFiles.find((file) => file === moduleResource)) { this.compilation.rebuildModule(module, (error) => { if (this.pluginOption.isVerbose()) { console.log(greenBright` -> Rebuild dependency: ${cyanBright(moduleResource)}`); } }); } } } if (isDirectory === true) return; const { actionType, newFileName, oldFileName } = Snapshot.detectFileChange(); const isScript = this.pluginOption.isScript(fileName); const inCollection = this.collection.hasScript(fileName); const isEntryFile = (file) => file && file.startsWith(entryDir) && this.pluginOption.isEntry(file); // 1. Invalidate an entry template. if ( this.pluginOption.isDynamicEntry() && (isEntryFile(fileName) || isEntryFile(oldFileName) || isEntryFile(newFileName)) ) { switch (actionType) { case 'modify': this.collection.disconnectEntry(fileName); break; case 'add': this.assetEntry.addEntry(newFileName); this.collection.disconnectEntry(newFileName); break; case 'rename': this.assetEntry.deleteEntry(oldFileName); this.assetEntry.addEntry(newFileName); break; case 'remove': this.assetEntry.deleteEntry(oldFileName); break; default: break; } return; } // 2. Invalidate a JavaScript file loaded in an entry template. if (actionType && isScript) { switch (actionType) { case 'add': // through case 'rename': const missingFiles = Snapshot.getMissingFiles(); const { modules } = this.compilation; missingFiles.forEach((files, issuer) => { const missingFile = Array.from(files).find((file) => newFileName.endsWith(file)); // if an already used js file was unlinked in html and then renamed if (!missingFile) return; for (const module of modules) { // the same template can be in many modules if (module.resource === issuer || module.resource === newFileName) { // reset errors for an unresolved js file, because the file can be renamed module._errors = []; // after rename a js file, try to rebuild the module of the entry file where the js file was linked this.compilation.rebuildModule(module, (error) => { // after rebuild, remove the missing file to avoid double rebuilding by another exception Snapshot.deleteMissingFile(issuer, missingFile); this.assetEntry.deleteMissingFile(missingFile); }); } } }); break; case 'remove': // do nothing break; default: break; } if (inCollection && (actionType === 'remove' || actionType === 'rename')) { this.assetEntry.deleteEntry(oldFileName); } return; } // 3. if a partial is changed then rebuild all entry templates, // because we don't have the dependency graph of the partial on the main template const isEntry = this.assetEntry.isEntryResource(fileName); if (!isEntry) { const dependency = PluginService.getDependencyInstance(this.compilation.compiler); // dependency is null when no html entry defined and a style in entry was changed if (dependency) { const isFileWatchable = dependency.isFileWatchable(fileName); const isTemplate = this.pluginOption.isEntry(fileName); if (isTemplate || isFileWatchable) { if (this.pluginOption.isVerbose()) { console.log(yellowBright`Modified partial: ${cyanBright(fileName)}`); } for (const module of this.compilation.modules) { const moduleResource = module.resource || ''; if (moduleResource && this.assetEntry.isEntryResource(moduleResource)) { this.compilation.rebuildModule(module, (error) => { if (error) { // TODO: research the strange error - "Cannot read properties of undefined (reading 'state')" // in node_modules/webpack/lib/util/AsyncQueue.js:196 } if (this.pluginOption.isVerbose()) { console.log(greenBright` -> Rebuild entrypoint: ${cyanBright(moduleResource)}`); } }); } } } } } } /** * Called after the entry configuration from webpack options has been processed. * * @param {string} context The base directory, an absolute path, for resolving entry points and loaders from the configuration. * @param {Object<name:string, entry: Object>} entries The webpack entries. */ afterEntry(context, entries) { this.assetEntry.addEntries(entries); } /** * Filter alternative requests. * * Entry files should not have alternative requests. * If the template file contains require and is compiled with `compile` mode, * then ContextModuleFactory generates additional needless request as the relative path without a query. * Such 'alternative request' must be removed from compilation. * * @param {Array<{}>} requests * @param {{}} options * @return {Array|undefined} Returns only alternative requests not related to entry files. */ filterAlternativeRequests(requests, options) { // skip the request required as 'asset/source' with the '?raw' resourceQuery // see https://webpack.js.org/guides/asset-modules/#replacing-inline-loader-syntax if (/\?raw/.test(options.resourceQuery)) return; return requests.filter((item) => !this.pluginOption.isEntry(item.request)); } /** * Called when a new dependency request is encountered. * * @param {Object} resolveData * @return {boolean|undefined} Return undefined to processing, false to ignore dependency. */ beforeResolve(resolveData) { const { request, dependencyType } = resolveData; const [file] = request.split('?', 1); const entryId = this.assetEntry.resolveEntryId(resolveData); /** @type PluginModuleMeta */ const meta = { isTemplate: this.assetEntry.isEntryResource(file), isScript: false, isStyle: false, isImportedStyle: false, isParentLoaderImport: false, isLoaderImport: dependencyType === 'loaderImport', isDependencyUrl: dependencyType === 'url', }; resolveData._bundlerPluginMeta = meta; resolveData.entryId = entryId; /* istanbul ignore next */ // prevent compilation of renamed or deleted entry point in serve/watch mode if (this.pluginOption.isDynamicEntry() && this.assetEntry.isDeletedEntryFile(file)) { for (const [entryName, entry] of this.compilation.entries) { if (entry.dependencies[0]?.request === request) { // delete the entry from compilation to prevent creation unused chunks this.compilation.entries.delete(entryName); } } return false; } if (meta.isDependencyUrl) { this.urlDependency.resolve(resolveData); } } /** * Called after the request is resolved. * * @param {Object} resolveData * @return {boolean|undefined} Return undefined to processing, false to ignore dependency. */ afterResolve(resolveData) { const { request, contextInfo, dependencyType, createData, _bundlerPluginMeta: meta } = resolveData; const { resource } = createData; const [file] = resource.split('?', 1); // note: the contextInfo.issuer is the filename w/o a query const { issuer } = contextInfo; // the filename with an extension is available only after resolve meta.isStyle = this.pluginOption.isStyle(file); meta.isCSSStyleSheet = this.isCSSStyleSheet(createData); // skip: module loaded via importModule, css url, data-URL if (meta.isLoaderImport || meta.isCSSStyleSheet || meta.isDependencyUrl || request.startsWith('data:')) return; if (issuer) { const isIssuerStyle = this.pluginOption.isStyle(issuer); const parentModule = resolveData.dependencies[0]?._parentModule; const { isLoaderImport } = parentModule?.resourceResolveData?._bundlerPluginMeta || {}; // skip the module loaded via importModule if (isLoaderImport) { meta.isParentLoaderImport = true; return; } // exclude from compilation the css-loader runtime scripts for styles specified in HTML only, // to avoid splitting the loader runtime scripts; // allow runtime scripts for styles imported in JavaScript, regards deep imported styles via url() if (isIssuerStyle && file.endsWith('.js')) { const rootIssuer = findRootIssuer(this.compilation, issuer); meta.isScript = true; // return true if the root issuer is a JS (not style and not template), otherwise return false return rootIssuer != null && !this.pluginOption.isStyle(rootIssuer) && !this.pluginOption.isEntry(rootIssuer); } // style loaded in *.vue file if (request.includes('?vue&')) { const { type } = Object.fromEntries(new URLSearchParams(request).entries()); if (type === 'style') { meta.isStyle = true; meta.isVueStyle = true; } } // try to detect imported style as resolved resource file, because a request can be a node module w/o an extension // the issuer can be a style if a scss contains like `@import 'main.css'` if (!this.pluginOption.isStyle(issuer) && !this.pluginOption.isEntry(issuer) && meta.isStyle) { const rootIssuer = findRootIssuer(this.compilation, issuer); this.collection.importStyleRootIssuers.add(rootIssuer || issuer); meta.isImportedStyle = true; if (!createData.request.includes(cssLoader.loader)) { // the request of an imported style must be different from the request for the same style specified in a html, // otherwise webpack doesn't apply the added loader for the imported style, // see the test case js-import-css-same-in-many4 createData.request = `${cssLoader.loader}!${createData.request}`; if (meta.isVueStyle) { createData.loaders = this.filterStyleLoaders(createData.loaders, parentModule.loaders); } else { createData.loaders = [cssLoader, ...createData.loaders]; } } } } meta.isScript = this.collection.hasScript(request); } /** * Whether the module is imported CSSStyleSheet in JS. * * @param {{}} module * @return {boolean} */ isCSSStyleSheet(module) { return ( Array.isArray(module.loaders) && module?.loaders.some( (loader) => loader.loader.includes('css-loader') && loader.options?.exportType === 'css-style-sheet' ) ); } /** * Returns unique style loaders only. * * If a style file is imported in *.vue file then: * - remove the needles vue loader * - remove double loaders, occurs when using the lang="scss" attribute, e.g.: <style src="./style.scss" lang="scss"> * * @param {Array<Object>} loaders The mishmash of loaders with duplicates. * @param {Array<Object> | []} parentLoaders The issuer loaders. * @return {Array<Object>} The style loaders. */ filterStyleLoaders(loaders, parentLoaders) { const loaderRegExp = /([\\/]node_modules[\\/].+?[\\/])/; const parentLoaderNames = []; const uniqueStyleLoaders = new Map(); for (let { loader } of parentLoaders) { let [loaderName] = loader.match(loaderRegExp); if (loaderName) parentLoaderNames.push(loaderName); } // ignore endpoint (first) loader used by the issuer, e.g., when a style is imported in *.vue file, ignore vue loader if (parentLoaderNames.find((name) => loaders[0].loader.includes(name))) { loaders.shift(); } for (let item of loaders) { // skip duplicate loader if (uniqueStyleLoaders.has(item.loader)) continue; uniqueStyleLoaders.set(item.loader, item); } return [cssLoader, ...uniqueStyleLoaders.values()]; } /** * Called after the `createModule` hook and before the `module` hook. * * @param {Object} createData * @param {Object} resolveData */ beforeModule(createData, resolveData) { const { _bundlerPluginMeta: meta } = resolveData; const query = createData.resourceResolveData?.query || ''; const isUrl = query.includes('url'); // lazy load CSS in JS using `?url` query, see js-import-css-lazy-url if (meta.isImportedStyle && isUrl && !query.includes(cssLoaderName)) { const filename = this.pluginOption.getCss().filename; if (this.IS_WEBPACK_VERSION_LOWER_5_96_0) { // Webpack <= 5.95 createData.generator = new AssetGenerator(undefined, filename); } else { // Webpack >= 5.96 const moduleGraph = this.compilation.moduleGraph; const dataUrl = undefined; const publicPath = undefined; const outputPath = undefined; const emit = true; createData.generator = new AssetGenerator(moduleGraph, dataUrl, filename, publicPath, outputPath, emit); } createData.parser = new AssetParser(false); createData.type = ASSET_MODULE_TYPE_RESOURCE; } } /** * Called after a module instance is created. * * @param {Module} module The Webpack module. * @param {Object} createData * @param {Object} resolveData */ afterCreateModule(module, createData, resolveData) { const { _bundlerPluginMeta: meta } = resolveData; const { rawRequest, resource } = createData; this.assetEntry.connectEntryAndModule(module, resolveData); // skip the module loaded via importModule if (meta.isLoaderImport || meta.isParentLoaderImport) return; const { type, loaders } = module; const { issuer } = resolveData.contextInfo; // add missed scripts to compilation after deserialization if (meta.isTemplate) { if (this.collection.isDeserialized()) { this.collection.addToCompilationDeserializedFiles(resource); } return; } if (!issuer || this.assetInline.isDataUrl(rawRequest)) return; if ( type === ASSET_MODULE_TYPE || type === ASSET_MODULE_TYPE_INLINE || (type === ASSET_MODULE_TYPE_SOURCE && this.assetInline.isSvgFile(resource)) ) { this.assetInline.add(resource, issuer, this.pluginOption.isEntry(issuer)); } if (meta.isDependencyUrl && meta.isScript) return; // add resolved sources in use cases: // - if used url() in SCSS for source assets // - if used import url() in CSS, like `@import url('./styles.css');` // - if used webpack context if (meta.isDependencyUrl || loaders.length > 0 || type === ASSET_MODULE_TYPE_RESOURCE) { this.resolver.addSourceFile(resource, rawRequest, issuer); } } /** * Called before a module build has started. * Use this method to modify the module. * * @param {{}} module The extended Webpack module. */ beforeBuildModule(module) { // do nothing, reserved for debugging } /** * Called after the build module but right before the execution of a loader. * * @param {Object} loaderContext The Webpack loader context. * @param {Object} module The extended Webpack module. */ beforeLoader(loaderContext, module) { const { isTemplate, isLoaderImport } = module.resourceResolveData._bundlerPluginMeta; // skip the module loaded via importModule if (isLoaderImport) return; if (isTemplate) { const entryId = this.assetEntry.getEntryId(module); const entry = this.assetEntry.getById(entryId); if (entry.isTemplate && entry.resource === module.resource) { this.beforeProcessTemplate(entryId); } loaderContext.entryId = entryId; loaderContext.entryName = entry.originalName; loaderContext.entryData = this.assetEntry.getData(entryId); } } /** * Called after a module has been built successfully, after loader processing. * * Note: when the `cache.type` option is set to 'filesystem', then by 2nd `npm start` this hook will not be called. * * @param {Object} module The Webpack module. */ afterBuildModule(module) {} /** * @param {Array<Object>} result * @param {Object} chunk * @param {Object} chunkGraph * @param {Object} outputOptions * @param {Object} codeGenerationResults */ renderManifest(result, { chunk, chunkGraph, codeGenerationResults }) { if (chunk instanceof HotUpdateChunk) return; const entry = this.assetEntry.getByChunk(chunk); // process only entries supported by this plugin if (!entry || (!entry.isTemplate && !entry.isStyle)) return; const chunkModules = chunkGraph.getChunkModulesIterable(chunk); const assetModules = new Set(); this.collection.addEntry(entry); // reserved solution // problem: if used `splitChunks.chunks` then, some assets may not found in chunkModules // solution: the `chunks` option must be defined in `splitChunks.cacheGroups.{cacheGroup}.chunks` only // see the test `resolve-image-in-multipages-splitChunks` // for (const [key, module] of this.compilation._modules.entries()) { // if (key.startsWith(ASSET_MODULE_TYPE_RESOURCE)) { // this.assetResource.saveData(module); // } // } for (const module of chunkModules) { const { error, buildInfo, resource, resourceResolveData } = module; const { isScript, isImportedStyle, isCSSStyleSheet } = resourceResolveData?._bundlerPluginMeta || {}; if (error) { // stop further processing of modules in webpack and display an error message return false; } if ( isScript || isImportedStyle || isCSSStyleSheet || !resource || !resourceResolveData?.context || this.assetInline.isDataUrl(resource) ) { // do nothing for scripts because webpack itself compiles and extracts JS files from scripts continue; } const contextIssuer = resourceResolveData.context.issuer; // note: the contextIssuer may be wrong, as previous entry, because Webpack distinct same modules by first access let issuer = contextIssuer === entry.sourceFile ? entry.resource : contextIssuer; if (!issuer || this.pluginOption.isEntry(issuer)) { issuer = entry.resource; } let moduleType = module.type; // decide an asset type by webpack option parser.dataUrlCondition.maxSize if (moduleType === ASSET_MODULE_TYPE) { moduleType = buildInfo.dataUrl === true ? ASSET_MODULE_TYPE_INLINE : ASSET_MODULE_TYPE_RESOURCE; } switch (moduleType) { case JAVASCRIPT_MODULE_TYPE_AUTO: const assetModule = this.createAssetModule(entry, chunk, module); if (assetModule == null) continue; if (assetModule === false) return; assetModules.add(assetModule); break; case ASSET_MODULE_TYPE_RESOURCE: // resource required in the template or in the CSS via url() this.assetResource.saveData(module); break; case ASSET_MODULE_TYPE_INLINE: this.assetInline.saveData(entry, chunk, module, codeGenerationResults); break; case ASSET_MODULE_TYPE_SOURCE: // support the source type for SVG only if (this.assetInline.isSvgFile(resource)) { this.assetInline.saveData(entry, chunk, module, codeGenerationResults); } break; default: // do nothing } } // 1. render entries and styles specified in HTML for (const module of assetModules) { const { fileManifest } = module; let content = this.renderModule(module); if (content == null) continue; if (typeof content === 'string') content = new RawSource(content); fileManifest.render = () => content; fileManifest.filename = module.assetFile; result.push(fileManifest); } // 2. renders styles imported in JavaScript if (!this.option.isCssHot() && this.collection.hasImportedStyle(this.currentEntryPoint?.id)) { this.renderImportStyles(result, { chunk }); } } /** * Called in PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE stage. * * @param {CompilationAssets} assets */ processAssetsOptimizeSize(assets) { if (!this.pluginOption.isExtractComments()) { this.assetTrash.removeComments(); } } /** * Called after render module's sources, after all optimizations. * * @param {CompilationAssets} assets */ processAssetsFinalAsync(assets) { if (PluginError.size > 0) { // when the previous compilation hook has an error, then skip this hook return Promise.resolve(); } return this.collection .render(assets) .then(() => { // remove all unused assets from compilation this.assetTrash.clearCompilation(); }) .catch((error) => { // this hook doesn't provide testable exceptions, therefore, save an exception to throw it in the done hook this.exceptions.add(error); }); } /** * @param {AssetEntryOptions} entry The entry point of the chunk. * @param {Chunk} chunk The chunk of an asset. * @param {Module} module The module of the chunk. * @return {Object|null|boolean} assetModule Returns the asset module object. * If returns undefined, then skip processing of the module. * If returns null, then break the hook processing to show the original error, occurs by an inner error. */ createAssetModule(entry, chunk, module) { const { compilation } = this; const { buildInfo, resource } = module; const [sourceFile] = resource.split('?', 1); const source = module.originalSource(); // break process if occurs an error in module builder if (source == null) return false; // note: the `id` is // - in production mode as a number // - in development mode as a relative path const moduleId = compilation.chunkGraph.getModuleId(module); const assetModule = { // resourceInfo outputPath: undefined, filename: undefined, // renderContent arguments type: undefined, inline: false, source, sourceFile, resource, assetFile: undefined, fileManifest: { identifier: undefined, hash: undefined, }, }; if (sourceFile === entry.sourceFile) { const assetFile = entry.filename; // note: the entry can be not a template file, e.g., a style or script defined directly in entry if (entry.isTemplate) { this.currentEntryPoint = entry; assetModule.type = Collection.type.template; // save the template request with the query, because it can be resolved with different output paths: // - 'index': './index.ext' => dist/index.html // - 'index/de': './index.ext?lang=de' => dist/de/index.html this.asset.add(resource, assetFile); } else if (this.pluginOption.isStyle(sourceFile)) { assetModule.type = Collection.type.style; } else { // skip an unsupported entry type return; } assetModule.name = entry.originalName; assetModule.outputPath = entry.outputPath; assetModule.filename = entry.filenameTemplate; assetModule.assetFile = assetFile; assetModule.fileManifest.identifier = `${pluginName}.${chunk.id}`; assetModule.fileManifest.hash = chunk.contentHash['javascript']; return assetModule; } // fix #88: when used js dynamic import with magic comments /* webpackPrefetch: true */ and css.inline=true if (!this.currentEntryPoint && entry.isTemplate) { this.currentEntryPoint = entry; } // extract CSS const cssOptions = this.pluginOption.getStyleOptions(sourceFile); if (cssOptions == null) { // ignore file if css option is disabled return; } const inline = this.collection.isInlineStyle(resource); const { name } = path.parse(sourceFile); const hash = buildInfo.assetInfo?.contenthash || buildInfo.hash; const { isCached, filename } = this.getStyleAsseFile({ name, chunkId: chunk.id, hash, resource: sourceFile, }); const assetFile = inline ? this.getInlineStyleAsseFile(filename, this.currentEntryPoint.filename) : filename; const data = { type: Collection.type.style, inline, resource, assetFile, }; this.collection.setData(this.currentEntryPoint, null, data); this.resolver.addAsset({ resource, filename: assetFile }); // skip already processed styles except inlined if (isCached && !inline) { return; } assetModule.type = Collection.type.style; assetModule.inline = inline; assetModule.outputPath = cssOptions.outputPath; assetModule.filename = cssOptions.filename; assetModule.assetFile = assetFile; assetModule.fileManifest.identifier = `${pluginName}.${chunk.id}.${moduleId}`; assetModule.fileManifest.hash = hash; return assetModule; } /** * Render styles imported in JavaScript. * * TODO: preload for images from imported styles * * @param {Array<Object>} result * @param {Object} chunk */ renderImportStyles(result, { chunk }) { const { createHash } = this.webpack.util; const isAutoPublicPath = this.pluginOption.isAutoPublicPath(); const publicPath = this.pluginOption.getPublicPath(); const esModule = this.collection.isImportStyleEsModule(); const urlRegex = new RegExp(`${esModule ? baseUri : ''}${urlPathPrefix}(.+?)(?=\\))`, 'g'); const entry = this.currentEntryPoint; const entryFilename = entry.filename; const orderedRootIssuers = this.collection.orderedResources.get(entry.id); for (const issuer of orderedRootIssuers) { // Fix #68: if the same `c.css` file was imported in many js files: `a.js` and `b.js`, // then webpack processes the css module only for 1st `a.js`, others issuers will be ignored, // then we lost relation: a.js -> c.css (ok) but b.js -> c.css (lost). // So we can't use the following check for avoid unnecessary searching in js files where no CSS has been imported // Side-effect: increases build time for cases when many js files do not import css. // TODO: create a cache for js files that don't import CSS. // if (!this.collection.importStyleRootIssuers.has(issuer)) { // console.log('--- importStyleRootIssuers: ', { // entryFilename, // issuer, // importStyleRootIssuers: this.collection.importStyleRootIssuers, // }); // continue; // } const issuerEntry = this.assetEntry.getByResource(issuer); const sources = []; const resources = []; const imports = []; const inlineSources = []; const inlineResources = []; const inlineImports = []; let cssHash = ''; // 1. get styles from all nested files imported in the root JS file and sort them const modules = this.collection.findImportedModules(entry.id, issuer, chunk); // 2. squash styles from all nested files and group by inline/file type const uniqueModuleIds = new Set(); for (const { module } of modules) { if (uniqueModuleIds.has(module.debugId)) { continue; } const urlQuery = module.resourceResolveData?.query || ''; const isUrl = urlQuery.includes('url'); const isInline = this.pluginOption.isInlineCss(urlQuery); const importData = { resource: module.resource, assets: [], }; // note: webpack self replaces inlined images in imported style, do nothing for it const { assetsInfo } = module.buildInfo; if (assetsInfo) { for (const [assetFile, asset] of assetsInfo) { const sourceFilename = asset.sourceFilename; const stylePath = path.dirname(module.resource); const data = { type: Collection.type.resource, inline: isInline, resource: path.resolve(stylePath, sourceFilename), assetFile: assetFile, issuer: { resource: module.resource, }, }; importData.assets.push(data); } } if (isUrl) { // get url of css output filename in js for the lazy load this.collection.setData( entry, { resource: issuer }, { type: Collection.type.style, // lazy file can't be inlined, it makes no sense inline: false, lazyUrl: true, resource: module.resource, assetFile: module.buildInfo.filename, } ); continue; } cssHash += module.buildInfo.hash; uniqueModuleIds.add(module.debugId); if (isInline) { inlineSources.push(...module._cssSource); inlineResources.push(module.resource); inlineImports.push(importData); } else { sources.push(...module._cssSource); resources.push(module.resource); imports.push(importData); } } if (sources.length === 0 && inlineSources.length === 0) continue; // 3. generate output filename // mixin importStyleIdx into hash to generate new hash after changes cssHash += this.collection.importStyleIdx++; const hash = createHash('md4').update(cssHash).digest('hex'); const { isCached, filename } = this.getStyleAsseFile({ name: issuerEntry.name, chunkId: chunk.id, hash, resource: issuer, useChunkFilename: true, }); // CSS injected into HTML if (inlineSources.length) { const assetFile = this.getInlineStyleAsseFile(filename, entryFilename); const outputFilename = assetFile; this.collection.setData( entry, { resource: issuer }, { type: Collection.type.style, inline: true, imported: true, // if style is imported then resource is the array of imported source files resource: inlineResources, assetFile: outputFilename, imports: inlineImports, } ); // 4. extracts CSS content from squashed sources const issuerFilename = entryFilename; const resolveAssetFile = (match, file) => isAutoPublicPath ? this.pluginOption.getAssetOutputFile(file, issuerFilename) : path.posix.join(publicPath, file); const cssContent = this.cssExtractModule.apply(inlineSources, (content) => content.replace(urlRegex, resolveAssetFile) ); // 5. add extracted CSS file into compilation const fileManifest = { render: () => cssContent, filename: assetFile, identifier: `${pluginName}.${chunk.id}.inline.css`, // the validity of the hash does not matter because it will be injected in the HTML hash: hash + 'inline', }; result.push(fileManifest); } // CSS saved into file if (sources.length) {