@zombie-mermaid/svg-renderer 2.2.6 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -8
- package/dist/index.cjs +37 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +1436 -893
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/render-mermaid-svg.test.ts +64 -0
- package/src/class/renderer.ts +65 -62
- package/src/edge-curves.ts +17 -16
- package/src/er/renderer.ts +50 -49
- package/src/index.ts +222 -5
- package/src/layout-engine/from-elk.ts +80 -5
- package/src/registry.ts +351 -0
- package/src/renderer.ts +190 -189
- package/src/sequence/renderer.ts +66 -65
- package/src/layout.ts +0 -8
package/src/er/renderer.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
escapeXml as escapeXmlUtil,
|
|
14
14
|
escapeAttr,
|
|
15
15
|
measureMultilineText,
|
|
16
|
+
f,
|
|
16
17
|
} from '@zombie-mermaid/core'
|
|
17
18
|
import { withDataSrc } from '../renderer.ts'
|
|
18
19
|
import {
|
|
@@ -144,19 +145,19 @@ function renderEntityBox(
|
|
|
144
145
|
|
|
145
146
|
// Semantic wrapper with entity metadata
|
|
146
147
|
parts.push(
|
|
147
|
-
`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
|
|
148
|
+
f`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
|
|
148
149
|
)
|
|
149
150
|
|
|
150
151
|
// Outer rectangle
|
|
151
152
|
parts.push(
|
|
152
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
153
|
-
`rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
153
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
154
|
+
f`rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
154
155
|
)
|
|
155
156
|
|
|
156
157
|
// Header background
|
|
157
158
|
parts.push(
|
|
158
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
159
|
-
`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
159
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
160
|
+
f`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
160
161
|
)
|
|
161
162
|
|
|
162
163
|
// Entity name (supports multi-line via <br> tags)
|
|
@@ -167,15 +168,15 @@ function renderEntityBox(
|
|
|
167
168
|
x + width / 2,
|
|
168
169
|
y + headerHeight / 2,
|
|
169
170
|
fontSizes.nodeLabel,
|
|
170
|
-
`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
|
|
171
|
+
f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
|
|
171
172
|
),
|
|
172
173
|
)
|
|
173
174
|
|
|
174
175
|
// Divider
|
|
175
176
|
const attrTop = y + headerHeight
|
|
176
177
|
parts.push(
|
|
177
|
-
` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
178
|
-
`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
178
|
+
f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
179
|
+
f`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
179
180
|
)
|
|
180
181
|
|
|
181
182
|
// Attribute rows
|
|
@@ -190,8 +191,8 @@ function renderEntityBox(
|
|
|
190
191
|
// Empty row placeholder when no attributes
|
|
191
192
|
if (attributes.length === 0) {
|
|
192
193
|
parts.push(
|
|
193
|
-
` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
194
|
-
`font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
|
|
194
|
+
f` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
195
|
+
f`font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
|
|
195
196
|
)
|
|
196
197
|
}
|
|
197
198
|
|
|
@@ -220,7 +221,7 @@ function renderAttribute(
|
|
|
220
221
|
if (attr.comment && attr.comment.length > 0) {
|
|
221
222
|
// Replace <br> with newlines for tooltip display
|
|
222
223
|
const tooltipText = attr.comment.replace(/<br\s*\/?>/gi, '\n')
|
|
223
|
-
parts.push(`<g><title>${escapeXml(tooltipText)}</title>`)
|
|
224
|
+
parts.push(f`<g><title>${escapeXml(tooltipText)}</title>`)
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
// Key badges on the left (keep proportional font — they're visual tags, not code)
|
|
@@ -230,29 +231,29 @@ function renderAttribute(
|
|
|
230
231
|
keyWidth =
|
|
231
232
|
estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8
|
|
232
233
|
parts.push(
|
|
233
|
-
`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
|
|
234
|
+
f`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
|
|
234
235
|
`fill="var(--_key-badge)" />`,
|
|
235
236
|
)
|
|
236
237
|
parts.push(
|
|
237
|
-
`<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
238
|
-
`font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
|
|
238
|
+
f`<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
239
|
+
f`font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
|
|
239
240
|
)
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
// Type (left-aligned after keys, monospace with syntax highlighting)
|
|
243
244
|
const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0)
|
|
244
245
|
parts.push(
|
|
245
|
-
`<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
246
|
-
`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
247
|
-
`<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
|
|
246
|
+
f`<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
247
|
+
f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
248
|
+
f`<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
|
|
248
249
|
)
|
|
249
250
|
|
|
250
251
|
// Name (right-aligned, monospace with syntax highlighting)
|
|
251
252
|
const nameX = boxX + boxWidth - 8
|
|
252
253
|
parts.push(
|
|
253
|
-
`<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
254
|
-
`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
255
|
-
`<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
|
|
254
|
+
f`<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
255
|
+
f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
256
|
+
f`<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
|
|
256
257
|
)
|
|
257
258
|
|
|
258
259
|
// Close the group if we opened one
|
|
@@ -273,23 +274,23 @@ function renderAttribute(
|
|
|
273
274
|
function renderRelationshipLine(rel: PositionedErRelationship): string {
|
|
274
275
|
if (rel.points.length < 2) return ''
|
|
275
276
|
|
|
276
|
-
const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
277
|
+
const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
|
|
277
278
|
const dashArray = !rel.identifying ? ' stroke-dasharray="6 4"' : ''
|
|
278
279
|
|
|
279
280
|
// Semantic data attributes for relationship inspection
|
|
280
|
-
const labelAttr = rel.label ? ` data-label="${escapeAttr(rel.label)}"` : ''
|
|
281
|
+
const labelAttr = rel.label ? f` data-label="${escapeAttr(rel.label)}"` : ''
|
|
281
282
|
const dataAttrs = [
|
|
282
283
|
'class="er-relationship"',
|
|
283
|
-
`data-entity1="${escapeAttr(rel.entity1)}"`,
|
|
284
|
-
`data-entity2="${escapeAttr(rel.entity2)}"`,
|
|
285
|
-
`data-cardinality1="${rel.cardinality1}"`,
|
|
286
|
-
`data-cardinality2="${rel.cardinality2}"`,
|
|
287
|
-
`data-identifying="${rel.identifying}"`,
|
|
284
|
+
f`data-entity1="${escapeAttr(rel.entity1)}"`,
|
|
285
|
+
f`data-entity2="${escapeAttr(rel.entity2)}"`,
|
|
286
|
+
f`data-cardinality1="${rel.cardinality1}"`,
|
|
287
|
+
f`data-cardinality2="${rel.cardinality2}"`,
|
|
288
|
+
f`data-identifying="${rel.identifying}"`,
|
|
288
289
|
]
|
|
289
290
|
|
|
290
291
|
return (
|
|
291
|
-
`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
292
|
-
`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
|
|
292
|
+
f`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
293
|
+
f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
|
|
293
294
|
)
|
|
294
295
|
}
|
|
295
296
|
|
|
@@ -312,14 +313,14 @@ function renderRelationshipLabel(
|
|
|
312
313
|
const bgH = metrics.height + 6
|
|
313
314
|
|
|
314
315
|
return (
|
|
315
|
-
`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
|
|
316
|
+
f`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
|
|
316
317
|
`fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="0.5" />` +
|
|
317
|
-
`\n${renderMultilineText(
|
|
318
|
+
f`\n${renderMultilineText(
|
|
318
319
|
rel.label,
|
|
319
320
|
mid.x,
|
|
320
321
|
mid.y,
|
|
321
322
|
fontSizes.edgeLabel,
|
|
322
|
-
`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
|
+
f`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
324
|
)}`
|
|
324
325
|
)
|
|
325
326
|
}
|
|
@@ -389,17 +390,17 @@ function renderCrowsFoot(
|
|
|
389
390
|
if (hasOneLine) {
|
|
390
391
|
const halfW = 6
|
|
391
392
|
parts.push(
|
|
392
|
-
`<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
|
|
393
|
-
`x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
|
|
394
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
393
|
+
f`<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
|
|
394
|
+
f`x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
|
|
395
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
395
396
|
)
|
|
396
397
|
// Second line slightly back for "exactly one" emphasis
|
|
397
398
|
const line2X = tipX - ux * 4
|
|
398
399
|
const line2Y = tipY - uy * 4
|
|
399
400
|
parts.push(
|
|
400
|
-
`<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
|
|
401
|
-
`x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
|
|
402
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
401
|
+
f`<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
|
|
402
|
+
f`x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
|
|
403
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
403
404
|
)
|
|
404
405
|
}
|
|
405
406
|
|
|
@@ -412,21 +413,21 @@ function renderCrowsFoot(
|
|
|
412
413
|
// Three lines from tip to back, fanning out
|
|
413
414
|
parts.push(
|
|
414
415
|
// Top fan line
|
|
415
|
-
`<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
|
|
416
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
417
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
416
|
+
f`<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
|
|
417
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
418
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
418
419
|
)
|
|
419
420
|
parts.push(
|
|
420
421
|
// Center line
|
|
421
|
-
`<line x1="${cfTipX}" y1="${cfTipY}" ` +
|
|
422
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
423
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
422
|
+
f`<line x1="${cfTipX}" y1="${cfTipY}" ` +
|
|
423
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
424
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
424
425
|
)
|
|
425
426
|
parts.push(
|
|
426
427
|
// Bottom fan line
|
|
427
|
-
`<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
|
|
428
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
429
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
428
|
+
f`<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
|
|
429
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
430
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
430
431
|
)
|
|
431
432
|
}
|
|
432
433
|
|
|
@@ -436,8 +437,8 @@ function renderCrowsFoot(
|
|
|
436
437
|
const circleX = point.x - ux * circleOffset
|
|
437
438
|
const circleY = point.y - uy * circleOffset
|
|
438
439
|
parts.push(
|
|
439
|
-
`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
|
|
440
|
-
`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
440
|
+
f`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
|
|
441
|
+
f`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
441
442
|
)
|
|
442
443
|
}
|
|
443
444
|
|
package/src/index.ts
CHANGED
|
@@ -23,10 +23,9 @@
|
|
|
23
23
|
// #624) call its primitives directly (zombie-mermaid#616), so it is part
|
|
24
24
|
// of the public API alongside `elk-adapter-utils.ts`.
|
|
25
25
|
//
|
|
26
|
-
// `layout.ts` re-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// being folded away here) because #625 is a move, not a redesign.
|
|
26
|
+
// `layout.ts` used to be a thin re-export of `layoutGraphSync` from
|
|
27
|
+
// `layout-engine.ts`; it was folded into `layout-engine.ts` directly
|
|
28
|
+
// (#1109), so `layoutGraphSync` now comes from the one star-export below.
|
|
30
29
|
//
|
|
31
30
|
// `class/`, `er/`, `sequence/`, `xychart/` hold each diagram type's
|
|
32
31
|
// renderer half (`layout.ts` + `renderer.ts`) — the other half
|
|
@@ -35,11 +34,18 @@
|
|
|
35
34
|
// `@zombie-mermaid/mermaid-parser` under the same issue (#624), per the
|
|
36
35
|
// scoping doc's finding 1: each per-type directory used to mix both halves
|
|
37
36
|
// in one place, and the split point already existed at the file level.
|
|
37
|
+
//
|
|
38
|
+
// `renderMermaidSVG` (below) is the single "Mermaid text in, SVG out" front
|
|
39
|
+
// door — moved here from the umbrella's `src/index.ts` under issue #1111,
|
|
40
|
+
// for parity with `@zombie-mermaid/ascii-renderer`'s `renderMermaidASCII`.
|
|
41
|
+
// `./registry.ts` (not re-exported — same as ascii-renderer's own
|
|
42
|
+
// `registry.ts` — it's this front door's internal dispatch table, not part
|
|
43
|
+
// of the public API) drives per-diagram-type dispatch; see that file's
|
|
44
|
+
// header for why it's a separate module from the ASCII side's table.
|
|
38
45
|
// ============================================================================
|
|
39
46
|
|
|
40
47
|
export * from './edge-curves.ts'
|
|
41
48
|
export * from './elk-instance.ts'
|
|
42
|
-
export * from './layout.ts'
|
|
43
49
|
export * from './layout-engine.ts'
|
|
44
50
|
export * from './layout-engine/elk-adapter-utils.ts'
|
|
45
51
|
export * from './layout-engine/elk-graph-builder.ts'
|
|
@@ -56,3 +62,214 @@ export * from './sequence/layout.ts'
|
|
|
56
62
|
export * from './sequence/renderer.ts'
|
|
57
63
|
export * from './xychart/layout.ts'
|
|
58
64
|
export * from './xychart/renderer.ts'
|
|
65
|
+
|
|
66
|
+
import { decodeXML } from 'entities'
|
|
67
|
+
import type {
|
|
68
|
+
RenderOptions,
|
|
69
|
+
DiagramColors,
|
|
70
|
+
SvgEmitOptions,
|
|
71
|
+
DiagramType,
|
|
72
|
+
} from '@zombie-mermaid/core'
|
|
73
|
+
import {
|
|
74
|
+
DEFAULTS,
|
|
75
|
+
themeStyleDeclarations,
|
|
76
|
+
isMonospaceFont,
|
|
77
|
+
setMonospaceMetrics,
|
|
78
|
+
detectDiagramType,
|
|
79
|
+
splitStatements,
|
|
80
|
+
} from '@zombie-mermaid/core'
|
|
81
|
+
import { resolveCssColors } from './resolve-colors.ts'
|
|
82
|
+
import { resolveFontSizes } from './styles.ts'
|
|
83
|
+
import { diagramRegistry } from './registry.ts'
|
|
84
|
+
import type { SvgRenderContext } from './registry.ts'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a DiagramColors object from render options.
|
|
88
|
+
* Uses DEFAULTS for bg/fg when not provided, and passes through
|
|
89
|
+
* optional enrichment colors (line, accent, muted, surface, border).
|
|
90
|
+
*/
|
|
91
|
+
function buildColors(options: RenderOptions): DiagramColors {
|
|
92
|
+
return {
|
|
93
|
+
bg: options.bg ?? DEFAULTS.bg,
|
|
94
|
+
fg: options.fg ?? DEFAULTS.fg,
|
|
95
|
+
line: options.line,
|
|
96
|
+
accent: options.accent,
|
|
97
|
+
muted: options.muted,
|
|
98
|
+
surface: options.surface,
|
|
99
|
+
border: options.border,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The exact CSS declaration list the root `<svg style="…">` attribute would
|
|
105
|
+
* carry for these options — `--bg`, `--fg`, whichever enrichment colours
|
|
106
|
+
* were given, and (unless `transparent`) `background: var(--bg)`.
|
|
107
|
+
*
|
|
108
|
+
* For hosts with a strict `Content-Security-Policy`: a `style=` attribute
|
|
109
|
+
* can't be nonced, so a `style-src` without `'unsafe-inline'` drops it and
|
|
110
|
+
* the diagram loses its colours. Render with `styleAttribute: false` and
|
|
111
|
+
* put this string in your own stylesheet on the SVG (or any ancestor —
|
|
112
|
+
* custom properties inherit) instead. Pass the same options object to both
|
|
113
|
+
* calls so the declarations match what the render expects. See
|
|
114
|
+
* `RenderOptions.styleAttribute` / `RenderOptions.nonce` and issue #216.
|
|
115
|
+
*
|
|
116
|
+
* Built by the same function that fills the attribute in normal renders,
|
|
117
|
+
* so there is one variable list to keep in sync. The string is compact
|
|
118
|
+
* (`--bg:#fff;--fg:#000;background:var(--bg)`) — valid inside any rule
|
|
119
|
+
* block — and the colour values are yours, unescaped, exactly as the
|
|
120
|
+
* attribute has always carried them.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const opts = { bg: '#1a1b26', fg: '#a9b1d6', nonce, styleAttribute: false }
|
|
125
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', opts)
|
|
126
|
+
* const css = `.diagram svg { ${themeCssVariables(opts)} }`
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function themeCssVariables(options: RenderOptions = {}): string {
|
|
130
|
+
return themeStyleDeclarations(buildColors(options), options.transparent)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the effective strict-CSP emission controls from the public
|
|
135
|
+
* options. Kept as one object so every renderer takes it as a single
|
|
136
|
+
* trailing parameter — see `SvgEmitOptions` in packages/core/src/theme.ts.
|
|
137
|
+
*/
|
|
138
|
+
function resolveSvgEmit(options: RenderOptions): SvgEmitOptions {
|
|
139
|
+
return {
|
|
140
|
+
nonce: options.nonce,
|
|
141
|
+
styleAttribute: options.styleAttribute,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Interactivity-derived render gates — whether xychart hover tooltips
|
|
146
|
+
// render, whether flowchart/state edge animation (`e1@{ animate: true }`)
|
|
147
|
+
// plays, and whether `click`-based links/`<title>` tooltips render — all
|
|
148
|
+
// now live next to their diagram type's registry entry in ./registry.ts
|
|
149
|
+
// (`resolveAnimationEnabled`/`resolveLinksEnabled` there, duplicated rather
|
|
150
|
+
// than imported since that module is imported BY this file — see those
|
|
151
|
+
// functions' own comments for why), since every diagram type's SVG dispatch
|
|
152
|
+
// is fully handled by the registry lookup below and there is no remaining
|
|
153
|
+
// switch case here for them to serve.
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render Mermaid diagram text to an SVG string — synchronously.
|
|
157
|
+
*
|
|
158
|
+
* Uses elk.bundled.js with a direct FakeWorker bypass (no setTimeout(0) delay).
|
|
159
|
+
* The ELK singleton is created lazily on first use and cached forever.
|
|
160
|
+
*
|
|
161
|
+
* Use this in React components with useMemo() to avoid flash:
|
|
162
|
+
* const svg = useMemo(() => renderMermaidSVG(code, opts), [code])
|
|
163
|
+
*
|
|
164
|
+
* @param text - Mermaid source text
|
|
165
|
+
* @param options - Rendering options (colors, font, spacing)
|
|
166
|
+
* @returns A self-contained SVG string
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B')
|
|
171
|
+
*
|
|
172
|
+
* // With theme
|
|
173
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
174
|
+
* bg: '#1a1b26', fg: '#a9b1d6'
|
|
175
|
+
* })
|
|
176
|
+
*
|
|
177
|
+
* // With CSS variables (for live theme switching)
|
|
178
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
179
|
+
* bg: 'var(--background)', fg: 'var(--foreground)', transparent: true
|
|
180
|
+
* })
|
|
181
|
+
*
|
|
182
|
+
* // With the original source stamped onto the root <svg> as data-src —
|
|
183
|
+
* // handy for a "copy source" button or an "open in Mermaid Live" link
|
|
184
|
+
* // without re-attaching it via string surgery on the output.
|
|
185
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', { embedSource: true })
|
|
186
|
+
*
|
|
187
|
+
* // With an accessible name — role="img" + aria-labelledby pointing at a
|
|
188
|
+
* // <title> child, so assistive tech announces the diagram instead of
|
|
189
|
+
* // reading every node label individually (see issue #215).
|
|
190
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
191
|
+
* title: 'Flowchart: Build → Test → Ship'
|
|
192
|
+
* })
|
|
193
|
+
*
|
|
194
|
+
* // Decorative diagram — already described in surrounding prose, so it's
|
|
195
|
+
* // hidden from assistive tech (aria-hidden="true") instead of named.
|
|
196
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', { decorative: true })
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function renderMermaidSVG(
|
|
200
|
+
text: string,
|
|
201
|
+
options: RenderOptions = {},
|
|
202
|
+
): string {
|
|
203
|
+
const svg = renderMermaidSVGRaw(text, options)
|
|
204
|
+
return options.resolveColors
|
|
205
|
+
? resolveCssColors(svg, buildColors(options))
|
|
206
|
+
: svg
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The renderer proper — `renderMermaidSVG` minus the optional `resolveColors` post-pass. */
|
|
210
|
+
function renderMermaidSVGRaw(text: string, options: RenderOptions): string {
|
|
211
|
+
// Decode XML entities that may leak from markdown parsers (e.g. rehype-raw).
|
|
212
|
+
// Without this, escapeXml() double-encodes them: < → &lt; → literal "<" in SVG.
|
|
213
|
+
// `text` itself is left untouched so `embedSource` below stamps the exact
|
|
214
|
+
// string the caller passed in, not this entity-decoded copy used
|
|
215
|
+
// internally for parsing.
|
|
216
|
+
const decoded = decodeXML(text)
|
|
217
|
+
|
|
218
|
+
const colors = buildColors(options)
|
|
219
|
+
const font = options.font ?? 'Inter'
|
|
220
|
+
// Box sizing depends on the metrics model, so pick it before any layout runs.
|
|
221
|
+
setMonospaceMetrics(isMonospaceFont(font))
|
|
222
|
+
const transparent = options.transparent ?? false
|
|
223
|
+
const fontSizes = resolveFontSizes(options.fontSizes)
|
|
224
|
+
const diagramType: DiagramType = detectDiagramType(decoded)
|
|
225
|
+
const embedSource = options.embedSource ? text : undefined
|
|
226
|
+
const title = options.title
|
|
227
|
+
const decorative = options.decorative
|
|
228
|
+
const emit = resolveSvgEmit(options)
|
|
229
|
+
|
|
230
|
+
const lines = splitStatements(decoded)
|
|
231
|
+
|
|
232
|
+
// Registry dispatch (see ./registry.ts): every diagram type, including
|
|
233
|
+
// 'flowchart' (and the 'state' pipeline it shares), is registered there.
|
|
234
|
+
// `parse` takes both `lines` (what every already-registered type's parser
|
|
235
|
+
// wants) and `decoded` (the raw text flowchart/state's parser needs
|
|
236
|
+
// instead — see the `parse` doc comment on `DiagramModule` in
|
|
237
|
+
// ./registry.ts).
|
|
238
|
+
const registered = diagramRegistry[diagramType]
|
|
239
|
+
const diagram = registered.parse(lines, decoded)
|
|
240
|
+
const positioned = registered.layoutForSvg(diagram, options)
|
|
241
|
+
const ctx: SvgRenderContext = {
|
|
242
|
+
colors,
|
|
243
|
+
font,
|
|
244
|
+
transparent,
|
|
245
|
+
fontSizes,
|
|
246
|
+
embedSource,
|
|
247
|
+
title,
|
|
248
|
+
decorative,
|
|
249
|
+
emit,
|
|
250
|
+
}
|
|
251
|
+
return registered.renderSvg(positioned, ctx, options)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Render Mermaid diagram text to an SVG string — async.
|
|
256
|
+
*
|
|
257
|
+
* Same result as renderMermaidSVG() but returns a Promise.
|
|
258
|
+
* Useful in async contexts (server handlers, data loaders, etc.)
|
|
259
|
+
*/
|
|
260
|
+
export async function renderMermaidSVGAsync(
|
|
261
|
+
text: string,
|
|
262
|
+
options: RenderOptions = {},
|
|
263
|
+
): Promise<string> {
|
|
264
|
+
return renderMermaidSVG(text, options)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Backward-compatible aliases
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** @deprecated Use `renderMermaidSVG` */
|
|
272
|
+
export const renderMermaidSync = renderMermaidSVG
|
|
273
|
+
|
|
274
|
+
/** @deprecated Use `renderMermaidSVGAsync` */
|
|
275
|
+
export const renderMermaid = renderMermaidSVGAsync
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* up ELK's raw output.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { ElkNode } from 'elkjs'
|
|
10
|
+
import type { ElkNode, ElkExtendedEdge } from 'elkjs'
|
|
11
11
|
import type {
|
|
12
12
|
MermaidGraph,
|
|
13
13
|
MermaidSubgraph,
|
|
@@ -432,9 +432,27 @@ function extractEdgesRecursively(
|
|
|
432
432
|
offsetY: number,
|
|
433
433
|
margins?: MarginInfo,
|
|
434
434
|
): void {
|
|
435
|
-
// First pass: collect all edge segments
|
|
435
|
+
// First pass: collect all edge segments.
|
|
436
|
+
//
|
|
437
|
+
// Under `hierarchyHandling: INCLUDE_CHILDREN` (see to-elk.ts — used when no
|
|
438
|
+
// subgraph has a direction override), ELK is free to leave a
|
|
439
|
+
// cross-hierarchy edge in an *ancestor* node's `edges` array while
|
|
440
|
+
// reporting its sections/labels in the coordinate space of the deeper
|
|
441
|
+
// container named by the edge's own (untyped-in-elkjs) `container`
|
|
442
|
+
// property. `collectEdgeSegments`'s tree walk assumes an edge's
|
|
443
|
+
// coordinates match the array it was found in, which breaks for exactly
|
|
444
|
+
// this case — the edge lands at the *ancestor's* offset instead of the
|
|
445
|
+
// declared container's, shifting nested-subgraph edges/labels off their
|
|
446
|
+
// node boundaries toward the root. Pre-computing every container's
|
|
447
|
+
// accumulated root offset here lets `collectEdgeSegments` prefer the
|
|
448
|
+
// edge's declared container when present, falling back to the owning
|
|
449
|
+
// array's offset (the previous behavior) otherwise — see
|
|
450
|
+
// zombie-mermaid#1145, ported from a fix by Galen Suen in
|
|
451
|
+
// lukilabs/beautiful-mermaid#152.
|
|
452
|
+
const containerOffsets = new Map<string, Point>()
|
|
453
|
+
collectContainerOffsets(elkNode, containerOffsets, 0, 0)
|
|
436
454
|
const segments = new Map<number, EdgeSegmentGroup>()
|
|
437
|
-
collectEdgeSegments(elkNode, segments, 0, 0)
|
|
455
|
+
collectEdgeSegments(elkNode, segments, 0, 0, containerOffsets)
|
|
438
456
|
|
|
439
457
|
// Track margin-routed edge count for spacing offsets
|
|
440
458
|
let marginEdgeIndex = 0
|
|
@@ -518,6 +536,35 @@ function extractEdgesRecursively(
|
|
|
518
536
|
}
|
|
519
537
|
}
|
|
520
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Build a map from every ELK node's own id to its accumulated root-relative
|
|
541
|
+
* offset, by walking the same `elkNode.children` tree `collectEdgeSegments`
|
|
542
|
+
* walks. Used to resolve a cross-hierarchy edge's declared `container`
|
|
543
|
+
* (an ELK output property not modeled in elkjs's types) to the offset of
|
|
544
|
+
* the container ELK actually reported its coordinates in, rather than the
|
|
545
|
+
* offset of whichever ancestor array the edge object happened to be left
|
|
546
|
+
* in — see the comment on `extractEdgesRecursively`'s call site.
|
|
547
|
+
*/
|
|
548
|
+
function collectContainerOffsets(
|
|
549
|
+
elkNode: ElkNode,
|
|
550
|
+
offsets: Map<string, Point>,
|
|
551
|
+
offsetX: number,
|
|
552
|
+
offsetY: number,
|
|
553
|
+
): void {
|
|
554
|
+
offsets.set(elkNode.id, { x: offsetX, y: offsetY })
|
|
555
|
+
for (const child of elkNode.children ?? []) {
|
|
556
|
+
// `x`/`y` are optional in elkjs's types, but ELK always assigns both to
|
|
557
|
+
// every laid-out child node — the `?? 0` fallback mirrors the same
|
|
558
|
+
// defensive pattern already used for this walk in `collectEdgeSegments`
|
|
559
|
+
// below, and is equally unreachable from real ELK output.
|
|
560
|
+
/* v8 ignore next */
|
|
561
|
+
const childX = child.x ?? 0
|
|
562
|
+
/* v8 ignore next */
|
|
563
|
+
const childY = child.y ?? 0
|
|
564
|
+
collectContainerOffsets(child, offsets, offsetX + childX, offsetY + childY)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
521
568
|
/**
|
|
522
569
|
* Post-process edge points to ensure all segments are purely orthogonal.
|
|
523
570
|
*
|
|
@@ -596,6 +643,7 @@ function collectEdgeSegments(
|
|
|
596
643
|
segments: Map<number, EdgeSegmentGroup>,
|
|
597
644
|
offsetX: number,
|
|
598
645
|
offsetY: number,
|
|
646
|
+
containerOffsets: Map<string, Point>,
|
|
599
647
|
): void {
|
|
600
648
|
if (elkNode.edges) {
|
|
601
649
|
for (const elkEdge of elkNode.edges) {
|
|
@@ -605,9 +653,35 @@ function collectEdgeSegments(
|
|
|
605
653
|
if (!parsed) continue
|
|
606
654
|
const { edgeIndex } = parsed
|
|
607
655
|
|
|
656
|
+
// Prefer the edge's declared container offset (see
|
|
657
|
+
// `collectContainerOffsets`'s comment) over the owning array's
|
|
658
|
+
// offset. `container` and `containerOffsets.get(container)` were
|
|
659
|
+
// verified (by direct instrumentation against real elk.bundled.js
|
|
660
|
+
// output, across both flat and multiply-nested subgraphs) to always
|
|
661
|
+
// be defined in practice — ELK tags every edge with its container,
|
|
662
|
+
// even at the root, and `collectContainerOffsets` walks the same
|
|
663
|
+
// tree so every container id it can name is already in the map. The
|
|
664
|
+
// `container?`/`?? offset{X,Y}` fallbacks below exist only because
|
|
665
|
+
// `container` is untyped in elkjs and `Map.get` is typed to allow a
|
|
666
|
+
// miss; kept as defensive narrowing rather than assumed impossible.
|
|
667
|
+
const container = (elkEdge as ElkExtendedEdge & { container?: string })
|
|
668
|
+
.container
|
|
669
|
+
/* v8 ignore next */
|
|
670
|
+
const containerOffset = container
|
|
671
|
+
? containerOffsets.get(container)
|
|
672
|
+
: undefined
|
|
673
|
+
/* v8 ignore next */
|
|
674
|
+
const edgeOffsetX = containerOffset?.x ?? offsetX
|
|
675
|
+
/* v8 ignore next */
|
|
676
|
+
const edgeOffsetY = containerOffset?.y ?? offsetY
|
|
677
|
+
|
|
608
678
|
// Extract points and label position
|
|
609
|
-
const points = extractEdgePoints(elkEdge,
|
|
610
|
-
const labelPosition = extractEdgeLabelPosition(
|
|
679
|
+
const points = extractEdgePoints(elkEdge, edgeOffsetX, edgeOffsetY)
|
|
680
|
+
const labelPosition = extractEdgeLabelPosition(
|
|
681
|
+
elkEdge,
|
|
682
|
+
edgeOffsetX,
|
|
683
|
+
edgeOffsetY,
|
|
684
|
+
)
|
|
611
685
|
|
|
612
686
|
// Store segment
|
|
613
687
|
const seg: EdgeSegmentGroup = segments.get(edgeIndex) ?? {
|
|
@@ -635,6 +709,7 @@ function collectEdgeSegments(
|
|
|
635
709
|
segments,
|
|
636
710
|
offsetX + (child.x ?? 0),
|
|
637
711
|
offsetY + (child.y ?? 0),
|
|
712
|
+
containerOffsets,
|
|
638
713
|
)
|
|
639
714
|
}
|
|
640
715
|
}
|