/
githubmirror
/
webpack
Обзор
Документация
Войти
/
githubmirror
/
webpack
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
v5.108.2
lib/html/HtmlParser.js
1 498 строк
53 KB
Alexander Akait
refactor: annotate constructor fields with @type to improve type coverage (#21265)
24 июн 2026, 13:36
Не верифицирован
24 июн 2026, 13:36
e8f9334
Код
Авторство
О чём код?
/* MIT License http://www.opensource.org/licenses/mit-license.php */ "use strict"; const Parser = require("../Parser"); const { TT_EOF, TT_FUNCTION, TT_STRING, TT_URL, TT_WHITESPACE, TokenStream, equalsLowerCase } = require("../css/syntax"); 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 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_SVG, NodeType, SVG_TAG_ADJUST, SourceProcessor, buildHtmlAst, decodeHtmlEntities, decodeHtmlEntitiesWithMap, findAttr, 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 */ const HORIZONTAL_TAB = "\u0009".charCodeAt(0); const NEWLINE = "\u000A".charCodeAt(0); const FORM_FEED = "\u000C".charCodeAt(0); const CARRIAGE_RETURN = "\u000D".charCodeAt(0); const SPACE = "\u0020".charCodeAt(0); /** * @param {number} char char * @returns {boolean} true when ASCII whitespace, otherwise false */ function isASCIIWhitespace(char) { return ( // Horizontal tab char === HORIZONTAL_TAB || // New line char === NEWLINE || // Form feed char === FORM_FEED || // Carriage return char === CARRIAGE_RETURN || // Space char === SPACE ); } /** @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; // 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); /** * 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 {string} source HTML source * @param {import("./syntax").HtmlAttribute} attr attribute * @returns {string} the attribute's source slice */ const attrSourceSpan = (source, attr) => { const end = attr.valueStart === -1 ? attr.nameEnd : source.charCodeAt(attr.valueStart - 1) === CC_QUOTATION || source.charCodeAt(attr.valueStart - 1) === CC_APOSTROPHE ? attr.valueEnd + 1 : attr.valueEnd; return source.slice(attr.nameStart - 1, end); }; // eslint-disable-next-line no-control-regex const IGNORE_CHARS_REGEXP = /[\u0000-\u001F\u007F-\u009F\u00A0]/g; /** * @param {string} input input * @returns {ParsedSource[]} parsed src */ const parseSrc = (input) => { const len = input.length; if (len === 0) throw new Error("Must be non-empty"); let start = 0; let end = len; while (start < end) { const code = input.charCodeAt(start); if (code > 32 && code !== 160) break; start++; } if (start === end) throw new Error("Must be non-empty"); while (end > start) { const code = input.charCodeAt(end - 1); if (code > 32 && code !== 160) break; end--; } let value = input.slice(start, end); if (IGNORE_CHARS_REGEXP.test(value)) { value = value.replace(IGNORE_CHARS_REGEXP, ""); if (value.length === 0) throw new Error("Must be non-empty"); } return [[value, start, end]]; }; /** * Extracts the `icon-uri` value of an `msapplication-task` meta content * (`name=…;action-uri=…;icon-uri=…`) — the other parts are page URLs, not assets. * @param {string} input input * @returns {ParsedSource[]} parsed icon-uri */ const parseMsapplicationTask = (input) => { const len = input.length; let pos = 0; while (pos < len) { let sep = input.indexOf(";", pos); if (sep === -1) sep = len; const eq = input.indexOf("=", pos); if (eq !== -1 && eq < sep) { const key = input.slice(pos, eq).trim().toLowerCase(); if (key === "icon-uri") { let start = eq + 1; let end = sep; while (start < end && isASCIIWhitespace(input.charCodeAt(start))) { start++; } while (end > start && isASCIIWhitespace(input.charCodeAt(end - 1))) { end--; } if (start === end) return []; return [[input.slice(start, end), start, end]]; } } pos = sep + 1; } return []; }; /** * Extracts `url(...)` references from a CSS value (an SVG presentation * attribute). Reuses webpack's CSS lexer so quoting/escaping match the CSS * spec; returns the `parseSrc` shape so the shared emit path maps and rewrites * the spans. Unquoted `url(path)` rewrites the content span; quoted * `url("path")` rewrites the inner string span (quotes preserved). * @param {string} input attribute value (a CSS component-value list) * @returns {ParsedSource[]} url references */ const parseCssUrls = (input) => { const ts = new TokenStream(input); /** @type {ParsedSource[]} */ const result = []; for (;;) { const t = ts.consume(); if (t.type === TT_EOF) break; if (t.type === TT_URL) { if (t.contentEnd > t.contentStart) { result.push([ input.slice(t.contentStart, t.contentEnd), t.contentStart, t.contentEnd ]); } } else if ( t.type === TT_FUNCTION && equalsLowerCase(input.slice(t.start, t.end - 1), "url") ) { let s = ts.consume(); while (s.type === TT_WHITESPACE) s = ts.consume(); if (s.type === TT_STRING) { const quote = input.charCodeAt(s.start); const innerStart = s.start + 1; // Drop the closing quote, unless the string is unterminated at EOF. const innerEnd = input.charCodeAt(s.end - 1) === quote ? s.end - 1 : s.end; if (innerEnd > innerStart) { result.push([ input.slice(innerStart, innerEnd), innerStart, innerEnd ]); } } } } return result; }; /** * @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", // 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", // TODO the manifest file is emitted as an asset, but its JSON // `icons`/`screenshots`/`shortcuts[].icons` URLs aren't parsed — needs a // dedicated webmanifest module type (parser + generator), not a table entry. "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" | "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"} 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 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"; 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. "color-profile": 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 }), 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 }), 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 {Record<string, Record<string, SourceItem>>} byTag per-tag sources * @param {Record<string, SourceItem>} any any-tag sources * @returns {Record<string, Record<string, SourceItem>>} 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 {Record<string, Record<string, SourceItem>>} */ const DEFAULT_SOURCES_FOLDED = foldAnySources( dict(DEFAULT_SOURCES_BY_TAG), DEFAULT_ANY_SOURCES ); /** @type {Record<string, Record<string, SourceItem>>} */ 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; // One source model: a per-tag table that also holds the any-tag sources // under `ANY_TAG`. The common cases reference precomputed tables. /** @type {Record<string, Record<string, SourceItem>>} */ 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 {Record<string, Record<string, SourceItem>>} */ const byTag = sources.includes("...") ? dict(DEFAULT_SOURCES_BY_TAG) : dict(); /** @type {Record<string, SourceItem>} */ const any = dict(DEFAULT_ANY_SOURCES); for (const entry of sources) { if (entry === "...") continue; /** @type {SourceItem} */ const item = { 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 {Record<string, Record<string, SourceItem>>} */ 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); // 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); // 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} EntryScriptInfo * @property {string} request * @property {string} entryName * @property {"script" | "script-module" | "modulepreload" | "stylesheet"} type */ /** @type {EntryScriptInfo[]} */ const scriptEntries = []; /** @type {EntryScriptInfo[]} */ const scriptModuleEntries = []; /** @type {EntryScriptInfo[]} */ const modulePreloadEntries = []; /** @type {EntryScriptInfo[]} */ const stylesheetEntries = []; // Offset of the first script tag; anchors injected stylesheet `<link>`s // before it so a later entry's CSS still loads ahead of every script. let firstScriptStart = -1; let nextEntryIndex = 0; /** * 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; const magicCommentContext = this.magicCommentContext; /** * @param {import("./syntax").HtmlAttribute | undefined} typeAttr type attribute * @param {number} nameEnd end offset of the tag name * @param {string} type type of the script * @param {string} input source string */ const reconcileScriptTypeAttr = (typeAttr, nameEnd, type, input) => { if (outputModule && type === "script") { // Chunk is an ES module; upgrade the tag. if (typeAttr && typeAttr.valueStart !== -1) { module.addPresentationalDependency( new ConstDependency("module", [ typeAttr.valueStart, typeAttr.valueEnd ]) ); } else { module.addPresentationalDependency( new ConstDependency(' type="module"', nameEnd) ); } } else if (!outputModule && type === "script-module" && typeAttr) { // Chunk is a classic IIFE; drop `type="module"` so the // browser doesn't load it under module semantics. let attrEnd; if (typeAttr.valueStart === -1) { attrEnd = typeAttr.nameEnd; } else if ( input[typeAttr.valueEnd] === '"' || input[typeAttr.valueEnd] === "'" ) { attrEnd = typeAttr.valueEnd + 1; } else { attrEnd = typeAttr.valueEnd; } let attrStart = typeAttr.nameStart; 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); }; // 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]: (n) => { const node = /** @type {import("./syntax").HtmlComment} */ (n); // Only proper `<!-- ... -->` comments carry magic comments. if ( node.end - node.start < 7 || source.charCodeAt(node.start) !== 0x3c || source.charCodeAt(node.start + 1) !== 0x21 || source.charCodeAt(node.start + 2) !== 0x2d || source.charCodeAt(node.start + 3) !== 0x2d || source.charCodeAt(node.end - 1) !== 0x3e || source.charCodeAt(node.end - 2) !== 0x2d || source.charCodeAt(node.end - 3) !== 0x2d ) { pendingWebpackIgnore = undefined; return; } const value = node.data; if (!webpackCommentRegExp.test(value)) { pendingWebpackIgnore = undefined; return; } /** @type {Record<string, EXPECTED_ANY>} */ let options; try { options = parseMagicComment(value, magicCommentContext); } catch (err) { const { line: sl, column: sc } = locConverter.get(node.start); const { line: el, column: ec } = locConverter.get(node.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; return; } if (options.webpackIgnore === undefined) { pendingWebpackIgnore = undefined; return; } if (typeof options.webpackIgnore !== "boolean") { const { line: sl, column: sc } = locConverter.get(node.start); const { line: el, column: ec } = locConverter.get(node.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; return; } pendingWebpackIgnore = options.webpackIgnore; }, [NodeType.Doctype]: () => { pendingWebpackIgnore = undefined; }, [NodeType.Element]: { // Children (and `<template>` content) are walked by the // processor after `enter`; `exit` clears a pending // `webpackIgnore` once they're done. enter: (n) => { const node = /** @type {import("./syntax").HtmlElement} */ (n); const ignore = pendingWebpackIgnore === true; pendingWebpackIgnore = undefined; if (ignore) { return; } const elementName = node.tagName; const attrs = node.attributes; /** @type {Map<string, string> | undefined} */ let attributesMap; const getAttributesMap = () => { if (attributesMap) return attributesMap; attributesMap = new Map(); for (const attr of attrs) { // Decoded values — filters and type resolvers compare what // the browser sees (e.g. `rel="icon"` means `icon`) attributesMap.set( attr.name, decodeHtmlEntities(attr.value, true) ); } return attributesMap; }; // 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 === attrs.length`); `attrs[i]` is only read on the // attribute steps, so the access stays in bounds. for (let i = 0; i < attrs.length + 1; i++) { const content = i === attrs.length; /** @type {SourceType} */ let type; /** @type {string} */ let value; /** @type {number} */ let start; /** @type {number} */ let end; /** @type {import("./syntax").HtmlAttribute | undefined} */ let attr; if (content) { if (!bodyItem) continue; if (bodyItem.filter && !bodyItem.filter(getAttributesMap())) { continue; } /** @type {number | undefined} */ let bodyStart; let bodyEnd = node.tagEnd; for (const child of node.children) { if (child.type === NodeType.Text) { if (bodyStart === undefined) bodyStart = child.start; bodyEnd = child.end; } } if (bodyStart === undefined) 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 = attrs[i]; const item = sources[attr.name]; if (!item) continue; if ( item.namespace !== undefined && node.namespace !== item.namespace ) { continue; } // Adoption-agency clones carry no offsets; skip blank values. if (attr.valueStart === undefined || attr.valueStart === -1) { continue; } value = attr.value; 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. `url(`), so a value-only check needn't re-read it. const decoded = value.includes("&") ? decodeHtmlEntities(value, true) : value; if (!filter(getAttributesMap(), decoded)) continue; } start = attr.valueStart; end = attr.valueEnd; 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 : decodeHtmlEntities(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 = decodeHtmlEntities(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, node.nameEnd, [start, end], entryName, scriptType === "script-module" ? "esm" : "commonjs" ); setLoc(dep, start, end); module.addPresentationalDependency(dep); reconcileScriptTypeAttr( findAttr(attrs, "type"), node.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": { // 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 a = /** @type {import("./syntax").HtmlAttribute} */ ( attr ); 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 } = decodeHtmlEntitiesWithMap(value, 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 "${ a.name }" 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; const s = start + (map ? map[us] : us); const e = start + (map ? map[ue] : ue); // `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 (node.start >= 0) { for (const copyableName of COPYABLE_SIBLING_ATTRS) { const copyableAttr = findAttr(attrs, copyableName); if (copyableAttr) { copyableAttrsText += attrSourceSpan( source, copyableAttr ); if (copyableName === "crossorigin") { hasOwnCrossOrigin = true; } } } } if ( firstScriptStart === -1 && (type === "script" || type === "script-module") ) { firstScriptStart = node.start; } // 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). /** @type {Range | null} */ let integrityRange = null; /** @type {Range | null} */ let typeValueRange = null; if (node.start >= 0 && elementName === nativeTag) { const integrityAttr = findAttr(attrs, "integrity"); if (integrityAttr && integrityAttr.nameStart >= 0) { let rs = integrityAttr.nameStart; while ( rs > 0 && isASCIIWhitespace(source.charCodeAt(rs - 1)) ) { rs--; } let re; if (integrityAttr.valueStart === -1) { re = integrityAttr.nameEnd; } else { const q = source.charCodeAt( integrityAttr.valueStart - 1 ); re = q === CC_QUOTATION || q === CC_APOSTROPHE ? integrityAttr.valueEnd + 1 : integrityAttr.valueEnd; } integrityRange = [rs - node.start, re - node.start]; } if (elementKind === "script-module") { const typeAttr = findAttr(attrs, "type"); if (typeAttr && typeAttr.valueStart >= 0) { typeValueRange = [ typeAttr.valueStart - node.start, typeAttr.valueEnd - node.start ]; } } } const dep = new HtmlEntryDependency( url, [s, e], entryName, entryCategory, elementKind, node.start, node.tagEnd, elementName === nativeTag, copyableAttrsText, node.nameEnd, hasOwnCrossOrigin, firstScriptStart, integrityRange, typeValueRange ); setLoc(dep, s, e); module.addPresentationalDependency(dep); if ( elementName === "script" && (type === "script" || type === "script-module") ) { reconcileScriptTypeAttr( findAttr(attrs, "type"), node.nameEnd, type, source ); } (type === "script" ? scriptEntries : type === "script-module" ? scriptModuleEntries : type === "stylesheet" ? stylesheetEntries : modulePreloadEntries ).push({ request: url, entryName, type }); } else { const dep = new HtmlSourceDependency(url, [s, e]); setLoc(dep, s, e); module.addDependency(dep); module.addCodeGenerationDependency(dep); } } break; } } } }, exit: () => { pendingWebpackIgnore = undefined; } } }) .process(source, { ast: buildHtmlAst(source) }); const buildInfo = /** @type {HtmlModuleBuildInfo} */ (module.buildInfo); buildInfo.strict = true; // 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. if ( scriptEntries.length > 0 || scriptModuleEntries.length > 0 || modulePreloadEntries.length > 0 || stylesheetEntries.length > 0 ) { buildInfo.htmlEntryScripts = { script: scriptEntries, "script-module": scriptModuleEntries, modulepreload: modulePreloadEntries, stylesheet: stylesheetEntries }; } const buildMeta = /** @type {BuildMeta} */ (state.module.buildMeta); buildMeta.exportsType = "default"; state.module.addDependency(new StaticExportsDependency(["default"], true)); return state; } } module.exports = HtmlParser;