@@ -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 = / @ m e d i a \s * \( \s * p r e f e r s - c o l o r - s c h e m e : [ ^ ) ] * \) \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 ( / : r o o t \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+
50122export 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- / @ m e d i a \s * \( \s * p r e f e r s - c o l o r - s c h e m e : \s * l i g h t \s * \) \s * \{ \s * : r o o t \s * \{ ( [ \s \S ] * ?) \} \s * \} / ,
166- ) ;
167- if ( lightMatch && lightMatch [ 1 ] ) {
168- const stripped = css . replace (
169- / @ m e d i a \s * \( \s * p r e f e r s - c o l o r - s c h e m e : [ ^ ) ] * \) \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 => / p r e f e r s - c o l o r - s c h e m e : \s * l i g h t / 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- / @ m e d i a \s * \( \s * p r e f e r s - c o l o r - s c h e m e : [ ^ ) ] * \) \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