Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 142 additions & 20 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,102 @@ function getIgnoreComment(node) {
}
}

// Parse @scope at-rule params into scope-start / scope-end clauses.
// Grammar: <scope-start>? (to <scope-end>)? 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 };
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it AI generated code? because I see a lot of rooms to improve

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ask Claude to simplify it and make faster, he knows how to do it


function normalizeNodeArray(nodes) {
const array = [];

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 &&
Expand All @@ -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") {
Expand Down
163 changes: 163 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
`,
},
];
Expand Down