@caddis/cli 0.4.52 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -1,1430 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Convert Markdown Architecture Documents (HLD / LLD) to Confluence Storage Format XHTML.
4
-
5
- Key Principles:
6
- 1. Strips AI slop, removes frontmatter/metadata notices, and enforces ASD-STE100.
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 (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'.
13
- 6. Enforces responsive, wrapped Confluence tables (<table class="wrapped"><colgroup>...).
14
- 7. Validates that output is 100% well-formed XML.
15
-
16
- Usage:
17
- python scripts/convert_doc_to_confluence.py <input.md> --output <output.html> [--diagram-type drawio|plantuml]
18
- """
19
-
20
- import argparse
21
- import html
22
- import os
23
- import re
24
- import sys
25
- import uuid
26
- import xml.etree.ElementTree as ET
27
- from typing import Any, Dict, List, Optional, Tuple
28
-
29
-
30
- def sanitize_ste_and_anti_slop(content: str) -> str:
31
- """Enforce ASD-STE100 principles, clean ASCII punctuation, and eliminate AI buzzwords."""
32
- # 1. Normalize em-dashes and en-dashes
33
- content = re.sub(r'^(#+\s+[^—\n]+?)\s*[—–]\s*(.+)$', r'\1: \2', content, flags=re.MULTILINE)
34
- content = re.sub(r'\*\*([^*—–\n]+?)\s*[—–]\s*([^*—–\n]+?)\*\*', r'**\1 (\2)**', content)
35
- content = content.replace("—", " - ").replace("–", "-")
36
- content = re.sub(r'[ \t]+-[ \t]+', ' - ', content)
37
-
38
- # 2. Smart quotes to straight ASCII
39
- content = content.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
40
-
41
- # 3. Unicode bullet characters to ASCII hyphen
42
- content = content.replace("•", "-")
43
-
44
- # 4. Filter AI buzzwords and purple prose
45
- buzzwords = [
46
- (r'\bdelve into\b', 'inspect'),
47
- (r'\bdelve\b', 'investigate'),
48
- (r'\bpivotal\b', 'primary'),
49
- (r'\btestament to\b', 'evidence of'),
50
- (r'\btapestry\b', 'architecture'),
51
- (r'\bbeacon of\b', 'model for'),
52
- (r'\bgame-changer\b', 'significant improvement'),
53
- (r'\brevolutionize\b', 'transform'),
54
- (r'\bcornerstone of\b', 'core element of'),
55
- (r'\bseamlessly\b', 'directly'),
56
- (r'\bseamless\b', 'direct'),
57
- (r'\bholistic\b', 'end-to-end'),
58
- (r'\bfoster\b', 'support'),
59
- (r'\bharnessing\b', 'using'),
60
- (r'\bleverage\b', 'use'),
61
- (r'\bleveraging\b', 'using'),
62
- (r'\bleverages\b', 'uses'),
63
- (r'\bleveraged\b', 'used'),
64
- (r'\bmeticulous\b', 'detailed'),
65
- (r'\bcrucial\b', 'critical'),
66
- (r'\bcutting-edge\b', 'modern'),
67
- (r'\brobust\b', 'reliable'),
68
- ]
69
- for pattern, repl in buzzwords:
70
- content = re.sub(pattern, repl, content, flags=re.IGNORECASE)
71
-
72
- return content
73
-
74
-
75
- def strip_ai_traces_and_metadata(content: str, is_hld: bool = True) -> str:
76
- """Remove frontmatter, AI tool mentions, and metadata blocks for Confluence HTML."""
77
- # 1. Strip YAML frontmatter
78
- if content.startswith("---"):
79
- parts = content.split("---", 2)
80
- if len(parts) >= 3:
81
- content = parts[2].strip()
82
-
83
- # 2. Strip Executive Metadata / Technical Metadata if HLD
84
- if is_hld:
85
- content = re.sub(r'##\s*(?:Executive\s+Metadata|Technical\s+Metadata).*?(?=(^##|\A|\Z|<hr/>|---))', '', content, flags=re.DOTALL | re.MULTILINE)
86
-
87
- # 3. Strip any lines referencing AI models, caddis, antigravity, claude
88
- banned_keywords = [
89
- "caddis", "antigravity", "creating model", "creation-agent",
90
- "last author", "last updated", "claude code", "codex", "gemini-2.5-pro",
91
- "gemini-3.5-flash", "gemini-3.8-flash", "simplified technical english", "asd-ste100"
92
- ]
93
- lines = []
94
- for line in content.splitlines():
95
- lower = line.lower()
96
- if any(b in lower for b in banned_keywords) and not ("gemini" in lower and "prompt" in lower):
97
- continue
98
- lines.append(line)
99
-
100
- return "\n".join(lines).strip()
101
-
102
-
103
- def format_inline_markdown(text: str) -> str:
104
- """Convert Markdown inline formatting (bold, code, links) to safe Confluence XHTML."""
105
- code_blocks: List[str] = []
106
-
107
- def replace_code(m):
108
- code_blocks.append(m.group(1))
109
- return f"__INLINE_CODE_{len(code_blocks)-1}__"
110
-
111
- text = re.sub(r'`([^`]+)`', replace_code, text)
112
- text = html.escape(text)
113
-
114
- # Links: [text](url) -> <a href="url">text</a>
115
- text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
116
-
117
- # Bold: **text** -> <strong>text</strong>
118
- text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
119
-
120
- # Italic: *text* -> <em>text</em>
121
- text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<em>\1</em>', text)
122
-
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>")
127
-
128
- return text
129
-
130
-
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:
144
- return ""
145
-
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
-
175
- return "\n".join(out)
176
-
177
-
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
- )
537
-
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)
543
- lines = [l.strip() for l in code.splitlines() if l.strip()]
544
-
545
- subgraphs = []
546
- sg_stack = []
547
- top_nodes = {}
548
- top_connections = []
549
-
550
- node_def_re = re.compile(
551
- r'([A-Za-z0-9_]+)\s*(?:\[\["?(.*?)"?\]\]|\(\("?(.*?)"?\)\)|\[\("?(.*?)"?\)\]|\[\/"?(.*?)"?\/\]|\[\\"?(.*?)"?\\\]|\["([^"]*)"\]|\[([^\]]*)\])'
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 = {}
557
-
558
- for line in lines:
559
- if line.startswith("flowchart") or line.startswith("graph") or line.startswith("classDef") or line.startswith("style ") or line.startswith("%%"):
560
- continue
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
568
- continue
569
-
570
- sg_match = subgraph_start_re.match(line)
571
- if sg_match:
572
- sg_id = sg_match.group(1)
573
- sg_title = sg_match.group(2) or sg_match.group(3) or sg_id
574
- new_sg = {
575
- "id": sg_id,
576
- "title": sg_title,
577
- "direction": "TB",
578
- "nodes": {},
579
- "edges": [],
580
- "children": []
581
- }
582
- if sg_stack:
583
- sg_stack[-1]["children"].append(new_sg)
584
- else:
585
- subgraphs.append(new_sg)
586
- sg_stack.append(new_sg)
587
- continue
588
-
589
- if line.lower() == "end":
590
- if sg_stack:
591
- sg_stack.pop()
592
- continue
593
-
594
- dir_match = direction_re.match(line)
595
- if dir_match and sg_stack:
596
- sg_stack[-1]["direction"] = dir_match.group(1).upper()
597
- continue
598
-
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):
613
- n_id = match.group(1)
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, "")}
618
- if sg_stack:
619
- sg_stack[-1]["nodes"][n_id] = node_info
620
- else:
621
- top_nodes[n_id] = node_info
622
-
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)
627
- if len(edge_tokens) >= 3:
628
- for i in range(0, len(edge_tokens) - 2, 2):
629
- src = edge_tokens[i].strip()
630
- arrow_token = edge_tokens[i+1].strip()
631
- dst = edge_tokens[i+2].strip()
632
-
633
- lbl_match = re.search(r'\|"?([^"\|]*)"?\|', arrow_token)
634
- lbl = lbl_match.group(1).strip() if lbl_match else ""
635
- dashed = "-.-" in arrow_token or ".-" in arrow_token
636
-
637
- if src and dst and re.match(r'^[A-Za-z0-9_]+$', src) and re.match(r'^[A-Za-z0-9_]+$', dst):
638
- edge_obj = {"src": src, "dst": dst, "label": lbl, "dashed": dashed}
639
- if sg_stack:
640
- sg_stack[-1]["edges"].append(edge_obj)
641
- else:
642
- top_connections.append(edge_obj)
643
-
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"
676
-
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
686
-
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))
689
-
690
- out = []
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")
698
- out.append("skinparam shadowing false")
699
- out.append('skinparam defaultFontName "Segoe UI"')
700
- out.append("skinparam roundCorner 6")
701
-
702
- out.append("skinparam package {")
703
- out.append(" BorderColor #475569")
704
- out.append(" BorderThickness 1.5")
705
- out.append(" BackgroundColor #F8FAFC")
706
- out.append(" FontColor #0F172A")
707
- out.append(" FontStyle bold")
708
- out.append(" Padding 10")
709
- out.append("}")
710
-
711
- out.append("skinparam rectangle {")
712
- out.append(" BorderThickness 1.5")
713
- out.append("}")
714
- out.append("skinparam database {")
715
- out.append(" BorderThickness 1.5")
716
- out.append("}\n")
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
-
831
- def render_subgraph(sg, indent=""):
832
- res = [f'{indent}package "<b>{sg["title"]}</b>" as {sg["id"]} {{']
833
- for child in sg.get("children", []):
834
- res.extend(render_subgraph(child, indent + " "))
835
- for n_id, n_info in sg["nodes"].items():
836
- formatted_lbl = (
837
- clean_node_label(n_info["label"])
838
- .replace("<br/>", "\\n")
839
- .replace("<br>", "\\n")
840
- .replace("•", "-")
841
- .replace('"', "'")
842
- )
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("")
854
-
855
- if sg["edges"]:
856
- res.append("")
857
- for e in sg["edges"]:
858
- arrow, lbl_suffix = format_edge_arrow(e, sg_dir)
859
- res.append(f'{indent} {e["src"]} {arrow} {e["dst"]}{lbl_suffix}')
860
- res.append(f"{indent}}}\n")
861
- return res
862
-
863
- for sg in subgraphs:
864
- out.extend(render_subgraph(sg))
865
-
866
- for n_id, n_info in top_nodes.items():
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}')
871
- if top_nodes:
872
- out.append("")
873
-
874
- for i in range(len(subgraphs) - 1):
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"]}')
879
- if subgraphs:
880
- out.append("")
881
-
882
- for e in top_connections:
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")
894
-
895
- out.append("@enduml")
896
- return "\n".join(out)
897
-
898
-
899
- def convert_mermaid_sequence_to_plantuml(code: str, is_lineage: bool = False) -> str:
900
- """Parse Mermaid sequenceDiagram into PlantUML sequence diagram with orthogonal lines."""
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("%%")]
903
-
904
- out = []
905
- out.append("@startuml")
906
- out.append("autonumber")
907
- out.append("scale max 2000 width")
908
- out.append("skinparam defaultFontSize 11")
909
- out.append("skinparam shadowing false")
910
- out.append('skinparam defaultFontName "Segoe UI"')
911
- out.append("skinparam roundCorner 6\n")
912
-
913
- out.append("skinparam actor {")
914
- out.append(" BorderColor #3949AB")
915
- out.append(" BackgroundColor #E8EAF6")
916
- out.append(" FontColor #1A237E")
917
- out.append("}")
918
- out.append("skinparam participant {")
919
- out.append(" BorderColor #0277BD")
920
- out.append(" BackgroundColor #E1F5FE")
921
- out.append(" FontColor #01579B")
922
- out.append("}")
923
- out.append("skinparam database {")
924
- out.append(" BorderColor #2E7D32")
925
- out.append(" BackgroundColor #E8F5E9")
926
- out.append(" FontColor #1B5E20")
927
- out.append("}")
928
- out.append("skinparam sequence {")
929
- out.append(" ArrowColor #1E88E5")
930
- out.append(" LifeLineBorderColor #90A4AE")
931
- out.append(" LifeLineBackgroundColor #ECEFF1")
932
- out.append(" DividerBorderColor #B0BEC5")
933
- out.append(" DividerBackgroundColor #ECEFF1")
934
- out.append(" DividerFontColor #263238")
935
- out.append("}\n")
936
-
937
- part_re = re.compile(r'^(?:participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?', re.IGNORECASE)
938
- msg_re = re.compile(r'^([A-Za-z0-9_]+)\s*(->>|-->>|->|-->)\s*([A-Za-z0-9_]+)\s*:\s*(.+)$')
939
- note_re = re.compile(r'^Note\s+(?:over|left of|right of)\s+([A-Za-z0-9_,\s]+)\s*:\s*(.+)$', re.IGNORECASE)
940
-
941
- declared_parts = set()
942
-
943
- for line in lines:
944
- if line.startswith("sequenceDiagram") or line.startswith("autonumber"):
945
- continue
946
-
947
- p_match = part_re.match(line)
948
- if p_match:
949
- pid = p_match.group(1)
950
- pname = (p_match.group(2) or pid).replace('"', '')
951
- is_actor = line.lower().startswith("actor ") and "scheduler" not in pid.lower() and "task" not in pid.lower()
952
- elem_kind = "actor" if is_actor else ("database" if "db" in pid.lower() else "participant")
953
- out.append(f'{elem_kind} "{pname}" as {pid}')
954
- declared_parts.add(pid)
955
- continue
956
-
957
- n_match = note_re.match(line)
958
- if n_match:
959
- target = n_match.group(1).strip()
960
- text = n_match.group(2).strip()
961
- out.append(f'== {text} ==')
962
- continue
963
-
964
- m_match = msg_re.match(line)
965
- if m_match:
966
- src = m_match.group(1)
967
- arrow = m_match.group(2)
968
- dst = m_match.group(3)
969
- txt = m_match.group(4).replace('"', '')
970
-
971
- for p in [src, dst]:
972
- if p not in declared_parts:
973
- elem_kind = "database" if "db" in p.lower() else "participant"
974
- out.append(f'{elem_kind} "{p}" as {p}')
975
- declared_parts.add(p)
976
-
977
- puml_arrow = "-->" if ("-->>" in arrow or "-->" in arrow) else "->"
978
- out.append(f'{src} {puml_arrow} {dst} : {txt}')
979
-
980
- out.append("@enduml")
981
- return "\n".join(out)
982
-
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
-
1078
- def render_plantuml_macro(puml_code: str) -> str:
1079
- """Wrap PlantUML code inside native Confluence <ac:structured-macro ac:name="plantuml"> with centered alignment."""
1080
- return (
1081
- f'<div style="text-align:center;">\n'
1082
- f'<ac:structured-macro ac:name="plantuml" ac:schema-version="1">\n'
1083
- f'<ac:parameter ac:name="atlassian-macro-output-type">INLINE</ac:parameter>\n'
1084
- f'<ac:plain-text-body><![CDATA[{puml_code}]]></ac:plain-text-body>\n'
1085
- f'</ac:structured-macro>\n'
1086
- f'</div>'
1087
- )
1088
-
1089
-
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)
1101
-
1102
- lines = cleaned_md.splitlines()
1103
- body_parts = []
1104
-
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
1112
-
1113
- app_name = title
1114
- safe_app_name = html.escape(app_name)
1115
-
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/>")
1123
-
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>")
1133
- body_parts.append("<hr/>")
1134
- body_parts.append("<h2>Document Information</h2>")
1135
- body_parts.append("<h3>Version History</h3>")
1136
- body_parts.append(
1137
- '<table class="wrapped"><colgroup><col/><col/><col/><col/></colgroup>'
1138
- '<thead><tr><th><p>Version</p></th><th><p>Date</p></th><th><p>Author</p></th><th><p>Description</p></th></tr></thead>'
1139
- '<tbody><tr><td><p>1.0</p></td><td><p>2026-09-17</p></td><td><p>Solutions Architecture</p></td><td><p>Initial High-Level Architecture Draft</p></td></tr></tbody>'
1140
- '</table>'
1141
- )
1142
- body_parts.append("<hr/>")
1143
- body_parts.append("<h2>Table of Contents</h2>")
1144
- body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
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
1154
- else:
1155
- body_parts.append(f"<h1>Lower-Level Design: {safe_app_name}</h1>")
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
-
1182
- body_parts.append("<h2>Table of Contents</h2>")
1183
- body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
1184
- body_parts.append("<hr/>")
1185
- i = start_idx
1186
- diagram_count = 0
1187
- last_heading = "architecture-diagram"
1188
-
1189
- while i < len(lines):
1190
- line = lines[i]
1191
- stripped = line.strip()
1192
-
1193
- if not stripped:
1194
- i += 1
1195
- continue
1196
-
1197
- if stripped in ("---", "***", "___"):
1198
- body_parts.append("<hr/>")
1199
- i += 1
1200
- continue
1201
-
1202
- # Headings
1203
- h_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
1204
- if h_match:
1205
- level = len(h_match.group(1))
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('-')
1208
- # Omit redundant Document Information or Table of Contents if re-encountered
1209
- if "table of contents" in heading_text.lower():
1210
- i += 1
1211
- continue
1212
- body_parts.append(f"<h{level}>{heading_text}</h{level}>")
1213
- i += 1
1214
- continue
1215
-
1216
- # Mermaid Diagram Blocks
1217
- if stripped.startswith("```mermaid"):
1218
- diag_lines = []
1219
- i += 1
1220
- while i < len(lines) and not lines[i].strip().startswith("```"):
1221
- diag_lines.append(lines[i])
1222
- i += 1
1223
- if i < len(lines):
1224
- i += 1
1225
-
1226
- diag_code = "\n".join(diag_lines)
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))
1248
- else:
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))
1259
-
1260
- continue
1261
-
1262
- # General Code Blocks
1263
- if stripped.startswith("```"):
1264
- lang = stripped[3:].strip() or "text"
1265
- code_lines = []
1266
- i += 1
1267
- while i < len(lines) and not lines[i].strip().startswith("```"):
1268
- code_lines.append(lines[i])
1269
- i += 1
1270
- if i < len(lines):
1271
- i += 1
1272
-
1273
- code_text = html.escape("\n".join(code_lines))
1274
- code_macro_id = str(uuid.uuid4())
1275
- body_parts.append(
1276
- f'<ac:structured-macro ac:macro-id="{code_macro_id}" ac:name="code" ac:schema-version="1">\n'
1277
- f' <ac:parameter ac:name="language">{lang}</ac:parameter>\n'
1278
- f' <ac:plain-text-body><![CDATA[{code_text}]]></ac:plain-text-body>\n'
1279
- f'</ac:structured-macro>'
1280
- )
1281
- continue
1282
-
1283
- # Tables
1284
- if stripped.startswith("|"):
1285
- table_lines = []
1286
- while i < len(lines) and lines[i].strip().startswith("|"):
1287
- table_lines.append(lines[i].strip())
1288
- i += 1
1289
- body_parts.append(convert_table(table_lines))
1290
- continue
1291
-
1292
- # Alerts (> [!NOTE], > [!IMPORTANT], > [!WARNING], > [!TIP])
1293
- if stripped.startswith("> [!"):
1294
- alert_match = re.match(r"^>\s*\[!(NOTE|IMPORTANT|WARNING|TIP|CAUTION)\]\s*(.*)$", stripped, re.IGNORECASE)
1295
- if alert_match:
1296
- alert_type = alert_match.group(1).lower()
1297
- first_text = alert_match.group(2)
1298
- alert_lines = [first_text] if first_text else []
1299
- i += 1
1300
- while i < len(lines) and lines[i].strip().startswith(">"):
1301
- sub_line = re.sub(r"^>\s*", "", lines[i].strip())
1302
- alert_lines.append(sub_line)
1303
- i += 1
1304
-
1305
- macro_name = "info" if alert_type in ("note", "tip") else "warning"
1306
- macro_id = str(uuid.uuid4())
1307
- alert_body = "<br/>".join([format_inline_markdown(al) for al in alert_lines if al])
1308
- body_parts.append(
1309
- f'<ac:structured-macro ac:macro-id="{macro_id}" ac:name="{macro_name}" ac:schema-version="1">\n'
1310
- f' <ac:rich-text-body><p>{alert_body}</p></ac:rich-text-body>\n'
1311
- f'</ac:structured-macro>'
1312
- )
1313
- continue
1314
-
1315
- # Unordered Lists
1316
- if re.match(r"^[-*]\s+", stripped):
1317
- list_items = []
1318
- while i < len(lines) and re.match(r"^[-*]\s+", lines[i].strip()):
1319
- item_text = re.sub(r"^[-*]\s+", "", lines[i].strip())
1320
- list_items.append(format_inline_markdown(item_text))
1321
- i += 1
1322
-
1323
- out_list = ["<ul>"]
1324
- for li in list_items:
1325
- out_list.append(f" <li>{li}</li>")
1326
- out_list.append("</ul>")
1327
- body_parts.append("\n".join(out_list))
1328
- continue
1329
-
1330
- # Ordered Lists
1331
- if re.match(r"^\d+\.\s+", stripped):
1332
- list_items = []
1333
- while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
1334
- item_text = re.sub(r"^\d+\.\s+", "", lines[i].strip())
1335
- list_items.append(format_inline_markdown(item_text))
1336
- i += 1
1337
-
1338
- out_list = ["<ol>"]
1339
- for li in list_items:
1340
- out_list.append(f" <li>{li}</li>")
1341
- out_list.append("</ol>")
1342
- body_parts.append("\n".join(out_list))
1343
- continue
1344
-
1345
- # Paragraphs
1346
- para_lines = [stripped]
1347
- i += 1
1348
- while i < len(lines):
1349
- next_line = lines[i].strip()
1350
- if not next_line or next_line.startswith("#") or next_line.startswith("```") or next_line.startswith("|") or next_line.startswith(">") or re.match(r"^[-*]\s+", next_line) or re.match(r"^\d+\.\s+", next_line):
1351
- break
1352
- para_lines.append(next_line)
1353
- i += 1
1354
-
1355
- p_text = format_inline_markdown(" ".join(para_lines))
1356
- body_parts.append(f"<p>{p_text}</p>")
1357
-
1358
- return "\n".join(body_parts)
1359
-
1360
-
1361
- def validate_confluence_xhtml(xhtml_content: str) -> None:
1362
- """Validate that Confluence XHTML content is strictly well-formed XML."""
1363
- test_wrapper = (
1364
- '<?xml version="1.0" encoding="UTF-8"?>\n'
1365
- '<root xmlns:ac="http://atlassian.com/confluence/ac"'
1366
- ' xmlns:ri="http://atlassian.com/confluence/ri">\n'
1367
- f'{xhtml_content}\n'
1368
- '</root>'
1369
- )
1370
- try:
1371
- ET.fromstring(test_wrapper)
1372
- except ET.ParseError as e:
1373
- print(f"XHTML validation error: {e}", file=sys.stderr)
1374
- raise
1375
-
1376
-
1377
- def main():
1378
- parser = argparse.ArgumentParser(description="Convert Markdown HLD/LLD to Confluence Storage Format XHTML with Draw.io diagrams.")
1379
- parser.add_argument("input_file", help="Path to input Markdown file")
1380
- parser.add_argument("--output", "-o", required=True, help="Path to output XHTML file")
1381
- parser.add_argument("--diagram-type", choices=["plantuml", "drawio"], default="plantuml", help="Diagram engine (default: plantuml)")
1382
- args = parser.parse_args()
1383
-
1384
- input_path = os.path.abspath(args.input_file)
1385
- output_path = os.path.abspath(args.output)
1386
-
1387
- if not os.path.isfile(input_path):
1388
- print(f"Error: input file {input_path} not found.", file=sys.stderr)
1389
- sys.exit(1)
1390
-
1391
- input_filename = os.path.basename(input_path).lower()
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")
1400
-
1401
- with open(input_path, "r", encoding="utf-8") as f:
1402
- md_text = f.read()
1403
-
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
- )
1413
-
1414
- try:
1415
- validate_confluence_xhtml(xhtml_body)
1416
- print("XHTML validation successful: Content is well-formed XML.")
1417
- except Exception as e:
1418
- print(f"Warning: XML validation failed ({e}), review generated output.", file=sys.stderr)
1419
-
1420
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
1421
- with open(output_path, "w", encoding="utf-8") as f:
1422
- f.write(xhtml_body)
1423
-
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}")
1427
-
1428
-
1429
- if __name__ == "__main__":
1430
- main()