Skip to content

Commit 92d73ef

Browse files
committed
fix(zoom-modal): brace-counting parser for prefers-color-scheme blocks replaces lazy regex
1 parent bb1db35 commit 92d73ef

1 file changed

Lines changed: 83 additions & 15 deletions

File tree

src/dashboard/src/components/about/ZoomableDiagram.tsx

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,78 @@ function readIsLight(): boolean {
4747
return document.documentElement.classList.contains('light');
4848
}
4949

50+
interface MediaBlock {
51+
head: string;
52+
body: string;
53+
}
54+
55+
/**
56+
* Brace-counting parser for `@media (prefers-color-scheme: …) { … }`
57+
* blocks. Replaces the original `[\s\S]*?` regex which would stop at
58+
* the first inner `}` and mishandle nested rule blocks. Returns the
59+
* extracted blocks plus the original CSS with every matched block
60+
* removed in one pass, so the caller can rewrite the stylesheet
61+
* without parsing twice. We control the SVG content today, but
62+
* keeping the parser brace-aware means a future change that nests
63+
* rules inside `:root` won't silently produce broken theme blocks.
64+
*/
65+
function extractPrefersColorSchemeBlocks(css: string): { blocks: MediaBlock[]; withoutBlocks: string } {
66+
const blocks: MediaBlock[] = [];
67+
const headRegex = /@media\s*\(\s*prefers-color-scheme:[^)]*\)\s*\{/g;
68+
let withoutBlocks = '';
69+
let lastEnd = 0;
70+
let match: RegExpExecArray | null;
71+
while ((match = headRegex.exec(css)) !== null) {
72+
const headStart = match.index;
73+
const headEnd = headRegex.lastIndex; // points just past the opening `{`
74+
let depth = 1;
75+
let cursor = headEnd;
76+
while (cursor < css.length && depth > 0) {
77+
const ch = css.charCodeAt(cursor);
78+
if (ch === 123 /* { */) depth += 1;
79+
else if (ch === 125 /* } */) depth -= 1;
80+
cursor += 1;
81+
}
82+
// depth === 0 → cursor is one past the matching `}`.
83+
if (depth !== 0) {
84+
// Unbalanced block. Bail out: leave the rest of the css intact
85+
// so we don't corrupt the stylesheet — we'd rather show a stale
86+
// OS-preference-driven SVG than blow it up entirely.
87+
withoutBlocks += css.slice(lastEnd);
88+
return { blocks, withoutBlocks };
89+
}
90+
blocks.push({
91+
head: css.slice(headStart, headEnd),
92+
body: css.slice(headEnd, cursor - 1).trim(),
93+
});
94+
withoutBlocks += css.slice(lastEnd, headStart);
95+
lastEnd = cursor;
96+
headRegex.lastIndex = cursor;
97+
}
98+
withoutBlocks += css.slice(lastEnd);
99+
return { blocks, withoutBlocks };
100+
}
101+
102+
/** Pull the body out of a leading `:root { … }` rule inside an @media
103+
* block. Same brace-counting strategy as `extractPrefersColorSchemeBlocks`
104+
* so a nested `{}` inside the variable definitions wouldn't truncate
105+
* the body early. */
106+
function extractRootBlockBody(blockBody: string): string | null {
107+
const rootMatch = blockBody.match(/:root\s*\{/);
108+
if (!rootMatch || rootMatch.index == null) return null;
109+
const start = rootMatch.index + rootMatch[0].length;
110+
let depth = 1;
111+
let cursor = start;
112+
while (cursor < blockBody.length && depth > 0) {
113+
const ch = blockBody.charCodeAt(cursor);
114+
if (ch === 123) depth += 1;
115+
else if (ch === 125) depth -= 1;
116+
cursor += 1;
117+
}
118+
if (depth !== 0) return null;
119+
return blockBody.slice(start, cursor - 1).trim();
120+
}
121+
50122
export function ZoomableDiagram({
51123
src,
52124
alt,
@@ -160,24 +232,20 @@ export function ZoomableDiagram({
160232

161233
svgEl.querySelectorAll('style').forEach((styleEl) => {
162234
const css = styleEl.textContent || '';
235+
const media = extractPrefersColorSchemeBlocks(css);
236+
if (!media.blocks.length) return;
237+
let rewritten = media.withoutBlocks;
163238
if (isLight) {
164-
const lightMatch = css.match(
165-
/@media\s*\(\s*prefers-color-scheme:\s*light\s*\)\s*\{\s*:root\s*\{([\s\S]*?)\}\s*\}/,
166-
);
167-
if (lightMatch && lightMatch[1]) {
168-
const stripped = css.replace(
169-
/@media\s*\(\s*prefers-color-scheme:[^)]*\)\s*\{[\s\S]*?\}\s*\}/g,
170-
'',
171-
);
172-
styleEl.textContent = `${stripped}\nsvg{${lightMatch[1]}}\n`;
173-
return;
239+
// Pull the `:root { ... }` body out of the light-mode block so
240+
// the modal's `data-theme="light"` actually applies the light
241+
// variables to the SVG without needing the OS to also be light.
242+
const lightBlock = media.blocks.find(b => /prefers-color-scheme:\s*light/i.test(b.head));
243+
if (lightBlock) {
244+
const rootBody = extractRootBlockBody(lightBlock.body);
245+
if (rootBody) rewritten += `\nsvg{${rootBody}}\n`;
174246
}
175247
}
176-
const stripped = css.replace(
177-
/@media\s*\(\s*prefers-color-scheme:[^)]*\)\s*\{[\s\S]*?\}\s*\}/g,
178-
'',
179-
);
180-
if (stripped !== css) styleEl.textContent = stripped;
248+
if (rewritten !== css) styleEl.textContent = rewritten;
181249
});
182250

183251
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);

0 commit comments

Comments
 (0)