diff --git a/src/index.js b/src/index.js index ba0c4f7..e6c1198 100644 --- a/src/index.js +++ b/src/index.js @@ -38,6 +38,40 @@ function getIgnoreComment(node) { } } +// 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 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] + ); + + 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); + + 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) { const array = []; @@ -561,7 +595,7 @@ module.exports = (options = {}) => { const localAliasMap = new Map(); return { - Once(root) { + Once(root, { result }) { const { icssImports } = extractICSS(root, false); const enforcePureMode = pureMode && !isPureCheckDisabled(root); @@ -613,62 +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; + } - atRule.params = atRule.params - .split("to") - .map((item) => { - const selector = item.trim().slice(1, -1).trim(); - const context = localizeNode( - selector, - options.mode, - localAliasMap + 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 ( + 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})`; - }) - .join(" to "); + } + return context.selector; + }; + atRule.params = [ + parsed.start !== null && `(${localize(parsed.start)})`, + parsed.end !== null && `to (${localize(parsed.end)})`, + ] + .filter(Boolean) + .join(" "); } + } + // 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, - global: globalMode, - }); - } - }); - } else if (atRule.nodes) { - atRule.nodes.forEach((declaration) => { - if (declaration.type === "decl") { - localizeDeclaration(declaration, { - localAliasMap, - options: options, + options, global: globalMode, }); } 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; + } +} `, }, ];