/* 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 `` // 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} attributes attributes * @param {string} name name * @returns {string | undefined} attribute value */ const getAttributeValue = (attributes, name) => attributes.get(name); /** @type {Map>} */ 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} 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} 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} attributes attributes * @returns {boolean} true when need to parse, otherwise false */ const filterLinkUnion = (attributes) => filterLinkHref(attributes) || filterLinkItemprop(attributes); /** * @param {Map} 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} 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 `` regex would split too early inside // `` patterns. /** @type {null | { contentStart: number, attrs: Map, 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 ` 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 ``/`` would leave the walker stuck in // rawtext mode, swallowing later markup. pendingAttributes.length = 0; return end; } const elementName = input.slice(nameStart, nameEnd).toLowerCase(); // `` position so we can slice the body; the walker is // already in rawtext state for ``. Only `` 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 | 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; // `` 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: `` 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())); // `` 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 `