From e6f45df78b32a4e3c9b2ec13daf22a8e64673a7c Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sun, 8 Nov 2026 22:23:29 +0000 Subject: [PATCH] fix: apply the quote mark given to the attribute constructor `parser.attribute({ attribute: "href", operator: "=", value: "foo bar", quoteMark: '"' })` printed as `[href=foo bar]`: the value was neither quoted nor escaped, though `attr.quoteMark` reported `"`. Passing a quote mark is the non-deprecated way to construct an attribute with a value, and the value is unescaped in that form, so the result did not parse back to the same value. Setting `quoteMark` or calling `setValue()` afterwards already quoted it. The constructor now quotes and escapes the value for its quote mark, unless `raws.value` is given. The parser drops the raw value this adds when it had none, so parsed attributes keep their original spelling. --- src/__tests__/attributes.mjs | 40 ++++++++++++++++++++++++++++++++++++ src/parser.js | 9 +++++++- src/selectors/attribute.js | 5 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/__tests__/attributes.mjs b/src/__tests__/attributes.mjs index 95081c4..955c75c 100644 --- a/src/__tests__/attributes.mjs +++ b/src/__tests__/attributes.mjs @@ -691,3 +691,43 @@ for (const trailing of ["*", "$", "^", "~", "|"]) { t.deepEqual(tree.toString(), "[href]"); }); } + +for (const [quoteMark, value, expected] of [ + ['"', "foo", '[href="foo"]'], + ['"', "foo bar", '[href="foo bar"]'], + ['"', 'say "hi"', '[href="say \\"hi\\""]'], + ["'", "it's", "[href='it\\'s']"], + [null, "foo bar", "[href=foo\\ bar]"], + [null, "foo", "[href=foo]"], +]) { + ava(`Attribute constructor applies quoteMark ${quoteMark} to ${value}`, (t) => { + const attr = parser.attribute({ attribute: "href", operator: "=", value, quoteMark }); + t.deepEqual(attr.quoteMark, quoteMark); + t.deepEqual(attr.toString(), expected); + const reparsed = parser().astSync(attr.toString()).first.first; + t.deepEqual(reparsed.value, value); + t.deepEqual(reparsed.quoteMark, quoteMark); + }); +} + +ava("Attribute constructor keeps an explicit raw value", (t) => { + const attr = parser.attribute({ + attribute: "href", + operator: "=", + value: "a:b", + quoteMark: '"', + raws: { value: '"a\\3A b"' }, + }); + t.deepEqual(attr.toString(), '[href="a\\3A b"]'); +}); + +ava("Parsed attributes keep their original raw value state", (t) => { + for (const input of ["[href=foo]", '[href="foo"]', "[href=@{foo}]", "[href=a\\:b]"]) { + const attr = parser().astSync(input).first.first; + t.deepEqual(attr.toString(), input); + t.deepEqual(attr.clone().toString(), input); + if (!input.includes("\\") && !input.includes('"')) { + t.deepEqual(attr.raws.value, undefined); + } + } +}); diff --git a/src/parser.js b/src/parser.js index 24d35be..8bf7755 100644 --- a/src/parser.js +++ b/src/parser.js @@ -518,7 +518,14 @@ export default class Parser { } unescapeProp(node, "attribute"); unescapeProp(node, "namespace"); - this.newNode(new Attribute(node)); + const hasRawValue = node.raws && node.raws.value !== undefined; + const attribute = new Attribute(node); + // Preserve the parsed spelling, including non-standard syntax, rather + // than the quoting and escaping used for API-created attributes. + if (!hasRawValue) { + delete attribute.raws.value; + } + this.newNode(attribute); this.position++; } diff --git a/src/selectors/attribute.js b/src/selectors/attribute.js index ef991c1..d66a67e 100644 --- a/src/selectors/attribute.js +++ b/src/selectors/attribute.js @@ -82,6 +82,11 @@ export default class Attribute extends Namespace { ), }); this._constructed = true; + // A value given with a quote mark is unescaped, so it still needs quoting + // and escaping for that quote mark, unless its raw spelling was given too. + if (typeof this._value === "string" && this.raws.value === undefined) { + this._syncRawValue(); + } } /**