From 8f7f199401605c7bcb353472a3217b299aba3a5c Mon Sep 17 00:00:00 2001 From: Jake Wang Date: Fri, 18 Sep 2026 22:02:19 -0400 Subject: [PATCH] Consume whitespace after hexadecimal CSS escapes --- src/__tests__/escapes.mjs | 26 ++++++++++++++++++++++++++ src/util/unesc.js | 14 +++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/__tests__/escapes.mjs b/src/__tests__/escapes.mjs index 4ee39b2..946176b 100644 --- a/src/__tests__/escapes.mjs +++ b/src/__tests__/escapes.mjs @@ -17,3 +17,29 @@ test("bare parens capture contents as a string", "(h1)", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].value, "(h1)"); t.deepEqual(tree.nodes[0].nodes[0].type, "string"); }); + +for (const hex of ["61", "000061"]) { + for (const whitespace of [" ", "\t", "\n", "\r", "\f", "\r\n"]) { + test( + `hex escape ${hex} terminated by ${JSON.stringify(whitespace)}`, + `[x="\\${hex}${whitespace}b"]`, + (t, tree) => { + t.is(tree.first.first.value, "ab"); + }, + ); + } +} + +for (const [value, expected] of [ + ["\\000061 b", "a b"], + ["\\000000 b", "\uFFFDb"], + ["\\00D800 b", "\uFFFDb"], + ["\\110000 b", "\uFFFDb"], + ["\\000061b", "ab"], + ["\\000061\u00a0b", "a\u00a0b"], + ["\\000061\vb", "a\vb"], +]) { + test(`hex escape boundary ${JSON.stringify(value)}`, `[x="${value}"]`, (t, tree) => { + t.is(tree.first.first.value, expected); + }); +} diff --git a/src/util/unesc.js b/src/util/unesc.js index be64a83..49cef40 100644 --- a/src/util/unesc.js +++ b/src/util/unesc.js @@ -9,13 +9,11 @@ function gobbleHex(str) { const lower = str.toLowerCase(); let hex = ""; - let spaceTerminated = false; for (let i = 0; i < 6 && lower[i] !== undefined; i++) { const code = lower.charCodeAt(i); // check to see if we are dealing with a valid hex char [a-f|0-9] const valid = (code >= 97 && code <= 102) || (code >= 48 && code <= 57); // https://drafts.csswg.org/css-syntax/#consume-escaped-code-point - spaceTerminated = code === 32; if (!valid) { break; } @@ -25,6 +23,12 @@ function gobbleHex(str) { if (hex.length === 0) { return undefined; } + let consumed = hex.length; + const next = str[consumed]; + if (next === " " || next === "\t" || next === "\n" || next === "\r" || next === "\f") { + // CSS preprocessing treats CRLF as a single newline. + consumed += next === "\r" && str[consumed + 1] === "\n" ? 2 : 1; + } const codePoint = parseInt(hex, 16); const isSurrogate = codePoint >= 0xd800 && codePoint <= 0xdfff; @@ -32,10 +36,10 @@ function gobbleHex(str) { // "If this number is zero, or is for a surrogate, or is greater than the maximum allowed code point" // https://drafts.csswg.org/css-syntax/#maximum-allowed-code-point if (isSurrogate || codePoint === 0x0000 || codePoint > 0x10ffff) { - return ["\uFFFD", hex.length + (spaceTerminated ? 1 : 0)]; + return ["\uFFFD", consumed]; } - return [String.fromCodePoint(codePoint), hex.length + (spaceTerminated ? 1 : 0)]; + return [String.fromCodePoint(codePoint), consumed]; } const CONTAINS_ESCAPE = /\\/; @@ -49,7 +53,7 @@ export default function unesc(str) { for (let i = 0; i < str.length; i++) { if (str[i] === "\\") { - const gobbled = gobbleHex(str.slice(i + 1, i + 7)); + const gobbled = gobbleHex(str.slice(i + 1, i + 9)); if (gobbled !== undefined) { ret += gobbled[0]; i += gobbled[1];