From 5bc2bd316ee6271bff3407656cbc4cc981a93d6c Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sun, 8 Nov 2026 22:22:35 +0000 Subject: [PATCH] fix: escape id values supplied to the constructor `parser.id({ value: "hover:bg" })` printed as `#hover:bg`, which parses back as an id followed by a `:bg` pseudo. Assigning the same value after construction escaped the colon. #349 fixed this for class names; ids had the same gap. The constructor now goes through the escaping setter unless `raws.value` is given, and the parser drops the raws it adds, as it does for classes, so parsed ids keep their original spelling. --- src/__tests__/id.mjs | 41 +++++++++++++++++++++++++++++++++++++++++ src/parser.js | 4 ++++ src/selectors/id.js | 4 ++++ 3 files changed, 49 insertions(+) diff --git a/src/__tests__/id.mjs b/src/__tests__/id.mjs index 054f0a8..e519b0d 100644 --- a/src/__tests__/id.mjs +++ b/src/__tests__/id.mjs @@ -1,4 +1,6 @@ import { test } from "./util/helpers.mjs"; +import apiTest from "./util/runner.mjs"; +import parser from "../../dist/index.js"; test("id selector", "#one", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].value, "one"); @@ -297,3 +299,42 @@ test("id selector with escaping (37)", "#f\\+o\\+o", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].type, "id"); t.deepEqual(tree.nodes[0].nodes[0].raws.value, "f\\+o\\+o"); }); + +for (const [value, escaped] of [ + ["hover:bg-blue", "hover\\:bg-blue"], + ["1abc", "\\31 abc"], + ["foo.bar", "foo\\.bar"], + ["foo\\bar", "foo\\\\bar"], + ["♥", "\\2665"], +]) { + apiTest(`ID constructor escapes ${value}`, (t) => { + const id = parser.id({ value }); + t.is(id.value, value); + t.is(id.toString(), `#${escaped}`, "constructor must escape the semantic value"); + t.is(parser().astSync(id.toString()).first.first.value, value); + }); +} + +apiTest("ID constructor preserves explicit raw spelling", (t) => { + const id = parser.id({ value: "hover:bg-blue", raws: { value: "hover\\3A bg-blue" } }); + t.is(id.value, "hover:bg-blue"); + t.is(id.toString(), "#hover\\3A bg-blue"); +}); + +apiTest("ID constructor leaves a plain identifier without raws", (t) => { + const id = parser.id({ value: "main" }); + t.is(id.toString(), "#main"); + t.is(id.raws, undefined); +}); + +apiTest("Parsed ids keep their original raw state when cloned", (t) => { + for (const input of ["#main", "#♥", "#foo@{bar}", "#hover\\3A bg-blue"]) { + const original = parser().astSync(input).first.first; + const clone = original.clone(); + t.is(clone.toString(), input); + t.deepEqual(clone.raws, original.raws); + if (!input.includes("\\")) { + t.is(original.raws, undefined); + } + } +}); diff --git a/src/parser.js b/src/parser.js index 24d35be..f4adf26 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1156,6 +1156,10 @@ export default class Parser { sourceIndex, }; node = new ID(unescapeProp(idOpts, "value")); + // Preserve the parsed spelling, as for class names above. + if (!idOpts.raws) { + delete node.raws; + } } else { let tagOpts = { value, diff --git a/src/selectors/id.js b/src/selectors/id.js index 8071a8c..e1b6f1c 100644 --- a/src/selectors/id.js +++ b/src/selectors/id.js @@ -8,6 +8,10 @@ export default class ID extends Node { super(opts); this.type = IDType; this._constructed = true; + const value = this.value; + if (typeof value === "string" && (!this.raws || this.raws.value === undefined)) { + this.value = value; + } } set value(v) {