js • Lines: 1489/*
MIT License http://www.opensource.org/licenses/mit-license.php
*/
"use strict";
const vm = require("vm");
const Parser = require("../Parser");
const ConstDependency = require("../dependencies/ConstDependency");
const HtmlInlineScriptDependency = require("../dependencies/HtmlInlineScriptDependency");
const HtmlInlineStyleDependency = require("../dependencies/HtmlInlineStyleDependency");
const HtmlScriptSrcDependency = require("../dependencies/HtmlScriptSrcDependency");
const HtmlSourceDependency = require("../dependencies/HtmlSourceDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const CommentCompilationWarning = require("../errors/CommentCompilationWarning");
const ModuleDependencyError = require("../errors/ModuleDependencyError");
const UnsupportedFeatureWarning = require("../errors/UnsupportedFeatureWarning");
const WebpackError = require("../errors/WebpackError");
const LocConverter = require("../util/LocConverter");
const createHash = require("../util/createHash");
const { contextify } = require("../util/identifier");
const {
createMagicCommentContext,
webpackCommentRegExp
} = require("../util/magicComment");
const walkHtmlTokens = require("./walkHtmlTokens");
/** @typedef {import("../Module").BuildInfo} BuildInfo */
/** @typedef {import("../Module").BuildMeta} BuildMeta */
/** @typedef {import("../Parser").ParserState} ParserState */
/** @typedef {import("../Parser").PreparsedAst} PreparsedAst */
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);
const COMMA = ",".charCodeAt(0);
const LEFT_PARENTHESIS = "(".charCodeAt(0);
const RIGHT_PARENTHESIS = ")".charCodeAt(0);
const SMALL_LETTER_W = "w".charCodeAt(0);
const SMALL_LETTER_X = "x".charCodeAt(0);
const SMALL_LETTER_H = "h".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 {[string, number, number]} ParsedSource */
// 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]];
};
// HTML `<style>` content is rawtext: it ends at the first `</style>`
// where the tag name is followed by whitespace, `>` or `/`. The
// lookahead (rather than a consuming character class) keeps the match
// from running past the first `>` into a later tag — the
// `[^>]*` only consumes any (rarely-seen) end-tag attributes before the
// closing `>` of the end tag itself.
const STYLE_END_REGEXP = /<\/style(?=[\s/>])[^>]*>/gi;
// (Don't use \s, to avoid matching non-breaking space)
// eslint-disable-next-line no-control-regex
const LEADING_SPACES_REGEXP = /^[ \t\n\r\u000C]+/;
// eslint-disable-next-line no-control-regex
const LEADING_COMMAS_OR_SPACES_REGEXP = /^[, \t\n\r\u000C]+/;
// eslint-disable-next-line no-control-regex
const LEADING_NOT_SPACES = /^[^ \t\n\r\u000C]+/;
const TRAILING_COMMAS_REGEXP = /[,]+$/;
const NON_NEGATIVE_INTEGER_REGEXP = /^\d+$/;
// ( Positive or negative or unsigned integers or decimals, without or without exponents.
// Must include at least one digit.
// According to spec tests any decimal point must be followed by a digit.
// No leading plus sign is allowed.)
// https://html.spec.whatwg.org/multipage/infrastructure.html#valid-floating-point-number
const FLOATING_POINT_REGEXP =
/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
/**
* @param {string} input input
* @returns {ParsedSource[]} parsed srcset
*/
const parseSrcset = (input) => {
// 1. Let input be the value passed to this algorithm.
const inputLength = input.length;
/** @type {string | undefined} */
let url;
/** @type {string[]} */
let descriptors;
/** @type {string} */
let currentDescriptor;
/** @type {string} */
let state;
/** @type {number} */
let charCode;
/** @type {number} */
let position = 0;
/** @type {number} */
let start;
/** @type {[string, number, number][]} */
const candidates = [];
/**
* @param {RegExp} regExp reg exp to collect characters
* @returns {string | undefined} characters
*/
function collectCharacters(regExp) {
/** @type {string} */
let chars;
const match = regExp.exec(input.slice(Math.max(0, position)));
if (match) {
[chars] = match;
position += chars.length;
return chars;
}
}
/**
* @returns {void}
*/
function parseDescriptors() {
// 9. Descriptor parser: Let error be no.
let pError = false;
// 10. Let width be absent.
// 11. Let density be absent.
// 12. Let future-compat-h be absent. (We're implementing it now as h)
/** @type {number | undefined} */
let width;
/** @type {number | undefined} */
let density;
/** @type {number | undefined} */
let height;
/** @type {string | undefined} */
let desc;
// 13. For each descriptor in descriptors, run the appropriate set of steps
// from the following list:
for (let i = 0; i < descriptors.length; i++) {
desc = descriptors[i];
const lastChar = desc[desc.length - 1].charCodeAt(0);
const value = desc.slice(0, Math.max(0, desc.length - 1));
// If the descriptor consists of a valid non-negative integer followed by
// a U+0077 LATIN SMALL LETTER W character
if (
NON_NEGATIVE_INTEGER_REGEXP.test(value) &&
lastChar === SMALL_LETTER_W
) {
// If width and density are not both absent, then let error be yes.
if (width || density) {
pError = true;
}
const intVal = Number.parseInt(value, 10);
// Apply the rules for parsing non-negative integers to the descriptor.
// If the result is zero, let error be yes.
// Otherwise, let width be the result.
if (intVal === 0) {
pError = true;
} else {
width = intVal;
}
}
// If the descriptor consists of a valid floating-point number followed by
// a U+0078 LATIN SMALL LETTER X character
else if (
FLOATING_POINT_REGEXP.test(value) &&
lastChar === SMALL_LETTER_X
) {
// If width, density and future-compat-h are not all absent, then let error
// be yes.
if (width || density || height) {
pError = true;
}
const floatVal = Number.parseFloat(value);
// Apply the rules for parsing floating-point number values to the descriptor.
// If the result is less than zero, let error be yes. Otherwise, let density
// be the result.
if (floatVal < 0) {
pError = true;
} else {
density = floatVal;
}
}
// If the descriptor consists of a valid non-negative integer followed by
// a U+0068 LATIN SMALL LETTER H character
else if (
NON_NEGATIVE_INTEGER_REGEXP.test(value) &&
lastChar === SMALL_LETTER_H
) {
// If height and density are not both absent, then let error be yes.
if (height || density) {
pError = true;
}
const intVal = Number.parseInt(value, 10);
// Apply the rules for parsing non-negative integers to the descriptor.
// If the result is zero, let error be yes. Otherwise, let future-compat-h
// be the result.
if (intVal === 0) {
pError = true;
} else {
height = intVal;
}
// Anything else, Let error be yes.
} else {
pError = true;
}
}
// 15. If error is still no, then append a new image source to candidates whose
// URL is url, associated with a width width if not absent and a pixel
// density density if not absent. Otherwise, there is a parse error.
if (!pError) {
candidates.push([
/** @type {string} */ (url),
start,
start + /** @type {string} */ (url).length
]);
} else {
throw new Error(
`Invalid srcset descriptor found in '${input}' at '${desc}'`
);
}
}
/**
* @returns {void}
*/
function tokenize() {
// 8.1. Descriptor tokenizer: Skip whitespace
collectCharacters(LEADING_SPACES_REGEXP);
// 8.2. Let current descriptor be the empty string.
currentDescriptor = "";
// 8.3. Let state be in descriptor.
state = "in descriptor";
while (true) {
// 8.4. Let charCode be the character at position.
charCode = input.charCodeAt(position);
// Do the following depending on the value of state.
// For the purpose of this step, "EOF" is a special character representing
// that position is past the end of input.
// In descriptor
if (state === "in descriptor") {
// Do the following, depending on the value of charCode:
// Space character
// If current descriptor is not empty, append current descriptor to
// descriptors and let current descriptor be the empty string.
// Set state to after descriptor.
if (isASCIIWhitespace(charCode)) {
if (currentDescriptor) {
descriptors.push(currentDescriptor);
currentDescriptor = "";
state = "after descriptor";
}
}
// U+002C COMMA (,)
// Advance position to the next character in input. If current descriptor
// is not empty, append current descriptor to descriptors. Jump to the step
// labeled descriptor parser.
else if (charCode === COMMA) {
position += 1;
if (currentDescriptor) {
descriptors.push(currentDescriptor);
}
parseDescriptors();
return;
}
// U+0028 LEFT PARENTHESIS (()
// Append charCode to current descriptor. Set state to in parens.
else if (charCode === LEFT_PARENTHESIS) {
currentDescriptor += input.charAt(position);
state = "in parens";
}
// EOF
// If current descriptor is not empty, append current descriptor to
// descriptors. Jump to the step labeled descriptor parser.
else if (Number.isNaN(charCode)) {
if (currentDescriptor) {
descriptors.push(currentDescriptor);
}
parseDescriptors();
return;
// Anything else
// Append charCode to current descriptor.
} else {
currentDescriptor += input.charAt(position);
}
}
// In parens
else if (state === "in parens") {
// U+0029 RIGHT PARENTHESIS ())
// Append charCode to current descriptor. Set state to in descriptor.
if (charCode === RIGHT_PARENTHESIS) {
currentDescriptor += input.charAt(position);
state = "in descriptor";
}
// EOF
// Append current descriptor to descriptors. Jump to the step labeled
// descriptor parser.
else if (Number.isNaN(charCode)) {
descriptors.push(currentDescriptor);
parseDescriptors();
return;
}
// Anything else
// Append charCode to current descriptor.
else {
currentDescriptor += input.charAt(position);
}
}
// After descriptor
else if (state === "after descriptor") {
// Do the following, depending on the value of charCode:
if (isASCIIWhitespace(charCode)) {
// Space character: Stay in this state.
}
// EOF: Jump to the step labeled descriptor parser.
else if (Number.isNaN(charCode)) {
parseDescriptors();
return;
}
// Anything else
// Set state to in descriptor. Set position to the previous character in input.
else {
state = "in descriptor";
position -= 1;
}
}
// Advance position to the next character in input.
position += 1;
}
}
// 3. Let candidates be an initially empty source set.
// const candidates = []; // Moved to top
// 4. Splitting loop: Collect a sequence of characters that are space
// characters or U+002C COMMA characters. If any U+002C COMMA characters
// were collected, that is a parse error.
while (true) {
collectCharacters(LEADING_COMMAS_OR_SPACES_REGEXP);
// 5. If position is past the end of input, return candidates and abort these steps.
if (position >= inputLength) {
if (candidates.length === 0) {
throw new Error("Must contain one or more image candidate strings");
}
// (we're done, this is the sole return path)
return candidates;
}
// 6. Collect a sequence of characters that are not space characters,
// and let that be url.
start = position;
url = collectCharacters(LEADING_NOT_SPACES);
// 7. Let descriptors be a new empty list.
descriptors = [];
// 8. If url ends with a U+002C COMMA character (,), follow these sub steps:
// (1). Remove all trailing U+002C COMMA characters from url. If this removed
// more than one character, that is a parse error.
if (url && url.charCodeAt(url.length - 1) === COMMA) {
url = url.replace(TRAILING_COMMAS_REGEXP, "");
// (Jump ahead to step 9 to skip tokenization and just push the candidate).
parseDescriptors();
}
// Otherwise, follow these sub steps:
else {
tokenize();
}
// 16. Return to the step labeled splitting loop.
}
};
/**
* @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",
// TODO Do we need to parser it?
// "msapplication-task",
"twitter:image"
])
],
[
"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 value = getAttributeValue(attributes, "itemprop");
if (!value) return false;
const allowedAttributes = META.get("itemprop");
if (!allowedAttributes) return false;
return allowedAttributes.has(value.trim().toLowerCase());
};
/**
* @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(" ").filter(Boolean);
const allowedRels = [
"stylesheet",
"icon",
"mask-icon",
"apple-touch-icon",
"apple-touch-icon-precomposed",
"apple-touch-startup-image",
"manifest",
"prefetch",
"preload",
"modulepreload"
];
return allowedRels.some((value) => usedRels.includes(value));
};
/**
* @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;
return allowedNames.has(name.trim().toLowerCase());
}
return false;
};
/**
* @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");
};
/** @type {Map<string, Map<string, { parse: (input: string) => ParsedSource[] | undefined, filter?: (attributes: Map<string, string>) => boolean, entry?: boolean | ((attributes: Map<string, string>) => boolean), entryCategory?: string }>>} */
const DEFAULT_SOURCES = new Map([
[
"audio",
new Map([
[
"src",
{
parse: parseSrc
}
]
])
],
[
"embed",
new Map([
[
"src",
{
parse: parseSrc
}
]
])
],
[
"img",
new Map([
[
"src",
{
parse: parseSrc
}
],
[
"srcset",
{
parse: parseSrcset
}
]
])
],
[
"input",
new Map([
[
"src",
{
parse: parseSrc
}
]
])
],
[
"link",
new Map([
[
"href",
{
parse: parseSrc,
filter: filterLinkUnion,
entry: isLinkModulePreload,
entryCategory: "esm"
}
],
[
"imagesrcset",
{
parse: parseSrcset,
filter: filterLinkHref
}
]
])
],
[
"meta",
new Map([
[
"content",
{
parse: parseSrc,
filter: filterMetaContent
}
]
])
],
[
"object",
new Map([
[
"data",
{
parse: parseSrc
}
]
])
],
[
"script",
new Map([
[
"src",
{
parse: parseSrc,
// Only executable-JS scripts become entries. Non-JS
// `<script>` types (e.g. `application/ld+json`,
// `importmap`) fall through to HtmlSourceDependency so
// the browser keeps seeing them as data blocks, with
// the asset URL rewritten like any other resource.
entry: isExecutableJsScript
}
]
])
],
[
"source",
new Map([
[
"src",
{
parse: parseSrc
}
],
[
"srcset",
{
parse: parseSrcset
}
]
])
],
[
"track",
new Map([
[
"src",
{
parse: parseSrc
}
]
])
],
[
"video",
new Map([
[
"poster",
{
parse: parseSrc
}
],
[
"src",
{
parse: parseSrc
}
]
])
],
// SVG
[
"image",
new Map([
[
"xlink:href",
{
parse: parseSrc
}
],
[
"href",
{
parse: parseSrc
}
]
])
],
[
"use",
new Map([
[
"xlink:href",
{
parse: parseSrc
}
],
[
"href",
{
parse: parseSrc
}
]
])
]
]);
class HtmlParser extends Parser {
/**
* Creates an instance of HtmlParser.
* @param {(string | typeof import("../util/Hash"))=} hashFunction algorithm or constructor used by `output.hashFunction`; falls back to the default when omitted
* @param {string=} context compilation context used to contextify the HTML module's identifier when seeding the entry-name hash
* @param {boolean=} outputModule whether `output.module` is enabled; when true, classic `<script src>` tags get `type="module"` auto-inserted so the rewritten src can load the emitted ES module chunk
* @param {boolean=} css whether `experiments.css` is enabled; when true, inline `<style>` bodies are routed through the CSS pipeline as `data:text/css` modules
*/
constructor(hashFunction, context, outputModule, css) {
super();
this.magicCommentContext = createMagicCommentContext();
this.hashFunction = hashFunction;
this.context = context;
this.outputModule = outputModule;
this.css = css;
}
/**
* 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;
// 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(this.hashFunction || "md4")
.update(this.context ? contextify(this.context, resource) : resource)
.digest("hex")
.slice(0, 8);
/** @typedef {{ nameStart: number, nameEnd: number, valueStart: number, valueEnd: number }} AttrToken */
/** @type {AttrToken[]} */
const pendingAttributes = [];
/**
* Reconciles the rewritten `<script>` tag's `type` attribute with the
* emitted chunk's actual format. Used by both the `<script src>` and
* inline `<script>` paths so the two stay in sync.
* @param {AttrToken | undefined} typeAttr existing `type` attribute, if any
* @param {number} nameEnd position right after `<script` (for inserts)
* @param {"classic" | "esm-script"} kind chunk kind decided by the parser
* @param {string} input full source string
* @returns {void}
*/
const reconcileScriptTypeAttr = (typeAttr, nameEnd, kind, input) => {
if (this.outputModule && kind === "classic") {
// 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 (!this.outputModule && kind === "esm-script" && 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;
}
// Consume one leading whitespace char so we don't leave a
// double space between `<script` and the next attribute.
let attrStart = typeAttr.nameStart;
if (
attrStart > 0 &&
isASCIIWhitespace(input.charCodeAt(attrStart - 1))
) {
attrStart -= 1;
}
module.addPresentationalDependency(
new ConstDependency("", [attrStart, attrEnd])
);
}
};
// Inline `<script>` body extraction is deferred to the matching
// `closeTag` event so the walker's script-data state machine
// (including its escaped/double-escaped sub-states) decides where
// the body ends. This is the spec-compliant way to find the close
// — a plain `</script>` regex would split too early inside
// `<!--<script>…</script>-->` patterns.
/** @type {null | { contentStart: number, attrs: Map<string, string>, typeAttr: AttrToken | undefined, nameEnd: number }} */
let pendingInlineScript = null;
// Script src / modulepreload references are collected per-category
// during the walk; HtmlModulesPlugin later turns them into real
// entries. Classic <script src> and <script type="module" src> 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 {"classic" | "esm-script" | "modulepreload" | "stylesheet"} kind
*/
/** @type {EntryScriptInfo[]} */
const classicEntries = [];
/** @type {EntryScriptInfo[]} */
const esmScriptEntries = [];
/** @type {EntryScriptInfo[]} */
const modulePreloadEntries = [];
/** @type {EntryScriptInfo[]} */
const stylesheetEntries = [];
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;
// TODO implement full HTML parser (WASM)
walkHtmlTokens(source, 0, {
comment: (input, start, end) => {
// Only proper `<!-- ... -->` comments carry magic comments.
// `walkHtmlTokens` also dispatches this callback for bogus
// comments such as `<!DOCTYPE …>` and `<?…>`, which must not
// be parsed as magic comments.
if (
end - start < 7 ||
input.charCodeAt(start) !== 0x3c /* < */ ||
input.charCodeAt(start + 1) !== 0x21 /* ! */ ||
input.charCodeAt(start + 2) !== 0x2d /* - */ ||
input.charCodeAt(start + 3) !== 0x2d /* - */ ||
input.charCodeAt(end - 1) !== 0x3e /* > */ ||
input.charCodeAt(end - 2) !== 0x2d /* - */ ||
input.charCodeAt(end - 3) !== 0x2d /* - */
) {
pendingWebpackIgnore = undefined;
return end;
}
const contentStart = start + 4;
const contentEnd = end - 3;
const value = input.slice(contentStart, contentEnd);
if (!webpackCommentRegExp.test(value)) {
pendingWebpackIgnore = undefined;
return end;
}
/** @type {Record<string, EXPECTED_ANY>} */
let options;
try {
options = vm.runInContext(
`(function(){return {${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;
return end;
}
if (options.webpackIgnore === undefined) {
pendingWebpackIgnore = undefined;
return end;
}
if (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;
return end;
}
pendingWebpackIgnore = options.webpackIgnore;
return end;
},
attribute: (
input,
nameStart,
nameEnd,
valueStart,
valueEnd,
quoteType
) => {
pendingAttributes.push({ nameStart, nameEnd, valueStart, valueEnd });
if (valueStart === -1) return nameEnd;
return quoteType !== walkHtmlTokens.QUOTE_NONE
? valueEnd + 1
: valueEnd;
},
closeTag: (input, start, end, nameStart, nameEnd) => {
pendingWebpackIgnore = undefined;
if (pendingInlineScript) {
const elName = input.slice(nameStart, nameEnd).toLowerCase();
if (elName === "script") {
const ps = pendingInlineScript;
pendingInlineScript = null;
const contentStart = ps.contentStart;
const contentEnd = start; // start of `</script>`
const jsContent = input.slice(contentStart, contentEnd);
if (jsContent.trim() === "") return end;
// Base64-encode the JS body so the data URI round-trips
// arbitrary JavaScript text, including non-ASCII source
// (`decodeDataURI` decodes non-base64 bodies as ASCII,
// which would corrupt Unicode string literals or
// identifiers).
const request = `data:text/javascript;base64,${Buffer.from(
jsContent,
"utf8"
).toString("base64")}`;
const useEsmEntry = isModuleScript(ps.attrs);
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
/** @type {"classic" | "esm-script"} */
const kind = useEsmEntry ? "esm-script" : "classic";
const { line: sl, column: sc } = locConverter.get(contentStart);
const { line: el, column: ec } = locConverter.get(contentEnd);
const dep = new HtmlInlineScriptDependency(
request,
ps.nameEnd,
[contentStart, contentEnd],
entryName,
useEsmEntry ? "esm" : "commonjs"
);
dep.setLoc(sl, sc, el, ec);
module.addPresentationalDependency(dep);
reconcileScriptTypeAttr(ps.typeAttr, ps.nameEnd, kind, input);
const collection =
kind === "classic" ? classicEntries : esmScriptEntries;
collection.push({ request, entryName, kind });
}
}
return end;
},
openTag: (input, start, end, nameStart, nameEnd) => {
const ignore = pendingWebpackIgnore === true;
pendingWebpackIgnore = undefined;
if (ignore) {
// For `<script>` and `<style>` we don't emit a dependency,
// but we must NOT advance past the close tag either: the
// walker is already in script-data/rawtext state for
// those tags and will consume the body and emit the
// matching `closeTag` itself. Returning a position past
// `</script>`/`</style>` would leave the walker stuck in
// rawtext mode, swallowing later markup.
pendingAttributes.length = 0;
return end;
}
const elementName = input.slice(nameStart, nameEnd).toLowerCase();
// `<style>` is rawtext: capture the inline CSS and hand it to
// the CSS pipeline as a virtual `data:text/css` module with
// `exportType: "text"`. We use the regex only to discover the
// `</style>` position so we can slice the body; the walker is
// already in rawtext state for `<style>` and will emit the
// matching `closeTag` event itself, so we must return `end`
// (the position right after the opening tag's `>`) rather
// than advancing past `</style>`. Only `<style>` tags whose
// `type` attribute is absent, empty, or `text/css` are
// processed; other types are left untouched.
if (elementName === "style") {
/** @type {string | undefined} */
let typeValue;
for (const attr of pendingAttributes) {
const attrName = input
.slice(attr.nameStart, attr.nameEnd)
.toLowerCase();
if (attrName === "type") {
typeValue =
attr.valueStart !== -1
? input.slice(attr.valueStart, attr.valueEnd)
: "";
break;
}
}
pendingAttributes.length = 0;
STYLE_END_REGEXP.lastIndex = end;
const closeMatch = STYLE_END_REGEXP.exec(input);
if (!closeMatch) return end;
const contentStart = end;
const contentEnd = closeMatch.index;
const trimmedType =
typeValue !== undefined ? typeValue.trim().toLowerCase() : "";
if (
typeValue !== undefined &&
trimmedType !== "" &&
trimmedType !== "text/css"
) {
return end;
}
// Inline-style processing requires the CSS pipeline; when
// `experiments.css` is off, leave the body alone — the
// walker is in rawtext mode for `<style>` and will skip
// over the body to the matching `</style>` itself.
if (!this.css) {
return end;
}
const cssContent = input.slice(contentStart, contentEnd);
if (cssContent.trim() === "") {
return end;
}
// URL-encode the CSS body so the data URI parser's `(.*)$`
// body group matches even when the source has newlines or
// other characters that would otherwise break the regex.
const request = `data:text/css,${encodeURIComponent(cssContent)}`;
const { line: sl, column: sc } = locConverter.get(contentStart);
const { line: el, column: ec } = locConverter.get(contentEnd);
const dep = new HtmlInlineStyleDependency(request, [
contentStart,
contentEnd
]);
dep.setLoc(sl, sc, el, ec);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
return end;
}
const sources = DEFAULT_SOURCES.get(elementName);
if (!sources) {
pendingAttributes.length = 0;
return end;
}
/** @type {Map<string, string> | undefined} */
let attributesMap;
const getAttributesMap = () => {
if (attributesMap) return attributesMap;
attributesMap = new Map();
for (const attr of pendingAttributes) {
const name = input
.slice(attr.nameStart, attr.nameEnd)
.toLowerCase();
const value =
attr.valueStart !== -1
? input.slice(attr.valueStart, attr.valueEnd)
: "";
attributesMap.set(name, value);
}
return attributesMap;
};
for (const attr of pendingAttributes) {
const attributeName = input
.slice(attr.nameStart, attr.nameEnd)
.toLowerCase();
const sourceItem = sources.get(attributeName);
if (!sourceItem) continue;
// TODO(html-entities): We should ideally decode entities here using
// `walkHtmlTokens.decodeHtmlEntities(input.slice(...))` so that URLs
// like `image.png?a=1&b=2` are correctly resolved as `&`.
// However, doing so currently breaks `srcset` parsing tests (e.g. `errors.js`)
// which explicitly expect whitespace entities like `	` to NOT be decoded
// before the srcset parser runs. A follow-up PR should implement selective
// decoding for specific URL attributes.
const attributeValue =
attr.valueStart !== -1
? input.slice(attr.valueStart, attr.valueEnd)
: "";
if (!attributeValue) continue;
if (
typeof sourceItem.filter === "function" &&
!sourceItem.filter(getAttributesMap())
) {
continue;
}
/** @type {ParsedSource[] | undefined} */
let parsedAttributeValue;
try {
parsedAttributeValue = sourceItem.parse(attributeValue);
} catch (err) {
const { line: sl, column: sc } = locConverter.get(attr.valueStart);
const { line: el, column: ec } = locConverter.get(attr.valueEnd);
module.addError(
new ModuleDependencyError(
module,
new WebpackError(
`Bad value for attribute "${attributeName}" on element "${elementName}": ${
/** @type {Error} */ (err).message
}`
),
{
start: { line: sl, column: sc },
end: { line: el, column: ec }
}
)
);
}
if (!parsedAttributeValue) continue;
// `<link rel="stylesheet">` is upgraded to an entry only when
// `experiments.css` is on — that's the mode where webpack can
// bundle the CSS into its own chunk. Without it, the
// stylesheet href stays a plain asset URL. Scope this to the
// `href` attribute only: `<link>` also exposes
// `imagesrcset` URLs which must continue to flow through
// the regular asset rewriting path even on a stylesheet
// link.
const isStylesheetEntry =
this.css &&
elementName === "link" &&
attributeName === "href" &&
isLinkStylesheet(getAttributesMap());
const isEntry =
isStylesheetEntry ||
sourceItem.entry === true ||
(typeof sourceItem.entry === "function" &&
sourceItem.entry(getAttributesMap()));
// `<script type="module" src>` and `<link rel="modulepreload">`
// reference ES modules; everything else under `<script src>` is a
// classic script. The category drives ESM vs CommonJS resolution
// of the entry — the chunk format is controlled by the user via
// `output.module` / `experiments.outputModule`.
const useEsmEntry =
(elementName === "script" && isModuleScript(getAttributesMap())) ||
sourceItem.entryCategory === "esm";
for (const parsedSource of parsedAttributeValue) {
const [value, innerStart, innerEnd] = parsedSource;
if (value.startsWith("#")) continue;
const sourceStart = attr.valueStart + innerStart;
const sourceEnd = attr.valueStart + innerEnd;
const { line: sl, column: sc } = locConverter.get(sourceStart);
const { line: el, column: ec } = locConverter.get(sourceEnd);
if (isEntry) {
const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
const isStylesheetLink =
elementName === "link" && isLinkStylesheet(getAttributesMap());
/** @type {"classic" | "esm-script" | "modulepreload" | "stylesheet"} */
const kind =
elementName === "link"
? isStylesheetLink
? "stylesheet"
: "modulepreload"
: useEsmEntry
? "esm-script"
: "classic";
// With `output.module` enabled, a classic `<script src>` is
// upgraded in place to `<script type="module" src>` (see the
// ConstDependency insertion below). Account for that in the
// dependency's `elementKind` so sibling tags emitted by the
// template for additional entry chunks (runtime / split chunks)
// also use `type="module"`.
const willBeModuleScript =
kind === "esm-script" ||
(this.outputModule &&
kind === "classic" &&
elementName === "script");
/** @type {"script-classic" | "script-module" | "modulepreload" | "stylesheet"} */
const elementKind =
kind === "modulepreload"
? "modulepreload"
: kind === "stylesheet"
? "stylesheet"
: willBeModuleScript
? "script-module"
: "script-classic";
const dep = new HtmlScriptSrcDependency(
value,
[sourceStart, sourceEnd],
entryName,
// `<link rel="stylesheet">` is bundled as a CSS entry —
// using a non-"url" category so the default `.css` rule
// (which gives the resolved module the CSS module type)
// wins over the `dependency: "url"` → asset rule.
kind === "stylesheet"
? "css-import"
: useEsmEntry
? "esm"
: sourceItem.entryCategory,
elementKind,
start,
end
);
dep.setLoc(sl, sc, el, ec);
module.addPresentationalDependency(dep);
// Reconcile the rewritten `<script>` tag's `type`
// attribute with the chunk's actual format. See
// `reconcileScriptTypeAttr` for the rules.
if (
elementName === "script" &&
(kind === "classic" || kind === "esm-script")
) {
/** @type {AttrToken | undefined} */
let typeAttr;
for (const a of pendingAttributes) {
if (
input.slice(a.nameStart, a.nameEnd).toLowerCase() === "type"
) {
typeAttr = a;
break;
}
}
reconcileScriptTypeAttr(typeAttr, nameEnd, kind, input);
}
const collection =
kind === "classic"
? classicEntries
: kind === "esm-script"
? esmScriptEntries
: kind === "stylesheet"
? stylesheetEntries
: modulePreloadEntries;
collection.push({ request: value, entryName, kind });
} else {
const dep = new HtmlSourceDependency(value, [
sourceStart,
sourceEnd
]);
dep.setLoc(sl, sc, el, ec);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
}
}
}
// `<script>` is rawtext (the "script data state" in the HTML
// tokenizer): its body must never be reparsed as HTML,
// regardless of whether the tag has a `src` attribute. The
// walker is already in script-data state for `<script>` and
// will emit the matching `closeTag` event itself, so we
// return `end` and defer body extraction to the closeTag
// callback (which gets the spec-correct `</script>` position
// even in escaped/double-escaped script-data sub-states).
// When the tag has no `src` and its body is non-empty, the
// closeTag handler bundles the inline JS as its own entry —
// the same pipeline that processes `<script src>` — by
// issuing a `data:text/javascript;base64,...` virtual request
// and adding a dependency that rewrites the tag to
// `<script src="…">` at render time. Only `<script>` tags
// whose `type` attribute is absent, empty, or a recognized
// JS mimetype are processed as JS; other types (e.g.
// `application/ld+json`, `importmap`) are left untouched.
if (elementName === "script") {
const attrs = getAttributesMap();
// Use attribute presence, not value: a `<script src>` with
// an empty or valueless `src` still ignores its inline
// body in the browser, so we must not bundle the body.
const hasSrc = attrs.has("src");
/** @type {AttrToken | undefined} */
let typeAttr;
for (const a of pendingAttributes) {
if (input.slice(a.nameStart, a.nameEnd).toLowerCase() === "type") {
typeAttr = a;
break;
}
}
pendingAttributes.length = 0;
if (hasSrc || !isExecutableJsScript(attrs)) {
// `<script src>` body is ignored by the browser, and
// non-JS `<script type>` (e.g. importmap, JSON-LD)
// passes through unchanged. Either way the walker
// consumes the body and emits the close tag itself.
return end;
}
pendingInlineScript = {
contentStart: end,
attrs,
typeAttr,
nameEnd
};
return end;
}
pendingAttributes.length = 0;
return end;
}
});
const buildInfo = /** @type {BuildInfo} */ (module.buildInfo);
buildInfo.strict = true;
// Hand off the collected entries to HtmlModulesPlugin; it creates the
// real compilation entries during the finishMake hook. The classic
// and esm-script groups are chained via a leader-only dependOn so
// they share a runtime; modulepreload entries are emitted as
// independent entries since `<link rel=modulepreload>` must preload
// without running.
if (
classicEntries.length > 0 ||
esmScriptEntries.length > 0 ||
modulePreloadEntries.length > 0 ||
stylesheetEntries.length > 0
) {
/** @type {Record<string, EntryScriptInfo[]>} */
(buildInfo.htmlEntryScripts) = {
classic: classicEntries,
"esm-script": esmScriptEntries,
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;