Ensure mermaid loads client-side and guard runtime loader
This commit is contained in:
parent
8cae9c670a
commit
da45e652c2
@ -37,6 +37,7 @@
|
|||||||
"@tiptap/react": "^2.10.2",
|
"@tiptap/react": "^2.10.2",
|
||||||
"@tiptap/starter-kit": "^2.10.2",
|
"@tiptap/starter-kit": "^2.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { templates } from '../../../config/wechat-templates'
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { type CodeThemeId } from '../../../config/code-themes'
|
import { type CodeThemeId } from '../../../config/code-themes'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import mermaid from 'mermaid'
|
|
||||||
import { useScrollSync } from '../hooks/useScrollSync'
|
import { useScrollSync } from '../hooks/useScrollSync'
|
||||||
|
import { loadMermaid } from '../../../lib/markdown/mermaid-utils'
|
||||||
|
|
||||||
interface EditorPreviewProps {
|
interface EditorPreviewProps {
|
||||||
previewRef: React.RefObject<HTMLDivElement>
|
previewRef: React.RefObject<HTMLDivElement>
|
||||||
@ -37,41 +37,57 @@ export function EditorPreview({
|
|||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const { handlePreviewScroll } = useScrollSync()
|
const { handlePreviewScroll } = useScrollSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const mermaidRef = useRef<Awaited<ReturnType<typeof loadMermaid>>>()
|
||||||
|
|
||||||
// 初始化 Mermaid
|
// 初始化 Mermaid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mermaid.initialize({
|
let cancelled = false
|
||||||
theme: theme === 'dark' ? 'dark' : 'default',
|
|
||||||
startOnLoad: false,
|
const initializeMermaidTheme = async () => {
|
||||||
securityLevel: 'loose',
|
const mermaid = await loadMermaid()
|
||||||
fontFamily: 'var(--font-sans)',
|
if (!mermaid || cancelled) return
|
||||||
fontSize: 14,
|
|
||||||
flowchart: {
|
mermaid.initialize({
|
||||||
htmlLabels: true,
|
theme: theme === 'dark' ? 'dark' : 'default',
|
||||||
curve: 'basis',
|
startOnLoad: false,
|
||||||
padding: 15,
|
securityLevel: 'loose',
|
||||||
useMaxWidth: false,
|
fontFamily: 'var(--font-sans)',
|
||||||
defaultRenderer: 'dagre-d3'
|
fontSize: 14,
|
||||||
},
|
flowchart: {
|
||||||
sequence: {
|
htmlLabels: true,
|
||||||
useMaxWidth: false,
|
curve: 'basis',
|
||||||
boxMargin: 10,
|
padding: 15,
|
||||||
mirrorActors: false,
|
useMaxWidth: false,
|
||||||
bottomMarginAdj: 2,
|
defaultRenderer: 'dagre-d3'
|
||||||
rightAngles: true,
|
},
|
||||||
showSequenceNumbers: false
|
sequence: {
|
||||||
},
|
useMaxWidth: false,
|
||||||
pie: {
|
boxMargin: 10,
|
||||||
useMaxWidth: true,
|
mirrorActors: false,
|
||||||
textPosition: 0.5,
|
bottomMarginAdj: 2,
|
||||||
useWidth: 800
|
rightAngles: true,
|
||||||
},
|
showSequenceNumbers: false
|
||||||
gantt: {
|
},
|
||||||
useMaxWidth: false,
|
pie: {
|
||||||
leftPadding: 75,
|
useMaxWidth: true,
|
||||||
rightPadding: 20
|
textPosition: 0.5,
|
||||||
}
|
useWidth: 800
|
||||||
})
|
},
|
||||||
|
gantt: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
leftPadding: 75,
|
||||||
|
rightPadding: 20
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mermaidRef.current = mermaid
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeMermaidTheme()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
// 使用 memo 包装预览内容
|
// 使用 memo 包装预览内容
|
||||||
@ -93,6 +109,9 @@ export function EditorPreview({
|
|||||||
// 渲染 Mermaid 图表
|
// 渲染 Mermaid 图表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderMermaid = async () => {
|
const renderMermaid = async () => {
|
||||||
|
const mermaid = mermaidRef.current ?? (await loadMermaid())
|
||||||
|
if (!mermaid) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const elements = document.querySelectorAll('.mermaid')
|
const elements = document.querySelectorAll('.mermaid')
|
||||||
if (!elements.length) return
|
if (!elements.length) return
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function MermaidRenderer({
|
|||||||
try {
|
try {
|
||||||
// 初始化 mermaid
|
// 初始化 mermaid
|
||||||
const currentTheme = getCurrentTheme()
|
const currentTheme = getCurrentTheme()
|
||||||
initializeMermaid(currentTheme, config)
|
await initializeMermaid(currentTheme, config)
|
||||||
|
|
||||||
// 清空容器内容
|
// 清空容器内容
|
||||||
containerRef.current.innerHTML = ''
|
containerRef.current.innerHTML = ''
|
||||||
|
|||||||
@ -1,32 +1,35 @@
|
|||||||
import mermaid from 'mermaid'
|
|
||||||
import type { MermaidConfig } from 'mermaid'
|
import type { MermaidConfig } from 'mermaid'
|
||||||
|
import { loadMermaid } from './mermaid-utils'
|
||||||
|
|
||||||
let initialized = false
|
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 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
|
// Generate a valid ID for mermaid diagrams
|
||||||
function generateMermaidId() {
|
function generateMermaidId() {
|
||||||
return `mermaid-${Math.floor(Math.random() * 100000)}`
|
return `mermaid-${Math.floor(Math.random() * 100000)}`
|
||||||
@ -70,9 +73,16 @@ function processPieChart(definition: string): string {
|
|||||||
// Function to initialize Mermaid diagrams
|
// Function to initialize Mermaid diagrams
|
||||||
export async function initMermaid() {
|
export async function initMermaid() {
|
||||||
try {
|
try {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mermaid = await loadMermaid()
|
||||||
|
if (!mermaid) return
|
||||||
|
|
||||||
// 确保只初始化一次
|
// 确保只初始化一次
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
mermaid.initialize(config)
|
mermaid.initialize(createMermaidConfig())
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import mermaid from 'mermaid'
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
mermaid: {
|
mermaid: {
|
||||||
@ -43,10 +41,35 @@ export const MERMAID_CONFIG = {
|
|||||||
securityLevel: 'loose' as const
|
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
|
* 初始化 Mermaid
|
||||||
*/
|
*/
|
||||||
export const initializeMermaid = async () => {
|
export const initializeMermaid = async () => {
|
||||||
|
const mermaid = await loadMermaid()
|
||||||
|
if (!mermaid || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 初始化配置
|
// 初始化配置
|
||||||
mermaid.initialize(MERMAID_CONFIG)
|
mermaid.initialize(MERMAID_CONFIG)
|
||||||
|
|||||||
@ -1,17 +1,29 @@
|
|||||||
import mermaid from 'mermaid'
|
|
||||||
import type { MermaidConfig } from 'mermaid'
|
import type { MermaidConfig } from 'mermaid'
|
||||||
import type { MermaidError, MermaidTheme } from '../types/mermaid'
|
import type { MermaidError, MermaidTheme } from '../types/mermaid'
|
||||||
import { createMermaidConfig } from '../config/mermaid'
|
import { createMermaidConfig } from '../config/mermaid'
|
||||||
|
import { loadMermaid } from '../mermaid-utils'
|
||||||
|
|
||||||
|
const getMermaid = async () => {
|
||||||
|
const mermaid = await loadMermaid()
|
||||||
|
return mermaid ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 Mermaid
|
* 初始化 Mermaid
|
||||||
*/
|
*/
|
||||||
export const initializeMermaid = (theme: MermaidTheme = 'default', config?: Partial<MermaidConfig>) => {
|
export const initializeMermaid = async (theme: MermaidTheme = 'default', config?: Partial<MermaidConfig>) => {
|
||||||
const defaultConfig = createMermaidConfig(theme)
|
try {
|
||||||
mermaid.initialize({
|
const mermaid = await getMermaid()
|
||||||
...defaultConfig,
|
if (!mermaid) return
|
||||||
...config
|
|
||||||
})
|
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>
|
config?: Partial<MermaidConfig>
|
||||||
): Promise<{ svg: string }> => {
|
): Promise<{ svg: string }> => {
|
||||||
try {
|
try {
|
||||||
|
const mermaid = await getMermaid()
|
||||||
|
if (!mermaid) {
|
||||||
|
throw new Error('Mermaid is only available in the browser environment.')
|
||||||
|
}
|
||||||
|
|
||||||
// 先尝试解析,检查语法错误
|
// 先尝试解析,检查语法错误
|
||||||
await mermaid.parse(content)
|
await mermaid.parse(content)
|
||||||
|
|
||||||
@ -45,6 +62,9 @@ export const renderMermaidDiagram = async (
|
|||||||
*/
|
*/
|
||||||
export const renderMermaidDiagrams = async (selector = '.mermaid') => {
|
export const renderMermaidDiagrams = async (selector = '.mermaid') => {
|
||||||
try {
|
try {
|
||||||
|
const mermaid = await getMermaid()
|
||||||
|
if (!mermaid) return
|
||||||
|
|
||||||
const elements = document.querySelectorAll<HTMLElement>(selector)
|
const elements = document.querySelectorAll<HTMLElement>(selector)
|
||||||
if (!elements.length) return
|
if (!elements.length) return
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,33 @@ import yaml from 'js-yaml'
|
|||||||
// 使用 process.cwd() 获取项目根目录,避免 __dirname 在生产环境的问题
|
// 使用 process.cwd() 获取项目根目录,避免 __dirname 在生产环境的问题
|
||||||
const configDir = path.join(process.cwd(), 'src', 'config')
|
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 {
|
function loadYamlSource(sourceKey: RuntimeSourceKey): string | undefined {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(configDir, `runtime-service-config.${sourceKey}.yaml`)
|
const filePath = path.join(configDir, `runtime-service-config.${sourceKey}.yaml`)
|
||||||
|
|||||||
@ -4032,6 +4032,7 @@ __metadata:
|
|||||||
autoprefixer: "npm:^10.4.16"
|
autoprefixer: "npm:^10.4.16"
|
||||||
baseline-browser-mapping: "npm:^2.8.32"
|
baseline-browser-mapping: "npm:^2.8.32"
|
||||||
class-variance-authority: "npm:^0.7.1"
|
class-variance-authority: "npm:^0.7.1"
|
||||||
|
clsx: "npm:^2.1.1"
|
||||||
dompurify: "npm:^3.2.6"
|
dompurify: "npm:^3.2.6"
|
||||||
eslint: "npm:8.57.0"
|
eslint: "npm:8.57.0"
|
||||||
eslint-config-next: "npm:^15.5.3"
|
eslint-config-next: "npm:^15.5.3"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user