From e4f1ae68fda6adef6b54695cc77ff5b589c8fd10 Mon Sep 17 00:00:00 2001 From: Merkle Bonsai Date: Wed, 29 Apr 2026 23:52:22 +0700 Subject: [PATCH 1/3] fix: parse @scope params with paren-aware tokenizer --- src/index.js | 139 ++++++++++++++++++++++++++++++++++++---- test/index.test.js | 154 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 11 deletions(-) diff --git a/src/index.js b/src/index.js index ba0c4f7..e3ada78 100644 --- a/src/index.js +++ b/src/index.js @@ -38,6 +38,102 @@ function getIgnoreComment(node) { } } +// Parse @scope at-rule params into scope-start / scope-end clauses. +// Grammar: ? (to )? where each clause is "(...)". +// Tracks paren depth, string literals, CSS comments, and identifier escapes +// so the "to" keyword is detected at the structural position rather than +// matched as a substring. Returns null on unparseable input. Fixes #90. +function parseScopeParams(params) { + const len = params.length; + let i = 0; + + const skipWs = () => { + while (i < len) { + if (/\s/.test(params[i])) { + i++; + } else if (params[i] === "/" && params[i + 1] === "*") { + const close = params.indexOf("*/", i + 2); + if (close === -1) return false; + i = close + 2; + } else { + return true; + } + } + return true; + }; + + // Read inner contents of `(...)` at position i; advance i past `)`. + // Tracks paren depth, string literals (with backslash escapes), CSS + // comments, and CSS ident escapes (`\(`, `\)`, etc.). + const readParens = () => { + if (params[i] !== "(") return null; + let depth = 1; + let j = i + 1; + let inStr = null; + while (j < len && depth > 0) { + const c = params[j]; + if (inStr) { + if (c === "\\") { + j += 2; // skip the escaped char (incl. closing quote, newline, etc.) + continue; + } + if (c === inStr) inStr = null; + j++; + } else if (c === "\\") { + j += 2; // CSS ident escape — the next char is literal, ignore parens + } else if (c === "/" && params[j + 1] === "*") { + const close = params.indexOf("*/", j + 2); + if (close === -1) return null; + j = close + 2; + } else if (c === '"' || c === "'") { + inStr = c; + j++; + } else if (c === "(") { + depth++; + j++; + } else if (c === ")") { + depth--; + j++; + } else { + j++; + } + } + if (depth !== 0) return null; + const inner = params.slice(i + 1, j - 1); + i = j; + return inner; + }; + + if (!skipWs()) return null; + let start = null; + let end = null; + + if (params[i] === "(") { + start = readParens(); + if (start === null) return null; + if (!skipWs()) return null; + } + + if (i < len) { + // Expect "to" keyword (case-insensitive per CSS) followed by `(scope-end)`. + if (params.slice(i, i + 2).toLowerCase() !== "to") return null; + // Boundary check: the char after "to" must be whitespace, "(", or + // start-of-comment. Empty fallback is safe: if "to" is at end-of-input + // with no trailing context, the subsequent paren check rejects it. + const next = i + 2 < len ? params[i + 2] : ""; + if (next !== "" && !/\s|\(/.test(next) && next !== "/") return null; + i += 2; + if (!skipWs()) return null; + if (params[i] !== "(") return null; + end = readParens(); + if (end === null) return null; + if (!skipWs()) return null; + if (i < len) return null; // trailing garbage + } + + return { start, end }; +} + function normalizeNodeArray(nodes) { const array = []; @@ -561,7 +657,7 @@ module.exports = (options = {}) => { const localAliasMap = new Map(); return { - Once(root) { + Once(root, { result }) { const { icssImports } = extractICSS(root, false); const enforcePureMode = pureMode && !isPureCheckDisabled(root); @@ -623,19 +719,24 @@ module.exports = (options = {}) => { ignoreComment.remove(); } - atRule.params = atRule.params - .split("to") - .map((item) => { - const selector = item.trim().slice(1, -1).trim(); + const parsed = parseScopeParams(atRule.params); + if (!parsed) { + atRule.warn( + result, + "Could not parse @scope params; selectors will not be " + + "localized for this rule. Params: " + + JSON.stringify(atRule.params) + ); + } + if (parsed) { + const localizeSelector = (selector) => { const context = localizeNode( selector, options.mode, localAliasMap ); - context.options = options; context.localAliasMap = localAliasMap; - if ( enforcePureMode && context.hasPureGlobals && @@ -648,10 +749,26 @@ module.exports = (options = {}) => { "(pure selectors must contain at least one local class or id)" ); } - - return `(${context.selector})`; - }) - .join(" to "); + return context.selector; + }; + + const start = + parsed.start !== null + ? localizeSelector(parsed.start.trim()) + : null; + const end = + parsed.end !== null + ? localizeSelector(parsed.end.trim()) + : null; + + if (start !== null && end !== null) { + atRule.params = `(${start}) to (${end})`; + } else if (start !== null) { + atRule.params = `(${start})`; + } else if (end !== null) { + atRule.params = `to (${end})`; + } + } } atRule.nodes.forEach((declaration) => { diff --git a/test/index.test.js b/test/index.test.js index ed4bb0f..488e521 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2054,6 +2054,160 @@ html { color: red; } } +`, + }, + // ── Regression: #90 — class names containing the substring "to" ──── + // Previously `params.split("to")` truncated any class name containing + // "to" (like "button" containing "bu" + "to" + "n"), producing + // malformed output with extra `to ()` clauses and partial class names. + { + name: "@scope at-rule — class name contains 'to' substring (#90)", + input: ` +@scope (.button) to (.toolbar) { + .button { + color: red; + } +} +`, + expected: ` +@scope (:local(.button)) to (:local(.toolbar)) { + :local(.button) { + color: red; + } +} +`, + }, + { + name: "@scope at-rule — multiple classes with 'to' substring (#90)", + input: ` +@scope (.photo-tile) to (.tooltip, .stockton) { + .into-view { + color: red; + } +} +`, + expected: ` +@scope (:local(.photo-tile)) to (:local(.tooltip), :local(.stockton)) { + :local(.into-view) { + color: red; + } +} +`, + }, + { + name: "@scope at-rule — attribute selector value contains 'to' (#90)", + input: ` +@scope ([data-section="footer"]) to ([role="button"]) { + .root { + color: red; + } +} +`, + expected: ` +@scope ([data-section="footer"]) to ([role="button"]) { + :local(.root) { + color: red; + } +} +`, + }, + { + name: "@scope at-rule — bare class with 'to' inside but no scope-end (#90)", + input: ` +@scope (.tooltip) { + .body { + color: red; + } +} +`, + expected: ` +@scope (:local(.tooltip)) { + :local(.body) { + color: red; + } +} +`, + }, + // CSS comments inside `@scope` params can contain unbalanced parens, + // a literal `to`, or both. Naive paren-depth counting would miscount; + // the parser must skip `/* ... */` regions when walking selector text. + { + name: "@scope at-rule — CSS comment containing 'to' and parens (#90)", + input: ` +@scope (.foo /* hi ) to ( bye */) to (.bar) { + .body { + color: red; + } +} +`, + expected: ` +@scope (:local(.foo)) to (:local(.bar)) { + :local(.body) { + color: red; + } +} +`, + }, + // CSS identifier escapes — `\(` and `\)` are legal in identifiers + // (e.g. CSS-in-JS tools sometimes emit them). The parser must treat + // backslash-escaped chars as literal so paren depth stays balanced. + { + name: "@scope at-rule — escaped paren in identifier (#90)", + input: ` +@scope (.foo\\(bar) to (.baz) { + .body { + color: red; + } +} +`, + expected: ` +@scope (:local(.foo\\(bar)) to (:local(.baz)) { + :local(.body) { + color: red; + } +} +`, + }, + // The `to` keyword is case-insensitive per CSS keyword rules. + { + name: "@scope at-rule — uppercase TO keyword (#90)", + input: ` +@scope (.foo) TO (.bar) { + .body { + color: red; + } +} +`, + expected: ` +@scope (:local(.foo)) to (:local(.bar)) { + :local(.body) { + color: red; + } +} +`, + }, + // Real-world `@scope` inputs use arbitrary functional-pseudo nesting: + // `:is()`, `:not()`, `:where()`, `:has()`, and full selector lists with + // commas. The parser separates scope-start from scope-end by matching + // the outermost paren pairs and the `to` keyword that appears between + // them at depth 0 — not by string-splitting. This case exercises that: + // both clauses contain colons, multiple parens, and the localizer must + // descend into each nested selector to localize the bare classes. + { + name: "@scope at-rule — nested :is()/:not() selectors", + input: ` +@scope (:is(.class:not(.another-class))) to (:not(:is(.class):not(.another-class))) { + .root { + color: red; + } +} +`, + expected: ` +@scope (:is(:local(.class):not(:local(.another-class)))) to (:not(:is(:local(.class)):not(:local(.another-class)))) { + :local(.root) { + color: red; + } +} `, }, ]; From 6b9c5a50005d9e8cf8fd10f1c4e1c213a670bc35 Mon Sep 17 00:00:00 2001 From: Merkle Bonsai Date: Wed, 29 Apr 2026 23:52:47 +0700 Subject: [PATCH 2/3] fix: prevent crash on body-less @scope at-rule --- src/index.js | 23 ++++++++++++++--------- test/index.test.js | 9 +++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/index.js b/src/index.js index e3ada78..0e9719d 100644 --- a/src/index.js +++ b/src/index.js @@ -771,15 +771,20 @@ module.exports = (options = {}) => { } } - atRule.nodes.forEach((declaration) => { - if (declaration.type === "decl") { - localizeDeclaration(declaration, { - localAliasMap, - options: options, - global: globalMode, - }); - } - }); + // Guard matches the non-scope branch below — body-less @scope + // at-rules (or postcss-misparsed inputs) have undefined .nodes; + // unconditional forEach crashes. + if (atRule.nodes) { + atRule.nodes.forEach((declaration) => { + if (declaration.type === "decl") { + localizeDeclaration(declaration, { + localAliasMap, + options: options, + global: globalMode, + }); + } + }); + } } else if (atRule.nodes) { atRule.nodes.forEach((declaration) => { if (declaration.type === "decl") { diff --git a/test/index.test.js b/test/index.test.js index 488e521..3f62d5a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2168,6 +2168,15 @@ html { } `, }, + // Body-less @scope at-rules (e.g. `@scope (.foo);`) have `atRule.nodes` + // === undefined; the unconditional `atRule.nodes.forEach(...)` in the + // @scope branch threw `Cannot read properties of undefined`. The + // non-scope at-rule branch has the same guard. + { + name: "@scope at-rule — body-less @scope no longer crashes", + input: `@scope (.foo);`, + expected: `@scope (:local(.foo));`, + }, // The `to` keyword is case-insensitive per CSS keyword rules. { name: "@scope at-rule — uppercase TO keyword (#90)", From 988304c3d76a7b53817c7994d2dfdc0092d44366 Mon Sep 17 00:00:00 2001 From: Merkle Bonsai Date: Sun, 3 May 2026 16:56:48 +0700 Subject: [PATCH 3/3] refactor: simplify @scope params handling Replace the hand-rolled tokenizer with a postcss-value-parser pass (already a dependency). Match three explicit grammar shapes that mirror the spec: (start), to (end), and (start) to (end). The `to(...)` form without whitespace, which value-parser tokenizes as a single function, is normalized into [to, (...)] upfront so all spacings collapse to one match. Also dedupe the at-rule body iteration that was identical between the @scope branch and the non-scope branch. Net: -93 LOC vs the prior @scope chunk, same end-to-end performance. --- src/index.js | 236 ++++++++++++++++----------------------------------- 1 file changed, 73 insertions(+), 163 deletions(-) diff --git a/src/index.js b/src/index.js index 0e9719d..e6c1198 100644 --- a/src/index.js +++ b/src/index.js @@ -38,100 +38,38 @@ function getIgnoreComment(node) { } } -// Parse @scope at-rule params into scope-start / scope-end clauses. -// Grammar: ? (to )? where each clause is "(...)". -// Tracks paren depth, string literals, CSS comments, and identifier escapes -// so the "to" keyword is detected at the structural position rather than -// matched as a substring. Returns null on unparseable input. Fixes #90. +// Parse `@scope (start)? (to (end))?` params (#90). Uses postcss-value-parser +// to tokenize parens, strings, escapes, and comments correctly. Quirk: `to(...)` +// with no whitespace parses as one function — we split it back so all spacings +// reduce to the same three grammar shapes. function parseScopeParams(params) { - const len = params.length; - let i = 0; - - const skipWs = () => { - while (i < len) { - if (/\s/.test(params[i])) { - i++; - } else if (params[i] === "/" && params[i + 1] === "*") { - const close = params.indexOf("*/", i + 2); - if (close === -1) return false; - i = close + 2; - } else { - return true; - } - } - return true; - }; - - // Read inner contents of `(...)` at position i; advance i past `)`. - // Tracks paren depth, string literals (with backslash escapes), CSS - // comments, and CSS ident escapes (`\(`, `\)`, etc.). - const readParens = () => { - if (params[i] !== "(") return null; - let depth = 1; - let j = i + 1; - let inStr = null; - while (j < len && depth > 0) { - const c = params[j]; - if (inStr) { - if (c === "\\") { - j += 2; // skip the escaped char (incl. closing quote, newline, etc.) - continue; - } - if (c === inStr) inStr = null; - j++; - } else if (c === "\\") { - j += 2; // CSS ident escape — the next char is literal, ignore parens - } else if (c === "/" && params[j + 1] === "*") { - const close = params.indexOf("*/", j + 2); - if (close === -1) return null; - j = close + 2; - } else if (c === '"' || c === "'") { - inStr = c; - j++; - } else if (c === "(") { - depth++; - j++; - } else if (c === ")") { - depth--; - j++; - } else { - j++; - } - } - if (depth !== 0) return null; - const inner = params.slice(i + 1, j - 1); - i = j; - return inner; - }; - - if (!skipWs()) return null; - let start = null; - let end = null; - - if (params[i] === "(") { - start = readParens(); - if (start === null) return null; - if (!skipWs()) return null; - } + const nodes = valueParser(params) + .nodes.filter((n) => n.type !== "space") + .flatMap((n) => + n.type === "function" && n.value.toLowerCase() === "to" + ? [ + { type: "word", value: "to" }, + { ...n, value: "" }, + ] + : [n] + ); - if (i < len) { - // Expect "to" keyword (case-insensitive per CSS) followed by `(scope-end)`. - if (params.slice(i, i + 2).toLowerCase() !== "to") return null; - // Boundary check: the char after "to" must be whitespace, "(", or - // start-of-comment. Empty fallback is safe: if "to" is at end-of-input - // with no trailing context, the subsequent paren check rejects it. - const next = i + 2 < len ? params[i + 2] : ""; - if (next !== "" && !/\s|\(/.test(next) && next !== "/") return null; - i += 2; - if (!skipWs()) return null; - if (params[i] !== "(") return null; - end = readParens(); - if (end === null) return null; - if (!skipWs()) return null; - if (i < len) return null; // trailing garbage - } + const isParen = (n) => n && n.type === "function" && n.value === ""; + const isTo = (n) => n && n.type === "word" && n.value.toLowerCase() === "to"; + const inner = (n) => valueParser.stringify(n.nodes); - return { start, end }; + if (nodes.length === 1 && isParen(nodes[0])) + return { start: inner(nodes[0]), end: null }; + if (nodes.length === 2 && isTo(nodes[0]) && isParen(nodes[1])) + return { start: null, end: inner(nodes[1]) }; + if ( + nodes.length === 3 && + isParen(nodes[0]) && + isTo(nodes[1]) && + isParen(nodes[2]) + ) + return { start: inner(nodes[0]), end: inner(nodes[2]) }; + return null; } function normalizeNodeArray(nodes) { @@ -709,88 +647,60 @@ module.exports = (options = {}) => { global: globalKeyframes, }); }); - } else if (/scope$/i.test(atRule.name)) { - if (atRule.params) { - const ignoreComment = pureMode - ? getIgnoreComment(atRule) - : undefined; - - if (ignoreComment) { - ignoreComment.remove(); - } + return; + } - const parsed = parseScopeParams(atRule.params); - if (!parsed) { - atRule.warn( - result, - "Could not parse @scope params; selectors will not be " + - "localized for this rule. Params: " + - JSON.stringify(atRule.params) + if (/scope$/i.test(atRule.name) && atRule.params) { + const ignoreComment = pureMode && getIgnoreComment(atRule); + if (ignoreComment) ignoreComment.remove(); + + const parsed = parseScopeParams(atRule.params); + if (!parsed) { + atRule.warn( + result, + `Could not parse @scope params; selectors will not be localized. Params: ${JSON.stringify( + atRule.params + )}` + ); + } else { + const localize = (selector) => { + const context = localizeNode( + selector.trim(), + options.mode, + localAliasMap ); - } - if (parsed) { - const localizeSelector = (selector) => { - const context = localizeNode( - selector, - options.mode, - localAliasMap + if ( + enforcePureMode && + context.hasPureGlobals && + !ignoreComment + ) { + throw atRule.error( + 'Selector in at-rule"' + + selector + + '" is not pure ' + + "(pure selectors must contain at least one local class or id)" ); - context.options = options; - context.localAliasMap = localAliasMap; - if ( - enforcePureMode && - context.hasPureGlobals && - !ignoreComment - ) { - throw atRule.error( - 'Selector in at-rule"' + - selector + - '" is not pure ' + - "(pure selectors must contain at least one local class or id)" - ); - } - return context.selector; - }; - - const start = - parsed.start !== null - ? localizeSelector(parsed.start.trim()) - : null; - const end = - parsed.end !== null - ? localizeSelector(parsed.end.trim()) - : null; - - if (start !== null && end !== null) { - atRule.params = `(${start}) to (${end})`; - } else if (start !== null) { - atRule.params = `(${start})`; - } else if (end !== null) { - atRule.params = `to (${end})`; } - } + return context.selector; + }; + atRule.params = [ + parsed.start !== null && `(${localize(parsed.start)})`, + parsed.end !== null && `to (${localize(parsed.end)})`, + ] + .filter(Boolean) + .join(" "); } + } - // Guard matches the non-scope branch below — body-less @scope - // at-rules (or postcss-misparsed inputs) have undefined .nodes; - // unconditional forEach crashes. - if (atRule.nodes) { - atRule.nodes.forEach((declaration) => { - if (declaration.type === "decl") { - localizeDeclaration(declaration, { - localAliasMap, - options: options, - global: globalMode, - }); - } - }); - } - } else if (atRule.nodes) { + // Localize decls in the at-rule body. Shallow on purpose — nested + // rules are picked up by walkRules below. Body-less at-rules + // (e.g. `@scope (.foo);`) have undefined .nodes. + if (atRule.nodes) { atRule.nodes.forEach((declaration) => { if (declaration.type === "decl") { localizeDeclaration(declaration, { localAliasMap, - options: options, + options, global: globalMode, }); }