From f9cdde0f115d24f6989a40a6b6ae71b989065fb0 Mon Sep 17 00:00:00 2001 From: Dennis Kigen Date: Mon, 9 Nov 2026 16:35:14 +0300 Subject: [PATCH] fix: don't double-escape scoped names with postcss-selector-parser 7.2.0 postcss-selector-parser 7.2.0 escapes values passed to the className() constructor. The scoped name we pass is already escaped (css-loader escapes it in generateScopedName), so it was escaped twice and the selector no longer matched the exported class. Pass the unescaped name as `value` and the escaped spelling as `raws.value`, matching how the parser represents a class it parsed itself. Do the same for ids, which postcss-selector-parser#360 would also start escaping. The trailing hex escape check now reads the serialised spelling, since `value` is no longer escaped. --- src/index.js | 33 ++++++++++++------- .../escaped-scoped-names/expected.css | 29 ++++++++++++++++ .../escaped-scoped-names/options.js | 6 ++++ .../escaped-scoped-names/source.css | 23 +++++++++++++ 4 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 test/test-cases/escaped-scoped-names/expected.css create mode 100644 test/test-cases/escaped-scoped-names/options.js create mode 100644 test/test-cases/escaped-scoped-names/source.css diff --git a/src/index.js b/src/index.js index c7e3ff4..52bcaa8 100644 --- a/src/index.js +++ b/src/index.js @@ -137,21 +137,28 @@ const plugin = (options = {}) => { case "selector": node.nodes = node.map((item) => localizeNode(item)); return node; - case "class": + case "class": { + const scopedName = exportScopedName( + node.value, + node.raws && node.raws.value ? node.raws.value : null, + node + ); + return selectorParser.className({ - value: exportScopedName( - node.value, - node.raws && node.raws.value ? node.raws.value : null, - node - ), + value: unescape(scopedName), + raws: { value: scopedName }, }); + } case "id": { + const scopedName = exportScopedName( + node.value, + node.raws && node.raws.value ? node.raws.value : null, + node + ); + return selectorParser.id({ - value: exportScopedName( - node.value, - node.raws && node.raws.value ? node.raws.value : null, - node - ), + value: unescape(scopedName), + raws: { value: scopedName }, }); } case "attribute": { @@ -190,7 +197,9 @@ const plugin = (options = {}) => { nextNode && nextNode.type === "combinator" && nextNode.value === " " && - /\\[A-F0-9]{1,6}$/.test(selector.last.value) + /\\[A-F0-9]{1,6}$/.test( + selector.last.stringifyProperty("value") + ) ) { selector.last.spaces.after = " "; } diff --git a/test/test-cases/escaped-scoped-names/expected.css b/test/test-cases/escaped-scoped-names/expected.css new file mode 100644 index 0000000..79f29ba --- /dev/null +++ b/test/test-cases/escaped-scoped-names/expected.css @@ -0,0 +1,29 @@ +.a\+x\/y\=\= { + color: red; +} + +#b\+x\/y\=\= { + color: red; +} + +.a\+x\/y\=\= .c\+x\/y\=\= { + color: red; +} + +.a\+x\/y\=\=.c\+x\/y\=\= { + color: red; +} + +.a\+x\/y\=\= > #b\+x\/y\=\= { + color: red; +} + +.global .a\+x\/y\=\= .global { + color: red; +} + +:export { + a: a+x/y==; + b: b+x/y==; + c: c+x/y==; +} diff --git a/test/test-cases/escaped-scoped-names/options.js b/test/test-cases/escaped-scoped-names/options.js new file mode 100644 index 0000000..29ca669 --- /dev/null +++ b/test/test-cases/escaped-scoped-names/options.js @@ -0,0 +1,6 @@ +module.exports = { + // Return names that are already escaped for CSS, as css-loader does. + generateScopedName: function (name) { + return String.raw`${name}\+x\/y\=\=`; + }, +}; diff --git a/test/test-cases/escaped-scoped-names/source.css b/test/test-cases/escaped-scoped-names/source.css new file mode 100644 index 0000000..5d0882d --- /dev/null +++ b/test/test-cases/escaped-scoped-names/source.css @@ -0,0 +1,23 @@ +:local(.a) { + color: red; +} + +:local(#b) { + color: red; +} + +:local(.a) :local(.c) { + color: red; +} + +:local(.a):local(.c) { + color: red; +} + +:local(.a) > :local(#b) { + color: red; +} + +.global :local(.a) .global { + color: red; +}