@caddis/cli 0.4.19 → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/_registry.md +4 -2
  3. package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
  4. package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
  5. package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
  6. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
  7. package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
  8. package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
  9. package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
  10. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  11. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  12. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  13. package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
  14. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  15. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  16. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  17. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  18. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  19. package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
  20. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
  21. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
  22. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
  23. package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +428 -0
  24. package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
  25. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
  26. package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
  27. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
  28. package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
  29. package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
  30. package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
  31. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  32. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  33. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  34. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
  35. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  36. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  37. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  38. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  39. package/bundles/manifest.json +3 -3
  40. 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 inline PlantUML macros (<ac:structured-macro ac:name="plantuml"/>) with 'skinparam linetype ortho'
10
- so that diagrams render natively upon copy-paste into Confluence with zero attachment access errors.
11
- 5. Supports optional Draw.io XML generation via --diagram-type drawio.
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|drawio]
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 slop buzzwords and purple prose
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
- """Safely escape XML characters and convert inline markdown (bold, italic, code)."""
105
- text = text.replace("—", " - ").replace("–", "-")
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
- # Escape XML characters
110
- text = re.sub(r"&(?!(amp|lt|gt|quot|apos);)", "&amp;", text)
111
- text = text.replace("<", "&lt;").replace(">", "&gt;")
107
+ def replace_code(m):
108
+ code_blocks.append(m.group(1))
109
+ return f"__INLINE_CODE_{len(code_blocks)-1}__"
112
110
 
113
- # Inline code
114
- code_spans = []
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 **bold** -> <strong>bold</strong>
125
- text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
117
+ # Bold: **text** -> <strong>text</strong>
118
+ text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
126
119
 
127
- # Italic *italic* -> <em>italic</em>
128
- text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
120
+ # Italic: *text* -> <em>text</em>
121
+ text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<em>\1</em>', text)
129
122
 
130
- for idx, span in enumerate(code_spans):
131
- text = text.replace(f"__INLINE_CODE_SPAN_{idx}__", span)
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(md_table_lines: List[str]) -> str:
137
- """Convert Markdown table to wrapped Confluence XHTML table matching original styling."""
138
- if len(md_table_lines) < 2:
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
- header_cols = [c.strip() for c in md_table_lines[0].split("|")[1:-1]]
142
- data_rows = []
143
- for line in md_table_lines[2:]:
144
- if "|" in line:
145
- cols = [c.strip() for c in line.split("|")[1:-1]]
146
- if any(cols):
147
- data_rows.append(cols)
148
-
149
- col_count = len(header_cols)
150
- out = ['<table class="wrapped">', f'<colgroup>{"<col/>" * col_count}</colgroup>', '<thead>', '<tr>']
151
- for h in header_cols:
152
- formatted_h = format_inline_markdown(h)
153
- out.append(f' <th><p>{formatted_h}</p></th>')
154
- out.append('</tr>')
155
- out.append('</thead>')
156
- out.append('<tbody>')
157
- for r in data_rows:
158
- out.append('<tr>')
159
- for c in r:
160
- formatted_c = format_inline_markdown(c)
161
- out.append(f' <td><p>{formatted_c}</p></td>')
162
- out.append('</tr>')
163
- out.append('</tbody>')
164
- out.append('</table>')
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
- # PlantUML Generator (Strictly Orthogonal & Native Inline Confluence Macro)
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/>", "&#xa;").replace("<br>", "&#xa;").replace('"', '&quot;')
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/>", "&#xa;").replace("<br>", "&#xa;").replace('"', '&quot;')
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/>", "&#xa;").replace("<br>", "&#xa;").replace('"', '&quot;') 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/>", "&#xa;").replace("<br>", "&#xa;").replace('"', '&quot;')
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
- def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
173
- """Parse Mermaid flowchart / graph into PlantUML with strict orthogonal routing."""
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_]+)(?:\["([^"]+)"\]|\[([^\]]+)\]|\{"([^"]+)"\}|\{([^\}]+)\}|\("([^"]+)"\)|\(([^\)]+)\))(?:::([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 not line or line.startswith("%%") or line.startswith("classDef") or line.startswith("style "):
559
+ if line.startswith("flowchart") or line.startswith("graph") or line.startswith("classDef") or line.startswith("style ") or line.startswith("%%"):
189
560
  continue
190
- if line.lower().startswith("flowchart") or line.lower().startswith("graph"):
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 match in node_def_re.finditer(line):
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:7] if g is not None)
225
- n_cls = match.group(8) or ""
226
- node_info = {"id": n_id, "label": n_lbl, "class": n_cls}
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', line)
233
- edge_tokens = re.split(r'(\s*(?:<-->|-->|---|->)\s*(?:\|"[^"]*"\|\s*|\|[^\|]*\|\s*)?)', clean_edge_line)
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 get_node_type(node_id: str, label: str, cls: str) -> Tuple[str, str]:
251
- id_low = node_id.lower()
252
- lbl_low = label.lower()
253
- cls_low = cls.lower()
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
- if any(k in cls_low for k in ["src", "med"]) or any(k in id_low for k in ["jsc", "src", "raw"]):
256
- return "database", "source_db"
257
- 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"]):
258
- return "rectangle", "service"
259
- 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"]):
260
- return "database", "staging_db"
261
- if any(k in cls_low for k in ["ui", "app"]) or any(k in id_low for k in ["ui", "api", "dash", "react"]):
262
- return "rectangle", "user_ui"
263
- if any(k in cls_low for k in ["ext"]) or any(k in id_low for k in ["feed", "gw", "bss", "tap", "apigee"]):
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
- return "rectangle", "service"
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 #546E7A")
703
+ out.append(" BorderColor #475569")
704
+ out.append(" BorderThickness 1.5")
280
705
  out.append(" BackgroundColor #F8FAFC")
281
- out.append(" FontColor #263238")
706
+ out.append(" FontColor #0F172A")
282
707
  out.append(" FontStyle bold")
283
- out.append("}\n")
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
- out.append("skinparam rectangle<<user_ui>> {")
296
- out.append(" BorderColor #E65100")
297
- out.append(" BackgroundColor #FFF3E0")
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 queue<<gateway>> {")
306
- out.append(" BorderColor #455A64")
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, stereotype = get_node_type(n_id, n_info["label"], n_info["class"])
324
- stereo_suffix = f" <<{stereotype}>>" if stereotype else ""
325
- res.append(f'{indent} {elem_type} "{formatted_lbl}" as {n_id}{stereo_suffix}')
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
- lbl_clean = e["label"].replace("<br/>", "\\n").replace("<br>", "\\n").replace('"', "'") if e["label"] else ""
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, stereotype = get_node_type(n_id, n_info["label"], n_info["class"])
343
- stereo_suffix = f" <<{stereotype}>>" if stereotype else ""
344
- out.append(f'{elem_type} "{formatted_lbl}" as {n_id}{stereo_suffix}')
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
- out.append(f'{subgraphs[i]["id"]} -[hidden]down- {subgraphs[i+1]["id"]}')
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
- lbl_clean = e["label"].replace("<br/>", "\\n").replace("<br>", "\\n").replace('"', "'") if e["label"] else ""
355
- lbl_suffix = f' : {lbl_clean}' if lbl_clean else ""
356
- out.append(f'{e["src"]} -down-> {e["dst"]}{lbl_suffix}')
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
- lines = [l.strip() for l in code.splitlines() if l.strip()]
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'<p><ac:structured-macro ac:name="plantuml" ac:schema-version="1">\n'
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></p>'
1085
+ f'</ac:structured-macro>\n'
1086
+ f'</div>'
452
1087
  )
453
1088
 
454
1089
 
455
- # ---------------------------------------------------------------------------
456
- # Confluence Document Layout Assembler
457
- # ---------------------------------------------------------------------------
458
-
459
- def convert_markdown_to_confluence_xhtml(md_text: str, is_hld: bool = True, diagram_type: str = "plantuml") -> str:
460
- """Convert Markdown document to native Confluence Storage Format XHTML."""
461
- # 1. Clean STE, dashes, and buzzwords
462
- clean_text = sanitize_ste_and_anti_slop(md_text)
463
-
464
- # 2. Extract Document Title
465
- title_match = re.search(r"^#\s+(.+)$", clean_text, re.MULTILINE)
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
- # 3. Strip AI traces, frontmatter, and metadata lists
475
- content = strip_ai_traces_and_metadata(clean_text, is_hld=is_hld)
1102
+ lines = cleaned_md.splitlines()
1103
+ body_parts = []
476
1104
 
477
- # Strip ## Document Information if present in markdown since we emit standard top block
478
- if is_hld:
479
- content = re.sub(
480
- r'##\s*Document Information.*?(?=\n##\s+\d|\Z)',
481
- '',
482
- content,
483
- flags=re.DOTALL
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
- # Strip the top # Title line from content since we emit standard Confluence top header
487
- content = re.sub(r"^#\s+.*?\n", "", content, count=1).strip()
1113
+ app_name = title
1114
+ safe_app_name = html.escape(app_name)
488
1115
 
489
- lines = content.splitlines()
490
- body_parts = []
1116
+ # Build Header Block
1117
+ if is_lineage:
1118
+ body_parts.append(f"<h1>Data Lineage &amp; 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
- # Emit standard Confluence Top Section
493
- if is_hld:
494
- body_parts.append(f"<h1>High-Level Architecture (HLA): {app_name}</h1>")
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: {app_name}</h1>")
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
- if "sequenceDiagram" in diag_code:
555
- puml = convert_mermaid_sequence_to_plantuml(diag_code)
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
- puml = convert_mermaid_flowchart_to_plantuml(diag_code)
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(md_text, is_hld=is_hld, diagram_type=args.diagram_type)
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__":