Source code

Revision control

Copy as Markdown

Other Tools

/*
*/
"use strict";
const { pathToFileURL } = require("url");
const { AsyncSeriesHook, AsyncSeriesWaterfallHook } = require("tapable");
const { RawSource } = require("webpack-sources");
const Compilation = require("../Compilation");
const EntryOptionPlugin = require("../EntryOptionPlugin");
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 HtmlEntryDependency = require("../dependencies/HtmlEntryDependency");
const HtmlInlineHtmlDependency = require("../dependencies/HtmlInlineHtmlDependency");
const HtmlInlineScriptDependency = require("../dependencies/HtmlInlineScriptDependency");
const HtmlInlineStyleDependency = require("../dependencies/HtmlInlineStyleDependency");
const HtmlSourceDependency = require("../dependencies/HtmlSourceDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const WebpackError = require("../errors/WebpackError");
const JavascriptModulesPlugin = require("../javascript/JavascriptModulesPlugin");
const { compareModulesByFullName } = require("../util/comparators");
const createHash = require("../util/createHash");
const createHooksRegistry = require("../util/createHooksRegistry");
const { getUndoPath, makePathsRelative } = require("../util/identifier");
const implicitTypeLoaderFallback = require("../util/implicitTypeLoaderFallback");
const memoize = require("../util/memoize");
const { digestNonNumericOnly } = require("../util/nonNumericOnlyHash");
const {
PUBLIC_PATH_AUTO: autoPlaceholder
} = require("../util/publicPathPlaceholder");
const removeBOM = require("../util/removeBOM");
const HtmlGenerator = require("./HtmlGenerator");
const HtmlModule = require("./HtmlModule");
const HtmlParser = require("./HtmlParser");
const { escapeAttribute } = require("./syntax");
/** @typedef {import("../../declarations/WebpackOptions").EntryDescriptionNormalized} EntryDescriptionNormalized */
/** @typedef {import("../../declarations/WebpackOptions").OutputHtmlOptions["favicon"]} FaviconOption */
/** @typedef {import("../../declarations/WebpackOptions").OutputHtmlOptions["manifest"]} ManifestOption */
/** @typedef {import("../../declarations/WebpackOptions").HtmlFaviconIcon} HtmlFaviconIcon */
/** @typedef {Exclude<HtmlFaviconIcon, string>} FaviconIcon */
/** @typedef {import("../Compiler")} Compiler */
/** @typedef {import("./HtmlModule").HtmlModuleBuildInfo} HtmlModuleBuildInfo */
/** @typedef {{ request: string, entryName: string, type: "script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch", css?: boolean }} HtmlEntryInfo */
/** @typedef {{ outputName: string }} HtmlTransformHtmlContext */
/** @typedef {{ outputName: string }} HtmlEmittedContext */
/**
* A tag to inject into an emitted page as a structured descriptor. Injected
* verbatim (not re-bundled) at the `injectTo` position.
* @typedef {object} HtmlTagDescriptor
* @property {string} tag tag name, e.g. `"script"` / `"link"` / `"meta"`
* @property {Record<string, string | boolean | undefined>=} attrs attributes; `true` renders a bare boolean attribute, `false`/`undefined` is omitted
* @property {string=} children inner content (ignored for void elements like `<link>`/`<meta>`)
* @property {("head" | "body" | "head-prepend" | "body-prepend")=} injectTo placement; defaults to `"head"`
* @property {boolean=} voidTag force a void element (no closing tag); inferred from the tag name when omitted
*/
/** @typedef {{ outputName: string, html: string }} HtmlInjectTagsContext */
/**
* A `<script>`/`<link>`/`<style>`/`<meta>` tag already present in an emitted
* page, exposed for in-place mutation by `transformTags`. Mutate `attrs`, set
* `remove`, or change `injectTo` to move it; don't reorder the array itself.
* @typedef {object} HtmlMutableTag
* @property {string} tag the (lowercased) tag name
* @property {Record<string, string | boolean | undefined>} attrs mutable attributes; a string value renders `name="value"`, `true` a bare attribute, `false`/`undefined`/deleting the key drops it
* @property {("head" | "body" | "head-prepend" | "body-prepend")=} injectTo the tag's current region (`"head"`/`"body"`); set a different value to move it there (`*-prepend` to the region's start)
* @property {boolean=} remove set true to delete the whole element
*/
/** @typedef {{ outputName: string, html: string }} HtmlTransformTagsContext */
/**
* @typedef {object} HtmlCompilationHooks
* @property {AsyncSeriesWaterfallHook<[HtmlTagDescriptor[], HtmlInjectTagsContext]>} injectTags called with the list of extra tags to inject into each page (initially empty) plus the current HTML; push `HtmlTagDescriptor`s and return the list — webpack serializes and places them by `injectTo`. A structured alternative to the string-level `transformHtml` for adding tags; runs before CSP so injected inline tags are hashed
* @property {AsyncSeriesHook<[HtmlMutableTag[], HtmlTransformTagsContext]>} transformTags called with the page's `<script>`/`<link>`/`<style>`/`<meta>` tags (webpack's own and any injected) as mutable descriptors; mutate `attrs` (add a `nonce`/`data-*`, switch `defer`↔`async`, …), set `remove: true`, or change `injectTo` to move a tag between `<head>` and `<body>`, and webpack rewrites the changed tags. Add new tags with `injectTags` instead
* @property {AsyncSeriesWaterfallHook<[string, HtmlTransformHtmlContext]>} transformHtml called with each emitted page's final HTML (all sentinels resolved) just before it is written; return the (possibly transformed) HTML — e.g. to minify, inject a CSP meta, or rewrite tags
* @property {AsyncSeriesHook<[HtmlEmittedContext]>} htmlEmitted called once each page's HTML asset has been finalized — a post-emit notification (nothing to return)
*/
const PLUGIN_NAME = "HtmlModulesPlugin";
// Built-in default favicon (the webpack logo). Referenced as a `file:` request
// so `<link rel="icon">` in the synthetic wrapper flows through the normal
// asset pipeline — emitted hashed, with the correct publicPath, no direct IO.
// Resolved from `__filename` (a sibling of this module) — no `path` needed.
const DEFAULT_FAVICON = new URL("./favicon.svg", pathToFileURL(__filename))
.href;
// mime-db is heavy — only load it when a favicon actually needs a `type`.
const getMimeTypes = memoize(() => require("../util/mimeTypes"));
/**
* Resolves `output.html.favicon` for one page to `[rel, icon]` pairs.
* `false`/absent → none; `true` → the webpack logo; a string → one `icon`
* link; an object maps each `rel` to an icon (a path string, an attributes
* object, or an array of those for several icons under one `rel`); a function
* receives the page name and returns any of those. Each icon is normalized to
* an object so `faviconLinkTag` reads one shape.
* @param {FaviconOption} favicon favicon option
* @param {string} name page/entry name
* @returns {[string, FaviconIcon][]} rel/icon pairs, in order
*/
const resolveFaviconLinks = (favicon, name) => {
const value = typeof favicon === "function" ? favicon(name) : favicon;
if (value === true) return [["icon", { href: DEFAULT_FAVICON }]];
if (typeof value === "string") return [["icon", { href: value }]];
if (value && typeof value === "object") {
/** @type {[string, FaviconIcon][]} */
const pairs = [];
for (const [rel, icon] of Object.entries(value)) {
for (const one of Array.isArray(icon) ? icon : [icon]) {
pairs.push([rel, typeof one === "string" ? { href: one } : one]);
}
}
return pairs;
}
return [];
};
/**
* @param {string} rel link relation
* @param {FaviconIcon} icon icon `href` plus optional link attributes
* @returns {string} a `<link rel=… [attrs…] href=…>` tag; `type` defaults to the file format
*/
const faviconLinkTag = (rel, icon) => {
const type = icon.type || getMimeTypes().lookup(icon.href) || "";
const attrs = [
`rel="${escapeAttribute(rel)}"`,
icon.sizes && `sizes="${escapeAttribute(icon.sizes)}"`,
type && `type="${escapeAttribute(type)}"`,
icon.media && `media="${escapeAttribute(icon.media)}"`,
icon.color && `color="${escapeAttribute(icon.color)}"`,
icon.crossorigin && `crossorigin="${escapeAttribute(icon.crossorigin)}"`,
`href="${escapeAttribute(icon.href)}"`
];
return `<link ${attrs.filter(Boolean).join(" ")}>`;
};
/**
* Resolves `output.html.manifest` for one page to a `<link rel="manifest">`
* tag, or `""` when unset. A string is a path to an existing `.webmanifest`
* file; an object is serialized to a base64 `data:application/manifest+json`
* URL (routed to `asset/webmanifest` by a default rule, so its relative icon
* `src`s are hashed like any request); a function receives the page name.
* Base64 (not percent-encoding) is required because this tag is embedded in
* the outer `data:text/html` wrapper, which is itself percent-decoded — a
* percent-encoded inner URL would be corrupted by that decode.
* @param {ManifestOption} manifest manifest option
* @param {string} name page/entry name
* @returns {string} the `<link>` tag, or ""
*/
const manifestLinkTag = (manifest, name) => {
const value = typeof manifest === "function" ? manifest(name) : manifest;
if (!value) return "";
const href =
typeof value === "string"
? value
: `data:application/manifest+json;base64,${Buffer.from(
JSON.stringify(value),
"utf8"
).toString("base64")}`;
return `<link rel="manifest" href="${escapeAttribute(href)}">`;
};
// `\.html`/`\.css` request matchers for the synthetic `output.html` wrapper.
const HTML_REQUEST_RE = /\.html(\?|$)/i;
const CSS_REQUEST_RE = /\.css(\?|$)/i;
/**
* Requests an `output.html` page must load for an entry: its `dependOn`
* ancestors first (transitive, deduped — so a diamond loads each once), then
* the entry's own imports. `.html` imports are dropped (own HTML entries).
* @param {string} name entry name
* @param {Record<string, EntryDescriptionNormalized>} entries normalized static entries
* @returns {string[]} deduped requests in load order
*/
const collectHtmlEntryRequests = (name, entries) => {
/** @type {string[]} */
const requests = [];
/** @type {Set<string>} */
const seenRequests = new Set();
/** @type {Set<string>} */
const visited = new Set();
const walk = (/** @type {string} */ entryName) => {
if (visited.has(entryName)) return;
visited.add(entryName);
const desc = entries[entryName];
if (!desc) return;
if (desc.dependOn) {
for (const dep of desc.dependOn) walk(dep);
}
if (!desc.import) return;
for (const request of desc.import) {
if (HTML_REQUEST_RE.test(request) || seenRequests.has(request)) continue;
seenRequests.add(request);
requests.push(request);
}
};
walk(name);
return requests;
};
/**
* @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"
};
const parserValidationOptions = {
name: "Html Modules Plugin",
baseDataPath: "parser"
};
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 hash = createHash(
/** @type {import("../../declarations/WebpackOptions").HashFunction} */
(outputOptions.hashFunction)
);
if (outputOptions.hashSalt) hash.update(outputOptions.hashSalt);
hash.update(content);
return digestNonNumericOnly(
hash,
/** @type {string} */ (outputOptions.hashDigest),
/** @type {number} */ (outputOptions.hashDigestLength)
);
}
/**
* Filename template for an extracted HTML page: `output.htmlFilename` for
* initial chunks, `output.htmlChunkFilename` otherwise — the HTML counterpart
* of `CssModulesPlugin.getChunkFilenameTemplate`.
* @param {import("../Chunk")} chunk chunk
* @param {import("../../declarations/WebpackOptions").Output} outputOptions output options
* @returns {import("../Chunk").ChunkFilenameTemplate} used filename template
*/
static getChunkFilenameTemplate(chunk, outputOptions) {
return chunk.canBeInitial()
? /** @type {import("../Chunk").ChunkFilenameTemplate} */ (
outputOptions.htmlFilename
)
: /** @type {import("../Chunk").ChunkFilenameTemplate} */ (
outputOptions.htmlChunkFilename
);
}
/**
* Applies the plugin by registering its hooks on the compiler.
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
const { output } = compiler.options;
const htmlOption = output.html;
const scriptLoading =
(typeof htmlOption === "object" && htmlOption.scriptLoading) || "auto";
// ESM output emits `type="module"` (already deferred), so scriptLoading
// is ignored under output.module — warn on an explicit defer/blocking.
let scriptAttr = " defer";
if (output.module) {
scriptAttr = "";
if (scriptLoading === "defer" || scriptLoading === "blocking") {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.warnings.push(
new WebpackError(
`output.html.scriptLoading: "${scriptLoading}" is ignored with output.module — ES module scripts are always deferred.`
)
);
});
}
} else if (scriptLoading === "blocking") {
scriptAttr = "";
}
// `output.html` (or an entry's `html`) wraps a non-HTML entry in a
// synthetic HTML module so the existing pipeline injects its JS/CSS
// chunks and applies the `template` option. `dependOn` ancestors are
// injected first so the page loads the shared/runtime chunks before
// the entry's own (see `collectHtmlEntryRequests`). `crossOriginLoading`
// and SRI are applied to the injected tags centrally in
// HtmlEntryDependency.
EntryOptionPlugin.getHooks(compiler).entry.tap(
PLUGIN_NAME,
(context, name, desc) => {
const html = desc.html !== undefined ? desc.html : htmlOption;
const imports = desc.import;
if (
!html ||
!imports ||
imports.every((r) => HTML_REQUEST_RE.test(r))
) {
return;
}
const entries = compiler.options.entry;
const requests =
typeof entries === "object" && entries[name]
? collectHtmlEntryRequests(name, entries)
: imports.filter((r) => !HTML_REQUEST_RE.test(r));
const links = [];
const scripts = [];
for (const r of requests) {
if (CSS_REQUEST_RE.test(r)) {
links.push(`<link rel="stylesheet" href="${r}">`);
} else {
scripts.push(`<script${scriptAttr} src="${r}"></script>`);
}
}
const htmlObj =
typeof html === "object"
? html
: typeof htmlOption === "object"
? htmlOption
: {};
const headTags = HtmlParser.buildHeadTags(htmlObj);
// Injected into webpack-generated pages only; each icon's href is a
// normal request the parser turns into a hashed asset.
const favicon =
typeof html === "object" && html.favicon !== undefined
? html.favicon
: false;
const faviconTag = resolveFaviconLinks(favicon, name)
.map(([rel, icon]) => faviconLinkTag(rel, icon))
.join("");
const manifest =
typeof html === "object" && html.manifest !== undefined
? html.manifest
: false;
const manifestTag = manifestLinkTag(manifest, name);
const inject = htmlObj.inject;
const scriptsInHead =
inject === "head" || (inject !== "body" && output.module);
const scriptsFirst = output.module || scriptAttr === " defer";
const headScripts = scriptsInHead ? scripts.join("") : "";
return `data:text/html,<!doctype html><html><head>${faviconTag}${manifestTag}${headTags}${
scriptsFirst
? headScripts + links.join("")
: links.join("") + headScripts
}</head><body>${scriptsInHead ? "" : scripts.join("")}</body></html>`;
}
);
// 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 `script`
// and `script-module` 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.
// Per-compilation state: the HTML modules seen during make (so
// `finishMake` creates their entries without scanning the whole module
// graph) and the stylesheet entry names (read in `afterChunks`).
/** @type {WeakMap<import("../Compilation"), { htmlModules: Set<import("../Module")>, stylesheetEntries: Set<string>, htmlAssetNames: Set<string> }>} */
const compilationState = new WeakMap();
/**
* @param {import("../Compilation")} compilation compilation
* @returns {{ htmlModules: Set<import("../Module")>, stylesheetEntries: Set<string>, htmlAssetNames: Set<string> }} per-compilation state
*/
const getState = (compilation) => {
let state = compilationState.get(compilation);
if (state === undefined) {
state = {
htmlModules: new Set(),
stylesheetEntries: new Set(),
htmlAssetNames: new Set()
};
compilationState.set(compilation, state);
}
return state;
};
compiler.hooks.finishMake.tapAsync(PLUGIN_NAME, (compilation, callback) => {
const { htmlModules, stylesheetEntries } = getState(compilation);
// Collect the entries an HTML module asks for. Only the script chains
// (`script`, `script-module`) share a runtime via a leader-only
// `dependOn`; `modulepreload`, `stylesheet` and `html` links are all
// independent entries (a CSS or page entry must not chain into a JS
// leader, or the chunk would mix unrelated outputs).
/** @type {(module: import("../Module")) => { context: string, request: string, name: string, dependOn: string[] | undefined }[]} */
const collectEntrySpecs = (module) => {
const { htmlEntries } = /** @type {HtmlModuleBuildInfo} */ (
module.buildInfo
);
if (!htmlEntries) return [];
const context = /** @type {string} */ (module.context);
const specs = [];
for (const [groupKind, group] of Object.entries(htmlEntries)) {
const isChainGroup =
groupKind === "script" || groupKind === "script-module";
/** @type {string | undefined} */
let leaderName;
for (const entry of group) {
const dependOn =
isChainGroup && leaderName !== undefined
? [leaderName]
: undefined;
if (isChainGroup && leaderName === undefined) {
leaderName = entry.entryName;
}
// Stylesheet entries and `as="style"` preload/prefetch entries emit
// a CSS chunk, so they need the CSS filename template below.
if (groupKind === "stylesheet" || entry.css) {
stylesheetEntries.add(entry.entryName);
}
specs.push({
context,
request: entry.request,
name: entry.entryName,
dependOn
});
}
}
return specs;
};
// Push one collected reference as a compilation entry; resolves with
// the built entry module.
/** @type {(spec: { context: string, request: string, name: string, dependOn: string[] | undefined }) => Promise<import("../Module") | null>} */
const addEntry = (spec) =>
new Promise((resolve, reject) => {
compilation.addEntry(
spec.context,
EntryPlugin.createDependency(spec.request, { name: spec.name }),
{
name: spec.name,
// Each entry gets its own filename from the synthetic entry name so
// it doesn't collide with `output.filename`. CSS entries set their
// `.css` name via `cssFilenameTemplate` below; `html` page entries
// emit their file via `renderManifest`.
filename: compilation.outputOptions.chunkFilename || "[name].js",
dependOn: spec.dependOn
},
(err, entryModule) =>
err ? reject(err) : resolve(entryModule || null)
);
});
// Create one HTML module's entries. A `type: "html"` link is itself an
// HTML entry, so after it builds we recurse into it — the same handling
// every HTML entry already gets. `processed` guards diamonds and cycles.
/** @type {WeakSet<import("../Module")>} */
const processed = new WeakSet();
/** @type {(module: import("../Module")) => Promise<void>} */
const processEntry = async (module) => {
if (processed.has(module)) return;
processed.add(module);
await Promise.all(
collectEntrySpecs(module).map(async (spec) => {
const entryModule = await addEntry(spec);
if (entryModule && entryModule.type === HTML_MODULE_TYPE) {
await processEntry(entryModule);
}
})
);
};
// Seed with the HTML modules seen during make (config entries /
// imports, fresh or cache-restored); linked pages are reached by
// recursion above.
Promise.all([...htmlModules].map(processEntry)).then(
() => callback(),
callback
);
});
const integrity =
typeof htmlOption === "object" ? htmlOption.integrity : undefined;
const integrityOn =
integrity === true ||
typeof integrity === "function" ||
(Array.isArray(integrity) && integrity.length > 0);
// `output.html.csp` injects a `<meta http-equiv="Content-Security-Policy">`
// once the page's inline content is final (in `processAssets` below).
const csp = typeof htmlOption === "object" ? htmlOption.csp : undefined;
// `"auto"` marks implicit enablement (no user rule for HTML files) — see
// `applyExperimentsDefaults`. Only then loaders win over the built-in type
// (e.g. html-webpack-plugin's template loader in its child compiler).
// TODO webpack 6: html defaults to `true`, drop this implicit-only fallback.
const implicitlyEnabled = compiler.options.experiments.html === "auto";
compiler.hooks.compilation.tap(
PLUGIN_NAME,
(compilation, { normalModuleFactory }) => {
if (implicitlyEnabled) {
implicitTypeLoaderFallback(
normalModuleFactory,
PLUGIN_NAME,
/\.html$/i,
HTML_MODULE_TYPE
);
}
const { htmlModules } = getState(compilation);
// Record HTML modules as they appear — freshly built
// (`succeedModule`) or restored from cache (`stillValidModule`) —
// so `finishMake` creates their entries without scanning every
// module in the graph. Tracking only records; entries are still
// added (and awaited) in `finishMake`.
const trackHtmlModule = (/** @type {import("../Module")} */ module) => {
if (module.type === HTML_MODULE_TYPE) htmlModules.add(module);
};
compilation.hooks.succeedModule.tap(PLUGIN_NAME, trackHtmlModule);
compilation.hooks.stillValidModule.tap(PLUGIN_NAME, trackHtmlModule);
// Resolve integrity and inline sentinels after `RealContentHashPlugin`
// so final bytes are in place. Only HTML pages carry these sentinels —
// never JS chunks that embed an HTML string.
// SRI only takes effect on a cross-origin subresource fetched
// with CORS; without `output.crossOriginLoading` the browser
// silently ignores `integrity` on cross-origin loads. Warn once
// so a CDN deployment doesn't ship no-op integrity attributes.
if (integrityOn && !compilation.outputOptions.crossOriginLoading) {
compilation.warnings.push(
new WebpackError(
'output.html.integrity is set but output.crossOriginLoading is not. Browsers ignore Subresource Integrity on cross-origin requests made without CORS; set output.crossOriginLoading (e.g. "anonymous") if any asset is served from a different origin.'
)
);
}
compilation.hooks.processAssets.tapPromise(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH + 1
},
async (assets) => {
const { htmlAssetNames } = getState(compilation);
const hooks = HtmlModulesPlugin.getCompilationHooks(compilation);
/** @type {Set<string>} */
const inlinedFiles = new Set();
for (const name of Object.keys(assets)) {
if (!htmlAssetNames.has(name)) continue;
const content = assets[name].source();
if (typeof content !== "string") continue;
let resolved = content;
if (
integrityOn &&
resolved.includes("__WEBPACK_HTML_INTEGRITY__")
) {
resolved = HtmlGenerator.resolveChunkIntegritySentinels(
resolved,
compilation,
/** @type {import("./HtmlGenerator").HtmlIntegrity} */ (
integrity
)
);
}
if (resolved.includes("__WEBPACK_HTML_INLINE__")) {
resolved = HtmlGenerator.resolveChunkInlineSentinels(
resolved,
compilation,
name,
inlinedFiles
);
}
// `injectTags` (add tags), `transformTags` (mutate/move/remove the
// page's own and injected tags), and CSP all run over a single
// parse: collect the tags/anchors once, let the hooks act, then
// render every edit — placement, attribute rewrites, moves,
// removals, CSP hashes/nonce/meta — in one pass. Injected inline
// `<script>`/`<style>` are hashed like the page's own.
const injected = await hooks.injectTags.promise([], {
outputName: name,
html: resolved
});
const transformTags = hooks.transformTags.taps.length > 0;
if (injected.length > 0 || transformTags || csp) {
const model = HtmlGenerator.collectHtml(resolved);
HtmlGenerator.addInjectedTags(model, injected);
if (transformTags) {
await hooks.transformTags.promise(model.tags, {
outputName: name,
html: resolved
});
}
resolved = HtmlGenerator.renderHtml(resolved, model, csp);
}
// Final, fully-resolved HTML — let plugins transform it (minify,
// inject a CSP meta, rewrite tags) before it is written.
resolved = await hooks.transformHtml.promise(resolved, {
outputName: name
});
if (resolved !== content) {
compilation.updateAsset(name, new RawSource(resolved));
}
await hooks.htmlEmitted.promise({ outputName: name });
}
// An inlined chunk file is dead weight once nothing else references
// it by URL (another page's tag, or the runtime's async chunk map);
// a content-hashed filename makes a substring hit a real reference.
// Materialize each other asset's source once (it can be expensive to
// render) and test all inlined files against it, rather than
// re-materializing every asset per inlined file.
if (inlinedFiles.size > 0) {
/** @type {Set<string>} */
const stillReferenced = new Set();
for (const name of Object.keys(compilation.assets)) {
if (
inlinedFiles.has(name) ||
stillReferenced.size === inlinedFiles.size
) {
continue;
}
const source = compilation.assets[name].source();
if (typeof source !== "string") continue;
for (const file of inlinedFiles) {
if (!stillReferenced.has(file) && source.includes(file)) {
stillReferenced.add(file);
}
}
}
for (const file of inlinedFiles) {
if (!stillReferenced.has(file)) compilation.deleteAsset(file);
}
}
}
);
// 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 } = getState(compilation);
if (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(
HtmlEntryDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
HtmlEntryDependency,
new HtmlEntryDependency.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()
);
// `<iframe srcdoc>` content is routed back through the HTML
// pipeline as a `data:text/html` module; the template reads the
// processed HTML from the nested module's `html` channel.
compilation.dependencyFactories.set(
HtmlInlineHtmlDependency,
normalModuleFactory
);
compilation.dependencyTemplates.set(
HtmlInlineHtmlDependency,
new HtmlInlineHtmlDependency.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()
);
normalModuleFactory.hooks.createModuleClass
.for(HTML_MODULE_TYPE)
.tap(
PLUGIN_NAME,
(createData, _resolveData) => new HtmlModule(createData)
);
normalModuleFactory.hooks.createParser
.for(HTML_MODULE_TYPE)
.tap(PLUGIN_NAME, (parserOptions) => {
compiler.validate(
() => getSchema("HtmlParserOptions"),
parserOptions,
parserValidationOptions,
(options) =>
require("../../schemas/plugins/HtmlParserOptions.check")(
options
)
);
return new HtmlParser(parserOptions);
});
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;
// `applyTemplate` is a no-op unless `module.parser.html.template`
// is set. Running it here (where the returned source becomes the
// module's stored source) keeps the parser's dependency offsets
// and the generator's render base in sync.
const parser = /** @type {HtmlParser} */ (module.parser);
return [parser.applyTemplate(removeBOM(source), module), ...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.
// Sentinel-resolved content and its content hash depend only on
// the HTML-type module source — not on the chunk or output
// filename — yet `renderManifest` runs per `(chunk, module)`.
// Memoize so a module landing in multiple chunks resolves and
// hashes its sentinels once. Scoped to this compilation because
// sentinel resolution embeds chunk filenames, which change
// across rebuilds; weakly keyed by source so entries release.
/** @type {WeakMap<import("webpack-sources").Source, { resolvedContent: string, contentHash: string }>} */
const resolvedSentinelHashCache = new WeakMap();
// Compute a linked/entry HTML page's emitted filename + chunk-URL-resolved
// content + content hash for one (module, chunk). Shared by the emit loop
// below and the page-link resolver. The content hash covers only the
// chunk-URL-resolved source — links to *other* pages stay sentinels here,
// so a page's filename doesn't depend on the filenames of pages it links to.
const computePageEmit = (
/** @type {NormalModule} */ module,
/** @type {import("../Chunk")} */ chunk
) => {
const { chunkGraph, outputOptions } = compilation;
const codeGenResult =
/** @type {import("../CodeGenerationResults")} */ (
compilation.codeGenerationResults
).get(module, chunk.runtime);
const placeholderSource = codeGenResult.sources.get(HTML_TYPE);
if (!placeholderSource) return undefined;
let cached = resolvedSentinelHashCache.get(placeholderSource);
if (cached === undefined) {
// Resolve chunk-URL sentinels *before* hashing so the HTML's
// `[contenthash]` invalidates with the referenced chunks'
// filenames. Inlined chunks have no URL, so tag their inline
// sentinels with the chunk content hash — that keeps the emitted
// bytes (and RealContentHashPlugin's later recompute)
// content-dependent, so the page hash tracks inlined content too.
// Asset URL sentinels (from `HtmlEntryDependency` resource-hint
// tags) resolve here too — deferred from template.apply so it
// doesn't race with asset-module codegen.
const resolvedContent = HtmlGenerator.resolveAssetUrlSentinels(
HtmlGenerator.embedInlineChunkHashes(
HtmlGenerator.resolveChunkUrlSentinels(
/** @type {string} */ (placeholderSource.source()),
compilation
),
compilation
),
compilation
);
cached = {
resolvedContent,
contentHash: HtmlModulesPlugin.computeContentHash(
resolvedContent,
outputOptions
)
};
resolvedSentinelHashCache.set(placeholderSource, cached);
}
const resource = module.getResource() || module.resource;
// Synthetic `output.html` entries are `data:text/html` modules with no
// real basename — name the file after the entry instead.
const sourceFilename =
resource.startsWith("data:text/html") && chunk.name
? chunk.name
: makePathsRelative(
compiler.context,
/** @type {string} */ (resource),
compiler.root
).replace(/^\.\//, "");
const filenameTemplate = HtmlModulesPlugin.getChunkFilenameTemplate(
chunk,
outputOptions
);
const { path: filename, info } = compilation.getPathWithInfo(
/** @type {import("../TemplatedPathPlugin").TemplatePath} */
(filenameTemplate),
{
module,
runtime: chunk.runtime,
chunkGraph,
contentHash: cached.contentHash,
contentHashType: HTML_TYPE,
filename: sourceFilename,
hash: compilation.hash
}
);
return {
resolvedContent: cached.resolvedContent,
contentHash: cached.contentHash,
filename,
info
};
};
// Emitted filename of a linked `type: "html"` page, keyed by its entry
// name. Looked up from the page's own entry chunk so it's independent of
// chunk render order; cached for the compilation.
/** @type {Map<string, string>} */
const htmlPageFilenameCache = new Map();
const computePageFilenameByEntry = (
/** @type {string} */ entryName
) => {
const cached = htmlPageFilenameCache.get(entryName);
if (cached !== undefined) return cached;
let filename = "data:,";
const entrypoint = compilation.entrypoints.get(entryName);
const chunk = entrypoint && entrypoint.getEntrypointChunk();
if (chunk) {
const modules =
compilation.chunkGraph.getOrderedChunkModulesIterableBySourceType(
chunk,
HTML_TYPE,
compareModulesByFullName(compilation.compiler)
);
const module = modules && modules[Symbol.iterator]().next().value;
if (module) {
const emit = computePageEmit(
/** @type {NormalModule} */ (module),
chunk
);
if (emit) filename = emit.filename;
}
}
htmlPageFilenameCache.set(entryName, filename);
return filename;
};
compilation.hooks.renderManifest.tap(
PLUGIN_NAME,
(result, { chunk }) => {
// 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);
// `<iframe srcdoc>` modules expose `html` only so HtmlInlineHtmlDependency
// can write the processed markup back into the host attribute — they are
// never standalone pages. `generator.extract: "inline"` marks them so no
// `.html` file is emitted; without this they'd collide on the
// `data:text/html` → chunk-name path (every srcdoc module in a chunk would
// emit `<chunk>.html`). An O(1) flag check, not a scan.
const generatorOptions = normalModule.generatorOptions;
if (generatorOptions && generatorOptions.extract === "inline") {
continue;
}
const emit = computePageEmit(normalModule, chunk);
if (!emit) continue;
const { resolvedContent, contentHash, filename, info } = emit;
// Resolve any remaining `[webpack/auto]` placeholders to an undo
// path computed from the emitted HTML's location, so asset/chunk
// URLs stay relative to `output.path` even when the page emits into
// a subdirectory. A relative `<base href>` prepends `../`s so the
// base can't misdirect the rewritten URLs (see `HtmlParser`).
const basePrefix =
/** @type {HtmlModuleBuildInfo} */ (normalModule.buildInfo)
.baseUrlPrefix || "";
const undoPath =
basePrefix +
getUndoPath(
filename,
/** @type {string} */ (outputOptions.path),
false
);
// Resolve linked-page (`type: "html"`) sentinels to each page's
// emitted filename before the undo path, so the href is relative to
// this page.
const finalContent = HtmlGenerator.resolveHtmlPageUrlSentinels(
resolvedContent,
computePageFilenameByEntry
)
.split(autoPlaceholder)
.join(undoPath);
const finalSource = new RawSource(finalContent);
// The same HTML module can land in multiple chunks with different
// `htmlFilename`/`htmlChunkFilename` shapes → different `undoPath`s and
// 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.
// Unchanged content reuses the memoized hash instead of re-digesting.
const finalContentHash =
finalContent === resolvedContent
? contentHash
: HtmlModulesPlugin.computeContentHash(
finalContent,
outputOptions
);
// Track HTML pages for the integrity sentinel pass (never JS chunks).
getState(compilation).htmlAssetNames.add(filename);
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 jsHooks =
JavascriptModulesPlugin.getCompilationHooks(compilation);
jsHooks.render.tap(PLUGIN_NAME, (source, renderContext) => {
// No HTML modules ⇒ no sentinels; skip materializing the JS source.
if (htmlModules.size === 0) return source;
const raw = source.source();
if (typeof raw !== "string") return source;
if (
!raw.includes("__WEBPACK_HTML_CHUNK_URL__") &&
!raw.includes("__WEBPACK_HTML_PAGE_URL__") &&
!raw.includes("__WEBPACK_HTML_INTEGRITY__") &&
!raw.includes("__WEBPACK_HTML_INLINE__") &&
!raw.includes("__WEBPACK_HTML_ASSET_URL__")
) {
return source;
}
// Strip integrity and inline sentinels (not resolve): a JS chunk
// can't hold real SRI hashes for its own not-yet-final bytes, and
// inline content belongs only in the final .html, never in JS bundles.
const resolved = HtmlGenerator.stripChunkIntegritySentinels(
HtmlGenerator.resolveAssetUrlSentinels(
HtmlGenerator.resolveHtmlPageUrlSentinels(
HtmlGenerator.resolveChunkUrlSentinels(raw, compilation),
computePageFilenameByEntry
),
compilation
)
.split(autoPlaceholder)
.join("")
).replace(
/__WEBPACK_HTML_INLINE__[0-9a-f]+__[a-z]+(?:__[0-9a-f]+)?__END__/g,
""
);
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);
}
});
}
);
}
}
/**
* Per-compilation hooks for the experimental HTML support.
* @param {Compilation} compilation the compilation
* @returns {HtmlCompilationHooks} the hooks
*/
HtmlModulesPlugin.getCompilationHooks = createHooksRegistry(
() =>
/** @type {HtmlCompilationHooks} */ ({
injectTags: new AsyncSeriesWaterfallHook(["tags", "context"]),
transformTags: new AsyncSeriesHook(["tags", "context"]),
transformHtml: new AsyncSeriesWaterfallHook(["html", "context"]),
htmlEmitted: new AsyncSeriesHook(["context"])
})
);
module.exports = HtmlModulesPlugin;