diff --git a/package.json b/package.json index 428da65d..246cda6d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lodash": "^4.17.21", "mdast-util-to-string": "^3.2.0", "mdx-annotations": "^0.1.1", + "mermaid": "^11.15.0", "next": "^16.0.0", "openapi-types": "^12.1.0", "postcss-focus-visible": "^8.0.2", diff --git a/src/components/Code.jsx b/src/components/Code.jsx index d03e2b8c..aaf526cb 100644 --- a/src/components/Code.jsx +++ b/src/components/Code.jsx @@ -11,6 +11,7 @@ import clsx from 'clsx' import { create } from 'zustand' import { Tag } from '@/components/Tag' +import { Mermaid } from '@/components/Mermaid' const languageNames = { js: 'JavaScript', @@ -290,6 +291,10 @@ export function Code({ children, ...props }) { export function Pre({ children, ...props }) { let isGrouped = useContext(CodeGroupContext) + if (props.language === 'mermaid') { + return + } + if (isGrouped) { return children } diff --git a/src/components/Mermaid.jsx b/src/components/Mermaid.jsx new file mode 100644 index 00000000..0cb0844f --- /dev/null +++ b/src/components/Mermaid.jsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +let mermaidPromise + +function loadMermaid() { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then((m) => m.default) + } + return mermaidPromise +} + +function isDarkMode() { + return ( + typeof document !== 'undefined' && + document.documentElement.classList.contains('dark') + ) +} + +function CloseIcon() { + return ( + + + + + ) +} + +export function Mermaid({ chart, alt = 'Diagram' }) { + let reactId = useId().replace(/[^a-zA-Z0-9]/g, '') + let renderCount = useRef(0) + let renderRun = useRef(0) + let hasRendered = useRef(false) + let [svg, setSvg] = useState('') + let [error, setError] = useState(null) + let [zoomed, setZoomed] = useState(false) + let [closing, setClosing] = useState(false) + + useEffect(() => { + let cancelled = false + let lastTheme = null + + async function render() { + let dark = isDarkMode() + // Skip redundant re-renders when an unrelated class toggles on . + if (lastTheme === dark && hasRendered.current) return + lastTheme = dark + let run = ++renderRun.current + + try { + let mermaid = await loadMermaid() + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: dark ? 'dark' : 'default', + fontFamily: + "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + }) + let id = `mermaid-${reactId}-${renderCount.current++}` + let { svg: out } = await mermaid.render(id, chart) + if (!cancelled && run === renderRun.current) { + hasRendered.current = true + setError(null) + setSvg(out) + } + } catch (e) { + if (!cancelled && run === renderRun.current) { + setError(e?.message || String(e)) + } + } + } + + render() + + // Re-render when the user toggles light/dark so the diagram matches the theme. + let observer = new MutationObserver(() => render()) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + + return () => { + cancelled = true + observer.disconnect() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chart]) + + let close = useCallback(() => { + setClosing(true) + setTimeout(() => { + setZoomed(false) + setClosing(false) + }, 200) + }, []) + + useEffect(() => { + if (!zoomed) return + let onKey = (e) => { + if (e.key === 'Escape') close() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [zoomed, close]) + + if (error) { + return ( +
+        {`Mermaid render error: ${error}\n\n${chart}`}
+      
+ ) + } + + if (!svg) { + return