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
JavaScript
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) {