Skip to content

Commit 40950c4

Browse files
authored
feat: add remarkMermaid plugin (#35)
1 parent 9f74742 commit 40950c4

File tree

5 files changed

+919
-3
lines changed

5 files changed

+919
-3
lines changed

app/components/mermaid/Mermaid.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client"
2+
3+
import { MutableRefObject, ReactElement, useEffect, useId, useRef, useState } from "react"
4+
import { MermaidConfig } from "mermaid"
5+
6+
function useIsVisible(ref: MutableRefObject<HTMLElement>) {
7+
const [isIntersecting, setIsIntersecting] = useState(false)
8+
9+
useEffect(() => {
10+
const observer = new IntersectionObserver(([entry]) => {
11+
if (entry.isIntersecting) {
12+
// disconnect after once visible to avoid re-rendering of chart when `isIntersecting` will
13+
// be changed to true/false
14+
observer.disconnect()
15+
setIsIntersecting(true)
16+
}
17+
})
18+
19+
observer.observe(ref.current)
20+
return () => {
21+
observer.disconnect()
22+
}
23+
}, [ref])
24+
25+
return isIntersecting
26+
}
27+
28+
export function Mermaid({ chart }: { chart: string }): ReactElement {
29+
const id = useId()
30+
const [svg, setSvg] = useState("")
31+
const containerRef = useRef<HTMLDivElement>(null!)
32+
const isVisible = useIsVisible(containerRef)
33+
34+
useEffect(() => {
35+
// Fix when inside element with `display: hidden` https://github.com/shuding/nextra/issues/3291
36+
if (!isVisible) {
37+
return
38+
}
39+
const htmlElement = document.documentElement
40+
const observer = new MutationObserver(renderChart)
41+
observer.observe(htmlElement, { attributes: true })
42+
renderChart()
43+
44+
return () => {
45+
observer.disconnect()
46+
}
47+
48+
// Switching themes taken from https://github.com/mermaid-js/mermaid/blob/1b40f552b20df4ab99a986dd58c9d254b3bfd7bc/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue#L53
49+
async function renderChart() {
50+
const isDarkTheme =
51+
htmlElement.classList.contains("dark") ||
52+
htmlElement.attributes.getNamedItem("data-theme")?.value === "dark"
53+
54+
const mermaidConfig: MermaidConfig = {
55+
securityLevel: "loose",
56+
fontFamily: "inherit",
57+
themeCSS: "margin: 1.5rem auto 0;",
58+
theme: isDarkTheme ? "dark" : "default",
59+
themeVariables: {
60+
background: isDarkTheme ? "#97eae5" : "#003366",
61+
primaryColor: isDarkTheme ? "#97eae5" : "#003366",
62+
git0: "#97eae5",
63+
git1: "#DC606B",
64+
git2: "#DC9B14"
65+
}
66+
}
67+
68+
const { default: mermaid } = await import("mermaid")
69+
70+
try {
71+
mermaid.initialize(mermaidConfig)
72+
const { svg } = await mermaid.render(
73+
// strip invalid characters for `id` attribute
74+
id.replaceAll(":", ""),
75+
chart.replaceAll("\\n", "\n"),
76+
containerRef.current
77+
)
78+
setSvg(svg)
79+
} catch (error) {
80+
console.error("Error while rendering mermaid", error)
81+
}
82+
}
83+
}, [chart, isVisible])
84+
85+
return <div ref={containerRef} dangerouslySetInnerHTML={{ __html: svg }} />
86+
}

app/components/mermaid/index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Code, Root, RootContent } from "mdast"
2+
import { Plugin } from "unified"
3+
import { visit } from "unist-util-visit"
4+
5+
const COMPONENT_NAME = "Mermaid"
6+
7+
const MERMAID_IMPORT_AST = {
8+
type: "mdxjsEsm",
9+
data: {
10+
estree: {
11+
body: [
12+
{
13+
type: "ImportDeclaration",
14+
specifiers: [
15+
{
16+
type: "ImportSpecifier",
17+
imported: { type: "Identifier", name: COMPONENT_NAME },
18+
local: { type: "Identifier", name: COMPONENT_NAME }
19+
}
20+
],
21+
source: { type: "Literal", value: "@/components/mermaid/Mermaid" }
22+
}
23+
]
24+
}
25+
}
26+
} as RootContent
27+
28+
export const remarkMermaid: Plugin<[], Root> = () => (ast, _file, done) => {
29+
// eslint-disable-next-line
30+
const codeblocks: any[] = []
31+
visit(ast, { type: "code", lang: "mermaid" }, (node: Code, index, parent) => {
32+
codeblocks.push([node, index, parent])
33+
})
34+
35+
if (codeblocks.length !== 0) {
36+
for (const [node, index, parent] of codeblocks) {
37+
parent.children.splice(index, 1, {
38+
type: "mdxJsxFlowElement",
39+
name: COMPONENT_NAME,
40+
attributes: [
41+
{
42+
type: "mdxJsxAttribute",
43+
name: "chart",
44+
value: node.value.replaceAll("\n", "\\n")
45+
}
46+
]
47+
})
48+
}
49+
ast.children.unshift(MERMAID_IMPORT_AST)
50+
}
51+
52+
done()
53+
}

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121
"react": "^18.3.1",
2222
"react-dom": "^18.3.1",
2323
"shiki": "^1.16.1",
24-
"tailwind-merge": "^2.3.0"
24+
"tailwind-merge": "^2.3.0",
25+
"unist-util-visit": "^5.0.0"
2526
},
2627
"devDependencies": {
2728
"@eslint/eslintrc": "^3.1.0",
2829
"@eslint/js": "^9.7.0",
2930
"@next/eslint-plugin-next": "^14.2.7",
31+
"@types/mdast": "^4.0.4",
3032
"@types/mdx": "^2.0.13",
3133
"@types/node": "20.14.8",
3234
"@types/react": "^18.3.3",
@@ -40,6 +42,7 @@
4042
"eslint-plugin-prettier": "^5.1.3",
4143
"globals": "^15.8.0",
4244
"lint-staged": "^15.2.7",
45+
"mermaid": "^11.3.0",
4346
"open-props": "^1.7.4",
4447
"postcss": "^8.4.38",
4548
"postcss-nesting": "^12.1.5",
@@ -48,7 +51,8 @@
4851
"remark-youtube": "^1.3.2",
4952
"simple-git-hooks": "^2.11.1",
5053
"tailwindcss": "^3.4.4",
51-
"typescript": "^5.5.2"
54+
"typescript": "^5.5.2",
55+
"unified": "^11.0.5"
5256
},
5357
"simple-git-hooks": {
5458
"pre-commit": "npx lint-staged"

0 commit comments

Comments
 (0)