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
130 changes: 81 additions & 49 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair, thanks for comment - I've been aimed (and aimed Claude) at spec compliance firsthand, not primitives like postcss-value-parser reuse.

Optimized in 988304c

Tried to benchmark, and the difference is very subtle between all versions, starting with pre-commits. With 5000 iterations run, it's within few nanosecs per run (some paths are 25-26 usec, some paths are 100-102 usec). So there was almost no degradation in performance initially - and sadly, I was unable to improve it more. So, only did code quality improves.

If you have some specific code style preferences (e.g. use switch in parseScopeParams) I can do it, just need an advice on preferred shape here.


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

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

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