@caddis/cli 0.4.52 → 0.4.54
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/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +4 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py
DELETED
|
@@ -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/>", "
").replace("<br>", "
").replace('"', '"')
|
|
353
|
-
lbl_esc = html.escape(lbl_clean)
|
|
354
|
-
style_str = f'rounded=1;whiteSpace=wrap;html=1;fillColor={style_info["fill"]};strokeColor={style_info["stroke"]};fontColor={style_info["color"]};strokeWidth=2;fontSize=11;fontFamily=Segoe UI;align=center;'
|
|
355
|
-
out.append(f' <mxCell id="{nid}" value="{lbl_esc}" style="{style_str}" vertex="1" parent="1">')
|
|
356
|
-
out.append(f' <mxGeometry x="{c["x"]}" y="{c["y"]}" width="{c["w"]}" height="{c["h"]}" as="geometry"/>')
|
|
357
|
-
out.append(' </mxCell>')
|
|
358
|
-
|
|
359
|
-
for nid, ninfo in top_nodes.items():
|
|
360
|
-
c = node_coords[nid]
|
|
361
|
-
style_info = _get_drawio_style(nid, ninfo["label"], ninfo["class"])
|
|
362
|
-
lbl_clean = ninfo["label"].replace("<br/>", "
").replace("<br>", "
").replace('"', '"')
|
|
363
|
-
lbl_esc = html.escape(lbl_clean)
|
|
364
|
-
style_str = f'rounded=1;whiteSpace=wrap;html=1;fillColor={style_info["fill"]};strokeColor={style_info["stroke"]};fontColor={style_info["color"]};strokeWidth=2;fontSize=11;fontFamily=Segoe UI;align=center;'
|
|
365
|
-
out.append(f' <mxCell id="{nid}" value="{lbl_esc}" style="{style_str}" vertex="1" parent="1">')
|
|
366
|
-
out.append(f' <mxGeometry x="{c["x"]}" y="{c["y"]}" width="{c["w"]}" height="{c["h"]}" as="geometry"/>')
|
|
367
|
-
out.append(' </mxCell>')
|
|
368
|
-
|
|
369
|
-
# Emit Edges with 100% Orthogonal Routing
|
|
370
|
-
for idx, e in enumerate(edges):
|
|
371
|
-
eid = f"edge_{idx+1}"
|
|
372
|
-
src = e["src"]
|
|
373
|
-
dst = e["dst"]
|
|
374
|
-
lbl_clean = e["label"].replace("<br/>", "
").replace("<br>", "
").replace('"', '"') if e["label"] else ""
|
|
375
|
-
lbl_esc = html.escape(lbl_clean)
|
|
376
|
-
dashed_str = "dashed=1;" if e["dashed"] else ""
|
|
377
|
-
edge_style = f"edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;fontSize=10;fontColor=#37474F;{dashed_str}"
|
|
378
|
-
out.append(f' <mxCell id="{eid}" value="{lbl_esc}" style="{edge_style}" edge="1" parent="1" source="{src}" target="{dst}">')
|
|
379
|
-
out.append(' <mxGeometry relative="1" as="geometry"/>')
|
|
380
|
-
out.append(' </mxCell>')
|
|
381
|
-
|
|
382
|
-
out.append(' </root>')
|
|
383
|
-
out.append('</mxGraphModel>')
|
|
384
|
-
return "\n".join(out), canvas_w
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def convert_mermaid_sequence_to_drawio(code: str, diagram_name: str) -> Tuple[str, int]:
|
|
388
|
-
"""Parse Mermaid sequenceDiagram into Draw.io <mxGraphModel> XML."""
|
|
389
|
-
lines = [l.strip() for l in code.splitlines() if l.strip()]
|
|
390
|
-
|
|
391
|
-
participants = []
|
|
392
|
-
messages = []
|
|
393
|
-
notes = []
|
|
394
|
-
|
|
395
|
-
part_re = re.compile(r'^(?:participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?', re.IGNORECASE)
|
|
396
|
-
msg_re = re.compile(r'^([A-Za-z0-9_]+)\s*(->>|-->>|->|-->)\s*([A-Za-z0-9_]+)\s*:\s*(.+)$')
|
|
397
|
-
note_re = re.compile(r'^Note\s+(?:over|left of|right of)\s+([A-Za-z0-9_,\s]+)\s*:\s*(.+)$', re.IGNORECASE)
|
|
398
|
-
|
|
399
|
-
part_order = []
|
|
400
|
-
part_names = {}
|
|
401
|
-
|
|
402
|
-
for l in lines:
|
|
403
|
-
if l.startswith("sequenceDiagram") or l.startswith("autonumber"):
|
|
404
|
-
continue
|
|
405
|
-
|
|
406
|
-
p_match = part_re.match(l)
|
|
407
|
-
if p_match:
|
|
408
|
-
pid = p_match.group(1)
|
|
409
|
-
pname = p_match.group(2) or pid
|
|
410
|
-
if pid not in part_order:
|
|
411
|
-
part_order.append(pid)
|
|
412
|
-
part_names[pid] = pname
|
|
413
|
-
continue
|
|
414
|
-
|
|
415
|
-
n_match = note_re.match(l)
|
|
416
|
-
if n_match:
|
|
417
|
-
target = n_match.group(1).strip()
|
|
418
|
-
text = n_match.group(2).strip()
|
|
419
|
-
notes.append({"target": target, "text": text, "index": len(messages)})
|
|
420
|
-
continue
|
|
421
|
-
|
|
422
|
-
m_match = msg_re.match(l)
|
|
423
|
-
if m_match:
|
|
424
|
-
src = m_match.group(1)
|
|
425
|
-
arrow = m_match.group(2)
|
|
426
|
-
dst = m_match.group(3)
|
|
427
|
-
txt = m_match.group(4)
|
|
428
|
-
|
|
429
|
-
for p in [src, dst]:
|
|
430
|
-
if p not in part_order:
|
|
431
|
-
part_order.append(p)
|
|
432
|
-
part_names[p] = p
|
|
433
|
-
|
|
434
|
-
messages.append({"src": src, "dst": dst, "text": txt, "dashed": "--" in arrow})
|
|
435
|
-
|
|
436
|
-
part_w = 180
|
|
437
|
-
part_h = 45
|
|
438
|
-
col_gap = 40
|
|
439
|
-
start_x = 40
|
|
440
|
-
start_y = 50
|
|
441
|
-
lifeline_h = max(550, (len(messages) + len(notes) + 2) * 55)
|
|
442
|
-
|
|
443
|
-
part_x = {}
|
|
444
|
-
for i, pid in enumerate(part_order):
|
|
445
|
-
part_x[pid] = start_x + (i * (part_w + col_gap))
|
|
446
|
-
|
|
447
|
-
canvas_w = max(1200, start_x + (len(part_order) * (part_w + col_gap)) + 60)
|
|
448
|
-
canvas_h = max(700, start_y + lifeline_h + 100)
|
|
449
|
-
|
|
450
|
-
out = []
|
|
451
|
-
out.append(f'<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="{canvas_w}" pageHeight="{canvas_h}" math="0" shadow="0">')
|
|
452
|
-
out.append(' <root>')
|
|
453
|
-
out.append(' <mxCell id="0"/>')
|
|
454
|
-
out.append(' <mxCell id="1" parent="0"/>')
|
|
455
|
-
|
|
456
|
-
# Emit Lifelines & Headers
|
|
457
|
-
for pid in part_order:
|
|
458
|
-
px = part_x[pid]
|
|
459
|
-
pname = part_names.get(pid, pid).replace("<br/>", "
").replace("<br>", "
").replace('"', '"')
|
|
460
|
-
pname_esc = html.escape(pname)
|
|
461
|
-
is_actor = "analyst" in pid.lower() or "user" in pid.lower()
|
|
462
|
-
fill_col = "#E8EAF6" if is_actor else "#E1F5FE"
|
|
463
|
-
stroke_col = "#3949AB" if is_actor else "#0277BD"
|
|
464
|
-
font_col = "#1A237E" if is_actor else "#01579B"
|
|
465
|
-
|
|
466
|
-
header_id = f"hdr_{pid}"
|
|
467
|
-
out.append(f' <mxCell id="{header_id}" value="{pname_esc}" style="rounded=1;whiteSpace=wrap;html=1;fillColor={fill_col};strokeColor={stroke_col};fontColor={font_col};fontStyle=1;fontSize=11;fontFamily=Segoe UI;align=center;" vertex="1" parent="1">')
|
|
468
|
-
out.append(f' <mxGeometry x="{px}" y="{start_y}" width="{part_w}" height="{part_h}" as="geometry"/>')
|
|
469
|
-
out.append(' </mxCell>')
|
|
470
|
-
|
|
471
|
-
ll_id = f"ll_{pid}"
|
|
472
|
-
mid_x = px + (part_w // 2)
|
|
473
|
-
out.append(f' <mxCell id="{ll_id}" value="" style="edgeStyle=none;html=1;strokeColor=#90A4AE;strokeWidth=1;dashed=1;endArrow=none;startArrow=none;" edge="1" parent="1" source="{header_id}">')
|
|
474
|
-
out.append(f' <mxGeometry relative="1" as="geometry">')
|
|
475
|
-
out.append(f' <mxPoint x="{mid_x}" y="{start_y + lifeline_h}" as="targetPoint"/>')
|
|
476
|
-
out.append(' </mxGeometry>')
|
|
477
|
-
out.append(' </mxCell>')
|
|
478
|
-
|
|
479
|
-
# Emit Messages and Notes
|
|
480
|
-
msg_y = start_y + part_h + 35
|
|
481
|
-
msg_step = 50
|
|
482
|
-
note_map = {n["index"]: n for n in notes}
|
|
483
|
-
|
|
484
|
-
for idx, m in enumerate(messages):
|
|
485
|
-
if idx in note_map:
|
|
486
|
-
n = note_map[idx]
|
|
487
|
-
n_txt = html.escape(n["text"])
|
|
488
|
-
out.append(f' <mxCell id="note_{idx}" value="{n_txt}" style="shape=note;whiteSpace=wrap;html=1;backgroundOutline=1;darkOpacity=0.05;fillColor=#FFF8E1;strokeColor=#FFA000;fontColor=#5D4037;fontSize=10;fontStyle=2;align=center;" vertex="1" parent="1">')
|
|
489
|
-
out.append(f' <mxGeometry x="{start_x + 20}" y="{msg_y - 15}" width="{canvas_w - start_x - 120}" height="26" as="geometry"/>')
|
|
490
|
-
out.append(' </mxCell>')
|
|
491
|
-
msg_y += 35
|
|
492
|
-
|
|
493
|
-
src_mid = part_x[m["src"]] + (part_w // 2)
|
|
494
|
-
dst_mid = part_x[m["dst"]] + (part_w // 2)
|
|
495
|
-
txt = html.escape(m["text"])
|
|
496
|
-
dash_str = "dashed=1;" if m["dashed"] else ""
|
|
497
|
-
edge_style = f"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#1E88E5;strokeWidth=2;endArrow=block;endFill=1;fontSize=10;fontColor=#263238;{dash_str}"
|
|
498
|
-
|
|
499
|
-
out.append(f' <mxCell id="seq_msg_{idx+1}" value="{idx+1}. {txt}" style="{edge_style}" edge="1" parent="1">')
|
|
500
|
-
out.append(f' <mxGeometry relative="1" as="geometry">')
|
|
501
|
-
out.append(f' <mxPoint x="{src_mid}" y="{msg_y}" as="sourcePoint"/>')
|
|
502
|
-
out.append(f' <mxPoint x="{dst_mid}" y="{msg_y}" as="targetPoint"/>')
|
|
503
|
-
out.append(' </mxGeometry>')
|
|
504
|
-
out.append(' </mxCell>')
|
|
505
|
-
msg_y += msg_step
|
|
506
|
-
|
|
507
|
-
out.append(' </root>')
|
|
508
|
-
out.append('</mxGraphModel>')
|
|
509
|
-
return "\n".join(out), canvas_w
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
def render_drawio_macro(drawio_xml: str, diagram_filename: str, display_name: str, width: int = 1200) -> str:
|
|
513
|
-
"""Generate native Confluence Draw.io macro."""
|
|
514
|
-
macro_id = str(uuid.uuid4())
|
|
515
|
-
name_esc = html.escape(diagram_filename)
|
|
516
|
-
disp_esc = html.escape(display_name)
|
|
517
|
-
return (
|
|
518
|
-
f'<p>\n'
|
|
519
|
-
f' <ac:structured-macro ac:macro-id="{macro_id}" ac:name="drawio" ac:schema-version="1">\n'
|
|
520
|
-
f' <ac:parameter ac:name="border">true</ac:parameter>\n'
|
|
521
|
-
f' <ac:parameter ac:name="diagramName">{name_esc}</ac:parameter>\n'
|
|
522
|
-
f' <ac:parameter ac:name="diagramDisplayName">{disp_esc}</ac:parameter>\n'
|
|
523
|
-
f' <ac:parameter ac:name="simpleViewer">false</ac:parameter>\n'
|
|
524
|
-
f' <ac:parameter ac:name="links">auto</ac:parameter>\n'
|
|
525
|
-
f' <ac:parameter ac:name="tbstyle">top</ac:parameter>\n'
|
|
526
|
-
f' <ac:parameter ac:name="lbox">true</ac:parameter>\n'
|
|
527
|
-
f' <ac:parameter ac:name="diagramWidth">{width}</ac:parameter>\n'
|
|
528
|
-
f' <ac:parameter ac:name="revision">1</ac:parameter>\n'
|
|
529
|
-
f' <ac:plain-text-body>\n'
|
|
530
|
-
f' <![CDATA[\n'
|
|
531
|
-
f'{drawio_xml}\n'
|
|
532
|
-
f' ]]>\n'
|
|
533
|
-
f' </ac:plain-text-body>\n'
|
|
534
|
-
f' </ac:structured-macro>\n'
|
|
535
|
-
f'</p>'
|
|
536
|
-
)
|
|
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 & 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()
|