2738 words
14 minutes
Astro with Mermaid

设置 Mermaid in Astro#

1)安装 Mermaid#

在项目根目录执行:

npm i mermaid

如果你用 pnpm:

pnpm add mermaid

如果你用 yarn:

yarn add mermaid

2)添加src/components/Mermaid.astro#

src/components/Mermaid.astro
---
// 完整交互版:主界面滚动条 + Ctrl缩放(zoom) + 双击全屏
// 全屏:拖拽平移 + Ctrl缩放(transform)
---
<script>
import mermaid from "mermaid";
const isDark = document.documentElement.classList.contains("dark");
console.log("isDark =", isDark);
const BASE_FONT = 11;
mermaid.initialize({
startOnLoad: false,
theme: isDark ? "dark" : "default",
securityLevel: "loose",
// ✅ Mermaid v10+ 类型不允许直接写 flowchart.fontSize / sequence.fontSize 等
// ✅ 统一通过 themeVariables + CSS 强制字体大小更稳定
themeVariables: {
fontSize: `${BASE_FONT}px`,
fontFamily: "Arial, sans-serif",
},
});
function logMermaidAllLabels(): void {
document.querySelectorAll<SVGSVGElement>(".mermaid svg").forEach((svg, svgIndex) => {
console.log(`====== [SVG ${svgIndex}] ======`);
// 1) 普通 svg text
svg.querySelectorAll<SVGTextElement>("text").forEach((node, i) => {
const style = getComputedStyle(node);
console.log(
`[text ${i}]`,
node.textContent?.trim(),
"font-size:",
style.fontSize,
"font-family:",
style.fontFamily
);
});
// 2) foreignObject (HTML label)
svg.querySelectorAll<SVGForeignObjectElement>("foreignObject").forEach((fo, i) => {
const div = fo.querySelector<HTMLElement>("div, span, p") || (fo as unknown as HTMLElement);
const style = getComputedStyle(div);
console.log(
`[foreignObject ${i}]`,
div.textContent?.trim(),
"font-size:",
style.fontSize,
"font-family:",
style.fontFamily
);
});
});
}
function forceSvgOverflow(div: HTMLElement): void {
const wrapper = div.querySelector<HTMLElement>(".mermaid-interactive-wrapper");
const svg = div.querySelector<SVGSVGElement>(".mermaid-interactive-wrapper svg");
if (!wrapper || !svg) return;
// ✅ 永远不要让 svg 自动 fit 容器
svg.style.width = "auto";
svg.style.maxWidth = "none";
svg.style.height = "auto";
svg.style.display = "block";
requestAnimationFrame(() => {
const wrapperWidth = wrapper.clientWidth;
// ✅ 优先使用 viewBox,更稳定(特别是 gantt)
const vb = svg.viewBox?.baseVal;
const viewBoxWidth = vb?.width || 0;
// fallback:bbox
const bboxWidth = svg.getBBox().width || 0;
const realWidth = Math.ceil((viewBoxWidth || bboxWidth) + 40);
// ✅ 用真实宽度撑开 svg
svg.style.width = `${realWidth}px`;
// ✅ 超出才滚动
if (realWidth > wrapperWidth) {
wrapper.classList.add("has-scroll");
} else {
wrapper.classList.remove("has-scroll");
}
console.log(
"[forceSvgOverflow]",
"wrapperWidth=",
wrapperWidth,
"realWidth=",
realWidth,
"viewBoxWidth=",
viewBoxWidth,
"bboxWidth=",
bboxWidth
);
});
}
function extractMermaidText(container: Element): string {
const code = container.querySelector<HTMLElement>("code");
if (code) {
const text = (code.innerText || code.textContent || "").trim();
return cleanMermaidText(text);
}
const linesNodeList = container.querySelectorAll<HTMLElement>('[class*="line"]:not([class*="number"])');
if (linesNodeList.length > 0) {
const lines = Array.from(linesNodeList) as HTMLElement[];
const text = lines
.map((lineEl) => {
const clone = lineEl.cloneNode(true) as HTMLElement;
clone
.querySelectorAll<HTMLElement>('[class*="number"], .line-number, .ln')
.forEach((el) => el.remove());
return (clone.innerText || clone.textContent || "").trim();
})
.filter((line) => line.length > 0)
.join("\n");
return cleanMermaidText(text);
}
const text = ((container as HTMLElement).innerText || container.textContent || "").trim();
return cleanMermaidText(text);
}
function cleanMermaidText(text: string): string {
const lines = text.split("\n");
const cleanedLines: string[] = [];
for (let line of lines) {
line = line.trim();
if (/^\d+$/.test(line)) continue;
line = line.replace(/^\d+\s+/, "");
if (line.length > 0) cleanedLines.push(line);
}
const result = cleanedLines.join("\n").trim();
console.log("[Mermaid] After cleaning:", result);
return result;
}
function findMermaidBlocks(): Element[] {
const results: Element[] = [];
document.querySelectorAll("pre").forEach((pre) => {
const code = pre.querySelector("code");
const cls = (code?.className || pre.className || "").toLowerCase();
if (cls.includes("mermaid")) {
results.push(pre);
}
});
document.querySelectorAll("figure, div.expressive-code, [class*='astro-code']").forEach((box) => {
const hasMermaidLabel = Array.from(box.querySelectorAll("*")).some((n) => {
const text = (n.textContent || "").trim().toUpperCase();
return text === "MERMAID";
});
if (hasMermaidLabel) {
results.push(box);
}
});
document.querySelectorAll<HTMLElement>('[data-language="mermaid"]').forEach((el) => {
const container = el.closest("figure, div, pre") || el;
results.push(container);
});
return Array.from(new Set(results));
}
// 创建模态框
function createModal(): void {
if (document.getElementById("mermaid-modal")) return;
const modal = document.createElement("div");
modal.id = "mermaid-modal";
modal.className = "mermaid-modal";
modal.innerHTML = `
<div class="mermaid-modal-overlay"></div>
<div class="mermaid-modal-content">
<button class="mermaid-modal-close" aria-label="关闭">×</button>
<div class="mermaid-modal-controls">
<button class="mermaid-zoom-btn" data-action="zoom-in" title="放大">🔍+</button>
<button class="mermaid-zoom-btn" data-action="zoom-out" title="缩小">🔍-</button>
<button class="mermaid-zoom-btn" data-action="reset" title="重置">↺</button>
<span class="mermaid-zoom-level">100%</span>
</div>
<div class="mermaid-modal-body"></div>
</div>
`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector<HTMLButtonElement>(".mermaid-modal-close");
const overlay = modal.querySelector<HTMLElement>(".mermaid-modal-overlay");
const closeModal = () => {
modal.classList.remove("active");
document.body.style.overflow = "";
};
closeBtn?.addEventListener("click", closeModal);
overlay?.addEventListener("click", closeModal);
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeModal();
}
});
}
// 更新缩放比例显示
function updateZoomDisplay(container: HTMLElement, scale: number): void {
const zoomLevel = container.closest(".mermaid-modal")?.querySelector<HTMLElement>(".mermaid-zoom-level");
if (zoomLevel) {
zoomLevel.textContent = `${Math.round(scale * 100)}%`;
}
}
// ✅ 主界面滚动条缩放(使用 zoom,保证 overflow-x 生效)
function makeScrollableZoom(wrapper: HTMLElement, content: HTMLElement): void {
let scale = 1;
const applyZoom = () => {
scale = Math.min(Math.max(scale, 0.5), 3); // ✅ 主界面缩放范围
(content.style as any).zoom = String(scale); // zoom 不是标准属性,TS 可能不认识
console.log("zoom =", (content.style as any).zoom);
};
wrapper.addEventListener(
"wheel",
(e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
scale += delta;
applyZoom();
},
{ passive: false }
);
applyZoom();
}
type TransformController = {
get scale(): number;
set scale(v: number);
get translateX(): number;
set translateX(v: number);
get translateY(): number;
set translateY(v: number);
applyTransform: () => void;
};
// ✅ 全屏交互:拖拽 + transform 缩放(保证永远返回 TransformController)
function makeInteractive(wrapper: HTMLElement, options = { enableDrag: true }): TransformController {
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
let lastTranslateX = 0;
let lastTranslateY = 0;
const svg = wrapper.querySelector<SVGSVGElement>("svg");
const applyTransform = () => {
if (!svg) return;
svg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
svg.style.transformOrigin = "0 0";
svg.style.transition = isDragging ? "none" : "transform 0.15s ease";
};
// ✅ 如果没 SVG,也返回一个空 controller,避免 transform 变 undefined
if (!svg) {
return {
get scale() {
return scale;
},
set scale(v: number) {
scale = v;
},
get translateX() {
return translateX;
},
set translateX(v: number) {
translateX = v;
},
get translateY() {
return translateY;
},
set translateY(v: number) {
translateY = v;
},
applyTransform,
};
}
// ✅ 拖拽(可选)
if (options.enableDrag) {
wrapper.addEventListener("mousedown", (e: MouseEvent) => {
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
lastTranslateX = translateX;
lastTranslateY = translateY;
wrapper.style.cursor = "grabbing";
e.preventDefault();
});
document.addEventListener("mousemove", (e: MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
translateX = lastTranslateX + deltaX;
translateY = lastTranslateY + deltaY;
applyTransform();
});
document.addEventListener("mouseup", () => {
if (isDragging) {
isDragging = false;
wrapper.style.cursor = "grab";
}
});
}
// Ctrl + 滚轮缩放(全屏)
wrapper.addEventListener(
"wheel",
(e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.min(Math.max(0.3, scale + delta), 5);
// 以鼠标位置为中心缩放
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const scaleChange = newScale / scale;
translateX = mouseX - (mouseX - translateX) * scaleChange;
translateY = mouseY - (mouseY - translateY) * scaleChange;
scale = newScale;
applyTransform();
updateZoomDisplay(wrapper, scale);
},
{ passive: false }
);
wrapper.style.cursor = options.enableDrag ? "grab" : "default";
wrapper.style.overflow = "hidden";
applyTransform();
return {
get scale() {
return scale;
},
set scale(v: number) {
scale = v;
},
get translateX() {
return translateX;
},
set translateX(v: number) {
translateX = v;
},
get translateY() {
return translateY;
},
set translateY(v: number) {
translateY = v;
},
applyTransform,
};
}
// 为普通视图添加交互
function addInteractiveFeature(mermaidDiv: HTMLElement): void {
const wrapper = document.createElement("div");
wrapper.className = "mermaid-interactive-wrapper";
const content = document.createElement("div");
content.className = "mermaid-interactive-content";
content.innerHTML = mermaidDiv.innerHTML;
mermaidDiv.innerHTML = "";
wrapper.appendChild(content);
mermaidDiv.appendChild(wrapper);
const hint = document.createElement("div");
hint.className = "mermaid-hint";
// hint.innerHTML = `<strong>提示:</strong> 主界面 Ctrl/⌘ + 滚轮缩放;双击进入全屏;全屏可拖拽平移 + Ctrl/⌘ 缩放`;
mermaidDiv.appendChild(hint);
// ✅ 主界面:滚动条缩放(zoom)
makeScrollableZoom(wrapper, content);
// 双击全屏
content.addEventListener("dblclick", () => {
openFullscreen(mermaidDiv);
});
}
// 打开全屏模态框
function openFullscreen(mermaidDiv: HTMLElement): void {
const modal = document.getElementById("mermaid-modal");
const modalBody = modal?.querySelector<HTMLElement>(".mermaid-modal-body");
if (!modal || !modalBody) return;
const svg = mermaidDiv.querySelector<SVGSVGElement>("svg");
if (!svg) return;
// 克隆 SVG 到模态框
const clonedSvg = svg.cloneNode(true) as SVGSVGElement;
modalBody.innerHTML = "";
modalBody.appendChild(clonedSvg);
const transform = makeInteractive(modalBody, { enableDrag: true });
const zoomIn = modal.querySelector<HTMLButtonElement>('[data-action="zoom-in"]');
const zoomOut = modal.querySelector<HTMLButtonElement>('[data-action="zoom-out"]');
const reset = modal.querySelector<HTMLButtonElement>('[data-action="reset"]');
zoomIn?.addEventListener("click", () => {
const newScale = Math.min(transform.scale + 0.2, 5);
transform.scale = newScale;
transform.applyTransform();
updateZoomDisplay(modalBody, newScale);
});
zoomOut?.addEventListener("click", () => {
const newScale = Math.max(transform.scale - 0.2, 0.3);
transform.scale = newScale;
transform.applyTransform();
updateZoomDisplay(modalBody, newScale);
});
reset?.addEventListener("click", () => {
transform.scale = 1;
transform.translateX = 0;
transform.translateY = 0;
transform.applyTransform();
updateZoomDisplay(modalBody, 1);
});
modal.classList.add("active");
document.body.style.overflow = "hidden";
updateZoomDisplay(modalBody, 1);
}
async function renderMermaid(): Promise<void> {
const blocks = findMermaidBlocks();
console.log("[Mermaid] Found blocks:", blocks.length);
let converted = 0;
for (const container of blocks) {
try {
const text = extractMermaidText(container);
const isMermaid =
text.includes("graph") ||
text.includes("sequenceDiagram") ||
text.includes("classDiagram") ||
text.includes("stateDiagram") ||
text.includes("erDiagram") ||
text.includes("journey") ||
text.includes("gantt") ||
text.includes("pie") ||
text.includes("flowchart");
if (!isMermaid) continue;
const div = document.createElement("div");
div.className = "mermaid mermaid-container";
div.textContent = text;
container.replaceWith(div);
converted++;
} catch (e) {
console.error("[Mermaid] Error processing block:", e);
}
}
console.log("[Mermaid] Converted:", converted);
if (converted > 0) {
try {
await mermaid.run({ querySelector: ".mermaid" });
logMermaidAllLabels();
console.log("[Mermaid] Rendered ✅");
createModal();
document.querySelectorAll<HTMLElement>(".mermaid-container").forEach((div) => {
if (!div.querySelector(".mermaid-interactive-wrapper")) {
addInteractiveFeature(div);
}
// ✅ 强制让 SVG 产生溢出 -> 横向滚动条必出现
forceSvgOverflow(div);
});
} catch (e) {
console.error("[Mermaid] Render failed ❌", e);
}
}
}
// 初始渲染
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
renderMermaid();
});
} else {
renderMermaid();
}
// Swup 支持(window.swup 不是标准类型)
const w = window as any;
if (w.swup) {
w.swup.hooks.on("page:view", renderMermaid);
}
document.addEventListener("swup:page:view", renderMermaid);
</script>
<style is:global>
/* 基础容器 */
.mermaid-container {
width: 100%;
margin: 2rem 0;
position: relative;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
background: #f8fafc;
}
:global(.dark) .mermaid-container {
border-color: #374151;
background: #1f2937;
}
/* ✅ 主界面滚动容器:横向滚动条 */
.mermaid-interactive-wrapper {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
position: relative;
border-radius: 4px;
background: white;
scrollbar-width: thin;
}
:global(.dark) .mermaid-interactive-wrapper {
background: #111827;
}
/* ✅ 内容容器必须由内容撑开,否则不会溢出 -> 没滚动条 */
.mermaid-interactive-content {
display: inline-block;
width: max-content;
height: auto;
}
.mermaid-interactive-content svg {
display: block;
max-width: none !important;
height: auto;
}
/* ✅ 主界面字体强制(Mermaid 不同图类型都能覆盖到) */
.mermaid-interactive-content svg text,
.mermaid-interactive-content svg .nodeLabel,
.mermaid-interactive-content svg .edgeLabel,
.mermaid-interactive-content svg .label,
.mermaid-interactive-content svg tspan {
font-size: clamp(10px, 1em, 14px) !important;
font-family: Arial, sans-serif !important;
}
/* 提示文字 */
.mermaid-hint {
text-align: center;
font-size: 0.875rem;
color: #64748b;
margin-top: 1rem;
padding: 0.5rem;
background: #f1f5f9;
border-radius: 4px;
}
.mermaid-hint strong {
color: #475569;
font-weight: 600;
}
:global(.dark) .mermaid-hint {
color: #94a3b8;
background: #334155;
}
:global(.dark) .mermaid-hint strong {
color: #cbd5e1;
}
/* 模态框 */
.mermaid-modal {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
align-items: center;
justify-content: center;
}
.mermaid-modal.active {
display: flex;
}
.mermaid-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(4px);
}
.mermaid-modal-content {
position: relative;
width: 95vw;
height: 95vh;
background: white;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1;
}
:global(.dark) .mermaid-modal-content {
background: #1f2937;
}
/* 关闭按钮 */
.mermaid-modal-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 2rem;
line-height: 1;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
/* 控制面板 */
.mermaid-modal-controls {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
align-items: center;
background: rgba(0, 0, 0, 0.8);
padding: 0.75rem 1.5rem;
border-radius: 100px;
z-index: 10;
backdrop-filter: blur(8px);
}
.mermaid-zoom-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 1rem;
cursor: pointer;
}
.mermaid-zoom-level {
color: white;
font-size: 0.875rem;
font-weight: 600;
min-width: 50px;
text-align: center;
margin: 0 0.5rem;
}
.mermaid-modal-body {
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* ✅ 模态框字体也 clamp(10~14) */
.mermaid-modal-body svg text,
.mermaid-modal-body svg .nodeLabel,
.mermaid-modal-body svg .edgeLabel,
.mermaid-modal-body svg .label,
.mermaid-modal-body svg tspan {
font-size: clamp(10px, 1em, 14px) !important;
font-family: Arial, sans-serif !important;
}
/* 响应式 */
@media (max-width: 768px) {
.mermaid-modal-content {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.mermaid-modal-controls {
bottom: 1rem;
padding: 0.5rem 1rem;
}
.mermaid-zoom-btn {
width: 32px;
height: 32px;
font-size: 0.875rem;
}
.mermaid-hint {
font-size: 0.75rem;
}
}
</style>

3)修改src/layouts/Layout.astro#

---
.....
+ import Mermaid from "../components/Mermaid.astro";
---
<body class=" min-h-screen transition " class:list={[{"lg:is-home": isHomePage, "enable-banner": enableBanner}]}
data-overlayscrollbars-initialize
>
<ConfigCarrier></ConfigCarrier>
+ <Mermaid client:load />
+ <slot />
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>

4) 修改样式src/styles/global.css#

.mermaid-interactive-wrapper {
width: 100%;
overflow-x: auto !important; /* ✅ 溢出才显示滚动条 */
overflow-y: hidden !important;
background: transparent;
}
/* ✅ 滚动条高度(不要太大,否则会挤压图) */
.mermaid-interactive-wrapper::-webkit-scrollbar {
height: 14px;
}
/* ✅ 轨道 */
.mermaid-interactive-wrapper::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 999px;
}
/* ✅ thumb */
.mermaid-interactive-wrapper::-webkit-scrollbar-thumb {
background: #94a3b8;
border-radius: 999px;
/* ✅ 让 thumb 变细并居中 */
border: 4px solid #f1f5f9;
background-clip: padding-box;
}
/* hover */
.mermaid-interactive-wrapper::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
:global(.dark) .mermaid-interactive-wrapper::-webkit-scrollbar-track {
background: #1f2937;
}
:global(.dark) .mermaid-interactive-wrapper::-webkit-scrollbar-thumb {
background: #475569;
border: 4px solid #1f2937;
background-clip: padding-box;
}
:global(.dark) .mermaid-interactive-wrapper::-webkit-scrollbar-thumb:hover {
background: #64748b;
}

Mermaid 测试#

在你的 Markdown 文件中添加以下测试代码:

测试案例#

流程图#

graph TD
A[开始] --> B{是否成功?}
B -->|是| C[完成]
B -->|否| D[重试]
D --> A

时序图#

sequenceDiagram
participant A as 用户
participant B as 系统
A->>B: 发送请求
B-->>A: 返回响应

类图#

classDiagram
class Animal {
+String name
+int age
+makeSound()
}
class Dog {
+bark()
}
Animal <|-- Dog

时序图#

sequenceDiagram
participant A as 用户
participant B as 系统
A->>B: 发送请求
B-->>A: 返回响应

类图#

classDiagram
class Animal {
+String name
+int age
+makeSound()
}
class Dog {
+bark()
}
Animal <|-- Dog

状态图#

stateDiagram-v2
[*] --> 待处理
待处理 --> 处理中
处理中 --> 已完成
处理中 --> 失败
失败 --> 待处理
已完成 --> [*]

甘特图#

gantt
title 项目计划
dateFormat YYYY-MM-DD
section 设计
需求分析 :a1, 2024-01-01, 30d
UI设计 :a2, after a1, 20d
section 开发
后端开发 :b1, 2024-02-01, 45d
前端开发 :b2, 2024-02-10, 40d

饼图#

pie title 技术栈占比
"JavaScript" : 45
"Python" : 30
"Go" : 15
"其他" : 10

如果显示正常现在你应该能在页面上看到渲染好的图表了!🎉

控制台检测#

请检查:

  1. 浏览器控制台是否显示 [Mermaid] Rendered ✅
  2. 控制台中 “Cleaned text” 的内容是否正确(没有行号)
  3. 页面上是否有 <div class="mermaid"> 元素

如果需要,可以截图或复制控制台的输出,我可以继续帮你调试。

额外优化建议#

如果一切正常,你还可以添加暗色主题支持:

<script>
import mermaid from "mermaid";
// 检测主题
const isDark = document.documentElement.classList.contains('dark');
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default', // 👈 根据主题切换
securityLevel: 'loose',
});
// ... 其余代码
</script>

这样 Mermaid 图表会自动适配你网站的深色/浅色主题!

Astro with Mermaid
https://lxy-alexander.github.io/blog/posts/guide/astro-with-mermaid/
Author
Alexander Lee
Published at
2026-02-01
License
CC BY-NC-SA 4.0