Skip to content

Commit ca80be9

Browse files
fix(html): drop a shell tag kept for whitespace that prints nothing, and respell escaped quotes (#21717)
* fix(html): drop a shell tag kept for whitespace that prints nothing `_canOmitShellStart` and `_canOmitShellEnd` skipped only a droppable comment, so a whitespace-only text node blocked `<head>` and `</head>` even though the printer discards it in every `collapseWhitespace` tier — whitespace under `<head>` or `<html>` renders nowhere. Route both through `_printsNothing`, which the §13.1.2.4 end-tag tests already use for exactly this. * fix(html): spell a value under the delimiter that escapes fewer of its quotes A value whose quotes the source escaped is shorter under the other delimiter: `alt="say &quot;hi&quot;"` is `alt='say "hi"'`, five bytes less per escape and the same value. Tried before unquoting, which would keep every `&quot;` it leaves bare. The round-trip case compared `attributes()`, which reports source bytes rather than the value the parser builds, so it read the respelling as a tree change — decode there, as its own contract already promised. * test(html): let the minifier comparison see whitespace it loses The DOM fingerprint normalized text with `replace(/\s+/g, " ").trim()` and dropped what was left empty, so a removed whitespace-only node — which fuses two inline boxes — read as no difference at all. Record each as a run instead, folding adjacent ones: dropping a comment merges the nodes either side, and counting those as lost whitespace flags every tool for a loss none of them took. Not under `<head>` / `<html>`, where nothing renders it. * fix(html): keep an empty attribute the spec does not define on that element `removeEmptyAttributes` read a flat name table, so `blocking`, `for`, `headers`, `ping`, `rel` and `sizes` were dropped from any element — including a custom one, where each is an ordinary author attribute a script reads back. The table now carries the element scope the token-list table already did, and the browser oracle probes each name on an element that reflects it rather than on `<a>`, where most of them read as unknown and were skipped. * test(html): mark a whitespace run with a non-string the text cannot be A literal U+0001 survives parsing into a text node, so the string marker was a value a document could carry — a minifier that replaced that text with whitespace would have fingerprinted the same. Also trims two comments past the two-line limit. * refactor(html): escape an attribute value through one escaper The quote respelling hand-rolled a second escape chain beside `escapeAttribute`, agreeing with it by coincidence rather than by construction. `escapeAttribute` now takes the delimiter to escape and the respelling calls it, so the two cannot drift apart. The delimiter is compared only when a quote is actually seen, leaving the ordinary character path as it was. * test(html): hold a declarative shadow root to the same standard as the page The browser oracle parsed with `DOMParser`, which leaves `<template shadowrootmode>` inert, so a shadow tree was compared as template content and no fixture carried one at all. It now parses with `parseHTMLUnsafe` and walks an open shadow root like the light tree, and a round-trip case covers open, closed, nested, slotted and the shadow root flags. * perf(html): write an attribute value in the fewest bytes the parser reads back Three spellings the printer was leaving on the table, each checked against the tokenizer's own rules: `<` and `>` carry no meaning inside a quoted value (§13.2.5.36/38), an `&` no reference can start after it is data (§13.2.5.41), and an apostrophe is free under a double quote just as a double quote is free under an apostrophe. The value is now decoded once and written under whichever delimiter costs least, so `title="&lt;b&gt;bold&lt;/b&gt;"` is `title="<b>bold</b>". A bare value still keeps `&gt;`, which would otherwise end it, and `?a=1&amp;b=2` keeps its escape because `&b` could continue into a reference. A reference spelling whitespace is left alone: writing it out would let a later `removeEmptyAttributes` pass read the attribute as empty. * test(html): keep a closed shadow root in view of the browser oracle Attaching declarative shadow roots hid what `shadowRoot` never hands back: a closed root's content read as empty, so the minifier could have mangled it unnoticed. Its content is now read off a second, inert parse where it is still the `<template>` it was written as.
1 parent 5e392ee commit ca80be9

23 files changed

Lines changed: 408 additions & 141 deletions

File tree

‎.changeset/048-html-remove-redundant-attributes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"webpack": minor
33
---
44

5-
Minify HTML further: leave out the implied `<html>` tag and any optional end tag nothing prints behind, print an empty attribute value bare, drop an empty attribute and a run of nested empty elements, fold an enumerated value to lower case, and add the `optimization.minimize.html` options `collapseWhitespace`, `mergeStyles`, `minifyConditionalComments`, `preserveComments`, `removeEmptyAttributes`, `removeEmptyElements`, `removeImpliedTags`, `removeRedundantAttributes`, `sortAttributes` and `sortTokenLists`.
5+
Minify HTML further: leave out the implied `<html>` tag and any optional end tag nothing prints behind, print an empty attribute value bare, write an attribute value in the fewest bytes that parse back the same, drop an empty attribute and a run of nested empty elements, fold an enumerated value to lower case, and add the `optimization.minimize.html` options `collapseWhitespace`, `mergeStyles`, `minifyConditionalComments`, `preserveComments`, `removeEmptyAttributes`, `removeEmptyElements`, `removeImpliedTags`, `removeRedundantAttributes`, `sortAttributes` and `sortTokenLists`.

‎.changeset/157-html-minify-text-fixes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"webpack": patch
33
---
44

5-
Fix HTML minification losing a character after a CR line ending, dropping a body's leading whitespace when its tag is implied, omitting an end tag whose absence lets the parser's adoption agency restructure the tree, and the parser case-folding the non-ASCII characters of an element or attribute name.
5+
Fix HTML minification losing a character after a CR line ending, dropping a body's leading whitespace when its tag is implied, keeping a shell tag over whitespace that prints nothing, omitting an end tag whose absence lets the parser's adoption agency restructure the tree, and the parser case-folding the non-ASCII characters of an element or attribute name.

‎cspell.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"language": "en",
44
"words": [
55
"accesskey",
6+
"shadowrootmode",
67
"noscript",
78
"pico",
89
"picocss",

‎declarations/WebpackOptions.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2409,7 +2409,7 @@ export interface OptimizationMinimizeHtml {
24092409
*/
24102410
preserveComments?: (RegExp | string)[];
24112411
/**
2412-
* Drop an attribute whose empty or all-whitespace value leaves it in the state its absence gives: `class`, `id`, `style`, `dir`, `for`, `accesskey`, `itemprop`, `itemref`, `itemtype`, and every attribute reflecting a token list (`rel`, `ping`, `headers`, `part`, `sizes`, `blocking`) — an empty list is no tokens. Off by default: an attribute selector matches on presence, so `[class]` stops matching. Never dropped: `title` and `lang`, whose empty value means what absence does not; `sandbox`, whose empty list is the most restrictive state an `<iframe>` has; and an event handler, whose empty body still compiles to a function where absence reads null.
2412+
* Drop an attribute whose empty or all-whitespace value leaves it in the state its absence gives: the globals `class`, `id`, `style`, `dir`, `accesskey`, `itemprop`, `itemref`, `itemtype` and `part`, and every attribute reflecting a token list on the elements the spec defines it for — `rel` on `<a>`, `<area>`, `<form>` and `<link>`, `ping` on `<a>` and `<area>`, `headers` on `<td>` and `<th>`, `blocking` on `<link>`, `<script>` and `<style>`, `sizes` on `<link>`, `for` on `<output>` — where an empty list is no tokens. Anywhere else that spelling is an author attribute whose meaning is a script's, so `<x-foo rel="">` and `<label for="">` keep it. Off by default: an attribute selector matches on presence, so `[class]` stops matching. Never dropped: `title` and `lang`, whose empty value means what absence does not; `sandbox`, whose empty list is the most restrictive state an `<iframe>` has; and an event handler, whose empty body still compiles to a function where absence reads null.
24132413
* @since 5.110.0
24142414
*/
24152415
removeEmptyAttributes?: boolean;

‎lib/html/data.js‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,25 +1615,25 @@ const EMPTY_ELEMENT_KEPT = new Set([
16151615
]);
16161616

16171617
/**
1618-
* Attributes `removeEmptyAttributes` may drop when their value is empty or all whitespace: the spec gives each the same state empty and absent. Four hand-reasoned globals, plus every token-list attribute (no tokens is what absence gives) and every §8.1.7.2.1 event handler (an empty handler body does nothing). `sandbox` is the token list held back — its empty list is the most restrictive state an `<iframe>` has. Only a presence selector (`[class]`) or a script reading the attribute back can tell, which is what keeps the option off by default.
1619-
* @type {Set<string>}
1618+
* Attributes `removeEmptyAttributes` may drop when their value is empty or all whitespace, mapped to the elements they mean that on (`null` = a global attribute): the spec gives each the same state empty and absent. Four hand-reasoned globals, plus every token-list attribute (no tokens is what absence gives). The element matters — on any element the spec does not define it for, `rel` or `headers` is an ordinary author attribute a script reads back, so `<x-foo rel="">` keeps it. `sandbox` is the token list held back: its empty list is the most restrictive state an `<iframe>` has. Only a presence selector (`[class]`) or a script reading the attribute back can tell, which is what keeps the option off by default.
1619+
* @type {Map<string, Set<string> | null>}
16201620
*/
1621-
const EMPTY_REMOVABLE_ATTRIBUTES = new Set([
1622-
"accesskey",
1623-
"blocking",
1624-
"class",
1625-
"dir",
1626-
"for",
1627-
"headers",
1628-
"id",
1629-
"itemprop",
1630-
"itemref",
1631-
"itemtype",
1632-
"part",
1633-
"ping",
1634-
"rel",
1635-
"sizes",
1636-
"style"
1621+
const EMPTY_REMOVABLE_ATTRIBUTES = new Map([
1622+
["accesskey", null],
1623+
["blocking", new Set(["link", "script", "style"])],
1624+
["class", null],
1625+
["dir", null],
1626+
["for", new Set(["output"])],
1627+
["headers", new Set(["td", "th"])],
1628+
["id", null],
1629+
["itemprop", null],
1630+
["itemref", null],
1631+
["itemtype", null],
1632+
["part", null],
1633+
["ping", new Set(["a", "area"])],
1634+
["rel", new Set(["a", "area", "form", "link"])],
1635+
["sizes", new Set(["link"])],
1636+
["style", null]
16371637
]);
16381638

16391639
/**

‎lib/html/syntax.js‎

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4532,17 +4532,22 @@ const _finishMap = (text, map, last, rawLength) => {
45324532
/**
45334533
* Escape a string per the WHATWG "escape a string" algorithm
45344534
* (https://html.spec.whatwg.org/multipage/parsing.html#escapingString) in
4535-
* attribute mode: `&`, U+00A0 and `"`. CR/LF are additionally encoded as
4536-
* numeric references so the result stays single-line (usable in a `data:`
4537-
* URI). Single pass, returns the input unchanged when nothing needs escaping.
4535+
* attribute mode: `&`, U+00A0 and whichever quote delimits the value \u2014 the
4536+
* other one stands literal, which is what makes `alt='say "hi"'` legal. CR/LF
4537+
* are additionally encoded as numeric references so the result stays
4538+
* single-line (usable in a `data:` URI). Single pass, returns the input
4539+
* unchanged when nothing needs escaping.
45384540
* @param {string} s string to escape
4541+
* @param {number=} delimiter the quote the value is written in, `"` by default
4542+
* @param {boolean=} minimal leave an `&` no reference can start after it bare
45394543
* @returns {string} HTML attribute-safe string
45404544
*/
4541-
const escapeAttribute = (s) => {
4545+
const escapeAttribute = (s, delimiter, minimal) => {
4546+
const quote = delimiter === CC_APOSTROPHE ? CC_APOSTROPHE : CC_QUOTATION_MARK;
45424547
// fast path: native indexOf scans beat any per-character loop
45434548
if (
45444549
!s.includes("&") &&
4545-
!s.includes('"') &&
4550+
!s.includes(quote === CC_APOSTROPHE ? "'" : '"') &&
45464551
!s.includes("\u00A0") &&
45474552
!s.includes("\n") &&
45484553
!s.includes("\r")
@@ -4554,12 +4559,34 @@ const escapeAttribute = (s) => {
45544559
for (let i = 0; i < s.length; i++) {
45554560
let rep;
45564561
switch (s.charCodeAt(i)) {
4557-
case CC_AMPERSAND:
4562+
case CC_AMPERSAND: {
4563+
// §13.2.5.41: a `&` the parser cannot continue into a reference is
4564+
// data. The minifier writes it bare; generated markup escapes it.
4565+
const next = s.charCodeAt(i + 1);
4566+
if (
4567+
minimal === true &&
4568+
next !== CC_NUMBER_SIGN &&
4569+
!(
4570+
(next >= 0x30 && next <= 0x39) ||
4571+
(next >= 0x41 && next <= 0x5a) ||
4572+
(next >= 0x61 && next <= 0x7a)
4573+
)
4574+
) {
4575+
continue;
4576+
}
45584577
rep = "&amp;";
45594578
break;
4579+
}
4580+
// Only the delimiter is escaped, so the compare costs a quote, not a
4581+
// character: everything ordinary still falls straight through.
45604582
case CC_QUOTATION_MARK:
4583+
if (quote !== CC_QUOTATION_MARK) continue;
45614584
rep = "&quot;";
45624585
break;
4586+
case CC_APOSTROPHE:
4587+
if (quote !== CC_APOSTROPHE) continue;
4588+
rep = "&#39;";
4589+
break;
45634590
case CC_NO_BREAK_SPACE:
45644591
rep = "&nbsp;";
45654592
break;
@@ -8931,11 +8958,7 @@ const _canOmitShellStart = (path, name, node) => {
89318958
if (name !== "html" && name !== "head" && name !== "body") return false;
89328959
if (path.attributeCount(node) !== 0) return false;
89338960
let first = path.firstChild(node);
8934-
while (
8935-
first !== 0 &&
8936-
path.type(first) === NodeType.Comment &&
8937-
!_keepComment(path.data(first), path.source(first))
8938-
) {
8961+
while (first !== 0 && _printsNothing(path, first)) {
89398962
first = path.nextSibling(first);
89408963
}
89418964
if (first === 0) return name === "html" || _removeImpliedTags === "all";
@@ -8961,11 +8984,7 @@ const _canOmitShellStart = (path, name, node) => {
89618984
const _canOmitShellEnd = (path, name, node) => {
89628985
if (_removeImpliedTags !== "all") return false;
89638986
let next = path.nextSibling(node);
8964-
while (
8965-
next !== 0 &&
8966-
path.type(next) === NodeType.Comment &&
8967-
!_keepComment(path.data(next), path.source(next))
8968-
) {
8987+
while (next !== 0 && _printsNothing(path, next)) {
89698988
next = path.nextSibling(next);
89708989
}
89718990
if (next === 0) return true;
@@ -9097,6 +9116,28 @@ const _quoteAttributeValue = (value) => {
90979116
return null;
90989117
};
90999118

9119+
/**
9120+
* The shortest way to write a decoded value: bare where the grammar allows it,
9121+
* else under whichever quote costs less. `<`, `>` and the quote that is not the
9122+
* delimiter carry no meaning inside a quoted value, so `alt="say &quot;hi&quot;"`
9123+
* is `alt='say "hi"'` and `title="&lt;b&gt;"` is `title="<b>"`.
9124+
* @param {string} value the decoded attribute value
9125+
* @returns {string} the value as written, delimiters included
9126+
*/
9127+
const _minimalAttributeValue = (value) => {
9128+
const underDouble = escapeAttribute(value, CC_QUOTATION_MARK, true);
9129+
const underSingle = escapeAttribute(value, CC_APOSTROPHE, true);
9130+
let best = `"${underDouble}"`;
9131+
if (underSingle.length + 2 < best.length) best = `'${underSingle}'`;
9132+
// A bare value costs no delimiters, so it wins whenever it is legal at all.
9133+
for (const bare of [underDouble, underSingle]) {
9134+
if (bare.length < best.length && _canUnquoteAttributeValue(bare)) {
9135+
best = bare;
9136+
}
9137+
}
9138+
return best;
9139+
};
9140+
91009141
/**
91019142
* Whether an attribute only restates the element's own default, so dropping it
91029143
* leaves the DOM reading the same value — but leaves an attribute selector,
@@ -9126,18 +9167,21 @@ const _isRedundantAttribute = (markers, defaults, tagName, name, value) => {
91269167
};
91279168

91289169
/**
9129-
* Whether an attribute carries nothing and the spec reads its absence the same
9130-
* way, so `removeEmptyAttributes` may drop it.
9170+
* Whether an empty attribute reads as its own absence, so `removeEmptyAttributes`
9171+
* may drop it — only on the elements the spec defines it on, never `<x-foo rel="">`.
9172+
* @param {string} elementName lowercased element name
91319173
* @param {string} name lowercased attribute name
91329174
* @param {string} value the attribute's raw value
91339175
* @returns {boolean} true when the attribute may be dropped
91349176
*/
9135-
const _isRemovableEmptyAttribute = (name, value) =>
9136-
_removeEmptyAttributes &&
9137-
EMPTY_REMOVABLE_ATTRIBUTES.has(name) &&
9177+
const _isRemovableEmptyAttribute = (elementName, name, value) => {
9178+
if (!_removeEmptyAttributes) return false;
9179+
const on = EMPTY_REMOVABLE_ATTRIBUTES.get(name);
9180+
if (on === undefined || (on !== null && !on.has(elementName))) return false;
91389181
// Read raw, so a value spelled with a character reference is kept whatever it
91399182
// decodes to. `_asciiTrim` returns the input when there is nothing to trim.
9140-
_asciiTrim(value) === "";
9183+
return _asciiTrim(value) === "";
9184+
};
91419185

91429186
/**
91439187
* Whether an element carries nothing at all, so `removeEmptyElements` may drop
@@ -9783,7 +9827,7 @@ const _minifyOpenTag = (path, open) => {
97839827
const valueStart = path.attributeValueStart(attribute);
97849828
if (valueStart === -1) {
97859829
// Valueless is the empty value, so it goes with the empty ones.
9786-
if (html && _isRemovableEmptyAttribute(attributeName, "")) {
9830+
if (html && _isRemovableEmptyAttribute(elementName, attributeName, "")) {
97879831
consumedEnd = nameEnd;
97889832
continue;
97899833
}
@@ -9811,7 +9855,7 @@ const _minifyOpenTag = (path, open) => {
98119855
attributeName,
98129856
rawValue
98139857
)) ||
9814-
_isRemovableEmptyAttribute(attributeName, rawValue))
9858+
_isRemovableEmptyAttribute(elementName, attributeName, rawValue))
98159859
) {
98169860
consumedEnd = valueEnd;
98179861
continue;
@@ -9845,7 +9889,30 @@ const _minifyOpenTag = (path, open) => {
98459889
consumedEnd = valueEnd;
98469890
continue;
98479891
}
9848-
if (_canUnquoteAttributeValue(value)) {
9892+
// Guarded on the reference, so the decode stays off the hot path — and
9893+
// tried before unquoting, which keeps every reference it writes bare.
9894+
const decoded = rawValue.includes("&")
9895+
? decodeEntities(rawValue, true)
9896+
: null;
9897+
// A reference spelling whitespace is deliberate: writing it out would
9898+
// let `removeEmptyAttributes` read the attribute as empty on a re-run.
9899+
const minimal =
9900+
decoded !== null &&
9901+
(_asciiTrim(decoded) !== "" || _asciiTrim(rawValue) === "")
9902+
? _minimalAttributeValue(decoded)
9903+
: null;
9904+
const unquotable = _canUnquoteAttributeValue(value);
9905+
if (
9906+
minimal !== null &&
9907+
minimal.length < (unquotable ? value.length : rawValue.length + 2)
9908+
) {
9909+
const opened = minimal.charCodeAt(0);
9910+
out += ` ${attributeName}=${minimal}`;
9911+
unquotedTail = opened !== CC_QUOTATION_MARK && opened !== CC_APOSTROPHE;
9912+
consumedEnd = valueEnd;
9913+
continue;
9914+
}
9915+
if (unquotable) {
98499916
out += ` ${attributeName}=${value}`;
98509917
unquotedTail = true;
98519918
consumedEnd = valueEnd;

‎schemas/WebpackOptions.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3781,7 +3781,7 @@
37813781
"added": "5.110.0"
37823782
},
37833783
"removeEmptyAttributes": {
3784-
"description": "Drop an attribute whose empty or all-whitespace value leaves it in the state its absence gives: `class`, `id`, `style`, `dir`, `for`, `accesskey`, `itemprop`, `itemref`, `itemtype`, and every attribute reflecting a token list (`rel`, `ping`, `headers`, `part`, `sizes`, `blocking`) — an empty list is no tokens. Off by default: an attribute selector matches on presence, so `[class]` stops matching. Never dropped: `title` and `lang`, whose empty value means what absence does not; `sandbox`, whose empty list is the most restrictive state an `<iframe>` has; and an event handler, whose empty body still compiles to a function where absence reads null.",
3784+
"description": "Drop an attribute whose empty or all-whitespace value leaves it in the state its absence gives: the globals `class`, `id`, `style`, `dir`, `accesskey`, `itemprop`, `itemref`, `itemtype` and `part`, and every attribute reflecting a token list on the elements the spec defines it for — `rel` on `<a>`, `<area>`, `<form>` and `<link>`, `ping` on `<a>` and `<area>`, `headers` on `<td>` and `<th>`, `blocking` on `<link>`, `<script>` and `<style>`, `sizes` on `<link>`, `for` on `<output>` — where an empty list is no tokens. Anywhere else that spelling is an author attribute whose meaning is a script's, so `<x-foo rel=\"\">` and `<label for=\"\">` keep it. Off by default: an attribute selector matches on presence, so `[class]` stops matching. Never dropped: `title` and `lang`, whose empty value means what absence does not; `sandbox`, whose empty list is the most restrictive state an `<iframe>` has; and an event handler, whose empty body still compiles to a function where absence reads null.",
37853785
"type": "boolean",
37863786
"added": "5.110.0"
37873787
},

‎test/HtmlSyntax.unittest.js‎

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2934,6 +2934,20 @@ describe("tokenize", () => {
29342934
expect(escapeAttribute("&")).toBe("&amp;");
29352935
expect(escapeAttribute("")).toBe("");
29362936
});
2937+
2938+
it("should escape only the delimiting quote", () => {
2939+
// Whichever quote writes the value is escaped and the other stands
2940+
// literal, which is what makes `alt='say "hi"'` legal.
2941+
const apostrophe = "'".charCodeAt(0);
2942+
expect(escapeAttribute("a\"b'c")).toBe("a&quot;b'c");
2943+
expect(escapeAttribute("a\"b'c", apostrophe)).toBe('a"b&#39;c');
2944+
expect(escapeAttribute("a&b c\nd\re", apostrophe)).toBe(
2945+
"a&amp;b&nbsp;c&#10;d&#13;e"
2946+
);
2947+
// The fast path has to test the delimiter it was given, not `"`.
2948+
expect(escapeAttribute('say "hi"', apostrophe)).toBe('say "hi"');
2949+
expect(escapeAttribute("plain", apostrophe)).toBe("plain");
2950+
});
29372951
});
29382952

29392953
describe("escapeText", () => {
@@ -4279,14 +4293,21 @@ describe("SourceProcessor — removeImpliedTags", () => {
42794293
}).code;
42804294

42814295
it("keeps the tag whitespace behind it would re-parse into", () => {
4282-
// A space opening the body lands in the head without `<body>`, and one
4283-
// behind `</head>` in the body without it.
4296+
// A space opening the body lands in the head without `<body>`.
42844297
expect(
42854298
minifyAll("<html><head><title>t</title></head><body> text</body></html>")
42864299
).toBe("<title>t</title><body> text");
4300+
});
4301+
4302+
it("drops a shell tag over whitespace that prints nothing", () => {
4303+
// Whitespace inside `<head>`, or behind `</head>`, sits outside every
4304+
// block formatting context, so no tier prints it and no tag guards it.
4305+
expect(
4306+
minifyAll("<html><head>\n<title>t</title>\n</head><body>x</body></html>")
4307+
).toBe("<title>t</title>x");
42874308
expect(
42884309
minifyAll("<html><head><title>t</title></head> <body>x</body></html>")
4289-
).toBe("<title>t</title></head>x");
4310+
).toBe("<title>t</title>x");
42904311
});
42914312

42924313
it("looks past a comment it is about to drop", () => {
@@ -4300,6 +4321,66 @@ describe("SourceProcessor — removeImpliedTags", () => {
43004321
});
43014322
});
43024323

4324+
describe("SourceProcessor — attribute quote spelling", () => {
4325+
const { SourceProcessor } = require("../lib/html/syntax");
4326+
4327+
/**
4328+
* @param {string} html a document
4329+
* @returns {string} its minified form
4330+
*/
4331+
const minify = (html) =>
4332+
new SourceProcessor().process(html, { mode: "minify" }).code;
4333+
4334+
it("swaps the delimiter so escaped quotes can stand literal", () => {
4335+
expect(minify('<img alt="say &quot;hi&quot; to &quot;them&quot;">')).toBe(
4336+
'<img alt=\'say "hi" to "them"\'>'
4337+
);
4338+
expect(minify('<div data-c="{&quot;a&quot;:1}">')).toBe(
4339+
"<div data-c='{\"a\":1}'></div>"
4340+
);
4341+
});
4342+
4343+
it("keeps every other reference the value spells", () => {
4344+
// `&amp;quot;` is the text `&quot;`, not a quote, so it stays escaped —
4345+
// and `&nbsp;` / `&#10;` still need a reference under either delimiter.
4346+
expect(minify('<img alt="&amp;quot; literal &quot;q&quot;">')).toBe(
4347+
"<img alt='&amp;quot; literal \"q\"'>"
4348+
);
4349+
expect(minify('<img alt="nb&nbsp;sp &quot;q&quot;">')).toBe(
4350+
"<img alt='nb&nbsp;sp \"q\"'>"
4351+
);
4352+
expect(minify('<img alt="line&#10;br &quot;q&quot;">')).toBe(
4353+
"<img alt='line&#10;br \"q\"'>"
4354+
);
4355+
});
4356+
4357+
it("escapes only the delimiter when a value carries both kinds", () => {
4358+
// One of them has to be escaped, so the cheaper delimiter is the one that
4359+
// leaves the more frequent quote standing literal.
4360+
expect(minify('<img alt="it&#39;s &quot;x&quot;">')).toBe(
4361+
"<img alt='it&#39;s \"x\"'>"
4362+
);
4363+
// Two of each: escaping the apostrophes is the shorter of the two.
4364+
expect(minify('<img alt="&#39;a&#39; &quot;b&quot;">')).toBe(
4365+
"<img alt='&#39;a&#39; \"b\"'>"
4366+
);
4367+
});
4368+
4369+
it("writes a reference the parser cannot read back as one", () => {
4370+
// `<` and `>` mean nothing inside a quoted value, and an `&` no reference
4371+
// can start after it is data — but one that could start a reference stays.
4372+
expect(minify('<p title="&lt;b&gt;bold&lt;/b&gt;">t</p>')).toBe(
4373+
'<p title="<b>bold</b>">t'
4374+
);
4375+
expect(minify('<p title="a &amp; b">t</p>')).toBe('<p title="a & b">t');
4376+
expect(minify('<a href="?a=1&amp;b=2">t</a>')).toBe(
4377+
'<a href="?a=1&amp;b=2">t</a>'
4378+
);
4379+
// A bare value ends at `>`, so there the reference has to stay.
4380+
expect(minify("<p title=a&gt;b>t</p>")).toBe("<p title=a&gt;b>t");
4381+
});
4382+
});
4383+
43034384
describe("SourceProcessor — token list values", () => {
43044385
const { SourceProcessor } = require("../lib/html/syntax");
43054386

0 commit comments

Comments
 (0)