Skip to content

Commit 868209f

Browse files
feat(html): generate output.html for entrypoints with dependOn support (#21215)
* feat: add output.html to generate HTML for non-HTML entrypoints When output.html (or an entry's `html` option) is enabled, the entrypoint is wrapped in a synthetic HTML module (<script src> for JS, <link> for CSS) that flows through the existing HtmlModulesPlugin/HtmlGenerator pipeline, so chunk injection, publicPath and the template option work like a real `entry: "./index.html"`. Adds `html` to the entry descriptor for per-entry control. * refactor: make entry wrapping pluggable via EntryOptionPlugin hook Move the output.html entry-wrapping logic out of EntryOptionPlugin into a tap on a new EntryOptionPlugin.getHooks(compiler).entry SyncBailHook, and register it from HtmlModulesPlugin. Other plugins can now redirect a non-HTML entry to a custom request (e.g. markdown). * feat(html): respect dependOn when generating output.html entrypoints The output.html wrapper turned each entry's imports into a synthetic HTML module, but dropped the entry's dependOn relationship. A dependant page neither loaded its dependOn target's chunk nor deduplicated the shared modules — the shared code was inlined into the dependant's own chunk. Resolve the transitive dependOn chain when wrapping an entry and inject the ancestors' scripts/styles first, so the existing leader-only sub-entry chaining and getEntrypointChunksInLoadOrder deduplicate the shared chunks. Diamond graphs load each shared file once; CSS links go in <head>, scripts in <body>. Add config cases for basic/transitive/diamond dependOn, CSS in a dependOn target, and runtimeChunk + splitChunks injection. * test: match src attribute instead of full script tag in output.html tests CodeQL's bad-HTML-tag-filter rule flagged the `<script ...></script>` regexp. These tests parse webpack's own deterministic output, so it's a false positive, but matching the `src` attribute (and using indexOf for the CSS ordering check) avoids the alert and is more robust to whitespace. * test: use RegExp.exec instead of matchAll for Node 10 compatibility `String.prototype.matchAll` is Node 12+, but webpack's integration tests run on Node 10.x. Replace it with an `exec` loop in the output.html dependOn/split-chunks tests. * feat(html): mirror output.crossOriginLoading onto injected output.html tags When output.crossOriginLoading is set, the synthetic output.html wrapper now adds a matching crossorigin attribute to the injected <script>/<link> tags. Because crossorigin is a copyable sibling attribute, it propagates to every cloned JS sibling and synthesized CSS link too. integrity is still dropped (content-specific); left a TODO to emit per-chunk SRI once a core option exists. * feat(html): apply output.crossOriginLoading to all injected html tags Move crossorigin handling out of the synthetic output.html wrapper and into HtmlScriptSrcDependency so it covers every injected tag — the rewritten entry tag plus all cloned/synthesized sibling <script>/<link> tags — for both output.html and real .html template entries. An author-set crossorigin is preserved; otherwise output.crossOriginLoading is used. Matches Vite (emits crossorigin on all injected tags) and webpack's runtime chunk loading. * test: avoid tag-shaped regexp in output.html crossorigin tests CodeQL's bad-HTML-tag-filter rule flagged the `<script ...></script>` matcher. These tests parse webpack's own deterministic output, so assert via attribute matching and occurrence counts instead of a tag regexp. * refactor(html): drive crossorigin injection from parse-time offsets Replace the tag-name and crossorigin-detection regexps in HtmlScriptSrcDependency with data captured by HtmlParser: the tag-name end offset and a hasOwnCrossOrigin flag. The template now inserts the attribute at a known offset instead of re-scanning the tag text. --------- Co-authored-by: aryanraj45 <143009186+aryanraj45@users.noreply.github.com>
1 parent eef17e3 commit 868209f

79 files changed

Lines changed: 1221 additions & 26 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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 `output.html` to emit an HTML file per entrypoint, injecting its JS/CSS chunks (including `dependOn` shared chunks).

declarations/WebpackOptions.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,10 @@ export interface EntryDescription {
12301230
* Specifies the filename of the output file on disk. You must **not** specify an absolute path here, but the path may contain folders separated by '/'! The specified path is joined with the value of the 'output.path' option to determine the location on disk.
12311231
*/
12321232
filename?: EntryFilename;
1233+
/**
1234+
* Generate an HTML file for this entrypoint with its JS and CSS output chunks injected. Overrides `output.html` for this entry.
1235+
*/
1236+
html?: boolean;
12331237
/**
12341238
* Module(s) that are loaded upon startup.
12351239
*/
@@ -2476,6 +2480,10 @@ export interface Output {
24762480
* The filename of the Hot Update Main File. It is inside the 'output.path' directory.
24772481
*/
24782482
hotUpdateMainFilename?: HotUpdateMainFilename;
2483+
/**
2484+
* Generate an HTML file for each non-HTML entrypoint with its JS and CSS output chunks injected. Can be overridden per entry via the entry descriptor `html` option.
2485+
*/
2486+
html?: boolean;
24792487
/**
24802488
* Specifies the filename template of non-initial output html files on disk. You must **not** specify an absolute path here, but the path may contain folders separated by '/'! The specified path is joined with the value of the 'output.path' option to determine the location on disk.
24812489
*/
@@ -3465,6 +3473,10 @@ export interface EntryDescriptionNormalized {
34653473
* Specifies the filename of output files on disk. You must **not** specify an absolute path here, but the path may contain folders separated by '/'! The specified path is joined with the value of the 'output.path' option to determine the location on disk.
34663474
*/
34673475
filename?: Filename;
3476+
/**
3477+
* Generate an HTML file for this entrypoint with its JS and CSS output chunks injected. Overrides `output.html` for this entry.
3478+
*/
3479+
html?: boolean;
34683480
/**
34693481
* Module(s) that are loaded upon startup. The last one is exported.
34703482
*/
@@ -4098,6 +4110,10 @@ export interface OutputNormalized {
40984110
* The filename of the Hot Update Main File. It is inside the 'output.path' directory.
40994111
*/
41004112
hotUpdateMainFilename?: HotUpdateMainFilename;
4113+
/**
4114+
* Generate an HTML file for each non-HTML entrypoint with its JS and CSS output chunks injected. Can be overridden per entry via the entry descriptor `html` option.
4115+
*/
4116+
html?: boolean;
41014117
/**
41024118
* Specifies the filename template of non-initial output html files on disk. You must **not** specify an absolute path here, but the path may contain folders separated by '/'! The specified path is joined with the value of the 'output.path' option to determine the location on disk.
41034119
*/

lib/EntryOptionPlugin.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,39 @@
55

66
"use strict";
77

8+
const { SyncBailHook } = require("tapable");
9+
810
/** @typedef {import("../declarations/WebpackOptions").EntryDescriptionNormalized} EntryDescription */
911
/** @typedef {import("../declarations/WebpackOptions").EntryNormalized} Entry */
1012
/** @typedef {import("./Compiler")} Compiler */
1113
/** @typedef {import("./Entrypoint").EntryOptions} EntryOptions */
1214

15+
/**
16+
* @typedef {object} EntryOptionPluginHooks
17+
* @property {SyncBailHook<[string, string, EntryDescription], string | undefined>} entry transform an entry into a different request (e.g. wrap a non-HTML entry in a synthetic HTML module); return `undefined` to keep the default behavior
18+
*/
19+
1320
const PLUGIN_NAME = "EntryOptionPlugin";
1421

22+
/** @type {WeakMap<Compiler, EntryOptionPluginHooks>} */
23+
const hooksMap = new WeakMap();
24+
1525
class EntryOptionPlugin {
26+
/**
27+
* @param {Compiler} compiler the compiler
28+
* @returns {EntryOptionPluginHooks} the hooks
29+
*/
30+
static getHooks(compiler) {
31+
let hooks = hooksMap.get(compiler);
32+
if (hooks === undefined) {
33+
hooks = {
34+
entry: new SyncBailHook(["context", "name", "entryDescription"])
35+
};
36+
hooksMap.set(compiler, hooks);
37+
}
38+
return hooks;
39+
}
40+
1641
/**
1742
* Applies the plugin by registering its hooks on the compiler.
1843
* @param {Compiler} compiler the compiler instance one is tapping into
@@ -50,8 +75,19 @@ class EntryOptionPlugin {
5075
const descImport =
5176
/** @type {Exclude<EntryDescription["import"], undefined>} */
5277
(desc.import);
53-
for (const entry of descImport) {
54-
new EntryPlugin(context, entry, options).apply(compiler);
78+
// A plugin (e.g. HtmlModulesPlugin) may rewrite the entry into a
79+
// single synthetic request; otherwise each import becomes an entry.
80+
const request = EntryOptionPlugin.getHooks(compiler).entry.call(
81+
context,
82+
name,
83+
desc
84+
);
85+
if (request !== undefined) {
86+
new EntryPlugin(context, request, options).apply(compiler);
87+
} else {
88+
for (const entry of descImport) {
89+
new EntryPlugin(context, entry, options).apply(compiler);
90+
}
5591
}
5692
}
5793
}

lib/config/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,6 +1684,7 @@ const applyOutputDefaults = (
16841684
}
16851685
return "[name].html";
16861686
});
1687+
D(output, "html", false);
16871688
D(output, "assetModuleFilename", "[hash][ext][query][fragment]");
16881689
D(output, "webassemblyModuleFilename", "[hash].module.wasm");
16891690
D(output, "compareBeforeEmit", true);

lib/config/normalization.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ const getNormalizedWebpackOptions = (config) => ({
368368
hotUpdateChunkFilename: output.hotUpdateChunkFilename,
369369
hotUpdateGlobal: output.hotUpdateGlobal,
370370
hotUpdateMainFilename: output.hotUpdateMainFilename,
371+
html: output.html,
371372
htmlChunkFilename: output.htmlChunkFilename,
372373
htmlFilename: output.htmlFilename,
373374
ignoreBrowserWarnings: output.ignoreBrowserWarnings,
@@ -553,6 +554,7 @@ const getNormalizedEntryStatic = (entry) => {
553554
(Array.isArray(value.import) ? value.import : [value.import])
554555
),
555556
filename: value.filename,
557+
html: value.html,
556558
layer: value.layer,
557559
runtime: value.runtime,
558560
baseUri: value.baseUri,

lib/dependencies/HtmlScriptSrcDependency.js

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const ModuleDependency = require("./ModuleDependency");
2222
/** @typedef {import("../javascript/JavascriptParser").Range} Range */
2323
/** @typedef {"script" | "script-module" | "modulepreload" | "stylesheet"} HtmlScriptElementKind */
2424

25-
/** @typedef {import("../serialization/ObjectMiddleware").ObjectDeserializerContext<[string, string, HtmlScriptElementKind, number, number, boolean, string]>} ObjectDeserializerContext */
26-
/** @typedef {import("../serialization/ObjectMiddleware").ObjectSerializerContext<[string, string, HtmlScriptElementKind, number, number, boolean, string]>} ObjectSerializerContext */
25+
/** @typedef {import("../serialization/ObjectMiddleware").ObjectDeserializerContext<[string, string, HtmlScriptElementKind, number, number, boolean, string, number, boolean]>} ObjectDeserializerContext */
26+
/** @typedef {import("../serialization/ObjectMiddleware").ObjectSerializerContext<[string, string, HtmlScriptElementKind, number, number, boolean, string, number, boolean]>} ObjectSerializerContext */
2727

2828
class HtmlScriptSrcDependency extends ModuleDependency {
2929
/**
@@ -37,6 +37,8 @@ class HtmlScriptSrcDependency extends ModuleDependency {
3737
* @param {number=} tagOpenEnd position of the character immediately after the opening tag's `>` in the source; combined with `tagStart` this lets the template clone the original opening tag verbatim (preserving attributes like `nonce`, `crossorigin`, `referrerpolicy`, `defer`, `async`) when generating sibling tags
3838
* @param {boolean=} tagIsNative whether the originating element is the native tag for `elementKind` (`<script>` / `<link>`); decided at parse time from the tag name so the template needn't re-parse the source text. A custom element mapped to a `script`/`stylesheet` source `type` is non-native and gets a freshly synthesized sibling tag instead of a verbatim clone
3939
* @param {string=} copyableAttrsText the originating tag's `nonce`/`crossorigin`/`referrerpolicy` attribute source spans (leading-space-prefixed, in that fixed order), captured at parse time so synthesized sibling `<link>`/`<script>` tags carry the same CSP/fetch policy without the template re-parsing the tag text; empty when none are present
40+
* @param {number=} tagNameEnd position right after the originating tag's name (e.g. after `<script`), captured at parse time so the template can insert a `crossorigin` attribute without re-scanning the tag text for the name boundary
41+
* @param {boolean=} hasOwnCrossOrigin whether the originating tag already carries a `crossorigin` attribute; when true the author's value wins and `output.crossOriginLoading` is not applied
4042
*/
4143
constructor(
4244
request,
@@ -47,7 +49,9 @@ class HtmlScriptSrcDependency extends ModuleDependency {
4749
tagStart,
4850
tagOpenEnd,
4951
tagIsNative,
50-
copyableAttrsText
52+
copyableAttrsText,
53+
tagNameEnd,
54+
hasOwnCrossOrigin
5155
) {
5256
super(request);
5357
this.range = range;
@@ -64,6 +68,10 @@ class HtmlScriptSrcDependency extends ModuleDependency {
6468
this.tagIsNative = tagIsNative !== false;
6569
/** @type {string} */
6670
this.copyableAttrsText = copyableAttrsText || "";
71+
/** @type {number} */
72+
this.tagNameEnd = tagNameEnd === undefined ? -1 : tagNameEnd;
73+
/** @type {boolean} */
74+
this.hasOwnCrossOrigin = hasOwnCrossOrigin === true;
6775
}
6876

6977
get type() {
@@ -86,7 +94,9 @@ class HtmlScriptSrcDependency extends ModuleDependency {
8694
.write(this.tagStart)
8795
.write(this.tagOpenEnd)
8896
.write(this.tagIsNative)
89-
.write(this.copyableAttrsText);
97+
.write(this.copyableAttrsText)
98+
.write(this.tagNameEnd)
99+
.write(this.hasOwnCrossOrigin);
90100
super.serialize(context);
91101
}
92102

@@ -108,7 +118,11 @@ class HtmlScriptSrcDependency extends ModuleDependency {
108118
this.tagIsNative = c5.read();
109119
const c6 = c5.rest;
110120
this.copyableAttrsText = c6.read();
111-
super.deserialize(c6.rest);
121+
const c7 = c6.rest;
122+
this.tagNameEnd = c7.read();
123+
const c8 = c7.rest;
124+
this.hasOwnCrossOrigin = c8.read();
125+
super.deserialize(c8.rest);
112126
}
113127
}
114128

@@ -289,11 +303,12 @@ const firstCssModulePostOrderIndex = (chunk, entrypoint, chunkGraph) => {
289303
* `defer`/`async`/`type` have no meaning on `<link>` and are dropped.
290304
* @param {string} extra the copyable CSP/fetch attribute source text (leading-space-prefixed, or empty)
291305
* @param {string} href URL for the stylesheet
306+
* @param {string} crossOrigin ` crossorigin="…"` to append from `output.crossOriginLoading`, or `""`
292307
* @returns {string} the sibling `<link>` tag's HTML
293308
*/
294-
const buildStylesheetLink = (extra, href) => {
309+
const buildStylesheetLink = (extra, href, crossOrigin) => {
295310
const safeHref = href.replace(/"/g, "&quot;");
296-
return `<link rel="stylesheet" href="${safeHref}"${extra}>`;
311+
return `<link rel="stylesheet" href="${safeHref}"${extra}${crossOrigin}>`;
297312
};
298313

299314
/**
@@ -308,11 +323,12 @@ const buildStylesheetLink = (extra, href) => {
308323
* @param {string} extra the copyable CSP/fetch attribute source text (leading-space-prefixed, or empty)
309324
* @param {string} src URL for the script
310325
* @param {boolean} isModule whether to emit `type="module"`
326+
* @param {string} crossOrigin ` crossorigin="…"` to append from `output.crossOriginLoading`, or `""`
311327
* @returns {string} the sibling `<script>` tag's HTML
312328
*/
313-
const buildScriptTag = (extra, src, isModule) => {
329+
const buildScriptTag = (extra, src, isModule, crossOrigin) => {
314330
const safeSrc = src.replace(/"/g, "&quot;");
315-
return `<script${isModule ? ' type="module"' : ""} src="${safeSrc}"${extra}></script>`;
331+
return `<script${isModule ? ' type="module"' : ""} src="${safeSrc}"${extra}${crossOrigin}></script>`;
316332
};
317333

318334
/**
@@ -330,24 +346,31 @@ const buildScriptTag = (extra, src, isModule) => {
330346
* @param {number} srcEndInTag offset of the src/href value end within `originalTag`
331347
* @param {string} newUrl URL to put into the cloned tag's src/href slot
332348
* @param {HtmlScriptElementKind} elementKind shape of the originating tag
349+
* @param {string} crossOrigin ` crossorigin="…"` to insert from `output.crossOriginLoading`, or `""`
350+
* @param {number} tagNameEndInTag offset right after the tag name within `originalTag`, where `crossOrigin` is inserted
333351
* @returns {string} the sibling tag's HTML (including a closing `</script>` for script tags)
334352
*/
335353
const cloneTagWithUrl = (
336354
originalTag,
337355
srcStartInTag,
338356
srcEndInTag,
339357
newUrl,
340-
elementKind
358+
elementKind,
359+
crossOrigin,
360+
tagNameEndInTag
341361
) => {
342362
let body =
343363
originalTag.slice(0, srcStartInTag) +
344364
newUrl +
345365
originalTag.slice(srcEndInTag);
346366

347367
// Strip dangerous-to-copy attributes from the cloned tag — currently
348-
// just `integrity`. The match handles all three quoting styles
368+
// just `integrity`, which is content-specific and would be wrong for a
369+
// different chunk's file. The match handles all three quoting styles
349370
// (`"…"`, `'…'`, unquoted) and the bare-attribute form. The `includes`
350371
// gate skips the regex engine for the common no-SRI tag.
372+
// TODO: emit a correct per-chunk `integrity` once a core SRI output
373+
// option exists, instead of dropping it.
351374
if (body.includes("integrity")) {
352375
body = body.replace(
353376
/\s+integrity(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?(?=[\s/>])/gi,
@@ -366,6 +389,16 @@ const cloneTagWithUrl = (
366389
}
367390
}
368391

392+
// Insert `crossorigin` from `output.crossOriginLoading` right after the tag
393+
// name (its parse-time offset). The type/integrity edits above only touch
394+
// text at or after the name boundary, so the offset stays valid.
395+
if (crossOrigin) {
396+
body =
397+
body.slice(0, tagNameEndInTag) +
398+
crossOrigin +
399+
body.slice(tagNameEndInTag);
400+
}
401+
369402
// `<link>` is a void element — no closing tag. `<script>` needs `</script>`.
370403
return elementKind === "modulepreload" || elementKind === "stylesheet"
371404
? body
@@ -387,6 +420,7 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
387420
const dep = /** @type {HtmlScriptSrcDependency} */ (dependency);
388421
const compilation = runtimeTemplate.compilation;
389422
const { chunkGraph } = compilation;
423+
const { crossOriginLoading } = compilation.outputOptions;
390424
const entrypoint = /** @type {Entrypoint | undefined} */ (
391425
compilation.entrypoints.get(dep.entryName)
392426
);
@@ -434,6 +468,24 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
434468
// tag name (`dep.tagIsNative`) rather than re-parsing the source text.
435469
const tagIsNative = dep.tagIsNative;
436470

471+
// `crossorigin` to mirror `output.crossOriginLoading` onto every injected
472+
// tag. Empty when the option is off or the originating tag already set
473+
// `crossorigin` (author value wins, flagged at parse time). Mirrors
474+
// webpack's runtime, which sets `crossOrigin` on chunk-loading scripts,
475+
// and matches Vite, which emits it on every injected script/stylesheet.
476+
const crossOrigin =
477+
crossOriginLoading && !dep.hasOwnCrossOrigin
478+
? ` crossorigin="${crossOriginLoading}"`
479+
: "";
480+
const tagNameEndInTag = dep.tagNameEnd - dep.tagStart;
481+
482+
// Mirror it onto the entry tag too (siblings get it via the builders
483+
// below), inserted right after the tag name — its parse-time offset — so
484+
// it sits alongside any `type="module"` the parser injected.
485+
if (tagIsNative && crossOrigin && dep.tagNameEnd >= 0) {
486+
source.insert(dep.tagNameEnd, crossOrigin);
487+
}
488+
437489
/**
438490
* @param {Chunk} chunk chunk to emit a sibling tag for
439491
* @param {"javascript" | "css"} kind content type slice of the chunk to emit
@@ -453,10 +505,12 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
453505
srcStartInTag,
454506
srcEndInTag,
455507
url,
456-
dep.elementKind
508+
dep.elementKind,
509+
crossOrigin,
510+
tagNameEndInTag
457511
);
458512
}
459-
return buildStylesheetLink(dep.copyableAttrsText, url);
513+
return buildStylesheetLink(dep.copyableAttrsText, url, crossOrigin);
460514
}
461515
// A JS chunk is loaded via `<script>`. Clone a native `<script>`
462516
// verbatim; synthesize a real `<script>` for custom elements that
@@ -467,13 +521,16 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
467521
srcStartInTag,
468522
srcEndInTag,
469523
url,
470-
dep.elementKind
524+
dep.elementKind,
525+
crossOrigin,
526+
tagNameEndInTag
471527
);
472528
}
473529
return buildScriptTag(
474530
dep.copyableAttrsText,
475531
url,
476-
dep.elementKind === "script-module"
532+
dep.elementKind === "script-module",
533+
crossOrigin
477534
);
478535
};
479536

0 commit comments

Comments
 (0)