From d91dc040371ff8348f36cacc9f9d8e293aa8934d Mon Sep 17 00:00:00 2001 From: Charles-Mathieu Seguin Date: Sun, 25 Oct 2026 11:56:36 -0700 Subject: [PATCH] fix(css): scope pseudo-only selectors, nesting, and deep functions - Scope pseudo-only compounds (`:hover`, `:is()`, `:where()`, `:not()`, `:has()`, `::before`, `::slotted()`), including leading compounds. - Recurse into rule blocks and re-parse `Raw` nested rules so explicit (`&`) and implicit CSS nesting is scoped, including inside at-rules. - Scope the `||` column combinator by splitting and scoping the Raw prelude parts. - Support the `:deep()` / `:v-deep()` function form. - Keep `:root` / `:scope` global, since the root element never receives the scope attribute. - Move all previously documented gaps into the supported matrix. Co-Authored-By: Claude --- src/css/css-features.spec.ts | 132 ++++++++---- src/css/scope-css.ts | 380 ++++++++++++++++++++++++++++++----- 2 files changed, 416 insertions(+), 96 deletions(-) diff --git a/src/css/css-features.spec.ts b/src/css/css-features.spec.ts index 9e406ab..1753152 100644 --- a/src/css/css-features.spec.ts +++ b/src/css/css-features.spec.ts @@ -480,22 +480,13 @@ const supported: FeatureCase[] = [ ".a { color: red; } .b { color: blue; }", ".a[data-123456] { color: red; } .b[data-123456] { color: blue; }", ], -]; - -/** - * Features that are parsed/serialized in a way the scoper cannot handle. - * These are marked `it.failing`: the suite stays green while the test - * documents the expected (correct) output. When a gap is fixed, the - * corresponding test starts failing, prompting us to move it to `supported`. - */ -const knownGaps: FeatureCase[] = [ // --------------------------------------------------------------------- - // Selectors made only of pseudo selectors: nothing receives the hash. + // Selectors made only of pseudo selectors // --------------------------------------------------------------------- [ - "standalone pseudo-class :hover", + "standalone pseudo-class", ":hover { color: red; }", - "[data-123456]:hover { color: red; }", + ":hover[data-123456] { color: red; }", ], [ "standalone functional :is()", @@ -518,24 +509,43 @@ const knownGaps: FeatureCase[] = [ ":has(.a)[data-123456] { color: red; }", ], [ - "standalone pseudo-element ::before", + "standalone pseudo-element", "::before { color: red; }", "[data-123456]::before { color: red; }", ], [ - "standalone pseudo-element ::slotted()", + "standalone ::slotted()", "::slotted(.a) { color: red; }", "[data-123456]::slotted(.a) { color: red; }", ], + [ + "scope attribute with a leading pseudo-element", + "*::before { color: red; }", + "*[data-123456]::before { color: red; }", + ], + [ + "pseudo-class then pseudo-element", + ".a:not(.b)::before { color: red; }", + ".a[data-123456]:not(.b)::before { color: red; }", + ], + [ + "document root pseudo-classes stay global", + ":root { --x: red; } :root:has(.a) { color: red; }", + ":root { --x: red; } :root:has(.a) { color: red; }", + ], + [ + "document root still scopes descendant subjects", + ":root .b { color: red; }", + ":root .b[data-123456] { color: red; }", + ], // --------------------------------------------------------------------- - // Leading pseudo-only compound in a complex selector: the first - // compound is skipped because no non-pseudo node registers a position. + // Leading pseudo-only compound in a complex selector // --------------------------------------------------------------------- [ "leading :hover compound", ":hover > .b { color: red; }", - "[data-123456]:hover > .b[data-123456] { color: red; }", + ":hover[data-123456] > .b[data-123456] { color: red; }", ], [ "leading :is() compound", @@ -554,8 +564,7 @@ const knownGaps: FeatureCase[] = [ ], // --------------------------------------------------------------------- - // CSS nesting. css-tree parses explicit `&` rules but the scoper does - // not descend into a rule's block; implicit nesting is parsed as Raw. + // CSS nesting // --------------------------------------------------------------------- [ "nesting with & descendant", @@ -567,15 +576,40 @@ const knownGaps: FeatureCase[] = [ ".a { & + .b { color: red; } }", ".a[data-123456] { & + .b[data-123456] { color: red; } }", ], + [ + "nesting with & and additional class", + ".a { &.foo { color: red; } }", + ".a[data-123456] { &.foo[data-123456] { color: red; } }", + ], + [ + "nesting with & and pseudo-class", + ".a { &:hover { color: red; } }", + ".a[data-123456] { &:hover { color: red; } }", + ], + [ + "nesting with parent selector at the end", + ".a { .b & { color: red; } }", + ".a[data-123456]{.b[data-123456] &{color:red}}", + ], [ "implicit descendant nesting", ".a { .b { color: red; } }", - ".a[data-123456] { .b[data-123456] { color: red; } }", + ".a[data-123456]{.b[data-123456]{color:red}}", ], [ "implicit child nesting", ".a { > .b { color: red; } }", - ".a[data-123456] { > .b[data-123456] { color: red; } }", + ".a[data-123456]{>.b[data-123456]{color:red}}", + ], + [ + "recursively nested rules", + ".a { .b { .c { color: red; } } }", + ".a[data-123456]{.b[data-123456]{.c[data-123456]{color:red}}}", + ], + [ + "recursively nested & rules", + ".a { & .b { & .c { color: red; } } }", + ".a[data-123456]{& .b[data-123456]{& .c[data-123456]{color:red}}}", ], [ "nesting with & inside @media", @@ -585,46 +619,60 @@ const knownGaps: FeatureCase[] = [ [ "implicit nesting inside @media", ".a { @media (min-width: 1px) { .b { color: red; } } }", - ".a[data-123456] { @media (min-width: 1px) { .b[data-123456] { color: red; } } }", + ".a[data-123456]{@media (min-width:1px){.b[data-123456]{color:red}}}", ], // --------------------------------------------------------------------- - // Selectors css-tree serializes as Raw are silently left global. + // Column combinator (serialized as Raw by css-tree) // --------------------------------------------------------------------- [ "column combinator ||", ".a || .b { color: red; }", ".a[data-123456] || .b[data-123456] { color: red; }", ], + [ + "multiple column combinators", + ".a || .b || .c { color: red; }", + ".a[data-123456] || .b[data-123456] || .c[data-123456] { color: red; }", + ], + [ + "column combinator mixed with other combinators", + ".a > .b || .c + .d { color: red; }", + ".a[data-123456]>.b[data-123456] || .c[data-123456]+.d[data-123456]{color:red}", + ], + [ + "column combinator with pseudo selectors", + ".a:hover || .b::before { color: red; }", + ".a[data-123456]:hover || .b[data-123456]::before { color: red; }", + ], // --------------------------------------------------------------------- - // Deep-selector forms not recognized by the scoper. + // :deep() function form // --------------------------------------------------------------------- [ ":deep() function form", ".a :deep(.b) { color: red; }", ".a[data-123456] .b { color: red; }", ], + [ + ":deep() with a complex argument", + ".a :deep(.b > .c) { color: red; }", + ".a[data-123456] .b > .c { color: red; }", + ], + [ + ":deep() with a selector list argument", + ".a :deep(.b, .c) { color: red; }", + ".a[data-123456] :is(.b, .c) { color: red; }", + ], + [ + "standalone :deep()", + ":deep(.a, .b) { color: red; }", + ":is(.a, .b) { color: red; }", + ], ]; describe("css feature support", () => { - describe("supported", () => { - supported.forEach(([name, input, expected]) => { - it(name, () => validateCss(input, expected)); - }); - }); - - describe("known gaps", () => { - knownGaps.forEach(([name, , expected]) => { - // Guard against a gap accidentally "passing" because the expected - // output itself is not valid CSS. - it(`expectation is valid css: ${name}`, () => { - expect(() => generate(parse(expected))).not.toThrow(); - }); - }); - - knownGaps.forEach(([name, input, expected]) => { - it.failing(name, () => validateCss(input, expected)); - }); + supported.forEach(([name, input, expected]) => { + it(name, () => validateCss(input, expected)); }); }); diff --git a/src/css/scope-css.ts b/src/css/scope-css.ts index f8eca5b..bc3af0c 100644 --- a/src/css/scope-css.ts +++ b/src/css/scope-css.ts @@ -4,8 +4,12 @@ import { AttributeSelector, StyleSheet, CssNode, - ListItem, List, + Raw, + Rule, + Selector, + PseudoClassSelector, + SelectorList, } from "css-tree"; /** @@ -22,7 +26,7 @@ interface LinkedList extends List { tail: LinkedListNode | null; } -type ItemArray = Array; +type Item = LinkedListNode; /** * Keyframe selectors (`from`, `to`, `50%`) are also parsed as `Rule` nodes with @@ -32,42 +36,109 @@ type ItemArray = Array; const isKeyframesAtRule = (name: string) => /^(-[a-z0-9]+-)?keyframes$/i.test(name); -const getSelectors = (items: List): LinkedList[] => { - const selectorLists: LinkedList[] = []; +const isScopePiercingPseudoSelector = (node: CssNode) => + node.type === "PseudoElementSelector" && + (node.name === "v-deep" || node.name === "deep"); - items.forEach((node) => { - if (node.type === "Rule" && node.prelude?.type === "SelectorList") { - selectorLists.push(node.prelude.children as LinkedList); - } else if ( - node.type === "Atrule" && - node.block && - !isKeyframesAtRule(node.name) - ) { - // Recurse into conditional/group at-rules (`@media`, `@container`, - // `@supports`, `@layer`, `@scope`, etc.) so their rules get scoped too. - selectorLists.push(...getSelectors(node.block.children)); +/** + * CSS-tree parses the contents of `:deep(...)` as a `Raw` node, unlike + * `:is()`/`:where()` which it parses as selectors. + */ +const isDeepFunctionSelector = (node: CssNode): node is PseudoClassSelector => + node.type === "PseudoClassSelector" && + (node.name === "deep" || node.name === "v-deep") && + !!node.children; + +/** + * Pseudo selectors that refer to the document root. They cannot be scoped + * because the root element never receives the scope attribute, so adding the + * attribute would only turn working rules into dead ones. + */ +const isRootLikePseudoSelector = (node: CssNode) => + node.type === "PseudoClassSelector" && + (node.name.toLowerCase() === "root" || node.name.toLowerCase() === "scope"); + +const isCombinator = (node: CssNode) => node.type === "Combinator"; + +const isNestingSelector = (node: CssNode) => node.type === "NestingSelector"; + +/** + * Simple selectors that can receive the scope attribute. `&` is excluded + * because the parent rule is already scoped, so `&[hash]` would be redundant. + */ +const isHashableSelector = (node: CssNode) => + node.type === "TypeSelector" || + node.type === "ClassSelector" || + node.type === "IdSelector" || + node.type === "AttributeSelector"; + +const getDeepFunctionSelectorText = ( + node: PseudoClassSelector, +): string | null => { + const children = node.children as List | null; + if (!children || children.isEmpty) { + return null; + } + + let text = ""; + children.forEach((child) => { + if (child.type === "Raw") { + text += child.value; } }); - return selectorLists; + return text || null; }; -const isScopePiercingPseudoSelector = (item: ListItem) => { - return ( - item?.data?.type === "PseudoElementSelector" && - (item?.data?.name === "v-deep" || item?.data?.name === "deep") - ); -}; +/** + * Splits a raw selector prelude on top-level `||` (column combinator) while + * ignoring `||` inside attribute selectors, functional pseudos, or strings. + */ +const splitOnColumnCombinator = (value: string): string[] => { + const parts: string[] = []; + let start = 0; + let parenthesisDepth = 0; + let bracketDepth = 0; + let quote: string | null = null; -const isPseudoSelector = (item: ListItem) => { - return ( - item?.data?.type === "PseudoClassSelector" || - item?.data?.type === "PseudoElementSelector" - ); -}; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; -const isCombinator = (item: ListItem) => { - return item?.data?.type === "Combinator"; + if (quote) { + if (char === "\\") { + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + } else if (char === "\\") { + index += 1; + } else if (char === "(") { + parenthesisDepth += 1; + } else if (char === ")") { + parenthesisDepth -= 1; + } else if (char === "[") { + bracketDepth += 1; + } else if (char === "]") { + bracketDepth -= 1; + } else if ( + char === "|" && + value[index + 1] === "|" && + parenthesisDepth === 0 && + bracketDepth === 0 + ) { + parts.push(value.slice(start, index)); + index += 1; + start = index + 1; + } + } + + parts.push(value.slice(start)); + return parts; }; export interface ScopedCssResult { @@ -83,7 +154,6 @@ export function scopeCss( ): ScopedCssResult { try { const ast = parse(css, { positions: true, filename: source }) as StyleSheet; - const selectorLists = getSelectors(ast.children); const attributeSelector: AttributeSelector = { type: "AttributeSelector", @@ -93,42 +163,244 @@ export function scopeCss( flags: null, }; - selectorLists.forEach((selectorList) => { - selectorList.forEach((selector) => { - if (selector.type !== "Selector") { + const scopeSelectorList = (selectorLists: LinkedList) => { + selectorLists.forEach((selector) => { + if (selector.type === "Selector") { + scopeSelector(selector as Selector); + } + }); + }; + + /** + * Inserts the scope attribute after every consecutive run of simple + * selectors. Pseudo selectors break a run, which preserves the previous + * behaviour of `a:hover.b` becoming `a[hash]:hover.b[hash]`. + */ + const scopeRuns = (items: Item[], selectorChildren: LinkedList) => { + let run: Item[] = []; + + const flushRun = () => { + if (run.length === 0) { return; } - let item = (selector.children as LinkedList).head; + const last = run[run.length - 1]; + if (last.next) { + selectorChildren.insertData(attributeSelector, last.next); + } else { + selectorChildren.appendData(attributeSelector); + } + run = []; + }; + + items.forEach((item) => { + if (isHashableSelector(item.data)) { + run.push(item); + } else { + flushRun(); + } + }); + + flushRun(); + }; + + const scopeCompound = (items: Item[], selectorChildren: LinkedList) => { + if (items.length === 0) { + return; + } + + if (items.some((item) => isHashableSelector(item.data))) { + scopeRuns(items, selectorChildren); + return; + } + + // `&` is already scoped through its parent rule. + if (items.some((item) => isNestingSelector(item.data))) { + return; + } + + // `:root` / `:scope` refer to the document root and can never match the + // scope attribute; leave them global. + if (items.some((item) => isRootLikePseudoSelector(item.data))) { + return; + } + + if (items.some((item) => item.data.type === "PseudoElementSelector")) { + // An attribute cannot follow a pseudo-element, so it has to be + // prepended: `::before` -> `[hash]::before`. + selectorChildren.insertData(attributeSelector, items[0]); + return; + } + + if (!items.some((item) => item.data.type === "PseudoClassSelector")) { + return; + } + + // `:hover` -> `:hover[hash]` + const last = items[items.length - 1]; + if (last.next) { + selectorChildren.insertData(attributeSelector, last.next); + } else { + selectorChildren.appendData(attributeSelector); + } + }; + + const replaceDeepFunction = (selectorChildren: LinkedList, item: Item) => { + const node = item.data as PseudoClassSelector; + const selectorText = getDeepFunctionSelectorText(node); + + if (!selectorText) { + return; + } + + let parsed: SelectorList; + try { + parsed = parse(selectorText, { + context: "selectorList", + }) as SelectorList; + } catch { + return; + } + + const selectors = parsed.children as List; + + if (selectors.size === 1) { + // Splice the argument selector in place of `:deep(...)`. + const replacement = new List(); + (selectors.first as Selector).children.forEach((child) => { + replacement.appendData(child); + }); + selectorChildren.replace(item, replacement); + } else { + // A selector list cannot be spliced into a single selector, but + // `:is(...)` has the same matching semantics here. + node.name = "is"; + node.children = new List(); + node.children.appendData(parsed); + } + }; + + const scopeSelector = (selector: Selector) => { + const selectorChildren = selector.children as LinkedList; + const compounds: Item[][] = []; + let compound: Item[] = []; + + for (let item = selectorChildren.head; item !== null; item = item.next) { + if (isCombinator(item.data)) { + compounds.push(compound); + compound = []; + } else { + compound.push(item); + } + } + compounds.push(compound); + + for (const items of compounds) { + const piercingIndex = items.findIndex( + (item) => + isScopePiercingPseudoSelector(item.data) || + isDeepFunctionSelector(item.data), + ); - const itemsToInsertScopeBefore: ItemArray = []; - let currentChunkIndex = 0; + if (piercingIndex !== -1) { + const piercingItem = items[piercingIndex]; - while (item !== null) { - if (isScopePiercingPseudoSelector(item)) { - Object.assign(item.data, attributeSelector); - break; - } else if (isCombinator(item) || isPseudoSelector(item)) { - currentChunkIndex += 1; - } else if (item.next) { - itemsToInsertScopeBefore[currentChunkIndex] = item.next; + if (isDeepFunctionSelector(piercingItem.data)) { + // Scope everything before `:deep(...)` in the same compound, + // then splice in the (unscoped) argument selector. + scopeRuns(items.slice(0, piercingIndex), selectorChildren); + replaceDeepFunction(selectorChildren, piercingItem); } else { - selector.children.appendData(attributeSelector); - break; + // Replace `::deep` / `::v-deep` with the scope attribute. + Object.assign(piercingItem.data, attributeSelector); } - item = item?.next ?? null; + + // Everything after a deep selector stays global. + return; } - // This is true if one of the two break statements above was run. Which means we don't want to add to the last one. - if (itemsToInsertScopeBefore[currentChunkIndex]) { - itemsToInsertScopeBefore.pop(); + scopeCompound(items, selectorChildren); + } + }; + + /** + * CSS-tree parses implicit nesting as a `Raw` node inside the parent + * rule. Re-parse it as a rule so its selector can be scoped and write the + * transformed rule back. + */ + const scopeRawNestedRule = (node: Raw) => { + if (!node.value.includes("{")) { + return; + } + + try { + const rule = parse(node.value, { context: "rule" }) as Rule; + + if (rule.type !== "Rule") { + return; } - itemsToInsertScopeBefore.forEach((scopePositionItem) => { - selector.children.insertData(attributeSelector, scopePositionItem); + scopeRule(rule); + node.value = generate(rule); + } catch { + // Leave constructs css-tree cannot parse untouched. + } + }; + + const scopeRawSelectorPrelude = (prelude: Raw) => { + const parts = splitOnColumnCombinator(prelude.value); + + if (parts.length === 1) { + return; + } + + try { + const scopedParts = parts.map((part) => { + const selectorList = parse(part.trim(), { + context: "selectorList", + }) as SelectorList; + scopeSelectorList(selectorList.children as LinkedList); + return generate(selectorList); }); + + prelude.value = scopedParts.join(" || "); + } catch { + // Leave constructs css-tree cannot parse untouched. + } + }; + + const scopeNodes = (children: List, insideRule: boolean) => { + children.forEach((node) => { + if (node.type === "Rule") { + scopeRule(node); + } else if ( + node.type === "Atrule" && + node.block && + !isKeyframesAtRule(node.name) + ) { + // Recurse into conditional/group at-rules (`@media`, `@container`, + // `@supports`, `@layer`, `@scope`, etc.) so their rules get scoped. + scopeNodes(node.block.children, insideRule); + } else if (insideRule && node.type === "Raw") { + scopeRawNestedRule(node); + } }); - }); + }; + + const scopeRule = (rule: Rule) => { + if (rule.prelude?.type === "SelectorList") { + scopeSelectorList(rule.prelude.children as LinkedList); + } else if (rule.prelude?.type === "Raw") { + scopeRawSelectorPrelude(rule.prelude); + } + + if (rule.block) { + // Nested rules (`& ...`) and nested at-rules live in the rule block. + scopeNodes(rule.block.children, true); + } + }; + + scopeNodes(ast.children, false); const { css: scopedCss, map } = generate(ast, { sourceMap: true,