@caddis/cli 0.4.19 → 0.4.21
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/_registry.md +4 -2
- package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
- package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
- package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
- package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +419 -0
- package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
- package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -5,15 +5,16 @@ Convert Markdown Architecture Documents (HLD / LLD) to Confluence Storage Format
|
|
|
5
5
|
Key Principles:
|
|
6
6
|
1. Strips AI slop, removes frontmatter/metadata notices, and enforces ASD-STE100.
|
|
7
7
|
2. Injects Confluence Table of Contents (<ac:structured-macro ac:name="toc"/>) and Version History.
|
|
8
|
-
3. Strips Executive Metadata / Technical Metadata blocks from Confluence HTML views.
|
|
9
|
-
4. Generates
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
3. Strips Executive Metadata / Technical Metadata blocks from Confluence HTML views (kept in markdown/LLD).
|
|
9
|
+
4. Generates Draw.io diagrams (<ac:structured-macro ac:name="drawio"/>) with strict 100% orthogonal connectors.
|
|
10
|
+
- Simultaneously writes out standalone .drawio files to a `diagrams/` directory.
|
|
11
|
+
- Attaching these .drawio files to the Confluence page ensures seamless rendering with zero attachment access errors.
|
|
12
|
+
5. Also supports PlantUML inline macros (<ac:structured-macro ac:name="plantuml"/>) with 'skinparam linetype ortho'.
|
|
12
13
|
6. Enforces responsive, wrapped Confluence tables (<table class="wrapped"><colgroup>...).
|
|
13
14
|
7. Validates that output is 100% well-formed XML.
|
|
14
15
|
|
|
15
16
|
Usage:
|
|
16
|
-
python scripts/convert_doc_to_confluence.py <input.md> --output <output.html> [--diagram-type plantuml
|
|
17
|
+
python scripts/convert_doc_to_confluence.py <input.md> --output <output.html> [--diagram-type drawio|plantuml]
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
20
|
import argparse
|
|
@@ -40,7 +41,7 @@ def sanitize_ste_and_anti_slop(content: str) -> str:
|
|
|
40
41
|
# 3. Unicode bullet characters to ASCII hyphen
|
|
41
42
|
content = content.replace("•", "-")
|
|
42
43
|
|
|
43
|
-
# 4. Filter AI
|
|
44
|
+
# 4. Filter AI buzzwords and purple prose
|
|
44
45
|
buzzwords = [
|
|
45
46
|
(r'\bdelve into\b', 'inspect'),
|
|
46
47
|
(r'\bdelve\b', 'investigate'),
|
|
@@ -81,14 +82,13 @@ def strip_ai_traces_and_metadata(content: str, is_hld: bool = True) -> str:
|
|
|
81
82
|
|
|
82
83
|
# 2. Strip Executive Metadata / Technical Metadata if HLD
|
|
83
84
|
if is_hld:
|
|
84
|
-
# Match ## Executive Metadata ... up to next header or hr
|
|
85
85
|
content = re.sub(r'##\s*(?:Executive\s+Metadata|Technical\s+Metadata).*?(?=(^##|\A|\Z|<hr/>|---))', '', content, flags=re.DOTALL | re.MULTILINE)
|
|
86
86
|
|
|
87
87
|
# 3. Strip any lines referencing AI models, caddis, antigravity, claude
|
|
88
88
|
banned_keywords = [
|
|
89
89
|
"caddis", "antigravity", "creating model", "creation-agent",
|
|
90
90
|
"last author", "last updated", "claude code", "codex", "gemini-2.5-pro",
|
|
91
|
-
"gemini-3.5-flash", "simplified technical english", "asd-ste100"
|
|
91
|
+
"gemini-3.5-flash", "gemini-3.8-flash", "simplified technical english", "asd-ste100"
|
|
92
92
|
]
|
|
93
93
|
lines = []
|
|
94
94
|
for line in content.splitlines():
|
|
@@ -101,76 +101,445 @@ def strip_ai_traces_and_metadata(content: str, is_hld: bool = True) -> str:
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
def format_inline_markdown(text: str) -> str:
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
text = text.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
|
|
107
|
-
text = text.replace("•", "-")
|
|
104
|
+
"""Convert Markdown inline formatting (bold, code, links) to safe Confluence XHTML."""
|
|
105
|
+
code_blocks: List[str] = []
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
def replace_code(m):
|
|
108
|
+
code_blocks.append(m.group(1))
|
|
109
|
+
return f"__INLINE_CODE_{len(code_blocks)-1}__"
|
|
112
110
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def save_code(m):
|
|
116
|
-
code_spans.append(f"<code>{m.group(1)}</code>")
|
|
117
|
-
return f"__INLINE_CODE_SPAN_{len(code_spans)-1}__"
|
|
118
|
-
|
|
119
|
-
text = re.sub(r"`([^`]+)`", save_code, text)
|
|
111
|
+
text = re.sub(r'`([^`]+)`', replace_code, text)
|
|
112
|
+
text = html.escape(text)
|
|
120
113
|
|
|
121
114
|
# Links: [text](url) -> <a href="url">text</a>
|
|
122
115
|
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
|
123
116
|
|
|
124
|
-
# Bold **
|
|
125
|
-
text = re.sub(r
|
|
117
|
+
# Bold: **text** -> <strong>text</strong>
|
|
118
|
+
text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
|
|
126
119
|
|
|
127
|
-
# Italic *
|
|
128
|
-
text = re.sub(r
|
|
120
|
+
# Italic: *text* -> <em>text</em>
|
|
121
|
+
text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<em>\1</em>', text)
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
# Restore code blocks
|
|
124
|
+
for idx, c in enumerate(code_blocks):
|
|
125
|
+
c_esc = html.escape(c)
|
|
126
|
+
text = text.replace(f"__INLINE_CODE_{idx}__", f"<code>{c_esc}</code>")
|
|
132
127
|
|
|
133
128
|
return text
|
|
134
129
|
|
|
135
130
|
|
|
136
|
-
def convert_table(
|
|
137
|
-
"""Convert Markdown table to
|
|
138
|
-
|
|
131
|
+
def convert_table(lines: List[str]) -> str:
|
|
132
|
+
"""Convert GitHub Markdown table to responsive Confluence wrapped table."""
|
|
133
|
+
rows = []
|
|
134
|
+
for line in lines:
|
|
135
|
+
stripped = line.strip()
|
|
136
|
+
if not stripped.startswith("|"):
|
|
137
|
+
continue
|
|
138
|
+
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
|
139
|
+
if all(re.match(r"^:?-+:?$", c) for c in cells if c):
|
|
140
|
+
continue
|
|
141
|
+
rows.append(cells)
|
|
142
|
+
|
|
143
|
+
if not rows:
|
|
139
144
|
return ""
|
|
140
145
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
out
|
|
151
|
-
for
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
out.append(
|
|
156
|
-
out.append(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
146
|
+
num_cols = max(len(r) for r in rows)
|
|
147
|
+
out = ['<table class="wrapped">']
|
|
148
|
+
out.append(" <colgroup>")
|
|
149
|
+
for _ in range(num_cols):
|
|
150
|
+
out.append(" <col/>")
|
|
151
|
+
out.append(" </colgroup>")
|
|
152
|
+
|
|
153
|
+
# Header
|
|
154
|
+
out.append(" <thead>")
|
|
155
|
+
out.append(" <tr>")
|
|
156
|
+
for cell in rows[0]:
|
|
157
|
+
out.append(f" <th><p>{format_inline_markdown(cell)}</p></th>")
|
|
158
|
+
for _ in range(num_cols - len(rows[0])):
|
|
159
|
+
out.append(" <th><p></p></th>")
|
|
160
|
+
out.append(" </tr>")
|
|
161
|
+
out.append(" </thead>")
|
|
162
|
+
|
|
163
|
+
# Body
|
|
164
|
+
out.append(" <tbody>")
|
|
165
|
+
for row in rows[1:]:
|
|
166
|
+
out.append(" <tr>")
|
|
167
|
+
for cell in row:
|
|
168
|
+
out.append(f" <td><p>{format_inline_markdown(cell)}</p></td>")
|
|
169
|
+
for _ in range(num_cols - len(row)):
|
|
170
|
+
out.append(" <td><p></p></td>")
|
|
171
|
+
out.append(" </tr>")
|
|
172
|
+
out.append(" </tbody>")
|
|
173
|
+
out.append("</table>")
|
|
174
|
+
|
|
165
175
|
return "\n".join(out)
|
|
166
176
|
|
|
167
177
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
def _get_drawio_style(node_id: str, label: str, cls: str) -> Dict[str, str]:
|
|
179
|
+
"""Return styling dictionary for Draw.io node based on Material palette."""
|
|
180
|
+
id_low = node_id.lower()
|
|
181
|
+
lbl_low = label.lower()
|
|
182
|
+
cls_low = cls.lower()
|
|
183
|
+
|
|
184
|
+
if any(k in cls_low for k in ["src", "med"]) or any(k in id_low for k in ["jsc", "src", "raw"]):
|
|
185
|
+
return {"fill": "#E8EAF6", "stroke": "#3949AB", "color": "#1A237E"}
|
|
186
|
+
if any(k in cls_low for k in ["stage", "batch", "engine"]) or any(k in id_low for k in ["val", "py", "orch", "task", "job", "refresher", "worker"]):
|
|
187
|
+
return {"fill": "#E1F5FE", "stroke": "#0277BD", "color": "#01579B"}
|
|
188
|
+
if any(k in cls_low for k in ["db", "mart"]) or any(k in id_low for k in ["db", "mart", "run", "exc", "sum"]):
|
|
189
|
+
return {"fill": "#E8F5E9", "stroke": "#2E7D32", "color": "#1B5E20"}
|
|
190
|
+
if any(k in cls_low for k in ["ui", "app"]) or any(k in id_low for k in ["ui", "api", "dash", "react"]):
|
|
191
|
+
return {"fill": "#FFF3E0", "stroke": "#E65100", "color": "#BF360C"}
|
|
192
|
+
if any(k in cls_low for k in ["ext"]) or any(k in id_low for k in ["feed", "gw", "bss", "tap", "apigee"]):
|
|
193
|
+
return {"fill": "#ECEFF1", "stroke": "#455A64", "color": "#263238"}
|
|
194
|
+
|
|
195
|
+
return {"fill": "#F1F5F9", "stroke": "#475569", "color": "#0F172A"}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def convert_mermaid_flowchart_to_drawio(code: str, diagram_name: str) -> Tuple[str, int]:
|
|
199
|
+
"""Parse Mermaid flowchart / graph into Draw.io <mxGraphModel> XML with 100% orthogonal edges."""
|
|
200
|
+
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
201
|
+
|
|
202
|
+
subgraphs = []
|
|
203
|
+
sg_stack = []
|
|
204
|
+
top_nodes = {}
|
|
205
|
+
edges = []
|
|
206
|
+
|
|
207
|
+
node_def_re = re.compile(
|
|
208
|
+
r'([A-Za-z0-9_]+)\s*(?:\[\[(.+?)\]\]|\(\((.+?)\)\)|\[\((.+?)\)\]|\[\/(.+?)\/\]|\[\\(.+?)\\\]|\[(.+?)\])'
|
|
209
|
+
)
|
|
210
|
+
subgraph_start_re = re.compile(r'^subgraph\s+([A-Za-z0-9_]+)(?:\s*\["([^"]+)"\]|\s*\[(.+)\])?', re.IGNORECASE)
|
|
211
|
+
|
|
212
|
+
for line in lines:
|
|
213
|
+
if line.startswith("flowchart") or line.startswith("graph") or line.startswith("classDef") or line.startswith("style "):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
sg_match = subgraph_start_re.match(line)
|
|
217
|
+
if sg_match:
|
|
218
|
+
sg_id = sg_match.group(1)
|
|
219
|
+
sg_title = sg_match.group(2) or sg_match.group(3) or sg_id
|
|
220
|
+
new_sg = {
|
|
221
|
+
"id": sg_id,
|
|
222
|
+
"title": sg_title,
|
|
223
|
+
"direction": "TB",
|
|
224
|
+
"nodes": {},
|
|
225
|
+
"edges": [],
|
|
226
|
+
}
|
|
227
|
+
subgraphs.append(new_sg)
|
|
228
|
+
sg_stack.append(new_sg)
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
if line.lower() == "end":
|
|
232
|
+
if sg_stack:
|
|
233
|
+
sg_stack.pop()
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if line.lower().startswith("direction "):
|
|
237
|
+
d = line.split()[1].upper()
|
|
238
|
+
if sg_stack:
|
|
239
|
+
sg_stack[-1]["direction"] = d
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Strip inline styling like :::srcStyle before parsing nodes and edges
|
|
243
|
+
clean_line_no_style = re.sub(r':::[A-Za-z0-9_]+', '', line)
|
|
244
|
+
|
|
245
|
+
# Extract nodes
|
|
246
|
+
for match in node_def_re.finditer(clean_line_no_style):
|
|
247
|
+
n_id = match.group(1)
|
|
248
|
+
n_lbl = next(g for g in match.groups()[1:7] if g is not None)
|
|
249
|
+
if (n_lbl.startswith('"') and n_lbl.endswith('"')) or (n_lbl.startswith("'") and n_lbl.endswith("'")):
|
|
250
|
+
n_lbl = n_lbl[1:-1]
|
|
251
|
+
node_info = {"id": n_id, "label": n_lbl, "class": ""}
|
|
252
|
+
if sg_stack:
|
|
253
|
+
sg_stack[-1]["nodes"][n_id] = node_info
|
|
254
|
+
else:
|
|
255
|
+
top_nodes[n_id] = node_info
|
|
256
|
+
|
|
257
|
+
# Extract edges
|
|
258
|
+
clean_edge_line = node_def_re.sub(r'\1', clean_line_no_style)
|
|
259
|
+
tokens = re.split(r'(\s*(?:<-->|-->|---|->|-\.->|\.-\.|\.\->)\s*(?:\|"[^"]*"\|\s*|\|[^\|]*\|\s*)?)', clean_edge_line)
|
|
260
|
+
if len(tokens) >= 3:
|
|
261
|
+
for i in range(0, len(tokens) - 2, 2):
|
|
262
|
+
src = tokens[i].strip()
|
|
263
|
+
arrow = tokens[i+1].strip()
|
|
264
|
+
dst = tokens[i+2].strip()
|
|
265
|
+
|
|
266
|
+
lbl_match = re.search(r'\|"?([^"\|]*)"?\|', arrow)
|
|
267
|
+
lbl = lbl_match.group(1).strip() if lbl_match else ""
|
|
268
|
+
dashed = "-.-" in arrow or ".-" in arrow
|
|
269
|
+
|
|
270
|
+
if src and dst and re.match(r'^[A-Za-z0-9_]+$', src) and re.match(r'^[A-Za-z0-9_]+$', dst):
|
|
271
|
+
edge_obj = {"src": src, "dst": dst, "label": lbl, "dashed": dashed}
|
|
272
|
+
edges.append(edge_obj)
|
|
273
|
+
|
|
274
|
+
# Compute Layout Coordinates
|
|
275
|
+
node_w = 260
|
|
276
|
+
node_h = 60
|
|
277
|
+
sg_margin_x = 40
|
|
278
|
+
sg_margin_y = 50
|
|
279
|
+
sg_pad_x = 30
|
|
280
|
+
sg_pad_y = 40
|
|
281
|
+
sg_gap = 40
|
|
282
|
+
|
|
283
|
+
sg_coords = {}
|
|
284
|
+
node_coords = {}
|
|
285
|
+
|
|
286
|
+
cur_x = sg_margin_x
|
|
287
|
+
cur_y = sg_margin_y
|
|
288
|
+
max_h = 0
|
|
289
|
+
|
|
290
|
+
for sg in subgraphs:
|
|
291
|
+
nodes_count = len(sg["nodes"])
|
|
292
|
+
sg_direction = sg.get("direction", "TB")
|
|
293
|
+
|
|
294
|
+
if sg_direction == "TB":
|
|
295
|
+
sg_w = node_w + (sg_pad_x * 2)
|
|
296
|
+
sg_h = sg_pad_y + (nodes_count * (node_h + 30)) + 20
|
|
297
|
+
sg_coords[sg["id"]] = {"x": cur_x, "y": cur_y, "w": sg_w, "h": sg_h}
|
|
298
|
+
|
|
299
|
+
n_y = cur_y + sg_pad_y + 10
|
|
300
|
+
for nid, ninfo in sg["nodes"].items():
|
|
301
|
+
node_coords[nid] = {"x": cur_x + sg_pad_x, "y": n_y, "w": node_w, "h": node_h}
|
|
302
|
+
n_y += node_h + 30
|
|
303
|
+
|
|
304
|
+
cur_x += sg_w + sg_gap
|
|
305
|
+
if sg_h > max_h:
|
|
306
|
+
max_h = sg_h
|
|
307
|
+
else:
|
|
308
|
+
sg_w = sg_pad_x + (nodes_count * (node_w + 30)) + 20
|
|
309
|
+
sg_h = node_h + (sg_pad_y * 2) + 20
|
|
310
|
+
sg_coords[sg["id"]] = {"x": cur_x, "y": cur_y, "w": sg_w, "h": sg_h}
|
|
311
|
+
|
|
312
|
+
n_x = cur_x + sg_pad_x
|
|
313
|
+
for nid, ninfo in sg["nodes"].items():
|
|
314
|
+
node_coords[nid] = {"x": n_x, "y": cur_y + sg_pad_y + 10, "w": node_w, "h": node_h}
|
|
315
|
+
n_x += node_w + 30
|
|
316
|
+
|
|
317
|
+
cur_y += sg_h + sg_gap
|
|
318
|
+
if sg_w > max_h:
|
|
319
|
+
max_h = sg_w
|
|
320
|
+
|
|
321
|
+
if top_nodes:
|
|
322
|
+
top_y = cur_y + max_h + sg_gap if subgraphs else sg_margin_y + 40
|
|
323
|
+
top_x = sg_margin_x
|
|
324
|
+
for nid, ninfo in top_nodes.items():
|
|
325
|
+
node_coords[nid] = {"x": top_x, "y": top_y, "w": node_w, "h": node_h}
|
|
326
|
+
top_x += node_w + sg_gap
|
|
327
|
+
if not subgraphs:
|
|
328
|
+
cur_x = top_x
|
|
329
|
+
|
|
330
|
+
canvas_w = max(1200, cur_x + 100)
|
|
331
|
+
canvas_h = max(800, cur_y + max_h + 200)
|
|
332
|
+
|
|
333
|
+
out = []
|
|
334
|
+
out.append(f'<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="{canvas_w}" pageHeight="{canvas_h}" math="0" shadow="0">')
|
|
335
|
+
out.append(' <root>')
|
|
336
|
+
out.append(' <mxCell id="0"/>')
|
|
337
|
+
out.append(' <mxCell id="1" parent="0"/>')
|
|
338
|
+
|
|
339
|
+
# Emit Subgraphs
|
|
340
|
+
for sg in subgraphs:
|
|
341
|
+
c = sg_coords[sg["id"]]
|
|
342
|
+
title_esc = html.escape(sg["title"])
|
|
343
|
+
out.append(f' <mxCell id="{sg["id"]}" value="{title_esc}" style="swimlane;whiteSpace=wrap;html=1;fillColor=#F8FAFC;strokeColor=#546E7A;strokeWidth=1.5;fontStyle=1;fontSize=12;fontColor=#263238;startSize=28;rounded=1;arcSize=4;" vertex="1" parent="1">')
|
|
344
|
+
out.append(f' <mxGeometry x="{c["x"]}" y="{c["y"]}" width="{c["w"]}" height="{c["h"]}" as="geometry"/>')
|
|
345
|
+
out.append(' </mxCell>')
|
|
346
|
+
|
|
347
|
+
# Emit Nodes
|
|
348
|
+
for sg in subgraphs:
|
|
349
|
+
for nid, ninfo in sg["nodes"].items():
|
|
350
|
+
c = node_coords[nid]
|
|
351
|
+
style_info = _get_drawio_style(nid, ninfo["label"], ninfo["class"])
|
|
352
|
+
lbl_clean = ninfo["label"].replace("<br/>", "
").replace("<br>", "
").replace('"', '"')
|
|
353
|
+
lbl_esc = html.escape(lbl_clean)
|
|
354
|
+
style_str = f'rounded=1;whiteSpace=wrap;html=1;fillColor={style_info["fill"]};strokeColor={style_info["stroke"]};fontColor={style_info["color"]};strokeWidth=2;fontSize=11;fontFamily=Segoe UI;align=center;'
|
|
355
|
+
out.append(f' <mxCell id="{nid}" value="{lbl_esc}" style="{style_str}" vertex="1" parent="1">')
|
|
356
|
+
out.append(f' <mxGeometry x="{c["x"]}" y="{c["y"]}" width="{c["w"]}" height="{c["h"]}" as="geometry"/>')
|
|
357
|
+
out.append(' </mxCell>')
|
|
358
|
+
|
|
359
|
+
for nid, ninfo in top_nodes.items():
|
|
360
|
+
c = node_coords[nid]
|
|
361
|
+
style_info = _get_drawio_style(nid, ninfo["label"], ninfo["class"])
|
|
362
|
+
lbl_clean = ninfo["label"].replace("<br/>", "
").replace("<br>", "
").replace('"', '"')
|
|
363
|
+
lbl_esc = html.escape(lbl_clean)
|
|
364
|
+
style_str = f'rounded=1;whiteSpace=wrap;html=1;fillColor={style_info["fill"]};strokeColor={style_info["stroke"]};fontColor={style_info["color"]};strokeWidth=2;fontSize=11;fontFamily=Segoe UI;align=center;'
|
|
365
|
+
out.append(f' <mxCell id="{nid}" value="{lbl_esc}" style="{style_str}" vertex="1" parent="1">')
|
|
366
|
+
out.append(f' <mxGeometry x="{c["x"]}" y="{c["y"]}" width="{c["w"]}" height="{c["h"]}" as="geometry"/>')
|
|
367
|
+
out.append(' </mxCell>')
|
|
368
|
+
|
|
369
|
+
# Emit Edges with 100% Orthogonal Routing
|
|
370
|
+
for idx, e in enumerate(edges):
|
|
371
|
+
eid = f"edge_{idx+1}"
|
|
372
|
+
src = e["src"]
|
|
373
|
+
dst = e["dst"]
|
|
374
|
+
lbl_clean = e["label"].replace("<br/>", "
").replace("<br>", "
").replace('"', '"') if e["label"] else ""
|
|
375
|
+
lbl_esc = html.escape(lbl_clean)
|
|
376
|
+
dashed_str = "dashed=1;" if e["dashed"] else ""
|
|
377
|
+
edge_style = f"edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;fontSize=10;fontColor=#37474F;{dashed_str}"
|
|
378
|
+
out.append(f' <mxCell id="{eid}" value="{lbl_esc}" style="{edge_style}" edge="1" parent="1" source="{src}" target="{dst}">')
|
|
379
|
+
out.append(' <mxGeometry relative="1" as="geometry"/>')
|
|
380
|
+
out.append(' </mxCell>')
|
|
381
|
+
|
|
382
|
+
out.append(' </root>')
|
|
383
|
+
out.append('</mxGraphModel>')
|
|
384
|
+
return "\n".join(out), canvas_w
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def convert_mermaid_sequence_to_drawio(code: str, diagram_name: str) -> Tuple[str, int]:
|
|
388
|
+
"""Parse Mermaid sequenceDiagram into Draw.io <mxGraphModel> XML."""
|
|
389
|
+
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
390
|
+
|
|
391
|
+
participants = []
|
|
392
|
+
messages = []
|
|
393
|
+
notes = []
|
|
394
|
+
|
|
395
|
+
part_re = re.compile(r'^(?:participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?', re.IGNORECASE)
|
|
396
|
+
msg_re = re.compile(r'^([A-Za-z0-9_]+)\s*(->>|-->>|->|-->)\s*([A-Za-z0-9_]+)\s*:\s*(.+)$')
|
|
397
|
+
note_re = re.compile(r'^Note\s+(?:over|left of|right of)\s+([A-Za-z0-9_,\s]+)\s*:\s*(.+)$', re.IGNORECASE)
|
|
398
|
+
|
|
399
|
+
part_order = []
|
|
400
|
+
part_names = {}
|
|
401
|
+
|
|
402
|
+
for l in lines:
|
|
403
|
+
if l.startswith("sequenceDiagram") or l.startswith("autonumber"):
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
p_match = part_re.match(l)
|
|
407
|
+
if p_match:
|
|
408
|
+
pid = p_match.group(1)
|
|
409
|
+
pname = p_match.group(2) or pid
|
|
410
|
+
if pid not in part_order:
|
|
411
|
+
part_order.append(pid)
|
|
412
|
+
part_names[pid] = pname
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
n_match = note_re.match(l)
|
|
416
|
+
if n_match:
|
|
417
|
+
target = n_match.group(1).strip()
|
|
418
|
+
text = n_match.group(2).strip()
|
|
419
|
+
notes.append({"target": target, "text": text, "index": len(messages)})
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
m_match = msg_re.match(l)
|
|
423
|
+
if m_match:
|
|
424
|
+
src = m_match.group(1)
|
|
425
|
+
arrow = m_match.group(2)
|
|
426
|
+
dst = m_match.group(3)
|
|
427
|
+
txt = m_match.group(4)
|
|
428
|
+
|
|
429
|
+
for p in [src, dst]:
|
|
430
|
+
if p not in part_order:
|
|
431
|
+
part_order.append(p)
|
|
432
|
+
part_names[p] = p
|
|
433
|
+
|
|
434
|
+
messages.append({"src": src, "dst": dst, "text": txt, "dashed": "--" in arrow})
|
|
435
|
+
|
|
436
|
+
part_w = 180
|
|
437
|
+
part_h = 45
|
|
438
|
+
col_gap = 40
|
|
439
|
+
start_x = 40
|
|
440
|
+
start_y = 50
|
|
441
|
+
lifeline_h = max(550, (len(messages) + len(notes) + 2) * 55)
|
|
442
|
+
|
|
443
|
+
part_x = {}
|
|
444
|
+
for i, pid in enumerate(part_order):
|
|
445
|
+
part_x[pid] = start_x + (i * (part_w + col_gap))
|
|
446
|
+
|
|
447
|
+
canvas_w = max(1200, start_x + (len(part_order) * (part_w + col_gap)) + 60)
|
|
448
|
+
canvas_h = max(700, start_y + lifeline_h + 100)
|
|
449
|
+
|
|
450
|
+
out = []
|
|
451
|
+
out.append(f'<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="{canvas_w}" pageHeight="{canvas_h}" math="0" shadow="0">')
|
|
452
|
+
out.append(' <root>')
|
|
453
|
+
out.append(' <mxCell id="0"/>')
|
|
454
|
+
out.append(' <mxCell id="1" parent="0"/>')
|
|
455
|
+
|
|
456
|
+
# Emit Lifelines & Headers
|
|
457
|
+
for pid in part_order:
|
|
458
|
+
px = part_x[pid]
|
|
459
|
+
pname = part_names.get(pid, pid).replace("<br/>", "
").replace("<br>", "
").replace('"', '"')
|
|
460
|
+
pname_esc = html.escape(pname)
|
|
461
|
+
is_actor = "analyst" in pid.lower() or "user" in pid.lower()
|
|
462
|
+
fill_col = "#E8EAF6" if is_actor else "#E1F5FE"
|
|
463
|
+
stroke_col = "#3949AB" if is_actor else "#0277BD"
|
|
464
|
+
font_col = "#1A237E" if is_actor else "#01579B"
|
|
465
|
+
|
|
466
|
+
header_id = f"hdr_{pid}"
|
|
467
|
+
out.append(f' <mxCell id="{header_id}" value="{pname_esc}" style="rounded=1;whiteSpace=wrap;html=1;fillColor={fill_col};strokeColor={stroke_col};fontColor={font_col};fontStyle=1;fontSize=11;fontFamily=Segoe UI;align=center;" vertex="1" parent="1">')
|
|
468
|
+
out.append(f' <mxGeometry x="{px}" y="{start_y}" width="{part_w}" height="{part_h}" as="geometry"/>')
|
|
469
|
+
out.append(' </mxCell>')
|
|
470
|
+
|
|
471
|
+
ll_id = f"ll_{pid}"
|
|
472
|
+
mid_x = px + (part_w // 2)
|
|
473
|
+
out.append(f' <mxCell id="{ll_id}" value="" style="edgeStyle=none;html=1;strokeColor=#90A4AE;strokeWidth=1;dashed=1;endArrow=none;startArrow=none;" edge="1" parent="1" source="{header_id}">')
|
|
474
|
+
out.append(f' <mxGeometry relative="1" as="geometry">')
|
|
475
|
+
out.append(f' <mxPoint x="{mid_x}" y="{start_y + lifeline_h}" as="targetPoint"/>')
|
|
476
|
+
out.append(' </mxGeometry>')
|
|
477
|
+
out.append(' </mxCell>')
|
|
478
|
+
|
|
479
|
+
# Emit Messages and Notes
|
|
480
|
+
msg_y = start_y + part_h + 35
|
|
481
|
+
msg_step = 50
|
|
482
|
+
note_map = {n["index"]: n for n in notes}
|
|
483
|
+
|
|
484
|
+
for idx, m in enumerate(messages):
|
|
485
|
+
if idx in note_map:
|
|
486
|
+
n = note_map[idx]
|
|
487
|
+
n_txt = html.escape(n["text"])
|
|
488
|
+
out.append(f' <mxCell id="note_{idx}" value="{n_txt}" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#FFF8E1;strokeColor=#FFA000;fontColor=#5D4037;fontSize=10;fontStyle=2;align=center;" vertex="1" parent="1">')
|
|
489
|
+
out.append(f' <mxGeometry x="{start_x + 20}" y="{msg_y - 15}" width="{canvas_w - start_x - 120}" height="26" as="geometry"/>')
|
|
490
|
+
out.append(' </mxCell>')
|
|
491
|
+
msg_y += 35
|
|
492
|
+
|
|
493
|
+
src_mid = part_x[m["src"]] + (part_w // 2)
|
|
494
|
+
dst_mid = part_x[m["dst"]] + (part_w // 2)
|
|
495
|
+
txt = html.escape(m["text"])
|
|
496
|
+
dash_str = "dashed=1;" if m["dashed"] else ""
|
|
497
|
+
edge_style = f"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#1E88E5;strokeWidth=2;endArrow=block;endFill=1;fontSize=10;fontColor=#263238;{dash_str}"
|
|
498
|
+
|
|
499
|
+
out.append(f' <mxCell id="seq_msg_{idx+1}" value="{idx+1}. {txt}" style="{edge_style}" edge="1" parent="1">')
|
|
500
|
+
out.append(f' <mxGeometry relative="1" as="geometry">')
|
|
501
|
+
out.append(f' <mxPoint x="{src_mid}" y="{msg_y}" as="sourcePoint"/>')
|
|
502
|
+
out.append(f' <mxPoint x="{dst_mid}" y="{msg_y}" as="targetPoint"/>')
|
|
503
|
+
out.append(' </mxGeometry>')
|
|
504
|
+
out.append(' </mxCell>')
|
|
505
|
+
msg_y += msg_step
|
|
506
|
+
|
|
507
|
+
out.append(' </root>')
|
|
508
|
+
out.append('</mxGraphModel>')
|
|
509
|
+
return "\n".join(out), canvas_w
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def render_drawio_macro(drawio_xml: str, diagram_filename: str, display_name: str, width: int = 1200) -> str:
|
|
513
|
+
"""Generate native Confluence Draw.io macro."""
|
|
514
|
+
macro_id = str(uuid.uuid4())
|
|
515
|
+
name_esc = html.escape(diagram_filename)
|
|
516
|
+
disp_esc = html.escape(display_name)
|
|
517
|
+
return (
|
|
518
|
+
f'<p>\n'
|
|
519
|
+
f' <ac:structured-macro ac:macro-id="{macro_id}" ac:name="drawio" ac:schema-version="1">\n'
|
|
520
|
+
f' <ac:parameter ac:name="border">true</ac:parameter>\n'
|
|
521
|
+
f' <ac:parameter ac:name="diagramName">{name_esc}</ac:parameter>\n'
|
|
522
|
+
f' <ac:parameter ac:name="diagramDisplayName">{disp_esc}</ac:parameter>\n'
|
|
523
|
+
f' <ac:parameter ac:name="simpleViewer">false</ac:parameter>\n'
|
|
524
|
+
f' <ac:parameter ac:name="links">auto</ac:parameter>\n'
|
|
525
|
+
f' <ac:parameter ac:name="tbstyle">top</ac:parameter>\n'
|
|
526
|
+
f' <ac:parameter ac:name="lbox">true</ac:parameter>\n'
|
|
527
|
+
f' <ac:parameter ac:name="diagramWidth">{width}</ac:parameter>\n'
|
|
528
|
+
f' <ac:parameter ac:name="revision">1</ac:parameter>\n'
|
|
529
|
+
f' <ac:plain-text-body>\n'
|
|
530
|
+
f' <![CDATA[\n'
|
|
531
|
+
f'{drawio_xml}\n'
|
|
532
|
+
f' ]]>\n'
|
|
533
|
+
f' </ac:plain-text-body>\n'
|
|
534
|
+
f' </ac:structured-macro>\n'
|
|
535
|
+
f'</p>'
|
|
536
|
+
)
|
|
171
537
|
|
|
172
|
-
|
|
173
|
-
|
|
538
|
+
|
|
539
|
+
def convert_mermaid_flowchart_to_plantuml(code: str, is_lineage: bool = False) -> str:
|
|
540
|
+
"""Parse Mermaid flowchart into PlantUML with strict orthogonal routing."""
|
|
541
|
+
# Strip Mermaid init blocks %%{init: ... }%%
|
|
542
|
+
code = re.sub(r'%%\{init:.*?\}%%', '', code, flags=re.DOTALL)
|
|
174
543
|
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
175
544
|
|
|
176
545
|
subgraphs = []
|
|
@@ -178,16 +547,24 @@ def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
|
|
|
178
547
|
top_nodes = {}
|
|
179
548
|
top_connections = []
|
|
180
549
|
|
|
181
|
-
subgraph_start_re = re.compile(r'^\s*subgraph\s+([A-Za-z0-9_]+)(?:\["([^"]+)"\]|\[([^\]]+)\])?', re.IGNORECASE)
|
|
182
|
-
direction_re = re.compile(r'^\s*direction\s+(LR|RL|TB|TD)', re.IGNORECASE)
|
|
183
550
|
node_def_re = re.compile(
|
|
184
|
-
r'([A-Za-z0-9_]+)(?:\["(
|
|
551
|
+
r'([A-Za-z0-9_]+)\s*(?:\[\["?(.*?)"?\]\]|\(\("?(.*?)"?\)\)|\[\("?(.*?)"?\)\]|\[\/"?(.*?)"?\/\]|\[\\"?(.*?)"?\\\]|\["([^"]*)"\]|\[([^\]]*)\])'
|
|
185
552
|
)
|
|
553
|
+
subgraph_start_re = re.compile(r'^subgraph\s+([A-Za-z0-9_]+)(?:\s*\["([^"]+)"\]|\s*\[(.+)\])?', re.IGNORECASE)
|
|
554
|
+
direction_re = re.compile(r'^direction\s+([A-Za-z]+)', re.IGNORECASE)
|
|
555
|
+
|
|
556
|
+
node_classes = {}
|
|
186
557
|
|
|
187
558
|
for line in lines:
|
|
188
|
-
if
|
|
559
|
+
if line.startswith("flowchart") or line.startswith("graph") or line.startswith("classDef") or line.startswith("style ") or line.startswith("%%"):
|
|
189
560
|
continue
|
|
190
|
-
|
|
561
|
+
|
|
562
|
+
class_match = re.match(r'^\s*class\s+([A-Za-z0-9_,\s]+)\s+([A-Za-z0-9_]+)\s*;?', line)
|
|
563
|
+
if class_match:
|
|
564
|
+
targets = [t.strip() for t in class_match.group(1).split(',') if t.strip()]
|
|
565
|
+
cls_name = class_match.group(2)
|
|
566
|
+
for t in targets:
|
|
567
|
+
node_classes[t] = cls_name
|
|
191
568
|
continue
|
|
192
569
|
|
|
193
570
|
sg_match = subgraph_start_re.match(line)
|
|
@@ -219,18 +596,34 @@ def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
|
|
|
219
596
|
sg_stack[-1]["direction"] = dir_match.group(1).upper()
|
|
220
597
|
continue
|
|
221
598
|
|
|
222
|
-
for
|
|
599
|
+
for m in re.finditer(r'([A-Za-z0-9_]+)(?:\[.*?\]|\(.*?\))?:::([A-Za-z0-9_]+)', line):
|
|
600
|
+
node_classes[m.group(1)] = m.group(2)
|
|
601
|
+
|
|
602
|
+
clean_line_no_style = re.sub(r':::[A-Za-z0-9_]+', '', line)
|
|
603
|
+
|
|
604
|
+
# Stash edge labels before matching node definitions so brackets inside labels aren't mistaken for nodes
|
|
605
|
+
edge_labels = []
|
|
606
|
+
def stash_label(m):
|
|
607
|
+
edge_labels.append(m.group(0))
|
|
608
|
+
return f" __EDGE_LABEL_{len(edge_labels)-1}__ "
|
|
609
|
+
|
|
610
|
+
stashed_line = re.sub(r'\|"[^"]*"\||\|[^|]*\|', stash_label, clean_line_no_style)
|
|
611
|
+
|
|
612
|
+
for match in node_def_re.finditer(stashed_line):
|
|
223
613
|
n_id = match.group(1)
|
|
224
|
-
n_lbl = next(g for g in match.groups()[1:
|
|
225
|
-
|
|
226
|
-
|
|
614
|
+
n_lbl = next((g for g in match.groups()[1:] if g is not None), "")
|
|
615
|
+
if (n_lbl.startswith('"') and n_lbl.endswith('"')) or (n_lbl.startswith("'") and n_lbl.endswith("'")):
|
|
616
|
+
n_lbl = n_lbl[1:-1]
|
|
617
|
+
node_info = {"id": n_id, "label": n_lbl, "class": node_classes.get(n_id, "")}
|
|
227
618
|
if sg_stack:
|
|
228
619
|
sg_stack[-1]["nodes"][n_id] = node_info
|
|
229
620
|
else:
|
|
230
621
|
top_nodes[n_id] = node_info
|
|
231
622
|
|
|
232
|
-
clean_edge_line = node_def_re.sub(r'\1',
|
|
233
|
-
|
|
623
|
+
clean_edge_line = node_def_re.sub(r'\1', stashed_line)
|
|
624
|
+
for idx, orig_lbl in enumerate(edge_labels):
|
|
625
|
+
clean_edge_line = clean_edge_line.replace(f"__EDGE_LABEL_{idx}__", orig_lbl)
|
|
626
|
+
edge_tokens = re.split(r'(\s*(?:<-->|-->|---|->|-\.->|\.-\.|\.\->)\s*(?:\|"[^"]*"\|\s*|\|[^\|]*\|\s*)?)', clean_edge_line)
|
|
234
627
|
if len(edge_tokens) >= 3:
|
|
235
628
|
for i in range(0, len(edge_tokens) - 2, 2):
|
|
236
629
|
src = edge_tokens[i].strip()
|
|
@@ -239,97 +632,230 @@ def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
|
|
|
239
632
|
|
|
240
633
|
lbl_match = re.search(r'\|"?([^"\|]*)"?\|', arrow_token)
|
|
241
634
|
lbl = lbl_match.group(1).strip() if lbl_match else ""
|
|
635
|
+
dashed = "-.-" in arrow_token or ".-" in arrow_token
|
|
242
636
|
|
|
243
637
|
if src and dst and re.match(r'^[A-Za-z0-9_]+$', src) and re.match(r'^[A-Za-z0-9_]+$', dst):
|
|
244
|
-
edge_obj = {"src": src, "dst": dst, "label": lbl}
|
|
638
|
+
edge_obj = {"src": src, "dst": dst, "label": lbl, "dashed": dashed}
|
|
245
639
|
if sg_stack:
|
|
246
640
|
sg_stack[-1]["edges"].append(edge_obj)
|
|
247
641
|
else:
|
|
248
642
|
top_connections.append(edge_obj)
|
|
249
643
|
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
644
|
+
def clean_node_label(label: str) -> str:
|
|
645
|
+
cleaned = label
|
|
646
|
+
cleaned = re.sub(r'<i>\s*(?:New Component|Existing Platform|Existing Component|Impacted Component|New|Existing)\s*</i>', '', cleaned, flags=re.IGNORECASE)
|
|
647
|
+
cleaned = re.sub(r'<b>\s*\((?:New Component|Existing Platform|Existing Component|Impacted Component|New|Existing)\)\s*</b>', '', cleaned, flags=re.IGNORECASE)
|
|
648
|
+
cleaned = re.sub(r'\((?:New Component|Existing Platform|Existing Component|Impacted Component|New|Existing)\)', '', cleaned, flags=re.IGNORECASE)
|
|
649
|
+
cleaned = re.sub(r'\[(?:New|Existing)\]', '', cleaned, flags=re.IGNORECASE)
|
|
650
|
+
cleaned = re.sub(r'(?:<br\s*/?>\s*)+$', '', cleaned, flags=re.IGNORECASE)
|
|
651
|
+
cleaned = re.sub(r'^(?:\s*<br\s*/?>)+', '', cleaned, flags=re.IGNORECASE)
|
|
652
|
+
return cleaned.strip()
|
|
653
|
+
|
|
654
|
+
# Flatten nodes to annotate element type and background color
|
|
655
|
+
all_nodes = {}
|
|
656
|
+
def collect_nodes(sg_list):
|
|
657
|
+
for sg in sg_list:
|
|
658
|
+
for nid, ninfo in sg["nodes"].items():
|
|
659
|
+
all_nodes[nid] = ninfo
|
|
660
|
+
collect_nodes(sg.get("children", []))
|
|
661
|
+
collect_nodes(subgraphs)
|
|
662
|
+
for nid, ninfo in top_nodes.items():
|
|
663
|
+
all_nodes[nid] = ninfo
|
|
664
|
+
|
|
665
|
+
for nid, ninfo in all_nodes.items():
|
|
666
|
+
id_low = nid.lower()
|
|
667
|
+
lbl_low = ninfo["label"].lower()
|
|
668
|
+
cls_low = (node_classes.get(nid, "") or ninfo.get("class", "")).lower()
|
|
669
|
+
|
|
670
|
+
is_db = (
|
|
671
|
+
"db" in id_low or "mart" in id_low or "raw" in id_low or "database" in lbl_low or
|
|
672
|
+
"store" in id_low or "table" in id_low or "snapshot" in id_low or
|
|
673
|
+
"jsc" in id_low or "src" in id_low or any(k in cls_low for k in ["db", "src", "mart", "staging"])
|
|
674
|
+
)
|
|
675
|
+
elem_type = "database" if is_db else "rectangle"
|
|
254
676
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return "queue", "gateway"
|
|
677
|
+
is_new_or_impacted = (
|
|
678
|
+
"new" in cls_low or "impacted" in cls_low or
|
|
679
|
+
"new" in lbl_low or "impacted" in lbl_low or
|
|
680
|
+
"new" in id_low or "impacted" in id_low
|
|
681
|
+
)
|
|
682
|
+
bg_color = "#FEF2F2;line:DC2626" if is_new_or_impacted else "#EFF6FF;line:2563EB"
|
|
683
|
+
ninfo["elem_type"] = elem_type
|
|
684
|
+
ninfo["bg_color"] = bg_color
|
|
685
|
+
ninfo["is_new"] = is_new_or_impacted
|
|
265
686
|
|
|
266
|
-
|
|
687
|
+
is_lr = any(re.match(r'^\s*(flowchart|graph)\s+LR', l, re.IGNORECASE) for l in lines)
|
|
688
|
+
has_sys_int = bool(re.search(r'SYS_|INT_', code))
|
|
267
689
|
|
|
268
690
|
out = []
|
|
269
691
|
out.append("@startuml")
|
|
692
|
+
if is_lr and not subgraphs:
|
|
693
|
+
out.append("left to right direction")
|
|
694
|
+
out.append("scale 1.2")
|
|
695
|
+
out.append("skinparam defaultFontSize 11")
|
|
696
|
+
out.append("skinparam nodesep 24")
|
|
697
|
+
out.append("skinparam ranksep 28\n")
|
|
270
698
|
out.append("skinparam shadowing false")
|
|
271
699
|
out.append('skinparam defaultFontName "Segoe UI"')
|
|
272
|
-
out.append("skinparam defaultFontSize 11")
|
|
273
700
|
out.append("skinparam roundCorner 6")
|
|
274
|
-
out.append("skinparam linetype ortho")
|
|
275
|
-
out.append("skinparam nodesep 70")
|
|
276
|
-
out.append("skinparam ranksep 40\n")
|
|
277
701
|
|
|
278
702
|
out.append("skinparam package {")
|
|
279
|
-
out.append(" BorderColor #
|
|
703
|
+
out.append(" BorderColor #475569")
|
|
704
|
+
out.append(" BorderThickness 1.5")
|
|
280
705
|
out.append(" BackgroundColor #F8FAFC")
|
|
281
|
-
out.append(" FontColor #
|
|
706
|
+
out.append(" FontColor #0F172A")
|
|
282
707
|
out.append(" FontStyle bold")
|
|
283
|
-
out.append("
|
|
284
|
-
|
|
285
|
-
out.append("skinparam database<<source_db>> {")
|
|
286
|
-
out.append(" BorderColor #3949AB")
|
|
287
|
-
out.append(" BackgroundColor #E8EAF6")
|
|
288
|
-
out.append(" FontColor #1A237E")
|
|
289
|
-
out.append("}")
|
|
290
|
-
out.append("skinparam database<<staging_db>> {")
|
|
291
|
-
out.append(" BorderColor #2E7D32")
|
|
292
|
-
out.append(" BackgroundColor #E8F5E9")
|
|
293
|
-
out.append(" FontColor #1B5E20")
|
|
708
|
+
out.append(" Padding 10")
|
|
294
709
|
out.append("}")
|
|
295
|
-
|
|
296
|
-
out.append("
|
|
297
|
-
out.append("
|
|
298
|
-
out.append(" FontColor #BF360C")
|
|
299
|
-
out.append("}")
|
|
300
|
-
out.append("skinparam rectangle<<service>> {")
|
|
301
|
-
out.append(" BorderColor #0277BD")
|
|
302
|
-
out.append(" BackgroundColor #E1F5FE")
|
|
303
|
-
out.append(" FontColor #01579B")
|
|
710
|
+
|
|
711
|
+
out.append("skinparam rectangle {")
|
|
712
|
+
out.append(" BorderThickness 1.5")
|
|
304
713
|
out.append("}")
|
|
305
|
-
out.append("skinparam
|
|
306
|
-
out.append("
|
|
307
|
-
out.append(" BackgroundColor #ECEFF1")
|
|
308
|
-
out.append(" FontColor #263238")
|
|
714
|
+
out.append("skinparam database {")
|
|
715
|
+
out.append(" BorderThickness 1.5")
|
|
309
716
|
out.append("}\n")
|
|
310
717
|
|
|
718
|
+
out.append("skinparam legend {")
|
|
719
|
+
out.append(" BackgroundColor #FFFFFF")
|
|
720
|
+
out.append(" BorderColor #CBD5E1")
|
|
721
|
+
out.append(" BorderThickness 1")
|
|
722
|
+
out.append(" FontSize 9")
|
|
723
|
+
out.append(' FontName "Segoe UI"')
|
|
724
|
+
out.append("}\n")
|
|
725
|
+
|
|
726
|
+
def wrap_edge_label(lbl: str, max_len: int = 24) -> str:
|
|
727
|
+
clean = lbl.strip()
|
|
728
|
+
clean = re.sub(r'\[(?:New|Existing|New Connectivity|Existing Connectivity)\]', '', clean, flags=re.IGNORECASE)
|
|
729
|
+
clean = re.sub(r'\((?:New|Existing|New Connectivity|Existing Connectivity)\)', '', clean, flags=re.IGNORECASE)
|
|
730
|
+
clean = clean.replace("<br/>", "\\n").replace("<br>", "\\n").replace('"', "'").strip()
|
|
731
|
+
if not clean:
|
|
732
|
+
return ""
|
|
733
|
+
if "\\n" in clean or "\n" in clean:
|
|
734
|
+
return clean
|
|
735
|
+
|
|
736
|
+
m = re.match(r'^(INT_[A-Za-z0-9_]+(?:\s*\[[^\]]+\])?\s*:\s*)(.+)$', clean)
|
|
737
|
+
if m:
|
|
738
|
+
prefix = m.group(1).strip()
|
|
739
|
+
desc = m.group(2).strip()
|
|
740
|
+
words = desc.split()
|
|
741
|
+
lines = [prefix]
|
|
742
|
+
curr = []
|
|
743
|
+
for w in words:
|
|
744
|
+
if sum(len(x) for x in curr) + len(curr) + len(w) > max_len:
|
|
745
|
+
lines.append(" ".join(curr))
|
|
746
|
+
curr = [w]
|
|
747
|
+
else:
|
|
748
|
+
curr.append(w)
|
|
749
|
+
if curr:
|
|
750
|
+
lines.append(" ".join(curr))
|
|
751
|
+
return "\\n".join(lines)
|
|
752
|
+
|
|
753
|
+
words = clean.split()
|
|
754
|
+
lines = []
|
|
755
|
+
curr = []
|
|
756
|
+
for w in words:
|
|
757
|
+
if sum(len(x) for x in curr) + len(curr) + len(w) > max_len:
|
|
758
|
+
lines.append(" ".join(curr))
|
|
759
|
+
curr = [w]
|
|
760
|
+
else:
|
|
761
|
+
curr.append(w)
|
|
762
|
+
if curr:
|
|
763
|
+
lines.append(" ".join(curr))
|
|
764
|
+
return "\\n".join(lines)
|
|
765
|
+
|
|
766
|
+
node_to_sg = {}
|
|
767
|
+
sg_dir_by_id = {}
|
|
768
|
+
sg_index_by_id = {}
|
|
769
|
+
def collect_sg_info(sg_list, idx_counter=None):
|
|
770
|
+
if idx_counter is None:
|
|
771
|
+
idx_counter = [0]
|
|
772
|
+
for sg in sg_list:
|
|
773
|
+
sg_index_by_id[sg["id"]] = idx_counter[0]
|
|
774
|
+
idx_counter[0] += 1
|
|
775
|
+
sg_dir_by_id[sg["id"]] = sg.get("direction", "TB")
|
|
776
|
+
for n_id in sg.get("nodes", {}):
|
|
777
|
+
node_to_sg[n_id] = sg["id"]
|
|
778
|
+
collect_sg_info(sg.get("children", []), idx_counter)
|
|
779
|
+
collect_sg_info(subgraphs)
|
|
780
|
+
|
|
781
|
+
def format_edge_arrow(e: dict, sg_dir: str = "TB") -> Tuple[str, str]:
|
|
782
|
+
raw_lbl = e.get("label", "")
|
|
783
|
+
wrapped_lbl = wrap_edge_label(raw_lbl)
|
|
784
|
+
lbl_suffix = f' : {wrapped_lbl}' if wrapped_lbl else ""
|
|
785
|
+
|
|
786
|
+
src_node = all_nodes.get(e["src"])
|
|
787
|
+
dst_node = all_nodes.get(e["dst"])
|
|
788
|
+
src_is_new = src_node and src_node.get("is_new", False)
|
|
789
|
+
dst_is_new = dst_node and dst_node.get("is_new", False)
|
|
790
|
+
|
|
791
|
+
is_new_edge = (
|
|
792
|
+
bool(re.search(r'\b(new|impacted)\b', raw_lbl, re.IGNORECASE)) or
|
|
793
|
+
src_is_new or dst_is_new
|
|
794
|
+
)
|
|
795
|
+
if bool(re.search(r'\bexisting\b', raw_lbl, re.IGNORECASE)):
|
|
796
|
+
color = "#2563EB"
|
|
797
|
+
elif is_new_edge:
|
|
798
|
+
color = "#DC2626"
|
|
799
|
+
else:
|
|
800
|
+
color = "#2563EB"
|
|
801
|
+
|
|
802
|
+
src_sg = node_to_sg.get(e["src"])
|
|
803
|
+
dst_sg = node_to_sg.get(e["dst"])
|
|
804
|
+
is_cross_sg = src_sg and dst_sg and src_sg != dst_sg
|
|
805
|
+
|
|
806
|
+
is_upward = False
|
|
807
|
+
if is_cross_sg and not is_lr:
|
|
808
|
+
src_idx = sg_index_by_id.get(src_sg, 0)
|
|
809
|
+
dst_idx = sg_index_by_id.get(dst_sg, 0)
|
|
810
|
+
if dst_idx < src_idx:
|
|
811
|
+
is_upward = True
|
|
812
|
+
|
|
813
|
+
if e.get("dashed"):
|
|
814
|
+
if is_cross_sg:
|
|
815
|
+
arrow = f".-[{color},norank]->" if (is_lr or is_upward) else f".-[{color}]->"
|
|
816
|
+
else:
|
|
817
|
+
arrow = f".-[{color}]->"
|
|
818
|
+
elif is_cross_sg:
|
|
819
|
+
if is_lr:
|
|
820
|
+
arrow = f"-[{color},norank]right->"
|
|
821
|
+
elif is_upward:
|
|
822
|
+
arrow = f"-[{color},norank]up->"
|
|
823
|
+
else:
|
|
824
|
+
arrow = f"-[{color}]->"
|
|
825
|
+
elif sg_dir == "LR":
|
|
826
|
+
arrow = f"-[{color}]right->"
|
|
827
|
+
else:
|
|
828
|
+
arrow = f"-[{color}]->"
|
|
829
|
+
return arrow, lbl_suffix
|
|
830
|
+
|
|
311
831
|
def render_subgraph(sg, indent=""):
|
|
312
|
-
res = [f'{indent}package "{sg["title"]}" as {sg["id"]} {{']
|
|
832
|
+
res = [f'{indent}package "<b>{sg["title"]}</b>" as {sg["id"]} {{']
|
|
313
833
|
for child in sg.get("children", []):
|
|
314
834
|
res.extend(render_subgraph(child, indent + " "))
|
|
315
835
|
for n_id, n_info in sg["nodes"].items():
|
|
316
836
|
formatted_lbl = (
|
|
317
|
-
n_info["label"]
|
|
837
|
+
clean_node_label(n_info["label"])
|
|
318
838
|
.replace("<br/>", "\\n")
|
|
319
839
|
.replace("<br>", "\\n")
|
|
320
840
|
.replace("•", "-")
|
|
321
841
|
.replace('"', "'")
|
|
322
842
|
)
|
|
323
|
-
elem_type
|
|
324
|
-
|
|
325
|
-
res.append(f'{indent} {elem_type} "{formatted_lbl}" as {n_id}{
|
|
843
|
+
elem_type = n_info["elem_type"]
|
|
844
|
+
bg_color = n_info["bg_color"]
|
|
845
|
+
res.append(f'{indent} {elem_type} "{formatted_lbl}" as {n_id} {bg_color}')
|
|
846
|
+
|
|
847
|
+
sg_dir = sg.get("direction", "TB")
|
|
848
|
+
if sg_dir == "LR":
|
|
849
|
+
node_ids = list(sg["nodes"].keys())
|
|
850
|
+
for i in range(len(node_ids) - 1):
|
|
851
|
+
res.append(f'{indent} {node_ids[i]} -[hidden]right-> {node_ids[i+1]}')
|
|
852
|
+
if node_ids:
|
|
853
|
+
res.append("")
|
|
326
854
|
|
|
327
855
|
if sg["edges"]:
|
|
328
856
|
res.append("")
|
|
329
|
-
arrow = "-right->" if sg["direction"] == "LR" else "-down->"
|
|
330
857
|
for e in sg["edges"]:
|
|
331
|
-
|
|
332
|
-
lbl_suffix = f' : {lbl_clean}' if lbl_clean else ""
|
|
858
|
+
arrow, lbl_suffix = format_edge_arrow(e, sg_dir)
|
|
333
859
|
res.append(f'{indent} {e["src"]} {arrow} {e["dst"]}{lbl_suffix}')
|
|
334
860
|
res.append(f"{indent}}}\n")
|
|
335
861
|
return res
|
|
@@ -338,37 +864,50 @@ def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
|
|
|
338
864
|
out.extend(render_subgraph(sg))
|
|
339
865
|
|
|
340
866
|
for n_id, n_info in top_nodes.items():
|
|
341
|
-
formatted_lbl = n_info["label"].replace("<br/>", "\\n").replace("<br>", "\\n").replace('"', "'")
|
|
342
|
-
elem_type
|
|
343
|
-
|
|
344
|
-
out.append(f'{elem_type} "{formatted_lbl}" as {n_id}{
|
|
867
|
+
formatted_lbl = clean_node_label(n_info["label"]).replace("<br/>", "\\n").replace("<br>", "\\n").replace('"', "'")
|
|
868
|
+
elem_type = n_info["elem_type"]
|
|
869
|
+
bg_color = n_info["bg_color"]
|
|
870
|
+
out.append(f'{elem_type} "{formatted_lbl}" as {n_id} {bg_color}')
|
|
345
871
|
if top_nodes:
|
|
346
872
|
out.append("")
|
|
347
873
|
|
|
348
874
|
for i in range(len(subgraphs) - 1):
|
|
349
|
-
|
|
875
|
+
if is_lr:
|
|
876
|
+
out.append(f'{subgraphs[i]["id"]} -[hidden]right-> {subgraphs[i+1]["id"]}')
|
|
877
|
+
else:
|
|
878
|
+
out.append(f'{subgraphs[i]["id"]} -[hidden]down-> {subgraphs[i+1]["id"]}')
|
|
350
879
|
if subgraphs:
|
|
351
880
|
out.append("")
|
|
352
881
|
|
|
353
882
|
for e in top_connections:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
883
|
+
arrow, lbl_suffix = format_edge_arrow(e)
|
|
884
|
+
out.append(f'{e["src"]} {arrow} {e["dst"]}{lbl_suffix}')
|
|
885
|
+
|
|
886
|
+
out.append("\nlegend bottom")
|
|
887
|
+
out.append("|= Marker |= Classification |= Description |")
|
|
888
|
+
out.append('|<color:#DC2626><b>━━━━▶</b></color> | <b>New/Impacted Connection</b> | New or modified integration interface created for this solution |')
|
|
889
|
+
out.append('|<color:#2563EB><b>━━━━▶</b></color> | <b>Existing Connection</b> | Pre-existing unmodified production integration pathway |')
|
|
890
|
+
out.append('|<back:#FEF2F2><color:#DC2626><b> ■ </b></color></back> | <b>New / Impacted Component</b> | Component introduced or modified specifically for this solution |')
|
|
891
|
+
out.append('|<back:#EFF6FF><color:#2563EB><b> ■ </b></color></back> | <b>Existing Platform</b> | Unmodified pre-existing production enterprise fleet component |')
|
|
892
|
+
out.append('|<size:9><b>[SYS_xx]</b></size> | <b>System ID</b> | Mapped directly to Architecture Specifications |')
|
|
893
|
+
out.append("endlegend")
|
|
357
894
|
|
|
358
895
|
out.append("@enduml")
|
|
359
896
|
return "\n".join(out)
|
|
360
897
|
|
|
361
898
|
|
|
362
|
-
def convert_mermaid_sequence_to_plantuml(code: str) -> str:
|
|
899
|
+
def convert_mermaid_sequence_to_plantuml(code: str, is_lineage: bool = False) -> str:
|
|
363
900
|
"""Parse Mermaid sequenceDiagram into PlantUML sequence diagram with orthogonal lines."""
|
|
364
|
-
|
|
901
|
+
code = re.sub(r'%%\{init:.*?\}%%', '', code, flags=re.DOTALL)
|
|
902
|
+
lines = [l.strip() for l in code.splitlines() if l.strip() and not l.strip().startswith("%%")]
|
|
365
903
|
|
|
366
904
|
out = []
|
|
367
905
|
out.append("@startuml")
|
|
368
906
|
out.append("autonumber")
|
|
907
|
+
out.append("scale max 2000 width")
|
|
908
|
+
out.append("skinparam defaultFontSize 11")
|
|
369
909
|
out.append("skinparam shadowing false")
|
|
370
910
|
out.append('skinparam defaultFontName "Segoe UI"')
|
|
371
|
-
out.append("skinparam defaultFontSize 11")
|
|
372
911
|
out.append("skinparam roundCorner 6\n")
|
|
373
912
|
|
|
374
913
|
out.append("skinparam actor {")
|
|
@@ -442,56 +981,155 @@ def convert_mermaid_sequence_to_plantuml(code: str) -> str:
|
|
|
442
981
|
return "\n".join(out)
|
|
443
982
|
|
|
444
983
|
|
|
984
|
+
def convert_mermaid_class_to_plantuml(diag_code: str) -> str:
|
|
985
|
+
"""Convert Mermaid classDiagram block into native PlantUML class diagram."""
|
|
986
|
+
lines = diag_code.strip().splitlines()
|
|
987
|
+
out = [
|
|
988
|
+
"@startuml",
|
|
989
|
+
"scale 1.2",
|
|
990
|
+
"skinparam defaultFontSize 11",
|
|
991
|
+
"skinparam defaultFontName \"Segoe UI\"",
|
|
992
|
+
"skinparam shadowing false",
|
|
993
|
+
"skinparam roundCorner 6",
|
|
994
|
+
"skinparam class {",
|
|
995
|
+
" BackgroundColor #F8FAFC",
|
|
996
|
+
" BorderColor #2563EB",
|
|
997
|
+
" BorderThickness 1.5",
|
|
998
|
+
" ArrowColor #2563EB",
|
|
999
|
+
" FontColor #0F172A",
|
|
1000
|
+
" FontStyle bold",
|
|
1001
|
+
" AttributeFontColor #334155",
|
|
1002
|
+
" AttributeFontSize 10",
|
|
1003
|
+
"}",
|
|
1004
|
+
""
|
|
1005
|
+
]
|
|
1006
|
+
for line in lines:
|
|
1007
|
+
stripped = line.strip()
|
|
1008
|
+
if not stripped or stripped.startswith("classDiagram"):
|
|
1009
|
+
continue
|
|
1010
|
+
# Convert Mermaid generic syntax ~Type~ to PlantUML <Type>
|
|
1011
|
+
converted = line
|
|
1012
|
+
while "~" in converted:
|
|
1013
|
+
converted = converted.replace("~", "<", 1).replace("~", ">", 1)
|
|
1014
|
+
out.append(converted)
|
|
1015
|
+
out.append("@enduml")
|
|
1016
|
+
return "\n".join(out)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def convert_mermaid_er_to_plantuml(diag_code: str) -> str:
|
|
1020
|
+
"""Convert Mermaid erDiagram block into native PlantUML ER entity diagram."""
|
|
1021
|
+
lines = diag_code.strip().splitlines()
|
|
1022
|
+
out = [
|
|
1023
|
+
"@startuml",
|
|
1024
|
+
"scale 1.2",
|
|
1025
|
+
"skinparam defaultFontSize 11",
|
|
1026
|
+
"skinparam defaultFontName \"Segoe UI\"",
|
|
1027
|
+
"skinparam shadowing false",
|
|
1028
|
+
"skinparam roundCorner 6",
|
|
1029
|
+
"skinparam entity {",
|
|
1030
|
+
" BackgroundColor #F8FAFC",
|
|
1031
|
+
" BorderColor #2563EB",
|
|
1032
|
+
" BorderThickness 1.5",
|
|
1033
|
+
" ArrowColor #2563EB",
|
|
1034
|
+
" FontColor #0F172A",
|
|
1035
|
+
" FontStyle bold",
|
|
1036
|
+
"}",
|
|
1037
|
+
""
|
|
1038
|
+
]
|
|
1039
|
+
in_entity = False
|
|
1040
|
+
entity_hdr_re = re.compile(r'^([A-Za-z0-9_-]+)\s*\{$')
|
|
1041
|
+
|
|
1042
|
+
for line in lines:
|
|
1043
|
+
stripped = line.strip()
|
|
1044
|
+
if not stripped or stripped.startswith("erDiagram"):
|
|
1045
|
+
continue
|
|
1046
|
+
|
|
1047
|
+
m_ent = entity_hdr_re.match(stripped)
|
|
1048
|
+
if m_ent and not in_entity:
|
|
1049
|
+
in_entity = True
|
|
1050
|
+
ent_name = m_ent.group(1)
|
|
1051
|
+
alias = ent_name.replace("-", "_")
|
|
1052
|
+
out.append(f'entity "{ent_name}" as {alias} {{')
|
|
1053
|
+
continue
|
|
1054
|
+
|
|
1055
|
+
if stripped == "}" and in_entity:
|
|
1056
|
+
in_entity = False
|
|
1057
|
+
out.append("}")
|
|
1058
|
+
continue
|
|
1059
|
+
|
|
1060
|
+
if in_entity:
|
|
1061
|
+
parts = stripped.split()
|
|
1062
|
+
if len(parts) >= 2:
|
|
1063
|
+
dtype = parts[0]
|
|
1064
|
+
col = parts[1]
|
|
1065
|
+
is_pk = "PK" in parts[2:]
|
|
1066
|
+
prefix = "* " if is_pk else ""
|
|
1067
|
+
suffix = " <<PK>>" if is_pk else ""
|
|
1068
|
+
out.append(f" {prefix}{col} : {dtype}{suffix}")
|
|
1069
|
+
else:
|
|
1070
|
+
out.append(f" {stripped}")
|
|
1071
|
+
else:
|
|
1072
|
+
out.append(line)
|
|
1073
|
+
|
|
1074
|
+
out.append("@enduml")
|
|
1075
|
+
return "\n".join(out)
|
|
1076
|
+
|
|
1077
|
+
|
|
445
1078
|
def render_plantuml_macro(puml_code: str) -> str:
|
|
446
|
-
"""Wrap PlantUML code inside native Confluence <ac:structured-macro ac:name="plantuml"
|
|
1079
|
+
"""Wrap PlantUML code inside native Confluence <ac:structured-macro ac:name="plantuml"> with centered alignment."""
|
|
447
1080
|
return (
|
|
448
|
-
f'<
|
|
1081
|
+
f'<div style="text-align:center;">\n'
|
|
1082
|
+
f'<ac:structured-macro ac:name="plantuml" ac:schema-version="1">\n'
|
|
449
1083
|
f'<ac:parameter ac:name="atlassian-macro-output-type">INLINE</ac:parameter>\n'
|
|
450
1084
|
f'<ac:plain-text-body><![CDATA[{puml_code}]]></ac:plain-text-body>\n'
|
|
451
|
-
f'</ac:structured-macro
|
|
1085
|
+
f'</ac:structured-macro>\n'
|
|
1086
|
+
f'</div>'
|
|
452
1087
|
)
|
|
453
1088
|
|
|
454
1089
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
app_title = title_match.group(1) if title_match else "Architecture Document"
|
|
467
|
-
app_name = re.sub(
|
|
468
|
-
r'^(High-Level Architecture:\s*|High-Level Architecture \(HLA\):\s*|High-Level Design:\s*|Lower-Level Design:\s*)',
|
|
469
|
-
'',
|
|
470
|
-
app_title,
|
|
471
|
-
flags=re.IGNORECASE
|
|
472
|
-
).strip()
|
|
1090
|
+
def convert_markdown_to_confluence_xhtml(
|
|
1091
|
+
md_text: str,
|
|
1092
|
+
is_hld: bool = True,
|
|
1093
|
+
is_lineage: bool = False,
|
|
1094
|
+
diagram_type: str = "plantuml",
|
|
1095
|
+
diagrams_dir: Optional[str] = None,
|
|
1096
|
+
app_prefix: str = "app"
|
|
1097
|
+
) -> str:
|
|
1098
|
+
"""Convert Markdown document to well-formed Confluence XHTML."""
|
|
1099
|
+
cleaned_md = sanitize_ste_and_anti_slop(md_text)
|
|
1100
|
+
cleaned_md = strip_ai_traces_and_metadata(cleaned_md, is_hld=is_hld)
|
|
473
1101
|
|
|
474
|
-
|
|
475
|
-
|
|
1102
|
+
lines = cleaned_md.splitlines()
|
|
1103
|
+
body_parts = []
|
|
476
1104
|
|
|
477
|
-
#
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
)
|
|
1105
|
+
# Extract Title
|
|
1106
|
+
title = f"{app_prefix.capitalize()} Architecture Specification"
|
|
1107
|
+
for line in lines:
|
|
1108
|
+
if line.startswith("# "):
|
|
1109
|
+
title = line[2:].strip()
|
|
1110
|
+
title = re.sub(r'^(?:High-Level\s+Architecture\s*\(HLA\):\s*|Lower-Level\s+Design:\s*)', '', title)
|
|
1111
|
+
break
|
|
485
1112
|
|
|
486
|
-
|
|
487
|
-
|
|
1113
|
+
app_name = title
|
|
1114
|
+
safe_app_name = html.escape(app_name)
|
|
488
1115
|
|
|
489
|
-
|
|
490
|
-
|
|
1116
|
+
# Build Header Block
|
|
1117
|
+
if is_lineage:
|
|
1118
|
+
body_parts.append(f"<h1>Data Lineage & Telemetry: {safe_app_name}</h1>")
|
|
1119
|
+
body_parts.append("<hr/>")
|
|
1120
|
+
body_parts.append("<h2>Table of Contents</h2>")
|
|
1121
|
+
body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
|
|
1122
|
+
body_parts.append("<hr/>")
|
|
491
1123
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1124
|
+
# Find starting index of Section 1
|
|
1125
|
+
start_idx = 0
|
|
1126
|
+
for idx, l in enumerate(lines):
|
|
1127
|
+
if re.match(r'^##\s+1\.', l.strip()):
|
|
1128
|
+
start_idx = idx
|
|
1129
|
+
break
|
|
1130
|
+
i = start_idx
|
|
1131
|
+
elif is_hld:
|
|
1132
|
+
body_parts.append(f"<h1>High-Level Architecture (HLA): {safe_app_name}</h1>")
|
|
495
1133
|
body_parts.append("<hr/>")
|
|
496
1134
|
body_parts.append("<h2>Document Information</h2>")
|
|
497
1135
|
body_parts.append("<h3>Version History</h3>")
|
|
@@ -505,14 +1143,49 @@ def convert_markdown_to_confluence_xhtml(md_text: str, is_hld: bool = True, diag
|
|
|
505
1143
|
body_parts.append("<h2>Table of Contents</h2>")
|
|
506
1144
|
body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
|
|
507
1145
|
body_parts.append("<hr/>")
|
|
1146
|
+
|
|
1147
|
+
# Find starting index of Section 1
|
|
1148
|
+
start_idx = 0
|
|
1149
|
+
for idx, l in enumerate(lines):
|
|
1150
|
+
if re.match(r'^##\s+1\.', l.strip()):
|
|
1151
|
+
start_idx = idx
|
|
1152
|
+
break
|
|
1153
|
+
i = start_idx
|
|
508
1154
|
else:
|
|
509
|
-
body_parts.append(f"<h1>Lower-Level Design: {
|
|
1155
|
+
body_parts.append(f"<h1>Lower-Level Design: {safe_app_name}</h1>")
|
|
510
1156
|
body_parts.append("<hr/>")
|
|
1157
|
+
|
|
1158
|
+
# Check for Technical Metadata before Section 1
|
|
1159
|
+
meta_lines = []
|
|
1160
|
+
in_meta = False
|
|
1161
|
+
start_idx = 0
|
|
1162
|
+
for idx, l in enumerate(lines):
|
|
1163
|
+
if re.match(r'^##\s+Technical Metadata', l.strip(), re.IGNORECASE):
|
|
1164
|
+
in_meta = True
|
|
1165
|
+
continue
|
|
1166
|
+
if re.match(r'^##\s+1\.', l.strip()):
|
|
1167
|
+
start_idx = idx
|
|
1168
|
+
break
|
|
1169
|
+
if in_meta:
|
|
1170
|
+
meta_lines.append(l)
|
|
1171
|
+
|
|
1172
|
+
if meta_lines:
|
|
1173
|
+
body_parts.append("<h2>Technical Metadata</h2>")
|
|
1174
|
+
meta_items = [re.sub(r'^[-*]\s+', '', ml.strip()) for ml in meta_lines if re.match(r'^[-*]\s+', ml.strip())]
|
|
1175
|
+
if meta_items:
|
|
1176
|
+
body_parts.append("<ul>")
|
|
1177
|
+
for item in meta_items:
|
|
1178
|
+
body_parts.append(f" <li>{format_inline_markdown(item)}</li>")
|
|
1179
|
+
body_parts.append("</ul>")
|
|
1180
|
+
body_parts.append("<hr/>")
|
|
1181
|
+
|
|
511
1182
|
body_parts.append("<h2>Table of Contents</h2>")
|
|
512
1183
|
body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
|
|
513
1184
|
body_parts.append("<hr/>")
|
|
1185
|
+
i = start_idx
|
|
1186
|
+
diagram_count = 0
|
|
1187
|
+
last_heading = "architecture-diagram"
|
|
514
1188
|
|
|
515
|
-
i = 0
|
|
516
1189
|
while i < len(lines):
|
|
517
1190
|
line = lines[i]
|
|
518
1191
|
stripped = line.strip()
|
|
@@ -531,6 +1204,7 @@ def convert_markdown_to_confluence_xhtml(md_text: str, is_hld: bool = True, diag
|
|
|
531
1204
|
if h_match:
|
|
532
1205
|
level = len(h_match.group(1))
|
|
533
1206
|
heading_text = format_inline_markdown(h_match.group(2))
|
|
1207
|
+
last_heading = re.sub(r'[^a-zA-Z0-9]+', '-', h_match.group(2).strip().lower()).strip('-')
|
|
534
1208
|
# Omit redundant Document Information or Table of Contents if re-encountered
|
|
535
1209
|
if "table of contents" in heading_text.lower():
|
|
536
1210
|
i += 1
|
|
@@ -550,13 +1224,39 @@ def convert_markdown_to_confluence_xhtml(md_text: str, is_hld: bool = True, diag
|
|
|
550
1224
|
i += 1
|
|
551
1225
|
|
|
552
1226
|
diag_code = "\n".join(diag_lines)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1227
|
+
diagram_count += 1
|
|
1228
|
+
|
|
1229
|
+
slug = last_heading if last_heading else f"diagram-{diagram_count}"
|
|
1230
|
+
diagram_filename = f"{app_prefix}-{slug}.drawio"
|
|
1231
|
+
display_name = last_heading.replace('-', ' ').title()
|
|
1232
|
+
|
|
1233
|
+
if diagram_type == "drawio":
|
|
1234
|
+
if "sequenceDiagram" in diag_code:
|
|
1235
|
+
drawio_xml, canvas_w = convert_mermaid_sequence_to_drawio(diag_code, display_name)
|
|
1236
|
+
else:
|
|
1237
|
+
drawio_xml, canvas_w = convert_mermaid_flowchart_to_drawio(diag_code, display_name)
|
|
1238
|
+
|
|
1239
|
+
# If diagrams_dir is specified, write standalone .drawio file
|
|
1240
|
+
if diagrams_dir:
|
|
1241
|
+
os.makedirs(diagrams_dir, exist_ok=True)
|
|
1242
|
+
drawio_path = os.path.join(diagrams_dir, diagram_filename)
|
|
1243
|
+
with open(drawio_path, "w", encoding="utf-8") as df:
|
|
1244
|
+
df.write(drawio_xml)
|
|
1245
|
+
print(f" [Draw.io Export] Saved: {drawio_path}")
|
|
1246
|
+
|
|
1247
|
+
body_parts.append(render_drawio_macro(drawio_xml, diagram_filename, display_name, canvas_w))
|
|
556
1248
|
else:
|
|
557
|
-
|
|
1249
|
+
if "sequenceDiagram" in diag_code:
|
|
1250
|
+
puml = convert_mermaid_sequence_to_plantuml(diag_code, is_lineage=is_lineage)
|
|
1251
|
+
elif "classDiagram" in diag_code:
|
|
1252
|
+
puml = convert_mermaid_class_to_plantuml(diag_code)
|
|
1253
|
+
elif "erDiagram" in diag_code:
|
|
1254
|
+
puml = convert_mermaid_er_to_plantuml(diag_code)
|
|
1255
|
+
else:
|
|
1256
|
+
puml = convert_mermaid_flowchart_to_plantuml(diag_code, is_lineage=is_lineage)
|
|
1257
|
+
|
|
1258
|
+
body_parts.append(render_plantuml_macro(puml))
|
|
558
1259
|
|
|
559
|
-
body_parts.append(render_plantuml_macro(puml))
|
|
560
1260
|
continue
|
|
561
1261
|
|
|
562
1262
|
# General Code Blocks
|
|
@@ -675,7 +1375,7 @@ def validate_confluence_xhtml(xhtml_content: str) -> None:
|
|
|
675
1375
|
|
|
676
1376
|
|
|
677
1377
|
def main():
|
|
678
|
-
parser = argparse.ArgumentParser(description="Convert Markdown HLD/LLD to Confluence Storage Format XHTML.")
|
|
1378
|
+
parser = argparse.ArgumentParser(description="Convert Markdown HLD/LLD to Confluence Storage Format XHTML with Draw.io diagrams.")
|
|
679
1379
|
parser.add_argument("input_file", help="Path to input Markdown file")
|
|
680
1380
|
parser.add_argument("--output", "-o", required=True, help="Path to output XHTML file")
|
|
681
1381
|
parser.add_argument("--diagram-type", choices=["plantuml", "drawio"], default="plantuml", help="Diagram engine (default: plantuml)")
|
|
@@ -690,12 +1390,26 @@ def main():
|
|
|
690
1390
|
|
|
691
1391
|
input_filename = os.path.basename(input_path).lower()
|
|
692
1392
|
is_hld = "-hld" in input_filename or "-hla" in input_filename
|
|
1393
|
+
is_lineage = "-data-lineage" in input_filename or "-lineage" in input_filename
|
|
1394
|
+
|
|
1395
|
+
# Derive app prefix (e.g. order-service)
|
|
1396
|
+
app_prefix = input_filename.split("-hld")[0].split("-lld")[0].split("-hla")[0].split("-data-lineage")[0].split("-lineage")[0]
|
|
1397
|
+
|
|
1398
|
+
# Target diagrams directory adjacent to output file
|
|
1399
|
+
diagrams_dir = os.path.join(os.path.dirname(output_path), "diagrams")
|
|
693
1400
|
|
|
694
1401
|
with open(input_path, "r", encoding="utf-8") as f:
|
|
695
1402
|
md_text = f.read()
|
|
696
1403
|
|
|
697
|
-
print(f"Converting {input_path} to Confluence Storage Format XHTML...")
|
|
698
|
-
xhtml_body = convert_markdown_to_confluence_xhtml(
|
|
1404
|
+
print(f"Converting {input_path} to Confluence Storage Format XHTML (Diagram Engine: {args.diagram_type})...")
|
|
1405
|
+
xhtml_body = convert_markdown_to_confluence_xhtml(
|
|
1406
|
+
md_text,
|
|
1407
|
+
is_hld=is_hld,
|
|
1408
|
+
is_lineage=is_lineage,
|
|
1409
|
+
diagram_type=args.diagram_type,
|
|
1410
|
+
diagrams_dir=diagrams_dir,
|
|
1411
|
+
app_prefix=app_prefix
|
|
1412
|
+
)
|
|
699
1413
|
|
|
700
1414
|
try:
|
|
701
1415
|
validate_confluence_xhtml(xhtml_body)
|
|
@@ -708,6 +1422,8 @@ def main():
|
|
|
708
1422
|
f.write(xhtml_body)
|
|
709
1423
|
|
|
710
1424
|
print(f"Confluence XHTML successfully written to: {output_path}")
|
|
1425
|
+
if args.diagram_type == "drawio":
|
|
1426
|
+
print(f"Standalone .drawio files generated in: {diagrams_dir}")
|
|
711
1427
|
|
|
712
1428
|
|
|
713
1429
|
if __name__ == "__main__":
|