@taloswork/ose-shared 0.1.8 → 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.
- package/package.json +3 -3
- package/skills/cozy-static-svg/SKILL.md +76 -0
- package/skills/cozy-static-svg/references/approved-preview.png +0 -0
- package/skills/cozy-static-svg/references/cross-platform.md +73 -0
- package/skills/cozy-static-svg/references/moon_keeps_watch.svg +229 -0
- package/skills/cozy-static-svg/references/style-anchor.md +271 -0
- package/skills/cozy-static-svg/scripts/artkit.py +321 -0
- package/skills/cozy-static-svg/scripts/to_png.py +333 -0
- package/skills/diagram-design/SKILL.md +568 -0
- package/skills/diagram-design/assets/example-architecture-dark.html +182 -0
- package/skills/diagram-design/assets/example-architecture-full.html +194 -0
- package/skills/diagram-design/assets/example-architecture.html +183 -0
- package/skills/diagram-design/assets/example-bar-dark.html +129 -0
- package/skills/diagram-design/assets/example-bar-full.html +105 -0
- package/skills/diagram-design/assets/example-bar.html +129 -0
- package/skills/diagram-design/assets/example-data-flow-dark.html +143 -0
- package/skills/diagram-design/assets/example-data-flow-full.html +185 -0
- package/skills/diagram-design/assets/example-data-flow.html +143 -0
- package/skills/diagram-design/assets/example-datalake-dark.html +243 -0
- package/skills/diagram-design/assets/example-datalake-full.html +248 -0
- package/skills/diagram-design/assets/example-datalake.html +269 -0
- package/skills/diagram-design/assets/example-dp-integration-dark.html +80 -0
- package/skills/diagram-design/assets/example-dp-integration-full.html +90 -0
- package/skills/diagram-design/assets/example-dp-integration.html +80 -0
- package/skills/diagram-design/assets/example-dp-security-matrix-dark.html +31 -0
- package/skills/diagram-design/assets/example-dp-security-matrix-full.html +32 -0
- package/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
- package/skills/diagram-design/assets/example-er-dark.html +202 -0
- package/skills/diagram-design/assets/example-er-full.html +205 -0
- package/skills/diagram-design/assets/example-er.html +202 -0
- package/skills/diagram-design/assets/example-flowchart-dark.html +156 -0
- package/skills/diagram-design/assets/example-flowchart-full.html +159 -0
- package/skills/diagram-design/assets/example-flowchart.html +156 -0
- package/skills/diagram-design/assets/example-gantt-dark.html +137 -0
- package/skills/diagram-design/assets/example-gantt-full.html +127 -0
- package/skills/diagram-design/assets/example-gantt.html +137 -0
- package/skills/diagram-design/assets/example-high-level-dark.html +251 -0
- package/skills/diagram-design/assets/example-high-level-full.html +247 -0
- package/skills/diagram-design/assets/example-high-level-vertical-dark.html +274 -0
- package/skills/diagram-design/assets/example-high-level-vertical-full.html +252 -0
- package/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
- package/skills/diagram-design/assets/example-high-level.html +301 -0
- package/skills/diagram-design/assets/example-import-drawio.html +185 -0
- package/skills/diagram-design/assets/example-import-mermaid.html +70 -0
- package/skills/diagram-design/assets/example-it-state-dark.html +90 -0
- package/skills/diagram-design/assets/example-it-state-full.html +69 -0
- package/skills/diagram-design/assets/example-it-state.html +128 -0
- package/skills/diagram-design/assets/example-layers-dark.html +123 -0
- package/skills/diagram-design/assets/example-layers-full.html +126 -0
- package/skills/diagram-design/assets/example-layers.html +123 -0
- package/skills/diagram-design/assets/example-line-dark.html +111 -0
- package/skills/diagram-design/assets/example-line-full.html +105 -0
- package/skills/diagram-design/assets/example-line.html +111 -0
- package/skills/diagram-design/assets/example-loop-dark.html +100 -0
- package/skills/diagram-design/assets/example-loop-full.html +137 -0
- package/skills/diagram-design/assets/example-loop-terminal.html +347 -0
- package/skills/diagram-design/assets/example-loop.html +100 -0
- package/skills/diagram-design/assets/example-medallion-dark.html +159 -0
- package/skills/diagram-design/assets/example-medallion-full.html +201 -0
- package/skills/diagram-design/assets/example-medallion.html +159 -0
- package/skills/diagram-design/assets/example-nested-dark.html +132 -0
- package/skills/diagram-design/assets/example-nested-full.html +135 -0
- package/skills/diagram-design/assets/example-nested.html +136 -0
- package/skills/diagram-design/assets/example-org-chart-dark.html +45 -0
- package/skills/diagram-design/assets/example-org-chart-full.html +45 -0
- package/skills/diagram-design/assets/example-org-chart.html +45 -0
- package/skills/diagram-design/assets/example-paved-road-animated.html +546 -0
- package/skills/diagram-design/assets/example-policy-trace-animated.html +490 -0
- package/skills/diagram-design/assets/example-process-dark.html +64 -0
- package/skills/diagram-design/assets/example-process-full.html +74 -0
- package/skills/diagram-design/assets/example-process.html +64 -0
- package/skills/diagram-design/assets/example-pyramid-dark.html +119 -0
- package/skills/diagram-design/assets/example-pyramid-full.html +122 -0
- package/skills/diagram-design/assets/example-pyramid.html +119 -0
- package/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
- package/skills/diagram-design/assets/example-quadrant-dark.html +135 -0
- package/skills/diagram-design/assets/example-quadrant-full.html +138 -0
- package/skills/diagram-design/assets/example-quadrant.html +135 -0
- package/skills/diagram-design/assets/example-queue-animated.html +577 -0
- package/skills/diagram-design/assets/example-radar-dark.html +139 -0
- package/skills/diagram-design/assets/example-radar-full.html +135 -0
- package/skills/diagram-design/assets/example-radar.html +139 -0
- package/skills/diagram-design/assets/example-scatter-dark.html +141 -0
- package/skills/diagram-design/assets/example-scatter-full.html +116 -0
- package/skills/diagram-design/assets/example-scatter.html +141 -0
- package/skills/diagram-design/assets/example-sequence-dark.html +222 -0
- package/skills/diagram-design/assets/example-sequence-full.html +388 -0
- package/skills/diagram-design/assets/example-sequence-oauth-dark.html +188 -0
- package/skills/diagram-design/assets/example-sequence-oauth-full.html +350 -0
- package/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
- package/skills/diagram-design/assets/example-sequence.html +222 -0
- package/skills/diagram-design/assets/example-state-dark.html +147 -0
- package/skills/diagram-design/assets/example-state-full.html +150 -0
- package/skills/diagram-design/assets/example-state.html +147 -0
- package/skills/diagram-design/assets/example-swimlane-dark.html +172 -0
- package/skills/diagram-design/assets/example-swimlane-full.html +175 -0
- package/skills/diagram-design/assets/example-swimlane.html +172 -0
- package/skills/diagram-design/assets/example-timeline-dark.html +135 -0
- package/skills/diagram-design/assets/example-timeline-full.html +138 -0
- package/skills/diagram-design/assets/example-timeline.html +135 -0
- package/skills/diagram-design/assets/example-tree-dark.html +173 -0
- package/skills/diagram-design/assets/example-tree-full.html +176 -0
- package/skills/diagram-design/assets/example-tree.html +173 -0
- package/skills/diagram-design/assets/example-venn-dark.html +132 -0
- package/skills/diagram-design/assets/example-venn-full.html +135 -0
- package/skills/diagram-design/assets/example-venn.html +110 -0
- package/skills/diagram-design/assets/icons.html +230 -0
- package/skills/diagram-design/assets/index.html +369 -0
- package/skills/diagram-design/assets/template-dark.html +85 -0
- package/skills/diagram-design/assets/template-full.html +413 -0
- package/skills/diagram-design/assets/template-motion.html +434 -0
- package/skills/diagram-design/assets/template-terminal.html +173 -0
- package/skills/diagram-design/assets/template.html +86 -0
- package/skills/diagram-design/references/animation.md +141 -0
- package/skills/diagram-design/references/export.md +136 -0
- package/skills/diagram-design/references/import-drawio.md +171 -0
- package/skills/diagram-design/references/import-mermaid.md +126 -0
- package/skills/diagram-design/references/onboarding.md +297 -0
- package/skills/diagram-design/references/output-spec.md +181 -0
- package/skills/diagram-design/references/primitive-annotation.md +36 -0
- package/skills/diagram-design/references/primitive-icons.md +827 -0
- package/skills/diagram-design/references/primitive-sketchy.md +43 -0
- package/skills/diagram-design/references/primitive-terminal.md +76 -0
- package/skills/diagram-design/references/semantic-patterns.md +122 -0
- package/skills/diagram-design/references/style-guide.md +139 -0
- package/skills/diagram-design/references/type-architecture.md +78 -0
- package/skills/diagram-design/references/type-bar.md +48 -0
- package/skills/diagram-design/references/type-data-flow.md +374 -0
- package/skills/diagram-design/references/type-dp-integration.md +410 -0
- package/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
- package/skills/diagram-design/references/type-er.md +23 -0
- package/skills/diagram-design/references/type-flowchart.md +23 -0
- package/skills/diagram-design/references/type-gantt.md +45 -0
- package/skills/diagram-design/references/type-high-level.md +458 -0
- package/skills/diagram-design/references/type-it-state.md +470 -0
- package/skills/diagram-design/references/type-layers.md +26 -0
- package/skills/diagram-design/references/type-line.md +44 -0
- package/skills/diagram-design/references/type-loop.md +223 -0
- package/skills/diagram-design/references/type-medallion.md +356 -0
- package/skills/diagram-design/references/type-nested.md +22 -0
- package/skills/diagram-design/references/type-org-chart.md +44 -0
- package/skills/diagram-design/references/type-process.md +495 -0
- package/skills/diagram-design/references/type-pyramid.md +33 -0
- package/skills/diagram-design/references/type-quadrant.md +81 -0
- package/skills/diagram-design/references/type-radar.md +80 -0
- package/skills/diagram-design/references/type-scatter.md +39 -0
- package/skills/diagram-design/references/type-sequence.md +130 -0
- package/skills/diagram-design/references/type-state.md +21 -0
- package/skills/diagram-design/references/type-swimlane.md +20 -0
- package/skills/diagram-design/references/type-timeline.md +20 -0
- package/skills/diagram-design/references/type-tree.md +24 -0
- package/skills/diagram-design/references/type-venn.md +26 -0
- package/skills/diagram-design/scripts/drawio_extract.py +856 -0
- package/skills/diagram-design/scripts/mermaid_extract.py +1285 -0
- package/skills/diagram-design/scripts/self_check.py +389 -0
|
@@ -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())
|