@@ -38,6 +38,8 @@ const config = {
3838const iconPaths = {
3939 copy : '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>' ,
4040 check : '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>' ,
41+ code : '<path d="M7 5L3 10L7 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 5L17 10L13 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>' ,
42+ diagram : '<path d="M3 3H17V17H3V3Z" stroke="currentColor" stroke-linecap="round"/><path d="M3 10H17" stroke="currentColor"/><path d="M10 3V17" stroke="currentColor"/>' ,
4143}
4244
4345function sanitize ( html : string ) {
@@ -173,9 +175,128 @@ function markCodeLinks(root: HTMLDivElement) {
173175 }
174176}
175177
178+ let mermaidPromise : Promise < typeof import ( "mermaid" ) > | undefined
179+
180+ function getMermaid ( ) {
181+ if ( ! mermaidPromise ) {
182+ mermaidPromise = import ( "mermaid" ) . then ( ( m ) => {
183+ m . default . initialize ( {
184+ startOnLoad : false ,
185+ theme : "dark" ,
186+ fontFamily : "var(--font-family-sans)" ,
187+ darkMode : true ,
188+ themeVariables : {
189+ darkMode : true ,
190+ background : "transparent" ,
191+ primaryColor : "hsl(var(--color-blue-600))" ,
192+ primaryTextColor : "var(--text-strong)" ,
193+ primaryBorderColor : "var(--border-base)" ,
194+ lineColor : "var(--text-dimmed)" ,
195+ secondaryColor : "hsl(var(--color-purple-600))" ,
196+ tertiaryColor : "hsl(var(--color-green-600))" ,
197+ } ,
198+ } )
199+ return m
200+ } )
201+ }
202+ return mermaidPromise
203+ }
204+
205+ let mermaidCounter = 0
206+
207+ async function renderMermaidDiagrams ( root : HTMLDivElement ) {
208+ const diagrams = Array . from ( root . querySelectorAll < HTMLElement > ( '[data-component="mermaid-diagram"]' ) )
209+ if ( diagrams . length === 0 ) return
210+
211+ const pending = diagrams . filter ( ( d ) => ! d . hasAttribute ( "data-rendered" ) )
212+ if ( pending . length === 0 ) return
213+
214+ const mermaid = await getMermaid ( )
215+
216+ for ( const container of pending ) {
217+ const encoded = container . getAttribute ( "data-mermaid" )
218+ if ( ! encoded ) continue
219+
220+ const renderSlot = container . querySelector < HTMLElement > ( '[data-slot="mermaid-render"]' )
221+ if ( ! renderSlot ) continue
222+
223+ let source : string
224+ try {
225+ source = decodeURIComponent ( escape ( atob ( encoded ) ) )
226+ } catch {
227+ continue
228+ }
229+
230+ try {
231+ const id = `mermaid-${ ++ mermaidCounter } `
232+ const { svg } = await mermaid . default . render ( id , source )
233+ renderSlot . innerHTML = svg
234+ container . setAttribute ( "data-rendered" , "true" )
235+
236+ if ( ! container . querySelector ( '[data-slot="mermaid-toggle"]' ) ) {
237+ const toggle = document . createElement ( "button" )
238+ toggle . type = "button"
239+ toggle . setAttribute ( "data-component" , "icon-button" )
240+ toggle . setAttribute ( "data-variant" , "secondary" )
241+ toggle . setAttribute ( "data-size" , "small" )
242+ toggle . setAttribute ( "data-slot" , "mermaid-toggle" )
243+ toggle . setAttribute ( "data-view" , "diagram" )
244+ toggle . setAttribute ( "aria-label" , "View source" )
245+ toggle . setAttribute ( "data-tooltip" , "View source" )
246+ toggle . appendChild ( createIcon ( iconPaths . code , "toggle-code-icon" ) )
247+ toggle . appendChild ( createIcon ( iconPaths . diagram , "toggle-diagram-icon" ) )
248+ container . appendChild ( toggle )
249+ }
250+ } catch {
251+ const sourceSlot = container . querySelector < HTMLElement > ( '[data-slot="mermaid-source"]' )
252+ if ( renderSlot && sourceSlot ) {
253+ renderSlot . hidden = true
254+ sourceSlot . hidden = false
255+ }
256+ container . setAttribute ( "data-rendered" , "error" )
257+ }
258+ }
259+ }
260+
261+ function setupMermaidToggle ( root : HTMLDivElement ) {
262+ const handleClick = ( event : MouseEvent ) => {
263+ const target = event . target
264+ if ( ! ( target instanceof Element ) ) return
265+
266+ const toggle = target . closest ( '[data-slot="mermaid-toggle"]' )
267+ if ( ! ( toggle instanceof HTMLButtonElement ) ) return
268+
269+ const container = toggle . closest ( '[data-component="mermaid-diagram"]' )
270+ if ( ! container ) return
271+
272+ const renderSlot = container . querySelector < HTMLElement > ( '[data-slot="mermaid-render"]' )
273+ const sourceSlot = container . querySelector < HTMLElement > ( '[data-slot="mermaid-source"]' )
274+ if ( ! renderSlot || ! sourceSlot ) return
275+
276+ const showingDiagram = toggle . getAttribute ( "data-view" ) === "diagram"
277+ if ( showingDiagram ) {
278+ renderSlot . hidden = true
279+ sourceSlot . hidden = false
280+ toggle . setAttribute ( "data-view" , "source" )
281+ toggle . setAttribute ( "aria-label" , "View diagram" )
282+ toggle . setAttribute ( "data-tooltip" , "View diagram" )
283+ } else {
284+ renderSlot . hidden = false
285+ sourceSlot . hidden = true
286+ toggle . setAttribute ( "data-view" , "diagram" )
287+ toggle . setAttribute ( "aria-label" , "View source" )
288+ toggle . setAttribute ( "data-tooltip" , "View source" )
289+ }
290+ }
291+
292+ root . addEventListener ( "click" , handleClick )
293+ return ( ) => root . removeEventListener ( "click" , handleClick )
294+ }
295+
176296function decorate ( root : HTMLDivElement , labels : CopyLabels ) {
177297 const blocks = Array . from ( root . querySelectorAll ( "pre" ) )
178298 for ( const block of blocks ) {
299+ if ( block . closest ( '[data-slot="mermaid-source"]' ) ) continue
179300 ensureCodeWrapper ( block , labels )
180301 }
181302 markCodeLinks ( root )
@@ -286,6 +407,7 @@ export function Markdown(
286407 )
287408
288409 let copyCleanup : ( ( ) => void ) | undefined
410+ let mermaidToggleCleanup : ( ( ) => void ) | undefined
289411
290412 createEffect ( ( ) => {
291413 const container = root ( )
@@ -309,6 +431,7 @@ export function Markdown(
309431 morphdom ( container , temp , {
310432 childrenOnly : true ,
311433 onBeforeElUpdated : ( fromEl , toEl ) => {
434+ // Preserve copy button state during morphdom updates
312435 if (
313436 fromEl instanceof HTMLButtonElement &&
314437 toEl instanceof HTMLButtonElement &&
@@ -318,6 +441,15 @@ export function Markdown(
318441 ) {
319442 setCopyState ( toEl , labels , true )
320443 }
444+ // Preserve already-rendered mermaid diagrams
445+ if (
446+ fromEl instanceof HTMLElement &&
447+ fromEl . getAttribute ( "data-rendered" ) === "true" &&
448+ toEl instanceof HTMLElement &&
449+ fromEl . getAttribute ( "data-mermaid" ) === toEl . getAttribute ( "data-mermaid" )
450+ ) {
451+ return false
452+ }
321453 if ( fromEl . isEqualNode ( toEl ) ) return false
322454 return true
323455 } ,
@@ -328,10 +460,17 @@ export function Markdown(
328460 copy : i18n . t ( "ui.message.copy" ) ,
329461 copied : i18n . t ( "ui.message.copied" ) ,
330462 } ) )
463+
464+ if ( ! mermaidToggleCleanup ) {
465+ mermaidToggleCleanup = setupMermaidToggle ( container )
466+ }
467+
468+ renderMermaidDiagrams ( container )
331469 } )
332470
333471 onCleanup ( ( ) => {
334472 if ( copyCleanup ) copyCleanup ( )
473+ if ( mermaidToggleCleanup ) mermaidToggleCleanup ( )
335474 } )
336475
337476 return (
0 commit comments