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;