@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.
@@ -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-exports `layoutGraphSync` from `layout-engine.ts`, so the
27
- // two star-exports below resolve to one and the same binding — legal, and
28
- // not an ambiguous re-export. It stays a module of its own (rather than
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; → &amp;lt; → literal "&lt;" 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, offsetX, offsetY)
610
- const labelPosition = extractEdgeLabelPosition(elkEdge, offsetX, offsetY)
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
  }