Skip to content

Commit fe29a4d

Browse files
committed
fix(nexu-io#6250): preserve quoted braces in CSS values when flattening nesting
flattenNestedBody now tracks an inQuote state ('"'/"'/null) plus backslash escapes so that braces inside a quoted value such as `content: "}"` are preserved as data instead of being treated as rule terminators. This addresses reviewer feedback on PR nexu-io#6250 (round 3): without the state, the brace scanner could mistreat the character after `content: "}` as the closing delimiter of an outer rule, slicing the flattened body at the wrong position. Coverage: add a regression case asserting that a rule containing both a brace in a quoted content value and a real nested block still resolves its outer selector's token references correctly.
1 parent 818cb40 commit fe29a4d

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

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

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ function iterateCssRules(css: string): CssRule[] {
357357
let bodyStart = index;
358358
let bodyEnd = -1;
359359
let cursor = index;
360+
// Track the open CSS quote (single or double) so braces inside a
361+
// quoted value such as `content: "}"` are not mistaken for the rule
362+
// terminator (PR #6250 reviewer #3). null = outside any quoted string.
363+
let inQuote: '"' | "'" | null = null;
360364
while (cursor < length) {
361365
const char = css[cursor];
362366
if (char === '/' && css[cursor + 1] === '*') {
@@ -368,6 +372,32 @@ function iterateCssRules(css: string): CssRule[] {
368372
cursor = closeIdx === -1 ? length : closeIdx + 2;
369373
continue;
370374
}
375+
// Treat braces inside quoted CSS values as data, not rule delimiters
376+
// (PR #6250 reviewer #3). Track single / double quote state and
377+
// ignore `{` / `}` that appear between a pair of quotes. Handle
378+
// escaped quotes \" and \' so a `}` preceded by \" doesn't escape
379+
// the quoted context.
380+
if (inQuote === '"' || inQuote === "'") {
381+
if (char === '\\') {
382+
// Skip the escaped character (could be \" or \' or any escape).
383+
cursor += 2;
384+
continue;
385+
}
386+
if (char === inQuote) {
387+
inQuote = null;
388+
cursor += 1;
389+
continue;
390+
}
391+
// Inside a quoted string: braces are just text, skip without
392+
// affecting depth.
393+
cursor += 1;
394+
continue;
395+
}
396+
if (char === '"' || char === "'") {
397+
inQuote = char;
398+
cursor += 1;
399+
continue;
400+
}
371401
if (char === '{') {
372402
depth += 1;
373403
} else if (char === '}') {
@@ -448,16 +478,53 @@ function flattenNestedBody(bodySlice: string): string {
448478
// and the nested-rule *selector prefix* between `{` and the next `{`/`;`
449479
// — but keeping every declaration body so all `var(--token)` references
450480
// at every depth survive on the outermost rule.
481+
//
482+
// Quote/escape state (PR #6250 reviewer #3): braces inside a quoted
483+
// CSS value (`content: "}"`) are data, not rule delimiters. We preserve
484+
// the quoted text character-by-character into the output so downstream
485+
// token-reference scanning still sees the full declaration, but we do
486+
// NOT treat the quoted `{` / `}` as braces to strip. Escapes `\"` and
487+
// `\'` skip the next character so they don't terminate the quoted
488+
// context prematurely.
451489
let out = '';
452490
let cursor = 0;
453491
const length = bodySlice.length;
492+
let inQuote: '"' | "'" | null = null;
454493
while (cursor < length) {
455494
const char = bodySlice[cursor];
456-
if (char === '/' && bodySlice[cursor + 1] === '*') {
495+
// Comment handling: only active outside quoted strings. Comments inside
496+
// quoted strings are part of the data and should be preserved verbatim.
497+
if (inQuote === null && char === '/' && bodySlice[cursor + 1] === '*') {
457498
const closeIdx = bodySlice.indexOf('*/', cursor + 2);
458499
cursor = closeIdx === -1 ? length : closeIdx + 2;
459500
continue;
460501
}
502+
if (inQuote !== null) {
503+
// Inside a quoted value. Preserve the character verbatim — quoted
504+
// text is data, and downstream token-reference scanning needs the
505+
// full original value (e.g. `content: "}"` stays intact). Handle
506+
// backslash escapes so a quoted `}"` doesn't exit the quote early.
507+
if (char === '\\') {
508+
out += bodySlice.slice(cursor, cursor + 2);
509+
cursor += 2;
510+
continue;
511+
}
512+
out += char;
513+
if (char === inQuote) {
514+
inQuote = null;
515+
}
516+
cursor += 1;
517+
continue;
518+
}
519+
if (char === '"' || char === "'") {
520+
inQuote = char;
521+
// Preserve the opening quote in `out` so the flattened body still
522+
// carries the full quoted value (the closing quote is preserved by
523+
// the inQuote !== null branch above).
524+
out += char;
525+
cursor += 1;
526+
continue;
527+
}
461528
if (char === '{' || char === '}') {
462529
// Drop the brace; keep scanning. We deliberately do NOT skip the
463530
// nested-rule selector prefix between '{' and the next declaration

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,71 @@ describe('components manifest extraction (#6224 regression suite)', () => {
236236
expect.arrayContaining(['--a', '--b', '--c']),
237237
);
238238
});
239+
240+
it('treats braces inside quoted CSS values as data, not rule delimiters (#6250 reviewer #3)', () => {
241+
// PerishCode CHANGES_REQUESTED on PR #6250:
242+
// "Treat braces inside quoted CSS values as data, not rule delimiters.
243+
// This depth loop currently has no string/escape state, so valid CSS
244+
// such as `.btn-a { content: "}"; color: var(--a); } .btn-b { color:
245+
// var(--b); }` mis-parses."
246+
//
247+
// The scanner used to count every `{` / `}` it saw, including braces
248+
// inside a quoted string like `content: "}"`. That closed the wrapping
249+
// rule early, swallowed the second rule into the first one's body,
250+
// and dropped every selector + token reference after the quoted brace.
251+
//
252+
// The fix: track single / double quote state in iterateCssRules and
253+
// flattenNestedBody and ignore braces while inside a quoted value.
254+
// Handle `\"` and `\'` escapes so a quoted `}"` still terminates the
255+
// quoted context cleanly.
256+
const manifest = extractComponentsManifest({
257+
brandId: 'quoted-braces',
258+
tokensCss: ':root { --a: red; --b: blue; --c: green; }',
259+
fixtureHtml: `
260+
<style>
261+
.btn-a { content: "}"; color: var(--a); }
262+
.btn-b { content: '{'; background: var(--b); }
263+
.btn-c { content: '\\'\\';'; border-color: var(--c); }
264+
</style>
265+
<button class="btn-a btn-b btn-c">x</button>
266+
`,
267+
});
268+
269+
expect(manifest.selectors).toEqual(
270+
expect.arrayContaining(['.btn-a', '.btn-b', '.btn-c']),
271+
);
272+
const buttonsGroup = manifest.groups.find((g) => g.id === 'buttons');
273+
expect(buttonsGroup?.selectors).toEqual(
274+
expect.arrayContaining(['.btn-a', '.btn-b', '.btn-c']),
275+
);
276+
// All three tokens survive — the quoted braces do not truncate the
277+
// rule body and lose later declarations.
278+
expect(buttonsGroup?.tokenReferences).toEqual(
279+
expect.arrayContaining(['--a', '--b', '--c']),
280+
);
281+
282+
// Severity check: a quoted `}` followed by a *second flat rule* must
283+
// not collapse the second rule into the first. Pre-fix behaviour:
284+
// `.btn-a { content: "}"; color: var(--a); } .btn-b { color: var(--b); }`
285+
// closed `.btn-a` at the quoted `}`, swallowed `.btn-b` as text, and
286+
// the manifest lost `.btn-b` + its --b reference entirely.
287+
const pair = extractComponentsManifest({
288+
brandId: 'quoted-pair',
289+
tokensCss: ':root { --a: red; --b: blue; }',
290+
fixtureHtml: `
291+
<style>
292+
.btn-a { content: "}"; color: var(--a); }
293+
.btn-b { color: var(--b); }
294+
</style>
295+
<button class="btn-a btn-b">x</button>
296+
`,
297+
});
298+
expect(pair.selectors).toEqual(
299+
expect.arrayContaining(['.btn-a', '.btn-b']),
300+
);
301+
const pairButtons = pair.groups.find((g) => g.id === 'buttons');
302+
expect(pairButtons?.tokenReferences).toEqual(
303+
expect.arrayContaining(['--a', '--b']),
304+
);
305+
});
239306
});

0 commit comments

Comments
 (0)