From 995ba31286b3330f67891414b3383ee3fa9afb59 Mon Sep 17 00:00:00 2001 From: Huzaifa Asif Date: Wed, 21 Oct 2026 06:15:55 +0800 Subject: [PATCH] fix: consume quoted attribute line continuations Preserve CSS string semantics when decoding and rewriting quoted attribute values. Add line continuation and escape boundary regression tests. AI assistance was used for implementation, testing and independent automated review. --- src/__tests__/attributes.mjs | 3 ++- src/__tests__/escapes.mjs | 30 ++++++++++++++++++++++++++++++ src/selectors/attribute.js | 2 +- src/util/unesc.js | 8 +++++++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/__tests__/attributes.mjs b/src/__tests__/attributes.mjs index a83d17d..da3af4f 100644 --- a/src/__tests__/attributes.mjs +++ b/src/__tests__/attributes.mjs @@ -437,7 +437,8 @@ test("extraneous non-combinating whitespace", " [href] , [class] ", (t, tr test("newline in attribute selector", '[class="woop \\\nwoop woop"]', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].attribute, "class"); t.deepEqual(tree.nodes[0].nodes[0].operator, "="); - t.deepEqual(tree.nodes[0].nodes[0].value, "woop \nwoop woop"); + // CSS string continuations do not contribute a character to the value. + t.deepEqual(tree.nodes[0].nodes[0].value, "woop woop woop"); t.true(tree.nodes[0].nodes[0].quoted); }); diff --git a/src/__tests__/escapes.mjs b/src/__tests__/escapes.mjs index 946176b..fe33910 100644 --- a/src/__tests__/escapes.mjs +++ b/src/__tests__/escapes.mjs @@ -43,3 +43,33 @@ for (const [value, expected] of [ t.is(tree.first.first.value, expected); }); } + +for (const quoteMark of ['"', "'"]) { + for (const newline of ["\n", "\r\n", "\r", "\f"]) { + const value = `a\\${newline}b`; + const selector = `[title=${quoteMark}${value}${quoteMark}]`; + test( + `quoted line continuation ${JSON.stringify(quoteMark)} ${JSON.stringify(newline)}`, + selector, + (t, tree) => { + const attribute = tree.first.first; + t.is(attribute.value, "ab"); + t.is(attribute.raws.value, `${quoteMark}${value}${quoteMark}`); + t.is(attribute.getQuotedValue(), `${quoteMark}ab${quoteMark}`); + attribute.setValue(attribute.value); + t.is(tree.toString(), `[title=${quoteMark}ab${quoteMark}]`); + }, + ); + } +} + +for (const [value, expected] of [ + [`a${"\\".repeat(3)}\nb`, "a\\b"], + ["a\\\tb", "a\tb"], + ["a\\ b", "a b"], + ["a\\a b", "a\nb"], +]) { + test(`quoted continuation boundary ${JSON.stringify(value)}`, `[x="${value}"]`, (t, tree) => { + t.is(tree.first.first.value, expected); + }); +} diff --git a/src/selectors/attribute.js b/src/selectors/attribute.js index f97d994..1c196b9 100644 --- a/src/selectors/attribute.js +++ b/src/selectors/attribute.js @@ -32,7 +32,7 @@ export function unescapeValue(value) { quoteMark = m[1]; unescaped = m[2]; } - unescaped = unesc(unescaped); + unescaped = unesc(unescaped, quoteMark !== null); if (unescaped !== value) { deprecatedUsage = true; } diff --git a/src/util/unesc.js b/src/util/unesc.js index 49cef40..0709f17 100644 --- a/src/util/unesc.js +++ b/src/util/unesc.js @@ -44,7 +44,7 @@ function gobbleHex(str) { const CONTAINS_ESCAPE = /\\/; -export default function unesc(str) { +export default function unesc(str, inString = false) { let needToProcess = CONTAINS_ESCAPE.test(str); if (!needToProcess) { return str; @@ -53,6 +53,12 @@ export default function unesc(str) { for (let i = 0; i < str.length; i++) { if (str[i] === "\\") { + const next = str[i + 1]; + if (inString && (next === "\n" || next === "\r" || next === "\f")) { + // CSS string continuations contribute no character; CRLF is one newline. + i += next === "\r" && str[i + 2] === "\n" ? 2 : 1; + continue; + } const gobbled = gobbleHex(str.slice(i + 1, i + 9)); if (gobbled !== undefined) { ret += gobbled[0];