From da45e652c2bc4c99cbde3c427d64bb90deb69bd7 Mon Sep 17 00:00:00 2001 From: cloudneutral Date: Tue, 16 Dec 2025 13:32:42 +0800 Subject: [PATCH] Ensure mermaid loads client-side and guard runtime loader --- package.json | 1 + .../editor/components/EditorPreview.tsx | 85 ++++++++++++------- .../markdown/components/MermaidRenderer.tsx | 2 +- .../lib/markdown/mermaid-init.ts | 56 +++++++----- .../lib/markdown/mermaid-utils.ts | 27 +++++- .../lib/markdown/utils/mermaid.ts | 34 ++++++-- src/server/runtime-loader.ts | 27 ++++++ yarn.lock | 1 + 8 files changed, 167 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index f32a5d4..5befde3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@tiptap/react": "^2.10.2", "@tiptap/starter-kit": "^2.10.2", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dompurify": "^3.2.6", "gray-matter": "^4.0.3", "html2canvas": "^1.4.1", diff --git a/src/modules/markdown-editor/components/editor/components/EditorPreview.tsx b/src/modules/markdown-editor/components/editor/components/EditorPreview.tsx index 247ec6b..9182add 100644 --- a/src/modules/markdown-editor/components/editor/components/EditorPreview.tsx +++ b/src/modules/markdown-editor/components/editor/components/EditorPreview.tsx @@ -7,8 +7,8 @@ import { templates } from '../../../config/wechat-templates' import { useState, useRef, useEffect, useMemo } from 'react' import { type CodeThemeId } from '../../../config/code-themes' import { useTheme } from 'next-themes' -import mermaid from 'mermaid' import { useScrollSync } from '../hooks/useScrollSync' +import { loadMermaid } from '../../../lib/markdown/mermaid-utils' interface EditorPreviewProps { previewRef: React.RefObject @@ -37,41 +37,57 @@ export function EditorPreview({ const contentRef = useRef(null) const { handlePreviewScroll } = useScrollSync() const { theme } = useTheme() + const mermaidRef = useRef>>() // 初始化 Mermaid useEffect(() => { - mermaid.initialize({ - theme: theme === 'dark' ? 'dark' : 'default', - startOnLoad: false, - securityLevel: 'loose', - fontFamily: 'var(--font-sans)', - fontSize: 14, - flowchart: { - htmlLabels: true, - curve: 'basis', - padding: 15, - useMaxWidth: false, - defaultRenderer: 'dagre-d3' - }, - sequence: { - useMaxWidth: false, - boxMargin: 10, - mirrorActors: false, - bottomMarginAdj: 2, - rightAngles: true, - showSequenceNumbers: false - }, - pie: { - useMaxWidth: true, - textPosition: 0.5, - useWidth: 800 - }, - gantt: { - useMaxWidth: false, - leftPadding: 75, - rightPadding: 20 - } - }) + let cancelled = false + + const initializeMermaidTheme = async () => { + const mermaid = await loadMermaid() + if (!mermaid || cancelled) return + + mermaid.initialize({ + theme: theme === 'dark' ? 'dark' : 'default', + startOnLoad: false, + securityLevel: 'loose', + fontFamily: 'var(--font-sans)', + fontSize: 14, + flowchart: { + htmlLabels: true, + curve: 'basis', + padding: 15, + useMaxWidth: false, + defaultRenderer: 'dagre-d3' + }, + sequence: { + useMaxWidth: false, + boxMargin: 10, + mirrorActors: false, + bottomMarginAdj: 2, + rightAngles: true, + showSequenceNumbers: false + }, + pie: { + useMaxWidth: true, + textPosition: 0.5, + useWidth: 800 + }, + gantt: { + useMaxWidth: false, + leftPadding: 75, + rightPadding: 20 + } + }) + + mermaidRef.current = mermaid + } + + initializeMermaidTheme() + + return () => { + cancelled = true + } }, [theme]) // 使用 memo 包装预览内容 @@ -93,6 +109,9 @@ export function EditorPreview({ // 渲染 Mermaid 图表 useEffect(() => { const renderMermaid = async () => { + const mermaid = mermaidRef.current ?? (await loadMermaid()) + if (!mermaid) return + try { const elements = document.querySelectorAll('.mermaid') if (!elements.length) return diff --git a/src/modules/markdown-editor/lib/markdown/components/MermaidRenderer.tsx b/src/modules/markdown-editor/lib/markdown/components/MermaidRenderer.tsx index 9c85940..ef0c98c 100644 --- a/src/modules/markdown-editor/lib/markdown/components/MermaidRenderer.tsx +++ b/src/modules/markdown-editor/lib/markdown/components/MermaidRenderer.tsx @@ -22,7 +22,7 @@ export function MermaidRenderer({ try { // 初始化 mermaid const currentTheme = getCurrentTheme() - initializeMermaid(currentTheme, config) + await initializeMermaid(currentTheme, config) // 清空容器内容 containerRef.current.innerHTML = '' diff --git a/src/modules/markdown-editor/lib/markdown/mermaid-init.ts b/src/modules/markdown-editor/lib/markdown/mermaid-init.ts index 49cccb4..97e545e 100644 --- a/src/modules/markdown-editor/lib/markdown/mermaid-init.ts +++ b/src/modules/markdown-editor/lib/markdown/mermaid-init.ts @@ -1,32 +1,35 @@ -import mermaid from 'mermaid' import type { MermaidConfig } from 'mermaid' +import { loadMermaid } from './mermaid-utils' let initialized = false -// Initialize mermaid with default configuration -const config: MermaidConfig = { - startOnLoad: false, - theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default', - securityLevel: 'loose' as const, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif', - themeVariables: { - 'fontSize': '16px', - 'fontFamily': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif', - 'primaryColor': document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5', - 'primaryTextColor': document.documentElement.classList.contains('dark') ? '#fff' : '#000', - 'primaryBorderColor': document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5', - 'lineColor': document.documentElement.classList.contains('dark') ? '#666' : '#999', - 'textColor': document.documentElement.classList.contains('dark') ? '#fff' : '#333' - }, - pie: { - textPosition: 0.75, - useMaxWidth: true - } -} - // 缓存已渲染的图表 const renderedDiagrams = new Map() +const createMermaidConfig = (): MermaidConfig => { + return { + startOnLoad: false, + theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default', + securityLevel: 'loose' as const, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif', + themeVariables: { + fontSize: '16px', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif', + primaryColor: document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5', + primaryTextColor: document.documentElement.classList.contains('dark') ? '#fff' : '#000', + primaryBorderColor: document.documentElement.classList.contains('dark') ? '#7c3aed' : '#4f46e5', + lineColor: document.documentElement.classList.contains('dark') ? '#666' : '#999', + textColor: document.documentElement.classList.contains('dark') ? '#fff' : '#333', + }, + pie: { + textPosition: 0.75, + useMaxWidth: true, + }, + } +} + // Generate a valid ID for mermaid diagrams function generateMermaidId() { return `mermaid-${Math.floor(Math.random() * 100000)}` @@ -70,9 +73,16 @@ function processPieChart(definition: string): string { // Function to initialize Mermaid diagrams export async function initMermaid() { try { + if (typeof window === 'undefined') { + return + } + + const mermaid = await loadMermaid() + if (!mermaid) return + // 确保只初始化一次 if (!initialized) { - mermaid.initialize(config) + mermaid.initialize(createMermaidConfig()) initialized = true } diff --git a/src/modules/markdown-editor/lib/markdown/mermaid-utils.ts b/src/modules/markdown-editor/lib/markdown/mermaid-utils.ts index d49d9aa..0944330 100644 --- a/src/modules/markdown-editor/lib/markdown/mermaid-utils.ts +++ b/src/modules/markdown-editor/lib/markdown/mermaid-utils.ts @@ -1,5 +1,3 @@ -import mermaid from 'mermaid' - declare global { interface Window { mermaid: { @@ -43,10 +41,35 @@ export const MERMAID_CONFIG = { securityLevel: 'loose' as const } +type MermaidModule = typeof import('mermaid') + +let mermaidLoader: Promise | null = null + +export const loadMermaid = async (): Promise => { + if (typeof window === 'undefined') { + return null + } + + if (!mermaidLoader) { + mermaidLoader = import('mermaid').then((module) => { + const mermaidModule = module.default ?? (module as unknown as MermaidModule) + window.mermaid = mermaidModule as unknown as Window['mermaid'] + return mermaidModule + }) + } + + return mermaidLoader +} + /** * 初始化 Mermaid */ export const initializeMermaid = async () => { + const mermaid = await loadMermaid() + if (!mermaid || typeof window === 'undefined') { + return + } + try { // 初始化配置 mermaid.initialize(MERMAID_CONFIG) diff --git a/src/modules/markdown-editor/lib/markdown/utils/mermaid.ts b/src/modules/markdown-editor/lib/markdown/utils/mermaid.ts index 66bb2e9..a7ce7b9 100644 --- a/src/modules/markdown-editor/lib/markdown/utils/mermaid.ts +++ b/src/modules/markdown-editor/lib/markdown/utils/mermaid.ts @@ -1,17 +1,29 @@ -import mermaid from 'mermaid' import type { MermaidConfig } from 'mermaid' import type { MermaidError, MermaidTheme } from '../types/mermaid' import { createMermaidConfig } from '../config/mermaid' +import { loadMermaid } from '../mermaid-utils' + +const getMermaid = async () => { + const mermaid = await loadMermaid() + return mermaid ?? null +} /** * 初始化 Mermaid */ -export const initializeMermaid = (theme: MermaidTheme = 'default', config?: Partial) => { - const defaultConfig = createMermaidConfig(theme) - mermaid.initialize({ - ...defaultConfig, - ...config - }) +export const initializeMermaid = async (theme: MermaidTheme = 'default', config?: Partial) => { + try { + const mermaid = await getMermaid() + if (!mermaid) return + + const defaultConfig = createMermaidConfig(theme) + mermaid.initialize({ + ...defaultConfig, + ...config + }) + } catch (error) { + console.error('Failed to initialize mermaid runtime:', error) + } } /** @@ -22,6 +34,11 @@ export const renderMermaidDiagram = async ( config?: Partial ): Promise<{ svg: string }> => { try { + const mermaid = await getMermaid() + if (!mermaid) { + throw new Error('Mermaid is only available in the browser environment.') + } + // 先尝试解析,检查语法错误 await mermaid.parse(content) @@ -45,6 +62,9 @@ export const renderMermaidDiagram = async ( */ export const renderMermaidDiagrams = async (selector = '.mermaid') => { try { + const mermaid = await getMermaid() + if (!mermaid) return + const elements = document.querySelectorAll(selector) if (!elements.length) return diff --git a/src/server/runtime-loader.ts b/src/server/runtime-loader.ts index d3b1be3..579c421 100644 --- a/src/server/runtime-loader.ts +++ b/src/server/runtime-loader.ts @@ -8,6 +8,33 @@ import yaml from 'js-yaml' // 使用 process.cwd() 获取项目根目录,避免 __dirname 在生产环境的问题 const configDir = path.join(process.cwd(), 'src', 'config') +const FORBIDDEN_IMPORT_CONTEXTS = [ + `${path.sep}src${path.sep}modules${path.sep}markdown-editor${path.sep}`, + `${path.sep}src${path.sep}components${path.sep}ui${path.sep}`, + 'tiptap', + 'mermaid', + 'next-themes', +] + +function assertServerOnlyContext() { + if (typeof window !== 'undefined') { + throw new Error('runtime-loader.ts is server-only and cannot run in the browser.') + } + + if (process.env.NODE_ENV !== 'production') { + const stack = new Error().stack ?? '' + const forbiddenCaller = FORBIDDEN_IMPORT_CONTEXTS.find((pattern) => stack.includes(pattern)) + + if (forbiddenCaller) { + throw new Error( + `[runtime-config] runtime-loader.ts must not be imported alongside UI/editor runtimes (${forbiddenCaller}).`, + ) + } + } +} + +assertServerOnlyContext() + function loadYamlSource(sourceKey: RuntimeSourceKey): string | undefined { try { const filePath = path.join(configDir, `runtime-service-config.${sourceKey}.yaml`) diff --git a/yarn.lock b/yarn.lock index afa5482..3c5f4c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4032,6 +4032,7 @@ __metadata: autoprefixer: "npm:^10.4.16" baseline-browser-mapping: "npm:^2.8.32" class-variance-authority: "npm:^0.7.1" + clsx: "npm:^2.1.1" dompurify: "npm:^3.2.6" eslint: "npm:8.57.0" eslint-config-next: "npm:^15.5.3"