Skip to content

Commit 85300a5

Browse files
fix(html): isolate XML-specific parsing behavior
1 parent 495bb61 commit 85300a5

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎lib/html/syntax-parser.js‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4720,7 +4720,10 @@ const _attrValueOf = (i) => {
47204720
* @param {number} i the attribute
47214721
* @returns {string} the value with its character references resolved
47224722
*/
4723-
const _attrValueDecoded = (i) => decodeEntities(_attrValueOf(i), true);
4723+
const _attrValueDecoded = (i) =>
4724+
xmlMode
4725+
? decodeXmlEntities(_attrValueOf(i))
4726+
: decodeEntities(_attrValueOf(i), true);
47244727

47254728
// Linear name lookup in a run — attribute lists are short, a loop beats a Map.
47264729
/** @type {(start: number, count: number, name: string) => number} */
@@ -5694,6 +5697,51 @@ const SHELL_END_BODY = 4;
56945697
let eofInTag = false;
56955698
let sawSelectedContent = false;
56965699
const decode = decodeEntities;
5700+
5701+
const XML_NAMED_REFERENCES = {
5702+
amp: "&",
5703+
apos: "'",
5704+
gt: ">",
5705+
lt: "<",
5706+
quot: '"'
5707+
};
5708+
5709+
/**
5710+
* @param {number} code Unicode code point
5711+
* @returns {boolean} whether XML 1.0 permits the character
5712+
*/
5713+
const isXmlChar = (code) =>
5714+
code === CC_TAB ||
5715+
code === CC_LF ||
5716+
code === 0x0d ||
5717+
(code >= 0x20 && code <= 0xd7ff) ||
5718+
(code >= 0xe000 && code <= 0xfffd) ||
5719+
(code >= 0x10000 && code <= 0x10ffff);
5720+
5721+
/**
5722+
* Decode the five predefined XML entities and numeric character references.
5723+
* Unlike HTML, XML has no built-in entity table beyond those five names.
5724+
* @param {string} value source text
5725+
* @returns {string} decoded text
5726+
*/
5727+
const decodeXmlEntities = (value) =>
5728+
value.replace(
5729+
/&(?:#(?:x([\da-f]+)|(\d+))|([a-z]+));/gi,
5730+
(match, hex, decimal, name) => {
5731+
if (name !== undefined) {
5732+
return Object.prototype.hasOwnProperty.call(XML_NAMED_REFERENCES, name)
5733+
? XML_NAMED_REFERENCES[
5734+
/** @type {keyof typeof XML_NAMED_REFERENCES} */ (name)
5735+
]
5736+
: match;
5737+
}
5738+
const code = Number.parseInt(
5739+
hex === undefined ? decimal : hex,
5740+
hex ? 16 : 10
5741+
);
5742+
return isXmlChar(code) ? String.fromCodePoint(code) : "\uFFFD";
5743+
}
5744+
);
56975745
/** @type {MutableToken} */
56985746
const tok = {
56995747
type: TOKEN_EOF,
@@ -8280,7 +8328,11 @@ const handleTextToken = (input, start, end) => {
82808328
_namespaceOf(top) === NS_HTML &&
82818329
NO_DECODE_TEXT.has(_tagNameOf(top)) &&
82828330
(mode === MODE_TEXT || mode === MODE_IN_BODY);
8283-
let data = noDecode ? normalized : decode(normalized, false);
8331+
let data = noDecode
8332+
? normalized
8333+
: xmlMode
8334+
? decodeXmlEntities(normalized)
8335+
: decode(normalized, false);
82848336
// In RAWTEXT/RCDATA/script/PLAINTEXT, NULL becomes U+FFFD (tokenizer
82858337
// rule); in data state NULLs pass through to be dropped in "in body".
82868338
if (rawMode && inputHasNul && data.includes("\0")) {
@@ -8364,7 +8416,7 @@ const handleStartTagToken = (
83648416
const name = xmlMode
83658417
? input.slice(nameStart, nameEnd)
83668418
: internLowerName(TAG_NAME_INTERN, input, nameStart, nameEnd);
8367-
if (name === "selectedcontent") sawSelectedContent = true;
8419+
if (!xmlMode && name === "selectedcontent") sawSelectedContent = true;
83688420
tok.type = TOKEN_START_TAG;
83698421
tok.name = name;
83708422
// The reused token carries the tag's attribute run; every consumer of

‎test/unitCases/HtmlSyntax.unittest.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3471,6 +3471,32 @@ describe("parseHtml", () => {
34713471
});
34723472
});
34733473

3474+
it("should not decode HTML-only or differently-cased entity names", () => {
3475+
const root = /** @type {MatElement} */ (
3476+
parseXml("<root>&nbsp;&AMP;&amp;</root>").children[0]
3477+
);
3478+
expect(root.children[0]).toMatchObject({
3479+
type: NodeType.Text,
3480+
data: "&nbsp;&AMP;&"
3481+
});
3482+
});
3483+
3484+
it("should not apply selectedcontent HTML behavior", () => {
3485+
const root = /** @type {MatElement} */ (
3486+
parseXml(
3487+
"<root><select><option selected=\"\"><span/></option><selectedcontent/></select></root>"
3488+
).children[0]
3489+
);
3490+
const select = /** @type {MatElement} */ (root.children[0]);
3491+
expect(select.children.map((node) => node.type)).toEqual([
3492+
NodeType.Element,
3493+
NodeType.Element
3494+
]);
3495+
expect(
3496+
/** @type {MatElement} */ (select.children[1]).children
3497+
).toHaveLength(0);
3498+
});
3499+
34743500
it("should honor AST skip options without changing XML structure", () => {
34753501
const root = /** @type {MatElement} */ (
34763502
parseXml("<root>text<!-- comment --><child/></root>", {

0 commit comments

Comments
 (0)