Skip to content

Commit 818cb40

Browse files
committed
fix(nexu-io#6250): traverse at-rule bodies + preserve nested token refs
PerishCode CHANGES_REQUESTED on PR nexu-io#6250 — two scanner fixes: 1. Traverse supported at-rule bodies (nexu-io#6250 reviewer #1) stripContainerAtRuleHeaders rewrites @media/@supports/@container/ @layer headers to '{', leaving an empty selector that swallowed the entire at-rule body as one rule and silently dropped every inner selector. iterateCssRules now recurses into the body slice on the empty-selector branch so inner rules surface with their real selectors and real bodies. extractCssSelectors reuses the same scanner so manifest.selectors matches manifest.groups[].selectors instead of falling back to the legacy regex that lost every selector immediately inside an at-rule. 2. Preserve nested-rule token references (nexu-io#6250 reviewer #2) flattenNestedBody previously tracked depth and only emitted declarations at depth === 0, which dropped inner-inner declarations two or more levels deep. The flatten now strips only the brace characters themselves and keeps every declaration body, so var(--token) references inside '& .child { & .grand { ... } }' attribute to the outermost ancestor instead of vanishing. Tests: 2 new regression cases in components-manifest-6224.test.ts covering at-rule body traversal (@media/@supports/@layer) and multi-level nested token preservation (vitest 5/5). 251 contracts package tests still pass. typecheck clean.
1 parent c6fab6d commit 818cb40

3 files changed

Lines changed: 205 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Fix components manifest extractor losing every other flat CSS rule because the legacy `(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{([^{}]*)\}` regex consumed each rule's closing `}` as the next rule's `[{}]` anchor. Replaced with a brace-depth scanner that also flattens one level of CSS nesting so tokens referenced inside `&:hover { ... }` attribute to the parent selector instead of leaking into a synthesised pseudo-selector. (#6224)
1313
- Anchor `classMatchers` to `^name(?:$|-)` so prefix-sharing classnames like `.navbar-button-thing`, `.mystatus`, `.platform-form` no longer cross-group via substring leakage. (#6224)
14+
- Fix components manifest extractor dropping selectors inside supported at-rule bodies (`@media` / `@supports` / `@container` / `@layer`). `stripContainerAtRuleHeaders` rewrites the at-rule header to `{`, and the brace-depth scanner now recurses into the resulting body slice so inner rules surface with their real selectors and token attribution preserved. `extractCssSelectors` reuses the same scanner so `manifest.selectors` matches `manifest.groups[].selectors` instead of falling back to a regex that lost every selector immediately inside an at-rule. (#6250)
15+
- Fix components manifest extractor losing token references declared inside nested-rule blocks two or more levels deep. `flattenNestedBody` now strips only the `{` / `}` brace characters and keeps every declaration body, so `var(--token)` references inside `& .child { & .grand { background: var(--c) } }` attribute to the outermost ancestor instead of being dropped. (#6250)
1416

1517
## [0.9.0] - 2026-05-29
1618

packages/contracts/src/design-systems/components-manifest.ts

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -260,16 +260,29 @@ function extractStyleBlocks(html: string): string[] {
260260
function extractCssSelectors(css: string): string[] {
261261
const selectors = new Set<string>();
262262
const commentlessCss = stripContainerAtRuleHeaders(stripCssComments(css));
263-
const selectorPattern = /(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{/g;
264-
let match: RegExpExecArray | null;
265263

266-
while ((match = selectorPattern.exec(commentlessCss)) !== null) {
267-
const rawSelectorList = match[1]?.trim();
268-
if (rawSelectorList == null || rawSelectorList.length === 0) continue;
269-
if (rawSelectorList.includes(':root')) continue;
270-
if (/^(?:from|to|\d+(?:\.\d+)?%)$/i.test(rawSelectorList)) continue;
264+
// The legacy `(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{` regex anchored each
265+
// rule at the *previous* rule's closing `}`, so half the flat rules in
266+
// a sheet were silently dropped (#6224 part 1). It also mis-handled
267+
// supported at-rule bodies: after `stripContainerAtRuleHeaders` turns
268+
// `@media ... {` into `{`, the regex sees `{ \n .inside { ... }` and
269+
// the bare `[^@{}]` exclusion rejects the inner selector — silent loss
270+
// of every selector immediately inside an at-rule (#6250 reviewer #1).
271+
//
272+
// Reuse the brace-depth scanner that already powers
273+
// `extractSelectorTokenReferences` — it walks the CSS character-by-character
274+
// and recursively descends into supported at-rule bodies so inner
275+
// selectors surface with their real selectors preserved.
276+
for (const { selectorList } of iterateCssRules(commentlessCss)) {
277+
if (selectorList.length === 0) continue;
278+
if (selectorList.includes(':root')) continue;
279+
if (/^(?:from|to|\d+(?:\.\d+)?%)$/i.test(selectorList)) continue;
280+
// Skip supported at-rule headers — they survived the strip pass because
281+
// the body wasn't processed by stripContainerAtRuleHeaders (e.g. nested
282+
// recursion landed on `@media`). At-rules never contribute a *selector*.
283+
if (selectorList.startsWith('@')) continue;
271284

272-
for (const selector of splitSelectorList(rawSelectorList)) {
285+
for (const selector of splitSelectorList(selectorList)) {
273286
const normalized = normalizeSelector(selector);
274287
if (normalized.length > 0 && !normalized.startsWith('@')) {
275288
selectors.add(normalized);
@@ -375,9 +388,29 @@ function iterateCssRules(css: string): CssRule[] {
375388
// Extract outer-body declarations, stripping nested `{ ... }` blocks
376389
// (their inner declarations are folded in via a recursive flatten so
377390
// tokens referenced inside `&:hover { background: var(--x) }` count
378-
// toward `.parent`).
379-
const body = flattenNestedBody(css.slice(bodyStart, bodyEnd));
380-
if (selectorList.length > 0) {
391+
// toward `.parent`). flattenNestedBody recursively folds nested-block
392+
// inner declarations — including declarations nested *two or more*
393+
// levels deep — so `& .child { & .grand { background: var(--c) } }`
394+
// contributes --c to the outermost ancestor (PR #6250 reviewer #2).
395+
const bodySlice = css.slice(bodyStart, bodyEnd);
396+
const body = flattenNestedBody(bodySlice);
397+
398+
if (selectorList.length === 0) {
399+
// Empty selector — the at-rule header (`@media`/`@supports`/
400+
// `@container`/`@layer`) was stripped to '{' by
401+
// stripContainerAtRuleHeaders before this scanner ran. The outermost
402+
// `{` after stripping opens an empty selector; we must NOT push it as
403+
// a rule (that would swallow the whole at-rule body as one giant
404+
// "rule" and lose every inner selector). Instead recurse into the
405+
// body slice so inner rules are emitted with their real selectors
406+
// and real bodies (PR #6250 reviewer #1).
407+
rules.push(...iterateCssRules(bodySlice));
408+
} else if (!selectorList.startsWith('@') || isSupportedAtRuleHeader(selectorList)) {
409+
// Ordinary selector, or a supported at-rule header that survived the
410+
// strip pass (e.g. recursive call landed on `@media` whose header was
411+
// not stripped because the parent body wasn't processed by
412+
// stripContainerAtRuleHeaders). Push it as-is; the body still has
413+
// nested-block declarations flattened into it.
381414
rules.push({ selectorList, body });
382415
}
383416
index = bodyEnd + 1;
@@ -389,15 +422,33 @@ function iterateCssRules(css: string): CssRule[] {
389422
return rules;
390423
}
391424

425+
// Supported at-rule headers whose bodies contain ordinary CSS rules whose
426+
// selectors + bodies must be enumerated (PR #6250 reviewer #1). Other
427+
// at-rules (@keyframes, @font-face, @page, @import, @namespace, @charset)
428+
// have bodies that are NOT ordinary rule trees — we treat them as opaque
429+
// declaration blocks and do not descend into them via the conditional
430+
// recursion in iterateCssRules.
431+
function isSupportedAtRuleHeader(selectorList: string): boolean {
432+
return /^@(?:media|supports|container|layer)\b/i.test(selectorList);
433+
}
434+
392435
function flattenNestedBody(bodySlice: string): string {
393436
// For `.parent { color: var(--a); &:hover { background: var(--b); } }` we
394437
// receive the inner slice `color: var(--a); &:hover { background: var(--b); } `
395438
// and want to emit `color: var(--a); background: var(--b); ` so both tokens
396439
// attribute to `.parent`. We strip the nested `{ ... }` wrapper but keep
397440
// its inner declarations, dropping the `&:hover` selector prefix — the
398441
// parent already owns the tokens.
442+
//
443+
// The flatten is *recursive*: declarations nested two or more levels
444+
// deep (`& .child { & .grand { background: var(--c) } }`) still fold
445+
// into the outermost ancestor, so the inner-inner `var(--c)` is counted
446+
// for `.parent` rather than dropped (PR #6250 reviewer #2). We walk the
447+
// body slice, dropping only the `{` / `}` brace characters themselves
448+
// and the nested-rule *selector prefix* between `{` and the next `{`/`;`
449+
// — but keeping every declaration body so all `var(--token)` references
450+
// at every depth survive on the outermost rule.
399451
let out = '';
400-
let depth = 0;
401452
let cursor = 0;
402453
const length = bodySlice.length;
403454
while (cursor < length) {
@@ -407,19 +458,17 @@ function flattenNestedBody(bodySlice: string): string {
407458
cursor = closeIdx === -1 ? length : closeIdx + 2;
408459
continue;
409460
}
410-
if (char === '{') {
411-
depth += 1;
412-
cursor += 1;
413-
continue;
414-
}
415-
if (char === '}') {
416-
depth = Math.max(0, depth - 1);
461+
if (char === '{' || char === '}') {
462+
// Drop the brace; keep scanning. We deliberately do NOT skip the
463+
// nested-rule selector prefix between '{' and the next declaration
464+
// — that prefix (e.g. `&:hover`) is just text with no `var(--token)`
465+
// references, and if it did contain a var() we'd want to surface it
466+
// on the outer selector anyway (rare in practice). Stripping only
467+
// the braces gives us full-depth folding with O(n) cost.
417468
cursor += 1;
418469
continue;
419470
}
420-
if (depth === 0) {
421-
out += char;
422-
}
471+
out += char;
423472
cursor += 1;
424473
}
425474
return out;

packages/contracts/tests/components-manifest-6224.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,136 @@ describe('components manifest extraction (#6224 regression suite)', () => {
104104
// `/^form(?:$|-)/i` rejects it.
105105
expect(inputsClasses).not.toContain('platform-form');
106106
});
107+
108+
it('keeps traversing supported at-rule bodies for token attribution (#6250 reviewer #1)', () => {
109+
// PerishCode CHANGES_REQUESTED on PR #6250:
110+
// "keep traversing supported at-rule bodies for token attribution"
111+
//
112+
// The current scanner replaces `@media ... {` with `{` (via
113+
// stripContainerAtRuleHeaders) and then treats everything inside the
114+
// at-rule as one giant body of the *empty* selector that opened the
115+
// at-rule. Rules inside `@media`/`@supports`/`@container`/`@layer`
116+
// therefore lose their selector + token attribution — every selector
117+
// inside `@media (min-width: 600px) { .inside-media { color: var(--bg) } }`
118+
// is silently dropped, and the inner rule's token references are lost entirely.
119+
//
120+
// The fix: the scanner must descend into supported at-rule bodies and
121+
// emit the inner rules with their real selectors preserved.
122+
//
123+
// We use `.btn-*` selectors so the inner rules land in the buttons group,
124+
// where we can assert both the selector survival and token attribution.
125+
const manifest = extractComponentsManifest({
126+
brandId: 'at-rule',
127+
tokensCss: ':root { --bg: #fff; --fg: #000; }',
128+
fixtureHtml: `
129+
<style>
130+
.btn-outside { color: var(--fg); }
131+
@media (min-width: 600px) {
132+
.btn-inside-media { background: var(--bg); }
133+
.btn-inside-media-two { border-color: var(--fg); }
134+
}
135+
@supports (display: grid) {
136+
.btn-inside-supports { color: var(--fg); }
137+
}
138+
@layer framework {
139+
.btn-inside-layer { background: var(--bg); }
140+
}
141+
</style>
142+
<button class="btn-outside btn-inside-media btn-inside-media-two btn-inside-supports btn-inside-layer">x</button>
143+
`,
144+
});
145+
146+
expect(manifest.selectors).toEqual(
147+
expect.arrayContaining([
148+
'.btn-outside',
149+
'.btn-inside-media',
150+
'.btn-inside-media-two',
151+
'.btn-inside-supports',
152+
'.btn-inside-layer',
153+
]),
154+
);
155+
// Inner rules keep their token attribution, not lost to the at-rule wrapper.
156+
const buttonsGroup = manifest.groups.find((g) => g.id === 'buttons');
157+
expect(buttonsGroup?.selectors).toEqual(
158+
expect.arrayContaining([
159+
'.btn-outside',
160+
'.btn-inside-media',
161+
'.btn-inside-media-two',
162+
'.btn-inside-supports',
163+
'.btn-inside-layer',
164+
]),
165+
);
166+
// .btn-inside-media carries --bg, .btn-inside-media-two carries --fg.
167+
expect(buttonsGroup?.tokenReferences).toEqual(
168+
expect.arrayContaining(['--bg', '--fg']),
169+
);
170+
});
171+
172+
it('preserves nested-rule token references so the parent group receives outer and nested tokens (#6250 reviewer #2)', () => {
173+
// PerishCode CHANGES_REQUESTED on PR #6250:
174+
// "preserve nested-rule token references so the parent group receives
175+
// both outer and nested tokens"
176+
//
177+
// When a rule has BOTH outer declarations AND a nested block, the
178+
// resulting parent-group entry must include both the outer-body tokens
179+
// AND the nested-block tokens (no silent loss of nested-only tokens).
180+
// The minimal failing case: `.btn-parent { color: var(--outer); &:focus { background: var(--inner); } }`.
181+
// We use `.btn-parent` so the selector lands in the buttons group, where
182+
// we can assert both tokens end up on the same group entry.
183+
const manifest = extractComponentsManifest({
184+
brandId: 'nested-both',
185+
tokensCss: ':root { --outer: red; --inner: blue; }',
186+
fixtureHtml: `
187+
<style>
188+
.btn-parent {
189+
color: var(--outer);
190+
&:focus {
191+
background: var(--inner);
192+
}
193+
}
194+
</style>
195+
<button class="btn-parent">x</button>
196+
`,
197+
});
198+
199+
expect(manifest.selectors).toEqual(['.btn-parent']);
200+
const buttonsGroup = manifest.groups.find((g) => g.id === 'buttons');
201+
expect(buttonsGroup?.selectors).toContain('.btn-parent');
202+
expect(buttonsGroup?.tokenReferences).toEqual(
203+
expect.arrayContaining(['--inner', '--outer']),
204+
);
205+
206+
// Deeper nesting: an `.btn-ancestor` rule wraps two levels of CSS nesting.
207+
// All three tokens (--a / --b / --c) must end up on the buttons group,
208+
// proving nested-rule token references are preserved at every depth —
209+
// not just the first nesting level.
210+
const deep = extractComponentsManifest({
211+
brandId: 'nested-deep',
212+
tokensCss: ':root { --a: red; --b: green; --c: blue; }',
213+
fixtureHtml: `
214+
<style>
215+
.btn-ancestor {
216+
color: var(--a);
217+
& .descendant {
218+
color: var(--b);
219+
& .granddesc {
220+
background: var(--c);
221+
}
222+
}
223+
}
224+
</style>
225+
<button class="btn-ancestor">
226+
<span class="descendant">
227+
<span class="granddesc">x</span>
228+
</span>
229+
</button>
230+
`,
231+
});
232+
expect(deep.selectors).toContain('.btn-ancestor');
233+
const deepButtons = deep.groups.find((g) => g.id === 'buttons');
234+
expect(deepButtons?.selectors).toContain('.btn-ancestor');
235+
expect(deepButtons?.tokenReferences).toEqual(
236+
expect.arrayContaining(['--a', '--b', '--c']),
237+
);
238+
});
107239
});

0 commit comments

Comments
 (0)