Ensure mermaid loads client-side and guard runtime loader

This commit is contained in:
cloudneutral 2025-12-16 13:32:42 +08:00
parent 8cae9c670a
commit da45e652c2
8 changed files with 167 additions and 66 deletions

View File

@ -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",

View File

@ -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<HTMLDivElement>
@ -37,41 +37,57 @@ export function EditorPreview({
const contentRef = useRef<HTMLDivElement>(null)
const { handlePreviewScroll } = useScrollSync()
const { theme } = useTheme()
const mermaidRef = useRef<Awaited<ReturnType<typeof loadMermaid>>>()
// 初始化 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

View File

@ -22,7 +22,7 @@ export function MermaidRenderer({
try {
// 初始化 mermaid
const currentTheme = getCurrentTheme()
initializeMermaid(currentTheme, config)
await initializeMermaid(currentTheme, config)
// 清空容器内容
containerRef.current.innerHTML = ''

View File

@ -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<string, string>()
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
}

View File

@ -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<MermaidModule> | null = null
export const loadMermaid = async (): Promise<MermaidModule | null> => {
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)

View File

@ -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<MermaidConfig>) => {
const defaultConfig = createMermaidConfig(theme)
mermaid.initialize({
...defaultConfig,
...config
})
export const initializeMermaid = async (theme: MermaidTheme = 'default', config?: Partial<MermaidConfig>) => {
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<MermaidConfig>
): 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<HTMLElement>(selector)
if (!elements.length) return

View File

@ -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`)

View File

@ -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"