diff --git a/lib/parse.js b/lib/parse.js index 7d343ca..480c2ad 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -12,6 +12,14 @@ var uUpper = "U".charCodeAt(0); var plus = "+".charCodeAt(0); var isUnicodeRange = /^[a-f0-9?-]+$/i; +function isHexDigit(code) { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 70) || + (code >= 97 && code <= 102) + ); +} + module.exports = function (input) { var tokens = []; var value = input; @@ -261,6 +269,29 @@ module.exports = function (input) { do { if (code === backslash) { next += 1; + code = value.charCodeAt(next); + if (isHexDigit(code)) { + escapePos = next; + do { + next += 1; + code = value.charCodeAt(next); + } while (next - escapePos < 6 && isHexDigit(code)); + // A hex escape can end in one CSS whitespace character. + if ( + code === 32 || + code === 9 || + code === 10 || + code === 12 || + code === 13 + ) { + next += 1; + if (code === 13 && value.charCodeAt(next) === 10) { + next += 1; + } + } + code = value.charCodeAt(next); + continue; + } } next += 1; code = value.charCodeAt(next); diff --git a/test/hex-escapes.js b/test/hex-escapes.js new file mode 100644 index 0000000..dec0ce4 --- /dev/null +++ b/test/hex-escapes.js @@ -0,0 +1,46 @@ +const { test } = require("node:test"); +const { deepEqual, strictEqual } = require("node:assert"); +const valueParser = require("../lib"); + +for (const whitespace of [" ", "\t", "\n", "\r", "\f", "\r\n"]) { + test(`hex escape consumes its whitespace terminator ${JSON.stringify(whitespace)}`, () => { + const value = "fo\\a" + whitespace + "o"; + const parsed = valueParser(value); + deepEqual(parsed.nodes, [ + { type: "word", sourceIndex: 0, sourceEndIndex: value.length, value }, + ]); + strictEqual(parsed.toString(), value); + }); +} + +test("hex escape consumes only one whitespace terminator", () => { + deepEqual(valueParser("fo\\a o").nodes, [ + { type: "word", sourceIndex: 0, sourceEndIndex: 5, value: "fo\\a " }, + { type: "space", sourceIndex: 5, sourceEndIndex: 6, value: " " }, + { type: "word", sourceIndex: 6, sourceEndIndex: 7, value: "o" }, + ]); +}); + +test("hex escape stops after six digits", () => { + const value = "\\123456a o"; + deepEqual(valueParser(value).nodes, [ + { type: "word", sourceIndex: 0, sourceEndIndex: 8, value: "\\123456a" }, + { type: "space", sourceIndex: 8, sourceEndIndex: 9, value: " " }, + { type: "word", sourceIndex: 9, sourceEndIndex: 10, value: "o" }, + ]); +}); + +test("hex escape preserves a following divider", () => { + deepEqual(valueParser("\\41 ,x").nodes, [ + { type: "word", sourceIndex: 0, sourceEndIndex: 4, value: "\\41 " }, + { + type: "div", + sourceIndex: 4, + sourceEndIndex: 5, + value: ",", + before: "", + after: "", + }, + { type: "word", sourceIndex: 5, sourceEndIndex: 6, value: "x" }, + ]); +}); diff --git a/test/parse.js b/test/parse.js index 22aa393..a6950ca 100644 --- a/test/parse.js +++ b/test/parse.js @@ -1559,9 +1559,7 @@ const tests = [ expected: [ { type: "word", sourceIndex: 0, sourceEndIndex: 6, value: "Hawaii" }, { type: "space", sourceIndex: 6, sourceEndIndex: 7, value: " " }, - { type: "word", sourceIndex: 7, sourceEndIndex: 10, value: "\\35" }, - { type: "space", sourceIndex: 10, sourceEndIndex: 11, value: " " }, - { type: "word", sourceIndex: 11, sourceEndIndex: 13, value: "-0" }, + { type: "word", sourceIndex: 7, sourceEndIndex: 13, value: "\\35 -0" }, ], }, {