Skip to content

Commit 64197bc

Browse files
feat(html): name an extracted tag's chunk with an option or a comment (#22200)
* feat(html): name an extracted tag's chunk with an option or a comment `module.parser.html.chunkName` takes a template — `[page]`, `[name]`, `[index]`, `[type]` — or a function of the same values, and a `webpackChunkName` comment before a tag names that one. Either is the stem its file is emitted under too, so a named chunk is no longer called after the page or the tag's url. It sits beside `sources`, which decides what a page extracts, because the name is minted in the same parse: `output.html` would have tied naming to the option whose presence wraps every entry in a generated page. A named inline tag leads a run rather than joining the one before it, so the name covers what follows; two tags of a page may not claim one name, which would merge entries the document keeps apart. `getRequestBaseName` moves to `lib/util/identifier.js`, the home for path helpers, now that the parser needs it as well. * fix(html): keep a claimed chunk name unique and name a linked page Falling back to `<name>-<index>` could land on a name another tag of the page already held, which merges two entries into one. Every candidate is checked now, counting on from the tag's own position. A `type: "html"` link's page kept its own path as its filename, so a name it asked for reached its entry and not its file. The changeset also named the option by the path it had before the move. * fix(html): refuse a page name another page or entry already holds A second page asking for a taken name was appended to the entry holding it, so both were emitted as one file and the build died on an asset conflict. Claim each page name by request instead: a link to the page that already holds it shares that page, anything else is reported. * test(html): cover the [name] placeholder in the chunkName template
1 parent 02186d1 commit 64197bc

45 files changed

Lines changed: 882 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/011-html-chunk-name.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": minor
3+
---
4+
5+
Add `module.parser.html.chunkName` and a `webpackChunkName` comment for tags.

‎lib/html/HtmlModulesPlugin.js‎

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
"use strict";
66

7-
const { basename, extname } = require("path");
87
const { pathToFileURL } = require("url");
98
const { AsyncSeriesHook, AsyncSeriesWaterfallHook } = require("tapable");
109
const { RawSource } = require("webpack-sources");
@@ -28,9 +27,9 @@ const {
2827
const createHash = require("../util/createHash");
2928
const createHooksRegistry = require("../util/createHooksRegistry");
3029
const {
30+
getRequestBaseName,
3131
getUndoPath,
32-
makePathsRelative,
33-
parseResource
32+
makePathsRelative
3433
} = require("../util/identifier");
3534
const implicitTypeLoaderFallback = require("../util/implicitTypeLoaderFallback");
3635
const lazyModule = require("../util/lazyModule");
@@ -80,7 +79,7 @@ const getCssModulesPlugin = memoize(() => require("../css/CssModulesPlugin"));
8079
/** @typedef {import("../Compilation").EntryData} EntryData */
8180
/** @import { HtmlModuleBuildInfo } from "./HtmlModule" */
8281
/** @import HtmlParser from "./HtmlParser" */
83-
/** @typedef {{ request: string, entryName: string, index: number, type: "script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch", css?: boolean }} HtmlEntryInfo */
82+
/** @typedef {{ request: string, entryName: string, index: number, type: "script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch", css?: boolean, chunkName?: string }} HtmlEntryInfo */
8483
/**
8584
* What one extracted entry needs to be named. Recorded in page order once
8685
* every page is built, and resolved to filenames once chunks exist.
@@ -114,7 +113,7 @@ const getCssModulesPlugin = memoize(() => require("../css/CssModulesPlugin"));
114113
* @property {boolean=} remove set true to delete the whole element
115114
*/
116115
/** @typedef {{ outputName: string, html: string }} HtmlTransformTagsContext */
117-
/** @typedef {{ context: string, request: string, name: string, index: number, dependOn: string[] | undefined }} HtmlEntrySpec */
116+
/** @typedef {{ context: string, request: string, name: string, index: number, dependOn: string[] | undefined, page: boolean }} HtmlEntrySpec */
118117

119118
const createCompilationHooks = () => ({
120119
/**
@@ -290,19 +289,6 @@ const getPageOnlyEntry = (compilation, module) => {
290289
return configured === undefined ? entry : undefined;
291290
};
292291

293-
/**
294-
* The basename a tag's url lends to the entry it becomes, so
295-
* `src="./my-script.js"` emits `my-script.js`. Undefined for an inline body.
296-
* @param {string} request the tag's request
297-
* @returns {string | undefined} the basename, when the url carries one
298-
*/
299-
const getRequestBaseName = (request) => {
300-
// An inline `<script>` body is a `data:` request webpack writes itself.
301-
if (request.startsWith("data:")) return undefined;
302-
const { path: file } = parseResource(request);
303-
return basename(file, extname(file)) || undefined;
304-
};
305-
306292
/**
307293
* Claims `base`, numbering it while something else already emits under that
308294
* name, so two tags sharing a basename stay distinct.
@@ -627,11 +613,11 @@ class HtmlModulesPlugin {
627613
// Per-compilation state: the HTML modules seen during make, so `finishMake`
628614
// creates their entries without scanning the module graph, plus the
629615
// stylesheet entry names and each emitted page's html options.
630-
/** @type {WeakMap<import("../Compilation"), { htmlModules: Set<Module>, stylesheetEntries: Set<string>, pendingNames: Map<string, PendingName>, htmlAssetOptions: Map<string, OutputHtmlOptions> }>} */
616+
/** @type {WeakMap<import("../Compilation"), { htmlModules: Set<Module>, stylesheetEntries: Set<string>, pendingNames: Map<string, PendingName>, htmlAssetOptions: Map<string, OutputHtmlOptions>, pageNames: Map<string, string> }>} */
631617
const compilationState = new WeakMap();
632618
/**
633619
* @param {import("../Compilation")} compilation compilation
634-
* @returns {{ htmlModules: Set<Module>, stylesheetEntries: Set<string>, pendingNames: Map<string, PendingName>, htmlAssetOptions: Map<string, OutputHtmlOptions> }} per-compilation state
620+
* @returns {{ htmlModules: Set<Module>, stylesheetEntries: Set<string>, pendingNames: Map<string, PendingName>, htmlAssetOptions: Map<string, OutputHtmlOptions>, pageNames: Map<string, string> }} per-compilation state
635621
*/
636622
const getState = (compilation) => {
637623
let state = compilationState.get(compilation);
@@ -640,14 +626,15 @@ class HtmlModulesPlugin {
640626
htmlModules: new Set(),
641627
stylesheetEntries: new Set(),
642628
pendingNames: new Map(),
643-
htmlAssetOptions: new Map()
629+
htmlAssetOptions: new Map(),
630+
pageNames: new Map()
644631
};
645632
compilationState.set(compilation, state);
646633
}
647634
return state;
648635
};
649636
compiler.hooks.finishMake.tapAsync(PLUGIN_NAME, (compilation, callback) => {
650-
const { htmlModules, stylesheetEntries, pendingNames } =
637+
const { htmlModules, stylesheetEntries, pendingNames, pageNames } =
651638
getState(compilation);
652639
/** @type {Set<string>} */
653640
const extractedNames = new Set();
@@ -699,7 +686,8 @@ class HtmlModulesPlugin {
699686
request: entry.request,
700687
name: entry.entryName,
701688
index: entry.index,
702-
dependOn
689+
dependOn,
690+
page: groupKind === "html"
703691
});
704692
}
705693
}
@@ -783,10 +771,13 @@ class HtmlModulesPlugin {
783771
holder = compilation.moduleGraph.getIssuer(holder);
784772
}
785773
for (const entry of ordered) {
774+
// A name the page or `module.parser.html.chunkName` asked for is what
775+
// the file is called too; otherwise the tag's url lends its basename.
786776
const base =
787777
wrapsEntry && namedEntry
788778
? namedEntry.name
789-
: getRequestBaseName(entry.request) ||
779+
: entry.chunkName ||
780+
getRequestBaseName(entry.request) ||
790781
pageBase ||
791782
entry.entryName;
792783
pendingNames.set(entry.entryName, {
@@ -797,6 +788,11 @@ class HtmlModulesPlugin {
797788
}
798789
links.sort((a, b) => a.index - b.index);
799790
for (const link of links) {
791+
// A linked page is named after its own path; a name the page asked
792+
// for replaces that, and the link to it is rewritten either way.
793+
if (link.chunkName && !contestedPageNames.has(link.entryName)) {
794+
pageNames.set(link.entryName, link.chunkName);
795+
}
800796
const page = linkedPages.get(link.entryName);
801797
if (page) recordNames(page, seen);
802798
}
@@ -822,6 +818,28 @@ class HtmlModulesPlugin {
822818
);
823819
});
824820

821+
// What each page name was claimed for, so a second page asking for a
822+
// taken one is refused rather than appended to the entry holding it.
823+
/** @type {Map<string, string>} */
824+
const pageRequests = new Map();
825+
// Names a second page asked for and did not get, so it keeps the one its
826+
// own path lends and emits beside the page holding the name.
827+
/** @type {Set<string>} */
828+
const contestedPageNames = new Set();
829+
/**
830+
* @param {string} name the name two things ask for
831+
* @param {string} taken what holds it
832+
* @param {string} request what asked for it
833+
*/
834+
const reportNameTaken = (name, taken, request) => {
835+
contestedPageNames.add(name);
836+
compilation.errors.push(
837+
new WebpackError(
838+
`The name "${name}" is asked for by ${request} and is already taken by ${taken}. A page is emitted as its name, so give one of them another.`
839+
)
840+
);
841+
};
842+
825843
// Create one HTML module's entries. A `type: "html"` link is itself an
826844
// HTML entry, so after it builds we recurse into it — the same handling
827845
// every HTML entry already gets. `processed` guards diamonds and cycles.
@@ -833,7 +851,32 @@ class HtmlModulesPlugin {
833851
processed.add(module);
834852
await Promise.all(
835853
collectEntrySpecs(module).map(async (spec) => {
854+
if (spec.page) {
855+
const claimed = pageRequests.get(spec.name);
856+
// The page holding the name is already an entry of its own:
857+
// a second link to it adds nothing, a second page cannot have it.
858+
if (claimed !== undefined) {
859+
if (claimed !== spec.request) {
860+
reportNameTaken(spec.name, claimed, spec.request);
861+
}
862+
return;
863+
}
864+
pageRequests.set(spec.name, spec.request);
865+
}
836866
const entryModule = await addEntry(spec);
867+
// A page entry webpack made for us holds that page alone; anything
868+
// else in it named the same and would be emitted over.
869+
if (spec.page) {
870+
const entry = compilation.entries.get(spec.name);
871+
if (entry && entry.dependencies.length !== 1) {
872+
reportNameTaken(
873+
spec.name,
874+
`entry "${spec.name}"`,
875+
spec.request
876+
);
877+
return;
878+
}
879+
}
837880
if (entryModule && entryModule.type === HTML_MODULE_TYPE) {
838881
linkedPages.set(spec.name, entryModule);
839882
await processEntry(entryModule);
@@ -1269,10 +1312,14 @@ class HtmlModulesPlugin {
12691312
resolvedSentinelHashCache.set(placeholderSource, cached);
12701313
}
12711314
const resource = module.getResource() || module.resource;
1272-
// Synthetic `output.html` entries are `data:text/html` modules with no
1273-
// real basename — name the file after the entry instead.
1315+
// A page is named after its own path, unless it asked for a name; a
1316+
// synthetic `output.html` entry has none, so its entry names it.
1317+
const requestedPageName = chunk.name
1318+
? getState(compilation).pageNames.get(chunk.name)
1319+
: undefined;
12741320
const sourceFilename =
1275-
resource.startsWith("data:text/html") && chunk.name
1321+
requestedPageName ||
1322+
(resource.startsWith("data:text/html") && chunk.name
12761323
? chunk.name
12771324
: toTemplateSourceFileName(
12781325
makePathsRelative(
@@ -1281,7 +1328,7 @@ class HtmlModulesPlugin {
12811328
compiler.root
12821329
).replace(/^\.\//, ""),
12831330
compilation
1284-
);
1331+
));
12851332
const filenameTemplate = HtmlModulesPlugin.getChunkFilenameTemplate(
12861333
chunk,
12871334
outputOptions

0 commit comments

Comments
 (0)