Skip to content

Commit a286b45

Browse files
authored
add Mermaid SVG rendering integration (#1)
1 parent 62f4dfa commit a286b45

File tree

6 files changed

+311
-1
lines changed

6 files changed

+311
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@rspress/plugin-preview": "2.0.0-rc.3",
1515
"@rspress/plugin-sitemap": "2.0.0-rc.3",
1616
"@rstack-dev/doc-ui": "^1.12.2",
17+
"mermaid": "^11.12.2",
1718
"react": "19.2.3",
1819
"react-dom": "19.2.3",
1920
"tailwindcss": "^4.1.18"

plugins/mermaid-renderer.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import React, { useEffect, useRef } from 'react';
2+
3+
export default function MermaidRenderer() {
4+
const isMounted = useRef(true);
5+
const observerRef = useRef<MutationObserver | null>(null);
6+
const isRenderingRef = useRef(false);
7+
const initializedRef = useRef(false);
8+
9+
// Initialize Mermaid once with neutral theme for compatibility with both light and dark modes
10+
useEffect(() => {
11+
const initializeMermaid = async () => {
12+
if (initializedRef.current) return;
13+
14+
try {
15+
const mermaid = await import('mermaid');
16+
17+
if (!isMounted.current) return;
18+
19+
// Use 'neutral' theme - works best with both light and dark page themes
20+
mermaid.default.initialize({
21+
startOnLoad: false,
22+
theme: 'neutral', // Fixed neutral theme for visual harmony
23+
securityLevel: 'loose',
24+
fontFamily: 'monospace',
25+
flowchart: {
26+
useMaxWidth: true,
27+
htmlLabels: true,
28+
curve: 'basis',
29+
},
30+
});
31+
32+
function renderMermaidDiagrams() {
33+
// Only look for blocks that haven't been processed yet
34+
const unprocessedCodeBlocks = document.querySelectorAll(
35+
'pre:not([data-mermaid-processed]) code',
36+
);
37+
let foundCount = 0;
38+
39+
// Cache the mermaid patterns for better performance
40+
const mermaidPatterns = [
41+
'graph',
42+
'flowchart',
43+
'stateDiagram',
44+
'sequenceDiagram',
45+
'classDiagram',
46+
'gantt',
47+
'pie',
48+
'gitGraph',
49+
'erDiagram',
50+
'journey',
51+
'mindmap',
52+
];
53+
54+
unprocessedCodeBlocks.forEach((codeElement) => {
55+
const preElement = codeElement.parentElement;
56+
if (!preElement) return;
57+
58+
const code = codeElement.textContent || '';
59+
const trimmedCode = code.trim();
60+
61+
// Check if it's mermaid code - optimized checks
62+
const isMermaid =
63+
codeElement.className.includes('mermaid') ||
64+
preElement.className.includes('mermaid') ||
65+
mermaidPatterns.some((pattern) =>
66+
trimmedCode.startsWith(pattern),
67+
) ||
68+
trimmedCode.includes('stateDiagram-v2');
69+
70+
if (isMermaid) {
71+
foundCount++;
72+
73+
// Create a div with class mermaid
74+
const mermaidDiv = document.createElement('div');
75+
mermaidDiv.className = 'mermaid';
76+
mermaidDiv.textContent = trimmedCode;
77+
78+
// Mark as processed before replacing
79+
preElement.setAttribute('data-mermaid-processed', 'true');
80+
// Replace pre element
81+
preElement.replaceWith(mermaidDiv);
82+
}
83+
});
84+
85+
return foundCount;
86+
}
87+
88+
// Wait for DOM to be completely ready
89+
setTimeout(async () => {
90+
if (!isMounted.current || isRenderingRef.current) return;
91+
92+
isRenderingRef.current = true;
93+
try {
94+
const newBlocks = renderMermaidDiagrams();
95+
if (newBlocks > 0 && isMounted.current) {
96+
try {
97+
await mermaid.default.run();
98+
} catch (error) {
99+
console.error('Failed to render mermaid diagrams:', error);
100+
}
101+
}
102+
} finally {
103+
isRenderingRef.current = false;
104+
initializedRef.current = true;
105+
}
106+
}, 500);
107+
} catch (err) {
108+
console.error('Failed to initialize mermaid:', err);
109+
}
110+
};
111+
112+
initializeMermaid();
113+
}, []); // Initialize only once
114+
115+
// Cleanup and MutationObserver setup
116+
useEffect(() => {
117+
// Set up MutationObserver for dynamic content
118+
observerRef.current = new MutationObserver((mutations) => {
119+
const hasNewMermaid = mutations.some((mutation) =>
120+
Array.from(mutation.addedNodes).some(
121+
(node) =>
122+
node.nodeType === Node.ELEMENT_NODE &&
123+
((node as Element).tagName === 'PRE' ||
124+
(node as Element).querySelector?.('pre') ||
125+
(node as Element).classList?.contains('mermaid')),
126+
),
127+
);
128+
129+
if (hasNewMermaid && isMounted.current && !isRenderingRef.current) {
130+
// Debounce to prevent rapid successive calls
131+
setTimeout(async () => {
132+
if (!isMounted.current || isRenderingRef.current) return;
133+
134+
isRenderingRef.current = true;
135+
try {
136+
const mermaid = await import('mermaid');
137+
if (isMounted.current) {
138+
try {
139+
await mermaid.default.run();
140+
} catch (error) {
141+
console.error(
142+
'Failed to render mermaid diagrams from mutation:',
143+
error,
144+
);
145+
}
146+
}
147+
} catch (error) {
148+
console.error('Failed to import mermaid:', error);
149+
} finally {
150+
isRenderingRef.current = false;
151+
}
152+
}, 100); // Small debounce delay
153+
}
154+
});
155+
156+
// Start observing the document body
157+
observerRef.current.observe(document.body, {
158+
childList: true,
159+
subtree: true,
160+
});
161+
162+
return () => {
163+
isMounted.current = false;
164+
isRenderingRef.current = false;
165+
initializedRef.current = false;
166+
167+
if (observerRef.current) {
168+
observerRef.current.disconnect();
169+
observerRef.current = null;
170+
}
171+
172+
// Clean up existing Mermaid diagrams
173+
const mermaidElements = document.querySelectorAll('.mermaid');
174+
mermaidElements.forEach((element) => {
175+
element.innerHTML = '';
176+
});
177+
};
178+
}, []);
179+
180+
return null; // No visual output, just side effects
181+
}

plugins/plugin-mermaid.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Plugin } from '@rspress/core';
2+
import * as path from 'path';
3+
4+
interface MermaidPluginOptions {
5+
theme?: 'dark' | 'light' | 'default' | 'base' | 'forest' | 'neutral' | 'null';
6+
themeVariables?: Record<string, string>;
7+
}
8+
9+
export const pluginMermaid = (options: MermaidPluginOptions = {}) => {
10+
return {
11+
name: 'rspress-plugin-mermaid',
12+
globalUIComponents: [path.join(__dirname, 'mermaid-renderer.tsx')],
13+
addMetaHeader: () => [
14+
{
15+
name: 'viewport',
16+
content: 'width=device-width, initial-scale=1.0',
17+
},
18+
],
19+
} as Plugin;
20+
};

rspress.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import * as path from 'node:path';
12
import { defineConfig } from '@rspress/core';
23
import { pluginPreview } from '@rspress/plugin-preview';
34
import { pluginSitemap } from '@rspress/plugin-sitemap';
4-
import * as path from 'node:path';
5+
import { pluginMermaid } from './plugins/plugin-mermaid';
56

67
const PUBLISH_URL = 'https://docs.bloque.app';
78

@@ -26,6 +27,9 @@ export default defineConfig({
2627
pluginSitemap({
2728
siteUrl: PUBLISH_URL,
2829
}),
30+
pluginMermaid({
31+
theme: 'default',
32+
}),
2933
],
3034
route: {
3135
cleanUrls: true,

styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import 'tailwindcss';
2+
@import './mermaid.css';
23

34
@theme {
45
--color-border: rgb(143 161 185 / 0.3);

styles/mermaid.css

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
:root {
2+
--mermaid-bg: #ffffff;
3+
}
4+
5+
html.dark {
6+
--mermaid-bg: #ffffff;
7+
}
8+
9+
10+
html.dark .mermaid {
11+
background: #ffffff;
12+
}
13+
14+
.mermaid svg {
15+
max-width: 100%;
16+
height: auto;
17+
display: block;
18+
margin: 0 auto;
19+
border-radius: 0.5rem;
20+
}
21+
22+
/* Responsive design */
23+
@media (max-width: 1024px) {
24+
.mermaid {
25+
margin: 1.5rem auto;
26+
}
27+
}
28+
29+
@media (max-width: 768px) {
30+
.mermaid {
31+
margin: 1rem auto;
32+
}
33+
34+
.mermaid svg {
35+
transform: scale(0.95);
36+
}
37+
}
38+
39+
@media (max-width: 640px) {
40+
.mermaid svg {
41+
transform: scale(0.85);
42+
}
43+
}
44+
45+
/* Print styles */
46+
@media print {
47+
.mermaid {
48+
page-break-inside: avoid;
49+
}
50+
}
51+
52+
/* Error handling */
53+
.mermaid-error {
54+
padding: 1.5rem;
55+
margin: 2rem 0;
56+
background-color: #fef2f2;
57+
border: 1px solid #fecaca;
58+
border-radius: 0.75rem;
59+
color: #991b1b;
60+
font-family:
61+
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New",
62+
monospace;
63+
font-size: 0.875rem;
64+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
65+
}
66+
67+
/* Enhanced error styling that works with neutral theme */
68+
.mermaid-error {
69+
padding: 1.5rem;
70+
margin: 2rem 0;
71+
background-color: #fef2f2;
72+
border: 1px solid #fecaca;
73+
border-radius: var(--mermaid-radius);
74+
color: #991b1b;
75+
font-family:
76+
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New",
77+
monospace;
78+
font-size: 0.875rem;
79+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
80+
transition: all 0.3s ease;
81+
}
82+
83+
html.dark .mermaid-error {
84+
background-color: #7f1d1d;
85+
border-color: #991b1b;
86+
color: #fca5a5;
87+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
88+
}
89+
90+
/* Enhanced SVG styling for better visibility in both themes */
91+
/* Estilo SVG - simple y consistente */
92+
.mermaid svg {
93+
max-width: 100%;
94+
height: auto;
95+
display: block;
96+
margin: 0 auto;
97+
padding: 4%;
98+
}
99+
100+
/* Mejoras sutiles para modo oscuro */
101+
html.dark .mermaid svg {
102+
filter: var(--mermaid-svg-filter);
103+
}

0 commit comments

Comments
 (0)