Skip to content

Commit 2748ea7

Browse files
fix(html): escape a bare & in minified XML
The XML minifier kept the source's spelling of a text or attribute value whenever it was shorter, so an `&` opening no reference (`a & b`, `&amp` the input ran out in) was written back bare and the output was not well-formed. Keep the source spelling only where every `&` opens a reference. Claude-Session: https://claude.ai/code/session_01Bqhj38umy4coKMvXQT28NV
1 parent 3cb5202 commit 2748ea7

5 files changed

Lines changed: 36 additions & 8 deletions

File tree

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+
Keep minified XML well-formed: close a cut-off CDATA, escape a bare `&`.

‎.changeset/060-xml-unclosed-cdata-style.md‎

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎lib/html/syntax-parser.js‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6392,6 +6392,10 @@ const isXmlChar = (code) =>
63926392
const XML_REFERENCE_REGEXP =
63936393
/&(?:#(?:x([\da-fA-F]+)|(\d+))|([^\s&;#<>"'=/%]+));/g;
63946394
const XML_CHARACTER_REFERENCE_REGEXP = /&#(?:x([\da-fA-F]+)|(\d+));/g;
6395+
// An `&` opening no reference `XML_REFERENCE_REGEXP` reads, which a strict
6396+
// parser refuses: the decoder kept it as text, so it is written escaped.
6397+
const XML_BARE_AMPERSAND_REGEXP =
6398+
/&(?!(?:#(?:x[\da-fA-F]+|\d+)|[^\s&;#<>"'=/%]+);)/;
63956399
// WHY: §3.3.3 normalizes each literal whitespace character of an attribute value
63966400
// to a space after §2.11 has folded CRLF, so a CRLF is one space; replacement
63976401
// text of an entity is already folded, so there each character counts alone.
@@ -6523,6 +6527,15 @@ const escapeXmlText = (data) =>
65236527
.replace(/>/g, "&gt;")
65246528
.replace(/\r/g, "&#13;");
65256529

6530+
/**
6531+
* Whether source text may be written back as it stands: every `&` in it opens
6532+
* a reference and no `<` sits in it, so a strict parser reads what it says.
6533+
* @param {string} raw the text or attribute value as written
6534+
* @returns {boolean} true when it is well-formed as written
6535+
*/
6536+
const _isXmlWellFormedText = (raw) =>
6537+
!raw.includes("<") && !XML_BARE_AMPERSAND_REGEXP.test(raw);
6538+
65266539
/**
65276540
* Escape an attribute value for XML (§3.1): `&`, `<` and the delimiter, plus the
65286541
* whitespace §3.3.3 would otherwise normalize to a space.
@@ -13377,7 +13390,7 @@ const _xmlOpenTag = (path, minify) => {
1337713390
_attrValues[i] === null &&
1337813391
valueStart > 0 &&
1337913392
raw.length < value.length &&
13380-
!raw.includes("<")
13393+
_isXmlWellFormedText(raw)
1338113394
) {
1338213395
const written = _htmlSource.charAt(valueStart - 1);
1338313396
if (written === '"' || written === "'") {
@@ -15779,6 +15792,7 @@ module.exports.escapeAttribute = escapeAttribute;
1577915792
module.exports.escapeText = escapeText;
1578015793
module.exports.escapeXmlAttribute = escapeXmlAttribute;
1578115794
module.exports.escapeXmlText = escapeXmlText;
15795+
module.exports._isXmlWellFormedText = _isXmlWellFormedText;
1578215796
module.exports.grammar = grammar;
1578315797
module.exports.isAllAsciiWhitespace = isAllAsciiWhitespace;
1578415798
module.exports.isAsciiAlphanumeric = isAsciiAlphanumeric;

‎lib/html/syntax-printer.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const {
5151
_isRemovableEmptyAttribute,
5252
_isRemovableEmptyElement,
5353
_isSvgRoot,
54+
_isXmlWellFormedText,
5455
_keepComment,
5556
_minifyOpenTag,
5657
_namespaceOf,
@@ -340,8 +341,10 @@ const _printXml = (path, writer, minify) => {
340341
escaped = `<![CDATA[${data}]]>`;
341342
}
342343
// The source's own spelling when shorter: a declared entity reference, or
343-
// a `>` it left bare. A `<` in it is a CDATA section, never kept as is.
344-
return raw.length < escaped.length && !raw.includes("<") ? raw : escaped;
344+
// a `>` it left bare. A `<` is a CDATA section, a bare `&` no reference.
345+
return raw.length < escaped.length && _isXmlWellFormedText(raw)
346+
? raw
347+
: escaped;
345348
}
346349
case NodeType.Comment: {
347350
const source = path.source();

‎test/unitCases/html/syntax.unittest.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4196,6 +4196,17 @@ b&#10;c">
41964196
);
41974197
});
41984198

4199+
it("should escape an `&` that opens no reference when minifying", () => {
4200+
expect(printXml("<r>&amp", "minify")).toBe("<r>&amp;amp</r>");
4201+
expect(printXml("<r>a & b</r>", "minify")).toBe("<r>a &amp; b</r>");
4202+
expect(printXml('<r a="&amp x" b="&#38"/>', "minify")).toBe(
4203+
'<r a="&amp;amp x" b="&amp;#38"/>'
4204+
);
4205+
expect(printXml("<r>&undeclared;</r>", "minify")).toBe(
4206+
"<r>&undeclared;</r>"
4207+
);
4208+
});
4209+
41994210
it("should close a blank stylesheet the input ran out in", () => {
42004211
const { builtinEmbeddedRenderer } = require("../../../lib/html/builtinEmbeddedRenderer");
42014212
/**

0 commit comments

Comments
 (0)