Files

908 lines
28 KiB
TypeScript

/**
* Mermaid runtime integration for FixIt.
*
* This module is responsible for rendering and maintaining Mermaid diagrams after
* bootstrapMermaid() loads Mermaid and optional extensions dynamically.
*
* Main responsibilities:
* - Lazy render Mermaid blocks via IntersectionObserver to reduce initial cost.
* - Render only the currently active diagram layer (light/dark), plus idle-time
* neutral rendering for fallback/print scenarios.
* - Keep Mermaid global initialize() calls serialized to avoid concurrent
* initialize/render cross-talk.
* - Bind pan/zoom behavior and synchronize transforms across light/dark layers
* during theme switches.
* - Wire diagram tab controls (diagram/code switch, zoom/reset/download actions).
* - React to FixIt events (theme switch and `fixit:content-decrypted`)
* and re-observe/re-render affected containers when context changes.
*
* Public entrypoints:
* - initMermaidRuntime(runtime): register loaded runtime dependencies and bind listeners.
* - bootstrapMermaid(options): dynamic-import bootstrap entry used by template script.
*/
import type {
MermaidConfig,
MermaidRuntime,
MermaidRuntimeModule,
PanzoomInstance,
PanzoomTransform,
TabContainerChangedEvent,
} from '../types'
import { eventBus } from '../core/event-bus'
import { isDarkMode } from '../utils'
interface MermaidPanzoomGroup {
container: Element
transform: PanzoomTransform | null
svgs: Set<SVGElement>
lastSvg: SVGElement | null
}
interface RenderOptions {
theme: string
darkMode: boolean
}
let mermaid: MermaidRuntimeModule | null = null
let config: MermaidConfig = {}
let hasBoundGlobalEvents = false
let mermaidContainerObserver: IntersectionObserver | null = null
let mermaidRenderLock: Promise<void> = Promise.resolve()
let mermaidConfigKey = ''
let mermaidIdSeq = 0
let panzoomInstances: WeakMap<SVGElement, PanzoomInstance> | null = null
let panzoomWheelHosts: WeakSet<Element> | null = null
let mermaidPanzoomGroups: WeakMap<Element, MermaidPanzoomGroup> | null = null
/**
* Gets (and initializes) the pan/zoom shared state for a Mermaid diagram container.
* The group keeps transforms in sync across `.mermaid` and `.mermaid-dark` layers.
* @param svg Mermaid SVG element inside a diagram container.
* @returns Shared pan/zoom group state, or null when element is out of supported context.
*/
function getMermaidPanzoomGroup(svg: SVGElement): MermaidPanzoomGroup | null {
if (!svg.closest('.diagram-view'))
return null
const container = svg.closest('.diagram-container')
if (!container)
return null
if (!mermaidPanzoomGroups)
mermaidPanzoomGroups = new WeakMap()
let group = mermaidPanzoomGroups.get(container)
if (!group) {
group = { container, transform: null, svgs: new Set(), lastSvg: null }
mermaidPanzoomGroups.set(container, group)
}
const svgs = container.querySelectorAll<SVGElement>('.mermaid svg, .mermaid-dark svg')
svgs.forEach((item) => {
group?.svgs.add(item)
})
return group
}
/**
* Compares pan/zoom transforms with a small tolerance.
* Used to avoid redundant sync work during repeated theme switches / re-renders.
* @param a Source transform.
* @param b Target transform.
* @returns Whether two transforms are effectively the same.
*/
function isSamePanzoomTransform(a: PanzoomTransform | null, b: PanzoomTransform | null): boolean {
if (!a || !b)
return false
const eps = 1e-3
return (
Math.abs(a.x - b.x) <= eps
&& Math.abs(a.y - b.y) <= eps
&& Math.abs(a.scale - b.scale) <= eps
)
}
/**
* Applies a pan/zoom transform to an instance.
* The pan step is deferred to the next macrotask so zoom can settle first.
* @param instance Panzoom instance.
* @param transform Target transform.
*/
function applyPanzoomTransform(instance: PanzoomInstance | null | undefined, transform: PanzoomTransform | null): void {
if (!instance || !transform)
return
const currentTransform: PanzoomTransform = {
scale: instance.getScale(),
...instance.getPan(),
}
if (isSamePanzoomTransform(currentTransform, transform))
return
instance.zoom(transform.scale, { animate: false, force: true })
setTimeout(() => instance.pan(transform.x, transform.y, { animate: false, force: true }))
}
/**
* Schedule low-priority work without blocking critical rendering.
* @param task Deferred task callback.
* @param timeout Timeout in milliseconds when browser supports requestIdleCallback.
*/
function runWhenIdle(task: () => void, timeout = 0): void {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(task, { timeout })
return
}
window.setTimeout(task, 80)
}
/**
* Checks whether a node is currently visible and should be rendered first.
* @param node Candidate Mermaid host element.
* @returns Whether node is currently visible and renderable.
*/
function isNodeVisuallyActive(node: Element): boolean {
if (!isRenderContextActive(node))
return false
const nodeStyle = getComputedStyle(node)
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden')
return false
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
/**
* Checks whether an element is inside an active renderable context.
* This excludes hidden tab panels / diagram layers and common hidden containers.
* @param el Candidate element.
* @returns Whether element is active in current render context.
*/
function isRenderContextActive(el: Element): boolean {
if (!el.isConnected)
return false
if (el.closest('[hidden], .hidden, [aria-hidden="true"]'))
return false
const diagramView = el.closest('.diagram-view')
if (diagramView?.closest('.diagram-tabs') && !diagramView.classList.contains('active'))
return false
const panel = el.closest('.tab-panel')
if (panel) {
// tab-container uses slot-based hiding; check actual layout visibility
const panelRect = panel.getBoundingClientRect()
if (panelRect.width === 0 && panelRect.height === 0)
return false
}
return true
}
/**
* Gets Mermaid theme name for the current effective color scheme.
* @returns Current Mermaid theme.
*/
function getTheme(): string {
const themes = config.themes ?? ['default', 'dark']
const lightTheme = themes[0] ?? 'default'
const darkTheme = themes[1] ?? lightTheme
return isDarkMode() ? darkTheme : lightTheme
}
/**
* Mermaid uses global runtime config; initialize() affects subsequent render().
* This lock serializes all render work to avoid concurrent initialize/render cross-talk.
* @param task Async render task.
* @returns Task result.
*/
function withMermaidLock<T>(task: () => Promise<T>): Promise<T> {
const run = mermaidRenderLock.then(task, task)
// Keep the chain alive even after errors so subsequent renders are not blocked.
mermaidRenderLock = run.then(
() => undefined,
() => undefined,
)
return run
}
/**
* Initializes Mermaid only when (theme, darkMode) changes.
* @param theme Mermaid theme name.
* @param darkMode Whether dark mode is currently active.
*/
function ensureMermaidInitialized(theme: string, darkMode: boolean): void {
if (!mermaid)
return
const key = `${String(theme)}|${darkMode ? '1' : '0'}`
if (key === mermaidConfigKey)
return
// Mermaid initialize() mutates global runtime behavior for following render() calls.
mermaidConfigKey = key
mermaid.initialize({
startOnLoad: false,
darkMode,
theme,
securityLevel: config.securityLevel,
look: config.look,
layout: config.layout,
fontFamily: config.fontFamily,
altFontFamily: config.fontFamily,
})
}
/**
* Renders a single Mermaid host element (`.mermaid`, `.mermaid-dark`, `.mermaid-neutral`).
* Source uses textContent to avoid HTML entities breaking Mermaid parsing.
* @param el Mermaid host element.
* @param options Render options with theme and dark mode.
*/
async function renderMermaidElement(el: Element | null, options: RenderOptions): Promise<void> {
if (!el || !mermaid || el.hasAttribute('data-processed') || el.hasAttribute('data-processing'))
return
if (!isRenderContextActive(el))
return
const source = el.textContent?.trim() ?? ''
if (!source) {
el.setAttribute('data-processed', '')
return
}
await withMermaidLock(async () => {
if (!el || !mermaid || el.hasAttribute('data-processed') || !isRenderContextActive(el))
return
// Mark processing to prevent duplicate renders from intersect/theme/tab events.
if (!el.hasAttribute('data-processing'))
el.setAttribute('data-processing', '')
ensureMermaidInitialized(options.theme, options.darkMode)
const id = `fixit-mermaid-${++mermaidIdSeq}`
try {
const result = await mermaid.render(id, source, el)
const svg = result?.svg ?? ''
svg && (el.innerHTML = svg)
if (typeof result?.bindFunctions === 'function') {
try {
result.bindFunctions(el)
}
catch {
// Ignore bind errors from third-party diagrams.
}
}
const svgEl = el.querySelector<SVGElement>('svg')
if (svgEl && !svgEl.id)
svgEl.id = id
el.removeAttribute('data-processing')
el.setAttribute('data-processed', '')
const svgId = svgEl?.id ?? id
if (svgId) {
const renderedSvg = document.getElementById(svgId)
if (renderedSvg instanceof SVGElement)
bindMermaidPanzoom(renderedSvg)
}
}
catch (error) {
const errDiv = el.querySelector<HTMLElement>(`#d${id}`)
if (errDiv) {
el.innerHTML = errDiv.innerHTML
}
el.removeAttribute('data-processing')
el.setAttribute('data-processed', '')
console.warn('FixIt Mermaid render failed:', error)
}
})
}
/**
* Collect all diagram containers that contain Mermaid elements.
* @param root Search root.
* @returns Diagram containers that contain Mermaid blocks.
*/
function collectMermaidContainers(root: ParentNode = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>('.diagram-container'))
.filter(container => container.querySelector('.mermaid, .mermaid-dark, .mermaid-neutral'))
}
/**
* Core rule: render only when the active layer is visible and not rendered yet.
* Active layer is `.mermaid` in light mode or `.mermaid-dark` in dark mode.
* @param container Diagram container.
*/
async function renderActiveLayerInContainer(container: Element | null): Promise<void> {
if (!container)
return
const darkMode = isDarkMode()
const el = container.querySelector(darkMode ? '.mermaid-dark' : '.mermaid')
if (!el)
return
if (el.hasAttribute('data-processed') || el.querySelector('svg'))
return
if (!isNodeVisuallyActive(el))
return
await renderMermaidElement(el, { theme: getTheme(), darkMode })
}
/**
* Neutral theme is not part of live light/dark switching UI.
* Render it in idle time for print / fallback scenarios.
*/
function scheduleNeutralRender(): void {
if (window.matchMedia('only screen and (max-width: 960px)').matches)
return
runWhenIdle(() => {
const neutralNodes = Array.from(document.querySelectorAll<Element>('.mermaid-neutral'))
.filter(el => !el.hasAttribute('data-processed'))
.filter(isRenderContextActive)
neutralNodes.forEach((el) => {
void renderMermaidElement(el, { theme: 'neutral', darkMode: false })
})
})
}
/** Lazy rendering via IntersectionObserver. */
function bindMermaidIntersectionObserver(): void {
if (mermaidContainerObserver)
return
mermaidContainerObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting)
return
const container = entry.target
if (!container || !isRenderContextActive(container))
return
void renderActiveLayerInContainer(container)
})
}, {
root: null,
// 200px preloading margin lets diagrams render slightly before entering viewport.
rootMargin: '200px 0px',
threshold: 0.01,
})
}
/**
* Registers Mermaid containers under root into the observer.
* @param root Search root.
* @param refresh Whether to re-observe matched containers.
*/
function observeMermaidContainers(root: ParentNode = document, refresh = false): void {
bindMermaidIntersectionObserver()
if (!mermaidContainerObserver)
return
const containers = collectMermaidContainers(root)
containers.forEach((container) => {
if (container.hasAttribute('data-mermaid-observed'))
return
container.setAttribute('data-mermaid-observed', '')
mermaidContainerObserver?.observe(container)
})
if (!refresh)
return
// Force observer callback refresh after context switches (tab/theme/decrypt events).
containers.forEach((container) => {
mermaidContainerObserver?.unobserve(container)
mermaidContainerObserver?.observe(container)
})
}
/**
* Gets the currently visible SVG for a diagram block with multiple Mermaid layers.
* @param root Diagram block root element.
* @returns Visible Mermaid SVG, or null.
*/
function getActiveMermaidSvg(root: Element | null): SVGElement | null {
const nodes = root?.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral')
if (!nodes?.length)
return null
const visible = Array.from(nodes).find((el) => {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden')
return false
const rect = el.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
})
return visible?.querySelector<SVGElement>('svg') ?? null
}
/**
* Enables pan/zoom for a Mermaid SVG inside `.diagram-view`.
* @param svg Mermaid SVG element.
*/
function bindMermaidPanzoom(svg: SVGElement): void {
if (!svg.closest('.diagram-view'))
return
if (!svg.closest('.mermaid, .mermaid-dark'))
return
if (!panzoomInstances)
panzoomInstances = new WeakMap()
if (!panzoomWheelHosts)
panzoomWheelHosts = new WeakSet()
if (panzoomInstances.has(svg))
return
if (typeof window.Panzoom !== 'function')
return
const group = getMermaidPanzoomGroup(svg)
const panzoom = window.Panzoom(svg, {
maxScale: 6,
minScale: 0.2,
step: 0.1,
})
panzoomInstances.set(svg, panzoom)
if (group?.transform)
applyPanzoomTransform(panzoom, group.transform)
const host = svg.closest<HTMLElement>('.mermaid, .mermaid-dark')
if (!host)
return
if (panzoomWheelHosts.has(host))
return
panzoomWheelHosts.add(host)
host.addEventListener('pointerdown', () => {
const currentSvg = host.querySelector<SVGElement>('svg')
if (group && currentSvg)
group.lastSvg = currentSvg
}, false)
host.addEventListener('wheel', (event: WheelEvent) => {
if (!event.ctrlKey)
return
const currentSvg = host.querySelector<SVGElement>('svg')
const instance = currentSvg ? panzoomInstances?.get(currentSvg) : null
if (!instance)
return
if (group && currentSvg)
group.lastSvg = currentSvg
event.preventDefault()
instance.zoomWithWheel(event)
}, { passive: false })
}
/**
* Initializes diagram tabs controls:
* - Diagram/Code tab switching
* - Zoom/reset/download buttons wiring
* @param tabs Diagram tabs root element.
*/
async function initSingleDiagramTabs(tabs: HTMLElement): Promise<void> {
const actions = tabs.querySelector<HTMLElement>('.tabs-actions')
const diagramTabBtn = tabs.querySelector<HTMLElement>('.tab-item[data-tab="diagram"]')
const codeTabBtn = tabs.querySelector<HTMLElement>('.tab-item[data-tab="code"]')
const diagramBlock = tabs.querySelector<HTMLElement>('.diagram-view')
const codeBlock = Array.from(tabs.querySelectorAll<HTMLElement>('.tabs-content > .code-block'))
.find(block => !block.classList.contains('diagram-view'))
if (!actions || !diagramTabBtn || !codeTabBtn || !diagramBlock || !codeBlock)
return
const zoomInBtn = tabs.querySelector<HTMLElement>('.diagram-zoom-in-btn')
const zoomOutBtn = tabs.querySelector<HTMLElement>('.diagram-zoom-out-btn')
const resetBtn = tabs.querySelector<HTMLElement>('.diagram-reset-btn')
const downloadBtn = tabs.querySelector<HTMLElement>('.diagram-download-btn')
tabs.dataset.diagramInit = 'true'
const diagramActionButtons = [zoomOutBtn, zoomInBtn, resetBtn, downloadBtn].filter(Boolean) as HTMLElement[]
const movedActionRestore = new WeakMap<HTMLElement, { parent: HTMLElement, next: ChildNode | null }>()
let codeActionCache: HTMLElement[] = []
const pinnedFullscreenBtn = codeBlock.querySelector<HTMLElement>('.code-header .fullscreen-btn')
if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement !== actions) {
actions.appendChild(pinnedFullscreenBtn)
}
const collectCodeActions = (): HTMLElement[] => {
const codeHeader = codeBlock.querySelector<HTMLElement>('.code-header')
if (codeHeader) {
return Array.from(codeHeader.querySelectorAll<HTMLElement>('.action-btn'))
.filter(btn => btn !== pinnedFullscreenBtn)
}
return Array.from(codeBlock.querySelectorAll<HTMLElement>('.copy-icon-btn'))
}
const getCodeActions = (): HTMLElement[] => {
if (codeActionCache.length && codeActionCache.some(el => el.isConnected))
return codeActionCache
codeActionCache = collectCodeActions()
return codeActionCache
}
const setDiagramActionsVisible = (visible: boolean): void => {
diagramActionButtons.forEach((btn) => {
btn.style.display = visible ? '' : 'none'
})
}
const moveToActions = (elements: HTMLElement[]): void => {
elements.forEach((el) => {
if (!el || el.parentElement === actions)
return
movedActionRestore.set(el, { parent: el.parentElement as HTMLElement, next: el.nextSibling })
if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement === actions) {
actions.insertBefore(el, pinnedFullscreenBtn)
}
else {
actions.appendChild(el)
}
})
}
const restoreMoved = (elements: HTMLElement[]): void => {
elements.forEach((el) => {
const restore = movedActionRestore.get(el)
if (!restore?.parent)
return
if (restore.next && restore.next.parentNode === restore.parent) {
restore.parent.insertBefore(el, restore.next)
}
else {
restore.parent.appendChild(el)
}
movedActionRestore.delete(el)
})
}
const switchTo = (target: 'diagram' | 'code'): void => {
const showDiagram = target === 'diagram'
diagramTabBtn.classList.toggle('active', showDiagram)
codeTabBtn.classList.toggle('active', !showDiagram)
diagramBlock.classList.toggle('active', showDiagram)
codeBlock.classList.toggle('active', !showDiagram)
const codeActions = getCodeActions()
if (showDiagram) {
setDiagramActionsVisible(true)
// Restore code actions back to code header in diagram mode.
restoreMoved(codeActions)
}
else {
setDiagramActionsVisible(false)
// Move code actions into tab action area while code tab is active.
moveToActions(codeActions)
}
}
const resolvePanzoom = (): PanzoomInstance | null => {
const svg = getActiveMermaidSvg(diagramBlock)
return svg && panzoomInstances ? (panzoomInstances.get(svg) ?? null) : null
}
diagramTabBtn.addEventListener('click', () => switchTo('diagram'), false)
codeTabBtn.addEventListener('click', () => switchTo('code'), false)
zoomInBtn?.addEventListener('click', () => {
const panzoom = resolvePanzoom()
if (!panzoom?.zoomIn)
return
try {
panzoom.zoomIn({ animate: true })
}
catch {
panzoom.zoomIn()
}
}, false)
zoomOutBtn?.addEventListener('click', () => {
const panzoom = resolvePanzoom()
if (!panzoom?.zoomOut)
return
try {
panzoom.zoomOut({ animate: true })
}
catch {
panzoom.zoomOut()
}
}, false)
resetBtn?.addEventListener('click', () => {
const panzoom = resolvePanzoom()
if (!panzoom?.reset)
return
try {
panzoom.reset({ animate: true })
}
catch {
panzoom.reset()
}
}, false)
downloadBtn?.addEventListener('click', () => {
const svg = getActiveMermaidSvg(diagramBlock)
if (!svg)
return
const clonedSvg = svg.cloneNode(true)
if (!(clonedSvg instanceof SVGElement))
return
// Remove style attribute (transform etc.) that may interfere with proper rendering in external viewers
clonedSvg.removeAttribute('style')
const xml = new XMLSerializer().serializeToString(clonedSvg)
const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
const filename = diagramBlock.dataset.filename ?? 'mermaid.mmd'
link.href = url
link.download = `${filename.split('.')[0]}.svg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, false)
}
/** Initializes Mermaid diagram tab controls for newly discovered blocks. */
function initDiagramControls(): void {
const tabsList = document.querySelectorAll<HTMLElement>('.diagram-tabs[data-diagram="mermaid"]:not([data-diagram-init])')
tabsList.forEach((tabs) => {
void initSingleDiagramTabs(tabs)
})
}
/** Binds tab switch listener so Mermaid in tab panels renders after tab changes. */
function bindTabContainerChanged(): void {
document.addEventListener('tab-container-changed', (event: TabContainerChangedEvent) => {
const panel = event.panel ?? event.detail?.relatedTarget
if (!panel)
return
observeMermaidContainers(panel, true)
// Also schedule neutral layer render for the newly visible panel
const neutralNodes = panel.querySelectorAll<Element>('.mermaid-neutral')
neutralNodes.forEach((el) => {
if (!el.hasAttribute('data-processed'))
void renderMermaidElement(el, { theme: 'neutral', darkMode: false })
})
}, false)
}
/** Binds theme switch sync for pan/zoom transforms across Mermaid layers. */
function bindThemeSync(): void {
const getActivePanzoomSvg = (container: Element): SVGElement | null => {
const nodes = container.querySelectorAll('.mermaid, .mermaid-dark')
if (!nodes.length)
return null
const visible = Array.from(nodes).find((el) => {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden')
return false
const rect = el.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
})
return visible?.querySelector<SVGElement>('svg') ?? null
}
const safeGetTransformFromSvg = (svg: SVGElement | null): PanzoomTransform | null => {
const instance = svg ? panzoomInstances?.get(svg) : null
if (!instance?.getPan || !instance?.getScale)
return null
try {
const pan = instance.getPan()
const scale = instance.getScale()
if (pan && typeof scale === 'number')
return { x: pan.x, y: pan.y, scale }
}
catch {
return null
}
return null
}
const sync = (nowDark: boolean): void => {
if (!mermaidPanzoomGroups)
return
document.querySelectorAll('.diagram-view .diagram-container').forEach((container) => {
const currentActive = getActivePanzoomSvg(container)
// Read transform from the layer that was visible before theme flip.
const fallbackSource = nowDark
? container.querySelector<SVGElement>('.mermaid svg')
: container.querySelector<SVGElement>('.mermaid-dark svg')
const groupSeed = currentActive ?? fallbackSource
if (!groupSeed)
return
const group = getMermaidPanzoomGroup(groupSeed)
if (!group)
return
const last = group.lastSvg
const sourceSvg = last?.isConnected ? last : (fallbackSource ?? currentActive)
const sourceTransform = safeGetTransformFromSvg(sourceSvg)
if (sourceTransform)
group.transform = sourceTransform
if (!group.transform)
return
if (currentActive) {
const instance = panzoomInstances?.get(currentActive)
applyPanzoomTransform(instance, group.transform)
}
})
}
eventBus.on('fixit:switch-theme', ({ detail }) => {
if (!detail?.isChanged)
return
sync(detail.isDark)
observeMermaidContainers(document, true)
})
}
/** Initializes Mermaid rendering, events, lazy loading and diagram controls. */
function initMermaid(): void {
const mermaidElements = document.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral')
if (!mermaidElements.length)
return
initDiagramControls()
observeMermaidContainers()
scheduleNeutralRender()
}
/** Binds Mermaid global listeners once per page lifecycle. */
function bindGlobalEventsOnce(): void {
if (hasBoundGlobalEvents)
return
hasBoundGlobalEvents = true
// eslint-disable-next-line no-console
console.log(
'%c💫 FixIt Mermaid',
'color: #FF3670; font-weight: bold; font-size: 16px; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);',
)
bindThemeSync()
bindTabContainerChanged()
initMermaid()
eventBus.on('fixit:content-decrypted', initMermaid)
}
/**
* Initializes Mermaid runtime with externally loaded dependencies.
* Accepts the core Mermaid module + optional zenuml/layout loaders.
* @param runtime Runtime object with Mermaid module, config and optional extensions.
*/
export async function initMermaidRuntime(runtime: MermaidRuntime): Promise<void> {
mermaid = runtime.mermaid
config = runtime.config
const externalDiagrams: unknown[] = []
if (runtime.zenuml)
externalDiagrams.push(runtime.zenuml)
if (externalDiagrams.length && typeof mermaid.registerExternalDiagrams === 'function') {
try {
await mermaid.registerExternalDiagrams(externalDiagrams)
}
catch (error) {
console.warn('FixIt Mermaid registerExternalDiagrams failed:', error)
}
}
const loaders: unknown[] = []
runtime.loaders.forEach((item) => {
if (Array.isArray(item))
loaders.push(...item)
else
loaders.push(item)
})
if (loaders.length && typeof mermaid.registerLayoutLoaders === 'function') {
try {
mermaid.registerLayoutLoaders(loaders)
}
catch (error) {
console.warn('FixIt Mermaid registerLayoutLoaders failed:', error)
}
}
mermaid.startOnLoad = false
window.mermaid = mermaid
document.addEventListener('DOMContentLoaded', bindGlobalEventsOnce, { once: true })
if (document.readyState !== 'loading')
bindGlobalEventsOnce()
}
/**
* Unwraps ESM default export shape used by third-party CDN modules.
* @param mod Imported module object.
* @returns Default export when present, otherwise module itself.
*/
function unwrapModule<T>(mod: { default?: T } | T): T {
if (mod && typeof mod === 'object' && 'default' in mod) {
return (mod as { default?: T }).default as T
}
return mod as T
}
/**
* Bootstrap entry used by template-injected script.
* Loads Mermaid and optional extensions via dynamic import and hands runtime to initMermaidRuntime.
*/
export async function bootstrapMermaid(): Promise<void> {
config = window.config?.mermaid ?? {}
const mermaidSource = config.cdn || 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'
const zenumlSource = config.zenuml || ''
const layoutLoaderSources = config.layoutLoaders || []
try {
// Mermaid core module is required; optional modules degrade gracefully.
const mermaidMod = await import(mermaidSource)
const mermaid = unwrapModule<MermaidRuntimeModule>(mermaidMod)
let zenuml: unknown
if (zenumlSource) {
try {
const zenumlMod = await import(zenumlSource)
zenuml = unwrapModule(zenumlMod)
}
catch (error) {
console.warn('FixIt Mermaid zenuml load failed:', error)
}
}
const loaded = await Promise.all(layoutLoaderSources.map(async (source) => {
try {
const loaderMod = await import(source)
return unwrapModule(loaderMod)
}
catch (error) {
console.warn('FixIt Mermaid layout loader load failed:', source, error)
return null
}
}))
const runtime: MermaidRuntime = {
mermaid,
config,
zenuml,
// Keep only successfully loaded optional loaders.
loaders: loaded.filter(item => item !== null),
}
await initMermaidRuntime(runtime)
}
catch (error) {
console.warn('FixIt Mermaid bootstrap failed:', error)
}
}