archi-cli 0.1.0__py3-none-any.whl

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.
archi_tool/render.py ADDED
@@ -0,0 +1,207 @@
1
+ """Render views from a .archimate model to Mermaid markdown.
2
+
3
+ Mermaid does its own auto-layout, so this renders the *content* of a view
4
+ (elements, nesting, drawn relations), not the pixel-exact Archi layout.
5
+ Nested diagram objects become subgraphs; connection line styles follow the
6
+ relation type. Output files carry a marker comment so stale files can be
7
+ cleaned up safely.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from .model import FOLDER_BY_ELEMENT_TYPE, xsi_type
15
+
16
+ MARKER = "<!-- Gegenereerd door `archi render` — niet handmatig bewerken -->"
17
+
18
+
19
+ def display_model_path(model) -> str:
20
+ """The model path as it should appear in generated output: relative to the
21
+ working directory so renders stay byte-identical across machines, even
22
+ when discovery resolved an absolute path via archi.toml."""
23
+ path = Path(model.path)
24
+ if not path.is_absolute():
25
+ # already relative (the common case): keep it as written
26
+ return path.as_posix()
27
+ try:
28
+ return path.relative_to(Path.cwd()).as_posix()
29
+ except ValueError:
30
+ # outside the working directory: the bare name keeps it deterministic
31
+ return path.name
32
+
33
+ # (fill, stroke, text) per ArchiMate layer — same palette as the ADO
34
+ # diagram conventions (Strategy amber, Motivation purple, Business lemon)
35
+ LAYER_PALETTE = {
36
+ "strategy": ("#FAC75A", "#D4882A", "#633806"),
37
+ "business": ("#FFF580", "#D4B830", "#5C4A00"),
38
+ "application": ("#B4E2FA", "#4A9CC9", "#0D3D57"),
39
+ "technology": ("#C9E7B7", "#7BAF5E", "#2E4A1E"),
40
+ "motivation": ("#CECBF6", "#7F77DD", "#26215C"),
41
+ "implementation_migration": ("#FBD5B5", "#D18A47", "#5C3305"),
42
+ "other": ("#D3D1C7", "#888780", "#444441"),
43
+ }
44
+ LAYER_STYLES = {
45
+ layer: f"fill:{fill},stroke:{stroke},color:{text}"
46
+ for layer, (fill, stroke, text) in LAYER_PALETTE.items()
47
+ }
48
+
49
+ # ArchiMate-ish approximations: circle for containment (no diamond in
50
+ # Mermaid), dotted for the dashed ArchiMate lines (influence, realization).
51
+ CONTAINMENT_TYPES = {"AggregationRelationship", "CompositionRelationship"}
52
+ DOTTED_TYPES = {"InfluenceRelationship", "RealizationRelationship"}
53
+
54
+ EDGE_LEGEND = ("pijlstijlen: `--o` bevat (aggregatie/compositie), "
55
+ "`-.->` gestippeld (beïnvloedt/realiseert), `-->` overig")
56
+
57
+
58
+ def slugify(name: str) -> str:
59
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
60
+ return slug or "view"
61
+
62
+
63
+ def is_descendant(node, ancestor) -> bool:
64
+ """True when ancestor contains node (via the lxml parent chain)."""
65
+ parent = node.getparent()
66
+ while parent is not None:
67
+ if parent is ancestor:
68
+ return True
69
+ parent = parent.getparent()
70
+ return False
71
+
72
+
73
+ def escape_label(name: str) -> str:
74
+ return (name or "(naamloos)").replace('"', "#quot;")
75
+
76
+
77
+ def edge_syntax(rel) -> str:
78
+ rel_type = xsi_type(rel) if rel is not None else ""
79
+ label = rel.get("name") if rel is not None else None
80
+ if rel_type in CONTAINMENT_TYPES:
81
+ return "--o" # containment label (bevat) would only add noise
82
+ if rel_type in DOTTED_TYPES:
83
+ return f'-.->|"{escape_label(label)}"|' if label else "-.->"
84
+ return f'-->|"{escape_label(label)}"|' if label else "-->"
85
+
86
+
87
+ def clean_acc_text(text: str) -> str:
88
+ """Single-line text for accTitle/accDescr (no newlines or braces)."""
89
+ return " ".join(text.replace("{", "(").replace("}", ")").split())
90
+
91
+
92
+ def render_view(model, diagram) -> str:
93
+ index = model.id_index()
94
+ lines = ["flowchart TD"]
95
+ # accessible name and description, like regelrecht does for its docs
96
+ lines.append(f" accTitle: {clean_acc_text(diagram.get('name') or 'View')}")
97
+ documentation = model.documentation(diagram)
98
+ if documentation:
99
+ lines.append(f" accDescr: {clean_acc_text(documentation)}")
100
+ mermaid_id_by_object = {}
101
+ layer_members = {}
102
+ counter = 0
103
+
104
+ def register(obj, element):
105
+ nonlocal counter
106
+ counter += 1
107
+ mermaid_id = f"n{counter}"
108
+ mermaid_id_by_object[obj.get("id")] = mermaid_id
109
+ layer = FOLDER_BY_ELEMENT_TYPE.get(xsi_type(element))
110
+ if layer:
111
+ layer_members.setdefault(layer, []).append(mermaid_id)
112
+ return mermaid_id
113
+
114
+ def walk(objects, depth):
115
+ indent = " " * depth
116
+ for obj in objects:
117
+ element = index.get(obj.get("archimateElement") or "")
118
+ if element is None:
119
+ continue # notes/groups without a model element
120
+ mermaid_id = register(obj, element)
121
+ label = escape_label(element.get("name"))
122
+ children = obj.findall("child")
123
+ if children:
124
+ lines.append(f'{indent}subgraph {mermaid_id}["{label}"]')
125
+ walk(children, depth + 1)
126
+ lines.append(f"{indent}end")
127
+ else:
128
+ lines.append(f'{indent}{mermaid_id}["{label}"]')
129
+
130
+ walk(diagram.findall("child"), 1)
131
+
132
+ for conn in diagram.iter("sourceConnection"):
133
+ source_id = mermaid_id_by_object.get(conn.get("source"))
134
+ target_id = mermaid_id_by_object.get(conn.get("target"))
135
+ if not source_id or not target_id:
136
+ continue
137
+ source_obj = index.get(conn.get("source"))
138
+ target_obj = index.get(conn.get("target"))
139
+ rel = index.get(conn.get("archimateRelationship") or "")
140
+ # nesting already expresses containment; skip the redundant arrow
141
+ if (rel is not None and xsi_type(rel) in CONTAINMENT_TYPES
142
+ and is_descendant(target_obj, source_obj)):
143
+ continue
144
+ lines.append(f" {source_id} {edge_syntax(rel)} {target_id}")
145
+
146
+ for layer, members in sorted(layer_members.items()):
147
+ lines.append(f" classDef {layer} {LAYER_STYLES[layer]}")
148
+ lines.append(f" class {','.join(members)} {layer}")
149
+
150
+ parts = [MARKER, "", f"# {diagram.get('name') or '(naamloze view)'}", ""]
151
+ documentation = model.documentation(diagram)
152
+ if documentation:
153
+ parts += [documentation, ""]
154
+ parts += ["```mermaid", *lines, "```", "",
155
+ f"*Gegenereerd uit `{display_model_path(model)}` — "
156
+ f"{EDGE_LEGEND}.*", ""]
157
+ return "\n".join(parts)
158
+
159
+
160
+ def render_index(model, entries) -> str:
161
+ rows = [MARKER, "", "# Views", "",
162
+ f"Gerenderde views uit `{display_model_path(model)}`. "
163
+ "Deze bestanden worden gegenereerd door `archi render`; "
164
+ "bewerk ze niet handmatig.", ""]
165
+ for name, filename in entries:
166
+ rows.append(f"- [{name}]({filename})")
167
+ rows.append("")
168
+ return "\n".join(rows)
169
+
170
+
171
+ def write_if_changed(path: Path, content: str) -> bool:
172
+ if path.exists() and path.read_text(encoding="utf-8") == content:
173
+ return False
174
+ # newline="\n" keeps LF on every platform (no CRLF churn on Windows)
175
+ path.write_text(content, encoding="utf-8", newline="\n")
176
+ return True
177
+
178
+
179
+ def render_all(model, out_dir) -> tuple[list, list]:
180
+ """Render every view; returns (written, removed) path lists."""
181
+ out = Path(out_dir)
182
+ out.mkdir(parents=True, exist_ok=True)
183
+ written, produced, entries = [], set(), []
184
+
185
+ for diagram in model.diagrams():
186
+ name = diagram.get("name") or diagram.get("id")
187
+ filename = slugify(name) + ".md"
188
+ path = out / filename
189
+ if write_if_changed(path, render_view(model, diagram)):
190
+ written.append(path)
191
+ produced.add(path.name)
192
+ entries.append((name, filename))
193
+
194
+ index_path = out / "README.md"
195
+ if write_if_changed(index_path, render_index(model, entries)):
196
+ written.append(index_path)
197
+ produced.add(index_path.name)
198
+
199
+ removed = []
200
+ for stale in out.glob("*.md"):
201
+ if stale.name in produced:
202
+ continue
203
+ first_line = stale.read_text(encoding="utf-8").split("\n", 1)[0]
204
+ if first_line.strip() == MARKER:
205
+ stale.unlink()
206
+ removed.append(stale)
207
+ return written, removed