mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 11:21:21 +00:00
493 lines
18 KiB
JavaScript
493 lines
18 KiB
JavaScript
/*
|
|
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;
|