📄 HtmlModulesPlugin.js
/home/palash/git/site/node_modules/webpack/lib/html/HtmlModulesPlugin.js
Language: js • Lines: 492
/*
	MIT License http://www.opensource.org/licenses/mit-license.php
*/

"use strict";

const { RawSource } = require("webpack-sources");
const EntryPlugin = require("../EntryPlugin");
const HotUpdateChunk = require("../HotUpdateChunk");
const { HTML_TYPE } = require("../ModuleSourceTypeConstants");
const { HTML_MODULE_TYPE } = require("../ModuleTypeConstants");
const NormalModule = require("../NormalModule");
const ConstDependency = require("../dependencies/ConstDependency");
const HtmlInlineScriptDependency = require("../dependencies/HtmlInlineScriptDependency");
const HtmlInlineStyleDependency = require("../dependencies/HtmlInlineStyleDependency");
const HtmlScriptSrcDependency = require("../dependencies/HtmlScriptSrcDependency");
const HtmlSourceDependency = require("../dependencies/HtmlSourceDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const { compareModulesByFullName } = require("../util/comparators");
const removeBOM = require("../util/removeBOM");
const HtmlGenerator = require("./HtmlGenerator");
const HtmlParser = require("./HtmlParser");

/** @typedef {import("../Compiler")} Compiler */
/** @typedef {{ request: string, entryName: string, kind: "classic" | "esm-script" | "modulepreload" | "stylesheet" }} EntryScriptInfo */

const PLUGIN_NAME = "HtmlModulesPlugin";

/**
 * @param {string} name definition name in `schemas/WebpackOptions.json`
 * @returns {EXPECTED_OBJECT} a schema referencing `#/definitions/<name>`
 */
const getSchema = (name) => {
	const { definitions } = require("../../schemas/WebpackOptions.json");

	return {
		definitions,
		oneOf: [{ $ref: `#/definitions/${name}` }]
	};
};

const generatorValidationOptions = {
	name: "Html Modules Plugin",
	baseDataPath: "generator"
};

class HtmlModulesPlugin {
	/**
	 * `output.hashFunction`/`hashSalt`/`hashDigest`/`hashDigestLength`
	 * digest of `content`, with `nonNumericOnlyHash` applied — webpack's
	 * standard `[contenthash]` recipe.
	 * @param {string | Buffer} content content to hash
	 * @param {import("../../declarations/WebpackOptions").Output} outputOptions output options
	 * @returns {string} content hash
	 */
	static computeContentHash(content, outputOptions) {
		const createHash = require("../util/createHash");
		const nonNumericOnlyHash = require("../util/nonNumericOnlyHash");

		const hash = createHash(
			/** @type {import("../../declarations/WebpackOptions").HashFunction} */
			(outputOptions.hashFunction)
		);
		if (outputOptions.hashSalt) hash.update(outputOptions.hashSalt);
		hash.update(content);
		return nonNumericOnlyHash(
			/** @type {string} */ (
				hash.digest(/** @type {string} */ (outputOptions.hashDigest))
			),
			/** @type {number} */ (outputOptions.hashDigestLength)
		);
	}

	/**
	 * Applies the plugin by registering its hooks on the compiler.
	 * @param {Compiler} compiler the compiler instance
	 * @returns {void}
	 */
	apply(compiler) {
		// Per-chunk `RawSource` reused across builds when bytes are unchanged:
		// keeping identity stable avoids invalidating `RealContentHashPlugin|analyse`.
		/** @type {Map<string, { content: string, source: import("webpack-sources").RawSource }>} */
		const sentinelResolvedSourceCache = new Map();

		// `<script src>` and `<link rel="modulepreload">` references collected
		// by HtmlParser become real compilation entries here. The classic
		// and esm-script groups are chained via a leader-only dependOn so
		// they share a runtime — the first entry of the group owns it and
		// every subsequent entry sets `dependOn: [leader]`. Modulepreload
		// entries are emitted as independent entries (no dependOn) so they
		// can never be imported as a runtime leader by a later script —
		// that's what keeps the "preload but don't execute" contract of
		// `<link rel="modulepreload">` intact.
		/** @type {WeakMap<import("../Compilation"), Set<string>>} */
		const stylesheetEntriesPerCompilation = new WeakMap();
		compiler.hooks.finishMake.tapAsync(PLUGIN_NAME, (compilation, callback) => {
			/** @type {Promise<void>[]} */
			const promises = [];
			/** @type {Set<string>} */
			const stylesheetEntries = new Set();
			stylesheetEntriesPerCompilation.set(compilation, stylesheetEntries);

			for (const module of compilation.modules) {
				if (module.type !== HTML_MODULE_TYPE) continue;
				const buildInfo = module.buildInfo;
				const htmlEntryScripts =
					buildInfo &&
					/** @type {Record<string, EntryScriptInfo[]> | undefined} */
					(buildInfo.htmlEntryScripts);
				if (!htmlEntryScripts) continue;

				const context = /** @type {string} */ (module.context);

				for (const [groupKind, group] of Object.entries(htmlEntryScripts)) {
					// Only the script chains (`classic`, `esm-script`) need a
					// shared runtime via leader-only `dependOn` — the others
					// either preload without executing (`modulepreload`) or
					// produce CSS chunks (`stylesheet`) which have no runtime
					// to share. CSS entries must NOT chain into a JS leader
					// either, because the resulting chunk would mix a CSS
					// stylesheet with a JS runtime.
					const isChainGroup =
						groupKind !== "modulepreload" && groupKind !== "stylesheet";
					/** @type {string | undefined} */
					let leaderName;
					for (const entry of group) {
						const dependOn =
							isChainGroup && leaderName !== undefined
								? [leaderName]
								: undefined;
						if (isChainGroup && leaderName === undefined) {
							leaderName = entry.entryName;
						}
						if (groupKind === "stylesheet") {
							stylesheetEntries.add(entry.entryName);
						}
						promises.push(
							new Promise((resolve, reject) => {
								compilation.addEntry(
									context,
									EntryPlugin.createDependency(entry.request, {
										name: entry.entryName
									}),
									{
										name: entry.entryName,
										// Each script src / modulepreload entry gets its own
										// filename derived from the synthetic entry name so it
										// doesn't collide with the user's `output.filename`.
										// For CSS entries the JS `filename` is irrelevant (no
										// `.js` is emitted) — the CSS file's name is set on the
										// chunk via `cssFilenameTemplate` in `afterChunks` below.
										filename:
											compilation.outputOptions.chunkFilename || "[name].js",
										dependOn
									},
									(err) => {
										if (err) reject(err);
										else resolve();
									}
								);
							})
						);
					}
				}
			}

			Promise.all(promises).then(
				() => callback(),
				(err) => callback(err)
			);
		});

		compiler.hooks.compilation.tap(
			PLUGIN_NAME,
			(compilation, { normalModuleFactory }) => {
				// CSS entries created by `<link rel="stylesheet">` in HTML need
				// their `.css` filename set via `chunk.cssFilenameTemplate`
				// (the field `CssModulesPlugin.getChunkFilenameTemplate` reads).
				// Compilation only flows `options.filename` to `chunk.filenameTemplate`,
				// which controls JS emit — there's no entry-level `cssFilename`.
				// Set it ourselves after chunks are created so each stylesheet
				// entry emits to a distinct file derived from `output.cssFilename`
				// (or `output.cssChunkFilename` for non-initial CSS chunks).
				compilation.hooks.afterChunks.tap(PLUGIN_NAME, () => {
					const stylesheetEntries =
						stylesheetEntriesPerCompilation.get(compilation);
					if (!stylesheetEntries || stylesheetEntries.size === 0) return;
					for (const entryName of stylesheetEntries) {
						const entrypoint = compilation.entrypoints.get(entryName);
						if (!entrypoint) continue;
						const chunk = entrypoint.getEntrypointChunk();
						if (!chunk) continue;
						// Each html-derived stylesheet entry uses the
						// `cssChunkFilename` template — even though the entry
						// chunk technically `canBeInitial()`, we deliberately
						// avoid `cssFilename` here because that template often
						// has no per-entry placeholder (it's derived from
						// `output.filename`, which can be a literal like
						// `bundle0.js`), and multiple `<link rel="stylesheet">`
						// tags would then collide on the same emitted `.css`
						// file. `cssChunkFilename` is derived from
						// `output.chunkFilename` which webpack auto-extends
						// with `[id].` when needed, guaranteeing uniqueness.
						chunk.cssFilenameTemplate =
							compilation.outputOptions.cssChunkFilename;
					}
				});
				compilation.dependencyFactories.set(
					HtmlSourceDependency,
					normalModuleFactory
				);
				compilation.dependencyTemplates.set(
					HtmlSourceDependency,
					new HtmlSourceDependency.Template()
				);
				compilation.dependencyFactories.set(
					HtmlScriptSrcDependency,
					normalModuleFactory
				);
				compilation.dependencyTemplates.set(
					HtmlScriptSrcDependency,
					new HtmlScriptSrcDependency.Template()
				);
				// Inline `<script>` content is bundled as its own entry — the
				// same pipeline that handles `<script src>` — via a
				// `data:text/javascript,...` request. The dependency
				// template rewrites the original tag to `<script src=…>`.
				compilation.dependencyFactories.set(
					HtmlInlineScriptDependency,
					normalModuleFactory
				);
				compilation.dependencyTemplates.set(
					HtmlInlineScriptDependency,
					new HtmlInlineScriptDependency.Template()
				);
				// Inline `<style>` content is routed through the CSS pipeline
				// as a `data:text/css` module. The dependency template reads
				// the processed CSS text from the CSS module's code
				// generation data (`css-text` channel set by CssGenerator
				// when `exportType` is `"text"`).
				compilation.dependencyFactories.set(
					HtmlInlineStyleDependency,
					normalModuleFactory
				);
				compilation.dependencyTemplates.set(
					HtmlInlineStyleDependency,
					new HtmlInlineStyleDependency.Template()
				);
				compilation.dependencyTemplates.set(
					StaticExportsDependency,
					new StaticExportsDependency.Template()
				);
				// `ConstDependency` is used by HtmlParser to insert
				// ` type="module"` into the rewritten <script> tag when
				// `output.module` is on. Register its template so the HTML
				// generator runs the insertion.
				compilation.dependencyTemplates.set(
					ConstDependency,
					new ConstDependency.Template()
				);
				const cssEnabled = Boolean(
					compiler.options.experiments && compiler.options.experiments.css
				);
				normalModuleFactory.hooks.createParser
					.for(HTML_MODULE_TYPE)
					.tap(
						PLUGIN_NAME,
						() =>
							new HtmlParser(
								compilation.outputOptions.hashFunction,
								compiler.context,
								compilation.outputOptions.module,
								cssEnabled
							)
					);

				normalModuleFactory.hooks.createGenerator
					.for(HTML_MODULE_TYPE)
					.tap(PLUGIN_NAME, (generatorOptions) => {
						compiler.validate(
							() => getSchema("HtmlGeneratorOptions"),
							generatorOptions,
							generatorValidationOptions,
							(options) =>
								require("../../schemas/plugins/HtmlGeneratorOptions.check")(
									options
								)
						);
						return new HtmlGenerator(generatorOptions, compilation.moduleGraph);
					});

				NormalModule.getCompilationHooks(compilation).processResult.tap(
					PLUGIN_NAME,
					(result, module) => {
						if (module.type === HTML_MODULE_TYPE) {
							const [source, ...rest] = result;

							return [removeBOM(source), ...rest];
						}

						return result;
					}
				);

				// Emit extracted `.html` files for any HTML module that opted
				// into extraction. The opt-in is computed by
				// `HtmlGenerator#_shouldExtract`: `module.generator.html.extract:
				// true` always extracts, `false` never extracts, and when
				// `extract` is unset the generator extracts iff the HTML module
				// is a compilation entry — the iteration below picks up only
				// modules whose generator reported the `html` source type, so
				// that decision is honored implicitly. The HTML content is read
				// from the generator's secondary `"html"` source type (see
				// HtmlGenerator#generate). The filename template comes from
				// `output.htmlFilename` (initial chunks) or
				// `output.htmlChunkFilename` (non-initial chunks), mirroring
				// the CSS pipeline. Path data follows the asset-module pattern —
				// `module` + a relative source `filename`, with `chunk`
				// intentionally omitted so `[name]` resolves to the HTML
				// source's basename (e.g. `page` for `./page.html`) rather
				// than the importing chunk's name (e.g. `main`). A per-module
				// content hash is computed from the rewritten HTML so the
				// template's `[contenthash]` placeholder works; the
				// compilation hash is also forwarded so `[fullhash]` /
				// `[hash]` work in user-supplied templates.
				const {
					getUndoPath,
					makePathsRelative
				} = require("../util/identifier");
				const CssUrlDependency = require("../dependencies/CssUrlDependency");

				const autoPlaceholder = CssUrlDependency.PUBLIC_PATH_AUTO;

				compilation.hooks.renderManifest.tap(
					PLUGIN_NAME,
					(result, { chunk, codeGenerationResults, hash: compilationHash }) => {
						// HMR's `HotUpdateChunk`s flow through the same hook
						// but aren't real output chunks — extracting `.html`
						// for them would create stray hot-update HTML files.
						// `CssModulesPlugin` early-returns for the same reason.
						if (chunk instanceof HotUpdateChunk) return result;
						const { chunkGraph } = compilation;
						const modules =
							chunkGraph.getOrderedChunkModulesIterableBySourceType(
								chunk,
								HTML_TYPE,
								compareModulesByFullName(compilation.compiler)
							);
						if (!modules) return result;
						const outputOptions = compilation.outputOptions;
						for (const module of modules) {
							const normalModule = /** @type {NormalModule} */ (module);
							const codeGenResult = codeGenerationResults.get(
								module,
								chunk.runtime
							);
							const placeholderSource = codeGenResult.sources.get(HTML_TYPE);
							if (!placeholderSource) continue;

							const filenameTemplate = chunk.canBeInitial()
								? outputOptions.htmlFilename
								: outputOptions.htmlChunkFilename;

							const sourceFilename = makePathsRelative(
								compiler.context,
								/** @type {string} */
								(normalModule.getResource() || normalModule.resource),
								compiler.root
							).replace(/^\.\//, "");

							const placeholderContent = /** @type {string} */ (
								placeholderSource.source()
							);
							// Resolve sentinels *before* hashing so the HTML's `[contenthash]`
							// invalidates with the referenced chunks' filenames.
							const resolvedContent = HtmlGenerator.resolveChunkUrlSentinels(
								placeholderContent,
								compilation
							);
							const contentHash = HtmlModulesPlugin.computeContentHash(
								resolvedContent,
								outputOptions
							);

							const { path: filename, info } = compilation.getPathWithInfo(
								/** @type {import("../TemplatedPathPlugin").TemplatePath} */
								(filenameTemplate),
								{
									module,
									runtime: chunk.runtime,
									chunkGraph,
									contentHash,
									contentHashType: HTML_TYPE,
									filename: sourceFilename,
									hash: compilationHash
								}
							);

							// Resolve any remaining `[webpack/auto]` placeholders to
							// an undo path computed from the emitted HTML's location.
							// Without this, an `output.htmlFilename` that emits into
							// a subdirectory (e.g. `pages/[name].html`) would leave
							// asset URLs like `image.png` and chunk URLs like
							// `main.js` root-relative, so the browser would resolve
							// them under the HTML's directory instead of the
							// `output.path` root.
							const undoPath = getUndoPath(
								filename,
								/** @type {string} */ (outputOptions.path),
								false
							);
							const finalContent = resolvedContent
								.split(autoPlaceholder)
								.join(undoPath);
							const finalSource = new RawSource(finalContent);
							// The same HTML module can land in multiple chunks
							// with different `output.htmlFilename` /
							// `output.htmlChunkFilename` shapes, which means
							// different `undoPath`s and therefore different
							// final content for the same module id. Include
							// the emitted filename in the asset cache key and
							// the post-undo-path content in the hash, so the
							// asset cache can't reuse one variant's bytes at
							// another variant's URL.
							const finalContentHash = HtmlModulesPlugin.computeContentHash(
								finalContent,
								outputOptions
							);

							result.push({
								render: () => finalSource,
								filename,
								info,
								auxiliary: true,
								identifier: `htmlModule${chunkGraph.getModuleId(
									module
								)}|${filename}`,
								hash: finalContentHash
							});
						}
						return result;
					}
				);

				// Resolve sentinels at JS chunk render time so later passes
				// (SourceMapDevToolPlugin, size optimizers, RealContentHash) see resolved bytes.
				const JavascriptModulesPlugin = require("../javascript/JavascriptModulesPlugin");

				const jsHooks =
					JavascriptModulesPlugin.getCompilationHooks(compilation);
				jsHooks.render.tap(PLUGIN_NAME, (source, renderContext) => {
					const raw = source.source();
					if (typeof raw !== "string") return source;
					if (!raw.includes("__WEBPACK_HTML_CHUNK_URL__")) return source;
					const resolved = HtmlGenerator.resolveChunkUrlSentinels(
						raw,
						compilation
					)
						.split(autoPlaceholder)
						.join("");
					if (resolved === raw) return source;
					const chunkId = String(renderContext.chunk.id);
					const prior = sentinelResolvedSourceCache.get(chunkId);
					if (prior !== undefined && prior.content === resolved) {
						return prior.source;
					}
					const newSource = new RawSource(resolved);
					sentinelResolvedSourceCache.set(chunkId, {
						content: resolved,
						source: newSource
					});
					return newSource;
				});

				// Prune cache entries for chunks no longer in the graph so a
				// long watch session can't accumulate stale entries.
				compilation.hooks.afterSeal.tap(PLUGIN_NAME, () => {
					if (sentinelResolvedSourceCache.size === 0) return;
					const live = new Set();
					for (const chunk of compilation.chunks) {
						live.add(String(chunk.id));
					}
					for (const id of sentinelResolvedSourceCache.keys()) {
						if (!live.has(id)) sentinelResolvedSourceCache.delete(id);
					}
				});
			}
		);
	}
}

module.exports = HtmlModulesPlugin;