@zombie-mermaid/svg-renderer 2.2.6 → 3.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "2.2.6",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
6
  "repository": {
@@ -36,7 +36,8 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
- "@zombie-mermaid/core": "2.2.6",
40
- "@zombie-mermaid/mermaid-parser": "2.2.6"
39
+ "entities": "^7.0.1",
40
+ "@zombie-mermaid/core": "3.0.0",
41
+ "@zombie-mermaid/mermaid-parser": "3.0.0"
41
42
  }
42
43
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Smoke tests for `renderMermaidSVG` — the "Mermaid text in, SVG out" front
3
+ * door moved into this package under #1111, for parity with
4
+ * `@zombie-mermaid/ascii-renderer`'s `renderMermaidASCII`. The umbrella's own
5
+ * extensive `src/__tests__/*` suite already exercises this function
6
+ * end-to-end (through the re-export in `src/index.ts`), so this file only
7
+ * checks what's specific to *this* package: every registered diagram type
8
+ * dispatches to real SVG output through `./registry.ts`'s `diagramRegistry`
9
+ * when called directly from `@zombie-mermaid/svg-renderer`, not just via the
10
+ * umbrella.
11
+ */
12
+ import { describe, it, expect } from 'vitest'
13
+ import {
14
+ renderMermaidSVG,
15
+ renderMermaidSVGAsync,
16
+ renderMermaidSync,
17
+ renderMermaid,
18
+ themeCssVariables,
19
+ } from '@zombie-mermaid/svg-renderer'
20
+
21
+ describe('renderMermaidSVG', () => {
22
+ it.each([
23
+ ['flowchart', 'graph TD\n A --> B'],
24
+ ['state', 'stateDiagram-v2\n [*] --> A'],
25
+ ['sequence', 'sequenceDiagram\n A->>B: hi'],
26
+ ['class', 'classDiagram\n Animal <|-- Dog'],
27
+ ['er', 'erDiagram\n A ||--o{ B : has'],
28
+ ['xychart', 'xychart-beta\n line [1, 2, 3]'],
29
+ ])('renders a %s diagram to an SVG string', (_label, source) => {
30
+ const svg = renderMermaidSVG(source)
31
+ expect(svg).toContain('<svg')
32
+ expect(svg).toContain('</svg>')
33
+ })
34
+
35
+ it('embeds the original source when embedSource is set', () => {
36
+ const svg = renderMermaidSVG('graph TD\n A --> B', { embedSource: true })
37
+ expect(svg).toContain('data-src=')
38
+ })
39
+ })
40
+
41
+ describe('renderMermaidSVGAsync', () => {
42
+ it('resolves to the same output as the sync render', async () => {
43
+ const source = 'graph TD\n A --> B'
44
+ expect(await renderMermaidSVGAsync(source)).toBe(renderMermaidSVG(source))
45
+ })
46
+ })
47
+
48
+ describe('themeCssVariables', () => {
49
+ it('includes the resolved --bg/--fg custom properties', () => {
50
+ const css = themeCssVariables({ bg: '#111111', fg: '#eeeeee' })
51
+ expect(css).toContain('--bg:#111111')
52
+ expect(css).toContain('--fg:#eeeeee')
53
+ })
54
+ })
55
+
56
+ describe('deprecated aliases', () => {
57
+ it('renderMermaidSync matches renderMermaidSVG', () => {
58
+ expect(renderMermaidSync).toBe(renderMermaidSVG)
59
+ })
60
+
61
+ it('renderMermaid matches renderMermaidSVGAsync', () => {
62
+ expect(renderMermaid).toBe(renderMermaidSVGAsync)
63
+ })
64
+ })
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
  }