zombie-mermaid 2.2.1 → 2.2.2

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.
Files changed (251) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +7 -2
  3. package/dist/ascii.cjs +9 -9
  4. package/dist/ascii.cjs.map +1 -1
  5. package/dist/ascii.js +4069 -3969
  6. package/dist/ascii.js.map +1 -1
  7. package/dist/cli.js +7605 -7381
  8. package/dist/index.cjs +52 -52
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.js +7613 -7459
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp.cjs +14142 -13717
  13. package/dist/mcp.cjs.map +1 -1
  14. package/dist/mcp.js +14142 -13717
  15. package/dist/mcp.js.map +1 -1
  16. package/package.json +27 -5
  17. package/packages/ascii-renderer/LICENSE +22 -0
  18. package/{src/ascii → packages/ascii-renderer/src}/class-diagram.ts +277 -57
  19. package/{src/ascii → packages/ascii-renderer/src}/er-diagram.ts +2 -2
  20. package/packages/ascii-renderer/src/flowchart.ts +94 -0
  21. package/{src/ascii → packages/ascii-renderer/src}/grid.ts +23 -0
  22. package/{src/ascii → packages/ascii-renderer/src}/index.ts +13 -84
  23. package/{src/ascii → packages/ascii-renderer/src}/registry.ts +23 -12
  24. package/{src/ascii → packages/ascii-renderer/src}/sequence.ts +55 -8
  25. package/{src/ascii → packages/ascii-renderer/src}/xychart.ts +6 -3
  26. package/packages/core/LICENSE +22 -0
  27. package/packages/core/src/click-directive.ts +106 -0
  28. package/packages/core/src/color-utils.ts +205 -0
  29. package/packages/core/src/diagram-type.ts +35 -0
  30. package/packages/core/src/direction-override.ts +46 -0
  31. package/packages/core/src/direction.ts +56 -0
  32. package/packages/core/src/index.ts +34 -0
  33. package/packages/core/src/init-directive.ts +248 -0
  34. package/packages/core/src/multiline-utils.ts +275 -0
  35. package/packages/core/src/statements.ts +197 -0
  36. package/packages/core/src/style-directives.ts +214 -0
  37. package/packages/core/src/text-metrics.ts +498 -0
  38. package/packages/core/src/theme.ts +696 -0
  39. package/packages/core/src/types.ts +670 -0
  40. package/packages/mcp/LICENSE +22 -0
  41. package/{src/mcp → packages/mcp/src}/server.ts +34 -2
  42. package/{src/mcp → packages/mcp/src}/tools/check-sequence-activations.ts +8 -5
  43. package/packages/mcp/src/tools/fix-sequence-activations.ts +77 -0
  44. package/{src/mcp → packages/mcp/src}/tools/render-ascii.ts +2 -2
  45. package/{src/mcp → packages/mcp/src}/tools/render-svg.ts +1 -1
  46. package/packages/mermaid-parser/LICENSE +22 -0
  47. package/{src → packages/mermaid-parser/src}/class/parser.ts +86 -8
  48. package/{src → packages/mermaid-parser/src}/er/parser.ts +59 -26
  49. package/packages/mermaid-parser/src/index.ts +48 -0
  50. package/{src → packages/mermaid-parser/src}/sequence/activation-check.ts +4 -3
  51. package/packages/mermaid-parser/src/sequence/activation-fix.ts +112 -0
  52. package/{src → packages/mermaid-parser/src}/sequence/parser.ts +74 -16
  53. package/{src → packages/mermaid-parser/src}/xychart/parser.ts +84 -6
  54. package/packages/svg-renderer/LICENSE +22 -0
  55. package/{src → packages/svg-renderer/src}/class/layout.ts +8 -4
  56. package/{src → packages/svg-renderer/src}/class/renderer.ts +7 -6
  57. package/packages/svg-renderer/src/edge-curves.ts +204 -0
  58. package/packages/svg-renderer/src/elk-instance.ts +292 -0
  59. package/{src → packages/svg-renderer/src}/er/layout.ts +6 -4
  60. package/{src → packages/svg-renderer/src}/er/renderer.ts +4 -4
  61. package/packages/svg-renderer/src/index.ts +58 -0
  62. package/packages/svg-renderer/src/layout-engine/constants.ts +19 -0
  63. package/packages/svg-renderer/src/layout-engine/edge-bundling.ts +379 -0
  64. package/packages/svg-renderer/src/layout-engine/elk-adapter-utils.ts +81 -0
  65. package/packages/svg-renderer/src/layout-engine/elk-graph-builder.ts +240 -0
  66. package/packages/svg-renderer/src/layout-engine/from-elk.ts +685 -0
  67. package/packages/svg-renderer/src/layout-engine/layer-alignment.ts +174 -0
  68. package/packages/svg-renderer/src/layout-engine/to-elk.ts +695 -0
  69. package/packages/svg-renderer/src/layout-engine.ts +74 -0
  70. package/packages/svg-renderer/src/layout.ts +8 -0
  71. package/packages/svg-renderer/src/renderer.ts +1485 -0
  72. package/packages/svg-renderer/src/resolve-colors.ts +339 -0
  73. package/{src → packages/svg-renderer/src}/sequence/layout.ts +2 -6
  74. package/{src → packages/svg-renderer/src}/sequence/renderer.ts +4 -4
  75. package/packages/svg-renderer/src/shape-clipping.ts +197 -0
  76. package/packages/svg-renderer/src/styles.ts +118 -0
  77. package/{src → packages/svg-renderer/src}/xychart/layout.ts +2 -2
  78. package/{src → packages/svg-renderer/src}/xychart/renderer.ts +6 -6
  79. package/src/__tests__/ascii-ansi.test.ts +6 -2
  80. package/src/__tests__/ascii-canvas-write.test.ts +2 -2
  81. package/src/__tests__/ascii-charset-border-junctions.test.ts +1 -1
  82. package/src/__tests__/ascii-cjk-width.test.ts +1 -1
  83. package/src/__tests__/ascii-class-box-occupancy.test.ts +147 -45
  84. package/src/__tests__/ascii-class-column-width-488-489.test.ts +1 -1
  85. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  86. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +1 -1
  87. package/src/__tests__/ascii-class-diagram-compartments.test.ts +1 -1
  88. package/src/__tests__/ascii-class-label-box-collision.test.ts +1 -1
  89. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +1 -1
  90. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +1 -1
  91. package/src/__tests__/ascii-class-padding.test.ts +1 -1
  92. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +86 -1
  93. package/src/__tests__/ascii-combining-mark-width.test.ts +2 -2
  94. package/src/__tests__/ascii-coords-overlay.test.ts +2 -2
  95. package/src/__tests__/ascii-decision-lr-box-start.test.ts +2 -2
  96. package/src/__tests__/ascii-display-width-unit.test.ts +1 -1
  97. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +1 -1
  98. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +12 -5
  99. package/src/__tests__/ascii-draw-bundles.test.ts +5 -5
  100. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +1 -1
  101. package/src/__tests__/ascii-edge-bundling-routing.test.ts +9 -5
  102. package/src/__tests__/ascii-edge-cross-style-overlap.test.ts +10 -6
  103. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +1 -1
  104. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +2 -2
  105. package/src/__tests__/ascii-edge-routing-fixes.test.ts +1 -1
  106. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +8 -4
  107. package/src/__tests__/ascii-edge-styles.test.ts +1 -1
  108. package/src/__tests__/ascii-emoji-cluster-width.test.ts +2 -2
  109. package/src/__tests__/ascii-er-box-occupancy.test.ts +1 -1
  110. package/src/__tests__/ascii-er-cardinality.test.ts +1 -1
  111. package/src/__tests__/ascii-er-corner-glyphs.test.ts +1 -1
  112. package/src/__tests__/ascii-er-jog-stray-line.test.ts +1 -1
  113. package/src/__tests__/ascii-er-label-padding.test.ts +1 -1
  114. package/src/__tests__/ascii-er-padding.test.ts +1 -1
  115. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +1 -1
  116. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +1 -1
  117. package/src/__tests__/ascii-er-stray-connectors.test.ts +7 -4
  118. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +1 -1
  119. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +1 -1
  120. package/src/__tests__/ascii-grid-deep-chain.test.ts +4 -4
  121. package/src/__tests__/ascii-hyperlinks.test.ts +9 -6
  122. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +8 -4
  123. package/src/__tests__/ascii-lane-search.test.ts +1 -1
  124. package/src/__tests__/ascii-multibox-cjk-width.test.ts +1 -1
  125. package/src/__tests__/ascii-multiline.test.ts +6 -3
  126. package/src/__tests__/ascii-nested-subgraph-sibling-order-444.test.ts +8 -5
  127. package/src/__tests__/ascii-package-boundary.test.ts +47 -37
  128. package/src/__tests__/ascii-padding-edge-cases.test.ts +1 -1
  129. package/src/__tests__/ascii-parallel-edges-329.test.ts +8 -5
  130. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +14 -4
  131. package/src/__tests__/ascii-reverse-direction-edges-629.test.ts +9 -5
  132. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +1 -1
  133. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +1 -1
  134. package/src/__tests__/ascii-sequence-box-group.test.ts +1 -1
  135. package/src/__tests__/ascii-sequence-cjk-width.test.ts +1 -1
  136. package/src/__tests__/ascii-sequence-create-destroy.test.ts +1 -1
  137. package/src/__tests__/ascii-sequence-form-invariants.test.ts +48 -50
  138. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +1 -1
  139. package/src/__tests__/ascii-sequence-notes.test.ts +1 -1
  140. package/src/__tests__/ascii-sequence-padding.test.ts +1 -1
  141. package/src/__tests__/ascii-sequence-self-arrow.test.ts +1 -1
  142. package/src/__tests__/ascii-shape-diamond.test.ts +3 -3
  143. package/src/__tests__/ascii-shape-rectangle.test.ts +5 -5
  144. package/src/__tests__/ascii-shape-rounded.test.ts +3 -3
  145. package/src/__tests__/ascii-shapes-circle.test.ts +3 -3
  146. package/src/__tests__/ascii-shapes-hexagon.test.ts +3 -3
  147. package/src/__tests__/ascii-shapes-special.test.ts +10 -5
  148. package/src/__tests__/ascii-shapes-stadium.test.ts +3 -3
  149. package/src/__tests__/ascii-shapes-state.test.ts +15 -5
  150. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +1 -1
  151. package/src/__tests__/ascii-subgraph-bbox.test.ts +8 -4
  152. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +1 -1
  153. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +88 -25
  154. package/src/__tests__/ascii-subgraph-later-edgeless-301.test.ts +8 -4
  155. package/src/__tests__/ascii-subgraph-title-padding.test.ts +1 -1
  156. package/src/__tests__/ascii-territory-unit.test.ts +2 -2
  157. package/src/__tests__/ascii-validate.test.ts +1 -1
  158. package/src/__tests__/ascii.test.ts +5 -2
  159. package/src/__tests__/box-color.test.ts +1 -1
  160. package/src/__tests__/class-arrow-directions.test.ts +1 -1
  161. package/src/__tests__/class-click-interactions.test.ts +3 -6
  162. package/src/__tests__/class-integration.test.ts +1 -1
  163. package/src/__tests__/class-notes.test.ts +3 -3
  164. package/src/__tests__/class-parser-error-messages.test.ts +162 -0
  165. package/src/__tests__/class-parser.test.ts +6 -6
  166. package/src/__tests__/class-styling.test.ts +2 -2
  167. package/src/__tests__/cli-render.test.ts +16 -1
  168. package/src/__tests__/direction-override.test.ts +1 -1
  169. package/src/__tests__/draw-lines.test.ts +2 -2
  170. package/src/__tests__/edge-cell-styles.test.ts +5 -2
  171. package/src/__tests__/elk-layout-cache.test.ts +6 -4
  172. package/src/__tests__/er-click-unsupported.test.ts +5 -8
  173. package/src/__tests__/er-parser-error-messages.test.ts +82 -0
  174. package/src/__tests__/er-parser.test.ts +3 -6
  175. package/src/__tests__/expanded-shapes.test.ts +1 -1
  176. package/src/__tests__/grid-occupancy.test.ts +1 -1
  177. package/src/__tests__/mcp-check-sequence-activations-tool.test.ts +1 -1
  178. package/src/__tests__/mcp-fix-sequence-activations-tool.test.ts +134 -0
  179. package/src/__tests__/mcp-render-ascii-tool.test.ts +5 -4
  180. package/src/__tests__/mcp-render-svg-theme-guard.test.ts +3 -3
  181. package/src/__tests__/mcp-render-svg-tool.test.ts +1 -1
  182. package/src/__tests__/mcp-server.test.ts +30 -1
  183. package/src/__tests__/multiline-labels.test.ts +29 -18
  184. package/src/__tests__/parser.test.ts +117 -5
  185. package/src/__tests__/pathfinder.test.ts +11 -4
  186. package/src/__tests__/render-options-overrides.test.ts +28 -21
  187. package/src/__tests__/render-options-per-type.test.ts +13 -9
  188. package/src/__tests__/resolve-colors.test.ts +1 -1
  189. package/src/__tests__/self-loop-edges.test.ts +112 -0
  190. package/src/__tests__/sequence-activation-check.test.ts +7 -8
  191. package/src/__tests__/sequence-activation-fix.test.ts +91 -0
  192. package/src/__tests__/sequence-layout.test.ts +4 -7
  193. package/src/__tests__/sequence-parser-syntax-errors.test.ts +179 -0
  194. package/src/__tests__/sequence-parser.test.ts +3 -6
  195. package/src/__tests__/statements.test.ts +53 -35
  196. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
  197. package/src/__tests__/theme-palette-contrast.test.ts +84 -0
  198. package/src/__tests__/workspace-package-boundaries.test.ts +199 -25
  199. package/src/__tests__/xychart-ascii.test.ts +1 -1
  200. package/src/__tests__/xychart-colors.test.ts +3 -2
  201. package/src/__tests__/xychart-layout.test.ts +1 -1
  202. package/src/__tests__/xychart-parser-errors.test.ts +95 -0
  203. package/src/browser.ts +8 -2
  204. package/src/cli/mcp.ts +1 -1
  205. package/src/cli/render.ts +59 -25
  206. package/src/cli/web.ts +1 -1
  207. package/src/diagram-registry.ts +190 -25
  208. package/src/index.ts +34 -131
  209. package/src/parser.ts +102 -40
  210. /package/{src/ascii → packages/ascii-renderer/src}/ansi.ts +0 -0
  211. /package/{src/ascii → packages/ascii-renderer/src}/canvas.ts +0 -0
  212. /package/{src/ascii → packages/ascii-renderer/src}/converter.ts +0 -0
  213. /package/{src/ascii → packages/ascii-renderer/src}/coords.ts +0 -0
  214. /package/{src/ascii → packages/ascii-renderer/src}/display-width.ts +0 -0
  215. /package/{src/ascii → packages/ascii-renderer/src}/draw-arrows.ts +0 -0
  216. /package/{src/ascii → packages/ascii-renderer/src}/draw-boxes.ts +0 -0
  217. /package/{src/ascii → packages/ascii-renderer/src}/draw-bundles.ts +0 -0
  218. /package/{src/ascii → packages/ascii-renderer/src}/draw-lines.ts +0 -0
  219. /package/{src/ascii → packages/ascii-renderer/src}/draw-subgraphs.ts +0 -0
  220. /package/{src/ascii → packages/ascii-renderer/src}/draw.ts +0 -0
  221. /package/{src/ascii → packages/ascii-renderer/src}/edge-bundling.ts +0 -0
  222. /package/{src/ascii → packages/ascii-renderer/src}/edge-cell-styles.ts +0 -0
  223. /package/{src/ascii → packages/ascii-renderer/src}/edge-routing.ts +0 -0
  224. /package/{src/ascii → packages/ascii-renderer/src}/grid-occupancy.ts +0 -0
  225. /package/{src/ascii → packages/ascii-renderer/src}/hyperlinks.ts +0 -0
  226. /package/{src/ascii → packages/ascii-renderer/src}/lane-search.ts +0 -0
  227. /package/{src/ascii → packages/ascii-renderer/src}/multiline-utils.ts +0 -0
  228. /package/{src/ascii → packages/ascii-renderer/src}/pathfinder.ts +0 -0
  229. /package/{src/ascii → packages/ascii-renderer/src}/shapes/circle.ts +0 -0
  230. /package/{src/ascii → packages/ascii-renderer/src}/shapes/corners.ts +0 -0
  231. /package/{src/ascii → packages/ascii-renderer/src}/shapes/diamond.ts +0 -0
  232. /package/{src/ascii → packages/ascii-renderer/src}/shapes/hexagon.ts +0 -0
  233. /package/{src/ascii → packages/ascii-renderer/src}/shapes/index.ts +0 -0
  234. /package/{src/ascii → packages/ascii-renderer/src}/shapes/rectangle.ts +0 -0
  235. /package/{src/ascii → packages/ascii-renderer/src}/shapes/rounded.ts +0 -0
  236. /package/{src/ascii → packages/ascii-renderer/src}/shapes/special.ts +0 -0
  237. /package/{src/ascii → packages/ascii-renderer/src}/shapes/stadium.ts +0 -0
  238. /package/{src/ascii → packages/ascii-renderer/src}/shapes/state.ts +0 -0
  239. /package/{src/ascii → packages/ascii-renderer/src}/shapes/types.ts +0 -0
  240. /package/{src/ascii → packages/ascii-renderer/src}/territory.ts +0 -0
  241. /package/{src/ascii → packages/ascii-renderer/src}/types.ts +0 -0
  242. /package/{src/ascii → packages/ascii-renderer/src}/validate.ts +0 -0
  243. /package/{src/mcp → packages/mcp/src}/index.ts +0 -0
  244. /package/{src → packages/mermaid-parser/src}/class/format.ts +0 -0
  245. /package/{src → packages/mermaid-parser/src}/class/types.ts +0 -0
  246. /package/{src → packages/mermaid-parser/src}/er/types.ts +0 -0
  247. /package/{src → packages/mermaid-parser/src}/expanded-shapes.ts +0 -0
  248. /package/{src → packages/mermaid-parser/src}/sequence/box-color.ts +0 -0
  249. /package/{src → packages/mermaid-parser/src}/sequence/types.ts +0 -0
  250. /package/{src → packages/mermaid-parser/src}/xychart/colors.ts +0 -0
  251. /package/{src → packages/mermaid-parser/src}/xychart/types.ts +0 -0
@@ -9,13 +9,13 @@
9
9
  // Relationship lines use Manhattan routing between entity boxes.
10
10
  // ============================================================================
11
11
 
12
- import { parseErDiagram } from '../er/parser.ts'
12
+ import { parseErDiagram } from '@zombie-mermaid/mermaid-parser'
13
13
  import type {
14
14
  ErDiagram,
15
15
  ErEntity,
16
16
  ErAttribute,
17
17
  Cardinality,
18
- } from '../er/types.ts'
18
+ } from '@zombie-mermaid/mermaid-parser'
19
19
  import type {
20
20
  AsciiConfig,
21
21
  CharRole,
@@ -0,0 +1,94 @@
1
+ // ============================================================================
2
+ // ASCII renderer — Flowchart / state diagram
3
+ //
4
+ // Renders flowcharts (graph TD / flowchart LR) and state diagrams
5
+ // (stateDiagram-v2) to ASCII/Unicode box-drawing art via the shared
6
+ // grid-based layout + A* pathfinding pipeline (converter -> grid -> draw).
7
+ //
8
+ // Extracted from the inline sequence that used to live directly in
9
+ // `renderMermaidASCII`'s switch/fallback (src/ascii/index.ts) so flowchart
10
+ // can be registered in `asciiRegistry` (src/ascii/registry.ts) alongside
11
+ // every other diagram type — see
12
+ // docs/decisions/diagram-type-registry-partial.md and issue #745. A pure,
13
+ // behavior-preserving extraction: same functions, same call order, same
14
+ // arguments.
15
+ // ============================================================================
16
+
17
+ import { parseMermaid } from '../../../src/parser.ts'
18
+ import { withDirectionOverride } from '@zombie-mermaid/core'
19
+ import type { Direction } from '@zombie-mermaid/core'
20
+ import { convertToAsciiGraph } from './converter.ts'
21
+ import { createMapping } from './grid.ts'
22
+ import { drawGraph } from './draw.ts'
23
+ import {
24
+ canvasToString,
25
+ flipCanvasVertically,
26
+ flipRoleCanvasVertically,
27
+ } from './canvas.ts'
28
+ import { buildNodeLinkCanvas, flipLinkCanvasVertically } from './hyperlinks.ts'
29
+ import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'
30
+
31
+ /**
32
+ * Flowchart-only ASCII extras: `direction` (override the parsed top-level
33
+ * direction, same semantics as `RenderOptions.direction` for SVG — see
34
+ * issue #276) and `hyperlinks` (OSC 8 terminal links). No other registered
35
+ * ASCII type reads `direction`; `hyperlinks` is shared with `class` (see
36
+ * `AsciiRenderExtras` in ./registry.ts).
37
+ */
38
+ export interface FlowchartAsciiExtras {
39
+ direction?: Direction
40
+ hyperlinks?: boolean
41
+ }
42
+
43
+ /**
44
+ * Render a flowchart or state diagram to ASCII/Unicode text art.
45
+ *
46
+ * Matches every other diagram type's ASCII entry-point shape
47
+ * (`text, config, colorMode, theme, extras`) so it can slot into
48
+ * `asciiRegistry` — see ./registry.ts.
49
+ */
50
+ export function renderFlowchartAscii(
51
+ text: string,
52
+ config: AsciiConfig,
53
+ colorMode: ColorMode,
54
+ theme: AsciiTheme,
55
+ extras: FlowchartAsciiExtras = {},
56
+ ): string {
57
+ // `extras.direction` replaces the parsed top-level direction before
58
+ // layout; see packages/core/src/direction-override.ts.
59
+ const parsed = withDirectionOverride(parseMermaid(text), extras.direction)
60
+
61
+ // Normalize direction for grid layout.
62
+ // BT is laid out as TD then flipped vertically after drawing.
63
+ // RL is treated as LR (full RL support not yet implemented).
64
+ if (parsed.direction === 'LR' || parsed.direction === 'RL') {
65
+ config.graphDirection = 'LR'
66
+ } else {
67
+ config.graphDirection = 'TD'
68
+ }
69
+
70
+ const graph = convertToAsciiGraph(parsed, config)
71
+ createMapping(graph)
72
+ drawGraph(graph)
73
+
74
+ // Opt-in OSC 8 hyperlinks: mark each `click`-linked node's label cells
75
+ // now, from the drawn node positions, before any flip below moves them.
76
+ const linkCanvas = extras.hyperlinks
77
+ ? buildNodeLinkCanvas(graph, parsed.interactions)
78
+ : undefined
79
+
80
+ // BT: flip the finished canvas vertically so the flow runs bottom→top.
81
+ // The grid layout ran as TD; flipping + character remapping produces BT.
82
+ if (parsed.direction === 'BT') {
83
+ flipCanvasVertically(graph.canvas)
84
+ flipRoleCanvasVertically(graph.roleCanvas)
85
+ if (linkCanvas) flipLinkCanvasVertically(linkCanvas)
86
+ }
87
+
88
+ return canvasToString(graph.canvas, {
89
+ roleCanvas: graph.roleCanvas,
90
+ colorMode,
91
+ theme,
92
+ linkCanvas,
93
+ })
94
+ }
@@ -40,6 +40,8 @@ import {
40
40
  } from './edge-cell-styles.ts'
41
41
  import { drawBox } from './draw.ts'
42
42
  import { getShapeDimensions } from './shapes/index.ts'
43
+ import { splitLines } from './multiline-utils.ts'
44
+ import { displayWidth } from './display-width.ts'
43
45
 
44
46
  // `requireGridCoord` is defined in types.ts (a pure predicate over
45
47
  // AsciiNode with no grid-state dependency) and re-exported here so the two
@@ -469,6 +471,27 @@ function calculateSubgraphBoundingBox(
469
471
  sg.minY = minY - subgraphPadding - subgraphLabelSpace
470
472
  sg.maxX = maxX + subgraphPadding
471
473
  sg.maxY = maxY + subgraphPadding
474
+
475
+ // Widen the box, if necessary, so the subgraph's own cluster label fits
476
+ // without truncation. `drawSubgraphLabel` (draw-subgraphs.ts) writes the
477
+ // label into the interior columns `1..width-1` (width = maxX - minX), so
478
+ // the box needs `width >= labelWidth + 1` to avoid clipping the label
479
+ // against its own right border. Widening symmetrically (splitting any
480
+ // extra columns across both sides) keeps the child nodes visually
481
+ // centered inside the enlarged box rather than skewing it to one side.
482
+ const labelWidth = Math.max(
483
+ 0,
484
+ ...splitLines(sg.name).map((line) => displayWidth(line)),
485
+ )
486
+ const currentWidth = sg.maxX - sg.minX
487
+ const requiredWidth = labelWidth + 1
488
+ if (requiredWidth > currentWidth) {
489
+ const extra = requiredWidth - currentWidth
490
+ const extraLeft = Math.floor(extra / 2)
491
+ const extraRight = extra - extraLeft
492
+ sg.minX -= extraLeft
493
+ sg.maxX += extraRight
494
+ }
472
495
  }
473
496
 
474
497
  /** Ensure non-overlapping root subgraphs have minimum spacing. */
@@ -16,22 +16,10 @@
16
16
  // const ascii = renderMermaidASCII('graph LR\n A --> B')
17
17
  // ============================================================================
18
18
 
19
- import { parseMermaid } from '../parser.ts'
20
- import { withDirectionOverride, detectDiagramType } from '@zombie-mermaid/core'
19
+ import { detectDiagramType } from '@zombie-mermaid/core'
21
20
  import type { Direction, DiagramType } from '@zombie-mermaid/core'
22
- import { convertToAsciiGraph } from './converter.ts'
23
- import { createMapping } from './grid.ts'
24
- import { drawGraph } from './draw.ts'
25
- import {
26
- canvasToString,
27
- flipCanvasVertically,
28
- flipRoleCanvasVertically,
29
- } from './canvas.ts'
30
- import { renderSequenceAscii } from './sequence.ts'
31
- import { renderClassAscii } from './class-diagram.ts'
32
21
  import { asciiRegistry } from './registry.ts'
33
22
  import { addCoordsOverlay } from './coords.ts'
34
- import { buildNodeLinkCanvas, flipLinkCanvasVertically } from './hyperlinks.ts'
35
23
  import {
36
24
  detectColorMode,
37
25
  DEFAULT_ASCII_THEME,
@@ -142,7 +130,7 @@ export function renderMermaidASCII(
142
130
  paddingX: options.paddingX ?? DEFAULT_PADDING_X,
143
131
  paddingY: options.paddingY ?? DEFAULT_PADDING_Y,
144
132
  boxBorderPadding: options.boxBorderPadding ?? DEFAULT_BOX_BORDER_PADDING,
145
- graphDirection: 'TD', // default, overridden for flowcharts below
133
+ graphDirection: 'TD', // default; renderFlowchartAscii overrides this for flowcharts/state diagrams
146
134
  }
147
135
 
148
136
  // Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode)
@@ -156,76 +144,17 @@ export function renderMermaidASCII(
156
144
 
157
145
  const diagramType: DiagramType = detectDiagramType(text)
158
146
 
159
- let result: string
160
-
161
- // Registry lookup first (see src/ascii/registry.ts — issue #533):
162
- // 'xychart' and 'er' are registered there and dispatch to the exact same
163
- // renderer calls their switch cases below used to make. Anything not
164
- // registered (currently 'sequence', 'class', 'flowchart') falls through
165
- // to the switch, unchanged.
166
- const registered = asciiRegistry[diagramType]
167
-
168
- if (registered) {
169
- result = registered(text, config, colorMode, theme, {})
170
- return options.showCoords ? addCoordsOverlay(result) : result
171
- }
172
-
173
- switch (diagramType) {
174
- case 'sequence':
175
- result = renderSequenceAscii(text, config, colorMode, theme)
176
- break
177
-
178
- case 'class':
179
- result = renderClassAscii(text, config, colorMode, theme, {
180
- hyperlinks: options.hyperlinks ?? false,
181
- })
182
- break
183
-
184
- case 'flowchart':
185
- default: {
186
- // Flowchart + state diagram pipeline (original). `options.direction`
187
- // replaces the parsed top-level direction before layout; see
188
- // packages/core/src/direction-override.ts.
189
- const parsed = withDirectionOverride(
190
- parseMermaid(text),
191
- options.direction,
192
- )
193
-
194
- // Normalize direction for grid layout.
195
- // BT is laid out as TD then flipped vertically after drawing.
196
- // RL is treated as LR (full RL support not yet implemented).
197
- if (parsed.direction === 'LR' || parsed.direction === 'RL') {
198
- config.graphDirection = 'LR'
199
- } else {
200
- config.graphDirection = 'TD'
201
- }
202
-
203
- const graph = convertToAsciiGraph(parsed, config)
204
- createMapping(graph)
205
- drawGraph(graph)
206
-
207
- // Opt-in OSC 8 hyperlinks: mark each `click`-linked node's label cells
208
- // now, from the drawn node positions, before any flip below moves them.
209
- const linkCanvas = options.hyperlinks
210
- ? buildNodeLinkCanvas(graph, parsed.interactions)
211
- : undefined
212
-
213
- // BT: flip the finished canvas vertically so the flow runs bottom→top.
214
- // The grid layout ran as TD; flipping + character remapping produces BT.
215
- if (parsed.direction === 'BT') {
216
- flipCanvasVertically(graph.canvas)
217
- flipRoleCanvasVertically(graph.roleCanvas)
218
- if (linkCanvas) flipLinkCanvasVertically(linkCanvas)
219
- }
220
-
221
- result = canvasToString(graph.canvas, {
222
- roleCanvas: graph.roleCanvas,
223
- colorMode,
224
- theme,
225
- linkCanvas,
226
- })
227
- }
228
- }
147
+ // Registry dispatch (see src/ascii/registry.ts — issue #533 / #745):
148
+ // every diagram type, including 'flowchart', is registered there and
149
+ // dispatches to the exact same renderer calls each type's original
150
+ // switch case (or, for 'flowchart', inline sequence) used to make.
151
+ // `extras` carries every ASCII-only per-type option: `hyperlinks` (read
152
+ // by 'class' and 'flowchart') and `direction` (read by 'flowchart'
153
+ // only); both are ignored by every other registered type.
154
+ const result = asciiRegistry[diagramType](text, config, colorMode, theme, {
155
+ hyperlinks: options.hyperlinks ?? false,
156
+ direction: options.direction,
157
+ })
229
158
 
230
159
  return options.showCoords ? addCoordsOverlay(result) : result
231
160
  }
@@ -25,27 +25,31 @@
25
25
  // no longer names anything under `src/ascii/`. Both directions are now
26
26
  // one-way, and the set of registered types stays a single decision per
27
27
  // renderer rather than a `switch` re-listed at each front door — the point of
28
- // #533. Adding a type to one renderer's table without the other is now
29
- // expressible, which matches reality: 'sequence'/'class'/'flowchart' are
30
- // unregistered on both sides today only because neither renderer's signature
31
- // has been adapted yet, not because the two must move in lockstep.
28
+ // #533. Adding a type to one renderer's table without the other is
29
+ // expressible in principle, though in practice 'xychart'/'er'/'sequence'/
30
+ // 'class'/'flowchart' now move together on both sides.
32
31
  // ============================================================================
33
32
 
34
- import type { DiagramType } from '@zombie-mermaid/core'
33
+ import type { DiagramType, Direction } from '@zombie-mermaid/core'
35
34
  import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'
36
35
  import { renderXYChartAscii } from './xychart.ts'
37
36
  import { renderErAscii } from './er-diagram.ts'
37
+ import { renderSequenceAscii } from './sequence.ts'
38
+ import { renderClassAscii } from './class-diagram.ts'
39
+ import { renderFlowchartAscii } from './flowchart.ts'
38
40
 
39
41
  /**
40
- * Small, closed set of ASCII-only extras not every type needs — today only
41
- * `class` reads `hyperlinks` (see `ClassAsciiOptions` in
42
- * src/ascii/class-diagram.ts), and `class` is not registered here yet. Kept
42
+ * Small, closed set of ASCII-only extras not every type needs — `class`
43
+ * reads `hyperlinks` (see `ClassAsciiOptions` in
44
+ * src/ascii/class-diagram.ts), `flowchart` reads both `direction` and
45
+ * `hyperlinks` (see `FlowchartAsciiExtras` in src/ascii/flowchart.ts). Kept
43
46
  * as its own type rather than reusing `AsciiRenderOptions` from
44
47
  * src/ascii/index.ts so this module stays a leaf of the ASCII tree: index.ts
45
48
  * imports it, never the other way round.
46
49
  */
47
50
  export interface AsciiRenderExtras {
48
51
  hyperlinks?: boolean
52
+ direction?: Direction
49
53
  }
50
54
 
51
55
  /**
@@ -65,13 +69,20 @@ export type AsciiRenderer = (
65
69
  ) => string
66
70
 
67
71
  /**
68
- * Types `renderMermaidASCII` dispatches through the registry. Anything
69
- * absent (currently 'sequence', 'class', 'flowchart') falls through to that
70
- * function's own switch, unchanged.
72
+ * Every diagram type `renderMermaidASCII` supports, dispatched through this
73
+ * table — see that function in src/ascii/index.ts, which has no fallback
74
+ * switch left now that 'flowchart' (the last holdout — see
75
+ * docs/decisions/diagram-type-registry-partial.md) is registered here too.
71
76
  */
72
- export const asciiRegistry: Partial<Record<DiagramType, AsciiRenderer>> = {
77
+ export const asciiRegistry: Record<DiagramType, AsciiRenderer> = {
73
78
  xychart: (text, config, colorMode, theme) =>
74
79
  renderXYChartAscii(text, config, colorMode, theme),
75
80
  er: (text, config, colorMode, theme) =>
76
81
  renderErAscii(text, config, colorMode, theme),
82
+ sequence: (text, config, colorMode, theme) =>
83
+ renderSequenceAscii(text, config, colorMode, theme),
84
+ class: (text, config, colorMode, theme, extras) =>
85
+ renderClassAscii(text, config, colorMode, theme, extras),
86
+ flowchart: (text, config, colorMode, theme, extras) =>
87
+ renderFlowchartAscii(text, config, colorMode, theme, extras),
77
88
  }
@@ -9,8 +9,8 @@
9
9
  // Instead: actors → columns, messages → rows, all positioned linearly.
10
10
  // ============================================================================
11
11
 
12
- import { parseSequenceDiagram } from '../sequence/parser.ts'
13
- import type { Block } from '../sequence/types.ts'
12
+ import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
13
+ import type { Block } from '@zombie-mermaid/mermaid-parser'
14
14
  import type { AsciiConfig, CharRole, AsciiTheme, ColorMode } from './types.ts'
15
15
  import {
16
16
  mkCanvas,
@@ -28,7 +28,7 @@ import {
28
28
  toDisplayCells,
29
29
  WIDE_CHAR_PLACEHOLDER,
30
30
  } from './display-width.ts'
31
- import type { Message } from '../sequence/types.ts'
31
+ import type { Message } from '@zombie-mermaid/mermaid-parser'
32
32
  import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
33
33
 
34
34
  // Width of a self-message's loop glyphs (├──┐ / ◀──┘), excluding the label.
@@ -256,6 +256,37 @@ export function renderSequenceAscii(
256
256
  }
257
257
  }
258
258
 
259
+ // Width (including border + padding, matching the box drawn in the
260
+ // vertical-layout pass below) of a note anchored to an actor. Computed up
261
+ // front, before lifeline x-positions are finalized, so `left`/`right`
262
+ // notes can participate in gap sizing the same way message labels already
263
+ // do (issue #953 case C) instead of only being checked against a layout
264
+ // that's already fixed.
265
+ function noteBoxWidth(note: (typeof diagram.notes)[number]): number {
266
+ const nLines = splitLines(note.text)
267
+ return Math.max(...nLines.map((l) => displayWidth(l))) + 2 + 2 * boxPad
268
+ }
269
+
270
+ // Per-actor widest 'left'/'right' note anchored to it. A `left` note on
271
+ // actor i draws into the gap to its left (between actor i-1 and i, or —
272
+ // for the leftmost actor — the canvas's own left margin); a `right` note
273
+ // draws into the gap to its right (between actor i and i+1). Both need to
274
+ // be reserved as part of layout, not clamped after the fact (issue #953
275
+ // cases A-C: notes previously only ever collided with whatever gap
276
+ // message-label sizing happened to leave).
277
+ const leftNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
278
+ const rightNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
279
+ for (const note of diagram.notes) {
280
+ if (note.position !== 'left' && note.position !== 'right') continue
281
+ const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
282
+ const w = noteBoxWidth(note)
283
+ if (note.position === 'left') {
284
+ leftNoteWidth[aIdx] = Math.max(leftNoteWidth[aIdx]!, w)
285
+ } else {
286
+ rightNoteWidth[aIdx] = Math.max(rightNoteWidth[aIdx]!, w)
287
+ }
288
+ }
289
+
259
290
  // Compute lifeline x-positions (greedy left-to-right).
260
291
  // See paddingOffset's doc comment (types.ts) for why this is an offset
261
292
  // from the paddingX default rather than the raw config value.
@@ -269,8 +300,19 @@ export function renderSequenceAscii(
269
300
  // adjacent participants in different groups (or one grouped, one not)
270
301
  // need room for the wall(s) between their boxes, plus a blank column on
271
302
  // each side of every wall. Same-group neighbours have no wall between.
303
+ //
304
+ // A `left` note on the leftmost actor has no left neighbour to reserve a
305
+ // gap against — it draws into the canvas's own left margin instead, so
306
+ // that margin (the initial lifeline position itself) must be at least
307
+ // wide enough for it: `nx = llX[0] - nWidth - 1 >= 0` (see the note-x
308
+ // calculation below) requires `llX[0] >= nWidth + 1` (issue #953 case A —
309
+ // previously `nx` was clamped to 0 instead, forcing the note onto the
310
+ // lifeline it's supposed to sit beside).
272
311
  const llX: number[] = [
273
- halfBox[0]! + (boxSpanOf[0] === -1 ? 0 : BRACKET_GAP + 1),
312
+ Math.max(
313
+ halfBox[0]! + (boxSpanOf[0] === -1 ? 0 : BRACKET_GAP + 1),
314
+ leftNoteWidth[0]! + 1,
315
+ ),
274
316
  ]
275
317
  for (let i = 1; i < diagram.actors.length; i++) {
276
318
  const prevSpan = boxSpanOf[i - 1]!
@@ -282,9 +324,16 @@ export function renderSequenceAscii(
282
324
  // The base constraint already leaves two blank columns between boxes;
283
325
  // each wall needs one column of its own plus one more blank beside it.
284
326
  const wallExtra = walls === 0 ? 0 : walls * (BRACKET_GAP + 1) - 1
327
+ // A `right` note on actor i-1, or a `left` note on actor i, draws into
328
+ // this gap — reserve room for whichever is wider (issue #953 cases B/C)
329
+ // so a wide note doesn't overflow into the neighbouring lifeline. The
330
+ // `+2` mirrors adjMaxWidth's own margin below and matches the 1-column
331
+ // clearance the note-x calculation already leaves on the lifeline side.
332
+ const noteGapNeed = Math.max(rightNoteWidth[i - 1]!, leftNoteWidth[i]!) + 2
285
333
  const gap = Math.max(
286
334
  halfBox[i - 1]! + halfBox[i]! + 2 + wallExtra,
287
335
  adjMaxWidth[i - 1]! + 2,
336
+ noteGapNeed,
288
337
  minLifelineGap,
289
338
  )
290
339
  llX[i] = llX[i - 1]! + gap
@@ -435,8 +484,7 @@ export function renderSequenceAscii(
435
484
  if (note.afterIndex !== -1) continue
436
485
  curY += rowGap // gap before note
437
486
  const nLines = splitLines(note.text)
438
- const nWidth =
439
- Math.max(...nLines.map((l) => displayWidth(l))) + 2 + 2 * boxPad
487
+ const nWidth = noteBoxWidth(note)
440
488
  const nHeight = nLines.length + 2
441
489
 
442
490
  const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
@@ -533,8 +581,7 @@ export function renderSequenceAscii(
533
581
  curY += rowGap
534
582
  const note = diagram.notes[n]!
535
583
  const nLines = splitLines(note.text)
536
- const nWidth =
537
- Math.max(...nLines.map((l) => displayWidth(l))) + 2 + 2 * boxPad
584
+ const nWidth = noteBoxWidth(note)
538
585
  const nHeight = nLines.length + 2
539
586
 
540
587
  // Determine x position based on note.position
@@ -11,8 +11,12 @@
11
11
  // Multi-series support: each series gets a distinct color from a palette.
12
12
  // ============================================================================
13
13
 
14
- import { parseXYChart } from '../xychart/parser.ts'
15
- import type { XYChart } from '../xychart/types.ts'
14
+ import {
15
+ parseXYChart,
16
+ getSeriesColor,
17
+ CHART_ACCENT_FALLBACK,
18
+ } from '@zombie-mermaid/mermaid-parser'
19
+ import type { XYChart } from '@zombie-mermaid/mermaid-parser'
16
20
  import type {
17
21
  AsciiConfig,
18
22
  AsciiTheme,
@@ -23,7 +27,6 @@ import type {
23
27
  } from './types.ts'
24
28
  import { colorizeText } from './ansi.ts'
25
29
  import { mkCanvas, mkRoleCanvas, write } from './canvas.ts'
26
- import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors.ts'
27
30
  import { splitStatements } from '@zombie-mermaid/core'
28
31
 
29
32
  // ============================================================================
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Craft Docs
4
+ Copyright (c) 2026 Dustin Fadler
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ import type { NodeInteraction } from './types.ts'
2
+
3
+ // ============================================================================
4
+ // Shared `click` directive parsing and href sanitization.
5
+ //
6
+ // Every diagram type that recognizes Mermaid's `click` statement (flowchart/
7
+ // state via src/parser.ts, class diagrams via
8
+ // packages/mermaid-parser/src/class/parser.ts) shares this exact grammar
9
+ // and the same href-safety rules — pulled out here so
10
+ // neither copy can drift from the other. See
11
+ // docs/decisions/no-script-interactivity.md for the tier model this
12
+ // implements: an `href` becomes a real `<a>` link (tier 2), a tooltip
13
+ // becomes a `<title>` (tier 1), and a `call`/`callback` binding is recorded
14
+ // as data but never executed (tier 3 stays unimplemented, permanently).
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Parse a `click <id> ...` statement and record the resulting
19
+ * {@link NodeInteraction} in `interactions`, keyed by whatever id scheme the
20
+ * caller's diagram type uses (flowchart/state node id, class diagram class
21
+ * id, ...).
22
+ *
23
+ * Supported forms:
24
+ * click id "url" ["tooltip"] [_blank|_self|_parent|_top]
25
+ * click id href "url" ["tooltip"] [_blank|_self|_parent|_top]
26
+ * click id call fn() ["tooltip"]
27
+ * click id callback fn() ["tooltip"]
28
+ *
29
+ * A line that doesn't match `click <id> ...` at all is a no-op — callers are
30
+ * expected to have already gated on a leading `click` keyword before calling
31
+ * this (see the `/^click\s+/i` checks at each call site) purely so this
32
+ * function's own regex can stay anchored to the id capture.
33
+ */
34
+ export function applyClickStatement(
35
+ line: string,
36
+ interactions: Map<string, NodeInteraction>,
37
+ ): void {
38
+ const match = line.match(/^click\s+([\w\p{L}-]+)\s+(.*)$/iu)
39
+ if (!match) return
40
+
41
+ const nodeId = match[1]!
42
+ let rest = match[2]!.trim()
43
+
44
+ const interaction: NodeInteraction = { ...interactions.get(nodeId) }
45
+
46
+ // `call fn()` / `callback fn()` — a script binding.
47
+ const callMatch = rest.match(/^(?:call|callback)\s+(.+?)\s*$/i)
48
+ if (callMatch) {
49
+ // A trailing quoted tooltip may follow the callback expression.
50
+ const withTooltip = callMatch[1]!.match(/^(.*?\))\s+"([^"]*)"\s*$/)
51
+ if (withTooltip) {
52
+ interaction.callback = withTooltip[1]!.trim()
53
+ interaction.tooltip = withTooltip[2]
54
+ } else {
55
+ interaction.callback = callMatch[1]!.trim()
56
+ }
57
+ interactions.set(nodeId, interaction)
58
+ return
59
+ }
60
+
61
+ // Optional explicit `href` keyword.
62
+ rest = rest.replace(/^href\s+/i, '')
63
+
64
+ // Remaining tokens: "url" ["tooltip"] [_target]
65
+ const quoted = [...rest.matchAll(/"([^"]*)"/g)].map((m) => m[1]!)
66
+ if (quoted.length > 0) interaction.href = quoted[0]
67
+ if (quoted.length > 1) interaction.tooltip = quoted[1]
68
+
69
+ const targetMatch = rest.match(/(_blank|_self|_parent|_top)\s*$/i)
70
+ if (targetMatch) interaction.target = targetMatch[1]!.toLowerCase()
71
+
72
+ if (interaction.href !== undefined || interaction.tooltip !== undefined) {
73
+ interactions.set(nodeId, interaction)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Accept only link schemes that cannot execute script.
79
+ *
80
+ * A `click` target comes from diagram text, which may be untrusted. An
81
+ * `href` of `javascript:...` (or a `data:` URL containing markup) would turn
82
+ * a rendered diagram into an XSS vector for any page that inlines the SVG, so
83
+ * anything but http/https/mailto and same-document or relative references is
84
+ * dropped rather than emitted.
85
+ *
86
+ * C0 controls are rejected outright, before any other check. The URL parser
87
+ * strips tab and newline from *anywhere* in a URL, so `java\tscript:` reaches
88
+ * a browser as `javascript:` — while the scheme match below sees `java\t…`,
89
+ * finds no scheme, and waves it through as a relative reference. Splitting a
90
+ * blocked scheme with a control character is the whole bypass; there is no
91
+ * legitimate URL with a raw control in it, so the entire range goes.
92
+ */
93
+ export function safeHref(href: string | undefined): string | undefined {
94
+ if (!href) return undefined
95
+
96
+ if (/[\x00-\x1F\x7F]/.test(href)) return undefined
97
+
98
+ const trimmed = href.trim()
99
+ // Relative, absolute-path, and fragment references carry no scheme.
100
+ if (/^[./#?]/.test(trimmed)) return trimmed
101
+
102
+ const scheme = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)?.[1]
103
+ if (scheme === undefined) return trimmed
104
+
105
+ return /^(https?|mailto)$/i.test(scheme) ? trimmed : undefined
106
+ }