From 3fe43f960b56bb304c400989714dbfcea0c78a6b Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sun, 9 Aug 2026 21:33:00 +0100 Subject: [PATCH] fix: `$` dropped from attribute names `attribute()` handled `$` in the value position, and treated it as the suffix match operator when followed by `=`, but had no branch for a `$` that belongs to the attribute name. Those fell through to the `tokens.caret` case, which only acts when the next token is `=`, so the character was silently discarded: parser().astSync("[#{$attr}]").toString() // "[#{attr}]" parser().astSync("[$attr]").toString() // "[$attr]" -> "[attr]" Preprocessors reach this through interpolation, which is how both reports arrived. The value position already worked, so `[lang=#{$locale}]` round-tripped while `[#{$attr}]` did not. The new branch mirrors the existing value-position handling and is guarded on the next token not being `=`, which keeps the suffix match operator intact. That guard is what allows a name and an operator to coexist: [$attr$="x"] attribute "$attr", operator "$=", value "x" Compared against main across 4,800 generated attribute selectors: 991 newly round-trip, 0 regressions, and none that previously parsed now throw. A trailing `$` such as `[a$]` also stops raising a raw TypeError as a side effect, since the new branch returns before the unguarded lookahead is reached. Fixes #211 Fixes #306 --- src/__tests__/nonstandard.mjs | 35 +++++++++++++++++++++++++++++++++++ src/parser.js | 16 ++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/__tests__/nonstandard.mjs b/src/__tests__/nonstandard.mjs index 064bc3d..8990eac 100644 --- a/src/__tests__/nonstandard.mjs +++ b/src/__tests__/nonstandard.mjs @@ -31,6 +31,41 @@ test("sass escapes (2)", "[lang=#{$locale}]", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].value, "#{$locale}"); }); +test("sass interpolation as an attribute name", "[#{$attr}]", (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].type, "attribute"); + t.deepEqual(tree.nodes[0].nodes[0].attribute, "#{$attr}"); + t.deepEqual(tree.nodes[0].nodes[0].operator, undefined); + t.deepEqual(tree.nodes[0].nodes[0].value, undefined); +}); + +test("sass variable as an attribute name", "[$attr]", (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].attribute, "$attr"); + t.deepEqual(tree.nodes[0].nodes[0].operator, undefined); +}); + +test( + "sass interpolation on both sides of an attribute selector", + "[#{$attr}=#{$value}]", + (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].attribute, "#{$attr}"); + t.deepEqual(tree.nodes[0].nodes[0].operator, "="); + t.deepEqual(tree.nodes[0].nodes[0].value, "#{$value}"); + }, +); + +// The `$` opening the name must not be mistaken for the suffix match operator, +// and the `$=` that follows must still parse as one. +test("sass variable name with a suffix match operator", '[$attr$="x"]', (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].attribute, "$attr"); + t.deepEqual(tree.nodes[0].nodes[0].operator, "$="); + t.deepEqual(tree.nodes[0].nodes[0].value, "x"); +}); + +test("namespaced sass variable attribute name", "[ns|$attr]", (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].namespace, "ns"); + t.deepEqual(tree.nodes[0].nodes[0].attribute, "$attr"); +}); + test("placeholder", "%foo", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].type, "tag"); t.deepEqual(tree.nodes[0].nodes[0].value, "%foo"); diff --git a/src/parser.js b/src/parser.js index bcec5ac..cd993e9 100644 --- a/src/parser.js +++ b/src/parser.js @@ -254,6 +254,22 @@ export default class Parser { } break; } + if ( + (lastAdded === "attribute" || !node.attribute) && + !(next && next[TOKEN.TYPE] === tokens.equals) + ) { + // A `$` that is not followed by `=` is part of the attribute name + // rather than the suffix match operator. Preprocessors emit these + // through interpolation, e.g. `[#{$var}]` in SCSS. + let oldRawAttribute = getProp(node, "raws", "attribute"); + node.attribute = (node.attribute || "") + "$"; + if (oldRawAttribute) { + node.raws.attribute = oldRawAttribute + "$"; + } + lastAdded = "attribute"; + spaceAfterMeaningfulToken = false; + break; + } // Falls through case tokens.caret: if (next[TOKEN.TYPE] === tokens.equals) {