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];