Source code

Revision control

Copy as Markdown

Other Tools

/*
*/
"use strict";
const Parser = require("../Parser");
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 CommentCompilationWarning = require("../errors/CommentCompilationWarning");
const ModuleDependencyError = require("../errors/ModuleDependencyError");
const UnsupportedFeatureWarning = require("../errors/UnsupportedFeatureWarning");
const WebpackError = require("../errors/WebpackError");
const ResourceHintPlugin = require("../prefetch/ResourceHintPlugin");
const parseResourceHintOptions = require("../prefetch/parseResourceHintOptions");
const LazySet = require("../util/LazySet");
const LocConverter = require("../util/LocConverter");
const createHash = require("../util/createHash");
const { contextify } = require("../util/identifier");
const {
createMagicCommentContext,
parseMagicComment,
webpackCommentRegExp
} = require("../util/magicComment");
const {
NS_HTML,
NS_SVG,
NodeType,
SVG_TAG_ADJUST,
SourceProcessor,
decodeEntities,
escapeAttribute,
escapeText,
isAsciiWhitespace,
parseCssUrls,
parseMsapplicationTask,
parseSrc,
parseSrcset
} = require("./syntax");
/** @typedef {import("../../declarations/WebpackOptions").HtmlParserOptions} HtmlParserOptions */
/** @typedef {import("../javascript/JavascriptParser").Range} Range */
/** @typedef {import("../Module")} Module */
/** @typedef {import("../Module").BuildInfo} BuildInfo */
/** @typedef {import("../Compilation").FileSystemDependencies} FileSystemDependencies */
/** @typedef {import("../Module").BuildMeta} BuildMeta */
/** @typedef {import("../NormalModule")} NormalModule */
/** @typedef {import("../Parser").ParserState} ParserState */
/** @typedef {import("../Parser").PreparsedAst} PreparsedAst */
/** @typedef {import("./HtmlModule").HtmlModuleBuildInfo} HtmlModuleBuildInfo */
/**
* @typedef {object} HtmlTemplateContext
* @property {Module} module the html module being transformed
* @property {string} resource absolute path of the module's resource
* @property {(dependency: string) => void} addDependency register a file (e.g. a template partial) as a build dependency so editing it triggers a rebuild
* @property {(dependency: string) => void} addContextDependency register a directory as a build dependency
* @property {(dependency: string) => void} addMissingDependency register a not-yet-existing path as a build dependency so creating it triggers a rebuild
* @property {(dependency: string) => void} addBuildDependency register a build dependency (e.g. a template engine config) so changing it invalidates the cache
* @property {(warning: Error | string) => void} emitWarning report a non-fatal warning on the module
* @property {(error: Error | string) => void} emitError report an error on the module
*/
/** @typedef {(source: string, context: HtmlTemplateContext) => string} HtmlTemplateFunction */
/** @typedef {import("./syntax").ParsedSource} ParsedSource */
// Cheap pre-filter for a `style="..."` attribute: only route it through the
// CSS pipeline when it can hold a URL-bearing function (`url()` / `src()` /
// `image()` / `image-set()`), otherwise the processed text equals the input.
const STYLE_ATTR_URL_REGEXP = /url\(|src\(|image\(|image-set\(/i;
// Cheap pre-filter for a `css-url` attribute value (an SVG presentation
// attribute such as `fill`, `clip-path`, …): only tokenize values that can
// hold a `url(...)` FuncIRI. Most are plain colors/keywords (`#fff`, `red`).
const FUNC_IRI_URL_REGEXP = /url\(/i;
// Cheap pre-filter for `<iframe srcdoc>` markup: only spin up a nested HTML
// module when the document can reference an asset — via any attribute (`src=`,
// `href=`, `style=`, …), a CSS `url(...)`, or a CSS `@import`. Pure
// formatting/text markup (`<p>hi</p>`) rewrites to itself, so skip it.
const SRCDOC_ASSET_REGEXP = /[=]|url\(|@import/i;
// A URL carrying its own scheme (`https:`, `data:`, …) ignores the document
// base per the URL spec, so `<base href>` never rewrites it.
const ABSOLUTE_URL_SCHEME_REGEXP = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
// a `charset=` declaration inside an `http-equiv="content-type"` content value
const CHARSET_DECLARATION_REGEXP = /charset\s*=/i;
/**
* @param {string} name meta name
* @param {string} content meta content
* @returns {string} meta tag
*/
const metaTag = (name, content) => {
// og: uses property=; all others (including twitter:) use name=
const attr = name.startsWith("og:")
? `property="${escapeAttribute(name)}"`
: `name="${escapeAttribute(name)}"`;
return `<meta ${attr} content="${escapeAttribute(content)}">`;
};
/**
* @param {string | { href: string, target?: string }} base base option
* @returns {string} base tag
*/
const baseTag = (base) => {
const href = typeof base === "string" ? base : base.href;
const targetAttr =
typeof base === "object" && base.target
? ` target="${escapeAttribute(base.target)}"`
: "";
return `<base href="${escapeAttribute(href)}"${targetAttr}>`;
};
/**
* Serializes every head tag an `output.html` options object asks for, in
* spec order: charset, base, meta tags, title. Used where a page is created
* from scratch (HtmlModulesPlugin's synthetic entry wrapper); authored pages
* instead merge the options against their existing tags during `parse` —
* existing tags win.
* @param {import("../../declarations/WebpackOptions").OutputHtmlOptions} opts html options
* @returns {string} head tags string
*/
const buildHeadTags = (opts) => {
let out = "";
const meta = opts.meta;
if (meta && meta.charset) {
out += `<meta charset="${escapeAttribute(meta.charset)}">`;
}
if (opts.base) out += baseTag(opts.base);
if (meta) {
for (const [name, content] of Object.entries(meta)) {
if (name === "charset") continue;
out += metaTag(name, content);
}
}
if (opts.title) {
out += `<title>${escapeText(opts.title)}</title>`;
}
return out;
};
// CSP/fetch attributes copied verbatim onto a synthesized sibling `<link>` /
// `<script>` (`HtmlEntryDependency`). Fixed output order, independent of
// source order.
const COPYABLE_SIBLING_ATTRS = ["nonce", "crossorigin", "referrerpolicy"];
const CC_QUOTATION = '"'.charCodeAt(0);
const CC_APOSTROPHE = "'".charCodeAt(0);
const CC_SLASH = "/".charCodeAt(0);
/**
* Byte-exact source span of an attribute including the single leading
* whitespace (` name`, ` name=value`, ` name="value"`), mirroring the
* tokenizer's end-of-attribute rule (`valueEnd + 1` past the closing quote).
* @param {import("./syntax").HtmlPath} path walk path (used only for attribute-ref reads)
* @param {string} source HTML source
* @param {import("./syntax").HtmlAttributeRef} attr attribute ref
* @returns {string} the attribute's source slice
*/
const attrSourceSpan = (path, source, attr) => {
const valueStart = path.attributeValueStart(attr);
const valueEnd = path.attributeValueEnd(attr);
const end =
valueStart === -1
? path.attributeNameEnd(attr)
: source.charCodeAt(valueStart - 1) === CC_QUOTATION ||
source.charCodeAt(valueStart - 1) === CC_APOSTROPHE
? valueEnd + 1
: valueEnd;
return source.slice(path.attributeNameStart(attr) - 1, end);
};
/**
* Source span of a tag's `integrity` attribute (offsets relative to
* `elementStart`, leading whitespace and quotes included), so the template can
* drop the content-specific author value without re-parsing. Null when absent.
* @param {import("./syntax").HtmlPath} path walk path (for attribute reads)
* @param {string} source HTML source
* @param {number} elementStart offset of the tag's opening `<`
* @returns {Range | null} the span, or null when there is no `integrity`
*/
const captureIntegrityRange = (path, source, elementStart) => {
const integrityAttr = path.findAttribute("integrity");
if (integrityAttr === 0 || path.attributeNameStart(integrityAttr) < 0) {
return null;
}
let rs = path.attributeNameStart(integrityAttr);
while (rs > 0 && isAsciiWhitespace(source.charCodeAt(rs - 1))) {
rs--;
}
let re;
const ivs = path.attributeValueStart(integrityAttr);
if (ivs === -1) {
re = path.attributeNameEnd(integrityAttr);
} else {
const q = source.charCodeAt(ivs - 1);
const ive = path.attributeValueEnd(integrityAttr);
re = q === CC_QUOTATION || q === CC_APOSTROPHE ? ive + 1 : ive;
}
return [rs - elementStart, re - elementStart];
};
/**
* @param {Map<string, string>} attributes attributes
* @param {string} name name
* @returns {string | undefined} attribute value
*/
const getAttributeValue = (attributes, name) => attributes.get(name);
/** @type {Map<string, Set<string>>} */
const META = new Map([
[
"name",
new Set([
// msapplication-TileImage
"msapplication-tileimage",
"msapplication-square70x70logo",
"msapplication-square150x150logo",
"msapplication-wide310x150logo",
"msapplication-square310x310logo",
"msapplication-config",
// Only the `icon-uri` part is an asset, see `parseMsapplicationTask`
"msapplication-task",
"twitter:image",
"twitter:image:src",
// Twitter player card: URL to the raw video/media stream
"twitter:player:stream",
// Legacy preview-image hint
"thumbnail"
])
],
[
"property",
new Set([
"og:image",
"og:image:url",
"og:image:secure_url",
"og:audio",
"og:audio:secure_url",
"og:video",
"og:video:secure_url",
"vk:image"
])
],
[
"itemprop",
new Set([
"image",
"logo",
"screenshot",
"thumbnailurl",
"contenturl",
"downloadurl",
"duringmedia",
"embedurl",
"installurl",
"layoutimage"
])
]
]);
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when need to parse, otherwise false
*/
const filterLinkItemprop = (attributes) => {
const itemprop = getAttributeValue(attributes, "itemprop");
if (!itemprop) return false;
const allowedAttributes = META.get("itemprop");
if (!allowedAttributes) return false;
return allowedAttributes.has(itemprop.trim().toLowerCase());
};
// `<link rel>` values whose `href`/`imagesrcset` webpack treats as a reference.
const ALLOWED_LINK_RELS = new Set([
"stylesheet",
"icon",
"mask-icon",
"apple-touch-icon",
"apple-touch-icon-precomposed",
"apple-touch-startup-image",
// Routed to the `asset/webmanifest` module type, which parses its icon URLs.
"manifest",
"prefetch",
"preload",
"modulepreload",
// Legacy preview-image hint (`<link rel="image_src" href>`)
"image_src"
]);
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when need to parse, otherwise false
*/
const filterLinkHref = (attributes) => {
const rel = getAttributeValue(attributes, "rel");
if (!rel) return false;
const usedRels = rel.trim().toLowerCase().split(/\s+/);
for (let i = 0; i < usedRels.length; i++) {
if (ALLOWED_LINK_RELS.has(usedRels[i])) return true;
}
return false;
};
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when need to parse, otherwise false
*/
const filterLinkUnion = (attributes) =>
filterLinkHref(attributes) || filterLinkItemprop(attributes);
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when need to parse, otherwise false
*/
const filterMetaContent = (attributes) => {
for (const item of META) {
const [key, allowedNames] = item;
const name = getAttributeValue(attributes, key);
if (!name) continue;
// Check every present attribute, not only the first one
if (allowedNames.has(name.trim().toLowerCase())) return true;
}
return false;
};
/**
* `<param value>` (obsolete `<object>`/`<applet>` child) is only a URL when
* `valuetype="ref"`; otherwise it's an opaque string.
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when `value` is a URL reference
*/
const filterParamRef = (attributes) => {
const valuetype = getAttributeValue(attributes, "valuetype");
if (!valuetype) return false;
return valuetype.trim().toLowerCase() === "ref";
};
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when the script element opts into ES module semantics
*/
const isModuleScript = (attributes) => {
const type = getAttributeValue(attributes, "type");
if (!type) return false;
return type.trim().toLowerCase() === "module";
};
// HTML `<script>` `type` values that the browser treats as executable
// JavaScript. Anything outside this set (e.g. `application/ld+json`,
// `importmap`, `application/wasm`) is a data block — webpack must not
// try to bundle it as a JS entry; it should pass through as an asset URL.
const JS_SCRIPT_TYPES = new Set([
"",
"module",
"text/javascript",
"application/javascript",
"text/ecmascript",
"application/ecmascript"
]);
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when the script element's `type` is executable JS
*/
const isExecutableJsScript = (attributes) => {
const type = getAttributeValue(attributes, "type");
if (type === undefined) return true;
return JS_SCRIPT_TYPES.has(type.trim().toLowerCase());
};
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when the link points at an ES module that should be bundled as an entry chunk
*/
const isLinkModulePreload = (attributes) => {
const rel = getAttributeValue(attributes, "rel");
if (!rel) return false;
return rel.trim().toLowerCase().split(/\s+/).includes("modulepreload");
};
/**
* @param {Map<string, string>} attributes attributes
* @returns {boolean} true when the link is a `<link rel="stylesheet">` that should be bundled as a CSS entry chunk
*/
const isLinkStylesheet = (attributes) => {
const rel = getAttributeValue(attributes, "rel");
if (!rel) return false;
return rel.trim().toLowerCase().split(/\s+/).includes("stylesheet");
};
/** @typedef {"src" | "srcset" | "css-url" | "msapplication-task" | "script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch" | "stylesheet-style" | "stylesheet-style-attribute" | "srcdoc"} SourceType */
/** Entry types: a `type` whose value is loaded as its own compilation entry chunk. */
/** @typedef {"script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch"} EntrySourceType */
/** @typedef {SourceType | ((attrs: Map<string, string>, css: boolean) => SourceType)} SourceTypeOrResolver */
/** @typedef {(attributes: Map<string, string>, value: string) => boolean} SourceFilter */
/** @typedef {{ tag?: string, attribute: string, type: SourceType, filter?: SourceFilter }} SourceEntry */
/** A `type` plus optional gates: `filter` (the decoded attribute map + the decoded value; return false to skip — covers both cross-attribute checks and cheap value checks) and `namespace` (restrict to an element namespace, e.g. SVG). */
/** @typedef {{ type: SourceTypeOrResolver, filter?: SourceFilter, namespace?: number }} SourceItem */
/** A tag→attribute lookup; a `undefined` value marks a built-in source disabled via `sources` (`type: false`). */
/** @typedef {Record<string, SourceItem | undefined>} SourceBucket */
/** @typedef {Record<string, SourceBucket>} SourceTable */
/** A source whose value is the element's text content (a `<style>`/`<script>` body) rather than an attribute. */
/** @typedef {{ type: SourceTypeOrResolver, filter?: (attributes: Map<string, string>) => boolean }} ContentSourceItem */
/**
* Builds a null-prototype dictionary from the given property bags
* (later bags win; `undefined` bags are skipped). A null prototype is
* essential here: the tables are indexed by HTML tag and attribute
* names, so a plain object would let names like `__proto__`,
* `constructor`, or `toString` resolve to inherited values at lookup
* time — producing bogus dependencies and letting `sources: false` be
* bypassed.
* @param {...(Record<string, EXPECTED_ANY> | undefined)} bags property bags
* @returns {EXPECTED_ANY} null-prototype dictionary
*/
const dict = (...bags) => Object.assign(Object.create(null), ...bags);
// Shared `SourceItem` singletons used by the built-in defaults —
// keeping one instance per kind stabilizes V8's hidden classes across
// lookups in the walk. `srcset` is its own type; the walk picks the
// `parseSrcset` parser for it and otherwise treats it like `src`.
/** @type {SourceItem} */
const PLAIN_SRC = { type: "src", filter: undefined };
/** @type {SourceItem} */
const PLAIN_SRCSET = { type: "srcset", filter: undefined };
/**
* `<link href>` is polymorphic: `rel="modulepreload"` → an ESM
* preload entry, `rel="stylesheet"` (with `experiments.css`) → a CSS
* entry, otherwise a plain asset URL.
* @type {SourceItem}
*/
const LINK_HREF = {
type: (attrs, css) => {
if (isLinkModulePreload(attrs)) return "modulepreload";
if (css && isLinkStylesheet(attrs)) return "stylesheet";
// `rel="preload"/"prefetch"` with `as="script"` (or `as="style"` when
// `experiments.css` is on) becomes a bundled, non-executing entry whose
// `href` is rewritten to the built chunk; other `as` targets (image,
// font, fetch, …) stay plain asset URLs.
const rel = getAttributeValue(attrs, "rel");
if (rel) {
const rels = rel.trim().toLowerCase().split(/\s+/);
const kind = rels.includes("preload")
? "preload"
: rels.includes("prefetch")
? "prefetch"
: undefined;
if (kind) {
const as = getAttributeValue(attrs, "as");
const asLower = as ? as.trim().toLowerCase() : "";
if (asLower === "script" || (css && asLower === "style")) return kind;
}
}
return "src";
},
filter: filterLinkUnion
};
/**
* `<script src>`: non-JS types (e.g. `application/ld+json`,
* `importmap`) stay plain asset URLs so the browser keeps seeing them
* as data blocks; `type="module"` opts into the ESM entry chunk;
* everything else in `JS_SCRIPT_TYPES` is a classic script.
* @type {SourceItem}
*/
const SCRIPT_SRC = {
type: (attrs) =>
isExecutableJsScript(attrs)
? isModuleScript(attrs)
? "script-module"
: "script"
: "src",
filter: undefined
};
/**
* `<meta content>`: most referenced names hold a single URL (`src`); the
* `msapplication-task` value is a `;`-delimited list whose `icon-uri` part
* is the only asset, so it selects the `msapplication-task` parser.
* @type {SourceItem}
*/
const META_CONTENT = {
type: (attrs) => {
const name = attrs.get("name");
return name !== undefined &&
name.trim().toLowerCase() === "msapplication-task"
? "msapplication-task"
: "src";
},
filter: filterMetaContent
};
// Built-in lookup table, written directly in its final resolved shape:
// `DEFAULT_SOURCES_BY_TAG[tag][attribute] = item`. No module-load
// loop, no separate array representation — every parser created with
// the default `sources` config just references this table and pays
// zero per-parser work.
/** @type {Record<string, Record<string, SourceItem>>} */
const DEFAULT_SOURCES_BY_TAG = dict({
// Obsolete Java-applet element; `code`/`object` are single class/object URLs.
applet: dict({ code: PLAIN_SRC, object: PLAIN_SRC }),
audio: dict({ src: PLAIN_SRC }),
// Deprecated presentational `background` attribute — an image URL.
body: dict({ background: PLAIN_SRC }),
embed: dict({ src: PLAIN_SRC }),
// `srcdoc` is an entity-encoded HTML document parsed and rewritten as a
// nested module — its handling lives in the generic source loop so the
// `sources` option can re-target it (or other tags/attributes) freely.
iframe: dict({ srcdoc: { type: "srcdoc", filter: undefined } }),
img: dict({ src: PLAIN_SRC, srcset: PLAIN_SRCSET }),
input: dict({ src: PLAIN_SRC }),
link: dict({
href: LINK_HREF,
imagesrcset: { type: "srcset", filter: filterLinkHref }
}),
meta: dict({ content: META_CONTENT }),
// MathML `<mglyph src>` references an image.
mglyph: dict({ src: PLAIN_SRC }),
// `classid` is a single object URI (`codebase`/`archive` are a base/list, skipped).
object: dict({ data: PLAIN_SRC, classid: PLAIN_SRC }),
// Obsolete `<param valuetype="ref" value="url">` child of `<object>`.
param: dict({ value: { type: "src", filter: filterParamRef } }),
// `href`/`xlink:href` reference the source of SVG `<script>` elements
script: dict({
src: SCRIPT_SRC,
href: SCRIPT_SRC,
"xlink:href": SCRIPT_SRC
}),
source: dict({ src: PLAIN_SRC, srcset: PLAIN_SRCSET }),
// Deprecated presentational `background` attribute — an image URL.
table: dict({ background: PLAIN_SRC }),
td: dict({ background: PLAIN_SRC }),
th: dict({ background: PLAIN_SRC }),
track: dict({ src: PLAIN_SRC }),
video: dict({ poster: PLAIN_SRC, src: PLAIN_SRC }),
// SVG. Tag names match the tree builder's adjusted camelCase
// (`feImage`/`textPath`/`linearGradient`/`radialGradient`). `href`/
// `xlink:href` reference another element/resource; fragment-only `#id`
// values are left untouched, so only external `file.svg#id` is rewritten.
// `color-profile`'s `xlink:href` points at an external ICC profile file.
// `cursor`/`font-face-uri` point at an external image/font file; `altGlyph`/
// `glyphRef`/`tref` are legacy element references (removed in SVG 2).
altGlyph: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
"color-profile": dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
cursor: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
feImage: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
filter: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
"font-face-uri": dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
glyphRef: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
image: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
linearGradient: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
mpath: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
pattern: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
radialGradient: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
textPath: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
tref: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC }),
use: dict({ "xlink:href": PLAIN_SRC, href: PLAIN_SRC })
// CSS `url(...)` references in SVG presentation attributes (fill, stroke,
// clip-path, …) are handled separately during the walk (`parseCssUrls`).
});
// SVG presentation attributes (`fill`, `stroke`, …) carry CSS `url(...)`
// FuncIRIs; `parseCssUrls` extracts external refs as assets (internal
// `url(#id)` is left untouched). They apply to every SVG-namespace element, so
// the item is `namespace`-gated rather than placed in a tag bucket. The filter
// is a cheap pre-check skipping the common plain values (`red`, `none`, `#fff`).
/** @type {SourceItem} */
const SVG_CSS_URL = {
type: "css-url",
filter: (attributes, value) => FUNC_IRI_URL_REGEXP.test(value),
namespace: NS_SVG
};
// The global `style=""` attribute (a CSS declaration list), available on every
// tag and even with `sources: false`. Like every `stylesheet-style*` source it
// only emits when `experiments.css` is on (checked where the dependency is
// created); the filter is a cheap pre-check skipping declarations without a
// `url()`/`src()`/… .
/** @type {SourceItem} */
const STYLE_ATTRIBUTE = {
type: "stylesheet-style-attribute",
filter: (attributes, value) => STYLE_ATTR_URL_REGEXP.test(value)
};
// Any-tag sources — applied to every element as a fallback and folded into
// each tag bucket, so the walk resolves one object per element and reads one
// property per attribute. `DEFAULT` (svg presentation + style) is active
// unless `sources: false`; only the always-on style attribute survives `false`.
/** @type {Record<string, SourceItem>} */
const DEFAULT_ANY_SOURCES = dict({ style: STYLE_ATTRIBUTE });
for (const name of [
"fill",
"stroke",
"clip-path",
"mask",
"filter",
"marker",
"marker-start",
"marker-mid",
"marker-end",
"cursor"
]) {
DEFAULT_ANY_SOURCES[name] = SVG_CSS_URL;
}
/** @type {Record<string, SourceItem>} */
const ALWAYS_ANY_SOURCES = dict({ style: STYLE_ATTRIBUTE });
// Reserved key holding the any-tag sources inside the per-tag table — the
// schema forbids an empty `tag`, so it never collides with a real tag. The
// walk falls back to it for elements without their own bucket.
const ANY_TAG = "";
/**
* Folds the any-tag sources into every per-tag bucket (tag-specific entries
* win) and stores them under `ANY_TAG`, so the whole source model is one
* object and the walk's per-attribute lookup is a single property read.
* @param {SourceTable} byTag per-tag sources
* @param {SourceBucket} any any-tag sources
* @returns {SourceTable} folded table
*/
const foldAnySources = (byTag, any) => {
for (const tag of Object.keys(byTag)) {
byTag[tag] = dict(any, byTag[tag]);
}
byTag[ANY_TAG] = any;
return byTag;
};
// Default table (per-tag defaults + any-tag sources folded in) and the
// `sources: false` table (only the always-on `style=""` attribute). Both are
// precomputed so the common cases reference them with zero per-parser work.
/** @type {SourceTable} */
const DEFAULT_SOURCES_FOLDED = foldAnySources(
dict(DEFAULT_SOURCES_BY_TAG),
DEFAULT_ANY_SOURCES
);
/** @type {SourceTable} */
const DISABLED_SOURCES_FOLDED = foldAnySources(dict(), ALWAYS_ANY_SOURCES);
/**
* Inline `<script>` body: classic JS unless `type="module"` opts into ESM.
* @param {Map<string, string>} attrs attributes
* @returns {SourceType} the entry type for the inline script
*/
const scriptContentType = (attrs) =>
isModuleScript(attrs) ? "script-module" : "script";
/**
* Inline `<script>` body: only an executable JS block with no external source
* carries content webpack should bundle.
* @param {Map<string, string>} attrs attributes
* @returns {boolean} true when the inline body should be bundled
*/
const scriptContentFilter = (attrs) =>
!attrs.has("src") &&
!attrs.has("href") &&
!attrs.has("xlink:href") &&
isExecutableJsScript(attrs);
/**
* `<style>` body is a stylesheet only when its `type` is empty or `text/css`.
* @param {Map<string, string>} attrs attributes
* @returns {boolean} true when the body is CSS
*/
const styleContentFilter = (attrs) => {
const type = attrs.get("type");
if (type === undefined) return true;
const t = type.trim().toLowerCase();
return t === "" || t === "text/css";
};
// Element-body sources, independent of the `sources` option (even `sources:
// false`): inline `<script>` bodies are always processed; the `<style>` body
// only emits when `experiments.css` is on (checked where the dependency is
// created, like every `stylesheet-style*` source).
/** @type {Record<string, ContentSourceItem>} */
const CONTENT_SOURCES = dict({
script: { type: scriptContentType, filter: scriptContentFilter },
style: { type: "stylesheet-style", filter: styleContentFilter }
});
class HtmlParser extends Parser {
/**
* Creates an instance of HtmlParser.
* @param {HtmlParserOptions} options parser options (from `module.parser.html`; always passed by the `createParser` hook)
*/
constructor(options) {
super();
this.magicCommentContext = createMagicCommentContext();
// Read by HtmlModulesPlugin's `processResult` hook, which transforms
// the module source before it is stored and parsed.
/** @type {HtmlTemplateFunction | undefined} */
this.template = options.template;
/** @type {import("../../declarations/WebpackOptions").UrlHintRule[] | undefined} */
this._urlHints = options.urlHints;
// `as` selects the parse mode — the HTML analog of the CSS parser's `as`.
// `"document"` (default) parses a full page; any other value is the
// context element whose inner HTML the source is parsed as (a fragment),
// so context-sensitive tags (`<tr>`, `<td>`, `<option>`, …) in a partial
// are kept, not dropped. `"template"` is the neutral generic fragment.
/** @type {string | undefined} */
this.fragmentContext =
options.as === undefined || options.as === "document"
? undefined
: options.as;
// One source model: a per-tag table that also holds the any-tag sources
// under `ANY_TAG`. The common cases reference precomputed tables.
/** @type {SourceTable} */
this.sourcesByTag = DEFAULT_SOURCES_FOLDED;
const sources = options.sources;
if (sources === undefined || sources === true) return;
if (sources === false) {
// Only the always-on `style=""` attribute survives; nothing else is
// extracted (svg presentation, `<script src>`, `<link>` entries, …).
this.sourcesByTag = DISABLED_SOURCES_FOLDED;
return;
}
// User array — `"..."` anywhere opts the per-tag defaults in as the
// base; the built-in any-tag sources (svg presentation + style) are
// always present. A user entry with no `tag` is an any-tag source (e.g.
// `{ attribute: "data-style", type: "stylesheet-style-attribute" }`).
// User entries override regardless of position. `dict()` keeps every
// table null-prototype (see its doc), and per-tag writes rebuild the
// bucket so the aliased default buckets stay intact.
/** @type {SourceTable} */
const byTag = sources.includes("...")
? dict(DEFAULT_SOURCES_BY_TAG)
: dict();
/** @type {SourceBucket} */
const any = dict(DEFAULT_ANY_SOURCES);
for (const entry of sources) {
if (entry === "...") continue;
// `type: false` disables a built-in source: writing `undefined` over the
// attribute makes the walk's `sources[name]` lookup skip it (see below).
/** @type {SourceItem | undefined} */
const item =
entry.type === false
? undefined
: {
type: entry.type,
filter:
typeof entry.filter === "function" ? entry.filter : undefined
};
const attr = entry.attribute.toLowerCase();
if (entry.tag === undefined) {
any[attr] = item;
} else {
const tag = entry.tag.toLowerCase();
byTag[tag] = dict(byTag[tag], { [attr]: item });
// The AST carries adjusted camelCase names for foreign-content
// tags (e.g. `feImage`) — register the entry under both.
const adjusted = SVG_TAG_ADJUST[tag];
if (adjusted !== undefined) {
byTag[adjusted] = dict(byTag[adjusted], { [attr]: item });
}
}
}
/** @type {SourceTable} */
this.sourcesByTag = foldAnySources(byTag, any);
}
/**
* Runs the `template` option over the source and returns the transformed
* html. Called from HtmlModulesPlugin's `processResult`, where the return
* value becomes the module's stored source so the parser (which records
* dependency offsets against it) and the generator (which renders from
* `module.originalSource()`) stay in agreement.
* @param {string | Buffer} source the original source
* @param {NormalModule} module the html module
* @returns {string | Buffer} the transformed source
*/
applyTemplate(source, module) {
if (!this.template) return source;
// `processResult` runs after `_doBuild` has initialized these
// dependency sets, so they are always present here.
const buildInfo = /** @type {BuildInfo} */ (module.buildInfo);
const fileDependencies = /** @type {FileSystemDependencies} */ (
buildInfo.fileDependencies
);
const contextDependencies = /** @type {FileSystemDependencies} */ (
buildInfo.contextDependencies
);
const missingDependencies = /** @type {FileSystemDependencies} */ (
buildInfo.missingDependencies
);
const transformed = this.template(
typeof source === "string" ? source : source.toString("utf8"),
{
module,
resource: module.resource,
addDependency: (dependency) => {
fileDependencies.add(dependency);
},
addContextDependency: (dependency) => {
contextDependencies.add(dependency);
},
addMissingDependency: (dependency) => {
missingDependencies.add(dependency);
},
addBuildDependency: (dependency) => {
if (buildInfo.buildDependencies === undefined) {
buildInfo.buildDependencies = new LazySet();
}
buildInfo.buildDependencies.add(dependency);
},
emitWarning: (warning) =>
module.addWarning(
warning instanceof Error ? warning : new WebpackError(warning)
),
emitError: (error) =>
module.addError(
error instanceof Error ? error : new WebpackError(error)
)
}
);
if (typeof transformed !== "string") {
throw new Error(
"The `template` html parser option must return a string."
);
}
return transformed;
}
/**
* Parses the provided source and updates the parser state.
* @param {string | Buffer | PreparsedAst} source the source to parse
* @param {ParserState} state the parser state
* @returns {ParserState} the parser state
*/
parse(source, state) {
if (Buffer.isBuffer(source)) {
source = source.toString("utf8");
} else if (typeof source === "object") {
throw new Error("webpackAst is unexpected for the HtmlParser");
}
if (source[0] === "\uFEFF") {
source = source.slice(1);
}
const locConverter = new LocConverter(source);
const module = state.module;
const compilation = state.compilation;
const { hashFunction, module: outputModule } = compilation.outputOptions;
const context = compilation.compiler.context;
const css = Boolean(compilation.options.experiments.css);
const urlHints = this._urlHints;
// Stable, per-HTML-module prefix used when generating entry names for
// script src / modulepreload references so they don't collide across
// HTML modules in the same compilation. We hash the module's resource
// path (a plain absolute path) — going through `contextify` against
// the compilation root keeps the hash machine-stable for the same
// project layout. Note: `module.identifier()` returns `html|<path>`
// for HTML modules, which doesn't start with `/`, so contextify would
// leave it absolute. `module.resource` is the bare path.
/** @type {string} */
const resource =
/** @type {EXPECTED_ANY} */ (module).resource || module.identifier();
const moduleHash = createHash(hashFunction || "md4")
.update(context ? contextify(context, resource) : resource)
.digest("hex")
.slice(0, 8);
// `output.html` head injection: facts are collected during the walk and
// become presentational dependencies afterwards — no later asset pass has
// to re-parse the page. `data:` documents are skipped: synthetic entry
// wrappers already carry the tags and iframe srcdoc pages must stay
// untouched.
const htmlOutputOptions =
compilation.options && compilation.options.output
? compilation.options.output.html
: undefined;
const headOptions =
typeof htmlOutputOptions === "object" &&
(htmlOutputOptions.title !== undefined ||
htmlOutputOptions.meta !== undefined ||
htmlOutputOptions.base !== undefined) &&
!resource.startsWith("data:")
? htmlOutputOptions
: undefined;
let doctypeEnd = -1;
let htmlTagEnd = -1;
// insertion offsets just inside the (possibly implicit) head
let headStart = 0;
let headLastEnd = 0;
let headSeen = false;
let inHead = false;
// `<template>` contents are inert
let headTemplateDepth = 0;
// Depth of open `<template>` elements anywhere in the tree. Template
// content is an inert, separate fragment per spec, so a `<base>` inside
// it must NOT set the document base (`headTemplateDepth` only covers
// templates seen while in `<head>`, so it can't guard body templates).
let templateDepth = 0;
let headHasTitle = false;
let headHasBase = false;
let headCharsetEnd = -1;
/** @type {Set<string> | undefined} */
let headMetaNames;
// Script src / modulepreload references are collected per-type
// during the walk; HtmlModulesPlugin later turns them into real
// entries. `script` and `script-module` entries are chained via a
// leader-only dependOn so they share a runtime.
// `<link rel="modulepreload">` entries are kept independent — they
// must preload without running, so they can never become a runtime
// leader that other entries would import.
/**
* @typedef {object} HtmlEntryInfo
* @property {string} request
* @property {string} entryName
* @property {"script" | "script-module" | "modulepreload" | "stylesheet" | "html" | "preload" | "prefetch"} type
* @property {boolean=} css entry emits a CSS chunk (`<link rel="preload" as="style">`), so it needs the CSS filename template
*/
/** @type {HtmlEntryInfo[]} */
const scriptEntries = [];
/** @type {HtmlEntryInfo[]} */
const scriptModuleEntries = [];
/** @type {HtmlEntryInfo[]} */
const modulePreloadEntries = [];
/** @type {HtmlEntryInfo[]} */
const stylesheetEntries = [];
// `type: "html"` links — each referenced `.html` becomes its own page
// entry (`<a href="page.html">`), independent (no dependOn).
/** @type {HtmlEntryInfo[]} */
const htmlLinkEntries = [];
// `<link rel="preload"/"prefetch">` of a bundled resource: each becomes an
// independent, non-executing entry whose `href` is rewritten to the built
// chunk's URL (JS for `as="script"`, CSS for `as="style"`).
/** @type {HtmlEntryInfo[]} */
const preloadEntries = [];
/** @type {HtmlEntryInfo[]} */
const prefetchEntries = [];
// Offset of the first classic blocking script tag; injected stylesheet
// `<link>`s stay ahead of it. `defer`/`async`/module scripts don't
// anchor — they execute after parsing (and after pending stylesheets),
// so CSS may follow their tags (the order Vite emits).
let firstBlockingScriptStart = -1;
let nextEntryIndex = 0;
// `<base href>` resolves the relative URLs that follow it. A relative base
// (`./assets/`) rewrites them into a subdirectory — still bundled; a
// root-relative or absolute base (`/`, `https://cdn/`) points them outside
// the build, so those URLs are left untouched. Resolved in the walk below
// from the first `<base href>` seen (`documentBase` undefined until then).
/** @type {string | undefined} */
let documentBase;
/** @type {string | undefined} */
let baseDir;
let baseIsExternal = false;
// Prepended to the emitted URLs' auto-public-path undo path so the base
// doesn't misdirect them: the browser resolves rewritten (relative)
// output URLs against the base dir, so one `../` per base segment cancels
// it. Undefined for absolute publicPath (no undo path is emitted).
/** @type {string | undefined} */
let baseUrlPrefix;
/**
* Tracks the `webpackIgnore` value from the most recent comment that
* appears before the next tag. Reset whenever a tag is emitted or a
* comment without a `webpackIgnore` value is encountered.
* @type {boolean | undefined}
*/
let pendingWebpackIgnore;
/** @type {boolean | undefined} */
let pendingWebpackInline;
/**
* Tracks the resource-hint values from the most recent `<!-- webpackPrefetch: … -->`
* (etc.) comment so the next asset URL attribute picks them up.
* @type {import("../prefetch/parseResourceHintOptions").ResourceHintOptions | undefined}
*/
let pendingHints;
const magicCommentContext = this.magicCommentContext;
/**
* @param {import("./syntax").HtmlPath} path walk path (used only for attribute-ref reads)
* @param {import("./syntax").HtmlAttributeRef} typeAttr type attribute ref (0 = none)
* @param {number} nameEnd end offset of the tag name
* @param {string} type type of the script
* @param {string} input source string
*/
const reconcileScriptTypeAttr = (path, typeAttr, nameEnd, type, input) => {
const valueStart =
typeAttr !== 0 ? path.attributeValueStart(typeAttr) : -1;
const valueEnd = typeAttr !== 0 ? path.attributeValueEnd(typeAttr) : -1;
if (outputModule && type === "script") {
// Chunk is an ES module; upgrade the tag.
if (typeAttr !== 0 && valueStart !== -1) {
module.addPresentationalDependency(
new ConstDependency("module", [valueStart, valueEnd])
);
} else {
module.addPresentationalDependency(
new ConstDependency(' type="module"', nameEnd)
);
}
} else if (!outputModule && type === "script-module" && typeAttr !== 0) {
// Chunk is a classic IIFE; drop `type="module"` so the
// browser doesn't load it under module semantics.
let attrEnd;
if (valueStart === -1) {
attrEnd = path.attributeNameEnd(typeAttr);
} else if (input[valueEnd] === '"' || input[valueEnd] === "'") {
attrEnd = valueEnd + 1;
} else {
attrEnd = valueEnd;
}
let attrStart = path.attributeNameStart(typeAttr);
if (
attrStart > 0 &&
isAsciiWhitespace(input.charCodeAt(attrStart - 1))
) {
attrStart -= 1;
}
module.addPresentationalDependency(
new ConstDependency("", [attrStart, attrEnd])
);
}
};
/**
* @param {string} mime mime type
* @param {string} text inline text
* @returns {string} a `data:` request for the text
*/
const dataUri = (mime, text) =>
`data:${mime};base64,${Buffer.from(text, "utf8").toString("base64")}`;
/**
* @param {ConstDependency | HtmlSourceDependency | HtmlEntryDependency | HtmlInlineStyleDependency | HtmlInlineScriptDependency | HtmlInlineHtmlDependency} dep dependency
* @param {number} start raw start offset
* @param {number} end raw end offset
*/
const setLoc = (dep, start, end) => {
const s = locConverter.get(start);
const e = locConverter.get(end);
dep.setLoc(s.line, s.column, e.line, e.column);
};
/**
* Classifies the first `<base href>` seen in the walk, setting
* `documentBase`/`baseDir`/`baseIsExternal`/`baseUrlPrefix` for the URLs
* that follow it.
* @param {import("./syntax").HtmlPath} path walk path (for attribute reads)
* @param {import("./syntax").HtmlAttributeRef} hrefAttr the base's href attribute
*/
const resolveDocumentBase = (path, hrefAttr) => {
documentBase = decodeEntities(path.attributeValue(hrefAttr), true).trim();
if (!documentBase) return;
if (
ABSOLUTE_URL_SCHEME_REGEXP.test(documentBase) ||
documentBase.charCodeAt(0) === CC_SLASH
) {
baseIsExternal = true;
return;
}
// Normalize the base path into its descending directory segments,
// resolving `.`/`..` like the URL spec. The final segment is the
// referenced file (dropped) unless the href ends with `/` or a
// dot-segment; `up` counts `..`s that climb above the document dir.
const parts = documentBase.split("/");
/** @type {string[]} */
const dirs = [];
let up = 0;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === "" || part === ".") continue;
if (part === "..") {
if (dirs.length > 0) dirs.pop();
else up++;
continue;
}
// A trailing plain segment (no following `/`) is the file, not a dir.
if (i === parts.length - 1) continue;
dirs.push(part);
}
baseDir = `${"../".repeat(up)}${dirs.length > 0 ? `${dirs.join("/")}/` : ""}`;
// A base above the document dir can't be cancelled with `../` (the
// document's own dir name is unknown) — leave the output URLs
// un-prefixed; they still resolve under an absolute publicPath.
if (up === 0 && dirs.length > 0) {
baseUrlPrefix = "../".repeat(dirs.length);
}
};
// Lazy decoded-attribute map for the current element — parse-scoped
// to avoid a closure and memo slot per element.
/** @type {import("./syntax").HtmlPath | undefined} */
let attrMapPath;
let attrMapCount = 0;
/** @type {Map<string, string> | undefined} */
let currentAttributesMap;
const getAttributesMap = () => {
if (currentAttributesMap) return currentAttributesMap;
currentAttributesMap = new Map();
const path = /** @type {import("./syntax").HtmlPath} */ (attrMapPath);
for (let i = 0; i < attrMapCount; i++) {
const attr = path.attributeAt(i);
// Decoded values — filters and type resolvers compare what
// the browser sees (e.g. `rel="&#105;con"` means `icon`)
currentAttributesMap.set(
path.attributeName(attr),
decodeEntities(path.attributeValue(attr), true)
);
}
return currentAttributesMap;
};
// TODO implement full HTML parser (WASM)
// The walker descends into children (and `<template>` content) itself;
// the Element `exit` clears a pending `webpackIgnore` once an element's
// children are done (the old `walkChildren` behaviour).
new SourceProcessor()
.use({
[NodeType.Comment]: (path) => {
const start = path.start();
const end = path.end();
// comments count toward the head-end insertion anchor
if (inHead && headTemplateDepth === 0 && end > headLastEnd) {
headLastEnd = end;
}
// Only proper `<!-- ... -->` comments carry magic comments.
if (
end - start < 7 ||
source.charCodeAt(start) !== 0x3c ||
source.charCodeAt(start + 1) !== 0x21 ||
source.charCodeAt(start + 2) !== 0x2d ||
source.charCodeAt(start + 3) !== 0x2d ||
source.charCodeAt(end - 1) !== 0x3e ||
source.charCodeAt(end - 2) !== 0x2d ||
source.charCodeAt(end - 3) !== 0x2d
) {
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
const value = path.data();
if (!webpackCommentRegExp.test(value)) {
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
/** @type {Record<string, EXPECTED_ANY>} */
let options;
try {
options = parseMagicComment(value, magicCommentContext);
} catch (err) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
module.addWarning(
new CommentCompilationWarning(
`Compilation error while processing magic comment(-s): /*${value}*/: ${
/** @type {Error} */ (err).message
}`,
{
start: { line: sl, column: sc },
end: { line: el, column: ec }
}
)
);
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
if (
options.webpackIgnore === undefined &&
options.webpackInline === undefined &&
options.webpackPrefetch === undefined &&
options.webpackPreload === undefined &&
options.webpackFetchPriority === undefined
) {
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
if (
options.webpackIgnore !== undefined &&
typeof options.webpackIgnore !== "boolean"
) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
module.addWarning(
new UnsupportedFeatureWarning(
`\`webpackIgnore\` expected a boolean, but received: ${options.webpackIgnore}.`,
{
start: { line: sl, column: sc },
end: { line: el, column: ec }
}
)
);
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
if (
options.webpackInline !== undefined &&
typeof options.webpackInline !== "boolean"
) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
module.addWarning(
new UnsupportedFeatureWarning(
`\`webpackInline\` expected a boolean, but received: ${options.webpackInline}.`,
{
start: { line: sl, column: sc },
end: { line: el, column: ec }
}
)
);
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
return;
}
pendingWebpackIgnore = options.webpackIgnore;
pendingWebpackInline = options.webpackInline;
if (
options.webpackPrefetch !== undefined ||
options.webpackPreload !== undefined ||
options.webpackFetchPriority !== undefined
) {
const s = locConverter.get(start);
const e = locConverter.get(end);
const parsedHints = parseResourceHintOptions(options, module, {
start: { line: s.line, column: s.column },
end: { line: e.line, column: e.column }
});
pendingHints =
parsedHints.prefetch !== undefined ||
parsedHints.preload !== undefined ||
parsedHints.fetchPriority !== undefined
? parsedHints
: undefined;
} else {
pendingHints = undefined;
}
},
[NodeType.Doctype]: (path) => {
// only visited when head injection is active (see `skip` below)
if (doctypeEnd === -1) doctypeEnd = path.end();
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
},
[NodeType.Element]: {
// Children (and `<template>` content) are walked by the
// processor after `enter`; `exit` clears a pending
// `webpackIgnore` once they're done.
enter: (path) => {
const ignore = pendingWebpackIgnore === true;
const forceInline = pendingWebpackInline === true;
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
// `<head>` anchors are collected for every page — they place
// `inject: "head"` siblings and resource hints; the
// title/meta/base facts only when configured. Structural, so it
// runs even for `webpackIgnore`d elements.
{
const name = path.tagName();
if (name === "template" && path.namespace() === NS_HTML) {
templateDepth++;
}
if (headTemplateDepth > 0) {
if (name === "template" && path.namespace() === NS_HTML) {
headTemplateDepth++;
}
} else if (inHead) {
if (headOptions !== undefined && path.namespace() === NS_HTML) {
switch (name) {
case "title":
headHasTitle = true;
break;
case "base":
headHasBase = true;
break;
case "meta": {
if (path.findAttribute("charset") !== 0) {
headCharsetEnd = path.tagEnd();
} else {
const httpEquiv = path.findAttribute("http-equiv");
const contentAttr =
httpEquiv !== 0 &&
decodeEntities(
path.attributeValue(httpEquiv),
true
).toLowerCase() === "content-type"
? path.findAttribute("content")
: 0;
if (
contentAttr !== 0 &&
CHARSET_DECLARATION_REGEXP.test(
decodeEntities(
path.attributeValue(contentAttr),
true
)
)
) {
headCharsetEnd = path.tagEnd();
}
}
const nameAttr = path.findAttribute("name");
const propertyAttr = path.findAttribute("property");
if (nameAttr !== 0 || propertyAttr !== 0) {
if (headMetaNames === undefined) {
headMetaNames = new Set();
}
if (nameAttr !== 0) {
headMetaNames.add(
decodeEntities(
path.attributeValue(nameAttr),
true
).toLowerCase()
);
}
if (propertyAttr !== 0) {
headMetaNames.add(
decodeEntities(
path.attributeValue(propertyAttr),
true
).toLowerCase()
);
}
}
break;
}
case "template":
headTemplateDepth++;
break;
}
} else if (
name === "template" &&
path.namespace() === NS_HTML
) {
headTemplateDepth++;
}
if (path.end() > headLastEnd) headLastEnd = path.end();
} else if (!headSeen && path.namespace() === NS_HTML) {
if (name === "html") {
htmlTagEnd = path.tagEnd();
} else if (name === "head") {
headSeen = true;
inHead = true;
// explicit head has real offsets; an implicit one (head
// tags are optional in HTML5) anchors after `<html>`,
// the doctype, or the document start
const tagEnd = path.tagEnd();
headStart =
tagEnd > 0
? tagEnd
: htmlTagEnd > 0
? htmlTagEnd
: doctypeEnd > 0
? doctypeEnd
: 0;
headLastEnd = headStart;
}
}
}
if (ignore) {
return;
}
const elementName = path.tagName();
const attributeCount = path.attributeCount();
const elementStart = path.start();
const tagEnd = path.tagEnd();
const nameEnd = path.nameEnd();
// The first `<base href>` freezes the base for later URLs — but
// only one in the real document tree, not inside an inert
// `<template>` (whose contents are a separate fragment).
if (
documentBase === undefined &&
templateDepth === 0 &&
elementName === "base" &&
path.namespace() === NS_HTML
) {
const hrefAttr = path.findAttribute("href");
if (hrefAttr !== 0) resolveDocumentBase(path, hrefAttr);
}
// Rebind the parse-scoped lazy attribute map to this element.
attrMapPath = path;
attrMapCount = attributeCount;
currentAttributesMap = undefined;
// Each matched attribute is a source; the element body (inline
// `<style>`/`<script>`) is one more — content rather than an attribute.
// All are dispatched by the same `switch (type)`.
const sources =
this.sourcesByTag[elementName] || this.sourcesByTag[ANY_TAG];
const bodyItem = CONTENT_SOURCES[elementName];
// Iterate the attributes, then one extra step for the element
// body (`i === attributeCount`); the attribute ref is only read on the
// attribute steps, so it stays in bounds.
for (let i = 0; i < attributeCount + 1; i++) {
const content = i === attributeCount;
/** @type {SourceType} */
let type;
/** @type {string} */
let value;
/** @type {number} */
let start;
/** @type {number} */
let end;
/** @type {import("./syntax").HtmlAttributeRef} */
let attr = 0;
if (content) {
if (!bodyItem) continue;
if (bodyItem.filter && !bodyItem.filter(getAttributesMap())) {
continue;
}
// `skip.text` drops the body `Text` node; the raw content
// span is [`tagEnd`, `contentEnd`] on the element.
const bodyStart = tagEnd;
const bodyEnd = path.contentEnd();
if (bodyEnd <= bodyStart) continue;
value = source.slice(bodyStart, bodyEnd);
if (value.trim() === "") continue;
start = bodyStart;
end = bodyEnd;
type =
typeof bodyItem.type === "function"
? bodyItem.type(getAttributesMap(), css)
: bodyItem.type;
} else {
attr = path.attributeAt(i);
const item = sources[path.attributeName(attr)];
if (!item) continue;
if (
item.namespace !== undefined &&
path.namespace() !== item.namespace
) {
continue;
}
// Adoption-agency clones carry no offsets; skip blank values.
if (path.attributeValueStart(attr) === -1) {
continue;
}
value = path.attributeValue(attr);
if (!value || !/\S/.test(value)) continue;
const filter = item.filter;
if (filter) {
// The filter also sees the decoded value (the browser tokenizes it
// decoded, e.g. `&#117;rl(`), so a value-only check needn't re-read it.
const decoded = value.includes("&")
? decodeEntities(value, true)
: value;
if (!filter(getAttributesMap(), decoded)) continue;
}
start = path.attributeValueStart(attr);
end = path.attributeValueEnd(attr);
type =
typeof item.type === "function"
? item.type(getAttributesMap(), css)
: item.type;
}
// A resolved `type` selects the parse algorithm and the dependency
// kind — the only dispatch in the parser.
switch (type) {
// Inline CSS — a `<style>` body (raw, a full stylesheet) or an
// attribute value (decoded, re-escaped on write-back; `*-attribute`
// is a declaration list). Needs `experiments.css`.
case "stylesheet-style":
case "stylesheet-style-attribute": {
if (!css) break;
const cssText = content ? value : decodeEntities(value, true);
if (cssText.trim() === "") break;
const dep = new HtmlInlineStyleDependency(
dataUri("text/css", cssText),
[start, end],
content ? false : type === "stylesheet-style-attribute",
!content
);
setLoc(dep, start, end);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
break;
}
// `<iframe srcdoc>`: an entity-encoded HTML document. Feed the
// decoded markup back through the HTML pipeline as a nested
// `data:text/html` module. Per spec the value is a full document
// whose URL is `about:srcdoc`, so its base URL is inherited from
// this document — asset URLs resolve against this file's context.
case "srcdoc": {
const htmlText = decodeEntities(value, true);
// Only spin up a nested module when there is markup that can
// reference an asset; text/formatting-only markup is identical
// after rewriting (see `SRCDOC_ASSET_REGEXP`).
if (
!htmlText.includes("<") ||
!SRCDOC_ASSET_REGEXP.test(htmlText)
) {
break;
}
const dep = new HtmlInlineHtmlDependency(
dataUri("text/html", htmlText),
[start, end]
);
setLoc(dep, start, end);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
break;
}
// Inline `<script>` body — bundled as its own entry chunk.
case "script":
case "script-module":
if (content) {
const scriptType =
/** @type {"script" | "script-module"} */ (type);
const request = dataUri("text/javascript", value);
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
const dep = new HtmlInlineScriptDependency(
request,
nameEnd,
[start, end],
entryName,
scriptType === "script-module" ? "esm" : "commonjs"
);
setLoc(dep, start, end);
module.addPresentationalDependency(dep);
reconcileScriptTypeAttr(
path,
path.findAttribute("type"),
nameEnd,
scriptType,
source
);
(scriptType === "script"
? scriptEntries
: scriptModuleEntries
).push({
request,
entryName,
type: scriptType
});
break;
}
// falls through — external `<script src>` joins the URL-bearing types
case "src":
case "srcset":
case "css-url":
case "msapplication-task":
case "modulepreload":
case "stylesheet":
case "html":
case "preload":
case "prefetch": {
// Parse the value into one or more URLs (decoding character
// references, mapping spans back to raw offsets), then emit a plain
// asset reference or an entry chunk per URL.
const parse =
type === "srcset"
? parseSrcset
: type === "css-url"
? parseCssUrls
: type === "msapplication-task"
? parseMsapplicationTask
: parseSrc;
let text = value;
/** @type {number[] | undefined} */
let map;
if (value.includes("&")) {
({ text, map } = decodeEntities(value, true, true));
if (!/\S/.test(text)) break;
}
/** @type {ParsedSource[] | undefined} */
let parsed;
try {
parsed = parse(text);
} catch (err) {
const ds = locConverter.get(start);
const de = locConverter.get(end);
module.addError(
new ModuleDependencyError(
module,
new WebpackError(
`Bad value for attribute "${path.attributeName(
attr
)}" on element "${elementName}": ${
/** @type {Error} */ (err).message
}`
),
{
start: { line: ds.line, column: ds.column },
end: { line: de.line, column: de.column }
}
)
);
break;
}
if (!parsed) break;
for (const [url, us, ue] of parsed) {
// Internal `url(#id)` / fragment-only refs aren't assets.
if (!url || url.startsWith("#")) continue;
// Resolve the request against `<base href>`. Only relative
// URLs are affected (scheme / `//` / `/` URLs ignore the
// base); an external base drops them from the build.
let request = url;
if (
documentBase &&
url.charCodeAt(0) !== CC_SLASH &&
!ABSOLUTE_URL_SCHEME_REGEXP.test(url)
) {
if (baseIsExternal) continue;
request = baseDir + url;
}
const s = start + (map ? map[us] : us);
const e = start + (map ? map[ue] : ue);
// `html` links the URL as its own emitted page: the same
// `HtmlEntryDependency` as `<script src>` / `<link>`, but the `html`
// kind rewrites to the page's filename and emits no sibling tags.
// Recorded as an independent entry (no `dependOn`), like an entry point.
if (type === "html") {
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
const dep = new HtmlEntryDependency(
request,
[s, e],
entryName,
undefined,
"html"
);
setLoc(dep, s, e);
module.addPresentationalDependency(dep);
htmlLinkEntries.push({ request, entryName, type });
continue;
}
// `rel="preload"/"prefetch"` of a bundled resource: an
// independent, non-executing entry whose `href` is rewritten to
// the built chunk's URL (CSS for `as="style"`, JS otherwise). No
// sibling tags — a resource hint references exactly one URL.
if (type === "preload" || type === "prefetch") {
const as = getAttributeValue(getAttributesMap(), "as");
const asStyle = as
? as.trim().toLowerCase() === "style"
: false;
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
// Tag metadata so the template can mirror `crossorigin`/
// `integrity` onto a native `preload` `<link>` (integrity-
// eligible per the SRI spec). `prefetch` gets none — the spec
// doesn't list it — so its `integrity` is left uncaptured.
const isNativeLink = elementName === "link";
const dep = new HtmlEntryDependency(
request,
[s, e],
entryName,
asStyle ? "css-import" : undefined,
type,
elementStart,
tagEnd,
isNativeLink,
"",
nameEnd,
isNativeLink && path.findAttribute("crossorigin") !== 0,
-1,
type === "preload" && isNativeLink
? captureIntegrityRange(path, source, elementStart)
: null,
null
);
setLoc(dep, s, e);
module.addPresentationalDependency(dep);
(type === "preload"
? preloadEntries
: prefetchEntries
).push({
request,
entryName,
type,
css: asStyle
});
continue;
}
// `src`/`srcset`/`css-url`/`msapplication-task` are plain assets;
// the rest are narrowed to entry types here and share one
// `HtmlEntryDependency`, recorded for HtmlModulesPlugin.
if (
type === "script" ||
type === "script-module" ||
type === "modulepreload" ||
type === "stylesheet"
) {
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
const willBeModuleScript =
type === "script-module" ||
(outputModule && type === "script");
/** @type {EntrySourceType} */
const elementKind =
type === "modulepreload"
? "modulepreload"
: type === "stylesheet"
? "stylesheet"
: willBeModuleScript
? "script-module"
: "script";
const entryCategory =
type === "stylesheet"
? "css-import"
: type === "script-module" || type === "modulepreload"
? "esm"
: undefined;
// Native = the loading element is the tag the template clones
// verbatim (`<script>` / `<link>`); a custom element is not.
const nativeTag =
elementKind === "stylesheet" ||
elementKind === "modulepreload"
? "link"
: "script";
// CSP/fetch attributes the template copies onto siblings.
let copyableAttrsText = "";
let hasOwnCrossOrigin = false;
if (elementStart >= 0) {
for (const copyableName of COPYABLE_SIBLING_ATTRS) {
const copyableAttr = path.findAttribute(copyableName);
if (copyableAttr !== 0) {
copyableAttrsText += attrSourceSpan(
path,
source,
copyableAttr
);
if (copyableName === "crossorigin") {
hasOwnCrossOrigin = true;
}
}
}
}
// Blocking-ness of the tag as *emitted*:
// `reconcileScriptTypeAttr` upgrades every script entry
// to `type="module"` under `output.module` and strips an
// authored `type="module"` otherwise — so only non-module
// output without `defer`/`async` blocks. Non-native
// elements anchor conservatively (their synthesized
// siblings may be plain blocking `<script>`s).
if (
firstBlockingScriptStart === -1 &&
(type === "script" || type === "script-module") &&
!outputModule &&
(elementName !== nativeTag ||
(path.findAttribute("defer") === 0 &&
path.findAttribute("async") === 0))
) {
firstBlockingScriptStart = elementStart;
}
// Sibling-clone attribute edits, captured from the parsed
// attributes (offsets relative to the tag) so the template
// needn't re-parse the tag text: `integrity` is dropped
// (content-specific) and `script-module` clones are forced
// to `type="module"` (its value range, else inserted).
const integrityRange =
elementStart >= 0 && elementName === nativeTag
? captureIntegrityRange(path, source, elementStart)
: null;
/** @type {Range | null} */
let typeValueRange = null;
if (
elementStart >= 0 &&
elementName === nativeTag &&
elementKind === "script-module"
) {
const typeAttr = path.findAttribute("type");
if (
typeAttr !== 0 &&
path.attributeValueStart(typeAttr) >= 0
) {
typeValueRange = [
path.attributeValueStart(typeAttr) - elementStart,
path.attributeValueEnd(typeAttr) - elementStart
];
}
}
const dep = new HtmlEntryDependency(
request,
[s, e],
entryName,
entryCategory,
elementKind,
elementStart,
tagEnd,
elementName === nativeTag,
copyableAttrsText,
nameEnd,
hasOwnCrossOrigin,
firstBlockingScriptStart,
integrityRange,
typeValueRange,
forceInline,
headSeen && headStart > 0 ? headStart : -1,
headSeen && headLastEnd > 0 ? headLastEnd : -1
);
setLoc(dep, s, e);
module.addPresentationalDependency(dep);
if (
elementName === "script" &&
(type === "script" || type === "script-module")
) {
reconcileScriptTypeAttr(
path,
path.findAttribute("type"),
nameEnd,
type,
source
);
}
(type === "script"
? scriptEntries
: type === "script-module"
? scriptModuleEntries
: type === "stylesheet"
? stylesheetEntries
: modulePreloadEntries
).push({ request, entryName, type });
} else {
const dep = new HtmlSourceDependency(request, [s, e]);
ResourceHintPlugin.applyDefaults(
dep,
ResourceHintPlugin.matchUrlHints(urlHints, request)
);
// `<!-- webpackPrefetch: true -->` (etc.) immediately
// before this tag wins over the project-wide default.
if (pendingHints) {
ResourceHintPlugin.applyParsedHints(dep, pendingHints);
pendingHints = undefined;
}
setLoc(dep, s, e);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
}
}
break;
}
}
}
},
exit: (path) => {
if (path.namespace() === NS_HTML) {
const name = path.tagName();
if (name === "template") {
if (templateDepth > 0) templateDepth--;
if (headTemplateDepth > 0) headTemplateDepth--;
} else if (name === "head") {
inHead = false;
}
}
pendingWebpackIgnore = undefined;
pendingWebpackInline = undefined;
pendingHints = undefined;
}
}
})
// Skip AST output this walk never reads: `text` (script/style bodies are
// read by offset via `contentEnd`) and `doctype` — unless head injection
// needs the doctype as an insertion anchor; keep `comments` for
// magic comments (`webpackIgnore`).
.process(source, {
fragmentContext: this.fragmentContext,
skip: { text: true, doctype: headOptions === undefined }
});
// `output.html` head injection: existing tags win, injected values are
// escaped, insertions at the same offset are merged to keep their order.
if (headOptions !== undefined && headSeen) {
const { title, meta, base } = headOptions;
let startTags = "";
if (meta && meta.charset && headCharsetEnd === -1) {
startTags += `<meta charset="${escapeAttribute(meta.charset)}">`;
}
// <base> must follow the charset declaration per spec
let baseAfterCharset = "";
if (base && !headHasBase) {
if (headCharsetEnd !== -1) baseAfterCharset = baseTag(base);
else startTags += baseTag(base);
}
let endTags = "";
if (meta) {
for (const [name, value] of Object.entries(meta)) {
if (
name === "charset" ||
(headMetaNames !== undefined &&
headMetaNames.has(name.toLowerCase()))
) {
continue;
}
endTags += metaTag(name, value);
}
}
if (title && !headHasTitle) {
endTags += `<title>${escapeText(title)}</title>`;
}
/** @type {[number, string][]} */
const inserts = [];
if (startTags) inserts.push([headStart, startTags]);
if (baseAfterCharset) inserts.push([headCharsetEnd, baseAfterCharset]);
if (endTags) inserts.push([headLastEnd, endTags]);
inserts.sort((a, b) => a[0] - b[0]);
for (let i = 0; i < inserts.length; i++) {
const at = inserts[i][0];
let tags = inserts[i][1];
while (i + 1 < inserts.length && inserts[i + 1][0] === at) {
tags += inserts[++i][1];
}
module.addPresentationalDependency(new ConstDependency(tags, at));
}
}
const buildInfo = /** @type {HtmlModuleBuildInfo} */ (module.buildInfo);
buildInfo.strict = true;
if (baseUrlPrefix !== undefined) buildInfo.baseUrlPrefix = baseUrlPrefix;
// Hand off the collected entries to HtmlModulesPlugin; it creates the
// real compilation entries during the finishMake hook. The `script`
// and `script-module` groups are chained via a leader-only dependOn
// so they share a runtime; `modulepreload` and `stylesheet` entries
// are emitted as independent entries since `<link rel=modulepreload>`
// must preload without running and stylesheets have no runtime. `html`
// links are independent page entries too.
if (
scriptEntries.length > 0 ||
scriptModuleEntries.length > 0 ||
modulePreloadEntries.length > 0 ||
stylesheetEntries.length > 0 ||
htmlLinkEntries.length > 0 ||
preloadEntries.length > 0 ||
prefetchEntries.length > 0
) {
buildInfo.htmlEntries = {
script: scriptEntries,
"script-module": scriptModuleEntries,
modulepreload: modulePreloadEntries,
stylesheet: stylesheetEntries,
html: htmlLinkEntries,
preload: preloadEntries,
prefetch: prefetchEntries
};
}
const buildMeta = /** @type {BuildMeta} */ (state.module.buildMeta);
buildMeta.exportsType = "default";
state.module.addDependency(new StaticExportsDependency(["default"], true));
return state;
}
}
HtmlParser.buildHeadTags = buildHeadTags;
module.exports = HtmlParser;