@caddis/cli 0.4.18 → 0.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +530 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +8 -0
- package/bundles/antigravity-plugin/skills/_registry.md +3 -0
- package/bundles/antigravity-plugin/skills/hld/SKILL.md +27 -0
- package/bundles/antigravity-plugin/skills/lld/SKILL.md +30 -0
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +87 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +2 -0
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +100 -0
- package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +714 -0
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +94 -0
- package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +714 -0
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,714 @@
|
|
|
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.
|
|
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.
|
|
12
|
+
6. Enforces responsive, wrapped Confluence tables (<table class="wrapped"><colgroup>...).
|
|
13
|
+
7. Validates that output is 100% well-formed XML.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python scripts/convert_doc_to_confluence.py <input.md> --output <output.html> [--diagram-type plantuml|drawio]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import html
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import uuid
|
|
25
|
+
import xml.etree.ElementTree as ET
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sanitize_ste_and_anti_slop(content: str) -> str:
|
|
30
|
+
"""Enforce ASD-STE100 principles, clean ASCII punctuation, and eliminate AI buzzwords."""
|
|
31
|
+
# 1. Normalize em-dashes and en-dashes
|
|
32
|
+
content = re.sub(r'^(#+\s+[^—\n]+?)\s*[—–]\s*(.+)$', r'\1: \2', content, flags=re.MULTILINE)
|
|
33
|
+
content = re.sub(r'\*\*([^*—–\n]+?)\s*[—–]\s*([^*—–\n]+?)\*\*', r'**\1 (\2)**', content)
|
|
34
|
+
content = content.replace("—", " - ").replace("–", "-")
|
|
35
|
+
content = re.sub(r'[ \t]+-[ \t]+', ' - ', content)
|
|
36
|
+
|
|
37
|
+
# 2. Smart quotes to straight ASCII
|
|
38
|
+
content = content.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
|
|
39
|
+
|
|
40
|
+
# 3. Unicode bullet characters to ASCII hyphen
|
|
41
|
+
content = content.replace("•", "-")
|
|
42
|
+
|
|
43
|
+
# 4. Filter AI slop buzzwords and purple prose
|
|
44
|
+
buzzwords = [
|
|
45
|
+
(r'\bdelve into\b', 'inspect'),
|
|
46
|
+
(r'\bdelve\b', 'investigate'),
|
|
47
|
+
(r'\bpivotal\b', 'primary'),
|
|
48
|
+
(r'\btestament to\b', 'evidence of'),
|
|
49
|
+
(r'\btapestry\b', 'architecture'),
|
|
50
|
+
(r'\bbeacon of\b', 'model for'),
|
|
51
|
+
(r'\bgame-changer\b', 'significant improvement'),
|
|
52
|
+
(r'\brevolutionize\b', 'transform'),
|
|
53
|
+
(r'\bcornerstone of\b', 'core element of'),
|
|
54
|
+
(r'\bseamlessly\b', 'directly'),
|
|
55
|
+
(r'\bseamless\b', 'direct'),
|
|
56
|
+
(r'\bholistic\b', 'end-to-end'),
|
|
57
|
+
(r'\bfoster\b', 'support'),
|
|
58
|
+
(r'\bharnessing\b', 'using'),
|
|
59
|
+
(r'\bleverage\b', 'use'),
|
|
60
|
+
(r'\bleveraging\b', 'using'),
|
|
61
|
+
(r'\bleverages\b', 'uses'),
|
|
62
|
+
(r'\bleveraged\b', 'used'),
|
|
63
|
+
(r'\bmeticulous\b', 'detailed'),
|
|
64
|
+
(r'\bcrucial\b', 'critical'),
|
|
65
|
+
(r'\bcutting-edge\b', 'modern'),
|
|
66
|
+
(r'\brobust\b', 'reliable'),
|
|
67
|
+
]
|
|
68
|
+
for pattern, repl in buzzwords:
|
|
69
|
+
content = re.sub(pattern, repl, content, flags=re.IGNORECASE)
|
|
70
|
+
|
|
71
|
+
return content
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def strip_ai_traces_and_metadata(content: str, is_hld: bool = True) -> str:
|
|
75
|
+
"""Remove frontmatter, AI tool mentions, and metadata blocks for Confluence HTML."""
|
|
76
|
+
# 1. Strip YAML frontmatter
|
|
77
|
+
if content.startswith("---"):
|
|
78
|
+
parts = content.split("---", 2)
|
|
79
|
+
if len(parts) >= 3:
|
|
80
|
+
content = parts[2].strip()
|
|
81
|
+
|
|
82
|
+
# 2. Strip Executive Metadata / Technical Metadata if HLD
|
|
83
|
+
if is_hld:
|
|
84
|
+
# Match ## Executive Metadata ... up to next header or hr
|
|
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", "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
|
+
"""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("•", "-")
|
|
108
|
+
|
|
109
|
+
# Escape XML characters
|
|
110
|
+
text = re.sub(r"&(?!(amp|lt|gt|quot|apos);)", "&", text)
|
|
111
|
+
text = text.replace("<", "<").replace(">", ">")
|
|
112
|
+
|
|
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)
|
|
120
|
+
|
|
121
|
+
# Links: [text](url) -> <a href="url">text</a>
|
|
122
|
+
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
|
123
|
+
|
|
124
|
+
# Bold **bold** -> <strong>bold</strong>
|
|
125
|
+
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
|
|
126
|
+
|
|
127
|
+
# Italic *italic* -> <em>italic</em>
|
|
128
|
+
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
|
129
|
+
|
|
130
|
+
for idx, span in enumerate(code_spans):
|
|
131
|
+
text = text.replace(f"__INLINE_CODE_SPAN_{idx}__", span)
|
|
132
|
+
|
|
133
|
+
return text
|
|
134
|
+
|
|
135
|
+
|
|
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:
|
|
139
|
+
return ""
|
|
140
|
+
|
|
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>')
|
|
165
|
+
return "\n".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# PlantUML Generator (Strictly Orthogonal & Native Inline Confluence Macro)
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def convert_mermaid_flowchart_to_plantuml(code: str) -> str:
|
|
173
|
+
"""Parse Mermaid flowchart / graph into PlantUML with strict orthogonal routing."""
|
|
174
|
+
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
175
|
+
|
|
176
|
+
subgraphs = []
|
|
177
|
+
sg_stack = []
|
|
178
|
+
top_nodes = {}
|
|
179
|
+
top_connections = []
|
|
180
|
+
|
|
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
|
+
node_def_re = re.compile(
|
|
184
|
+
r'([A-Za-z0-9_]+)(?:\["([^"]+)"\]|\[([^\]]+)\]|\{"([^"]+)"\}|\{([^\}]+)\}|\("([^"]+)"\)|\(([^\)]+)\))(?:::([A-Za-z0-9_]+))?'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for line in lines:
|
|
188
|
+
if not line or line.startswith("%%") or line.startswith("classDef") or line.startswith("style "):
|
|
189
|
+
continue
|
|
190
|
+
if line.lower().startswith("flowchart") or line.lower().startswith("graph"):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
sg_match = subgraph_start_re.match(line)
|
|
194
|
+
if sg_match:
|
|
195
|
+
sg_id = sg_match.group(1)
|
|
196
|
+
sg_title = sg_match.group(2) or sg_match.group(3) or sg_id
|
|
197
|
+
new_sg = {
|
|
198
|
+
"id": sg_id,
|
|
199
|
+
"title": sg_title,
|
|
200
|
+
"direction": "TB",
|
|
201
|
+
"nodes": {},
|
|
202
|
+
"edges": [],
|
|
203
|
+
"children": []
|
|
204
|
+
}
|
|
205
|
+
if sg_stack:
|
|
206
|
+
sg_stack[-1]["children"].append(new_sg)
|
|
207
|
+
else:
|
|
208
|
+
subgraphs.append(new_sg)
|
|
209
|
+
sg_stack.append(new_sg)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if line.lower() == "end":
|
|
213
|
+
if sg_stack:
|
|
214
|
+
sg_stack.pop()
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
dir_match = direction_re.match(line)
|
|
218
|
+
if dir_match and sg_stack:
|
|
219
|
+
sg_stack[-1]["direction"] = dir_match.group(1).upper()
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
for match in node_def_re.finditer(line):
|
|
223
|
+
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}
|
|
227
|
+
if sg_stack:
|
|
228
|
+
sg_stack[-1]["nodes"][n_id] = node_info
|
|
229
|
+
else:
|
|
230
|
+
top_nodes[n_id] = node_info
|
|
231
|
+
|
|
232
|
+
clean_edge_line = node_def_re.sub(r'\1', line)
|
|
233
|
+
edge_tokens = re.split(r'(\s*(?:<-->|-->|---|->)\s*(?:\|"[^"]*"\|\s*|\|[^\|]*\|\s*)?)', clean_edge_line)
|
|
234
|
+
if len(edge_tokens) >= 3:
|
|
235
|
+
for i in range(0, len(edge_tokens) - 2, 2):
|
|
236
|
+
src = edge_tokens[i].strip()
|
|
237
|
+
arrow_token = edge_tokens[i+1].strip()
|
|
238
|
+
dst = edge_tokens[i+2].strip()
|
|
239
|
+
|
|
240
|
+
lbl_match = re.search(r'\|"?([^"\|]*)"?\|', arrow_token)
|
|
241
|
+
lbl = lbl_match.group(1).strip() if lbl_match else ""
|
|
242
|
+
|
|
243
|
+
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}
|
|
245
|
+
if sg_stack:
|
|
246
|
+
sg_stack[-1]["edges"].append(edge_obj)
|
|
247
|
+
else:
|
|
248
|
+
top_connections.append(edge_obj)
|
|
249
|
+
|
|
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()
|
|
254
|
+
|
|
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"
|
|
265
|
+
|
|
266
|
+
return "rectangle", "service"
|
|
267
|
+
|
|
268
|
+
out = []
|
|
269
|
+
out.append("@startuml")
|
|
270
|
+
out.append("skinparam shadowing false")
|
|
271
|
+
out.append('skinparam defaultFontName "Segoe UI"')
|
|
272
|
+
out.append("skinparam defaultFontSize 11")
|
|
273
|
+
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
|
+
|
|
278
|
+
out.append("skinparam package {")
|
|
279
|
+
out.append(" BorderColor #546E7A")
|
|
280
|
+
out.append(" BackgroundColor #F8FAFC")
|
|
281
|
+
out.append(" FontColor #263238")
|
|
282
|
+
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")
|
|
294
|
+
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")
|
|
304
|
+
out.append("}")
|
|
305
|
+
out.append("skinparam queue<<gateway>> {")
|
|
306
|
+
out.append(" BorderColor #455A64")
|
|
307
|
+
out.append(" BackgroundColor #ECEFF1")
|
|
308
|
+
out.append(" FontColor #263238")
|
|
309
|
+
out.append("}\n")
|
|
310
|
+
|
|
311
|
+
def render_subgraph(sg, indent=""):
|
|
312
|
+
res = [f'{indent}package "{sg["title"]}" as {sg["id"]} {{']
|
|
313
|
+
for child in sg.get("children", []):
|
|
314
|
+
res.extend(render_subgraph(child, indent + " "))
|
|
315
|
+
for n_id, n_info in sg["nodes"].items():
|
|
316
|
+
formatted_lbl = (
|
|
317
|
+
n_info["label"]
|
|
318
|
+
.replace("<br/>", "\\n")
|
|
319
|
+
.replace("<br>", "\\n")
|
|
320
|
+
.replace("•", "-")
|
|
321
|
+
.replace('"', "'")
|
|
322
|
+
)
|
|
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}')
|
|
326
|
+
|
|
327
|
+
if sg["edges"]:
|
|
328
|
+
res.append("")
|
|
329
|
+
arrow = "-right->" if sg["direction"] == "LR" else "-down->"
|
|
330
|
+
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 ""
|
|
333
|
+
res.append(f'{indent} {e["src"]} {arrow} {e["dst"]}{lbl_suffix}')
|
|
334
|
+
res.append(f"{indent}}}\n")
|
|
335
|
+
return res
|
|
336
|
+
|
|
337
|
+
for sg in subgraphs:
|
|
338
|
+
out.extend(render_subgraph(sg))
|
|
339
|
+
|
|
340
|
+
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}')
|
|
345
|
+
if top_nodes:
|
|
346
|
+
out.append("")
|
|
347
|
+
|
|
348
|
+
for i in range(len(subgraphs) - 1):
|
|
349
|
+
out.append(f'{subgraphs[i]["id"]} -[hidden]down- {subgraphs[i+1]["id"]}')
|
|
350
|
+
if subgraphs:
|
|
351
|
+
out.append("")
|
|
352
|
+
|
|
353
|
+
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}')
|
|
357
|
+
|
|
358
|
+
out.append("@enduml")
|
|
359
|
+
return "\n".join(out)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def convert_mermaid_sequence_to_plantuml(code: str) -> str:
|
|
363
|
+
"""Parse Mermaid sequenceDiagram into PlantUML sequence diagram with orthogonal lines."""
|
|
364
|
+
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
365
|
+
|
|
366
|
+
out = []
|
|
367
|
+
out.append("@startuml")
|
|
368
|
+
out.append("autonumber")
|
|
369
|
+
out.append("skinparam shadowing false")
|
|
370
|
+
out.append('skinparam defaultFontName "Segoe UI"')
|
|
371
|
+
out.append("skinparam defaultFontSize 11")
|
|
372
|
+
out.append("skinparam roundCorner 6\n")
|
|
373
|
+
|
|
374
|
+
out.append("skinparam actor {")
|
|
375
|
+
out.append(" BorderColor #3949AB")
|
|
376
|
+
out.append(" BackgroundColor #E8EAF6")
|
|
377
|
+
out.append(" FontColor #1A237E")
|
|
378
|
+
out.append("}")
|
|
379
|
+
out.append("skinparam participant {")
|
|
380
|
+
out.append(" BorderColor #0277BD")
|
|
381
|
+
out.append(" BackgroundColor #E1F5FE")
|
|
382
|
+
out.append(" FontColor #01579B")
|
|
383
|
+
out.append("}")
|
|
384
|
+
out.append("skinparam database {")
|
|
385
|
+
out.append(" BorderColor #2E7D32")
|
|
386
|
+
out.append(" BackgroundColor #E8F5E9")
|
|
387
|
+
out.append(" FontColor #1B5E20")
|
|
388
|
+
out.append("}")
|
|
389
|
+
out.append("skinparam sequence {")
|
|
390
|
+
out.append(" ArrowColor #1E88E5")
|
|
391
|
+
out.append(" LifeLineBorderColor #90A4AE")
|
|
392
|
+
out.append(" LifeLineBackgroundColor #ECEFF1")
|
|
393
|
+
out.append(" DividerBorderColor #B0BEC5")
|
|
394
|
+
out.append(" DividerBackgroundColor #ECEFF1")
|
|
395
|
+
out.append(" DividerFontColor #263238")
|
|
396
|
+
out.append("}\n")
|
|
397
|
+
|
|
398
|
+
part_re = re.compile(r'^(?:participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?', re.IGNORECASE)
|
|
399
|
+
msg_re = re.compile(r'^([A-Za-z0-9_]+)\s*(->>|-->>|->|-->)\s*([A-Za-z0-9_]+)\s*:\s*(.+)$')
|
|
400
|
+
note_re = re.compile(r'^Note\s+(?:over|left of|right of)\s+([A-Za-z0-9_,\s]+)\s*:\s*(.+)$', re.IGNORECASE)
|
|
401
|
+
|
|
402
|
+
declared_parts = set()
|
|
403
|
+
|
|
404
|
+
for line in lines:
|
|
405
|
+
if line.startswith("sequenceDiagram") or line.startswith("autonumber"):
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
p_match = part_re.match(line)
|
|
409
|
+
if p_match:
|
|
410
|
+
pid = p_match.group(1)
|
|
411
|
+
pname = (p_match.group(2) or pid).replace('"', '')
|
|
412
|
+
is_actor = line.lower().startswith("actor ") and "scheduler" not in pid.lower() and "task" not in pid.lower()
|
|
413
|
+
elem_kind = "actor" if is_actor else ("database" if "db" in pid.lower() else "participant")
|
|
414
|
+
out.append(f'{elem_kind} "{pname}" as {pid}')
|
|
415
|
+
declared_parts.add(pid)
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
n_match = note_re.match(line)
|
|
419
|
+
if n_match:
|
|
420
|
+
target = n_match.group(1).strip()
|
|
421
|
+
text = n_match.group(2).strip()
|
|
422
|
+
out.append(f'== {text} ==')
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
m_match = msg_re.match(line)
|
|
426
|
+
if m_match:
|
|
427
|
+
src = m_match.group(1)
|
|
428
|
+
arrow = m_match.group(2)
|
|
429
|
+
dst = m_match.group(3)
|
|
430
|
+
txt = m_match.group(4).replace('"', '')
|
|
431
|
+
|
|
432
|
+
for p in [src, dst]:
|
|
433
|
+
if p not in declared_parts:
|
|
434
|
+
elem_kind = "database" if "db" in p.lower() else "participant"
|
|
435
|
+
out.append(f'{elem_kind} "{p}" as {p}')
|
|
436
|
+
declared_parts.add(p)
|
|
437
|
+
|
|
438
|
+
puml_arrow = "-->" if ("-->>" in arrow or "-->" in arrow) else "->"
|
|
439
|
+
out.append(f'{src} {puml_arrow} {dst} : {txt}')
|
|
440
|
+
|
|
441
|
+
out.append("@enduml")
|
|
442
|
+
return "\n".join(out)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def render_plantuml_macro(puml_code: str) -> str:
|
|
446
|
+
"""Wrap PlantUML code inside native Confluence <ac:structured-macro ac:name="plantuml">."""
|
|
447
|
+
return (
|
|
448
|
+
f'<p><ac:structured-macro ac:name="plantuml" ac:schema-version="1">\n'
|
|
449
|
+
f'<ac:parameter ac:name="atlassian-macro-output-type">INLINE</ac:parameter>\n'
|
|
450
|
+
f'<ac:plain-text-body><![CDATA[{puml_code}]]></ac:plain-text-body>\n'
|
|
451
|
+
f'</ac:structured-macro></p>'
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
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()
|
|
473
|
+
|
|
474
|
+
# 3. Strip AI traces, frontmatter, and metadata lists
|
|
475
|
+
content = strip_ai_traces_and_metadata(clean_text, is_hld=is_hld)
|
|
476
|
+
|
|
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
|
+
)
|
|
485
|
+
|
|
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()
|
|
488
|
+
|
|
489
|
+
lines = content.splitlines()
|
|
490
|
+
body_parts = []
|
|
491
|
+
|
|
492
|
+
# Emit standard Confluence Top Section
|
|
493
|
+
if is_hld:
|
|
494
|
+
body_parts.append(f"<h1>High-Level Architecture (HLA): {app_name}</h1>")
|
|
495
|
+
body_parts.append("<hr/>")
|
|
496
|
+
body_parts.append("<h2>Document Information</h2>")
|
|
497
|
+
body_parts.append("<h3>Version History</h3>")
|
|
498
|
+
body_parts.append(
|
|
499
|
+
'<table class="wrapped"><colgroup><col/><col/><col/><col/></colgroup>'
|
|
500
|
+
'<thead><tr><th><p>Version</p></th><th><p>Date</p></th><th><p>Author</p></th><th><p>Description</p></th></tr></thead>'
|
|
501
|
+
'<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>'
|
|
502
|
+
'</table>'
|
|
503
|
+
)
|
|
504
|
+
body_parts.append("<hr/>")
|
|
505
|
+
body_parts.append("<h2>Table of Contents</h2>")
|
|
506
|
+
body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
|
|
507
|
+
body_parts.append("<hr/>")
|
|
508
|
+
else:
|
|
509
|
+
body_parts.append(f"<h1>Lower-Level Design: {app_name}</h1>")
|
|
510
|
+
body_parts.append("<hr/>")
|
|
511
|
+
body_parts.append("<h2>Table of Contents</h2>")
|
|
512
|
+
body_parts.append('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"/></p>')
|
|
513
|
+
body_parts.append("<hr/>")
|
|
514
|
+
|
|
515
|
+
i = 0
|
|
516
|
+
while i < len(lines):
|
|
517
|
+
line = lines[i]
|
|
518
|
+
stripped = line.strip()
|
|
519
|
+
|
|
520
|
+
if not stripped:
|
|
521
|
+
i += 1
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
if stripped in ("---", "***", "___"):
|
|
525
|
+
body_parts.append("<hr/>")
|
|
526
|
+
i += 1
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
# Headings
|
|
530
|
+
h_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
531
|
+
if h_match:
|
|
532
|
+
level = len(h_match.group(1))
|
|
533
|
+
heading_text = format_inline_markdown(h_match.group(2))
|
|
534
|
+
# Omit redundant Document Information or Table of Contents if re-encountered
|
|
535
|
+
if "table of contents" in heading_text.lower():
|
|
536
|
+
i += 1
|
|
537
|
+
continue
|
|
538
|
+
body_parts.append(f"<h{level}>{heading_text}</h{level}>")
|
|
539
|
+
i += 1
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
# Mermaid Diagram Blocks
|
|
543
|
+
if stripped.startswith("```mermaid"):
|
|
544
|
+
diag_lines = []
|
|
545
|
+
i += 1
|
|
546
|
+
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
547
|
+
diag_lines.append(lines[i])
|
|
548
|
+
i += 1
|
|
549
|
+
if i < len(lines):
|
|
550
|
+
i += 1
|
|
551
|
+
|
|
552
|
+
diag_code = "\n".join(diag_lines)
|
|
553
|
+
|
|
554
|
+
if "sequenceDiagram" in diag_code:
|
|
555
|
+
puml = convert_mermaid_sequence_to_plantuml(diag_code)
|
|
556
|
+
else:
|
|
557
|
+
puml = convert_mermaid_flowchart_to_plantuml(diag_code)
|
|
558
|
+
|
|
559
|
+
body_parts.append(render_plantuml_macro(puml))
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
# General Code Blocks
|
|
563
|
+
if stripped.startswith("```"):
|
|
564
|
+
lang = stripped[3:].strip() or "text"
|
|
565
|
+
code_lines = []
|
|
566
|
+
i += 1
|
|
567
|
+
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
568
|
+
code_lines.append(lines[i])
|
|
569
|
+
i += 1
|
|
570
|
+
if i < len(lines):
|
|
571
|
+
i += 1
|
|
572
|
+
|
|
573
|
+
code_text = html.escape("\n".join(code_lines))
|
|
574
|
+
code_macro_id = str(uuid.uuid4())
|
|
575
|
+
body_parts.append(
|
|
576
|
+
f'<ac:structured-macro ac:macro-id="{code_macro_id}" ac:name="code" ac:schema-version="1">\n'
|
|
577
|
+
f' <ac:parameter ac:name="language">{lang}</ac:parameter>\n'
|
|
578
|
+
f' <ac:plain-text-body><![CDATA[{code_text}]]></ac:plain-text-body>\n'
|
|
579
|
+
f'</ac:structured-macro>'
|
|
580
|
+
)
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
# Tables
|
|
584
|
+
if stripped.startswith("|"):
|
|
585
|
+
table_lines = []
|
|
586
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
587
|
+
table_lines.append(lines[i].strip())
|
|
588
|
+
i += 1
|
|
589
|
+
body_parts.append(convert_table(table_lines))
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
# Alerts (> [!NOTE], > [!IMPORTANT], > [!WARNING], > [!TIP])
|
|
593
|
+
if stripped.startswith("> [!"):
|
|
594
|
+
alert_match = re.match(r"^>\s*\[!(NOTE|IMPORTANT|WARNING|TIP|CAUTION)\]\s*(.*)$", stripped, re.IGNORECASE)
|
|
595
|
+
if alert_match:
|
|
596
|
+
alert_type = alert_match.group(1).lower()
|
|
597
|
+
first_text = alert_match.group(2)
|
|
598
|
+
alert_lines = [first_text] if first_text else []
|
|
599
|
+
i += 1
|
|
600
|
+
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
601
|
+
sub_line = re.sub(r"^>\s*", "", lines[i].strip())
|
|
602
|
+
alert_lines.append(sub_line)
|
|
603
|
+
i += 1
|
|
604
|
+
|
|
605
|
+
macro_name = "info" if alert_type in ("note", "tip") else "warning"
|
|
606
|
+
macro_id = str(uuid.uuid4())
|
|
607
|
+
alert_body = "<br/>".join([format_inline_markdown(al) for al in alert_lines if al])
|
|
608
|
+
body_parts.append(
|
|
609
|
+
f'<ac:structured-macro ac:macro-id="{macro_id}" ac:name="{macro_name}" ac:schema-version="1">\n'
|
|
610
|
+
f' <ac:rich-text-body><p>{alert_body}</p></ac:rich-text-body>\n'
|
|
611
|
+
f'</ac:structured-macro>'
|
|
612
|
+
)
|
|
613
|
+
continue
|
|
614
|
+
|
|
615
|
+
# Unordered Lists
|
|
616
|
+
if re.match(r"^[-*]\s+", stripped):
|
|
617
|
+
list_items = []
|
|
618
|
+
while i < len(lines) and re.match(r"^[-*]\s+", lines[i].strip()):
|
|
619
|
+
item_text = re.sub(r"^[-*]\s+", "", lines[i].strip())
|
|
620
|
+
list_items.append(format_inline_markdown(item_text))
|
|
621
|
+
i += 1
|
|
622
|
+
|
|
623
|
+
out_list = ["<ul>"]
|
|
624
|
+
for li in list_items:
|
|
625
|
+
out_list.append(f" <li>{li}</li>")
|
|
626
|
+
out_list.append("</ul>")
|
|
627
|
+
body_parts.append("\n".join(out_list))
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
# Ordered Lists
|
|
631
|
+
if re.match(r"^\d+\.\s+", stripped):
|
|
632
|
+
list_items = []
|
|
633
|
+
while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
|
|
634
|
+
item_text = re.sub(r"^\d+\.\s+", "", lines[i].strip())
|
|
635
|
+
list_items.append(format_inline_markdown(item_text))
|
|
636
|
+
i += 1
|
|
637
|
+
|
|
638
|
+
out_list = ["<ol>"]
|
|
639
|
+
for li in list_items:
|
|
640
|
+
out_list.append(f" <li>{li}</li>")
|
|
641
|
+
out_list.append("</ol>")
|
|
642
|
+
body_parts.append("\n".join(out_list))
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
# Paragraphs
|
|
646
|
+
para_lines = [stripped]
|
|
647
|
+
i += 1
|
|
648
|
+
while i < len(lines):
|
|
649
|
+
next_line = lines[i].strip()
|
|
650
|
+
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):
|
|
651
|
+
break
|
|
652
|
+
para_lines.append(next_line)
|
|
653
|
+
i += 1
|
|
654
|
+
|
|
655
|
+
p_text = format_inline_markdown(" ".join(para_lines))
|
|
656
|
+
body_parts.append(f"<p>{p_text}</p>")
|
|
657
|
+
|
|
658
|
+
return "\n".join(body_parts)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def validate_confluence_xhtml(xhtml_content: str) -> None:
|
|
662
|
+
"""Validate that Confluence XHTML content is strictly well-formed XML."""
|
|
663
|
+
test_wrapper = (
|
|
664
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
665
|
+
'<root xmlns:ac="http://atlassian.com/confluence/ac"'
|
|
666
|
+
' xmlns:ri="http://atlassian.com/confluence/ri">\n'
|
|
667
|
+
f'{xhtml_content}\n'
|
|
668
|
+
'</root>'
|
|
669
|
+
)
|
|
670
|
+
try:
|
|
671
|
+
ET.fromstring(test_wrapper)
|
|
672
|
+
except ET.ParseError as e:
|
|
673
|
+
print(f"XHTML validation error: {e}", file=sys.stderr)
|
|
674
|
+
raise
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def main():
|
|
678
|
+
parser = argparse.ArgumentParser(description="Convert Markdown HLD/LLD to Confluence Storage Format XHTML.")
|
|
679
|
+
parser.add_argument("input_file", help="Path to input Markdown file")
|
|
680
|
+
parser.add_argument("--output", "-o", required=True, help="Path to output XHTML file")
|
|
681
|
+
parser.add_argument("--diagram-type", choices=["plantuml", "drawio"], default="plantuml", help="Diagram engine (default: plantuml)")
|
|
682
|
+
args = parser.parse_args()
|
|
683
|
+
|
|
684
|
+
input_path = os.path.abspath(args.input_file)
|
|
685
|
+
output_path = os.path.abspath(args.output)
|
|
686
|
+
|
|
687
|
+
if not os.path.isfile(input_path):
|
|
688
|
+
print(f"Error: input file {input_path} not found.", file=sys.stderr)
|
|
689
|
+
sys.exit(1)
|
|
690
|
+
|
|
691
|
+
input_filename = os.path.basename(input_path).lower()
|
|
692
|
+
is_hld = "-hld" in input_filename or "-hla" in input_filename
|
|
693
|
+
|
|
694
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
695
|
+
md_text = f.read()
|
|
696
|
+
|
|
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)
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
validate_confluence_xhtml(xhtml_body)
|
|
702
|
+
print("XHTML validation successful: Content is well-formed XML.")
|
|
703
|
+
except Exception as e:
|
|
704
|
+
print(f"Warning: XML validation failed ({e}), review generated output.", file=sys.stderr)
|
|
705
|
+
|
|
706
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
707
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
708
|
+
f.write(xhtml_body)
|
|
709
|
+
|
|
710
|
+
print(f"Confluence XHTML successfully written to: {output_path}")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
if __name__ == "__main__":
|
|
714
|
+
main()
|