@taloswork/ose-shared 0.1.7 → 0.1.9

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 (166) hide show
  1. package/package.json +3 -3
  2. package/skills/cozy-static-svg/SKILL.md +76 -0
  3. package/skills/cozy-static-svg/references/approved-preview.png +0 -0
  4. package/skills/cozy-static-svg/references/cross-platform.md +73 -0
  5. package/skills/cozy-static-svg/references/moon_keeps_watch.svg +229 -0
  6. package/skills/cozy-static-svg/references/style-anchor.md +271 -0
  7. package/skills/cozy-static-svg/scripts/artkit.py +321 -0
  8. package/skills/cozy-static-svg/scripts/to_png.py +333 -0
  9. package/skills/diagram-design/SKILL.md +568 -0
  10. package/skills/diagram-design/assets/example-architecture-dark.html +182 -0
  11. package/skills/diagram-design/assets/example-architecture-full.html +194 -0
  12. package/skills/diagram-design/assets/example-architecture.html +183 -0
  13. package/skills/diagram-design/assets/example-bar-dark.html +129 -0
  14. package/skills/diagram-design/assets/example-bar-full.html +105 -0
  15. package/skills/diagram-design/assets/example-bar.html +129 -0
  16. package/skills/diagram-design/assets/example-data-flow-dark.html +143 -0
  17. package/skills/diagram-design/assets/example-data-flow-full.html +185 -0
  18. package/skills/diagram-design/assets/example-data-flow.html +143 -0
  19. package/skills/diagram-design/assets/example-datalake-dark.html +243 -0
  20. package/skills/diagram-design/assets/example-datalake-full.html +248 -0
  21. package/skills/diagram-design/assets/example-datalake.html +269 -0
  22. package/skills/diagram-design/assets/example-dp-integration-dark.html +80 -0
  23. package/skills/diagram-design/assets/example-dp-integration-full.html +90 -0
  24. package/skills/diagram-design/assets/example-dp-integration.html +80 -0
  25. package/skills/diagram-design/assets/example-dp-security-matrix-dark.html +31 -0
  26. package/skills/diagram-design/assets/example-dp-security-matrix-full.html +32 -0
  27. package/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
  28. package/skills/diagram-design/assets/example-er-dark.html +202 -0
  29. package/skills/diagram-design/assets/example-er-full.html +205 -0
  30. package/skills/diagram-design/assets/example-er.html +202 -0
  31. package/skills/diagram-design/assets/example-flowchart-dark.html +156 -0
  32. package/skills/diagram-design/assets/example-flowchart-full.html +159 -0
  33. package/skills/diagram-design/assets/example-flowchart.html +156 -0
  34. package/skills/diagram-design/assets/example-gantt-dark.html +137 -0
  35. package/skills/diagram-design/assets/example-gantt-full.html +127 -0
  36. package/skills/diagram-design/assets/example-gantt.html +137 -0
  37. package/skills/diagram-design/assets/example-high-level-dark.html +251 -0
  38. package/skills/diagram-design/assets/example-high-level-full.html +247 -0
  39. package/skills/diagram-design/assets/example-high-level-vertical-dark.html +274 -0
  40. package/skills/diagram-design/assets/example-high-level-vertical-full.html +252 -0
  41. package/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
  42. package/skills/diagram-design/assets/example-high-level.html +301 -0
  43. package/skills/diagram-design/assets/example-import-drawio.html +185 -0
  44. package/skills/diagram-design/assets/example-import-mermaid.html +70 -0
  45. package/skills/diagram-design/assets/example-it-state-dark.html +90 -0
  46. package/skills/diagram-design/assets/example-it-state-full.html +69 -0
  47. package/skills/diagram-design/assets/example-it-state.html +128 -0
  48. package/skills/diagram-design/assets/example-layers-dark.html +123 -0
  49. package/skills/diagram-design/assets/example-layers-full.html +126 -0
  50. package/skills/diagram-design/assets/example-layers.html +123 -0
  51. package/skills/diagram-design/assets/example-line-dark.html +111 -0
  52. package/skills/diagram-design/assets/example-line-full.html +105 -0
  53. package/skills/diagram-design/assets/example-line.html +111 -0
  54. package/skills/diagram-design/assets/example-loop-dark.html +100 -0
  55. package/skills/diagram-design/assets/example-loop-full.html +137 -0
  56. package/skills/diagram-design/assets/example-loop-terminal.html +347 -0
  57. package/skills/diagram-design/assets/example-loop.html +100 -0
  58. package/skills/diagram-design/assets/example-medallion-dark.html +159 -0
  59. package/skills/diagram-design/assets/example-medallion-full.html +201 -0
  60. package/skills/diagram-design/assets/example-medallion.html +159 -0
  61. package/skills/diagram-design/assets/example-nested-dark.html +132 -0
  62. package/skills/diagram-design/assets/example-nested-full.html +135 -0
  63. package/skills/diagram-design/assets/example-nested.html +136 -0
  64. package/skills/diagram-design/assets/example-org-chart-dark.html +45 -0
  65. package/skills/diagram-design/assets/example-org-chart-full.html +45 -0
  66. package/skills/diagram-design/assets/example-org-chart.html +45 -0
  67. package/skills/diagram-design/assets/example-paved-road-animated.html +546 -0
  68. package/skills/diagram-design/assets/example-policy-trace-animated.html +490 -0
  69. package/skills/diagram-design/assets/example-process-dark.html +64 -0
  70. package/skills/diagram-design/assets/example-process-full.html +74 -0
  71. package/skills/diagram-design/assets/example-process.html +64 -0
  72. package/skills/diagram-design/assets/example-pyramid-dark.html +119 -0
  73. package/skills/diagram-design/assets/example-pyramid-full.html +122 -0
  74. package/skills/diagram-design/assets/example-pyramid.html +119 -0
  75. package/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
  76. package/skills/diagram-design/assets/example-quadrant-dark.html +135 -0
  77. package/skills/diagram-design/assets/example-quadrant-full.html +138 -0
  78. package/skills/diagram-design/assets/example-quadrant.html +135 -0
  79. package/skills/diagram-design/assets/example-queue-animated.html +577 -0
  80. package/skills/diagram-design/assets/example-radar-dark.html +139 -0
  81. package/skills/diagram-design/assets/example-radar-full.html +135 -0
  82. package/skills/diagram-design/assets/example-radar.html +139 -0
  83. package/skills/diagram-design/assets/example-scatter-dark.html +141 -0
  84. package/skills/diagram-design/assets/example-scatter-full.html +116 -0
  85. package/skills/diagram-design/assets/example-scatter.html +141 -0
  86. package/skills/diagram-design/assets/example-sequence-dark.html +222 -0
  87. package/skills/diagram-design/assets/example-sequence-full.html +388 -0
  88. package/skills/diagram-design/assets/example-sequence-oauth-dark.html +188 -0
  89. package/skills/diagram-design/assets/example-sequence-oauth-full.html +350 -0
  90. package/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
  91. package/skills/diagram-design/assets/example-sequence.html +222 -0
  92. package/skills/diagram-design/assets/example-state-dark.html +147 -0
  93. package/skills/diagram-design/assets/example-state-full.html +150 -0
  94. package/skills/diagram-design/assets/example-state.html +147 -0
  95. package/skills/diagram-design/assets/example-swimlane-dark.html +172 -0
  96. package/skills/diagram-design/assets/example-swimlane-full.html +175 -0
  97. package/skills/diagram-design/assets/example-swimlane.html +172 -0
  98. package/skills/diagram-design/assets/example-timeline-dark.html +135 -0
  99. package/skills/diagram-design/assets/example-timeline-full.html +138 -0
  100. package/skills/diagram-design/assets/example-timeline.html +135 -0
  101. package/skills/diagram-design/assets/example-tree-dark.html +173 -0
  102. package/skills/diagram-design/assets/example-tree-full.html +176 -0
  103. package/skills/diagram-design/assets/example-tree.html +173 -0
  104. package/skills/diagram-design/assets/example-venn-dark.html +132 -0
  105. package/skills/diagram-design/assets/example-venn-full.html +135 -0
  106. package/skills/diagram-design/assets/example-venn.html +110 -0
  107. package/skills/diagram-design/assets/icons.html +230 -0
  108. package/skills/diagram-design/assets/index.html +369 -0
  109. package/skills/diagram-design/assets/template-dark.html +85 -0
  110. package/skills/diagram-design/assets/template-full.html +413 -0
  111. package/skills/diagram-design/assets/template-motion.html +434 -0
  112. package/skills/diagram-design/assets/template-terminal.html +173 -0
  113. package/skills/diagram-design/assets/template.html +86 -0
  114. package/skills/diagram-design/references/animation.md +141 -0
  115. package/skills/diagram-design/references/export.md +136 -0
  116. package/skills/diagram-design/references/import-drawio.md +171 -0
  117. package/skills/diagram-design/references/import-mermaid.md +126 -0
  118. package/skills/diagram-design/references/onboarding.md +297 -0
  119. package/skills/diagram-design/references/output-spec.md +181 -0
  120. package/skills/diagram-design/references/primitive-annotation.md +36 -0
  121. package/skills/diagram-design/references/primitive-icons.md +827 -0
  122. package/skills/diagram-design/references/primitive-sketchy.md +43 -0
  123. package/skills/diagram-design/references/primitive-terminal.md +76 -0
  124. package/skills/diagram-design/references/semantic-patterns.md +122 -0
  125. package/skills/diagram-design/references/style-guide.md +139 -0
  126. package/skills/diagram-design/references/type-architecture.md +78 -0
  127. package/skills/diagram-design/references/type-bar.md +48 -0
  128. package/skills/diagram-design/references/type-data-flow.md +374 -0
  129. package/skills/diagram-design/references/type-dp-integration.md +410 -0
  130. package/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
  131. package/skills/diagram-design/references/type-er.md +23 -0
  132. package/skills/diagram-design/references/type-flowchart.md +23 -0
  133. package/skills/diagram-design/references/type-gantt.md +45 -0
  134. package/skills/diagram-design/references/type-high-level.md +458 -0
  135. package/skills/diagram-design/references/type-it-state.md +470 -0
  136. package/skills/diagram-design/references/type-layers.md +26 -0
  137. package/skills/diagram-design/references/type-line.md +44 -0
  138. package/skills/diagram-design/references/type-loop.md +223 -0
  139. package/skills/diagram-design/references/type-medallion.md +356 -0
  140. package/skills/diagram-design/references/type-nested.md +22 -0
  141. package/skills/diagram-design/references/type-org-chart.md +44 -0
  142. package/skills/diagram-design/references/type-process.md +495 -0
  143. package/skills/diagram-design/references/type-pyramid.md +33 -0
  144. package/skills/diagram-design/references/type-quadrant.md +81 -0
  145. package/skills/diagram-design/references/type-radar.md +80 -0
  146. package/skills/diagram-design/references/type-scatter.md +39 -0
  147. package/skills/diagram-design/references/type-sequence.md +130 -0
  148. package/skills/diagram-design/references/type-state.md +21 -0
  149. package/skills/diagram-design/references/type-swimlane.md +20 -0
  150. package/skills/diagram-design/references/type-timeline.md +20 -0
  151. package/skills/diagram-design/references/type-tree.md +24 -0
  152. package/skills/diagram-design/references/type-venn.md +26 -0
  153. package/skills/diagram-design/scripts/drawio_extract.py +856 -0
  154. package/skills/diagram-design/scripts/mermaid_extract.py +1285 -0
  155. package/skills/diagram-design/scripts/self_check.py +389 -0
  156. package/skills/document/bin/doc-gen.cjs +3 -6
  157. package/skills/document/bin/html2pptx.cjs +17 -9
  158. package/skills/document/bin/pptx-clone.cjs +41 -30
  159. package/skills/document/bin/pptx-embed-fonts.cjs +6 -2
  160. package/skills/document/bin/pptx-slide.cjs +46 -50
  161. package/skills/document/content-pipeline.md +54 -5
  162. package/skills/document/layouts/README.md +8 -0
  163. package/skills/document/layouts/blocks-2x2.tpl +19 -9
  164. package/skills/document/layouts/cards-3.tpl +1 -1
  165. package/skills/document/layouts/org-tree.tpl +1 -1
  166. package/skills/document/layouts/steps-vertical.tpl +1 -1
@@ -0,0 +1,1285 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a normalized intermediate representation (IR) from Mermaid text.
3
+
4
+ Trust boundary: this program parses bounded text. It never evaluates, renders,
5
+ fetches, or executes Mermaid, JavaScript, URLs, directives, or label content.
6
+ Every label and directive value is untrusted data. Click targets and styling are
7
+ counted and discarded; retained labels are emitted only as inert text.
8
+
9
+ Supported grammars are flowchart/graph, sequenceDiagram, stateDiagram-v2, and
10
+ erDiagram. Inputs may be .mmd, .mermaid, or Markdown files containing fenced
11
+ ``mermaid`` blocks.
12
+
13
+ Usage:
14
+ python3 mermaid_extract.py <file> [--diagram N|all] [--json]
15
+ [--max-rows N] [--out PATH]
16
+
17
+ Exit codes: 0 success, 2 unreadable, unsupported, malformed, or over limits.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import html
24
+ import json
25
+ import re
26
+ import sys
27
+ from dataclasses import asdict, dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, NoReturn
30
+
31
+
32
+ MAX_SOURCE_BYTES = 4 * 1024 * 1024
33
+ MAX_NODES = 2000
34
+ MAX_EDGES = 5000
35
+ SUPPORTED_KINDS = "flowchart, sequenceDiagram, stateDiagram-v2, erDiagram"
36
+ UNSUPPORTED_KINDS = {
37
+ "pie",
38
+ "mindmap",
39
+ "gitgraph",
40
+ "quadrantchart",
41
+ "timeline",
42
+ "c4context",
43
+ "sankey",
44
+ "sankey-beta",
45
+ "gantt",
46
+ "journey",
47
+ "classdiagram",
48
+ "statediagram",
49
+ }
50
+ MARKDOWN_SUFFIXES = {".md", ".markdown", ".mdown", ".mkd"}
51
+ MERMAID_SUFFIXES = {".mmd", ".mermaid"}
52
+
53
+
54
+ def _fail(message: str) -> NoReturn:
55
+ print(f"mermaid_extract: {message}", file=sys.stderr)
56
+ raise SystemExit(2)
57
+
58
+
59
+ @dataclass
60
+ class Node:
61
+ id: str
62
+ label: str = ""
63
+ shape: str = "rect"
64
+ parent: str | None = None
65
+ depth: int = 0
66
+ container: bool = False
67
+ children: list[str] = field(default_factory=list)
68
+ fields: list[str] = field(default_factory=list)
69
+ in_degree: int = 0
70
+ out_degree: int = 0
71
+
72
+
73
+ @dataclass
74
+ class Edge:
75
+ id: str
76
+ source: str
77
+ target: str
78
+ label: str = ""
79
+ style: str = "solid"
80
+ arrowhead: str = "arrow"
81
+ bidirectional: bool = False
82
+ undirected: bool = False
83
+ order: int = 0
84
+
85
+
86
+ @dataclass
87
+ class Diagram:
88
+ index: int
89
+ kind: str
90
+ source_line: int
91
+ direction: str = "TD"
92
+ nodes: list[Node] = field(default_factory=list)
93
+ edges: list[Edge] = field(default_factory=list)
94
+ fragments: list[dict[str, Any]] = field(default_factory=list)
95
+ notes: list[str] = field(default_factory=list)
96
+ discarded: dict[str, int] = field(
97
+ default_factory=lambda: {"style_directives": 0, "click_handlers": 0}
98
+ )
99
+ _nodes_by_id: dict[str, Node] = field(default_factory=dict, init=False, repr=False)
100
+
101
+ @property
102
+ def node_map(self) -> dict[str, Node]:
103
+ return self._nodes_by_id
104
+
105
+ def add_node(
106
+ self,
107
+ node_id: str,
108
+ label: str = "",
109
+ shape: str = "rect",
110
+ parent: str | None = None,
111
+ container: bool = False,
112
+ ) -> Node:
113
+ existing = self._nodes_by_id.get(node_id)
114
+ if existing is not None:
115
+ if label and (label != node_id or existing.label == existing.id):
116
+ existing.label = label
117
+ if shape != "rect" or not existing.shape:
118
+ existing.shape = shape
119
+ if parent is not None and existing.parent is None:
120
+ existing.parent = parent
121
+ existing.depth = self._depth_for(parent)
122
+ self._attach(parent, node_id)
123
+ existing.container = existing.container or container
124
+ return existing
125
+ if len(self.nodes) >= MAX_NODES:
126
+ _fail(f"node limit exceeded (max {MAX_NODES})")
127
+ node = Node(
128
+ id=node_id,
129
+ label=label or node_id,
130
+ shape=shape,
131
+ parent=parent,
132
+ depth=self._depth_for(parent),
133
+ container=container,
134
+ )
135
+ self.nodes.append(node)
136
+ self._nodes_by_id[node_id] = node
137
+ if parent is not None:
138
+ self._attach(parent, node_id)
139
+ return node
140
+
141
+ def _depth_for(self, parent: str | None) -> int:
142
+ if parent is None:
143
+ return 0
144
+ parent_node = self._nodes_by_id.get(parent)
145
+ return (parent_node.depth + 1) if parent_node is not None else 1
146
+
147
+ def _attach(self, parent: str, child: str) -> None:
148
+ parent_node = self._nodes_by_id.get(parent)
149
+ if parent_node is not None and child not in parent_node.children:
150
+ parent_node.children.append(child)
151
+ parent_node.container = True
152
+
153
+ def add_edge(
154
+ self,
155
+ source: str,
156
+ target: str,
157
+ label: str = "",
158
+ style: str = "solid",
159
+ arrowhead: str = "arrow",
160
+ bidirectional: bool = False,
161
+ undirected: bool = False,
162
+ ) -> Edge:
163
+ if len(self.edges) >= MAX_EDGES:
164
+ _fail(f"edge limit exceeded (max {MAX_EDGES})")
165
+ edge = Edge(
166
+ id=f"e{len(self.edges) + 1}",
167
+ source=source,
168
+ target=target,
169
+ label=label,
170
+ style=style,
171
+ arrowhead=arrowhead,
172
+ bidirectional=bidirectional,
173
+ undirected=undirected,
174
+ order=len(self.edges) + 1,
175
+ )
176
+ self.edges.append(edge)
177
+ return edge
178
+
179
+
180
+ @dataclass
181
+ class SourceBlock:
182
+ index: int
183
+ text: str
184
+ source_line: int
185
+
186
+
187
+ def clean_label(value: str) -> str:
188
+ """Flatten Mermaid label markup without interpreting it."""
189
+ text = value.strip()
190
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'`":
191
+ text = text[1:-1]
192
+ if text.startswith("`") and text.endswith("`"):
193
+ text = text[1:-1]
194
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
195
+ text = re.sub(r"<[^>]+>", "", text)
196
+ text = re.sub(r"(?<!&)#(quot|apos|amp|lt|gt);", r"&\1;", text)
197
+ text = html.unescape(text)
198
+ text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
199
+ text = re.sub(r"__(.*?)__", r"\1", text)
200
+ text = re.sub(r"(?<!\w)[*_](.*?)[*_](?!\w)", r"\1", text)
201
+ text = text.replace("\\\"", '"').replace("\\'", "'")
202
+ return "\n".join(part.strip() for part in text.splitlines()).strip()
203
+
204
+
205
+ def _read_bounded(path: Path) -> str:
206
+ try:
207
+ with path.open("rb") as source:
208
+ data = source.read(MAX_SOURCE_BYTES + 1)
209
+ except OSError as error:
210
+ _fail(f"{path}: {error}")
211
+ if len(data) > MAX_SOURCE_BYTES:
212
+ _fail(
213
+ f"source exceeds the {MAX_SOURCE_BYTES // (1024 * 1024)} MiB limit"
214
+ )
215
+ try:
216
+ return data.decode("utf-8")
217
+ except UnicodeDecodeError:
218
+ _fail(f"{path.name}: source is not valid UTF-8 text")
219
+
220
+
221
+ def load_blocks(path: Path) -> list[SourceBlock]:
222
+ suffix = path.suffix.casefold()
223
+ if suffix not in MERMAID_SUFFIXES | MARKDOWN_SUFFIXES:
224
+ _fail(f"{path.name}: not a Mermaid file")
225
+ source = _read_bounded(path)
226
+ if suffix in MERMAID_SUFFIXES:
227
+ return [SourceBlock(0, source, 1)]
228
+
229
+ blocks: list[SourceBlock] = []
230
+ lines = source.splitlines()
231
+ start: int | None = None
232
+ fence = ""
233
+ content: list[str] = []
234
+ for line_number, line in enumerate(lines, 1):
235
+ if start is None:
236
+ match = re.match(r"^\s*(`{3,}|~{3,})\s*mermaid\s*$", line, re.I)
237
+ if match:
238
+ start = line_number + 1
239
+ fence = match.group(1)
240
+ content = []
241
+ continue
242
+ if re.match(rf"^\s*{re.escape(fence[0])}{{{len(fence)},}}\s*$", line):
243
+ blocks.append(SourceBlock(len(blocks), "\n".join(content), start))
244
+ start = None
245
+ fence = ""
246
+ content = []
247
+ else:
248
+ content.append(line)
249
+ if start is not None:
250
+ _fail(f"{path.name}: unterminated mermaid fence starting at line {start - 1}")
251
+ if not blocks:
252
+ _fail(f"{path.name}: no fenced mermaid block found")
253
+ return blocks
254
+
255
+
256
+ FRONTMATTER_MAX_LINES = 40
257
+
258
+
259
+ def _frontmatter_end(lines: list[str]) -> int:
260
+ """Return the last line index of a leading `---` frontmatter block, or -1."""
261
+ first = next(
262
+ (index for index, line in enumerate(lines) if line.strip()),
263
+ None,
264
+ )
265
+ if first is None or lines[first].strip() != "---":
266
+ return -1
267
+ limit = min(len(lines), first + FRONTMATTER_MAX_LINES + 1)
268
+ for index in range(first + 1, limit):
269
+ if lines[index].strip() == "---":
270
+ return index
271
+ return -1
272
+
273
+
274
+ def _prepared_lines(block: SourceBlock) -> list[tuple[int, str]]:
275
+ prepared: list[tuple[int, str]] = []
276
+ in_directive = False
277
+ lines = block.text.splitlines()
278
+ frontmatter_end = _frontmatter_end(lines)
279
+ for offset, raw in enumerate(lines):
280
+ line_number = block.source_line + offset
281
+ stripped = raw.strip()
282
+ if offset <= frontmatter_end:
283
+ # Mermaid's `--- title: ... ---` frontmatter is source config; the
284
+ # redraw discards it the same way it discards `%%{init}%%`.
285
+ prepared.append((line_number, ""))
286
+ continue
287
+ if in_directive:
288
+ if "}%%" in stripped:
289
+ in_directive = False
290
+ prepared.append((line_number, ""))
291
+ continue
292
+ if stripped.startswith("%%{"):
293
+ if "}%%" not in stripped:
294
+ in_directive = True
295
+ prepared.append((line_number, ""))
296
+ continue
297
+ if stripped.startswith("%%"):
298
+ prepared.append((line_number, ""))
299
+ continue
300
+ prepared.append((line_number, raw.rstrip()))
301
+ return prepared
302
+
303
+
304
+ def _kind_and_direction(
305
+ lines: list[tuple[int, str]],
306
+ ) -> tuple[str, str, int]:
307
+ for position, (line_number, raw) in enumerate(lines):
308
+ text = raw.strip()
309
+ if not text:
310
+ continue
311
+ match = re.match(r"^(flowchart|graph)\s+(TD|TB|LR|RL|BT)\b", text, re.I)
312
+ if match:
313
+ return "flowchart", match.group(2).upper(), position
314
+ if re.match(r"^sequenceDiagram\b", text, re.I):
315
+ return "sequenceDiagram", "LR", position
316
+ if re.match(r"^stateDiagram-v2\b", text, re.I):
317
+ return "stateDiagram-v2", "TD", position
318
+ if re.match(r"^erDiagram\b", text, re.I):
319
+ return "erDiagram", "TD", position
320
+ token = text.split(maxsplit=1)[0]
321
+ if token.casefold() in UNSUPPORTED_KINDS:
322
+ _fail(
323
+ f"unsupported diagram kind: `{token}` (supported: {SUPPORTED_KINDS})"
324
+ )
325
+ _fail(f"not a Mermaid file at line {line_number}")
326
+ _fail("not a Mermaid file")
327
+
328
+
329
+ def _top_level_mask(text: str) -> str:
330
+ """Keep top-level syntax positions and blank quoted/bracketed content."""
331
+ output = list(text)
332
+ stack: list[str] = []
333
+ quote: str | None = None
334
+ escaped = False
335
+ pairs = {"]": "[", ")": "(", "}": "{"}
336
+ for index, character in enumerate(text):
337
+ if quote is not None:
338
+ output[index] = " "
339
+ if escaped:
340
+ escaped = False
341
+ elif character == "\\":
342
+ escaped = True
343
+ elif character == quote:
344
+ quote = None
345
+ continue
346
+ if character in "\"'`":
347
+ quote = character
348
+ output[index] = " "
349
+ continue
350
+ if character in "[({":
351
+ stack.append(character)
352
+ output[index] = " "
353
+ continue
354
+ if character in "])}":
355
+ if stack and stack[-1] == pairs[character]:
356
+ stack.pop()
357
+ output[index] = " "
358
+ continue
359
+ if stack:
360
+ output[index] = " "
361
+ return "".join(output)
362
+
363
+
364
+ def _split_top_level(text: str, delimiter: str) -> list[str]:
365
+ mask = _top_level_mask(text)
366
+ parts: list[str] = []
367
+ start = 0
368
+ for index, character in enumerate(mask):
369
+ if character == delimiter:
370
+ parts.append(text[start:index])
371
+ start = index + 1
372
+ parts.append(text[start:])
373
+ return parts
374
+
375
+
376
+ def _statement_complete(text: str) -> bool:
377
+ """Return whether quotes and node delimiters close within a statement."""
378
+ stack: list[str] = []
379
+ quote: str | None = None
380
+ escaped = False
381
+ pairs = {"]": "[", ")": "(", "}": "{"}
382
+ for character in text:
383
+ if quote is not None:
384
+ if escaped:
385
+ escaped = False
386
+ elif character == "\\":
387
+ escaped = True
388
+ elif character == quote:
389
+ quote = None
390
+ continue
391
+ if character in "\"'`":
392
+ quote = character
393
+ elif character in "[({":
394
+ stack.append(character)
395
+ elif character in "])}":
396
+ if stack and stack[-1] == pairs[character]:
397
+ stack.pop()
398
+ return quote is None and not stack
399
+
400
+
401
+ def _logical_statements(
402
+ lines: list[tuple[int, str]],
403
+ ) -> list[tuple[int, str]]:
404
+ """Join multiline Mermaid strings before parsing semicolon statements."""
405
+ logical: list[tuple[int, str]] = []
406
+ pending: list[str] = []
407
+ start_line = 0
408
+ for line_number, raw in lines:
409
+ if not pending and not raw.strip():
410
+ continue
411
+ if not pending:
412
+ start_line = line_number
413
+ pending.append(raw)
414
+ combined = "\n".join(pending)
415
+ if not _statement_complete(combined):
416
+ continue
417
+ logical.extend(
418
+ (start_line, statement)
419
+ for statement in _split_top_level(combined, ";")
420
+ )
421
+ pending = []
422
+ if pending:
423
+ _fail(f"unterminated statement at line {start_line}")
424
+ return logical
425
+
426
+
427
+ SHAPE_FOR_DELIMITERS = (
428
+ ("(((", ")))", "circle"),
429
+ ("((", "))", "circle"),
430
+ ("([", "])", "stadium"),
431
+ ("{{", "}}", "hexagon"),
432
+ ("[(", ")]", "cylinder"),
433
+ ("[[", "]]", "subroutine"),
434
+ ("[/", "/]", "parallelogram"),
435
+ ("[\\", "\\]", "parallelogram"),
436
+ ("[/", "\\]", "trapezoid"),
437
+ ("[\\", "/]", "trapezoid"),
438
+ ("[", "]", "rect"),
439
+ ("(", ")", "round"),
440
+ ("{", "}", "rhombus"),
441
+ (">", "]", "asymmetric"),
442
+ )
443
+
444
+ EXPANDED_SHAPE_FAMILIES = {
445
+ "rect": "rect",
446
+ "rectangle": "rect",
447
+ "proc": "rect",
448
+ "process": "rect",
449
+ "rounded": "round",
450
+ "event": "round",
451
+ "stadium": "stadium",
452
+ "pill": "stadium",
453
+ "terminal": "stadium",
454
+ "circle": "circle",
455
+ "circ": "circle",
456
+ "sm-circ": "circle",
457
+ "small-circle": "circle",
458
+ "start": "circle",
459
+ "dbl-circ": "circle",
460
+ "double-circle": "circle",
461
+ "fr-circ": "circle",
462
+ "framed-circle": "circle",
463
+ "stop": "circle",
464
+ "cyl": "cylinder",
465
+ "cylinder": "cylinder",
466
+ "database": "cylinder",
467
+ "db": "cylinder",
468
+ "h-cyl": "cylinder",
469
+ "horizontal-cylinder": "cylinder",
470
+ "lin-cyl": "cylinder",
471
+ "lined-cylinder": "cylinder",
472
+ "diam": "rhombus",
473
+ "decision": "rhombus",
474
+ "diamond": "rhombus",
475
+ "question": "rhombus",
476
+ "hex": "hexagon",
477
+ "hexagon": "hexagon",
478
+ "prepare": "hexagon",
479
+ "fr-rect": "subroutine",
480
+ "framed-rectangle": "subroutine",
481
+ "subproc": "subroutine",
482
+ "subprocess": "subroutine",
483
+ "subroutine": "subroutine",
484
+ "lean-r": "parallelogram",
485
+ "lean-l": "parallelogram",
486
+ "in-out": "parallelogram",
487
+ "lean-right": "parallelogram",
488
+ "lean-left": "parallelogram",
489
+ "out-in": "parallelogram",
490
+ "trap-t": "trapezoid",
491
+ "trap-b": "trapezoid",
492
+ "trapezoid": "trapezoid",
493
+ "inv-trapezoid": "trapezoid",
494
+ "manual": "trapezoid",
495
+ "priority": "trapezoid",
496
+ }
497
+
498
+
499
+ def classify_shape(expression: str) -> str:
500
+ """Return the normalized Mermaid shape family for a node suffix."""
501
+ for opening, closing, shape in SHAPE_FOR_DELIMITERS:
502
+ if expression.startswith(opening) and expression.endswith(closing):
503
+ return shape
504
+ return "rect"
505
+
506
+
507
+ def _strip_class_suffix(text: str) -> str:
508
+ """Drop Mermaid's `:::class` attachment; source styling is discarded."""
509
+ mask = _top_level_mask(text)
510
+ index = mask.find(":::")
511
+ if index == -1:
512
+ return text
513
+ end = index + 3
514
+ while end < len(text) and (text[end].isalnum() or text[end] in "_-"):
515
+ end += 1
516
+ return (text[:index] + text[end:]).strip()
517
+
518
+
519
+ def _parse_expanded_attributes(text: str) -> tuple[str, str] | None:
520
+ """Normalize Mermaid v11.3+ ``@{ ... }`` node attributes.
521
+
522
+ Only semantic label/shape data crosses the trust boundary. Image URLs,
523
+ registered icon names, dimensions, and renderer configuration are dropped.
524
+ """
525
+ if not text.startswith("@{") or not text.endswith("}"):
526
+ return None
527
+ values: dict[str, str] = {}
528
+ for raw_attribute in _split_top_level(text[2:-1], ","):
529
+ key, separator, raw_value = raw_attribute.partition(":")
530
+ if not separator:
531
+ continue
532
+ key = key.strip().casefold()
533
+ if not re.fullmatch(r"[a-z][a-z0-9_-]*", key):
534
+ continue
535
+ values[key] = clean_label(raw_value)
536
+ shape_name = values.get("shape", "").casefold()
537
+ if not shape_name:
538
+ if "img" in values:
539
+ shape_name = "image"
540
+ elif "icon" in values:
541
+ shape_name = "icon"
542
+ else:
543
+ shape_name = "rect"
544
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", shape_name):
545
+ shape_name = "rect"
546
+ shape = EXPANDED_SHAPE_FAMILIES.get(shape_name, shape_name)
547
+ return values.get("label", ""), shape
548
+
549
+
550
+ def _parse_node_expression(expression: str) -> tuple[str, str, str] | None:
551
+ text = _strip_class_suffix(expression.strip().rstrip(";").strip())
552
+ if not text:
553
+ return None
554
+ match = re.match(r"^([\w.:-]+)", text, re.UNICODE)
555
+ if match is None:
556
+ return None
557
+ node_id = match.group(1)
558
+ rest = text[match.end() :].strip()
559
+ if not rest:
560
+ return node_id, node_id, "rect"
561
+ expanded = _parse_expanded_attributes(rest)
562
+ if expanded is not None:
563
+ label, shape = expanded
564
+ return node_id, label or node_id, shape
565
+ for opening, closing, _shape in SHAPE_FOR_DELIMITERS:
566
+ if rest.startswith(opening) and rest.endswith(closing):
567
+ label = rest[len(opening) : len(rest) - len(closing)]
568
+ return node_id, clean_label(label), classify_shape(rest)
569
+ return None
570
+
571
+
572
+ @dataclass
573
+ class _Operator:
574
+ start: int
575
+ end: int
576
+ label: str
577
+ style: str
578
+ arrowhead: str
579
+ bidirectional: bool = False
580
+ undirected: bool = False
581
+
582
+
583
+ def _operator_style(token: str) -> tuple[str, str, bool, bool]:
584
+ style = "dashed" if "." in token else "thick" if "=" in token else "solid"
585
+ arrowhead = "cross" if token.endswith("x") else "circle" if token.endswith("o") else "arrow"
586
+ undirected = ">" not in token and not token.endswith(("x", "o"))
587
+ bidirectional = (
588
+ token.startswith("<") and token.endswith(">")
589
+ ) or (token.startswith(("x", "o")) and token.endswith(("x", "o")))
590
+ return style, arrowhead, bidirectional, undirected
591
+
592
+
593
+ def _edge_operators(text: str) -> list[_Operator]:
594
+ mask = _top_level_mask(text)
595
+ operators: list[_Operator] = []
596
+ occupied: list[tuple[int, int]] = []
597
+
598
+ # Labeled links carry the label between the opening and closing operator:
599
+ # `A-- text -->B`, `A-. retry .-> B`, `A== critical ==> B`, and the
600
+ # undirected forms of each.
601
+ text_edge = re.compile(
602
+ r"(?:--|-\.|==)\s+(.+?)\s+(\.-+[>xo]|\.-+|-{2,}>|--[xo]|=+>|={2,}|-{3,})"
603
+ )
604
+ for match in text_edge.finditer(mask):
605
+ token = match.group(2)
606
+ style, arrowhead, bidirectional, undirected = _operator_style(token)
607
+ operators.append(
608
+ _Operator(
609
+ match.start(),
610
+ match.end(),
611
+ clean_label(text[match.start(1) : match.end(1)]),
612
+ style,
613
+ arrowhead,
614
+ bidirectional,
615
+ undirected,
616
+ )
617
+ )
618
+ occupied.append((match.start(), match.end()))
619
+
620
+ pattern = re.compile(
621
+ r"[xo][-=.]+[xo]|<[-=.]+>|-+\.-+>|=+>|-+(?:>|x|o)|-+\.-+|={3,}|-{3,}"
622
+ )
623
+ for match in pattern.finditer(mask):
624
+ if any(start <= match.start() < end for start, end in occupied):
625
+ continue
626
+ token = match.group()
627
+ end = match.end()
628
+ label = ""
629
+ if end < len(text) and text[end] == "|":
630
+ close = text.find("|", end + 1)
631
+ if close != -1:
632
+ label = clean_label(text[end + 1 : close])
633
+ end = close + 1
634
+ style, arrowhead, bidirectional, undirected = _operator_style(token)
635
+ operators.append(
636
+ _Operator(
637
+ match.start(), end, label, style, arrowhead, bidirectional, undirected
638
+ )
639
+ )
640
+ occupied.append((match.start(), end))
641
+ return sorted(operators, key=lambda operator: operator.start)
642
+
643
+
644
+ def _endpoint_group(
645
+ diagram: Diagram, text: str, parent: str | None
646
+ ) -> list[str] | None:
647
+ identifiers: list[str] = []
648
+ for raw in _split_top_level(text.strip(), "&"):
649
+ parsed = _parse_node_expression(raw)
650
+ if parsed is None:
651
+ return None
652
+ node_id, label, shape = parsed
653
+ diagram.add_node(node_id, label, shape, parent)
654
+ identifiers.append(node_id)
655
+ return identifiers or None
656
+
657
+
658
+ STYLE_DIRECTIVES = ("style ", "classDef ", "class ", "linkStyle ")
659
+
660
+
661
+ def _discard_nonsemantic(diagram: Diagram, text: str) -> bool:
662
+ lowered = text.casefold()
663
+ if any(lowered.startswith(prefix.casefold()) for prefix in STYLE_DIRECTIVES):
664
+ diagram.discarded["style_directives"] += 1
665
+ return True
666
+ if lowered.startswith("click "):
667
+ diagram.discarded["click_handlers"] += 1
668
+ return True
669
+ return False
670
+
671
+
672
+ def _parse_flowchart(
673
+ diagram: Diagram, lines: list[tuple[int, str]], header_position: int
674
+ ) -> None:
675
+ containers: list[str] = []
676
+ for line_number, raw in _logical_statements(lines[header_position + 1 :]):
677
+ text = raw.strip()
678
+ if not text:
679
+ continue
680
+ lowered = text.casefold()
681
+ if _discard_nonsemantic(diagram, text):
682
+ continue
683
+ if lowered.startswith("direction "):
684
+ if not containers:
685
+ direction = text.split(maxsplit=1)[1].upper()
686
+ if direction in {"TD", "TB", "LR", "RL", "BT"}:
687
+ diagram.direction = direction
688
+ continue
689
+ if lowered.startswith("subgraph "):
690
+ spec = text.split(maxsplit=1)[1].strip()
691
+ parsed = _parse_node_expression(spec)
692
+ if parsed is None:
693
+ generated = f"subgraph-{len([node for node in diagram.nodes if node.container]) + 1}"
694
+ node_id, label = generated, clean_label(spec)
695
+ else:
696
+ node_id, label, _shape = parsed
697
+ parent = containers[-1] if containers else None
698
+ diagram.add_node(node_id, label, "container", parent, container=True)
699
+ containers.append(node_id)
700
+ continue
701
+ if lowered == "end":
702
+ if containers:
703
+ containers.pop()
704
+ continue
705
+
706
+ operators = _edge_operators(text)
707
+ parent = containers[-1] if containers else None
708
+ if operators:
709
+ segments: list[str] = []
710
+ cursor = 0
711
+ for operator in operators:
712
+ segments.append(text[cursor : operator.start])
713
+ cursor = operator.end
714
+ segments.append(text[cursor:])
715
+ if len(segments) != len(operators) + 1:
716
+ _fail(f"malformed edge at line {line_number}")
717
+ groups = [_endpoint_group(diagram, segment, parent) for segment in segments]
718
+ if any(group is None for group in groups):
719
+ _fail(f"malformed edge at line {line_number}")
720
+ valid_groups = [group for group in groups if group is not None]
721
+ for index, operator in enumerate(operators):
722
+ for source in valid_groups[index]:
723
+ for target in valid_groups[index + 1]:
724
+ diagram.add_edge(
725
+ source,
726
+ target,
727
+ operator.label,
728
+ operator.style,
729
+ operator.arrowhead,
730
+ operator.bidirectional,
731
+ operator.undirected,
732
+ )
733
+ continue
734
+ if re.search(r"(?:--|==|-.).*?(?:>|x|o|-)", _top_level_mask(text)):
735
+ _fail(f"malformed edge at line {line_number}")
736
+ parsed = _parse_node_expression(text)
737
+ if parsed is not None:
738
+ node_id, label, shape = parsed
739
+ diagram.add_node(node_id, label, shape, parent)
740
+
741
+
742
+ def _parse_sequence(
743
+ diagram: Diagram, lines: list[tuple[int, str]], header_position: int
744
+ ) -> None:
745
+ fragment_stack: list[dict[str, Any]] = []
746
+ participant_re = re.compile(
747
+ r"^(participant|actor)\s+([\w.:-]+)(?:\s+as\s+(.+))?$", re.I
748
+ )
749
+ message_re = re.compile(
750
+ r"^([\w.:-]+?)(?:\(\))?\s*(--?>>|--?>|--?\)|--?x)"
751
+ r"\s*[+-]?\s*(?:\(\))?([\w.:-]+)\s*:\s*(.*)$"
752
+ )
753
+ for line_number, raw in lines[header_position + 1 :]:
754
+ text = raw.strip()
755
+ if not text:
756
+ continue
757
+ lowered = text.casefold()
758
+ if _discard_nonsemantic(diagram, text):
759
+ continue
760
+ participant = participant_re.match(text)
761
+ if participant:
762
+ node_id = participant.group(2)
763
+ diagram.add_node(
764
+ node_id,
765
+ clean_label(participant.group(3) or node_id),
766
+ "actor" if participant.group(1).casefold() == "actor" else "lifeline",
767
+ )
768
+ continue
769
+ fragment = re.match(r"^(alt|opt|loop|par|critical|break)\b\s*(.*)$", text, re.I)
770
+ if fragment:
771
+ entry = {
772
+ "kind": fragment.group(1).casefold(),
773
+ "label": clean_label(fragment.group(2)),
774
+ "line": line_number,
775
+ "depth": len(fragment_stack),
776
+ "regions": [],
777
+ }
778
+ diagram.fragments.append(entry)
779
+ fragment_stack.append(entry)
780
+ continue
781
+ region = re.match(r"^(else|and|option)\b\s*(.*)$", text, re.I)
782
+ if region and fragment_stack:
783
+ fragment_stack[-1]["regions"].append(clean_label(region.group(2)))
784
+ continue
785
+ if lowered == "end":
786
+ if fragment_stack:
787
+ fragment_stack.pop()
788
+ continue
789
+ if lowered.startswith(("activate ", "deactivate ", "+", "-")):
790
+ continue
791
+ if lowered.startswith("note "):
792
+ _, separator, note = text.partition(":")
793
+ diagram.notes.append(clean_label(note if separator else text[5:]))
794
+ continue
795
+ message = message_re.match(text)
796
+ if message:
797
+ source, token, target, label = message.groups()
798
+ diagram.add_node(source, source, "lifeline")
799
+ diagram.add_node(target, target, "lifeline")
800
+ diagram.add_edge(
801
+ source,
802
+ target,
803
+ clean_label(label),
804
+ "dashed" if token.startswith("--") else "solid",
805
+ "cross" if token.endswith("x") else "async" if token.endswith(")") else "arrow",
806
+ )
807
+ continue
808
+ if re.search(r"--?>>|--?>|--?\)|--?x", text):
809
+ _fail(f"malformed edge at line {line_number}")
810
+
811
+
812
+ def _state_endpoint(
813
+ diagram: Diagram,
814
+ token: str,
815
+ role: str,
816
+ parent: str | None,
817
+ ) -> str | None:
818
+ value = _strip_class_suffix(token.strip())
819
+ if value == "[*]":
820
+ prefix = "start" if role == "source" else "end"
821
+ count = sum(node.id.startswith("__" + prefix) for node in diagram.nodes)
822
+ node_id = f"__{prefix}_{count + 1}"
823
+ diagram.add_node(node_id, f"[{prefix}]", prefix, parent)
824
+ return node_id
825
+ parsed = _parse_node_expression(value)
826
+ if parsed is None:
827
+ return None
828
+ node_id, label, shape = parsed
829
+ diagram.add_node(node_id, label, "state" if shape == "rect" else shape, parent)
830
+ return node_id
831
+
832
+
833
+ def _parse_state(
834
+ diagram: Diagram, lines: list[tuple[int, str]], header_position: int
835
+ ) -> None:
836
+ containers: list[str] = []
837
+ for line_number, raw in lines[header_position + 1 :]:
838
+ text = raw.strip()
839
+ if not text:
840
+ continue
841
+ if _discard_nonsemantic(diagram, text):
842
+ continue
843
+ if text == "}":
844
+ if containers:
845
+ containers.pop()
846
+ continue
847
+ parent = containers[-1] if containers else None
848
+ direction_match = re.match(r"^direction\s+(TD|TB|LR|RL|BT)$", text, re.I)
849
+ if direction_match and not containers:
850
+ diagram.direction = direction_match.group(1).upper()
851
+ continue
852
+ composite = re.match(r"^state\s+([\w.:-]+)\s*\{$", text, re.I)
853
+ if composite:
854
+ node_id = composite.group(1)
855
+ diagram.add_node(node_id, node_id, "container", parent, container=True)
856
+ containers.append(node_id)
857
+ continue
858
+ alias = re.match(r'^state\s+"(.*?)"\s+as\s+([\w.:-]+)$', text, re.I)
859
+ if alias:
860
+ diagram.add_node(alias.group(2), clean_label(alias.group(1)), "state", parent)
861
+ continue
862
+ stereotype = re.match(
863
+ r"^state\s+([\w.:-]+)\s+<<(fork|join|choice)>>$", text, re.I
864
+ )
865
+ if stereotype:
866
+ diagram.add_node(
867
+ stereotype.group(1), stereotype.group(1), stereotype.group(2).casefold(), parent
868
+ )
869
+ continue
870
+ if "-->" in text:
871
+ source_text, target_text = text.split("-->", 1)
872
+ label = ""
873
+ label_separator = re.search(r"(?<!:):(?!:)", target_text)
874
+ if label_separator:
875
+ label = target_text[label_separator.end() :]
876
+ target_text = target_text[: label_separator.start()]
877
+ source = _state_endpoint(diagram, source_text, "source", parent)
878
+ target = _state_endpoint(diagram, target_text, "target", parent)
879
+ if source is None or target is None:
880
+ _fail(f"malformed edge at line {line_number}")
881
+ diagram.add_edge(source, target, clean_label(label))
882
+ continue
883
+ description = re.match(r"^([A-Za-z_][\w.-]*)\s*:\s*(.+)$", text)
884
+ if description:
885
+ diagram.add_node(
886
+ description.group(1), clean_label(description.group(2)), "state", parent
887
+ )
888
+ continue
889
+ plain = re.match(r"^state\s+([\w.:-]+)$", text, re.I)
890
+ if plain:
891
+ diagram.add_node(plain.group(1), plain.group(1), "state", parent)
892
+
893
+
894
+ def _parse_er(
895
+ diagram: Diagram, lines: list[tuple[int, str]], header_position: int
896
+ ) -> None:
897
+ current: Node | None = None
898
+ relationship = re.compile(
899
+ r"^([A-Za-z_][\w.-]*)\s+(\S*(?:--|\.\.)\S*)\s+"
900
+ r"([A-Za-z_][\w.-]*)\s*(?::\s*(.*))?$"
901
+ )
902
+ for line_number, raw in lines[header_position + 1 :]:
903
+ text = raw.strip()
904
+ if not text:
905
+ continue
906
+ if _discard_nonsemantic(diagram, text):
907
+ continue
908
+ if text == "}":
909
+ current = None
910
+ continue
911
+ direction_match = re.match(r"^direction\s+(TD|TB|LR|RL|BT)$", text, re.I)
912
+ if direction_match and current is None:
913
+ diagram.direction = direction_match.group(1).upper()
914
+ continue
915
+ entity = re.match(r"^([A-Za-z_][\w.-]*)\s*\{$", text)
916
+ if entity:
917
+ current = diagram.add_node(entity.group(1), entity.group(1), "table")
918
+ continue
919
+ if current is not None:
920
+ current.fields.append(clean_label(text))
921
+ continue
922
+ edge = relationship.match(text)
923
+ if edge:
924
+ source, cardinality, target, relationship_label = edge.groups()
925
+ diagram.add_node(source, source, "table")
926
+ diagram.add_node(target, target, "table")
927
+ left, separator, right = cardinality.partition("--")
928
+ if not separator:
929
+ left, separator, right = cardinality.partition("..")
930
+ label_parts = [f"{left} {separator} {right}".strip()]
931
+ if relationship_label:
932
+ label_parts.append(clean_label(relationship_label))
933
+ diagram.add_edge(
934
+ source,
935
+ target,
936
+ " · ".join(label_parts),
937
+ "dashed" if separator == ".." else "solid",
938
+ "cardinality",
939
+ undirected=True,
940
+ )
941
+ continue
942
+ if "--" in text or ".." in text:
943
+ _fail(f"malformed edge at line {line_number}")
944
+
945
+
946
+ def parse_block(block: SourceBlock) -> Diagram:
947
+ lines = _prepared_lines(block)
948
+ kind, direction, header_position = _kind_and_direction(lines)
949
+ diagram = Diagram(block.index, kind, block.source_line, direction=direction)
950
+ if kind == "flowchart":
951
+ _parse_flowchart(diagram, lines, header_position)
952
+ elif kind == "sequenceDiagram":
953
+ _parse_sequence(diagram, lines, header_position)
954
+ elif kind == "stateDiagram-v2":
955
+ _parse_state(diagram, lines, header_position)
956
+ else:
957
+ _parse_er(diagram, lines, header_position)
958
+ _finalize_degrees(diagram)
959
+ return diagram
960
+
961
+
962
+ def _finalize_degrees(diagram: Diagram) -> None:
963
+ nodes = diagram.node_map
964
+ for edge in diagram.edges:
965
+ if edge.source in nodes:
966
+ nodes[edge.source].out_degree += 1
967
+ if edge.target in nodes:
968
+ nodes[edge.target].in_degree += 1
969
+
970
+
971
+ def _has_cycle(nodes: list[Node], edges: list[Edge]) -> bool:
972
+ adjacency: dict[str, list[str]] = {node.id: [] for node in nodes}
973
+ for edge in edges:
974
+ if edge.source in adjacency and edge.target in adjacency:
975
+ adjacency[edge.source].append(edge.target)
976
+ WHITE, GREY, BLACK = 0, 1, 2
977
+ colors = {node.id: WHITE for node in nodes}
978
+
979
+ def visit(start: str) -> bool:
980
+ stack: list[tuple[str, Any]] = [(start, iter(adjacency[start]))]
981
+ colors[start] = GREY
982
+ while stack:
983
+ node_id, targets = stack[-1]
984
+ for target in targets:
985
+ if colors.get(target, BLACK) == GREY:
986
+ return True
987
+ if colors.get(target, BLACK) == WHITE:
988
+ colors[target] = GREY
989
+ stack.append((target, iter(adjacency.get(target, []))))
990
+ break
991
+ else:
992
+ colors[node_id] = BLACK
993
+ stack.pop()
994
+ return False
995
+
996
+ return any(colors[node.id] == WHITE and visit(node.id) for node in nodes)
997
+
998
+
999
+ def shape_family(shape: str) -> str:
1000
+ return "container" if shape == "container" else shape
1001
+
1002
+
1003
+ def analyze(diagram: Diagram) -> dict[str, Any]:
1004
+ containers = [node for node in diagram.nodes if node.container or node.children]
1005
+ leaves = [node for node in diagram.nodes if not (node.container or node.children)]
1006
+ shapes: dict[str, int] = {}
1007
+ for node in diagram.nodes:
1008
+ family = shape_family(node.shape)
1009
+ shapes[family] = shapes.get(family, 0) + 1
1010
+
1011
+ def name(node: Node) -> str:
1012
+ return node.label.replace("\n", " · ") or node.id
1013
+
1014
+ hubs = [
1015
+ {"id": node.id, "label": name(node), "degree": node.in_degree + node.out_degree}
1016
+ for node in sorted(
1017
+ leaves,
1018
+ key=lambda item: (item.in_degree + item.out_degree, item.id),
1019
+ reverse=True,
1020
+ )[:5]
1021
+ if node.in_degree + node.out_degree > 0
1022
+ ]
1023
+ entry_points = [name(node) for node in leaves if node.out_degree and not node.in_degree]
1024
+ terminals = [name(node) for node in leaves if node.in_degree and not node.out_degree]
1025
+ orphans = [name(node) for node in leaves if not node.in_degree and not node.out_degree]
1026
+ candidates = {
1027
+ "flowchart": ["flowchart" if shapes.get("rhombus") else "architecture", "architecture"],
1028
+ "sequenceDiagram": ["sequence"],
1029
+ "stateDiagram-v2": ["state machine"],
1030
+ "erDiagram": ["ER / data model"],
1031
+ }[diagram.kind]
1032
+ candidates = list(dict.fromkeys(candidates))
1033
+ collapsible = [
1034
+ {
1035
+ "id": node.id,
1036
+ "label": name(node),
1037
+ "children": len(node.children),
1038
+ "child_labels": [
1039
+ name(diagram.node_map[child])
1040
+ for child in node.children
1041
+ if child in diagram.node_map
1042
+ ][:8],
1043
+ }
1044
+ for node in containers
1045
+ if node.children
1046
+ ]
1047
+ collapsible.sort(key=lambda item: item["children"], reverse=True)
1048
+ drawable = len(leaves)
1049
+ return {
1050
+ "nodes_total": len(diagram.nodes),
1051
+ "nodes_drawable": drawable,
1052
+ "containers": len(containers),
1053
+ "leaves": len(leaves),
1054
+ "edges_total": len(diagram.edges),
1055
+ "edges_labeled": sum(bool(edge.label) for edge in diagram.edges),
1056
+ "edges_dangling": 0,
1057
+ "max_depth": max((node.depth for node in diagram.nodes), default=0),
1058
+ "shapes": dict(sorted(shapes.items(), key=lambda item: (-item[1], item[0]))),
1059
+ "has_cycle": _has_cycle(diagram.nodes, diagram.edges),
1060
+ "hubs": hubs,
1061
+ "entry_points": entry_points[:6],
1062
+ "terminals": terminals[:6],
1063
+ "orphans": orphans[:6],
1064
+ "type_candidates": candidates,
1065
+ "collapsible_groups": collapsible[:8],
1066
+ "over_node_budget": drawable > 9,
1067
+ "over_edge_budget": len(diagram.edges) > 12,
1068
+ }
1069
+
1070
+
1071
+ def _escape_markdown(text: str) -> str:
1072
+ encoded = html.escape(text, quote=False)
1073
+ return re.sub(r"([\\`*{}\[\]()#+\-.!_|>])", r"\\\1", encoded)
1074
+
1075
+
1076
+ def _escape_table(text: str) -> str:
1077
+ return _escape_markdown(text.replace("\n", " ⏎ "))
1078
+
1079
+
1080
+ def digest(
1081
+ path: Path,
1082
+ diagrams: list[Diagram],
1083
+ selected: list[Diagram],
1084
+ max_rows: int,
1085
+ ) -> str:
1086
+ output = [f"# Mermaid IR — {path.name}", ""]
1087
+ output.append(
1088
+ f"{len(diagrams)} diagram(s): "
1089
+ + ", ".join(
1090
+ f"[{diagram.index}] {diagram.kind} ({len(diagram.nodes)}n/{len(diagram.edges)}e)"
1091
+ for diagram in diagrams
1092
+ )
1093
+ )
1094
+ for diagram in selected:
1095
+ info = analyze(diagram)
1096
+ output.extend(
1097
+ [
1098
+ "",
1099
+ f"## Diagram {diagram.index} — {diagram.kind}",
1100
+ "",
1101
+ f"- source layout: none (Mermaid is layout-free); direction: {diagram.direction}",
1102
+ f"- nodes: {info['nodes_total']} total / {info['nodes_drawable']} drawable / "
1103
+ f"{info['containers']} containers, depth {info['max_depth']}",
1104
+ f"- edges: {info['edges_total']} ({info['edges_labeled']} labeled, "
1105
+ f"{info['edges_dangling']} dangling), cycle: {info['has_cycle']}",
1106
+ f"- shapes: {info['shapes']}",
1107
+ f"- type candidates: {', '.join(info['type_candidates'])}",
1108
+ f"- budget: nodes {'OVER' if info['over_node_budget'] else 'ok'} (max 9), "
1109
+ f"edges {'OVER' if info['over_edge_budget'] else 'ok'} (max 12)",
1110
+ ]
1111
+ )
1112
+ if diagram.discarded["style_directives"] or diagram.discarded["click_handlers"]:
1113
+ output.append(
1114
+ f"- discarded: {diagram.discarded['style_directives']} style directives, "
1115
+ f"{diagram.discarded['click_handlers']} click handlers"
1116
+ )
1117
+ if diagram.fragments:
1118
+ fragments = ", ".join(
1119
+ f"{item['kind']}({_escape_markdown(item['label'] or 'unlabeled')})"
1120
+ for item in diagram.fragments
1121
+ )
1122
+ output.append(f"- fragments: {fragments}")
1123
+ if diagram.notes:
1124
+ output.append(
1125
+ f"- notes: {'; '.join(_escape_markdown(note) for note in diagram.notes[:6])}"
1126
+ )
1127
+ if info["hubs"]:
1128
+ output.append(
1129
+ "- hubs (focal candidates): "
1130
+ + ", ".join(
1131
+ f"{_escape_markdown(hub['label'])}({hub['degree']})"
1132
+ for hub in info["hubs"]
1133
+ )
1134
+ )
1135
+ if info["entry_points"]:
1136
+ output.append(
1137
+ f"- entry points: {', '.join(_escape_markdown(label) for label in info['entry_points'])}"
1138
+ )
1139
+ if info["terminals"]:
1140
+ output.append(
1141
+ f"- terminals: {', '.join(_escape_markdown(label) for label in info['terminals'])}"
1142
+ )
1143
+ if info["orphans"]:
1144
+ output.append(
1145
+ f"- unconnected: {', '.join(_escape_markdown(label) for label in info['orphans'])}"
1146
+ )
1147
+ if info["collapsible_groups"]:
1148
+ output.append("- collapsible groups (simplify here first):")
1149
+ for group in info["collapsible_groups"]:
1150
+ output.append(
1151
+ f" - {_escape_markdown(group['label'])} — {group['children']} children: "
1152
+ + ", ".join(_escape_markdown(label) for label in group["child_labels"])
1153
+ )
1154
+
1155
+ output.extend(
1156
+ [
1157
+ "",
1158
+ "### Nodes",
1159
+ "",
1160
+ "| id | label | shape | depth | parent | deg | fields |",
1161
+ "|---|---|---|---|---|---|---|",
1162
+ ]
1163
+ )
1164
+ for node in diagram.nodes[:max_rows]:
1165
+ output.append(
1166
+ f"| {_escape_table(node.id)} | {_escape_table(node.label)} | {node.shape} | "
1167
+ f"{node.depth} | {node.parent or '-'} | {node.in_degree}/{node.out_degree} | "
1168
+ f"{_escape_table('; '.join(node.fields)) or '-'} |"
1169
+ )
1170
+ if len(diagram.nodes) > max_rows:
1171
+ output.append(
1172
+ f"| … | +{len(diagram.nodes) - max_rows} more (use --json) | | | | | |"
1173
+ )
1174
+
1175
+ output.extend(
1176
+ [
1177
+ "",
1178
+ "### Edges",
1179
+ "",
1180
+ "| source | target | label | style |",
1181
+ "|---|---|---|---|",
1182
+ ]
1183
+ )
1184
+ names = {node.id: node.label.split("\n")[0] for node in diagram.nodes}
1185
+ for edge in diagram.edges[:max_rows]:
1186
+ marks = [edge.style, edge.arrowhead]
1187
+ if edge.bidirectional:
1188
+ marks.append("bidir")
1189
+ if edge.undirected:
1190
+ marks.append("undirected")
1191
+ output.append(
1192
+ f"| {_escape_table(names.get(edge.source, edge.source))} | "
1193
+ f"{_escape_table(names.get(edge.target, edge.target))} | "
1194
+ f"{_escape_table(edge.label) or '-'} | {' '.join(marks)} |"
1195
+ )
1196
+ if len(diagram.edges) > max_rows:
1197
+ output.append(
1198
+ f"| … | +{len(diagram.edges) - max_rows} more (use --json) | | |"
1199
+ )
1200
+ output.append("")
1201
+ return "\n".join(output)
1202
+
1203
+
1204
+ def to_json(path: Path, diagrams: list[Diagram], selected: list[Diagram]) -> str:
1205
+ return json.dumps(
1206
+ {
1207
+ "source": str(path),
1208
+ "diagrams_total": len(diagrams),
1209
+ "diagrams": [
1210
+ {
1211
+ "index": diagram.index,
1212
+ "kind": diagram.kind,
1213
+ "source_line": diagram.source_line,
1214
+ "direction": diagram.direction,
1215
+ "analysis": analyze(diagram),
1216
+ "discarded": diagram.discarded,
1217
+ "fragments": diagram.fragments,
1218
+ "notes": diagram.notes,
1219
+ "nodes": [asdict(node) for node in diagram.nodes],
1220
+ "edges": [asdict(edge) for edge in diagram.edges],
1221
+ }
1222
+ for diagram in selected
1223
+ ],
1224
+ },
1225
+ indent=2,
1226
+ ensure_ascii=False,
1227
+ )
1228
+
1229
+
1230
+ def select_diagrams(diagrams: list[Diagram], selector: str | None) -> list[Diagram]:
1231
+ if selector is None:
1232
+ return diagrams[:1]
1233
+ if selector == "all":
1234
+ return diagrams
1235
+ if selector.isdigit():
1236
+ index = int(selector)
1237
+ selected = [diagram for diagram in diagrams if diagram.index == index]
1238
+ if not selected:
1239
+ _fail(f"no diagram with index {index} (have 0..{len(diagrams) - 1})")
1240
+ return selected
1241
+ _fail("--diagram must be an index or 'all'")
1242
+
1243
+
1244
+ def main(argv: list[str] | None = None) -> int:
1245
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
1246
+ parser.add_argument("file", help=".mmd, .mermaid, or Markdown with mermaid fences")
1247
+ parser.add_argument(
1248
+ "--diagram", help="diagram index or 'all' (default: first diagram)"
1249
+ )
1250
+ parser.add_argument("--json", action="store_true", help="emit the full IR as JSON")
1251
+ parser.add_argument(
1252
+ "--max-rows",
1253
+ type=int,
1254
+ default=40,
1255
+ help="rows per table in the Markdown digest (default 40)",
1256
+ )
1257
+ parser.add_argument("--out", help="write to this path instead of stdout")
1258
+ args = parser.parse_args(argv)
1259
+ if args.max_rows < 1:
1260
+ _fail("--max-rows must be at least 1")
1261
+
1262
+ path = Path(args.file)
1263
+ if not path.is_file():
1264
+ _fail(f"{path}: no such file")
1265
+ blocks = load_blocks(path)
1266
+ diagrams = [parse_block(block) for block in blocks]
1267
+ selected = select_diagrams(diagrams, args.diagram)
1268
+ output = (
1269
+ to_json(path, diagrams, selected)
1270
+ if args.json
1271
+ else digest(path, diagrams, selected, args.max_rows)
1272
+ )
1273
+ if args.out:
1274
+ try:
1275
+ Path(args.out).write_text(output, encoding="utf-8")
1276
+ except OSError as error:
1277
+ _fail(f"cannot write {args.out}: {error}")
1278
+ print(f"wrote {args.out} ({len(output)} bytes)")
1279
+ else:
1280
+ sys.stdout.write(output if output.endswith("\n") else output + "\n")
1281
+ return 0
1282
+
1283
+
1284
+ if __name__ == "__main__":
1285
+ raise SystemExit(main())