Skip to content

Commit f7164ce

Browse files
authored
feat: bundle mermaid and elk directly into DS (#732)
1 parent bb7c858 commit f7164ce

File tree

10 files changed

+2921
-191
lines changed

10 files changed

+2921
-191
lines changed

.github/workflows/cd.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ jobs:
4242
with:
4343
node-version: ${{ steps.engines.outputs.nodeVersion }}
4444
registry-url: 'https://registry.npmjs.org'
45-
- name: 'Install Dependencies'
46-
run: yarn install --immutable
47-
- name: 'Clean & Build'
48-
run: yarn clean && yarn build
45+
- name: 'Install Dependencies and Clean'
46+
run: yarn install --immutable && yarn clean
4947
- name: Semantic Release
5048
uses: cycjimmy/semantic-release-action@v3
5149
env:

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,16 @@
6060
"@tanstack/react-virtual": "3.13.12",
6161
"chroma-js": "2.4.2",
6262
"classnames": "2.3.2",
63+
"d3": "7.9.0",
6364
"dayjs": "1.11.13",
65+
"elkjs": "0.11.0",
6466
"highlight.js": "11.11.1",
6567
"honorable": "1.0.0-beta.17",
6668
"honorable-recipe-mapper": "0.2.0",
6769
"honorable-theme-default": "1.0.0-beta.5",
6870
"immer": "10.0.3",
6971
"lodash-es": "4.17.21",
72+
"mermaid": "11.4.1",
7073
"react-animate-height": "3.2.3",
7174
"react-aria": "3.44.0",
7275
"react-embed": "3.7.0",
@@ -94,6 +97,7 @@
9497
"@storybook/react-vite": "9.1.10",
9598
"@testing-library/jest-dom": "5.17.0",
9699
"@types/chroma-js": "2.4.3",
100+
"@types/d3": "7.4.3",
97101
"@types/lodash-es": "4.17.12",
98102
"@types/react": "19.1.9",
99103
"@types/react-dom": "19.1.7",
@@ -153,4 +157,4 @@
153157
"eslint --fix"
154158
]
155159
}
156-
}
160+
}

src/components/Mermaid.tsx

Lines changed: 86 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
useLayoutEffect,
77
useState,
88
} from 'react'
9+
import mermaid from 'mermaid'
10+
import elkLayouts from './mermaid-elk/layouts'
911
import {
1012
CheckIcon,
1113
CopyIcon,
@@ -18,9 +20,21 @@ import { useCopyText } from './Code'
1820
import Highlight from './Highlight'
1921
import { PanZoomWrapper } from './PanZoomWrapper'
2022

21-
const MERMAID_CDN_URL =
22-
'https://cdn.jsdelivr.net/npm/[email protected]/dist/mermaid.min.js'
23-
const NOT_LOADED_ERROR = 'Mermaid not loaded'
23+
// Initialize mermaid once at module load
24+
let initialized = false
25+
const initializeMermaid = () => {
26+
if (initialized) return
27+
mermaid.initialize({ startOnLoad: false })
28+
29+
// Register ELK layout with mermaid
30+
try {
31+
mermaid.registerLayoutLoaders(elkLayouts)
32+
} catch (err) {
33+
console.error('Failed to register ELK layout with mermaid:', err)
34+
}
35+
36+
initialized = true
37+
}
2438

2539
// helps prevent flickering (and potentially expensive recalculations) in virutalized lists
2640
// need to do this outside of React lifecycle memoization (useMemo etc) so it can persist across component mounts/unmounts
@@ -65,34 +79,41 @@ export function Mermaid({
6579
setError(cached instanceof Error ? cached : null)
6680
return
6781
}
68-
let numRetries = 0
69-
let pollTimeout: NodeJS.Timeout | null = null
70-
// poll for when window.mermaid becomes available
71-
const checkAndRender = async () => {
82+
83+
let isCancelled = false
84+
85+
const renderDiagram = async () => {
7286
try {
7387
setIsLoading(true)
7488
setError(null)
75-
setSvgStr(await renderMermaid(diagram))
76-
setIsLoading(false)
77-
} catch (caughtErr) {
78-
let err = caughtErr
79-
if (!(caughtErr instanceof Error)) err = new Error(caughtErr)
80-
// if not loaded yet, wait and retry
81-
if (err.message.includes(NOT_LOADED_ERROR) && numRetries < 50) {
82-
pollTimeout = setTimeout(checkAndRender, 150)
83-
numRetries++
84-
} else {
85-
console.error('Error parsing Mermaid (rendering plaintext):', err)
86-
setError(err)
89+
90+
// Initialize mermaid if not already done
91+
initializeMermaid()
92+
93+
const { svg } = await mermaid.render(id, diagram)
94+
cachedRenders[id] = svg
95+
96+
if (!isCancelled) {
97+
setSvgStr(svg)
8798
setIsLoading(false)
88-
cachedRenders[id] = err
8999
}
100+
} catch (caughtErr) {
101+
if (isCancelled) return
102+
const err =
103+
caughtErr instanceof Error ? caughtErr : new Error(String(caughtErr))
104+
console.error('Error parsing Mermaid (rendering plaintext):', err)
105+
setError(err)
106+
setIsLoading(false)
107+
cachedRenders[id] = err
90108
}
91109
}
92-
checkAndRender()
93110

94-
return () => clearTimeout(pollTimeout)
95-
}, [diagram, setError, svgStr])
111+
renderDiagram()
112+
113+
return () => {
114+
isCancelled = true
115+
}
116+
}, [diagram, setError])
96117

97118
if (error)
98119
return (
@@ -105,76 +126,51 @@ export function Mermaid({
105126
)
106127

107128
return (
108-
<>
109-
<script
110-
async
111-
src={MERMAID_CDN_URL}
112-
/>
113-
<PanZoomWrapper
114-
key={panZoomKey}
115-
actionButtons={
116-
<>
117-
<IconFrame
118-
clickable
119-
onClick={() => setPanZoomKey((key) => key + 1)}
120-
icon={<ReloadIcon />}
121-
type="floating"
122-
tooltip="Reset view to original size"
123-
/>
124-
<IconFrame
125-
clickable
126-
onClick={handleCopy}
127-
icon={copied ? <CheckIcon /> : <CopyIcon />}
128-
type="floating"
129-
tooltip="Copy Mermaid code"
130-
/>
131-
<IconFrame
132-
clickable
133-
onClick={() => svgStr && downloadMermaidSvg(svgStr)}
134-
icon={<DownloadIcon />}
135-
type="floating"
136-
tooltip="Download as PNG"
137-
/>
138-
</>
139-
}
140-
{...props}
141-
>
142-
{isLoading ? (
143-
<div css={{ color: styledTheme.colors.grey[950] }}>
144-
Loading diagram...
145-
</div>
146-
) : (
147-
svgStr && (
148-
<div
149-
dangerouslySetInnerHTML={{ __html: svgStr }}
150-
style={{ textAlign: 'center' }}
151-
/>
152-
)
153-
)}
154-
</PanZoomWrapper>
155-
</>
129+
<PanZoomWrapper
130+
key={panZoomKey}
131+
actionButtons={
132+
<>
133+
<IconFrame
134+
clickable
135+
onClick={() => setPanZoomKey((key) => key + 1)}
136+
icon={<ReloadIcon />}
137+
type="floating"
138+
tooltip="Reset view to original size"
139+
/>
140+
<IconFrame
141+
clickable
142+
onClick={handleCopy}
143+
icon={copied ? <CheckIcon /> : <CopyIcon />}
144+
type="floating"
145+
tooltip="Copy Mermaid code"
146+
/>
147+
<IconFrame
148+
clickable
149+
onClick={() => svgStr && downloadMermaidSvg(svgStr)}
150+
icon={<DownloadIcon />}
151+
type="floating"
152+
tooltip="Download as PNG"
153+
/>
154+
</>
155+
}
156+
{...props}
157+
>
158+
{isLoading ? (
159+
<div css={{ color: styledTheme.colors.grey[950] }}>
160+
Loading diagram...
161+
</div>
162+
) : (
163+
svgStr && (
164+
<div
165+
dangerouslySetInnerHTML={{ __html: svgStr }}
166+
style={{ textAlign: 'center' }}
167+
/>
168+
)
169+
)}
170+
</PanZoomWrapper>
156171
)
157172
}
158173

159-
let initialized = false
160-
const getOrInitializeMermaid = () => {
161-
if (!window.mermaid) return null
162-
if (!initialized) {
163-
window.mermaid.initialize({ startOnLoad: false })
164-
initialized = true
165-
}
166-
return window.mermaid
167-
}
168-
169-
const renderMermaid = async (code: string) => {
170-
const mermaid = getOrInitializeMermaid()
171-
if (!mermaid) throw new Error(NOT_LOADED_ERROR)
172-
const id = getMermaidId(code)
173-
const { svg } = await mermaid.render(id, code)
174-
cachedRenders[id] = svg
175-
return svg
176-
}
177-
178174
export const downloadMermaidSvg = (svgStr: string) => {
179175
const parser = new DOMParser()
180176
const svg = parser.parseFromString(svgStr, 'text/html').querySelector('svg')
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Mermaid ELK Layout Engine
2+
3+
This directory contains code adapted from `@mermaid-js/layout-elk` v0.2.0.
4+
5+
## Source
6+
7+
The code in this directory is taken from the official Mermaid.js ELK layout package:
8+
9+
- **Package**: `@mermaid-js/layout-elk`
10+
- **Version**: 0.2.0
11+
- **Repository**: https://github.com/mermaid-js/mermaid
12+
- **License**: MIT
13+
14+
## Why Bundled?
15+
16+
Instead of installing `@mermaid-js/layout-elk` as a separate package, we've integrated the essential code directly into the design system for the following reasons:
17+
18+
1. **Single ELK instance**: Uses our bundled `elkjs` 0.11.0 (compatible with the original 0.9.3)
19+
2. **No duplication**: Console repo already has `elkjs` for react-flow layouts
20+
3. **Simplicity**: Everything bundled together
21+
4. **Full control**: Direct access to the sophisticated mermaid-specific ELK layout logic
22+
23+
## Files
24+
25+
- **`layouts.ts`**: ELK layout loader definitions for mermaid registration
26+
- **`render.ts`**: Main rendering logic with mermaid-specific ELK optimizations (1090+ lines)
27+
- **`geometry.ts`**: Node/edge intersection and boundary calculations
28+
- **`find-common-ancestor.ts`**: Tree traversal utilities for subgraph handling
29+
30+
## Modifications
31+
32+
The code has been adapted to:
33+
34+
- Use our bundled `elkjs` 0.11.0 from the design system
35+
- Follow our TypeScript and formatting conventions
36+
- Remove package-specific build configurations
37+
38+
## Updates
39+
40+
This code is relatively stable. If updating to a newer version of `@mermaid-js/layout-elk`:
41+
42+
1. Download the new version's source
43+
2. Copy the relevant files from `src/`
44+
3. Ensure imports point to our bundled `elkjs`
45+
4. Run formatting/linting to match our conventions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// @ts-nocheck - Third-party code from @mermaid-js/layout-elk
2+
export interface TreeData {
3+
parentById: Record<string, string>
4+
childrenById: Record<string, string[]>
5+
}
6+
7+
export const findCommonAncestor = (
8+
id1: string,
9+
id2: string,
10+
{ parentById }: TreeData
11+
) => {
12+
const visited = new Set()
13+
let currentId = id1
14+
15+
// Edge case with self edges
16+
if (id1 === id2) {
17+
return parentById[id1] || 'root'
18+
}
19+
20+
while (currentId) {
21+
visited.add(currentId)
22+
if (currentId === id2) {
23+
return currentId
24+
}
25+
currentId = parentById[currentId]
26+
}
27+
28+
currentId = id2
29+
while (currentId) {
30+
if (visited.has(currentId)) {
31+
return currentId
32+
}
33+
currentId = parentById[currentId]
34+
}
35+
36+
return 'root'
37+
}

0 commit comments

Comments
 (0)