Skip to content

Commit ca0fa43

Browse files
authored
feat: support mermaid rendering (#84)
1 parent 0a16710 commit ca0fa43

File tree

7 files changed

+575
-164
lines changed

7 files changed

+575
-164
lines changed

.vitepress/config.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx'
22
import { defineConfig } from 'vitepress'
33
import { vitepressDemoPlugin } from 'vitepress-demo-plugin'
44
import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs'
5+
import { isMermaidFence, renderMermaidFence } from './theme/markdown/mermaid'
56
import path from 'path'
67
import { fileURLToPath } from 'url'
78

@@ -197,6 +198,9 @@ export default defineConfig({
197198
const langMap: Record<string, string> = { env: 'bash', vue3: 'vue' }
198199
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
199200
const token = tokens[idx]
201+
if (token && isMermaidFence(token)) {
202+
return renderMermaidFence(token, idx, env || {})
203+
}
200204
if (token && token.info) {
201205
const info = token.info.trim()
202206
const lang = info.split(/\s+/)[0]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<template>
2+
<div class="vp-mermaid-wrapper">
3+
<div v-if="errorMessage" class="vp-mermaid-error">
4+
<p class="vp-mermaid-error-title">Mermaid 渲染失败,已回退为源码:</p>
5+
<pre><code>{{ sourceCode }}</code></pre>
6+
</div>
7+
<div v-else-if="!svgCode" class="vp-mermaid-loading">Rendering Mermaid...</div>
8+
<div v-else class="vp-mermaid-diagram" v-html="svgCode"></div>
9+
</div>
10+
</template>
11+
12+
<script setup lang="ts">
13+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
14+
import { useRoute } from 'vitepress'
15+
import type { MermaidPalette } from '../utils/mermaid'
16+
import { renderMermaidSvg } from '../utils/mermaid'
17+
18+
const props = defineProps({
19+
id: {
20+
type: String,
21+
required: true,
22+
},
23+
graph: {
24+
type: String,
25+
required: true,
26+
},
27+
})
28+
29+
const route = useRoute()
30+
31+
const svgCode = ref('')
32+
const errorMessage = ref('')
33+
const renderVersion = ref(0)
34+
let mutationObserver: MutationObserver | null = null
35+
36+
const safeId = computed(() => props.id.replace(/[^a-zA-Z0-9_-]/g, '_'))
37+
38+
const sourceCode = computed(() => {
39+
try {
40+
return decodeURIComponent(props.graph)
41+
} catch (error) {
42+
return props.graph
43+
}
44+
})
45+
46+
const getIsDarkMode = () => {
47+
if (typeof document === 'undefined') {
48+
return false
49+
}
50+
return document.documentElement.classList.contains('dark')
51+
}
52+
53+
const readThemePalette = (): MermaidPalette => {
54+
const styles = getComputedStyle(document.documentElement)
55+
const read = (name: string, fallback: string) => {
56+
const value = styles.getPropertyValue(name).trim()
57+
return value || fallback
58+
}
59+
60+
return {
61+
bg: read('--vp-c-bg', getIsDarkMode() ? '#0b1220' : '#ffffff'),
62+
bgSoft: read('--vp-c-bg-soft', getIsDarkMode() ? '#1f2937' : '#f6f8fa'),
63+
text1: read('--vp-c-text-1', getIsDarkMode() ? '#f8fafc' : '#1f2937'),
64+
text2: read('--vp-c-text-2', getIsDarkMode() ? '#cbd5e1' : '#475569'),
65+
border: read('--vp-c-border', getIsDarkMode() ? '#64748b' : '#94a3b8'),
66+
brand: read('--vp-c-brand-1', getIsDarkMode() ? '#60a5fa' : '#2563eb'),
67+
}
68+
}
69+
70+
const renderDiagram = async () => {
71+
if (typeof window === 'undefined') {
72+
return
73+
}
74+
75+
const currentVersion = renderVersion.value + 1
76+
renderVersion.value = currentVersion
77+
svgCode.value = ''
78+
errorMessage.value = ''
79+
80+
try {
81+
const svg = await renderMermaidSvg({
82+
id: `${safeId.value}-${currentVersion}`,
83+
code: sourceCode.value,
84+
isDark: getIsDarkMode(),
85+
palette: readThemePalette(),
86+
})
87+
88+
if (currentVersion !== renderVersion.value) {
89+
return
90+
}
91+
92+
svgCode.value = svg
93+
} catch (error) {
94+
if (currentVersion !== renderVersion.value) {
95+
return
96+
}
97+
errorMessage.value = error instanceof Error ? error.message : String(error)
98+
}
99+
}
100+
101+
onMounted(() => {
102+
mutationObserver = new MutationObserver(() => {
103+
renderDiagram()
104+
})
105+
mutationObserver.observe(document.documentElement, {
106+
attributes: true,
107+
attributeFilter: ['class'],
108+
})
109+
renderDiagram()
110+
})
111+
112+
watch(() => props.graph, renderDiagram)
113+
watch(() => route.path, renderDiagram)
114+
115+
onBeforeUnmount(() => {
116+
mutationObserver?.disconnect()
117+
mutationObserver = null
118+
renderVersion.value += 1
119+
})
120+
</script>

.vitepress/theme/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { setupDarkModeListener } from './color-mode'
55
import Layout from './Layout.vue'
66
import HomePage from './home/index.vue'
77
import CustomTable from './components/CustomTable.vue'
8+
import MermaidBlock from './components/MermaidBlock.vue'
89
import '@opentiny/tiny-robot-style'
910
import {nextTick, watch} from 'vue';
1011
import {useRoute} from 'vitepress';
@@ -37,6 +38,7 @@ export default {
3738
})
3839
app.component('HomePage', HomePage)
3940
app.component('CustomTable', CustomTable)
41+
app.component('MermaidBlock', MermaidBlock)
4042
enhanceAppWithTabs(app)
4143
},
4244
Layout,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
type MarkdownEnv = Record<string, unknown>
2+
3+
const MERMAID_LANGS = new Set(['mermaid', 'mmd'])
4+
5+
const sanitizeId = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '_')
6+
7+
const getFenceLang = (info = '') => info.trim().split(/\s+/)[0]
8+
9+
export const isMermaidFence = (token: { info?: string }) => {
10+
const lang = getFenceLang(token?.info)
11+
return MERMAID_LANGS.has(lang)
12+
}
13+
14+
export const renderMermaidFence = (
15+
token: { content?: string },
16+
idx: number,
17+
env: MarkdownEnv = {},
18+
) => {
19+
const pagePath = (env.relativePath || env.path || env.filePath || 'page') as string
20+
const blockId = sanitizeId(`${pagePath}-${idx}`)
21+
const graph = encodeURIComponent(token?.content || '')
22+
23+
return `<ClientOnly><MermaidBlock id="${blockId}" graph="${graph}"></MermaidBlock></ClientOnly>`
24+
}

0 commit comments

Comments
 (0)