diff --git a/src/index.js b/src/index.js index ba0c4f7..0e9719d 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,21 +749,42 @@ 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) => { - 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 ed4bb0f..3f62d5a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2054,6 +2054,169 @@ 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; + } +} +`, + }, + // 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)", + 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; + } +} `, }, ];