Skip to content

Commit e9e02fb

Browse files
fix: minify data: URL payloads in HTML attributes and new URL (#22300)
* fix: minify data: URL payloads in HTML attributes and new URL Offer a URL attribute's data: payload to the HTML printer's renderer, regenerate modules that copy a rendered asset url into an analyzable new URL, write a rendered text payload back without percent-encoding all of it, decline payloads a raw # cuts short, and dispatch .webmanifest assets to the JSON minifier. * fix: keep data: URI parameters and let a manifest minimizer claim JSON Rebuild a data: URI with the parameters it was written with, so a ;charset= survives, and treat a user minimizer matching .webmanifest as claiming JSON. Cover a new URL inside a concatenated module. * test: write the embedded stylesheet's data: payload without a fragment A raw # ends the payload a browser reads, so the minifier now leaves it as written; spell the color without one so the case still minifies it. * fix: leave a new URL data: payload a raw # cuts short as written A browser reads what follows the # as a fragment, so the payload the module holds is not the one the URL carries; decline rendering it, as the CSS and HTML printers already do. * refactor(html): drop the foreign-tag branch a data: URL attribute cannot reach Only HTML-namespace attributes are offered as data: URLs, and only a foreign element's /> reads back how a value ends. * fix: minify percent-escaped data: payloads and read # as content Decode a payload's percent-escapes as the URL parser does and read a raw # as payload content, as webpack's data: modules do, rather than declining both. A CSS url() a deferred renderer declines now prints as an untapped run prints it instead of as written. * fix: leave an HTML or CSS data: URL a raw # cuts short as written A browser reads a raw # as the start of a fragment, so the payload it loads ends there; rewriting it (with the # escaped) changed what loads. * fix: redo a concatenated module whose inner module references a rendered asset * fix: skip a concatenated module already redone for its own embedded sources
1 parent c3bea45 commit e9e02fb

35 files changed

Lines changed: 775 additions & 74 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Minify data: URL payloads in HTML attributes and `new URL`, and web manifests.

‎lib/Compilation.js‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4161,7 +4161,35 @@ Or do you want to use the entrypoints '${name}' and '${runtime}' independently o
41614161
(err) => {
41624162
if (err) return callback(/** @type {WebpackError} */ (err));
41634163
const affected = new Set(pending.map(({ jobModule }) => jobModule));
4164-
const redo = jobs.filter(({ module }) => affected.has(module));
4164+
// A module referencing one of these may have copied what it generated
4165+
// into its own code (an analyzable `new URL`), so it goes again after.
4166+
/** @type {Set<Module>} */
4167+
const referencing = new Set();
4168+
for (const { info } of pending) {
4169+
for (const connection of this.moduleGraph.getIncomingConnections(
4170+
info.module
4171+
)) {
4172+
const { originModule } = connection;
4173+
if (originModule !== null && !affected.has(originModule)) {
4174+
referencing.add(originModule);
4175+
}
4176+
}
4177+
}
4178+
4179+
const ConcatenatedModule = require("./optimize/ConcatenatedModule");
4180+
4181+
const redo = [
4182+
...jobs.filter(({ module }) => affected.has(module)),
4183+
// A referencing module concatenated into another is generated as
4184+
// part of it, so the job to redo is the concatenated module's.
4185+
...jobs.filter(
4186+
({ module }) =>
4187+
!affected.has(module) &&
4188+
((module instanceof ConcatenatedModule &&
4189+
module.modules.some((inner) => referencing.has(inner))) ||
4190+
referencing.has(module))
4191+
)
4192+
];
41654193
// Generated directly rather than through the codegen cache: the module
41664194
// hash is unchanged, so the cache would hand back the first pass again.
41674195
const results =

‎lib/asset/AssetGenerator.js‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ const RuntimeGlobals = require("../runtime/RuntimeGlobals");
2727
const { toTemplateSourceFileName } = require("../template/TemplatedPathPlugin");
2828
const { getScheme } = require("../util/URLAbsoluteSpecifier");
2929
const createHash = require("../util/createHash");
30-
const { languageOfMediaType } = require("../util/dataURL");
30+
const {
31+
encodeDataURIPayload,
32+
languageOfMediaType
33+
} = require("../util/dataURL");
3134
const { makePathsRelative, parseResource } = require("../util/identifier");
3235
const memoize = require("../util/memoize");
3336
const nonNumericOnlyHash = require("../util/nonNumericOnlyHash");
@@ -635,6 +638,17 @@ class AssetGenerator extends Generator {
635638
encodedContent =
636639
/** @type {string} */
637640
(module.resourceResolveData.encodedContent);
641+
} else if (
642+
renderedSource !== undefined &&
643+
encoding === false &&
644+
module.resourceResolveData &&
645+
module.resourceResolveData.encoding === false
646+
) {
647+
// A rendered payload of a `data:` URI written as text is written back
648+
// as text: percent-encoding all of it outgrows what minifying saved.
649+
encodedContent = encodeDataURIPayload(
650+
/** @type {string} */ (source.source())
651+
);
638652
} else {
639653
encodedContent = encodeDataUri(
640654
/** @type {"base64" | false} */ (encoding),
@@ -676,13 +690,12 @@ class AssetGenerator extends Generator {
676690
(module.buildInfo).dataUrl &&
677691
needContent
678692
) {
679-
// A payload bound for CSS or HTML is offered by their serializers, which
680-
// see the `url()` it ends up in; only the JavaScript one is ours.
693+
// WHY: CSS and HTML serializers offer the payload in the `url()` or
694+
// attribute it lands in, but the `asset-url` value is also what an
695+
// analyzable `new URL` copies into JavaScript, which nothing re-reads.
681696
const encodedSource = this.generateDataUri(
682697
module,
683-
type === JAVASCRIPT_TYPE
684-
? this._renderEmbedded(module, runtimeTemplate)
685-
: undefined
698+
this._renderEmbedded(module, runtimeTemplate)
686699
);
687700
content =
688701
type === JAVASCRIPT_TYPE

‎lib/config/defaults.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2646,7 +2646,8 @@ const applyOptimizationDefaults = (
26462646
if (minifyJson) {
26472647
minify.push(require("../json/jsonMinify"));
26482648
minimizerOptions.push({});
2649-
extensions.push("json");
2649+
// A web app manifest is JSON under its own extension.
2650+
extensions.push("json", "webmanifest");
26502651
}
26512652
new TerserPlugin({
26522653
test: assetNameTest(extensions),

‎lib/config/getClaimedAssetTypes.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ module.exports = (compiler) => {
5757
for (const candidate of [...minimizer, ...plugins]) {
5858
if (!css) css = claims(candidate, "file.css");
5959
if (!html) html = claims(candidate, "file.html");
60-
if (!json) json = claims(candidate, "file.json");
60+
// The JSON minifier takes web app manifests too, so claiming either stops it.
61+
if (!json) {
62+
json =
63+
claims(candidate, "file.json") || claims(candidate, "file.webmanifest");
64+
}
6165
if (css && html && json) break;
6266
}
6367
return { css, html, json };

‎lib/css/syntax-printer.js‎

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@ const { CSS_TYPE } = require("../module/ModuleSourceTypeConstants");
99

1010
const GenericSourceProcessor = require("../util/SourceProcessor");
1111

12-
const {
13-
buildDataURI,
14-
decodeDataURIPayload,
15-
languageOfMediaType,
16-
parseDataURI
17-
} = require("../util/dataURL");
12+
const { buildDataURI, readEmbeddedDataURI } = require("../util/dataURL");
1813

1914
/**
2015
* Renders source this stylesheet embeds — a `data:` URL's payload today.
@@ -1575,18 +1570,32 @@ const _minifyUrlFunction = (fn, inner) => {
15751570
// whether a data URL holds a document.
15761571
const decoded = _decodePercentEscapes(written, false, mark);
15771572
// A `data:` payload a renderer rewrites is serialized afresh, so what it hands
1578-
// back is no longer what the quotes were written around. Quoted as written
1579-
// until then, the unquoted form below not carrying a document payload.
1573+
// back is no longer what the quotes were written around; declined, it is
1574+
// printed as an untapped run prints it.
15801575
if (_S._deferEmbeddedSource !== undefined) {
15811576
const deferred = _deferDataUrl(
15821577
decoded,
15831578
(url) => `${fn}(${_serializeUrl(url, mark)})`,
1584-
`${fn}(${mark}${written}${mark})`
1579+
_plainUrlFunction(fn, written, decoded, mark) ||
1580+
`${fn}(${mark}${written}${mark})`
15851581
);
15861582
if (deferred !== null) return deferred;
15871583
}
15881584
const rendered = _renderDataUrl(decoded);
15891585
if (rendered !== null) return `${fn}(${_serializeUrl(rendered, mark)})`;
1586+
return _plainUrlFunction(fn, written, decoded, mark);
1587+
};
1588+
1589+
/**
1590+
* A quoted `url()` as it prints with nothing rendered: unquoted where it is a
1591+
* url-token without the quotes, its needless percent-escapes decoded.
1592+
* @param {string} fn the lowercased function name
1593+
* @param {string} written the url as written, quotes excluded
1594+
* @param {string} decoded the url with its needless percent-escapes decoded
1595+
* @param {string} mark the quote it was written in
1596+
* @returns {string | null} the shorter `url()`, or null to keep the function
1597+
*/
1598+
const _plainUrlFunction = (fn, written, decoded, mark) => {
15901599
// Taking the quotes off what is a url-token without them is `normalizeQuotes`;
15911600
// writing a percent-escape as the byte it names is not a switch of its own —
15921601
// an escape and the byte name one string, so nothing reads them apart.
@@ -4142,14 +4151,37 @@ const printer = (path, writer) => {
41424151
}
41434152
}
41444153
const percent = raw.includes("%");
4154+
/**
4155+
* @returns {string} the url-token as it prints with nothing rendered
4156+
*/
4157+
const plainUrl = () => {
4158+
// Padding only exists when the content is whitespace-bounded; checking
4159+
// that costs two char reads, reading the content costs a slice.
4160+
if (
4161+
!percent &&
4162+
!isWhitespace(raw.charCodeAt(open + 1)) &&
4163+
!isWhitespace(raw.charCodeAt(raw.length - 2))
4164+
) {
4165+
return head === null ? raw : head + raw.slice(open + 1);
4166+
}
4167+
const name = head === null ? raw.slice(0, open + 1) : head;
4168+
const value = _urlValue(path, unterminated);
4169+
// A url-token already carrying an escape is one this printer did not
4170+
// write, so its `\%` is not read as the start of a percent-escape.
4171+
return `${name}${
4172+
percent && !value.includes("\\")
4173+
? _decodePercentEscapes(value, true, "")
4174+
: value
4175+
})`;
4176+
};
41454177
// Ahead of the padding fast path below: a base64 `data:` url is
41464178
// whitespace-free and `%`-free, so it would take it and never be offered.
41474179
if (_S._deferEmbeddedSource !== undefined) {
41484180
const opener = head === null ? raw.slice(0, open + 1) : head;
41494181
const deferred = _deferDataUrl(
41504182
_urlValue(path, unterminated),
41514183
(url) => `${opener}${_serializeUrl(url, '"')})`,
4152-
raw
4184+
plainUrl()
41534185
);
41544186
if (deferred !== null) return deferred;
41554187
}
@@ -4160,24 +4192,7 @@ const printer = (path, writer) => {
41604192
return `${name}${_serializeUrl(renderedUrl, '"')})`;
41614193
}
41624194
}
4163-
// Padding only exists when the content is whitespace-bounded; checking
4164-
// that costs two char reads, reading the content costs a slice.
4165-
if (
4166-
!percent &&
4167-
!isWhitespace(raw.charCodeAt(open + 1)) &&
4168-
!isWhitespace(raw.charCodeAt(raw.length - 2))
4169-
) {
4170-
return head === null ? raw : head + raw.slice(open + 1);
4171-
}
4172-
if (head === null) head = raw.slice(0, open + 1);
4173-
const value = _urlValue(path, unterminated);
4174-
// A url-token already carrying an escape is one this printer did not
4175-
// write, so its `\%` is not read as the start of a percent-escape.
4176-
return `${head}${
4177-
percent && !value.includes("\\")
4178-
? _decodePercentEscapes(value, true, "")
4179-
: value
4180-
})`;
4195+
return plainUrl();
41814196
}
41824197
case T_STRING: {
41834198
const source = path.source();
@@ -4846,13 +4861,7 @@ const _readDataUrl = (url) => {
48464861
// A CSS escape is still written here — the quoted form is percent-decoded,
48474862
// not unescaped — so the payload would be read with its backslashes in it.
48484863
if (url.includes("\\")) return null;
4849-
const parsed = parseDataURI(url);
4850-
if (parsed === null) return null;
4851-
const type = languageOfMediaType(parsed.mediaType);
4852-
if (type === undefined) return null;
4853-
const payload = decodeDataURIPayload(parsed);
4854-
if (payload === null || payload === "") return null;
4855-
return { parsed, type, payload };
4864+
return readEmbeddedDataURI(url);
48564865
};
48574866

48584867
/**

‎lib/html/htmlMinify.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,12 @@ const htmlMinify = async (input, sourceMap, minimizerOptions = {}) => {
131131
*/
132132
const printDocument = async (text) => {
133133
// The built-in renderer is synchronous, so with no caller's renderer only
134-
// a nested document needs the async pass — and no `srcdoc`, none is there.
135-
if (renderEmbeddedSource === undefined && !/srcdoc/i.test(text)) {
134+
// a nested document needs the async pass — an `<iframe srcdoc>` or a
135+
// `data:` URL naming HTML; with neither spelled, none is there.
136+
if (
137+
renderEmbeddedSource === undefined &&
138+
!/srcdoc|data:[^,]*html/i.test(text)
139+
) {
136140
return new SourceProcessor().process(text, {
137141
...printOptions,
138142
renderEmbeddedSource: builtin

‎lib/html/syntax-parser.js‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
} = require("../module/ModuleSourceTypeConstants");
1313

1414
const GenericSourceProcessor = require("../util/SourceProcessor");
15+
const { buildDataURI, readEmbeddedDataURI } = require("../util/dataURL");
1516

1617
const {
1718
ADDRESS_DIV_P,
@@ -10098,6 +10099,26 @@ const _isEventHandlerAttribute = (tagName, name) => {
1009810099
return elements === null || elements.has(tagName);
1009910100
};
1010010101

10102+
/**
10103+
* The `data:` URL a URL attribute holds, read for the renderer, or null where
10104+
* the attribute is no URL on this element or the URL offers nothing.
10105+
* @param {string} tagName lowercased element name
10106+
* @param {string} name lowercased attribute name
10107+
* @param {string} value the attribute's decoded value
10108+
* @returns {ReturnType<typeof readEmbeddedDataURI>} the payload and its language, or null
10109+
*/
10110+
const _attributeDataUrl = (tagName, name, value) => {
10111+
const elements = URL_ATTRIBUTES.get(name);
10112+
if (elements === undefined || (elements !== null && !elements.has(tagName))) {
10113+
return null;
10114+
}
10115+
const url = _asciiTrim(value);
10116+
// The URL parser drops every tab and newline, so a payload holding one is
10117+
// not the text the browser reads.
10118+
if ((url.charCodeAt(0) | 0x20) !== 0x64 || /[\t\n\r]/.test(url)) return null;
10119+
return readEmbeddedDataURI(url);
10120+
};
10121+
1010110122
// One list's token spans, reused: ordering a list allocates only its result.
1010210123
/** @type {number[]} */
1010310124
const _tokenSpanStarts = [];
@@ -11122,6 +11143,71 @@ const _minifyOpenTag = (path, open) => {
1112211143
continue;
1112311144
}
1112411145

11146+
// A URL attribute's `data:` payload, offered decoded as its media type's
11147+
// language and written back only where the rebuilt URL spells shorter.
11148+
const dataUrl =
11149+
html &&
11150+
!isStyle &&
11151+
!verbatim &&
11152+
(_S._renderEmbeddedSource !== undefined ||
11153+
_S._deferEmbeddedSource !== undefined)
11154+
? _attributeDataUrl(
11155+
elementName,
11156+
attributeName,
11157+
hasReference ? /** @type {string} */ (spellable) : rawValue
11158+
)
11159+
: null;
11160+
11161+
if (dataUrl !== null) {
11162+
// HTML only, so no foreign `/>` reads back how this value ends.
11163+
const asWritten =
11164+
written === null
11165+
? ` ${attributeName}=${sourceDelimiter}${rawValue}${sourceDelimiter}`
11166+
: written.text;
11167+
const declinedTail = written !== null && written.unquotedTail;
11168+
/**
11169+
* @param {string | null | undefined} rendered what the renderer answered
11170+
* @returns {{ text: string, unquotedTail: boolean } | null} what to emit, or null where nothing it answered is written
11171+
*/
11172+
const writeDataUrl = (rendered) => {
11173+
if (typeof rendered !== "string" || rendered === dataUrl.payload) {
11174+
return null;
11175+
}
11176+
const url = buildDataURI(dataUrl.parsed, rendered);
11177+
const answered = _writeAttribute(
11178+
attributeName,
11179+
escapeAttribute(url, CC_QUOTATION_MARK, true),
11180+
rawValue,
11181+
url,
11182+
quoted,
11183+
sourceDelimiter
11184+
);
11185+
11186+
return answered !== null && answered.text.length < asWritten.length
11187+
? answered
11188+
: null;
11189+
};
11190+
11191+
if (_S._deferEmbeddedSource !== undefined) {
11192+
out += _deferEmbedded(dataUrl.payload, dataUrl.type, (rendered) => {
11193+
const answered = writeDataUrl(rendered);
11194+
11195+
return answered === null ? asWritten : answered.text;
11196+
});
11197+
unquotedTail = declinedTail;
11198+
} else {
11199+
const answered = writeDataUrl(
11200+
_renderEmbeddedOrNull(dataUrl.payload, dataUrl.type)
11201+
);
11202+
11203+
out += answered === null ? asWritten : answered.text;
11204+
unquotedTail =
11205+
answered === null ? declinedTail : answered.unquotedTail;
11206+
}
11207+
consumedEnd = valueEnd;
11208+
continue;
11209+
}
11210+
1112511211
// Offered whatever the built-in made of it: a list already at its
1112611212
// shortest is one `_writeAttribute` declines, not one to ask nobody about.
1112711213
if (

‎lib/json/jsonMinify.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ jsonMinify.getTypes = () => ["json"];
6161

6262
/**
6363
* @param {string} name asset filename
64-
* @returns {boolean} true for JSON assets
64+
* @returns {boolean} true for JSON assets, a web app manifest included
6565
*/
66-
jsonMinify.filter = (name) => /^[^?#]*\.json(?:[?#].*)?$/i.test(name);
66+
jsonMinify.filter = (name) =>
67+
/^[^?#]*\.(?:json|webmanifest)(?:[?#].*)?$/i.test(name);
6768

6869
module.exports = jsonMinify;

0 commit comments

Comments
 (0)