@likec4/generators 1.49.0 → 1.50.0
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/dist/index.d.mts +124 -16
- package/dist/index.mjs +2151 -371
- package/package.json +9 -7
- package/src/drawio/constants.ts +64 -0
- package/src/drawio/generate-drawio.ts +1278 -131
- package/src/drawio/index.ts +19 -2
- package/src/drawio/parse-drawio.ts +1707 -195
- package/src/drawio/xml-utils.ts +32 -0
- package/src/index.ts +16 -2
- package/src/puml/generate-puml.ts +6 -6
package/dist/index.mjs
CHANGED
|
@@ -1,169 +1,10 @@
|
|
|
1
|
-
import { RichText, flattenMarkdownOrString } from "@likec4/core/types";
|
|
2
|
-
import { isEmptyish, isNullish, keys, map, pipe, values } from "remeda";
|
|
3
1
|
import { CompositeGeneratorNode, NL, expandToNode, joinToNode, toString } from "langium/generate";
|
|
4
|
-
import {
|
|
2
|
+
import { isEmptyish, isNullish, keys, map, pipe, values } from "remeda";
|
|
3
|
+
import { LikeC4Styles, nonexhaustive } from "@likec4/core";
|
|
4
|
+
import { RichText, flattenMarkdownOrString } from "@likec4/core/types";
|
|
5
|
+
import pako from "pako";
|
|
5
6
|
import JSON5 from "json5";
|
|
6
7
|
import { compareNatural, invariant, sortNaturalByFqn } from "@likec4/core/utils";
|
|
7
|
-
const DEFAULT_ELEMENT_COLORS = {
|
|
8
|
-
primary: {
|
|
9
|
-
fill: "#3b82f6",
|
|
10
|
-
stroke: "#2563eb"
|
|
11
|
-
},
|
|
12
|
-
gray: {
|
|
13
|
-
fill: "#6b7280",
|
|
14
|
-
stroke: "#4b5563"
|
|
15
|
-
},
|
|
16
|
-
green: {
|
|
17
|
-
fill: "#22c55e",
|
|
18
|
-
stroke: "#16a34a"
|
|
19
|
-
},
|
|
20
|
-
red: {
|
|
21
|
-
fill: "#ef4444",
|
|
22
|
-
stroke: "#dc2626"
|
|
23
|
-
},
|
|
24
|
-
blue: {
|
|
25
|
-
fill: "#3b82f6",
|
|
26
|
-
stroke: "#2563eb"
|
|
27
|
-
},
|
|
28
|
-
indigo: {
|
|
29
|
-
fill: "#6366f1",
|
|
30
|
-
stroke: "#4f46e5"
|
|
31
|
-
},
|
|
32
|
-
muted: {
|
|
33
|
-
fill: "#9ca3af",
|
|
34
|
-
stroke: "#6b7280"
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
const DEFAULT_EDGE_COLOR = "#6b7280";
|
|
38
|
-
function escapeXml(unsafe) {
|
|
39
|
-
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
40
|
-
}
|
|
41
|
-
function drawioShape(shape) {
|
|
42
|
-
switch (shape) {
|
|
43
|
-
case "person": return "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;";
|
|
44
|
-
case "rectangle": return "shape=rectangle;";
|
|
45
|
-
case "browser": return "shape=rectangle;rounded=1;";
|
|
46
|
-
case "mobile": return "shape=rectangle;rounded=1;";
|
|
47
|
-
case "cylinder": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
48
|
-
case "queue": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
49
|
-
case "storage": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
50
|
-
case "bucket": return "shape=rectangle;rounded=1;";
|
|
51
|
-
case "document": return "shape=document;whiteSpace=wrap;html=1;boundedLbl=1;";
|
|
52
|
-
case "component": return "shape=component;";
|
|
53
|
-
default: return "shape=rectangle;";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function getElementColors(viewmodel, color) {
|
|
57
|
-
const styles = "$styles" in viewmodel && viewmodel.$styles ? viewmodel.$styles : null;
|
|
58
|
-
if (styles) try {
|
|
59
|
-
const values = styles.colors(color);
|
|
60
|
-
return {
|
|
61
|
-
fill: values.elements.fill,
|
|
62
|
-
stroke: values.elements.stroke
|
|
63
|
-
};
|
|
64
|
-
} catch {}
|
|
65
|
-
return DEFAULT_ELEMENT_COLORS[color] ?? DEFAULT_ELEMENT_COLORS["primary"];
|
|
66
|
-
}
|
|
67
|
-
function getEdgeStrokeColor(viewmodel, color) {
|
|
68
|
-
const styles = "$styles" in viewmodel && viewmodel.$styles ? viewmodel.$styles : null;
|
|
69
|
-
if (styles) try {
|
|
70
|
-
return styles.colors(color).relationships.line;
|
|
71
|
-
} catch {}
|
|
72
|
-
return DEFAULT_EDGE_COLOR;
|
|
73
|
-
}
|
|
74
|
-
function generateDrawio(viewmodel) {
|
|
75
|
-
const view = viewmodel.$view;
|
|
76
|
-
const { nodes, edges } = view;
|
|
77
|
-
const rootId = "0";
|
|
78
|
-
const defaultParentId = "1";
|
|
79
|
-
const nodeIds = /* @__PURE__ */ new Map();
|
|
80
|
-
let cellId = 2;
|
|
81
|
-
const getCellId = (nodeId) => {
|
|
82
|
-
let id = nodeIds.get(nodeId);
|
|
83
|
-
if (!id) {
|
|
84
|
-
id = String(cellId++);
|
|
85
|
-
nodeIds.set(nodeId, id);
|
|
86
|
-
}
|
|
87
|
-
return id;
|
|
88
|
-
};
|
|
89
|
-
const vertexCells = [];
|
|
90
|
-
const edgeCells = [];
|
|
91
|
-
const sortedNodes = [...nodes].sort((a, b) => {
|
|
92
|
-
if (isNullish(a.parent) && isNullish(b.parent)) return 0;
|
|
93
|
-
if (isNullish(a.parent)) return -1;
|
|
94
|
-
if (isNullish(b.parent)) return 1;
|
|
95
|
-
if (a.parent === b.parent) return 0;
|
|
96
|
-
if (a.id.startsWith(b.id + ".")) return 1;
|
|
97
|
-
if (b.id.startsWith(a.id + ".")) return -1;
|
|
98
|
-
return 0;
|
|
99
|
-
});
|
|
100
|
-
const getBBox = (n) => {
|
|
101
|
-
const d = n;
|
|
102
|
-
return {
|
|
103
|
-
x: typeof d.x === "number" ? d.x : Array.isArray(d.position) ? d.position[0] : 0,
|
|
104
|
-
y: typeof d.y === "number" ? d.y : Array.isArray(d.position) ? d.position[1] : 0,
|
|
105
|
-
width: typeof d.width === "number" ? d.width : d.size?.width ?? 120,
|
|
106
|
-
height: typeof d.height === "number" ? d.height : d.size?.height ?? 60
|
|
107
|
-
};
|
|
108
|
-
};
|
|
109
|
-
for (const node of sortedNodes) {
|
|
110
|
-
const id = getCellId(node.id);
|
|
111
|
-
const parentId = node.parent ? getCellId(node.parent) : defaultParentId;
|
|
112
|
-
const label = escapeXml(node.title);
|
|
113
|
-
const shapeStyle = drawioShape(node.shape);
|
|
114
|
-
const { x, y, width, height } = getBBox(node);
|
|
115
|
-
const elemColors = getElementColors(viewmodel, node.color);
|
|
116
|
-
const colorStyle = elemColors != null ? `fillColor=${elemColors.fill};strokeColor=${elemColors.stroke};fontColor=${elemColors.stroke};` : "";
|
|
117
|
-
const description = node.description && flattenMarkdownOrString(node.description);
|
|
118
|
-
const desc = isEmptyish(description) ? "" : escapeXml(description);
|
|
119
|
-
const technology = node.technology && flattenMarkdownOrString(node.technology);
|
|
120
|
-
const tech = isEmptyish(technology) ? "" : escapeXml(technology);
|
|
121
|
-
const userData = desc !== "" || tech !== "" ? `<mxUserObject><data key="likec4Description">${desc}</data><data key="likec4Technology">${tech}</data></mxUserObject>\n ` : "";
|
|
122
|
-
vertexCells.push(`<mxCell id="${id}" value="${label}" style="${shapeStyle}${colorStyle}verticalAlign=middle;align=center;overflow=fill;spacingLeft=2;spacingRight=2;spacingTop=2;spacingBottom=2;" vertex="1" parent="${parentId}">
|
|
123
|
-
${userData}<mxGeometry x="${Math.round(x)}" y="${Math.round(y)}" width="${Math.round(width)}" height="${Math.round(height)}" as="geometry" />
|
|
124
|
-
</mxCell>`);
|
|
125
|
-
}
|
|
126
|
-
for (const edge of edges) {
|
|
127
|
-
const id = String(cellId++);
|
|
128
|
-
const sourceId = getCellId(edge.source);
|
|
129
|
-
const targetId = getCellId(edge.target);
|
|
130
|
-
const label = edge.label ? escapeXml(edge.label) : "";
|
|
131
|
-
const strokeColor = getEdgeStrokeColor(viewmodel, edge.color);
|
|
132
|
-
const dashStyle = edge.line === "dashed" ? "dashed=1;" : edge.line === "dotted" ? "dashed=1;dashPattern=1 1;" : "";
|
|
133
|
-
edgeCells.push(`<mxCell id="${id}" value="${label}" style="endArrow=block;html=1;rounded=0;exitX=1;exitY=0.5;entryX=0;entryY=0.5;strokeColor=${strokeColor};${dashStyle}" edge="1" parent="${defaultParentId}" source="${sourceId}" target="${targetId}">
|
|
134
|
-
<mxGeometry relative="1" as="geometry" />
|
|
135
|
-
</mxCell>`);
|
|
136
|
-
}
|
|
137
|
-
let bounds = {
|
|
138
|
-
x: 0,
|
|
139
|
-
y: 0,
|
|
140
|
-
width: 800,
|
|
141
|
-
height: 600
|
|
142
|
-
};
|
|
143
|
-
try {
|
|
144
|
-
const b = viewmodel.bounds;
|
|
145
|
-
if (b != null && typeof b.x === "number") bounds = b;
|
|
146
|
-
} catch {}
|
|
147
|
-
const allCells = [
|
|
148
|
-
`<mxCell id="${defaultParentId}" vertex="1" parent="${rootId}">
|
|
149
|
-
<mxGeometry x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" as="geometry" />
|
|
150
|
-
</mxCell>`,
|
|
151
|
-
...vertexCells,
|
|
152
|
-
...edgeCells
|
|
153
|
-
].join("\n");
|
|
154
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
-
<mxfile host="LikeC4" modified="${(/* @__PURE__ */ new Date()).toISOString()}" agent="LikeC4" version="1.0" etag="" type="device">
|
|
156
|
-
<diagram name="${escapeXml(view.id)}" id="likec4-${escapeXml(view.id)}">
|
|
157
|
-
<mxGraphModel dx="800" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale=1 pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
|
158
|
-
<root>
|
|
159
|
-
<mxCell id="${rootId}" />
|
|
160
|
-
${allCells}
|
|
161
|
-
</root>
|
|
162
|
-
</mxGraphModel>
|
|
163
|
-
</diagram>
|
|
164
|
-
</mxfile>
|
|
165
|
-
`;
|
|
166
|
-
}
|
|
167
8
|
const capitalizeFirstLetter$2 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
168
9
|
const fqnName$2 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$2).join("");
|
|
169
10
|
const nodeName$2 = (node) => {
|
|
@@ -211,66 +52,165 @@ function generateD2(viewmodel) {
|
|
|
211
52
|
};
|
|
212
53
|
return toString(new CompositeGeneratorNode().append("direction: ", d2direction(view), NL, NL).append(joinToNode(nodes.filter((n) => isNullish(n.parent)), (n) => printNode(n), { appendNewLineIfNotEmpty: true })).appendIf(edges.length > 0, NL, joinToNode(edges, (e) => printEdge(e), { appendNewLineIfNotEmpty: true })));
|
|
213
54
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
55
|
+
/**
|
|
56
|
+
* DrawIO protocol and layout constants. Single source of truth so export/parse
|
|
57
|
+
* stay in sync and magic numbers are named for readability.
|
|
58
|
+
*/
|
|
59
|
+
/** Draw.io internal page link prefix; cell link becomes "data:page/id,likec4-<viewId>". */
|
|
60
|
+
const DRAWIO_PAGE_LINK_PREFIX = "data:page/id,likec4-";
|
|
61
|
+
/** Diagram (tab) id inside mxfile; we use "likec4-<viewId>" so Draw.io opens the correct tab. */
|
|
62
|
+
const DRAWIO_DIAGRAM_ID_PREFIX = "likec4-";
|
|
63
|
+
/** Fixed canvas size so the diagram opens centered in Draw.io (layout bounds often equal content). */
|
|
64
|
+
const DEFAULT_CANVAS_WIDTH = 800;
|
|
65
|
+
const DEFAULT_CANVAS_HEIGHT = 600;
|
|
66
|
+
/** Default node bbox when layout has no position (used to detect "unlaid" nodes for spread/wrap). */
|
|
67
|
+
const DEFAULT_NODE_WIDTH = 120;
|
|
68
|
+
const DEFAULT_NODE_HEIGHT = 60;
|
|
69
|
+
/** Vertical gap when spreading multiple nodes that share the same default bbox. */
|
|
70
|
+
const NODES_SPREAD_GAP = 24;
|
|
71
|
+
/** First id assigned to container title cells (incremented per container). */
|
|
72
|
+
const CONTAINER_TITLE_CELL_ID_START = 1e4;
|
|
73
|
+
/** Container title text cell: min/max width (px), approximate width per character, height, inset from container edge. */
|
|
74
|
+
const CONTAINER_TITLE_MIN_WIDTH_PX = 60;
|
|
75
|
+
const CONTAINER_TITLE_MAX_WIDTH_PX = 260;
|
|
76
|
+
const CONTAINER_TITLE_CHAR_WIDTH_PX = 8;
|
|
77
|
+
const CONTAINER_TITLE_HEIGHT_PX = 18;
|
|
78
|
+
const CONTAINER_TITLE_INSET_X = 8;
|
|
79
|
+
const CONTAINER_TITLE_INSET_Y = 8;
|
|
80
|
+
/** Max height (px) for container title area when matching title cell to container (parse). */
|
|
81
|
+
const CONTAINER_TITLE_AREA_MAX_HEIGHT_PX = 40;
|
|
82
|
+
/** Ratio of container height used for title area when matching (parse). */
|
|
83
|
+
const CONTAINER_TITLE_AREA_HEIGHT_RATIO = .5;
|
|
84
|
+
/** Tolerance (px) for title cell position inside container bounds (parse). */
|
|
85
|
+
const CONTAINER_TITLE_AREA_TOLERANCE = 2;
|
|
86
|
+
/** Default container fill opacity (0–100) when not set in style. */
|
|
87
|
+
const DEFAULT_CONTAINER_OPACITY = 15;
|
|
88
|
+
/** Default node fill/stroke/font when no theme color (hex). */
|
|
89
|
+
const DEFAULT_NODE_FILL_HEX = "#dae8fc";
|
|
90
|
+
const DEFAULT_NODE_STROKE_HEX = "#2563eb";
|
|
91
|
+
const DEFAULT_NODE_FONT_HEX = "#1e40af";
|
|
92
|
+
/** mxGraphModel page dimensions (draw.io default A4-like). */
|
|
93
|
+
const MXGRAPH_PAGE_WIDTH = 827;
|
|
94
|
+
const MXGRAPH_PAGE_HEIGHT = 1169;
|
|
95
|
+
/** mxGraphModel default grid origin (dx, dy) in mxGraphModel attribute. */
|
|
96
|
+
const MXGRAPH_DEFAULT_DX = 800;
|
|
97
|
+
const MXGRAPH_DEFAULT_DY = 800;
|
|
98
|
+
/** Default filename when exporting all views into one .drawio file (CLI and playground). */
|
|
99
|
+
const DEFAULT_DRAWIO_ALL_FILENAME = "diagrams.drawio";
|
|
100
|
+
/** LikeC4 app font (matches --mantine-font-family / --likec4-app-font-default). Used in generate-drawio for cell text. */
|
|
101
|
+
const LIKEC4_FONT_FAMILY = "'IBM Plex Sans Variable',ui-sans-serif,system-ui,sans-serif";
|
|
102
|
+
/** Container title color in diagram (matches LikeC4 diagram compound title). Used in generate-drawio for container title cell. */
|
|
103
|
+
const CONTAINER_TITLE_COLOR = "#74c0fc";
|
|
104
|
+
/**
|
|
105
|
+
* Shared XML escape/decode for DrawIO generate and parse.
|
|
106
|
+
* Single place so escaping rules stay in sync (Clean Code 8.5.2).
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Escape for use inside XML attributes and text.
|
|
110
|
+
* @param unsafe - Raw string that may contain &, <, >, ", '
|
|
111
|
+
* @returns XML-safe string with entities escaped
|
|
112
|
+
*/
|
|
113
|
+
function escapeXml(unsafe) {
|
|
114
|
+
return unsafe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Decode XML entities (inverse of escapeXml for the five standard entities).
|
|
118
|
+
* @param s - String with < > " ' &
|
|
119
|
+
* @returns Decoded string
|
|
120
|
+
*/
|
|
121
|
+
function decodeXmlEntities(s) {
|
|
122
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("&", "&");
|
|
263
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse DrawIO (mxGraph) XML and generate LikeC4 source code.
|
|
126
|
+
* Extracts vertices as elements and edges as relations; preserves colors, descriptions,
|
|
127
|
+
* technology and other compatible attributes for full bidirectional compatibility.
|
|
128
|
+
* Supports both uncompressed (raw mxGraphModel inside <diagram>) and compressed
|
|
129
|
+
* (base64 + deflate, draw.io default) diagram content.
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* Normalize unknown to a string for error messages (Error → message; else String).
|
|
133
|
+
* @param err - Caught value (Error or other).
|
|
134
|
+
* @returns Human-readable string for logging or rethrow.
|
|
135
|
+
*/
|
|
136
|
+
function toErrorMessage(err) {
|
|
137
|
+
return err instanceof Error ? err.message : String(err);
|
|
138
|
+
}
|
|
139
|
+
/** Type guard: true when cell is an edge with non-empty source and target. */
|
|
140
|
+
function isEdgeWithEndpoints(c) {
|
|
141
|
+
return c.edge === true && typeof c.source === "string" && typeof c.target === "string";
|
|
142
|
+
}
|
|
143
|
+
/** True if char is space, tab, or newline (word boundary for attr names). */
|
|
144
|
+
function isAttrBoundaryChar(c) {
|
|
145
|
+
return c === " " || c === " " || c === "\n" || c === "\r";
|
|
146
|
+
}
|
|
147
|
+
/** Get attribute value from open-tag string (e.g. "id=\"x\" vertex=\"1\""). No regex to avoid ReDoS. Matches only on word boundary so "id=" does not match "userid=". */
|
|
264
148
|
function getAttr(attrs, name) {
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
149
|
+
const needle = `${name}=`;
|
|
150
|
+
const lower = attrs.toLowerCase();
|
|
151
|
+
const needleLower = needle.toLowerCase();
|
|
152
|
+
let i = 0;
|
|
153
|
+
while (i < attrs.length) {
|
|
154
|
+
const start = lower.indexOf(needleLower, i);
|
|
155
|
+
if (start === -1) return void 0;
|
|
156
|
+
if (!isAttrBoundaryChar(start === 0 ? " " : attrs[start - 1] ?? " ")) {
|
|
157
|
+
i = start + 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const quoteStart = start + needle.length;
|
|
161
|
+
if (quoteStart < attrs.length && attrs[quoteStart] === "\"") {
|
|
162
|
+
const valueEnd = attrs.indexOf("\"", quoteStart + 1);
|
|
163
|
+
if (valueEnd !== -1) return attrs.slice(quoteStart + 1, valueEnd);
|
|
164
|
+
}
|
|
165
|
+
i = start + 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Find end of XML open tag (first unquoted '>'). Handles both single- and double-quoted attributes. Avoids regex for S5852. */
|
|
169
|
+
function findOpenTagEnd(xml, start) {
|
|
170
|
+
let quoteChar = "";
|
|
171
|
+
let i = start;
|
|
172
|
+
while (i < xml.length) {
|
|
173
|
+
const c = xml[i];
|
|
174
|
+
if (c === "\"" || c === "'") {
|
|
175
|
+
if (quoteChar === "") quoteChar = c;
|
|
176
|
+
else if (quoteChar === c) quoteChar = "";
|
|
177
|
+
} else if (c === ">" && quoteChar === "") return i;
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
return -1;
|
|
268
181
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
182
|
+
/** Find start of tag '<tagName' (case-insensitive). */
|
|
183
|
+
function indexOfTagStart(xml, tagName, fromIndex) {
|
|
184
|
+
const lower = xml.toLowerCase();
|
|
185
|
+
const needle = `<${tagName.toLowerCase()}`;
|
|
186
|
+
return lower.indexOf(needle, fromIndex);
|
|
273
187
|
}
|
|
188
|
+
/** Find start of closing tag '</tagName>' (case-insensitive). */
|
|
189
|
+
function indexOfClosingTag(xml, tagName, fromIndex) {
|
|
190
|
+
const lower = xml.toLowerCase();
|
|
191
|
+
const needle = `</${tagName.toLowerCase()}>`;
|
|
192
|
+
return lower.indexOf(needle, fromIndex);
|
|
193
|
+
}
|
|
194
|
+
/** Parse numeric attribute; returns undefined if missing or not a number. */
|
|
195
|
+
function parseNum(str) {
|
|
196
|
+
if (str === void 0 || str === "") return void 0;
|
|
197
|
+
const num = Number.parseFloat(str);
|
|
198
|
+
return Number.isNaN(num) ? void 0 : num;
|
|
199
|
+
}
|
|
200
|
+
/** Get style value decoded for URI component; undefined if missing or empty. Safe against malformed percent-encoding. */
|
|
201
|
+
function getDecodedStyle(styleMap, key) {
|
|
202
|
+
const v = styleMap.get(key);
|
|
203
|
+
if (v == null || v === "") return void 0;
|
|
204
|
+
try {
|
|
205
|
+
return decodeURIComponent(v);
|
|
206
|
+
} catch {
|
|
207
|
+
return v;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Parse DrawIO style string (semicolon-separated key=value) into a map.
|
|
212
|
+
* Entries with empty values are intentionally dropped (meaningful style values are non-empty).
|
|
213
|
+
*/
|
|
274
214
|
function parseStyle(style) {
|
|
275
215
|
const map = /* @__PURE__ */ new Map();
|
|
276
216
|
if (!style) return map;
|
|
@@ -284,198 +224,2026 @@ function parseStyle(style) {
|
|
|
284
224
|
}
|
|
285
225
|
return map;
|
|
286
226
|
}
|
|
227
|
+
/** Keys we map to dedicated DrawioCell fields; other data keys go to customData. */
|
|
228
|
+
const MAPPED_DATA_KEYS = new Set(["likec4description", "likec4technology"]);
|
|
229
|
+
/** Extract all <data key="...">...</data> from mxUserObject inner XML. Index-based to avoid ReDoS. */
|
|
230
|
+
function parseAllUserData(fullTag) {
|
|
231
|
+
const out = {};
|
|
232
|
+
let from = 0;
|
|
233
|
+
for (;;) {
|
|
234
|
+
const tagStart = indexOfTagStart(fullTag, "data", from);
|
|
235
|
+
if (tagStart === -1) break;
|
|
236
|
+
const endOpen = findOpenTagEnd(fullTag, tagStart);
|
|
237
|
+
if (endOpen === -1) break;
|
|
238
|
+
const key = getAttr(fullTag.slice(tagStart, endOpen + 1), "key")?.trim();
|
|
239
|
+
const closeStart = indexOfClosingTag(fullTag, "data", endOpen + 1);
|
|
240
|
+
if (closeStart === -1) break;
|
|
241
|
+
const raw = fullTag.slice(endOpen + 1, closeStart).trim();
|
|
242
|
+
if (key) out[key] = decodeXmlEntities(raw);
|
|
243
|
+
from = closeStart + 7;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
/** Extract open tag <mxGeometry ...> from cell XML. No regex to avoid ReDoS. */
|
|
248
|
+
function extractMxGeometryOpenTag(fullTag) {
|
|
249
|
+
const tagStart = indexOfTagStart(fullTag, "mxGeometry", 0);
|
|
250
|
+
if (tagStart === -1) return "";
|
|
251
|
+
const endOpen = findOpenTagEnd(fullTag, tagStart);
|
|
252
|
+
if (endOpen === -1) return "";
|
|
253
|
+
return fullTag.slice(tagStart, endOpen + 1);
|
|
254
|
+
}
|
|
255
|
+
/** Extract inner content of first <mxGeometry>...</mxGeometry> in cell XML. No regex to avoid ReDoS. */
|
|
256
|
+
function extractMxGeometryInner(fullTag) {
|
|
257
|
+
const tagStart = indexOfTagStart(fullTag, "mxGeometry", 0);
|
|
258
|
+
if (tagStart === -1) return void 0;
|
|
259
|
+
const endOpen = findOpenTagEnd(fullTag, tagStart);
|
|
260
|
+
if (endOpen === -1) return void 0;
|
|
261
|
+
const closeStart = indexOfClosingTag(fullTag, "mxGeometry", endOpen + 1);
|
|
262
|
+
if (closeStart === -1) return void 0;
|
|
263
|
+
return fullTag.slice(endOpen + 1, closeStart);
|
|
264
|
+
}
|
|
265
|
+
/** Extract edge waypoints from mxGeometry Array/mxPoint inside cell XML. Returns JSON array of [x,y][] or undefined. */
|
|
266
|
+
function parseEdgePoints(fullTag) {
|
|
267
|
+
const inner = extractMxGeometryInner(fullTag);
|
|
268
|
+
if (inner === void 0) return void 0;
|
|
269
|
+
const points = [];
|
|
270
|
+
let from = 0;
|
|
271
|
+
for (;;) {
|
|
272
|
+
const tagStart = indexOfTagStart(inner, "mxPoint", from);
|
|
273
|
+
if (tagStart === -1) break;
|
|
274
|
+
const endOpen = findOpenTagEnd(inner, tagStart);
|
|
275
|
+
if (endOpen === -1) break;
|
|
276
|
+
const tag = inner.slice(tagStart, endOpen + 1);
|
|
277
|
+
const px = parseNum(getAttr(tag, "x"));
|
|
278
|
+
const py = parseNum(getAttr(tag, "y"));
|
|
279
|
+
if (px !== void 0 && py !== void 0) points.push([px, py]);
|
|
280
|
+
from = endOpen + 1;
|
|
281
|
+
}
|
|
282
|
+
if (points.length === 0) return void 0;
|
|
283
|
+
return JSON.stringify(points);
|
|
284
|
+
}
|
|
285
|
+
/** Extract LikeC4 custom data from mxUserObject/data inside cell XML. */
|
|
287
286
|
function parseUserData(fullTag) {
|
|
287
|
+
const all = parseAllUserData(fullTag);
|
|
288
288
|
const out = {};
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
const
|
|
292
|
-
|
|
289
|
+
if (all["likec4Description"] != null) out.description = all["likec4Description"];
|
|
290
|
+
if (all["likec4Technology"] != null) out.technology = all["likec4Technology"];
|
|
291
|
+
const rest = {};
|
|
292
|
+
for (const [k, v] of Object.entries(all)) if (!MAPPED_DATA_KEYS.has(k.toLowerCase()) && v != null && v !== "") rest[k] = v;
|
|
293
|
+
if (Object.keys(rest).length > 0) out.customData = JSON.stringify(rest);
|
|
293
294
|
return out;
|
|
294
295
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
296
|
+
/** Extract viewId from Draw.io internal page link for navigateTo round-trip. No regex to avoid ReDoS. */
|
|
297
|
+
function navigateToFromLink(link) {
|
|
298
|
+
if (!link || link === "") return void 0;
|
|
299
|
+
const prefix = DRAWIO_PAGE_LINK_PREFIX.toLowerCase();
|
|
300
|
+
if (!link.toLowerCase().startsWith(prefix)) return void 0;
|
|
301
|
+
return link.slice(20).trim();
|
|
302
|
+
}
|
|
303
|
+
/** Optional fields for DrawioCell (SOLID: single place for conditional spreads). */
|
|
304
|
+
function buildCellOptionalFields(params) {
|
|
305
|
+
const { styleMap, userData, geomStr, fullTag, vertex, edge, navigateTo } = params;
|
|
306
|
+
const x = parseNum(getAttr(geomStr, "x"));
|
|
307
|
+
const y = parseNum(getAttr(geomStr, "y"));
|
|
308
|
+
const width = parseNum(getAttr(geomStr, "width"));
|
|
309
|
+
const height = parseNum(getAttr(geomStr, "height"));
|
|
310
|
+
const fillColor = styleMap.get("fillcolor");
|
|
311
|
+
const strokeColor = styleMap.get("strokecolor");
|
|
312
|
+
const description = userData.description ?? getDecodedStyle(styleMap, "likec4description");
|
|
313
|
+
const technology = userData.technology ?? getDecodedStyle(styleMap, "likec4technology");
|
|
314
|
+
const notes = getDecodedStyle(styleMap, "likec4notes");
|
|
315
|
+
const tags = getDecodedStyle(styleMap, "likec4tags");
|
|
316
|
+
const icon = getDecodedStyle(styleMap, "likec4icon");
|
|
317
|
+
const endArrow = styleMap.get("endarrow");
|
|
318
|
+
const startArrow = styleMap.get("startarrow");
|
|
319
|
+
const dashed = styleMap.get("dashed");
|
|
320
|
+
const dashPattern = styleMap.get("dashpattern");
|
|
321
|
+
const summary = getDecodedStyle(styleMap, "likec4summary");
|
|
322
|
+
const links = getDecodedStyle(styleMap, "likec4links");
|
|
323
|
+
const border = getDecodedStyle(styleMap, "likec4border");
|
|
324
|
+
const colorName = getDecodedStyle(styleMap, "likec4colorname");
|
|
325
|
+
const opacityFromStyle = styleMap.get("opacity");
|
|
326
|
+
const opacityFromLikec4 = styleMap.get("likec4opacity");
|
|
327
|
+
const opacityFromFill = styleMap.get("fillopacity");
|
|
328
|
+
const opacity = (opacityFromLikec4 != null && opacityFromLikec4 !== "" ? opacityFromLikec4 : void 0) ?? (opacityFromStyle != null && opacityFromStyle !== "" ? opacityFromStyle : void 0) ?? (opacityFromFill != null && opacityFromFill !== "" ? opacityFromFill : void 0);
|
|
329
|
+
const strokeWidthRaw = styleMap.get("strokewidth");
|
|
330
|
+
const strokeWidth = strokeWidthRaw != null && strokeWidthRaw !== "" ? strokeWidthRaw : void 0;
|
|
331
|
+
const size = getDecodedStyle(styleMap, "likec4size");
|
|
332
|
+
const padding = getDecodedStyle(styleMap, "likec4padding");
|
|
333
|
+
const textSize = getDecodedStyle(styleMap, "likec4textsize");
|
|
334
|
+
const iconPosition = getDecodedStyle(styleMap, "likec4iconposition");
|
|
335
|
+
const link = getDecodedStyle(styleMap, "link");
|
|
336
|
+
const relationshipKind = getDecodedStyle(styleMap, "likec4relationshipkind");
|
|
337
|
+
const notation = getDecodedStyle(styleMap, "likec4notation");
|
|
338
|
+
const metadata = getDecodedStyle(styleMap, "likec4metadata");
|
|
339
|
+
const optional = {};
|
|
340
|
+
if (params.valueRaw != null && params.valueRaw !== "") optional.value = decodeXmlEntities(params.valueRaw);
|
|
341
|
+
if (params.parent != null && params.parent !== "") optional.parent = params.parent;
|
|
342
|
+
if (params.source != null && params.source !== "") optional.source = params.source;
|
|
343
|
+
if (params.target != null && params.target !== "") optional.target = params.target;
|
|
344
|
+
if (params.style != null && params.style !== "") optional.style = params.style;
|
|
345
|
+
if (x !== void 0) optional.x = x;
|
|
346
|
+
if (y !== void 0) optional.y = y;
|
|
347
|
+
if (width !== void 0) optional.width = width;
|
|
348
|
+
if (height !== void 0) optional.height = height;
|
|
349
|
+
if (fillColor !== void 0) optional.fillColor = fillColor;
|
|
350
|
+
if (strokeColor !== void 0) optional.strokeColor = strokeColor;
|
|
351
|
+
if (description != null) optional.description = description;
|
|
352
|
+
if (technology != null) optional.technology = technology;
|
|
353
|
+
if (notes != null) optional.notes = notes;
|
|
354
|
+
if (tags != null) optional.tags = tags;
|
|
355
|
+
if (navigateTo != null) optional.navigateTo = navigateTo;
|
|
356
|
+
if (icon != null) optional.icon = icon;
|
|
357
|
+
if (endArrow != null && endArrow !== "") optional.endArrow = endArrow;
|
|
358
|
+
if (startArrow != null && startArrow !== "") optional.startArrow = startArrow;
|
|
359
|
+
if (dashed != null && dashed !== "") optional.dashed = dashed;
|
|
360
|
+
if (dashPattern != null && dashPattern !== "") optional.dashPattern = dashPattern;
|
|
361
|
+
if (summary != null) optional.summary = summary;
|
|
362
|
+
if (links != null) optional.links = links;
|
|
363
|
+
if (link != null && vertex) optional.link = link;
|
|
364
|
+
if (border != null) optional.border = border;
|
|
365
|
+
if (strokeWidth != null && vertex) optional.strokeWidth = strokeWidth;
|
|
366
|
+
if (size != null && vertex) optional.size = size;
|
|
367
|
+
if (padding != null && vertex) optional.padding = padding;
|
|
368
|
+
if (textSize != null && vertex) optional.textSize = textSize;
|
|
369
|
+
if (iconPosition != null && vertex) optional.iconPosition = iconPosition;
|
|
370
|
+
if (colorName != null) optional.colorName = colorName;
|
|
371
|
+
if (opacity != null) optional.opacity = opacity;
|
|
372
|
+
if (relationshipKind != null) optional.relationshipKind = relationshipKind;
|
|
373
|
+
if (notation != null) optional.notation = notation;
|
|
374
|
+
if (metadata != null && edge) optional.metadata = metadata;
|
|
375
|
+
if (userData.customData != null) optional.customData = userData.customData;
|
|
376
|
+
if (edge) {
|
|
377
|
+
const pts = parseEdgePoints(fullTag);
|
|
378
|
+
if (pts != null) optional.edgePoints = pts;
|
|
379
|
+
}
|
|
380
|
+
return optional;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Build one DrawioCell from parsed mxCell tag parts.
|
|
384
|
+
* attrs: open-tag attribute string; inner: content between open/close; fullTag: full tag for geometry regex.
|
|
385
|
+
* overrides: optional id/navigateTo to override parsed values. Returns null if id missing.
|
|
386
|
+
*/
|
|
387
|
+
function buildCellFromMxCell(attrs, inner, fullTag, overrides) {
|
|
388
|
+
const id = overrides?.id ?? getAttr(attrs, "id");
|
|
389
|
+
if (!id) return null;
|
|
390
|
+
const vertex = getAttr(attrs, "vertex") === "1";
|
|
391
|
+
const edge = getAttr(attrs, "edge") === "1";
|
|
392
|
+
const style = getAttr(attrs, "style");
|
|
393
|
+
const geomStr = extractMxGeometryOpenTag(fullTag);
|
|
394
|
+
const styleMap = parseStyle(style ?? void 0);
|
|
395
|
+
const userData = parseUserData(inner);
|
|
396
|
+
const navigateTo = overrides?.navigateTo ?? getDecodedStyle(styleMap, "likec4navigateto");
|
|
397
|
+
return {
|
|
398
|
+
id,
|
|
399
|
+
vertex,
|
|
400
|
+
edge,
|
|
401
|
+
...buildCellOptionalFields({
|
|
402
|
+
valueRaw: getAttr(attrs, "value"),
|
|
403
|
+
parent: getAttr(attrs, "parent"),
|
|
404
|
+
source: getAttr(attrs, "source"),
|
|
405
|
+
target: getAttr(attrs, "target"),
|
|
406
|
+
style: getAttr(attrs, "style"),
|
|
407
|
+
styleMap,
|
|
408
|
+
userData,
|
|
409
|
+
geomStr,
|
|
410
|
+
fullTag,
|
|
328
411
|
vertex,
|
|
329
412
|
edge,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
413
|
+
navigateTo
|
|
414
|
+
})
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Extract one mxCell from xml starting at tagStart. Returns attrs, inner, fullTag and next search index, or null. */
|
|
418
|
+
function extractOneMxCell(xml, tagStart) {
|
|
419
|
+
const endOpen = findOpenTagEnd(xml, tagStart);
|
|
420
|
+
if (endOpen === -1) return null;
|
|
421
|
+
const attrs = xml.slice(tagStart + 7, endOpen).trim();
|
|
422
|
+
const isSelfClosing = xml.slice(tagStart, endOpen).trimEnd().endsWith("/");
|
|
423
|
+
const afterBracket = endOpen + 1;
|
|
424
|
+
let inner;
|
|
425
|
+
let endTagPos;
|
|
426
|
+
if (isSelfClosing) {
|
|
427
|
+
inner = "";
|
|
428
|
+
endTagPos = endOpen;
|
|
429
|
+
} else {
|
|
430
|
+
const closeStart = indexOfClosingTag(xml, "mxCell", afterBracket);
|
|
431
|
+
if (closeStart === -1) return null;
|
|
432
|
+
inner = xml.slice(afterBracket, closeStart);
|
|
433
|
+
endTagPos = closeStart + 9 - 1;
|
|
434
|
+
}
|
|
435
|
+
const fullTag = xml.slice(tagStart, endTagPos + 1);
|
|
436
|
+
return {
|
|
437
|
+
attrs,
|
|
438
|
+
inner,
|
|
439
|
+
fullTag,
|
|
440
|
+
next: endTagPos + 1
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Simple XML parser for DrawIO mxCell elements. Extracts cells with id, value, parent,
|
|
445
|
+
* source, target, vertex, edge, geometry, style colors and LikeC4 user data.
|
|
446
|
+
* Also parses <UserObject id="..." link="..."> wrapping mxCell (export format for navigateTo) so the inner mxCell gets id and navigateTo from the link.
|
|
447
|
+
* Uses indexOf-based extraction instead of regex to avoid S5852 (super-linear backtracking DoS).
|
|
448
|
+
*/
|
|
449
|
+
function parseDrawioXml(xml) {
|
|
450
|
+
const cells = [];
|
|
451
|
+
const parsedIds = /* @__PURE__ */ new Set();
|
|
452
|
+
const closeUserObjectLen = 13;
|
|
453
|
+
let uoStart = indexOfTagStart(xml, "UserObject", 0);
|
|
454
|
+
while (uoStart !== -1) {
|
|
455
|
+
const endOpen = findOpenTagEnd(xml, uoStart);
|
|
456
|
+
if (endOpen === -1) break;
|
|
457
|
+
const openTag = xml.slice(uoStart, endOpen + 1);
|
|
458
|
+
const userObjId = getAttr(openTag, "id")?.trim();
|
|
459
|
+
const navigateTo = navigateToFromLink(getAttr(openTag, "link") ?? void 0);
|
|
460
|
+
const closeStart = indexOfClosingTag(xml, "UserObject", endOpen + 1);
|
|
461
|
+
if (closeStart === -1) break;
|
|
462
|
+
const innerXml = xml.slice(endOpen + 1, closeStart);
|
|
463
|
+
if (userObjId) {
|
|
464
|
+
const innerMxStart = indexOfTagStart(innerXml, "mxCell", 0);
|
|
465
|
+
if (innerMxStart !== -1) {
|
|
466
|
+
const mx = extractOneMxCell(innerXml, innerMxStart);
|
|
467
|
+
if (mx) {
|
|
468
|
+
const fullTag = `<mxCell id="${escapeXml(userObjId)}" ${mx.attrs}>${innerXml}</mxCell>`;
|
|
469
|
+
const cell = buildCellFromMxCell(mx.attrs, innerXml, fullTag, navigateTo != null && navigateTo !== "" ? {
|
|
470
|
+
id: userObjId,
|
|
471
|
+
navigateTo
|
|
472
|
+
} : { id: userObjId });
|
|
473
|
+
if (cell) {
|
|
474
|
+
cells.push(cell);
|
|
475
|
+
parsedIds.add(cell.id);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
uoStart = indexOfTagStart(xml, "UserObject", closeStart + closeUserObjectLen);
|
|
481
|
+
}
|
|
482
|
+
let mxStart = indexOfTagStart(xml, "mxCell", 0);
|
|
483
|
+
while (mxStart !== -1) {
|
|
484
|
+
const mx = extractOneMxCell(xml, mxStart);
|
|
485
|
+
if (mx) {
|
|
486
|
+
const id = getAttr(mx.attrs, "id");
|
|
487
|
+
if (id && !parsedIds.has(id)) {
|
|
488
|
+
const cell = buildCellFromMxCell(mx.attrs, mx.inner, mx.fullTag);
|
|
489
|
+
if (cell) {
|
|
490
|
+
cells.push(cell);
|
|
491
|
+
parsedIds.add(cell.id);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
mxStart = indexOfTagStart(xml, "mxCell", mx.next);
|
|
495
|
+
} else mxStart = indexOfTagStart(xml, "mxCell", mxStart + 7);
|
|
341
496
|
}
|
|
342
497
|
return cells;
|
|
343
498
|
}
|
|
344
|
-
|
|
345
|
-
|
|
499
|
+
/** Map draw.io endArrow/startArrow style value to LikeC4 RelationshipArrowType. */
|
|
500
|
+
function likec4Arrow(drawioArrow) {
|
|
501
|
+
if (!drawioArrow || drawioArrow === "") return void 0;
|
|
502
|
+
switch (drawioArrow.toLowerCase()) {
|
|
503
|
+
case "none": return "none";
|
|
504
|
+
case "block": return "normal";
|
|
505
|
+
case "open": return "open";
|
|
506
|
+
case "diamond": return "diamond";
|
|
507
|
+
case "oval": return "dot";
|
|
508
|
+
default: return "normal";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** Map draw.io dashed/dashPattern to LikeC4 line type. */
|
|
512
|
+
function likec4LineType(dashed, dashPattern) {
|
|
513
|
+
if (dashed === "1" || dashed === "true") {
|
|
514
|
+
const pattern = (dashPattern ?? "").trim();
|
|
515
|
+
if (pattern === "1 1" || pattern === "2 2") return "dotted";
|
|
516
|
+
return "dashed";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Infer LikeC4 element kind from DrawIO shape style. When parent is a container (container=1), child is component.
|
|
521
|
+
* Explicit container=1 in style → system (context box); others default to container unless actor/swimlane.
|
|
522
|
+
*/
|
|
523
|
+
function inferKind(style, parentCell) {
|
|
524
|
+
const s = style?.toLowerCase() ?? "";
|
|
525
|
+
switch (true) {
|
|
526
|
+
case !style: return parentCell?.style?.toLowerCase().includes("container=1") ? "component" : "container";
|
|
527
|
+
case s.includes("umlactor") || s.includes("shape=person"): return "actor";
|
|
528
|
+
case s.includes("swimlane"):
|
|
529
|
+
case s.includes("container=1"): return "system";
|
|
530
|
+
case !!parentCell?.style?.toLowerCase().includes("container=1"): return "component";
|
|
531
|
+
default: return "container";
|
|
532
|
+
}
|
|
346
533
|
}
|
|
347
|
-
|
|
348
|
-
|
|
534
|
+
/** Infer LikeC4 shape from DrawIO style when possible (cylinder, document, etc.). */
|
|
535
|
+
function inferShape(style) {
|
|
536
|
+
if (!style) return void 0;
|
|
349
537
|
const s = style.toLowerCase();
|
|
350
|
-
if (s.includes("
|
|
351
|
-
if (s.includes("
|
|
352
|
-
return "
|
|
538
|
+
if (s.includes("shape=cylinder") || s.includes("cylinder3")) return "cylinder";
|
|
539
|
+
if (s.includes("shape=document")) return "document";
|
|
540
|
+
if (s.includes("shape=rectangle") && s.includes("rounded")) return "rectangle";
|
|
353
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* Sanitize a string for use as LikeC4 identifier (element name).
|
|
544
|
+
*/
|
|
354
545
|
function toId(name) {
|
|
355
|
-
return name.trim().
|
|
546
|
+
return name.trim().replaceAll(/\s+/g, "_").replaceAll(/[^\w-]/g, "").replace(/^[0-9]/, "_$&") || "element";
|
|
356
547
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
for (const c of cells) byId.set(c.id, c);
|
|
361
|
-
const vertices = cells.filter((c) => c.vertex && c.id !== "1");
|
|
362
|
-
const edges = cells.filter((c) => c.edge && c.source && c.target);
|
|
363
|
-
const rootId = "1";
|
|
364
|
-
const idToFqn = /* @__PURE__ */ new Map();
|
|
365
|
-
const idToCell = /* @__PURE__ */ new Map();
|
|
366
|
-
for (const v of vertices) idToCell.set(v.id, v);
|
|
367
|
-
const usedNames = /* @__PURE__ */ new Set();
|
|
368
|
-
function uniqueName(base) {
|
|
548
|
+
/** Returns a unique name generator that avoids collisions by appending _1, _2, ... (DRY: used in parseDrawioToLikeC4 and buildDiagramState). */
|
|
549
|
+
function makeUniqueName(usedNames) {
|
|
550
|
+
return (base) => {
|
|
369
551
|
let name = toId(base || "element");
|
|
370
552
|
let n = name;
|
|
371
553
|
let i = 0;
|
|
372
554
|
while (usedNames.has(n)) n = `${name}_${++i}`;
|
|
373
555
|
usedNames.add(n);
|
|
374
556
|
return n;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/** Assign FQNs to element vertices: root first, then hierarchy by parent, then orphans (DRY). */
|
|
560
|
+
function assignFqnsToElementVertices(idToFqn, elementVertices, containerIdToTitle, isRootParent, uniqueName) {
|
|
561
|
+
const baseName = (v) => v.value ?? containerIdToTitle.get(v.id) ?? v.id;
|
|
562
|
+
for (const v of elementVertices) if (isRootParent(v.parent)) idToFqn.set(v.id, uniqueName(baseName(v)));
|
|
380
563
|
let changed = true;
|
|
381
564
|
while (changed) {
|
|
382
565
|
changed = false;
|
|
383
|
-
for (const v of
|
|
566
|
+
for (const v of elementVertices) {
|
|
384
567
|
if (idToFqn.has(v.id)) continue;
|
|
385
568
|
const parent = v.parent ? idToFqn.get(v.parent) : null;
|
|
386
569
|
if (parent != null) {
|
|
387
|
-
|
|
388
|
-
idToFqn.set(v.id, `${parent}.${local}`);
|
|
570
|
+
idToFqn.set(v.id, `${parent}.${uniqueName(baseName(v))}`);
|
|
389
571
|
changed = true;
|
|
390
572
|
}
|
|
391
573
|
}
|
|
392
574
|
}
|
|
393
|
-
for (const v of
|
|
575
|
+
for (const v of elementVertices) if (!idToFqn.has(v.id)) idToFqn.set(v.id, uniqueName(baseName(v)));
|
|
576
|
+
}
|
|
577
|
+
/** Build map of hex color -> custom name from vertices and edges for specification customColors (DRY). */
|
|
578
|
+
function buildHexToCustomName(elementVertices, edges) {
|
|
394
579
|
const hexToCustomName = /* @__PURE__ */ new Map();
|
|
395
580
|
let customColorIndex = 0;
|
|
396
|
-
for (const v of
|
|
581
|
+
for (const v of elementVertices) {
|
|
397
582
|
const fill = v.fillColor?.trim();
|
|
398
583
|
if (fill && /^#[0-9A-Fa-f]{3,8}$/.test(fill)) {
|
|
399
|
-
if (
|
|
584
|
+
if (v.colorName?.trim()) {
|
|
585
|
+
const name = v.colorName.trim().replaceAll(/\s+/g, "_");
|
|
586
|
+
if (!hexToCustomName.has(fill)) hexToCustomName.set(fill, name);
|
|
587
|
+
} else if (!hexToCustomName.has(fill)) hexToCustomName.set(fill, `drawio_color_${++customColorIndex}`);
|
|
400
588
|
}
|
|
401
589
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
lines.push("}");
|
|
407
|
-
lines.push("");
|
|
590
|
+
let edgeColorIndex = 0;
|
|
591
|
+
for (const e of edges) {
|
|
592
|
+
const stroke = e.strokeColor?.trim();
|
|
593
|
+
if (stroke && /^#[0-9A-Fa-f]{3,8}$/.test(stroke) && !hexToCustomName.has(stroke)) hexToCustomName.set(stroke, `drawio_edge_color_${++edgeColorIndex}`);
|
|
408
594
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
fqn
|
|
427
|
-
});
|
|
428
|
-
children.set(parentFqn, list);
|
|
429
|
-
} else roots.push({
|
|
430
|
-
cellId,
|
|
431
|
-
fqn
|
|
432
|
-
});
|
|
595
|
+
return hexToCustomName;
|
|
596
|
+
}
|
|
597
|
+
/** Detect container cells and their title cells (text shape inside/near container); returns maps for FQN resolution (DRY). */
|
|
598
|
+
function computeContainerTitles(vertices) {
|
|
599
|
+
const containerIdToTitle = /* @__PURE__ */ new Map();
|
|
600
|
+
const titleCellIds = /* @__PURE__ */ new Set();
|
|
601
|
+
const containerCells = vertices.filter((v) => v.style?.toLowerCase().includes("container=1") && v.x != null && v.y != null && v.width != null && v.height != null);
|
|
602
|
+
for (const cont of containerCells) {
|
|
603
|
+
const cw = cont.width, ch = cont.height;
|
|
604
|
+
const titleAreaHeight = Math.min(CONTAINER_TITLE_AREA_MAX_HEIGHT_PX, ch * CONTAINER_TITLE_AREA_HEIGHT_RATIO) + CONTAINER_TITLE_AREA_TOLERANCE;
|
|
605
|
+
const best = vertices.find((v) => v.id !== cont.id && v.parent === cont.id && (v.style?.toLowerCase().includes("shape=text") || v.style?.toLowerCase().includes("text;")) && v.x != null && v.y != null && v.x >= -CONTAINER_TITLE_AREA_TOLERANCE && v.x <= cw + CONTAINER_TITLE_AREA_TOLERANCE && v.y >= -CONTAINER_TITLE_AREA_TOLERANCE && v.y <= titleAreaHeight);
|
|
606
|
+
if (best) {
|
|
607
|
+
const raw = (best.value ?? "").trim();
|
|
608
|
+
if (raw) {
|
|
609
|
+
containerIdToTitle.set(cont.id, stripHtml(raw));
|
|
610
|
+
titleCellIds.add(best.id);
|
|
611
|
+
}
|
|
433
612
|
}
|
|
434
613
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (childList && childList.length > 0 || desc || tech || colorName) {
|
|
450
|
-
lines.push(`${pad}{`);
|
|
451
|
-
if (colorName) lines.push(`${pad} style { color ${colorName} }`);
|
|
452
|
-
if (desc) lines.push(`${pad} description '${desc.replace(/'/g, "''")}'`);
|
|
453
|
-
if (tech) lines.push(`${pad} technology '${tech.replace(/'/g, "''")}'`);
|
|
454
|
-
if (childList && childList.length > 0) for (const ch of childList) emitElement(ch.cellId, ch.fqn, indent + 1);
|
|
455
|
-
lines.push(`${pad}}`);
|
|
456
|
-
} else {
|
|
457
|
-
lines.push(`${pad}{`);
|
|
458
|
-
lines.push(`${pad}}`);
|
|
614
|
+
return {
|
|
615
|
+
containerIdToTitle,
|
|
616
|
+
titleCellIds
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/** Strip XML/HTML tags without regex to avoid S5852 (super-linear backtracking). */
|
|
620
|
+
function stripTags(s) {
|
|
621
|
+
let out = "";
|
|
622
|
+
let i = 0;
|
|
623
|
+
while (i < s.length) {
|
|
624
|
+
const open = s.indexOf("<", i);
|
|
625
|
+
if (open === -1) {
|
|
626
|
+
out += s.slice(i);
|
|
627
|
+
break;
|
|
459
628
|
}
|
|
460
|
-
|
|
629
|
+
out += s.slice(i, open);
|
|
630
|
+
const close = s.indexOf(">", open);
|
|
631
|
+
if (close === -1) {
|
|
632
|
+
out += "<";
|
|
633
|
+
i = open + 1;
|
|
634
|
+
} else i = close + 1;
|
|
461
635
|
}
|
|
462
|
-
|
|
463
|
-
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
/** Decode XML entities, take first line up to <br, strip tags. Single implementation for cell value and title (DRY). */
|
|
639
|
+
function stripHtml(raw) {
|
|
640
|
+
if (!raw || raw.trim() === "") return "";
|
|
641
|
+
const firstLine = decodeXmlEntities(raw).split("\n")[0] ?? "";
|
|
642
|
+
const brIdx = firstLine.toLowerCase().indexOf("<br");
|
|
643
|
+
return stripTags(brIdx === -1 ? firstLine : firstLine.slice(0, brIdx)).trim() || "";
|
|
644
|
+
}
|
|
645
|
+
/** Escape single quotes for LikeC4 string literals (DRY). */
|
|
646
|
+
function escapeLikec4Quotes(s) {
|
|
647
|
+
return s.replaceAll("'", "''");
|
|
648
|
+
}
|
|
649
|
+
/** Decode optional root cell style field (likec4ViewTitle, likec4ViewDescription, etc.). Safe against malformed percent-encoding. */
|
|
650
|
+
function decodeRootStyleField(raw) {
|
|
651
|
+
if (raw == null || raw === "") return "";
|
|
652
|
+
try {
|
|
653
|
+
return decodeURIComponent(raw);
|
|
654
|
+
} catch {
|
|
655
|
+
return raw;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Build view block lines for model (single responsibility; DRY with parseDrawioToLikeC4). */
|
|
659
|
+
function buildViewBlockLines(viewId, viewTitle, viewDesc) {
|
|
660
|
+
return [
|
|
661
|
+
"views {",
|
|
662
|
+
` view ${viewId} {`,
|
|
663
|
+
...viewTitle ? [` title '${escapeLikec4Quotes(viewTitle)}'`] : [],
|
|
664
|
+
...viewDesc ? [` description '${escapeLikec4Quotes(viewDesc)}'`] : [],
|
|
665
|
+
" include *",
|
|
666
|
+
" }",
|
|
667
|
+
"}",
|
|
668
|
+
""
|
|
669
|
+
];
|
|
670
|
+
}
|
|
671
|
+
/** Push element declaration line: name = actor|system|container 'title'. */
|
|
672
|
+
function pushElementHeader(ctx, pad, name, kind, title) {
|
|
673
|
+
const escaped = escapeLikec4Quotes(title);
|
|
674
|
+
switch (kind) {
|
|
675
|
+
case "actor":
|
|
676
|
+
ctx.lines.push(`${pad}${name} = actor '${escaped}'`);
|
|
677
|
+
break;
|
|
678
|
+
case "system":
|
|
679
|
+
ctx.lines.push(`${pad}${name} = system '${escaped}'`);
|
|
680
|
+
break;
|
|
681
|
+
default: ctx.lines.push(`${pad}${name} = container '${escaped}'`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Push style { ... } block for element when any style part is present. */
|
|
685
|
+
function pushElementStyleBlock(ctx, pad, cell, colorName) {
|
|
686
|
+
const border = cell.border?.trim();
|
|
687
|
+
const opacityVal = cell.opacity;
|
|
688
|
+
const shapeOverride = inferShape(cell.style);
|
|
689
|
+
const sizeVal = cell.size?.trim();
|
|
690
|
+
const paddingVal = cell.padding?.trim();
|
|
691
|
+
const textSizeVal = cell.textSize?.trim();
|
|
692
|
+
const iconPositionVal = cell.iconPosition?.trim();
|
|
693
|
+
if (!colorName && !(border && [
|
|
694
|
+
"solid",
|
|
695
|
+
"dashed",
|
|
696
|
+
"dotted",
|
|
697
|
+
"none"
|
|
698
|
+
].includes(border)) && !(opacityVal && /^\d+$/.test(opacityVal)) && !shapeOverride && !sizeVal && !paddingVal && !textSizeVal && !iconPositionVal) return;
|
|
699
|
+
const styleParts = [];
|
|
700
|
+
if (colorName) styleParts.push(`color ${colorName}`);
|
|
701
|
+
if (border && [
|
|
702
|
+
"solid",
|
|
703
|
+
"dashed",
|
|
704
|
+
"dotted",
|
|
705
|
+
"none"
|
|
706
|
+
].includes(border)) styleParts.push(`border ${border}`);
|
|
707
|
+
if (opacityVal && /^\d+$/.test(opacityVal)) styleParts.push(`opacity ${opacityVal}`);
|
|
708
|
+
if (shapeOverride) styleParts.push(`shape ${shapeOverride}`);
|
|
709
|
+
if (sizeVal) styleParts.push(`size ${sizeVal}`);
|
|
710
|
+
if (paddingVal) styleParts.push(`padding ${paddingVal}`);
|
|
711
|
+
if (textSizeVal) styleParts.push(`textSize ${textSizeVal}`);
|
|
712
|
+
if (iconPositionVal) styleParts.push(`iconPosition ${iconPositionVal}`);
|
|
713
|
+
if (styleParts.length > 0) ctx.lines.push(`${pad} style { ${styleParts.join(", ")} }`);
|
|
714
|
+
}
|
|
715
|
+
/** Format link lines for .c4 (DRY: elements and edges). Returns lines with given prefix. */
|
|
716
|
+
function formatLinkLines(linksJson, nativeLink, linePrefix) {
|
|
717
|
+
const out = [];
|
|
718
|
+
try {
|
|
719
|
+
const linksArr = linksJson ? JSON.parse(linksJson) : null;
|
|
720
|
+
if (Array.isArray(linksArr)) {
|
|
721
|
+
for (const link of linksArr) if (link?.url) out.push(`${linePrefix}link '${escapeLikec4Quotes(String(link.url))}'${link.title ? ` '${escapeLikec4Quotes(String(link.title))}'` : ""}`);
|
|
722
|
+
}
|
|
723
|
+
if (out.length === 0 && nativeLink) out.push(`${linePrefix}link '${escapeLikec4Quotes(nativeLink)}'`);
|
|
724
|
+
} catch {
|
|
725
|
+
if (nativeLink) out.push(`${linePrefix}link '${escapeLikec4Quotes(nativeLink)}'`);
|
|
726
|
+
}
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
/** Push link line(s) from linksJson or nativeLink (DRY with emitEdgesToLines). */
|
|
730
|
+
function pushElementLinks(ctx, pad, linksJson, nativeLink) {
|
|
731
|
+
const prefix = `${pad} `;
|
|
732
|
+
for (const line of formatLinkLines(linksJson, nativeLink, prefix)) ctx.lines.push(line);
|
|
733
|
+
}
|
|
734
|
+
/** Whether element has any body content (Clean Code: single place for hasBody condition). */
|
|
735
|
+
function elementHasBody(cell, childList, colorName, opts) {
|
|
736
|
+
return (childList?.length ?? 0) > 0 || !!opts.desc || !!opts.tech || !!opts.notes || !!opts.summary || !!opts.linksJson || !!opts.nativeLink || !!opts.notation || opts.tagList.length > 0 || !!colorName || !!cell.border?.trim() || !!cell.opacity || !!inferShape(cell.style) || !!cell.size || !!cell.padding || !!cell.textSize || !!cell.iconPosition || !!opts.navigateTo || !!opts.icon;
|
|
737
|
+
}
|
|
738
|
+
/** Push element body lines (style, tags, description, links, children). */
|
|
739
|
+
function pushElementBody(ctx, pad, cell, childList, fqn, indent, colorName, opts) {
|
|
740
|
+
ctx.lines.push(`${pad}{`);
|
|
741
|
+
pushElementStyleBlock(ctx, pad, cell, colorName);
|
|
742
|
+
for (const t of opts.tagList) ctx.lines.push(`${pad} #${t.replaceAll(/\s+/g, "_")}`);
|
|
743
|
+
if (opts.desc) ctx.lines.push(`${pad} description '${escapeLikec4Quotes(opts.desc)}'`);
|
|
744
|
+
if (opts.tech) ctx.lines.push(`${pad} technology '${escapeLikec4Quotes(opts.tech)}'`);
|
|
745
|
+
if (opts.summary) ctx.lines.push(`${pad} summary '${escapeLikec4Quotes(opts.summary)}'`);
|
|
746
|
+
if (opts.notes) ctx.lines.push(`${pad} notes '${escapeLikec4Quotes(opts.notes)}'`);
|
|
747
|
+
if (opts.notation) ctx.lines.push(`${pad} notation '${escapeLikec4Quotes(opts.notation)}'`);
|
|
748
|
+
pushElementLinks(ctx, pad, opts.linksJson, opts.nativeLink);
|
|
749
|
+
if (opts.navigateTo) ctx.lines.push(`${pad} navigateTo ${opts.navigateTo}`);
|
|
750
|
+
if (opts.icon) ctx.lines.push(`${pad} icon '${escapeLikec4Quotes(opts.icon)}'`);
|
|
751
|
+
if (childList && childList.length > 0) for (const ch of childList) emitElement.toLines(ctx, ch.cellId, ch.fqn, indent + 1);
|
|
752
|
+
ctx.lines.push(`${pad}}`);
|
|
753
|
+
}
|
|
754
|
+
/** Emit one element (and its children) to LikeC4 source lines (actor/system/container + body). */
|
|
755
|
+
function emitElementToLines(ctx, cellId, fqn, indent) {
|
|
756
|
+
const cell = ctx.idToCell.get(cellId);
|
|
757
|
+
if (!cell) return;
|
|
758
|
+
const parentCell = cell.parent ? ctx.byId.get(cell.parent) : void 0;
|
|
759
|
+
const kind = inferKind(cell.style, parentCell);
|
|
760
|
+
const title = stripHtml(cell.value && cell.value.trim() || "") || (ctx.containerIdToTitle.get(cell.id) ?? ctx.containerIdToTitle.get(cellId) ?? "") || fqn.split(".").pop() || "Element";
|
|
761
|
+
const name = fqn.split(".").pop();
|
|
762
|
+
const pad = " ".repeat(indent);
|
|
763
|
+
const desc = cell.description?.trim();
|
|
764
|
+
const tech = cell.technology?.trim();
|
|
765
|
+
const notes = cell.notes?.trim();
|
|
766
|
+
const tagsStr = cell.tags?.trim();
|
|
767
|
+
const tagList = tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
768
|
+
const navigateTo = cell.navigateTo?.trim();
|
|
769
|
+
const icon = cell.icon?.trim();
|
|
770
|
+
const summary = cell.summary?.trim();
|
|
771
|
+
const linksJson = cell.links?.trim();
|
|
772
|
+
const nativeLink = cell.link?.trim();
|
|
773
|
+
const notation = cell.notation?.trim();
|
|
774
|
+
const colorName = cell.fillColor && /^#[0-9A-Fa-f]{3,8}$/.test(cell.fillColor.trim()) ? ctx.hexToCustomName.get(cell.fillColor.trim()) : void 0;
|
|
775
|
+
const childList = ctx.children.get(fqn);
|
|
776
|
+
const opts = {
|
|
777
|
+
desc,
|
|
778
|
+
tech,
|
|
779
|
+
notes,
|
|
780
|
+
summary,
|
|
781
|
+
linksJson,
|
|
782
|
+
nativeLink,
|
|
783
|
+
notation,
|
|
784
|
+
tagList,
|
|
785
|
+
navigateTo,
|
|
786
|
+
icon
|
|
787
|
+
};
|
|
788
|
+
const hasBody = elementHasBody(cell, childList, colorName, opts);
|
|
789
|
+
pushElementHeader(ctx, pad, name, kind, title);
|
|
790
|
+
if (hasBody) pushElementBody(ctx, pad, cell, childList, fqn, indent, colorName, opts);
|
|
791
|
+
else {
|
|
792
|
+
ctx.lines.push(`${pad}{`);
|
|
793
|
+
ctx.lines.push(`${pad}}`);
|
|
794
|
+
}
|
|
795
|
+
ctx.lines.push("");
|
|
796
|
+
}
|
|
797
|
+
/** Group element-emit helpers for navigation (header, styleBlock, links, body, toLines). */
|
|
798
|
+
const emitElement = { toLines: emitElementToLines };
|
|
799
|
+
/** Emit relation lines (src -> tgt with optional style/body) to LikeC4 source lines. */
|
|
800
|
+
function emitEdgesToLines(lines, edgeEntries, hexToCustomName) {
|
|
801
|
+
for (const { cell: e, src, tgt } of edgeEntries) {
|
|
802
|
+
const title = e.value && e.value.trim() ? escapeLikec4Quotes(e.value.trim()) : "";
|
|
803
|
+
const desc = e.description?.trim();
|
|
804
|
+
const tech = e.technology?.trim();
|
|
805
|
+
const notes = e.notes?.trim();
|
|
806
|
+
const navTo = e.navigateTo?.trim();
|
|
807
|
+
const head = likec4Arrow(e.endArrow);
|
|
808
|
+
const tail = likec4Arrow(e.startArrow);
|
|
809
|
+
const line = likec4LineType(e.dashed, e.dashPattern);
|
|
810
|
+
const relKind = e.relationshipKind?.trim();
|
|
811
|
+
const notation = e.notation?.trim();
|
|
812
|
+
const linksJson = e.links?.trim();
|
|
813
|
+
const metadataJson = e.metadata?.trim();
|
|
814
|
+
const edgeStrokeHex = e.strokeColor?.trim();
|
|
815
|
+
const hasBody = !!notes || !!navTo || !!head || !!tail || !!line || !!notation || !!linksJson || !!metadataJson || !!edgeStrokeHex;
|
|
816
|
+
const relationHead = ` ${src}${relKind && /^[a-zA-Z0-9_-]+$/.test(relKind) ? ` -[${relKind}]-> ` : " -> "}${tgt}${title ? ` '${title}'` : desc || tech ? ` ''` : ""}${desc ? ` '${escapeLikec4Quotes(desc)}'` : ""}${tech ? ` '${escapeLikec4Quotes(tech)}'` : ""}`;
|
|
817
|
+
if (hasBody) {
|
|
818
|
+
const bodyLines = [];
|
|
819
|
+
if (notes) bodyLines.push(` notes '${escapeLikec4Quotes(notes)}'`);
|
|
820
|
+
if (navTo) bodyLines.push(` navigateTo ${navTo}`);
|
|
821
|
+
if (notation) bodyLines.push(` notation '${escapeLikec4Quotes(notation)}'`);
|
|
822
|
+
for (const linkLine of formatLinkLines(linksJson, void 0, " ")) bodyLines.push(linkLine);
|
|
823
|
+
try {
|
|
824
|
+
const metaObj = metadataJson ? JSON.parse(metadataJson) : null;
|
|
825
|
+
if (metaObj != null && typeof metaObj === "object" && !Array.isArray(metaObj)) {
|
|
826
|
+
const metaAttrs = [];
|
|
827
|
+
for (const [k, v] of Object.entries(metaObj)) {
|
|
828
|
+
if (k.trim() === "") continue;
|
|
829
|
+
const safeKey = /^[a-zA-Z_]\w*$/.test(k) ? k : `'${escapeLikec4Quotes(k)}'`;
|
|
830
|
+
if (Array.isArray(v)) {
|
|
831
|
+
const arrVals = v.map((s) => `'${escapeLikec4Quotes(String(s))}'`);
|
|
832
|
+
metaAttrs.push(` ${safeKey} [ ${arrVals.join(", ")} ];`);
|
|
833
|
+
} else if (v != null && typeof v === "string") metaAttrs.push(` ${safeKey} '${escapeLikec4Quotes(v)}';`);
|
|
834
|
+
}
|
|
835
|
+
if (metaAttrs.length > 0) bodyLines.push(" metadata {" + metaAttrs.join("") + " }");
|
|
836
|
+
}
|
|
837
|
+
} catch {}
|
|
838
|
+
if (head || tail || line || edgeStrokeHex) {
|
|
839
|
+
const parts = [];
|
|
840
|
+
if (line) parts.push(`line ${line}`);
|
|
841
|
+
if (head) parts.push(`head ${head}`);
|
|
842
|
+
if (tail) parts.push(`tail ${tail}`);
|
|
843
|
+
if (edgeStrokeHex && /^#[0-9A-Fa-f]{3,8}$/.test(edgeStrokeHex)) {
|
|
844
|
+
const edgeColorName = hexToCustomName.get(edgeStrokeHex);
|
|
845
|
+
if (edgeColorName) parts.push(`color ${edgeColorName}`);
|
|
846
|
+
}
|
|
847
|
+
if (parts.length > 0) bodyLines.push(` style { ${parts.join(", ")} }`);
|
|
848
|
+
}
|
|
849
|
+
lines.push(relationHead + " {", ...bodyLines, " }");
|
|
850
|
+
} else lines.push(relationHead);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Collect layout, stroke, customData and waypoint lines for one diagram state (shared by single and multi emit). Single pass over idToFqn. */
|
|
854
|
+
function collectRoundtripForState(viewId, idToFqn, byId, edges) {
|
|
855
|
+
const layoutNodes = {};
|
|
856
|
+
const strokeColorLines = [];
|
|
857
|
+
const strokeWidthLines = [];
|
|
858
|
+
const customDataLines = [];
|
|
859
|
+
const waypointLines = [];
|
|
860
|
+
for (const [cellId, fqn] of idToFqn) {
|
|
861
|
+
const cell = byId.get(cellId);
|
|
862
|
+
if (cell?.vertex && cell.x != null && cell.y != null && cell.width != null && cell.height != null) layoutNodes[fqn] = {
|
|
863
|
+
x: cell.x,
|
|
864
|
+
y: cell.y,
|
|
865
|
+
width: cell.width,
|
|
866
|
+
height: cell.height
|
|
867
|
+
};
|
|
868
|
+
if (cell?.vertex) {
|
|
869
|
+
if (cell.strokeColor?.trim() && /^#[0-9A-Fa-f]{3,8}$/.test(cell.strokeColor.trim())) strokeColorLines.push(`// ${fqn}=${cell.strokeColor.trim()}`);
|
|
870
|
+
if (cell.strokeWidth != null && cell.strokeWidth.trim() !== "") strokeWidthLines.push(`// ${fqn}=${cell.strokeWidth.trim()}`);
|
|
871
|
+
}
|
|
872
|
+
if (cell?.customData?.trim()) customDataLines.push(`// ${fqn} ${cell.customData.trim()}`);
|
|
873
|
+
}
|
|
874
|
+
const edgesWithEndpoints = edges.filter(isEdgeWithEndpoints);
|
|
875
|
+
for (const e of edgesWithEndpoints) {
|
|
464
876
|
const src = idToFqn.get(e.source);
|
|
465
877
|
const tgt = idToFqn.get(e.target);
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
|
|
878
|
+
if (src && tgt) {
|
|
879
|
+
if (e.customData?.trim()) customDataLines.push(`// ${src}|${tgt} ${e.customData.trim()}`);
|
|
880
|
+
if (e.edgePoints?.trim()) waypointLines.push(`// ${src}|${tgt}|${e.id} ${e.edgePoints.trim()}`);
|
|
881
|
+
}
|
|
469
882
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
883
|
+
return {
|
|
884
|
+
layoutNodes,
|
|
885
|
+
strokeColorLines,
|
|
886
|
+
strokeWidthLines,
|
|
887
|
+
customDataLines,
|
|
888
|
+
waypointLines
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** Append round-trip comment blocks for one diagram (layout, stroke, customData, waypoints) to lines. */
|
|
892
|
+
function emitRoundtripCommentsSingle(lines, viewId, idToFqn, byId, edges) {
|
|
893
|
+
const r = collectRoundtripForState(viewId, idToFqn, byId, edges);
|
|
894
|
+
if (Object.keys(r.layoutNodes).length > 0) lines.push("// <likec4.layout.drawio>", "// " + JSON.stringify({ [viewId]: { nodes: r.layoutNodes } }), "// </likec4.layout.drawio>");
|
|
895
|
+
if (r.strokeColorLines.length > 0) lines.push("// <likec4.strokeColor.vertices>", ...r.strokeColorLines, "// </likec4.strokeColor.vertices>");
|
|
896
|
+
if (r.strokeWidthLines.length > 0) lines.push("// <likec4.strokeWidth.vertices>", ...r.strokeWidthLines, "// </likec4.strokeWidth.vertices>");
|
|
897
|
+
if (r.customDataLines.length > 0) lines.push("// <likec4.customData>", ...r.customDataLines, "// </likec4.customData>");
|
|
898
|
+
if (r.waypointLines.length > 0) lines.push("// <likec4.edge.waypoints>", ...r.waypointLines, "// </likec4.edge.waypoints>");
|
|
899
|
+
}
|
|
900
|
+
/** Append merged round-trip comment blocks for multiple diagrams to lines. */
|
|
901
|
+
function emitRoundtripCommentsMulti(lines, states) {
|
|
902
|
+
const layoutByView = {};
|
|
903
|
+
const strokeColorLines = [];
|
|
904
|
+
const strokeWidthLines = [];
|
|
905
|
+
const customDataLines = [];
|
|
906
|
+
const waypointLines = [];
|
|
907
|
+
for (const st of states) {
|
|
908
|
+
const r = collectRoundtripForState(st.viewId, st.idToFqn, st.idToCell, st.edges);
|
|
909
|
+
layoutByView[st.viewId] = { nodes: r.layoutNodes };
|
|
910
|
+
strokeColorLines.push(...r.strokeColorLines);
|
|
911
|
+
strokeWidthLines.push(...r.strokeWidthLines);
|
|
912
|
+
customDataLines.push(...r.customDataLines);
|
|
913
|
+
waypointLines.push(...r.waypointLines);
|
|
914
|
+
}
|
|
915
|
+
if (Object.values(layoutByView).some((v) => v != null && Object.keys(v.nodes).length > 0)) lines.push("// <likec4.layout.drawio>", "// " + JSON.stringify(layoutByView), "// </likec4.layout.drawio>");
|
|
916
|
+
if (strokeColorLines.length > 0) lines.push("// <likec4.strokeColor.vertices>", ...strokeColorLines, "// </likec4.strokeColor.vertices>");
|
|
917
|
+
if (strokeWidthLines.length > 0) lines.push("// <likec4.strokeWidth.vertices>", ...strokeWidthLines, "// </likec4.strokeWidth.vertices>");
|
|
918
|
+
if (customDataLines.length > 0) lines.push("// <likec4.customData>", ...customDataLines, "// </likec4.customData>");
|
|
919
|
+
if (waypointLines.length > 0) lines.push("// <likec4.edge.waypoints>", ...waypointLines, "// </likec4.edge.waypoints>");
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Decompress draw.io diagram content: base64 → inflateRaw → decodeURIComponent.
|
|
923
|
+
* Exported for tests (error message contract). Handles both Node (Buffer) and browser (atob).
|
|
924
|
+
* @param base64Content - Compressed diagram string from <diagram> inner content.
|
|
925
|
+
* @returns Decoded mxGraphModel XML string.
|
|
926
|
+
* @throws Error when base64 decode, inflate, or URI decode fails.
|
|
927
|
+
*/
|
|
928
|
+
function decompressDrawioDiagram(base64Content) {
|
|
929
|
+
const trimmed = base64Content.trim();
|
|
930
|
+
let bytes;
|
|
931
|
+
try {
|
|
932
|
+
if (typeof Buffer !== "undefined") bytes = new Uint8Array(Buffer.from(trimmed, "base64"));
|
|
933
|
+
else {
|
|
934
|
+
const binary = atob(trimmed);
|
|
935
|
+
bytes = new Uint8Array(binary.length);
|
|
936
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) & 255;
|
|
937
|
+
}
|
|
938
|
+
} catch (err) {
|
|
939
|
+
throw new Error(`DrawIO diagram decompression failed (base64 decode): ${toErrorMessage(err)}`);
|
|
940
|
+
}
|
|
941
|
+
let inflated;
|
|
942
|
+
try {
|
|
943
|
+
inflated = pako.inflateRaw(bytes, { to: "string" });
|
|
944
|
+
} catch (err) {
|
|
945
|
+
throw new Error(`DrawIO diagram decompression failed (inflate): ${toErrorMessage(err)}`);
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
return decodeURIComponent(inflated);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
throw new Error(`DrawIO diagram decompression failed (URI decode): ${toErrorMessage(err)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Extract first diagram name, id and content from mxfile. Handles compressed (base64+deflate) and uncompressed content.
|
|
955
|
+
*/
|
|
956
|
+
function getFirstDiagram(fullXml) {
|
|
957
|
+
return getAllDiagrams(fullXml)[0] ?? {
|
|
958
|
+
name: "index",
|
|
959
|
+
id: `${DRAWIO_DIAGRAM_ID_PREFIX}index`,
|
|
960
|
+
content: ""
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** Length of '<diagram' open tag for slice offset in getAllDiagrams (indexOf-based to avoid regex DoS S5852). */
|
|
964
|
+
const DIAGRAM_TAG_OPEN_LEN = 8;
|
|
965
|
+
/**
|
|
966
|
+
* Extract all diagram name, id and content from mxfile (for multi-tab .drawio).
|
|
967
|
+
* Uses indexOf-based extraction instead of regex to avoid S5852 (super-linear backtracking DoS).
|
|
968
|
+
* @param fullXml - Full .drawio mxfile XML string.
|
|
969
|
+
* @returns Array of DiagramInfo (one per <diagram>); content decompressed when needed.
|
|
970
|
+
*/
|
|
971
|
+
function getAllDiagrams(fullXml) {
|
|
972
|
+
const results = [];
|
|
973
|
+
const closeDiagramLen = 10;
|
|
974
|
+
let start = indexOfTagStart(fullXml, "diagram", 0);
|
|
975
|
+
while (start !== -1) {
|
|
976
|
+
const endOpen = findOpenTagEnd(fullXml, start);
|
|
977
|
+
if (endOpen === -1) break;
|
|
978
|
+
const attrs = fullXml.slice(start + DIAGRAM_TAG_OPEN_LEN, endOpen).trim();
|
|
979
|
+
const closeStart = indexOfClosingTag(fullXml, "diagram", endOpen + 1);
|
|
980
|
+
if (closeStart === -1) break;
|
|
981
|
+
const inner = fullXml.slice(endOpen + 1, closeStart);
|
|
982
|
+
const name = getAttr(attrs, "name") ?? (results.length === 0 ? "index" : `diagram_${results.length + 1}`);
|
|
983
|
+
const id = getAttr(attrs, "id") ?? `${DRAWIO_DIAGRAM_ID_PREFIX}${name}`;
|
|
984
|
+
let content;
|
|
985
|
+
if (inner.includes("<mxGraphModel")) content = inner;
|
|
986
|
+
else if (inner.trim() === "") content = inner;
|
|
987
|
+
else try {
|
|
988
|
+
content = decompressDrawioDiagram(inner);
|
|
989
|
+
} catch {
|
|
990
|
+
content = inner;
|
|
991
|
+
}
|
|
992
|
+
results.push({
|
|
993
|
+
name,
|
|
994
|
+
id,
|
|
995
|
+
content
|
|
996
|
+
});
|
|
997
|
+
start = indexOfTagStart(fullXml, "diagram", closeStart + closeDiagramLen);
|
|
998
|
+
}
|
|
999
|
+
return results;
|
|
1000
|
+
}
|
|
1001
|
+
/** Split parsed cells into vertices and edges (single responsibility). */
|
|
1002
|
+
function verticesAndEdgesFromCells(cells) {
|
|
1003
|
+
return {
|
|
1004
|
+
vertices: cells.filter((c) => c.vertex && c.id !== "0" && c.id !== "1"),
|
|
1005
|
+
edges: cells.filter((c) => c.edge && c.source && c.target)
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
/** Build common diagram state from cells and diagram name (byId, FQN/hierarchy, view fields from root). */
|
|
1009
|
+
function buildCommonDiagramStateFromCells(cells, diagramName) {
|
|
1010
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1011
|
+
for (const c of cells) byId.set(c.id, c);
|
|
1012
|
+
const { vertices, edges } = verticesAndEdgesFromCells(cells);
|
|
1013
|
+
const rootIds = new Set(["0", "1"]);
|
|
1014
|
+
const isRootParent = (p) => !p || rootIds.has(p);
|
|
1015
|
+
const idToFqn = /* @__PURE__ */ new Map();
|
|
1016
|
+
const idToCell = /* @__PURE__ */ new Map();
|
|
1017
|
+
for (const v of vertices) idToCell.set(v.id, v);
|
|
1018
|
+
const { containerIdToTitle, titleCellIds } = computeContainerTitles(vertices);
|
|
1019
|
+
const elementVertices = vertices.filter((v) => !titleCellIds.has(v.id));
|
|
1020
|
+
assignFqnsToElementVertices(idToFqn, elementVertices, containerIdToTitle, isRootParent, makeUniqueName(/* @__PURE__ */ new Set()));
|
|
1021
|
+
const hexToCustomName = buildHexToCustomName(elementVertices, edges);
|
|
1022
|
+
const children = /* @__PURE__ */ new Map();
|
|
1023
|
+
const roots = [];
|
|
1024
|
+
for (const [cellId, fqn] of idToFqn) {
|
|
1025
|
+
const cell = idToCell.get(cellId);
|
|
1026
|
+
if (cell) if (isRootParent(cell.parent)) roots.push({
|
|
1027
|
+
cellId,
|
|
1028
|
+
fqn
|
|
1029
|
+
});
|
|
1030
|
+
else {
|
|
1031
|
+
const parentFqn = cell.parent != null ? idToFqn.get(cell.parent) : void 0;
|
|
1032
|
+
if (parentFqn != null) {
|
|
1033
|
+
const list = children.get(parentFqn) ?? [];
|
|
1034
|
+
list.push({
|
|
1035
|
+
cellId,
|
|
1036
|
+
fqn
|
|
1037
|
+
});
|
|
1038
|
+
children.set(parentFqn, list);
|
|
1039
|
+
} else roots.push({
|
|
1040
|
+
cellId,
|
|
1041
|
+
fqn
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const viewId = toId(diagramName?.trim() ? diagramName : "index") || "index";
|
|
1046
|
+
const rootCell = byId.get("1");
|
|
1047
|
+
const rootStyle = rootCell?.style ? parseStyle(rootCell.style) : /* @__PURE__ */ new Map();
|
|
1048
|
+
return {
|
|
1049
|
+
idToFqn,
|
|
1050
|
+
idToCell,
|
|
1051
|
+
containerIdToTitle,
|
|
1052
|
+
roots,
|
|
1053
|
+
children,
|
|
1054
|
+
hexToCustomName,
|
|
1055
|
+
edges,
|
|
1056
|
+
viewId,
|
|
1057
|
+
viewTitle: decodeRootStyleField(rootStyle.get("likec4viewtitle")),
|
|
1058
|
+
viewDesc: decodeRootStyleField(rootStyle.get("likec4viewdescription")),
|
|
1059
|
+
viewNotation: decodeRootStyleField(rootStyle.get("likec4viewnotation")),
|
|
1060
|
+
byId
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
/** Build single-diagram state from cells and diagram name (orchestrator: common state + lines buffer). */
|
|
1064
|
+
function buildSingleDiagramState(cells, diagramName) {
|
|
1065
|
+
const common = buildCommonDiagramStateFromCells(cells, diagramName);
|
|
1066
|
+
const { hexToCustomName } = common;
|
|
1067
|
+
const lines = [];
|
|
1068
|
+
if (hexToCustomName.size > 0) {
|
|
1069
|
+
lines.push("specification {");
|
|
1070
|
+
for (const [hex, name] of hexToCustomName) lines.push(` color ${name} ${hex}`);
|
|
1071
|
+
lines.push("}", "");
|
|
1072
|
+
}
|
|
1073
|
+
lines.push("model {", "");
|
|
1074
|
+
return {
|
|
1075
|
+
...common,
|
|
1076
|
+
lines
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/** Emit LikeC4 source from single-diagram state (orchestrator: elements + edges + view + roundtrip). */
|
|
1080
|
+
function emitLikeC4SourceFromSingleState(state) {
|
|
1081
|
+
const { lines, idToCell, containerIdToTitle, children, hexToCustomName, byId } = state;
|
|
1082
|
+
const emitCtx = {
|
|
1083
|
+
lines,
|
|
1084
|
+
idToCell,
|
|
1085
|
+
containerIdToTitle,
|
|
1086
|
+
children,
|
|
1087
|
+
hexToCustomName,
|
|
1088
|
+
byId
|
|
1089
|
+
};
|
|
1090
|
+
for (const { cellId, fqn } of state.roots) emitElement.toLines(emitCtx, cellId, fqn, 1);
|
|
1091
|
+
const edgeEntries = [];
|
|
1092
|
+
for (const e of state.edges.filter(isEdgeWithEndpoints)) {
|
|
1093
|
+
const src = state.idToFqn.get(e.source);
|
|
1094
|
+
const tgt = state.idToFqn.get(e.target);
|
|
1095
|
+
if (src && tgt) edgeEntries.push({
|
|
1096
|
+
cell: e,
|
|
1097
|
+
src,
|
|
1098
|
+
tgt
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
emitEdgesToLines(lines, edgeEntries, hexToCustomName);
|
|
1102
|
+
lines.push("}", "");
|
|
1103
|
+
lines.push(...buildViewBlockLines(state.viewId, state.viewTitle, state.viewDesc));
|
|
1104
|
+
if (state.viewNotation) lines.push(`// likec4.view.notation ${state.viewId} '${escapeLikec4Quotes(state.viewNotation)}'`);
|
|
1105
|
+
emitRoundtripCommentsSingle(lines, state.viewId, state.idToFqn, byId, state.edges);
|
|
1106
|
+
return lines.join("\n");
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Convert DrawIO XML to LikeC4 source (.c4) string.
|
|
1110
|
+
* Vertices → model elements (actor/container); edges → relations (->). Uses first diagram only.
|
|
1111
|
+
* @param xml - Full .drawio mxfile XML (single or multi-tab).
|
|
1112
|
+
* @returns LikeC4 .c4 source string (model + views + round-trip comments).
|
|
1113
|
+
*/
|
|
1114
|
+
function parseDrawioToLikeC4(xml) {
|
|
1115
|
+
const { name: diagramName, content: xmlToParse } = getFirstDiagram(xml);
|
|
1116
|
+
return emitLikeC4SourceFromSingleState(buildSingleDiagramState(parseDrawioXml(xmlToParse), diagramName));
|
|
1117
|
+
}
|
|
1118
|
+
/** Build diagram state from XML content and name (for multi-tab); returns null if parse fails. */
|
|
1119
|
+
function buildDiagramState(content, diagramName) {
|
|
1120
|
+
const { byId: _byId, ...diagramState } = buildCommonDiagramStateFromCells(parseDrawioXml(content), diagramName);
|
|
1121
|
+
return diagramState;
|
|
1122
|
+
}
|
|
1123
|
+
/** Merge multiple diagram states into shared maps and view infos (single responsibility). */
|
|
1124
|
+
function mergeDiagramStatesIntoMaps(states) {
|
|
1125
|
+
const fqnToCell = /* @__PURE__ */ new Map();
|
|
1126
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1127
|
+
const containerIdToTitle = /* @__PURE__ */ new Map();
|
|
1128
|
+
const relationKeyToEdge = /* @__PURE__ */ new Map();
|
|
1129
|
+
const hexToCustomName = /* @__PURE__ */ new Map();
|
|
1130
|
+
const viewInfos = [];
|
|
1131
|
+
for (const st of states) {
|
|
1132
|
+
for (const [cellId, fqn] of st.idToFqn) {
|
|
1133
|
+
const cell = st.idToCell.get(cellId);
|
|
1134
|
+
if (cell && !fqnToCell.has(fqn)) fqnToCell.set(fqn, cell);
|
|
1135
|
+
}
|
|
1136
|
+
for (const [id, cell] of st.idToCell) {
|
|
1137
|
+
if (byId.has(id)) continue;
|
|
1138
|
+
byId.set(id, cell);
|
|
1139
|
+
}
|
|
1140
|
+
for (const [id, title] of st.containerIdToTitle) {
|
|
1141
|
+
if (containerIdToTitle.has(id)) continue;
|
|
1142
|
+
containerIdToTitle.set(id, title);
|
|
1143
|
+
}
|
|
1144
|
+
for (const e of st.edges.filter(isEdgeWithEndpoints)) {
|
|
1145
|
+
const src = st.idToFqn.get(e.source);
|
|
1146
|
+
const tgt = st.idToFqn.get(e.target);
|
|
1147
|
+
if (src && tgt) {
|
|
1148
|
+
const key = `${src}|${tgt}`;
|
|
1149
|
+
if (relationKeyToEdge.has(key)) continue;
|
|
1150
|
+
relationKeyToEdge.set(key, {
|
|
1151
|
+
src,
|
|
1152
|
+
tgt,
|
|
1153
|
+
cell: e
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
for (const [hex, name] of st.hexToCustomName) {
|
|
1158
|
+
if (hexToCustomName.has(hex)) continue;
|
|
1159
|
+
hexToCustomName.set(hex, name);
|
|
1160
|
+
}
|
|
1161
|
+
viewInfos.push({
|
|
1162
|
+
viewId: st.viewId,
|
|
1163
|
+
viewTitle: st.viewTitle,
|
|
1164
|
+
viewDesc: st.viewDesc,
|
|
1165
|
+
viewNotation: st.viewNotation,
|
|
1166
|
+
fqnSet: new Set(st.idToFqn.values())
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
fqnToCell,
|
|
1171
|
+
byId,
|
|
1172
|
+
containerIdToTitle,
|
|
1173
|
+
relationKeyToEdge,
|
|
1174
|
+
hexToCustomName,
|
|
1175
|
+
viewInfos
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/** Build parent → children FQN map and root FQNs (single responsibility). */
|
|
1179
|
+
function buildRootsFromFqnToCell(fqnToCell) {
|
|
1180
|
+
const rootsFromMap = /* @__PURE__ */ new Map();
|
|
1181
|
+
for (const fqn of fqnToCell.keys()) {
|
|
1182
|
+
const parent = fqn.includes(".") ? fqn.split(".").slice(0, -1).join(".") : "";
|
|
1183
|
+
if (parent && fqnToCell.has(parent)) {
|
|
1184
|
+
const list = rootsFromMap.get(parent) ?? [];
|
|
1185
|
+
list.push(fqn);
|
|
1186
|
+
rootsFromMap.set(parent, list);
|
|
1187
|
+
} else {
|
|
1188
|
+
const list = rootsFromMap.get("") ?? [];
|
|
1189
|
+
list.push(fqn);
|
|
1190
|
+
rootsFromMap.set("", list);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
rootsFromMap,
|
|
1195
|
+
rootFqns: rootsFromMap.get("") ?? []
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
/** Emit specification + model { elements + edges } for multi-diagram (single responsibility). */
|
|
1199
|
+
function emitMultiDiagramModel(lines, merged, rootsFromMap, rootFqns) {
|
|
1200
|
+
if (merged.hexToCustomName.size > 0) {
|
|
1201
|
+
lines.push("specification {");
|
|
1202
|
+
for (const [hex, name] of merged.hexToCustomName) lines.push(` color ${name} ${hex}`);
|
|
1203
|
+
lines.push("}", "");
|
|
1204
|
+
}
|
|
1205
|
+
lines.push("model {", "");
|
|
1206
|
+
const idToCellMulti = /* @__PURE__ */ new Map();
|
|
1207
|
+
for (const [fqn, cell] of merged.fqnToCell) idToCellMulti.set(fqn, cell);
|
|
1208
|
+
const childrenMulti = /* @__PURE__ */ new Map();
|
|
1209
|
+
for (const [parentFqn, childFqns] of rootsFromMap) {
|
|
1210
|
+
if (parentFqn === "") continue;
|
|
1211
|
+
const list = childFqns.map((cf) => ({
|
|
1212
|
+
cellId: cf,
|
|
1213
|
+
fqn: cf
|
|
1214
|
+
}));
|
|
1215
|
+
if (list.length > 0) childrenMulti.set(parentFqn, list);
|
|
1216
|
+
}
|
|
1217
|
+
const emitCtxMulti = {
|
|
1218
|
+
lines,
|
|
1219
|
+
idToCell: idToCellMulti,
|
|
1220
|
+
containerIdToTitle: merged.containerIdToTitle,
|
|
1221
|
+
children: childrenMulti,
|
|
1222
|
+
hexToCustomName: merged.hexToCustomName,
|
|
1223
|
+
byId: merged.byId
|
|
1224
|
+
};
|
|
1225
|
+
for (const fqn of rootFqns) emitElement.toLines(emitCtxMulti, fqn, fqn, 1);
|
|
1226
|
+
const edgeEntriesMulti = [];
|
|
1227
|
+
for (const { src, tgt, cell } of merged.relationKeyToEdge.values()) edgeEntriesMulti.push({
|
|
1228
|
+
cell,
|
|
1229
|
+
src,
|
|
1230
|
+
tgt
|
|
1231
|
+
});
|
|
1232
|
+
emitEdgesToLines(lines, edgeEntriesMulti, merged.hexToCustomName);
|
|
1233
|
+
lines.push("}", "");
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Convert DrawIO XML to LikeC4 source when file has multiple diagrams (tabs).
|
|
1237
|
+
* Merges elements by FQN and relations by (source, target); one view per diagram.
|
|
1238
|
+
* @param xml - Full .drawio mxfile XML with multiple <diagram> elements.
|
|
1239
|
+
* @returns LikeC4 .c4 source string (model + views + round-trip comments).
|
|
1240
|
+
*/
|
|
1241
|
+
function parseDrawioToLikeC4Multi(xml) {
|
|
1242
|
+
const diagrams = getAllDiagrams(xml);
|
|
1243
|
+
if (diagrams.length === 0) return `model {
|
|
1244
|
+
|
|
1245
|
+
}
|
|
1246
|
+
views {
|
|
1247
|
+
view index {
|
|
1248
|
+
include *
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
`;
|
|
1252
|
+
if (diagrams.length === 1) {
|
|
1253
|
+
const d = diagrams[0];
|
|
1254
|
+
return emitLikeC4SourceFromSingleState(buildSingleDiagramState(parseDrawioXml(d.content), d.name));
|
|
1255
|
+
}
|
|
1256
|
+
const states = [];
|
|
1257
|
+
for (const d of diagrams) {
|
|
1258
|
+
const s = buildDiagramState(d.content, d.name);
|
|
1259
|
+
if (s) states.push(s);
|
|
1260
|
+
}
|
|
1261
|
+
if (states.length === 0) return parseDrawioToLikeC4(xml);
|
|
1262
|
+
const merged = mergeDiagramStatesIntoMaps(states);
|
|
1263
|
+
const { rootsFromMap, rootFqns } = buildRootsFromFqnToCell(merged.fqnToCell);
|
|
1264
|
+
const lines = [];
|
|
1265
|
+
emitMultiDiagramModel(lines, merged, rootsFromMap, rootFqns);
|
|
1266
|
+
lines.push("views {");
|
|
1267
|
+
for (const v of merged.viewInfos) {
|
|
1268
|
+
const includeList = [...v.fqnSet].sort((a, b) => a.localeCompare(b));
|
|
1269
|
+
lines.push(` view ${v.viewId} {`, ...v.viewTitle ? [` title '${escapeLikec4Quotes(v.viewTitle)}'`] : [], ...v.viewDesc ? [` description '${escapeLikec4Quotes(v.viewDesc)}'`] : [], ` include ${includeList.length > 0 ? includeList.join(", ") : "*"}`, " }");
|
|
1270
|
+
}
|
|
1271
|
+
lines.push("}", "");
|
|
1272
|
+
for (const v of merged.viewInfos) if (v.viewNotation) lines.push(`// likec4.view.notation ${v.viewId} '${escapeLikec4Quotes(v.viewNotation)}'`);
|
|
1273
|
+
emitRoundtripCommentsMulti(lines, states);
|
|
1274
|
+
return lines.join("\n");
|
|
1275
|
+
}
|
|
1276
|
+
const LAYOUT_START = "// <likec4.layout.drawio>";
|
|
1277
|
+
const LAYOUT_END = "// </likec4.layout.drawio>";
|
|
1278
|
+
const STROKE_COLOR_START = "// <likec4.strokeColor.vertices>";
|
|
1279
|
+
const STROKE_COLOR_END = "// </likec4.strokeColor.vertices>";
|
|
1280
|
+
const STROKE_WIDTH_START = "// <likec4.strokeWidth.vertices>";
|
|
1281
|
+
const STROKE_WIDTH_END = "// </likec4.strokeWidth.vertices>";
|
|
1282
|
+
const WAYPOINTS_START = "// <likec4.edge.waypoints>";
|
|
1283
|
+
const WAYPOINTS_END = "// </likec4.edge.waypoints>";
|
|
1284
|
+
/**
|
|
1285
|
+
* Parse DrawIO round-trip comment blocks from .c4 source (layout, strokeColor, strokeWidth, waypoints).
|
|
1286
|
+
* Used to build GenerateDrawioOptions for re-export after editing in draw.io.
|
|
1287
|
+
* TODO: the four section parsers (layout, strokeColor, strokeWidth, waypoints) repeat the same
|
|
1288
|
+
* "parse block between markers" pattern; consider a small helper to reduce duplication.
|
|
1289
|
+
* @param c4Source - Full .c4 source string (e.g. concatenated workspace files).
|
|
1290
|
+
* @returns DrawioRoundtripData or null if no likec4.* comment blocks found.
|
|
1291
|
+
*/
|
|
1292
|
+
function parseDrawioRoundtripComments(c4Source) {
|
|
1293
|
+
const lines = c4Source.split(/\r?\n/);
|
|
1294
|
+
let layoutByView = {};
|
|
1295
|
+
let strokeColorByFqn = {};
|
|
1296
|
+
let strokeWidthByFqn = {};
|
|
1297
|
+
let edgeWaypoints = {};
|
|
1298
|
+
let found = false;
|
|
1299
|
+
let i = 0;
|
|
1300
|
+
while (i < lines.length) {
|
|
1301
|
+
const line = lines[i];
|
|
1302
|
+
if (line == null) {
|
|
1303
|
+
i += 1;
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (line.trim() === LAYOUT_START) {
|
|
1307
|
+
found = true;
|
|
1308
|
+
i += 1;
|
|
1309
|
+
const layoutLines = [];
|
|
1310
|
+
while (i < lines.length && lines[i]?.trim() !== LAYOUT_END) {
|
|
1311
|
+
const ln = lines[i]?.trim();
|
|
1312
|
+
if (ln?.startsWith("// ")) layoutLines.push(ln.slice(3));
|
|
1313
|
+
i += 1;
|
|
1314
|
+
}
|
|
1315
|
+
if (layoutLines.length > 0) try {
|
|
1316
|
+
const json = layoutLines.join("\n");
|
|
1317
|
+
layoutByView = JSON.parse(json);
|
|
1318
|
+
} catch {}
|
|
1319
|
+
i += 1;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (line.trim() === STROKE_COLOR_START) {
|
|
1323
|
+
found = true;
|
|
1324
|
+
i += 1;
|
|
1325
|
+
while (i < lines.length && lines[i]?.trim() !== STROKE_COLOR_END) {
|
|
1326
|
+
const ln = lines[i]?.trim();
|
|
1327
|
+
if (ln?.startsWith("// ") && ln.includes("=")) {
|
|
1328
|
+
const rest = ln.slice(3).trim();
|
|
1329
|
+
const eq = rest.indexOf("=");
|
|
1330
|
+
if (eq > 0) {
|
|
1331
|
+
const fqn = rest.slice(0, eq).trim();
|
|
1332
|
+
const hex = rest.slice(eq + 1).trim();
|
|
1333
|
+
if (fqn && hex) strokeColorByFqn[fqn] = hex;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
i += 1;
|
|
1337
|
+
}
|
|
1338
|
+
i += 1;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (line.trim() === STROKE_WIDTH_START) {
|
|
1342
|
+
found = true;
|
|
1343
|
+
i += 1;
|
|
1344
|
+
while (i < lines.length && lines[i]?.trim() !== STROKE_WIDTH_END) {
|
|
1345
|
+
const ln = lines[i]?.trim();
|
|
1346
|
+
if (ln?.startsWith("// ") && ln.includes("=")) {
|
|
1347
|
+
const rest = ln.slice(3).trim();
|
|
1348
|
+
const eq = rest.indexOf("=");
|
|
1349
|
+
if (eq > 0) {
|
|
1350
|
+
const fqn = rest.slice(0, eq).trim();
|
|
1351
|
+
const val = rest.slice(eq + 1).trim();
|
|
1352
|
+
if (fqn && val !== "") strokeWidthByFqn[fqn] = val;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
i += 1;
|
|
1356
|
+
}
|
|
1357
|
+
i += 1;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
if (line.trim() === WAYPOINTS_START) {
|
|
1361
|
+
found = true;
|
|
1362
|
+
i += 1;
|
|
1363
|
+
while (i < lines.length && lines[i]?.trim() !== WAYPOINTS_END) {
|
|
1364
|
+
const ln = lines[i]?.trim();
|
|
1365
|
+
if (ln?.startsWith("// ")) {
|
|
1366
|
+
const rest = ln.slice(3).trim();
|
|
1367
|
+
const space = rest.indexOf(" ");
|
|
1368
|
+
if (space > 0) {
|
|
1369
|
+
const key = rest.slice(0, space).trim();
|
|
1370
|
+
const json = rest.slice(space + 1).trim();
|
|
1371
|
+
if (key && json) try {
|
|
1372
|
+
const pts = JSON.parse(json);
|
|
1373
|
+
if (Array.isArray(pts)) edgeWaypoints[key] = pts;
|
|
1374
|
+
} catch {}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
i += 1;
|
|
1378
|
+
}
|
|
1379
|
+
i += 1;
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
i += 1;
|
|
1383
|
+
}
|
|
1384
|
+
if (found) return {
|
|
1385
|
+
layoutByView,
|
|
1386
|
+
strokeColorByFqn,
|
|
1387
|
+
strokeWidthByFqn,
|
|
1388
|
+
edgeWaypoints
|
|
1389
|
+
};
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* DrawIO diagram generator.
|
|
1394
|
+
*
|
|
1395
|
+
* Design system alignment: colors, spacing, and font sizes are taken from the
|
|
1396
|
+
* viewmodel's styles (LikeC4Styles / theme). Container padding uses
|
|
1397
|
+
* theme.spacing (xl, xl+md for vertical). Container title uses groupColors.stroke
|
|
1398
|
+
* and theme.textSizes.xs. Element and edge colors use getElementColors /
|
|
1399
|
+
* getEdgeLabelColors from the theme. The only value not from core theme is the
|
|
1400
|
+
* Font family matches LikeC4 app (--likec4-app-font / --likec4-app-font-default:
|
|
1401
|
+
* 'IBM Plex Sans Variable', ui-sans-serif, system-ui, sans-serif).
|
|
1402
|
+
*/
|
|
1403
|
+
/**
|
|
1404
|
+
* DrawIO expects diagram content as base64(deflateRaw(encodeURIComponent(xml))).
|
|
1405
|
+
* @internal
|
|
1406
|
+
*/
|
|
1407
|
+
function compressDrawioDiagramXml(xml) {
|
|
1408
|
+
const encoded = encodeURIComponent(xml);
|
|
1409
|
+
const bytes = new TextEncoder().encode(encoded);
|
|
1410
|
+
return uint8ArrayToBase64(pako.deflateRaw(bytes));
|
|
1411
|
+
}
|
|
1412
|
+
/** Encode bytes to base64 (Node Buffer or btoa for browser). */
|
|
1413
|
+
function uint8ArrayToBase64(bytes) {
|
|
1414
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
1415
|
+
let binary = "";
|
|
1416
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
1417
|
+
return btoa(binary);
|
|
1418
|
+
}
|
|
1419
|
+
/** Optional node fields (DSL allows more than base type). Single place for type/cast — Clean Code. */
|
|
1420
|
+
const nodeOptionalFields = {
|
|
1421
|
+
getNotes(node) {
|
|
1422
|
+
return node.notes;
|
|
1423
|
+
},
|
|
1424
|
+
getSummary(node) {
|
|
1425
|
+
return node.summary;
|
|
1426
|
+
},
|
|
1427
|
+
getTags(node) {
|
|
1428
|
+
return node.tags;
|
|
1429
|
+
},
|
|
1430
|
+
getNavigateTo(node) {
|
|
1431
|
+
return node.navigateTo;
|
|
1432
|
+
},
|
|
1433
|
+
getIcon(node) {
|
|
1434
|
+
return node.icon;
|
|
1435
|
+
},
|
|
1436
|
+
getLinks(node) {
|
|
1437
|
+
return node.links;
|
|
1438
|
+
},
|
|
1439
|
+
getNotation(node) {
|
|
1440
|
+
return node.notation;
|
|
1441
|
+
},
|
|
1442
|
+
getCustomData(node) {
|
|
1443
|
+
return node.customData;
|
|
1444
|
+
},
|
|
1445
|
+
getChildren(node) {
|
|
1446
|
+
return node.children;
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
/** Optional edge fields. Single place for type/cast — Clean Code. */
|
|
1450
|
+
const edgeOptionalFields = {
|
|
1451
|
+
getKind(edge) {
|
|
1452
|
+
return edge.kind;
|
|
1453
|
+
},
|
|
1454
|
+
getNotation(edge) {
|
|
1455
|
+
return edge.notation;
|
|
1456
|
+
},
|
|
1457
|
+
getLinks(edge) {
|
|
1458
|
+
return edge.links;
|
|
1459
|
+
},
|
|
1460
|
+
getMetadata(edge) {
|
|
1461
|
+
return edge.metadata;
|
|
1462
|
+
},
|
|
1463
|
+
getCustomData(edge) {
|
|
1464
|
+
return edge.customData;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
/** Project styles or central default (LikeC4Styles.DEFAULT) when view has no $styles. */
|
|
1468
|
+
function getEffectiveStyles(viewmodel) {
|
|
1469
|
+
return viewmodel.$styles ?? LikeC4Styles.DEFAULT;
|
|
1470
|
+
}
|
|
1471
|
+
/** Escape for use inside HTML (e.g. cell value with html=1). */
|
|
1472
|
+
function escapeHtml(text) {
|
|
1473
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
1474
|
+
}
|
|
1475
|
+
/** Coerce to non-empty string for style/attribute use; empty string when null/undefined/empty or non-primitive. DRY for navTo, iconName, etc. */
|
|
1476
|
+
function toNonEmptyString(value) {
|
|
1477
|
+
if (value == null) return "";
|
|
1478
|
+
const t = typeof value;
|
|
1479
|
+
if (t === "string") {
|
|
1480
|
+
const s = value;
|
|
1481
|
+
return s.trim() === "" ? "" : s;
|
|
1482
|
+
}
|
|
1483
|
+
if (t === "number") return String(value);
|
|
1484
|
+
if (t === "boolean") return String(value);
|
|
1485
|
+
return "";
|
|
1486
|
+
}
|
|
1487
|
+
/** Container dashed style from border (KISS: single place for 3-way branch). */
|
|
1488
|
+
function getContainerDashedStyle(isContainer, borderVal) {
|
|
1489
|
+
if (isContainer && borderVal !== "none") return "dashed=1;";
|
|
1490
|
+
if (borderVal === "dashed") return "dashed=1;";
|
|
1491
|
+
return "";
|
|
1492
|
+
}
|
|
1493
|
+
/** Default stroke width for node from border and container. No stroke set ('') for leaf without border → Draw.io uses its default. */
|
|
1494
|
+
function getDefaultStrokeWidth(borderVal, isContainer) {
|
|
1495
|
+
if (borderVal === "none") return "0";
|
|
1496
|
+
return isContainer ? "1" : borderVal ? "1" : "";
|
|
1497
|
+
}
|
|
1498
|
+
/** Apply stroke color override to base element colors (KISS: named function instead of IIFE). */
|
|
1499
|
+
function applyStrokeColorOverride(base, override) {
|
|
1500
|
+
return {
|
|
1501
|
+
fill: base?.fill ?? DEFAULT_NODE_FILL_HEX,
|
|
1502
|
+
stroke: override,
|
|
1503
|
+
font: base?.font ?? override
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
/** Theme color key valid for styles.theme.colors; falls back to primary/gray for elements/edges. */
|
|
1507
|
+
function resolveThemeColor(styles, color, fallback) {
|
|
1508
|
+
if (color && color in styles.theme.colors) return color;
|
|
1509
|
+
return fallback;
|
|
1510
|
+
}
|
|
1511
|
+
/** Get theme color values with fallback to DEFAULT on error (DRY + SRP for try/catch). */
|
|
1512
|
+
function getThemeColorValues(viewmodel, color, fallback) {
|
|
1513
|
+
const styles = getEffectiveStyles(viewmodel);
|
|
1514
|
+
const themeColor = resolveThemeColor(styles, color ?? fallback, fallback);
|
|
1515
|
+
try {
|
|
1516
|
+
return styles.colors(themeColor);
|
|
1517
|
+
} catch {
|
|
1518
|
+
return LikeC4Styles.DEFAULT.colors(fallback);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Map LikeC4 element shape to draw.io cell style string.
|
|
1523
|
+
* Rounded corners: arcSize as percentage integer (e.g. 12) for subtly rounded corners in Draw.io.
|
|
1524
|
+
*/
|
|
1525
|
+
function drawioShape(shape) {
|
|
1526
|
+
const rectStyle = "shape=rectangle;rounded=1;arcSize=12;";
|
|
1527
|
+
switch (shape) {
|
|
1528
|
+
case "person": return "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;";
|
|
1529
|
+
case "rectangle":
|
|
1530
|
+
case "browser":
|
|
1531
|
+
case "mobile":
|
|
1532
|
+
case "bucket": return rectStyle;
|
|
1533
|
+
case "cylinder":
|
|
1534
|
+
case "queue":
|
|
1535
|
+
case "storage": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
1536
|
+
case "document": return "shape=document;whiteSpace=wrap;html=1;boundedLbl=1;";
|
|
1537
|
+
case "component": return "shape=component;";
|
|
1538
|
+
default: return rectStyle;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Resolve element fill, stroke and font colors from project styles or default theme.
|
|
1543
|
+
* Uses ElementColorValues (hiContrast for font when present).
|
|
1544
|
+
*/
|
|
1545
|
+
function getElementColors(viewmodel, color) {
|
|
1546
|
+
const elementColors = getThemeColorValues(viewmodel, color, "primary").elements;
|
|
1547
|
+
return {
|
|
1548
|
+
fill: String(elementColors.fill ?? DEFAULT_NODE_FILL_HEX),
|
|
1549
|
+
stroke: String(elementColors.stroke ?? DEFAULT_NODE_STROKE_HEX),
|
|
1550
|
+
font: String(elementColors.hiContrast ?? elementColors.stroke ?? DEFAULT_NODE_FONT_HEX)
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
/** Edge stroke (line) color from theme RelationshipColorValues.line. */
|
|
1554
|
+
function getEdgeStrokeColor(viewmodel, color) {
|
|
1555
|
+
const values = getThemeColorValues(viewmodel, color ?? "gray", "gray");
|
|
1556
|
+
return String(values.relationships?.line ?? DEFAULT_NODE_FONT_HEX);
|
|
1557
|
+
}
|
|
1558
|
+
/** Edge label font and background from theme (RelationshipColorValues.label, labelBg) for readable connector text. */
|
|
1559
|
+
function getEdgeLabelColors(viewmodel, color) {
|
|
1560
|
+
const rel = getThemeColorValues(viewmodel, color ?? "gray", "gray").relationships;
|
|
1561
|
+
return {
|
|
1562
|
+
font: String(rel?.label ?? rel?.line ?? DEFAULT_NODE_FONT_HEX),
|
|
1563
|
+
background: String(rel?.labelBg ?? "#ffffff")
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Compute draw.io exit/entry anchors (0–1) from source to target bbox centers
|
|
1568
|
+
* so edges connect on the correct sides (LikeC4-style layout).
|
|
1569
|
+
*/
|
|
1570
|
+
function edgeAnchors(sourceBbox, targetBbox) {
|
|
1571
|
+
const sCx = sourceBbox.x + sourceBbox.width / 2;
|
|
1572
|
+
const sCy = sourceBbox.y + sourceBbox.height / 2;
|
|
1573
|
+
const tCx = targetBbox.x + targetBbox.width / 2;
|
|
1574
|
+
const tCy = targetBbox.y + targetBbox.height / 2;
|
|
1575
|
+
const dx = tCx - sCx;
|
|
1576
|
+
const dy = tCy - sCy;
|
|
1577
|
+
const hor = Math.abs(dx) >= Math.abs(dy);
|
|
1578
|
+
return {
|
|
1579
|
+
exitX: hor ? dx >= 0 ? 1 : 0 : .5,
|
|
1580
|
+
exitY: hor ? .5 : dy >= 0 ? 1 : 0,
|
|
1581
|
+
entryX: hor ? dx >= 0 ? 0 : 1 : .5,
|
|
1582
|
+
entryY: hor ? .5 : dy >= 0 ? 0 : 1
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/** Normalize one waypoint to [x, y]; returns one element or empty. Called per element via flatMap. */
|
|
1586
|
+
function normalizeEdgePoint(pt) {
|
|
1587
|
+
if (Array.isArray(pt) && pt.length >= 2 && typeof pt[0] === "number" && typeof pt[1] === "number") return [[pt[0], pt[1]]];
|
|
1588
|
+
const o = pt;
|
|
1589
|
+
if (typeof o.x === "number" && typeof o.y === "number") return [[o.x, o.y]];
|
|
1590
|
+
return [];
|
|
1591
|
+
}
|
|
1592
|
+
/** Build HTML value for a vertex cell (title only or title + description). */
|
|
1593
|
+
function buildNodeValueHtml(title, desc, isContainer, fontHex, fontFamily, fontSizePx) {
|
|
1594
|
+
if (isContainer) return "";
|
|
1595
|
+
if (desc !== "") return `<div style="box-sizing:border-box;width:100%;min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:${fontHex};font-family:${fontFamily};"><b style="font-size:${fontSizePx}px;">${escapeHtml(title)}</b><br/><span style="font-weight:normal;font-size:${fontSizePx}px;">${escapeHtml(desc)}</span></div>`;
|
|
1596
|
+
return `<div style="box-sizing:border-box;width:100%;min-height:100%;display:flex;align-items:center;justify-content:center;text-align:center;color:${fontHex};font-family:${fontFamily};"><b style="font-size:${fontSizePx}px;">${escapeHtml(title)}</b></div>`;
|
|
1597
|
+
}
|
|
1598
|
+
/** Push "key=value" to parts when value is set; encodes value for style string (avoids repeated conditionals). */
|
|
1599
|
+
function pushStylePart(parts, key, value) {
|
|
1600
|
+
if (value != null && value !== "") parts.push(`${key}=${encodeURIComponent(value)}`);
|
|
1601
|
+
}
|
|
1602
|
+
/** Push "key=value" for numeric value (no encoding). */
|
|
1603
|
+
function pushStylePartNum(parts, key, value) {
|
|
1604
|
+
if (value != null) parts.push(`${key}=${value}`);
|
|
1605
|
+
}
|
|
1606
|
+
/** Build Draw.io link= style for navigateTo (empty string when no nav). DRY for node and container title. */
|
|
1607
|
+
function buildNavLinkStyle(navTo) {
|
|
1608
|
+
return navTo === "" ? "" : `link=${encodeURIComponent(`${DRAWIO_PAGE_LINK_PREFIX}${navTo}`)};`;
|
|
1609
|
+
}
|
|
1610
|
+
/** Flatten markdown/string and trim to single export string; empty when missing or empty-ish. DRY for node/edge fields. */
|
|
1611
|
+
function toExportString(raw) {
|
|
1612
|
+
const flat = raw != null ? flattenMarkdownOrString(raw) : null;
|
|
1613
|
+
return flat != null && !isEmptyish(flat) ? flat.trim() : "";
|
|
1614
|
+
}
|
|
1615
|
+
/** Serialize links array to style-safe JSON string (empty when none). DRY for node and edge links. */
|
|
1616
|
+
function linksToStyleJson(links) {
|
|
1617
|
+
if (!Array.isArray(links) || links.length === 0) return "";
|
|
1618
|
+
return encodeURIComponent(JSON.stringify(links.map((l) => ({
|
|
1619
|
+
url: l.url,
|
|
1620
|
+
title: l.title
|
|
1621
|
+
}))));
|
|
1622
|
+
}
|
|
1623
|
+
/** Serialize metadata object to style-safe JSON string (empty when none). DRY for edge metadata. */
|
|
1624
|
+
function metadataToStyleJson(metadata) {
|
|
1625
|
+
if (metadata == null || typeof metadata !== "object" || Array.isArray(metadata) || Object.keys(metadata).length === 0) return "";
|
|
1626
|
+
return encodeURIComponent(JSON.stringify(metadata));
|
|
1627
|
+
}
|
|
1628
|
+
const HEX_COLOR_RE = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
1629
|
+
/** Build LikeC4 style string (likec4Description=...; etc.) for round-trip. */
|
|
1630
|
+
function buildLikec4StyleForNode(params) {
|
|
1631
|
+
const parts = [];
|
|
1632
|
+
pushStylePart(parts, "likec4Description", params.desc);
|
|
1633
|
+
pushStylePart(parts, "likec4Technology", params.tech);
|
|
1634
|
+
pushStylePart(parts, "likec4Notes", params.notes);
|
|
1635
|
+
pushStylePart(parts, "likec4Tags", params.tagList);
|
|
1636
|
+
pushStylePart(parts, "likec4NavigateTo", params.navTo);
|
|
1637
|
+
pushStylePart(parts, "likec4Icon", params.iconName);
|
|
1638
|
+
pushStylePart(parts, "likec4Summary", params.summaryStr);
|
|
1639
|
+
if (params.linksJson !== "") parts.push(`likec4Links=${params.linksJson}`);
|
|
1640
|
+
pushStylePart(parts, "likec4Border", params.borderVal);
|
|
1641
|
+
pushStylePartNum(parts, "likec4Opacity", params.containerOpacityNum);
|
|
1642
|
+
pushStylePart(parts, "likec4StrokeWidth", params.strokeWidth);
|
|
1643
|
+
if (params.colorNameForRoundtrip !== "") parts.push(`likec4ColorName=${params.colorNameForRoundtrip}`);
|
|
1644
|
+
pushStylePart(parts, "likec4Size", params.nodeStyle?.size);
|
|
1645
|
+
pushStylePart(parts, "likec4Padding", params.nodeStyle?.padding);
|
|
1646
|
+
pushStylePart(parts, "likec4TextSize", params.nodeStyle?.textSize);
|
|
1647
|
+
pushStylePart(parts, "likec4IconPosition", params.nodeStyle?.iconPosition);
|
|
1648
|
+
if (params.strokeHex && HEX_COLOR_RE.test(params.strokeHex)) pushStylePart(parts, "likec4StrokeColor", params.strokeHex);
|
|
1649
|
+
pushStylePart(parts, "likec4Notation", params.nodeNotation ?? void 0);
|
|
1650
|
+
return parts.length > 0 ? parts.join(";") + ";" : "";
|
|
1651
|
+
}
|
|
1652
|
+
/** Build mxUserObject XML from customData for round-trip; returns empty string when customData is missing or empty. */
|
|
1653
|
+
function buildMxUserObjectXml(customData) {
|
|
1654
|
+
if (!customData || typeof customData !== "object" || Array.isArray(customData) || Object.keys(customData).length === 0) return "";
|
|
1655
|
+
return "\n <mxUserObject>" + Object.entries(customData).map(([k, v]) => {
|
|
1656
|
+
const safeV = typeof v === "string" ? v : v != null ? String(v) : "";
|
|
1657
|
+
return `<data key="${escapeXml(k)}">${escapeXml(safeV)}</data>`;
|
|
1658
|
+
}).join("") + "</mxUserObject>";
|
|
1659
|
+
}
|
|
1660
|
+
/** Build LikeC4 style string for an edge (likec4Description=...; etc.) for round-trip. */
|
|
1661
|
+
function buildLikec4StyleForEdge(params) {
|
|
1662
|
+
const parts = [];
|
|
1663
|
+
pushStylePart(parts, "likec4Description", params.edgeDesc);
|
|
1664
|
+
pushStylePart(parts, "likec4Technology", params.edgeTech);
|
|
1665
|
+
pushStylePart(parts, "likec4Notes", params.edgeNotes);
|
|
1666
|
+
pushStylePart(parts, "likec4NavigateTo", params.edgeNavTo);
|
|
1667
|
+
pushStylePart(parts, "likec4RelationshipKind", params.edgeKind ?? void 0);
|
|
1668
|
+
pushStylePart(parts, "likec4Notation", params.edgeNotation ?? void 0);
|
|
1669
|
+
if (params.edgeLinksJson !== "") parts.push(`likec4Links=${params.edgeLinksJson}`);
|
|
1670
|
+
if (params.edgeMetadataJson !== "") parts.push(`likec4Metadata=${params.edgeMetadataJson}`);
|
|
1671
|
+
return parts.length > 0 ? parts.join(";") + ";" : "";
|
|
1672
|
+
}
|
|
1673
|
+
/** Escaped edge label for mxCell value (single responsibility). */
|
|
1674
|
+
function buildEdgeLabelValue(edge) {
|
|
1675
|
+
return edge.label ? escapeXml(edge.label) : "";
|
|
1676
|
+
}
|
|
1677
|
+
/** Edge waypoints → mxGeometry XML (single responsibility). */
|
|
1678
|
+
function buildEdgeGeometryXml(edge, edgeWaypoints) {
|
|
1679
|
+
const rawEdgePoints = edgeWaypoints?.[`${edge.source}|${edge.target}|${edge.id}`] ?? edgeWaypoints?.[`${edge.source}|${edge.target}`];
|
|
1680
|
+
const edgePoints = Array.isArray(rawEdgePoints) ? rawEdgePoints.flatMap(normalizeEdgePoint) : [];
|
|
1681
|
+
if (!(edgePoints.length > 0)) return "<mxGeometry relative=\"1\" as=\"geometry\" />";
|
|
1682
|
+
return `<mxGeometry relative="1" as="geometry">${"<Array as=\"points\">" + edgePoints.map(([px, py]) => `<mxPoint x="${Math.round(px)}" y="${Math.round(py)}"/>`).join("") + "</Array>"}</mxGeometry>`;
|
|
1683
|
+
}
|
|
1684
|
+
/** Full edge style string for mxCell (arrows, anchors, stroke, dash, label, likec4 roundtrip). */
|
|
1685
|
+
function buildEdgeStyleString(edge, layout, viewmodel, label) {
|
|
1686
|
+
const { bboxes, fontFamily } = layout;
|
|
1687
|
+
const sourceBbox = bboxes.get(edge.source);
|
|
1688
|
+
const targetBbox = bboxes.get(edge.target);
|
|
1689
|
+
const anchors = sourceBbox && targetBbox ? edgeAnchors(sourceBbox, targetBbox) : {
|
|
1690
|
+
exitX: 1,
|
|
1691
|
+
exitY: .5,
|
|
1692
|
+
entryX: 0,
|
|
1693
|
+
entryY: .5
|
|
1694
|
+
};
|
|
1695
|
+
const anchorStyle = `exitX=${anchors.exitX};exitY=${anchors.exitY};entryX=${anchors.entryX};entryY=${anchors.entryY};`;
|
|
1696
|
+
const strokeColor = getEdgeStrokeColor(viewmodel, edge.color);
|
|
1697
|
+
const dashStyle = edge.line === "dashed" ? "dashed=1;" : edge.line === "dotted" ? "dashed=1;dashPattern=1 1;" : "";
|
|
1698
|
+
const endArrow = drawioArrow(edge.head);
|
|
1699
|
+
const startArrow = edge.tail == null || edge.tail === "none" ? "none" : drawioArrow(edge.tail);
|
|
1700
|
+
const edgeLikec4Style = buildLikec4StyleForEdge({
|
|
1701
|
+
edgeDesc: toExportString(edge.description),
|
|
1702
|
+
edgeTech: toExportString(edge.technology),
|
|
1703
|
+
edgeNotes: toExportString(edge.notes),
|
|
1704
|
+
edgeNavTo: toNonEmptyString(edge.navigateTo),
|
|
1705
|
+
edgeKind: edgeOptionalFields.getKind(edge),
|
|
1706
|
+
edgeNotation: edgeOptionalFields.getNotation(edge),
|
|
1707
|
+
edgeLinksJson: linksToStyleJson(edgeOptionalFields.getLinks(edge)),
|
|
1708
|
+
edgeMetadataJson: metadataToStyleJson(edgeOptionalFields.getMetadata(edge))
|
|
1709
|
+
});
|
|
1710
|
+
const edgeLabelColors = getEdgeLabelColors(viewmodel, edge.color);
|
|
1711
|
+
return `endArrow=${endArrow};startArrow=${startArrow};html=1;rounded=0;${anchorStyle}strokeColor=${strokeColor};strokeWidth=2;${dashStyle}${label === "" ? "" : `fontColor=${edgeLabelColors.font};fontSize=12;align=center;verticalAlign=middle;labelBackgroundColor=none;fontFamily=${encodeURIComponent(fontFamily)};`}${edgeLikec4Style}`;
|
|
1712
|
+
}
|
|
1713
|
+
/** Build a single edge mxCell XML (orchestrator: label + geometry + style + assembly). */
|
|
1714
|
+
function buildEdgeCellXml(edge, layout, options, viewmodel, getCellId, edgeCellId) {
|
|
1715
|
+
const { defaultParentId } = layout;
|
|
1716
|
+
const sourceId = getCellId(edge.source);
|
|
1717
|
+
const targetId = getCellId(edge.target);
|
|
1718
|
+
const label = buildEdgeLabelValue(edge);
|
|
1719
|
+
const edgeGeometryXml = buildEdgeGeometryXml(edge, options?.edgeWaypoints);
|
|
1720
|
+
return `<mxCell id="${edgeCellId}" value="${label}" style="${buildEdgeStyleString(edge, layout, viewmodel, label)}" edge="1" parent="${defaultParentId}" source="${sourceId}" target="${targetId}">
|
|
1721
|
+
${edgeGeometryXml}${buildMxUserObjectXml(edgeOptionalFields.getCustomData(edge))}
|
|
1722
|
+
</mxCell>`;
|
|
1723
|
+
}
|
|
1724
|
+
/** Geometry for one node cell: id, parent, position, size (single responsibility). */
|
|
1725
|
+
function computeNodeGeometry(node, layout, getCellId) {
|
|
1726
|
+
const { bboxes, defaultParentId, nodeIdsInView } = layout;
|
|
1727
|
+
const id = getCellId(node.id);
|
|
1728
|
+
const bbox = bboxes.get(node.id);
|
|
1729
|
+
const { width, height } = bbox;
|
|
1730
|
+
const parentId = node.parent != null && nodeIdsInView.has(node.parent) ? getCellId(node.parent) : defaultParentId;
|
|
1731
|
+
const parentBbox = node.parent != null ? bboxes.get(node.parent) : void 0;
|
|
1732
|
+
return {
|
|
1733
|
+
id,
|
|
1734
|
+
parentId,
|
|
1735
|
+
x: parentBbox == null ? bbox.x + layout.offsetX : bbox.x - parentBbox.x,
|
|
1736
|
+
y: parentBbox == null ? bbox.y + layout.offsetY : bbox.y - parentBbox.y,
|
|
1737
|
+
width,
|
|
1738
|
+
height
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
/** Style parts and value for one node (colors, stroke, likec4 roundtrip style, value HTML). */
|
|
1742
|
+
function computeNodeStylePartsAndValue(node, layout, options, viewmodel) {
|
|
1743
|
+
const { containerNodeIds, effectiveStyles, fontFamily, containerTitleFontSizePx, containerTitleColor } = layout;
|
|
1744
|
+
const strokeColorByNodeId = options?.strokeColorByNodeId;
|
|
1745
|
+
const strokeWidthByNodeId = options?.strokeWidthByNodeId;
|
|
1746
|
+
const isContainer = containerNodeIds.has(node.id);
|
|
1747
|
+
const title = node.title;
|
|
1748
|
+
const desc = toExportString(node.description);
|
|
1749
|
+
const tech = toExportString(node.technology);
|
|
1750
|
+
const notes = toExportString(nodeOptionalFields.getNotes(node));
|
|
1751
|
+
const tags = nodeOptionalFields.getTags(node);
|
|
1752
|
+
const tagList = Array.isArray(tags) && tags.length > 0 ? tags.join(",") : "";
|
|
1753
|
+
const navTo = toNonEmptyString(nodeOptionalFields.getNavigateTo(node));
|
|
1754
|
+
const iconName = toNonEmptyString(nodeOptionalFields.getIcon(node));
|
|
1755
|
+
const shapeStyle = isContainer ? "shape=rectangle;rounded=0;container=1;collapsible=0;startSize=0;" : drawioShape(node.shape);
|
|
1756
|
+
const strokeColorOverride = strokeColorByNodeId?.[node.id];
|
|
1757
|
+
const strokeWidthOverride = strokeWidthByNodeId?.[node.id];
|
|
1758
|
+
const elemColors = strokeColorOverride ? applyStrokeColorOverride(getElementColors(viewmodel, node.color), strokeColorOverride) : getElementColors(viewmodel, node.color);
|
|
1759
|
+
const fillHex = elemColors?.fill ?? DEFAULT_NODE_FILL_HEX;
|
|
1760
|
+
const strokeHex = elemColors?.stroke ?? DEFAULT_NODE_STROKE_HEX;
|
|
1761
|
+
const fontHex = elemColors?.font ?? elemColors?.stroke ?? DEFAULT_NODE_FONT_HEX;
|
|
1762
|
+
const colorStyle = `fillColor=${fillHex};strokeColor=${strokeHex};fontColor=${fontHex};`;
|
|
1763
|
+
const nodeStyle = node.style;
|
|
1764
|
+
const fontSizePx = effectiveStyles.fontSize(nodeStyle?.textSize);
|
|
1765
|
+
const value = escapeXml(buildNodeValueHtml(title, desc, isContainer, fontHex, fontFamily, fontSizePx));
|
|
1766
|
+
const borderVal = nodeStyle?.border;
|
|
1767
|
+
const strokeWidth = strokeWidthOverride ?? getDefaultStrokeWidth(borderVal, isContainer);
|
|
1768
|
+
const strokeWidthStyle = strokeWidth !== "" ? `strokeWidth=${strokeWidth};` : "";
|
|
1769
|
+
const containerDashed = getContainerDashedStyle(isContainer, borderVal);
|
|
1770
|
+
const containerOpacityNum = isContainer === true ? nodeStyle?.opacity ?? DEFAULT_CONTAINER_OPACITY : void 0;
|
|
1771
|
+
const fillOpacityStyle = containerOpacityNum != null && isContainer === true ? `fillOpacity=${Math.min(100, Math.max(0, containerOpacityNum))};` : "";
|
|
1772
|
+
const likec4Style = buildLikec4StyleForNode({
|
|
1773
|
+
desc,
|
|
1774
|
+
tech,
|
|
1775
|
+
notes,
|
|
1776
|
+
tagList,
|
|
1777
|
+
navTo,
|
|
1778
|
+
iconName,
|
|
1779
|
+
summaryStr: toExportString(nodeOptionalFields.getSummary(node)),
|
|
1780
|
+
linksJson: linksToStyleJson(nodeOptionalFields.getLinks(node)),
|
|
1781
|
+
borderVal,
|
|
1782
|
+
containerOpacityNum,
|
|
1783
|
+
strokeWidth,
|
|
1784
|
+
colorNameForRoundtrip: node.color ? encodeURIComponent(String(node.color)) : "",
|
|
1785
|
+
nodeStyle,
|
|
1786
|
+
strokeHex,
|
|
1787
|
+
nodeNotation: nodeOptionalFields.getNotation(node)
|
|
1788
|
+
});
|
|
1789
|
+
const userObjectXml = buildMxUserObjectXml(nodeOptionalFields.getCustomData(node));
|
|
1790
|
+
const navLinkStyle = buildNavLinkStyle(navTo);
|
|
1791
|
+
return {
|
|
1792
|
+
value,
|
|
1793
|
+
styleStr: `${isContainer ? "align=left;verticalAlign=top;overflow=fill;whiteSpace=wrap;html=1;" : `align=center;verticalAlign=middle;verticalLabelPosition=middle;labelPosition=center;fontSize=${fontSizePx};fontStyle=1;spacingTop=4;spacingLeft=2;spacingRight=2;spacingBottom=2;overflow=fill;whiteSpace=wrap;html=1;fontFamily=${encodeURIComponent(fontFamily)};`}${shapeStyle}${colorStyle}${strokeWidthStyle}${containerDashed}${fillOpacityStyle}${navLinkStyle}${likec4Style}`,
|
|
1794
|
+
userObjectXml,
|
|
1795
|
+
navTo,
|
|
1796
|
+
isContainer,
|
|
1797
|
+
title,
|
|
1798
|
+
fontFamily,
|
|
1799
|
+
containerTitleFontSizePx,
|
|
1800
|
+
containerTitleColor
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
/** Orchestrator: compute node geometry + style/value, then merge into NodeCellExportData. */
|
|
1804
|
+
function computeNodeCellExportData(node, layout, options, viewmodel, getCellId, containerTitleCellId) {
|
|
1805
|
+
const geometry = computeNodeGeometry(node, layout, getCellId);
|
|
1806
|
+
const styleAndValue = computeNodeStylePartsAndValue(node, layout, options, viewmodel);
|
|
1807
|
+
return {
|
|
1808
|
+
...geometry,
|
|
1809
|
+
value: styleAndValue.value,
|
|
1810
|
+
styleStr: styleAndValue.styleStr,
|
|
1811
|
+
userObjectXml: styleAndValue.userObjectXml,
|
|
1812
|
+
navTo: styleAndValue.navTo,
|
|
1813
|
+
isContainer: styleAndValue.isContainer,
|
|
1814
|
+
fontFamily: styleAndValue.fontFamily,
|
|
1815
|
+
...styleAndValue.isContainer && {
|
|
1816
|
+
title: styleAndValue.title ?? "",
|
|
1817
|
+
titleCellId: String(containerTitleCellId),
|
|
1818
|
+
containerTitleFontSizePx: styleAndValue.containerTitleFontSizePx,
|
|
1819
|
+
containerTitleColor: styleAndValue.containerTitleColor
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/** Build node vertex mxCell XML from precomputed data (single responsibility — XML assembly only). mxUserObject before mxGeometry for parser/roundtrip (align with draw.io expectations). */
|
|
1824
|
+
function buildNodeCellXml(data) {
|
|
1825
|
+
const geometryLine = `<mxGeometry height="${Math.round(data.height)}" width="${Math.round(data.width)}" x="${Math.round(data.x)}" y="${Math.round(data.y)}" as="geometry" />`;
|
|
1826
|
+
const innerContent = data.userObjectXml !== "" ? `${data.userObjectXml}\n ${geometryLine}` : `\n ${geometryLine}`;
|
|
1827
|
+
const userObjectLabel = data.isContainer && data.title != null ? escapeXml(data.title) : data.value;
|
|
1828
|
+
const cellXml = data.navTo === "" ? `<mxCell id="${data.id}" value="${data.value}" style="${data.styleStr}" vertex="1" parent="${data.parentId}">\n ${innerContent}\n</mxCell>` : `<UserObject label="${userObjectLabel}" link="${DRAWIO_PAGE_LINK_PREFIX}${escapeXml(data.navTo)}" id="${data.id}">\n <mxCell parent="${data.parentId}" style="${data.styleStr}" value="${data.value}" vertex="1">\n ${innerContent}\n</mxCell>\n</UserObject>`;
|
|
1829
|
+
if (!data.isContainer) return {
|
|
1830
|
+
vertexXml: cellXml,
|
|
1831
|
+
isContainer: false
|
|
1832
|
+
};
|
|
1833
|
+
return {
|
|
1834
|
+
vertexXml: cellXml,
|
|
1835
|
+
titleCellXml: buildContainerTitleCellXml(data.title ?? "", data.titleCellId ?? data.id, data.navTo, data.id, data.fontFamily, data.containerTitleFontSizePx ?? 12, data.containerTitleColor ?? CONTAINER_TITLE_COLOR),
|
|
1836
|
+
isContainer: true
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
/** Build container title cell XML (child of container, relative position CONTAINER_TITLE_INSET_*). */
|
|
1840
|
+
function buildContainerTitleCellXml(title, titleId, navTo, containerId, fontFamily, fontSizePx, colorHex) {
|
|
1841
|
+
const titleValue = escapeXml(title);
|
|
1842
|
+
const titleWidth = Math.max(CONTAINER_TITLE_MIN_WIDTH_PX, Math.min(CONTAINER_TITLE_MAX_WIDTH_PX, title.length * CONTAINER_TITLE_CHAR_WIDTH_PX));
|
|
1843
|
+
const titleHeight = CONTAINER_TITLE_HEIGHT_PX;
|
|
1844
|
+
const titleX = CONTAINER_TITLE_INSET_X;
|
|
1845
|
+
const titleY = CONTAINER_TITLE_INSET_Y;
|
|
1846
|
+
const navLinkStyle = buildNavLinkStyle(navTo);
|
|
1847
|
+
const titleStyle = `shape=text;html=1;fillColor=none;strokeColor=none;align=left;verticalAlign=top;fontSize=${fontSizePx};fontStyle=1;fontColor=${colorHex};fontFamily=${encodeURIComponent(fontFamily)};${navLinkStyle}`;
|
|
1848
|
+
if (navTo === "") return `<mxCell id="${titleId}" value="${titleValue}" style="${titleStyle}" vertex="1" parent="${containerId}">\n <mxGeometry x="${Math.round(titleX)}" y="${Math.round(titleY)}" width="${titleWidth}" height="${titleHeight}" as="geometry" />\n</mxCell>`;
|
|
1849
|
+
const titleInner = `<mxCell parent="${containerId}" style="${titleStyle}" value="${titleValue}" vertex="1">\n <mxGeometry x="${Math.round(titleX)}" y="${Math.round(titleY)}" width="${titleWidth}" height="${titleHeight}" as="geometry" />\n</mxCell>`;
|
|
1850
|
+
return `<UserObject label="${escapeXml(title)}" link="${DRAWIO_PAGE_LINK_PREFIX}${escapeXml(navTo)}" id="${titleId}">\n ${titleInner}\n</UserObject>`;
|
|
1851
|
+
}
|
|
1852
|
+
/** View title for diagram name and root cell (single source of truth). */
|
|
1853
|
+
function getViewTitle(view) {
|
|
1854
|
+
return typeof view.title === "string" ? view.title : null;
|
|
1855
|
+
}
|
|
1856
|
+
/** Normalize view description from txt/md/string to plain string (single responsibility). */
|
|
1857
|
+
function getViewDescriptionString(view) {
|
|
1858
|
+
const raw = view.description;
|
|
1859
|
+
if (raw != null && typeof raw === "object" && "txt" in raw) return String(raw.txt);
|
|
1860
|
+
if (raw != null && typeof raw === "object" && "md" in raw) return String(raw.md);
|
|
1861
|
+
if (typeof raw === "string") return raw;
|
|
1862
|
+
return "";
|
|
1863
|
+
}
|
|
1864
|
+
/** Build root cell style string from view metadata (title, description, notation) for round-trip. */
|
|
1865
|
+
function buildRootCellStyle(view) {
|
|
1866
|
+
const viewTitle = getViewTitle(view);
|
|
1867
|
+
const viewDesc = getViewDescriptionString(view);
|
|
1868
|
+
const viewDescEnc = viewDesc.trim() !== "" ? encodeURIComponent(viewDesc.trim()) : "";
|
|
1869
|
+
const viewNotationRaw = view.notation;
|
|
1870
|
+
const viewNotation = typeof viewNotationRaw === "string" && viewNotationRaw !== "" ? viewNotationRaw : void 0;
|
|
1871
|
+
const viewNotationEnc = viewNotation != null ? encodeURIComponent(viewNotation) : "";
|
|
1872
|
+
return [
|
|
1873
|
+
"rounded=1;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;",
|
|
1874
|
+
`likec4ViewTitle=${encodeURIComponent(viewTitle ?? view.id)};`,
|
|
1875
|
+
viewDescEnc !== "" ? `likec4ViewDescription=${viewDescEnc};` : "",
|
|
1876
|
+
viewNotationEnc !== "" ? `likec4ViewNotation=${viewNotationEnc};` : ""
|
|
1877
|
+
].join("");
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Map LikeC4 RelationshipArrowType to draw.io endArrow/startArrow style value.
|
|
1881
|
+
*/
|
|
1882
|
+
function drawioArrow(arrow) {
|
|
1883
|
+
switch (arrow) {
|
|
1884
|
+
case "none": return "none";
|
|
1885
|
+
case "open":
|
|
1886
|
+
case "onormal":
|
|
1887
|
+
case "vee": return "open";
|
|
1888
|
+
case "diamond":
|
|
1889
|
+
case "odiamond": return "diamond";
|
|
1890
|
+
case "dot":
|
|
1891
|
+
case "odot": return "oval";
|
|
1892
|
+
case "crow": return "block";
|
|
1893
|
+
default: return "block";
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
const DEFAULT_BBOX = {
|
|
1897
|
+
x: 0,
|
|
1898
|
+
y: 0,
|
|
1899
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1900
|
+
height: DEFAULT_NODE_HEIGHT
|
|
1901
|
+
};
|
|
1902
|
+
/** True when bbox equals default (unlaid) dimensions. */
|
|
1903
|
+
function isDefaultBbox(b) {
|
|
1904
|
+
return b.x === DEFAULT_BBOX.x && b.y === DEFAULT_BBOX.y && b.width === DEFAULT_BBOX.width && b.height === DEFAULT_BBOX.height;
|
|
1905
|
+
}
|
|
1906
|
+
/** Spread nodes that share the same default bbox vertically so they don't overlap (single responsibility). */
|
|
1907
|
+
function spreadUnlaidNodesOverVertical(bboxes, sortedNodes, containerNodeIds) {
|
|
1908
|
+
const bboxKey = (b) => `${b.x},${b.y},${b.width},${b.height}`;
|
|
1909
|
+
const nonContainerNodes = sortedNodes.filter((n) => !containerNodeIds.has(n.id));
|
|
1910
|
+
const byBbox = /* @__PURE__ */ new Map();
|
|
1911
|
+
for (const n of nonContainerNodes) {
|
|
1912
|
+
const b = bboxes.get(n.id);
|
|
1913
|
+
if (!b) continue;
|
|
1914
|
+
const key = bboxKey(b);
|
|
1915
|
+
const list = byBbox.get(key) ?? [];
|
|
1916
|
+
list.push(n);
|
|
1917
|
+
byBbox.set(key, list);
|
|
1918
|
+
}
|
|
1919
|
+
for (const bboxNodes of byBbox.values()) {
|
|
1920
|
+
if (bboxNodes.length <= 1) continue;
|
|
1921
|
+
const firstNode = bboxNodes[0];
|
|
1922
|
+
const firstBbox = firstNode ? bboxes.get(firstNode.id) : void 0;
|
|
1923
|
+
if (firstBbox && isDefaultBbox(firstBbox)) bboxNodes.forEach((node, i) => {
|
|
1924
|
+
bboxes.set(node.id, {
|
|
1925
|
+
...firstBbox,
|
|
1926
|
+
x: firstBbox.x,
|
|
1927
|
+
y: firstBbox.y + i * (firstBbox.height + NODES_SPREAD_GAP)
|
|
1928
|
+
});
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/** Wrap container bboxes around children when container has default bbox (single responsibility). */
|
|
1933
|
+
function computeContainerBboxesFromChildren(bboxes, containerNodeIds, sortedNodes, nodeIdsInView, containerPadding, containerPaddingVertical) {
|
|
1934
|
+
const containerNodesSorted = [...sortedNodes].filter((n) => containerNodeIds.has(n.id)).sort((a, b) => (b.level ?? 0) - (a.level ?? 0));
|
|
1935
|
+
for (const node of containerNodesSorted) {
|
|
1936
|
+
const inView = (nodeOptionalFields.getChildren(node) ?? []).filter((id) => nodeIdsInView.has(id));
|
|
1937
|
+
if (inView.length === 0) continue;
|
|
1938
|
+
if (!isDefaultBbox(bboxes.get(node.id))) continue;
|
|
1939
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1940
|
+
for (const cid of inView) {
|
|
1941
|
+
const b = bboxes.get(cid);
|
|
1942
|
+
if (!b) continue;
|
|
1943
|
+
minX = Math.min(minX, b.x);
|
|
1944
|
+
minY = Math.min(minY, b.y);
|
|
1945
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
1946
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
1947
|
+
}
|
|
1948
|
+
if (minX !== Infinity) bboxes.set(node.id, {
|
|
1949
|
+
x: minX - containerPadding,
|
|
1950
|
+
y: minY - containerPaddingVertical,
|
|
1951
|
+
width: maxX - minX + 2 * containerPadding,
|
|
1952
|
+
height: maxY - minY + 2 * containerPaddingVertical
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
/** Compute canvas offsets to center content (single responsibility). */
|
|
1957
|
+
function computeContentBoundsAndOffsets(bboxes) {
|
|
1958
|
+
let contentMinX = Infinity, contentMinY = Infinity, contentMaxX = -Infinity, contentMaxY = -Infinity;
|
|
1959
|
+
for (const b of bboxes.values()) {
|
|
1960
|
+
contentMinX = Math.min(contentMinX, b.x);
|
|
1961
|
+
contentMinY = Math.min(contentMinY, b.y);
|
|
1962
|
+
contentMaxX = Math.max(contentMaxX, b.x + b.width);
|
|
1963
|
+
contentMaxY = Math.max(contentMaxY, b.y + b.height);
|
|
1964
|
+
}
|
|
1965
|
+
if (contentMinX === Infinity) contentMinX = 0;
|
|
1966
|
+
if (contentMinY === Infinity) contentMinY = 0;
|
|
1967
|
+
if (contentMaxX === -Infinity) contentMaxX = contentMinX + DEFAULT_CANVAS_WIDTH;
|
|
1968
|
+
if (contentMaxY === -Infinity) contentMaxY = contentMinY + DEFAULT_CANVAS_HEIGHT;
|
|
1969
|
+
const contentCx = contentMinX + (contentMaxX - contentMinX) / 2;
|
|
1970
|
+
const contentCy = contentMinY + (contentMaxY - contentMinY) / 2;
|
|
1971
|
+
return {
|
|
1972
|
+
offsetX: DEFAULT_CANVAS_WIDTH / 2 - contentCx,
|
|
1973
|
+
offsetY: DEFAULT_CANVAS_HEIGHT / 2 - contentCy,
|
|
1974
|
+
canvasWidth: DEFAULT_CANVAS_WIDTH,
|
|
1975
|
+
canvasHeight: DEFAULT_CANVAS_HEIGHT
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Layout phase: compute bboxes, container wrap, content bounds, and offsets.
|
|
1980
|
+
* Delegates to spreadUnlaidNodesOverVertical, computeContainerBboxesFromChildren, computeContentBoundsAndOffsets.
|
|
1981
|
+
*/
|
|
1982
|
+
function computeDiagramLayout(viewmodel, options) {
|
|
1983
|
+
const view = viewmodel.$view;
|
|
1984
|
+
const { nodes } = view;
|
|
1985
|
+
const layoutOverride = options?.layoutOverride;
|
|
1986
|
+
const sortedNodes = [...nodes].sort((a, b) => {
|
|
1987
|
+
if (isNullish(a.parent) && isNullish(b.parent)) return 0;
|
|
1988
|
+
if (isNullish(a.parent)) return -1;
|
|
1989
|
+
if (isNullish(b.parent)) return 1;
|
|
1990
|
+
if (a.parent === b.parent) return 0;
|
|
1991
|
+
if (a.id.startsWith(b.id + ".")) return 1;
|
|
1992
|
+
if (b.id.startsWith(a.id + ".")) return -1;
|
|
1993
|
+
return 0;
|
|
1994
|
+
});
|
|
1995
|
+
const getBBox = (n) => {
|
|
1996
|
+
const over = layoutOverride?.[n.id];
|
|
1997
|
+
if (over) return over;
|
|
1998
|
+
const d = n;
|
|
1999
|
+
return {
|
|
2000
|
+
x: typeof d.x === "number" ? d.x : Array.isArray(d.position) ? d.position[0] : 0,
|
|
2001
|
+
y: typeof d.y === "number" ? d.y : Array.isArray(d.position) ? d.position[1] : 0,
|
|
2002
|
+
width: typeof d.width === "number" ? d.width : d.size?.width ?? DEFAULT_NODE_WIDTH,
|
|
2003
|
+
height: typeof d.height === "number" ? d.height : d.size?.height ?? DEFAULT_NODE_HEIGHT
|
|
2004
|
+
};
|
|
2005
|
+
};
|
|
2006
|
+
const bboxes = /* @__PURE__ */ new Map();
|
|
2007
|
+
for (const node of sortedNodes) bboxes.set(node.id, getBBox(node));
|
|
2008
|
+
const nodeIdsInView = new Set(nodes.map((n) => n.id));
|
|
2009
|
+
const containerNodeIds = new Set(nodes.filter((n) => {
|
|
2010
|
+
const ch = nodeOptionalFields.getChildren(n);
|
|
2011
|
+
return Array.isArray(ch) && ch.some((childId) => nodeIdsInView.has(childId));
|
|
2012
|
+
}).map((n) => n.id));
|
|
2013
|
+
spreadUnlaidNodesOverVertical(bboxes, sortedNodes, containerNodeIds);
|
|
2014
|
+
const effectiveStyles = getEffectiveStyles(viewmodel);
|
|
2015
|
+
const containerPadding = effectiveStyles.theme.spacing.xl;
|
|
2016
|
+
computeContainerBboxesFromChildren(bboxes, containerNodeIds, sortedNodes, nodeIdsInView, containerPadding, effectiveStyles.theme.spacing.xl + effectiveStyles.theme.spacing.md);
|
|
2017
|
+
const { offsetX, offsetY, canvasWidth, canvasHeight } = computeContentBoundsAndOffsets(bboxes);
|
|
2018
|
+
return {
|
|
2019
|
+
view,
|
|
2020
|
+
bboxes,
|
|
2021
|
+
containerNodeIds,
|
|
2022
|
+
sortedNodes,
|
|
2023
|
+
offsetX,
|
|
2024
|
+
offsetY,
|
|
2025
|
+
canvasWidth,
|
|
2026
|
+
canvasHeight,
|
|
2027
|
+
defaultParentId: "1",
|
|
2028
|
+
rootId: "0",
|
|
2029
|
+
effectiveStyles,
|
|
2030
|
+
fontFamily: LIKEC4_FONT_FAMILY,
|
|
2031
|
+
containerTitleFontSizePx: Math.round(effectiveStyles.theme.textSizes.xs),
|
|
2032
|
+
containerTitleColor: CONTAINER_TITLE_COLOR,
|
|
2033
|
+
nodeIdsInView
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Generate DrawIO (mxGraph) XML from a layouted LikeC4 view.
|
|
2038
|
+
* Preserves positions, hierarchy, colors, descriptions and technology so the diagram
|
|
2039
|
+
* can be opened and edited in draw.io with full compatibility.
|
|
2040
|
+
*
|
|
2041
|
+
* @param viewmodel - Layouted LikeC4 view model (from model.view(id))
|
|
2042
|
+
* @param options - Optional overrides for layout/colors (round-trip from comment blocks)
|
|
2043
|
+
* @returns Diagram name, id and content (for single or multi composition)
|
|
2044
|
+
*/
|
|
2045
|
+
function generateDiagramContent(viewmodel, options) {
|
|
2046
|
+
const view = viewmodel.$view;
|
|
2047
|
+
const { edges } = view;
|
|
2048
|
+
const useCompressed = options?.compressed !== false;
|
|
2049
|
+
const layout = computeDiagramLayout(viewmodel, options);
|
|
2050
|
+
const { sortedNodes, defaultParentId, rootId, canvasWidth, canvasHeight } = layout;
|
|
2051
|
+
const nodeIds = /* @__PURE__ */ new Map();
|
|
2052
|
+
let cellId = 2;
|
|
2053
|
+
const getCellId = (nodeId) => {
|
|
2054
|
+
let id = nodeIds.get(nodeId);
|
|
2055
|
+
if (!id) {
|
|
2056
|
+
if (cellId >= CONTAINER_TITLE_CELL_ID_START) throw new Error("DrawIO cell ID range exhausted");
|
|
2057
|
+
id = String(cellId++);
|
|
2058
|
+
nodeIds.set(nodeId, id);
|
|
2059
|
+
}
|
|
2060
|
+
return id;
|
|
2061
|
+
};
|
|
2062
|
+
const containerCells = [];
|
|
2063
|
+
const vertexCells = [];
|
|
2064
|
+
const edgeCells = [];
|
|
2065
|
+
let containerTitleCellId = CONTAINER_TITLE_CELL_ID_START;
|
|
2066
|
+
for (const node of sortedNodes) {
|
|
2067
|
+
const result = buildNodeCellXml(computeNodeCellExportData(node, layout, options, viewmodel, getCellId, containerTitleCellId));
|
|
2068
|
+
if (result.isContainer) {
|
|
2069
|
+
containerCells.push(result.vertexXml);
|
|
2070
|
+
if (result.titleCellXml) containerCells.push(result.titleCellXml);
|
|
2071
|
+
containerTitleCellId++;
|
|
2072
|
+
} else vertexCells.push(result.vertexXml);
|
|
2073
|
+
}
|
|
2074
|
+
for (const edge of edges) {
|
|
2075
|
+
if (cellId >= CONTAINER_TITLE_CELL_ID_START) throw new Error("DrawIO cell ID range exhausted");
|
|
2076
|
+
const edgeId = String(cellId++);
|
|
2077
|
+
edgeCells.push(buildEdgeCellXml(edge, layout, options, viewmodel, getCellId, edgeId));
|
|
2078
|
+
}
|
|
2079
|
+
const allCells = [
|
|
2080
|
+
`<mxCell id="${defaultParentId}" value="" style="${buildRootCellStyle(view)}" vertex="1" parent="${rootId}">
|
|
2081
|
+
<mxGeometry x="0" y="0" width="${canvasWidth}" height="${canvasHeight}" as="geometry" />
|
|
2082
|
+
</mxCell>`,
|
|
2083
|
+
...containerCells,
|
|
2084
|
+
...vertexCells,
|
|
2085
|
+
...edgeCells
|
|
2086
|
+
].join("\n");
|
|
2087
|
+
const diagramName = (getViewTitle(view) ?? view.id).trim() || view.id;
|
|
2088
|
+
const mxGraphModelXml = `<mxGraphModel dx="${MXGRAPH_DEFAULT_DX}" dy="${MXGRAPH_DEFAULT_DY}" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${MXGRAPH_PAGE_WIDTH}" pageHeight="${MXGRAPH_PAGE_HEIGHT}" math="0" shadow="0">
|
|
2089
|
+
<root>
|
|
2090
|
+
<mxCell id="${rootId}" />
|
|
2091
|
+
${allCells}
|
|
2092
|
+
</root>
|
|
2093
|
+
</mxGraphModel>`;
|
|
2094
|
+
const content = useCompressed ? compressDrawioDiagramXml(mxGraphModelXml) : mxGraphModelXml;
|
|
2095
|
+
return {
|
|
2096
|
+
name: diagramName,
|
|
2097
|
+
id: view.id,
|
|
2098
|
+
content
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
/** Wrap one or more diagram contents in mxfile XML. */
|
|
2102
|
+
function wrapInMxFile(diagrams, modified) {
|
|
2103
|
+
const modifiedAttr = escapeXml(modified ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
2104
|
+
if (diagrams.length === 0) return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2105
|
+
<mxfile host="LikeC4" modified="${modifiedAttr}" agent="LikeC4" version="1.0" etag="" type="device">
|
|
2106
|
+
</mxfile>
|
|
2107
|
+
`;
|
|
2108
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2109
|
+
<mxfile host="LikeC4" modified="${modifiedAttr}" agent="LikeC4" version="1.0" etag="" type="device"${diagrams.length > 1 ? ` pages="${diagrams.length}"` : ""}>
|
|
2110
|
+
${diagrams.map((d) => ` <diagram name="${escapeXml(d.name)}" id="${DRAWIO_DIAGRAM_ID_PREFIX}${escapeXml(d.id)}">${d.content}</diagram>`).join("\n")}
|
|
2111
|
+
</mxfile>
|
|
2112
|
+
`;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Generate a single DrawIO file from one view.
|
|
2116
|
+
*
|
|
2117
|
+
* @param viewmodel - Layouted LikeC4 view model (from model.view(id))
|
|
2118
|
+
* @param options - Optional overrides for layout/colors (round-trip from comment blocks)
|
|
2119
|
+
* @returns DrawIO .drawio XML string
|
|
2120
|
+
*/
|
|
2121
|
+
function generateDrawio(viewmodel, options) {
|
|
2122
|
+
return wrapInMxFile([generateDiagramContent(viewmodel, options)], options?.modified);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Generate a single DrawIO file with multiple diagrams (tabs).
|
|
2126
|
+
* Each view becomes one tab in draw.io. Use this when exporting a project
|
|
2127
|
+
* so all views open in one file with one tab per view.
|
|
2128
|
+
*
|
|
2129
|
+
* @param viewmodels - Layouted view models (e.g. from model.views())
|
|
2130
|
+
* @param optionsByViewId - Optional per-view options (e.g. compressed: false for each tab)
|
|
2131
|
+
* @param modified - Optional ISO timestamp for mxfile modified attribute (for deterministic output)
|
|
2132
|
+
* @returns DrawIO .drawio XML string with multiple <diagram> elements
|
|
2133
|
+
*/
|
|
2134
|
+
function generateDrawioMulti(viewmodels, optionsByViewId, modified) {
|
|
2135
|
+
return wrapInMxFile(viewmodels.map((vm) => generateDiagramContent(vm, optionsByViewId?.[vm.$view.id])), modified);
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Build export options from .c4 source round-trip comment blocks (layout, strokes, waypoints).
|
|
2139
|
+
* Shared by CLI and playground so options are built in one place (DRY).
|
|
2140
|
+
*
|
|
2141
|
+
* @param viewId - View id for layoutOverride lookup.
|
|
2142
|
+
* @param roundtrip - Parsed round-trip data or null (caller may parse once for many views).
|
|
2143
|
+
* @param overrides - Optional overrides (e.g. compressed: false).
|
|
2144
|
+
* @returns GenerateDrawioOptions for this view.
|
|
2145
|
+
*/
|
|
2146
|
+
function buildOptionsFromRoundtrip(viewId, roundtrip, overrides) {
|
|
2147
|
+
const options = {
|
|
2148
|
+
compressed: false,
|
|
2149
|
+
...overrides
|
|
2150
|
+
};
|
|
2151
|
+
if (!roundtrip) return options;
|
|
2152
|
+
const layoutForView = roundtrip.layoutByView[viewId]?.nodes;
|
|
2153
|
+
if (layoutForView != null) options.layoutOverride = layoutForView;
|
|
2154
|
+
if (Object.keys(roundtrip.strokeColorByFqn).length > 0) options.strokeColorByNodeId = roundtrip.strokeColorByFqn;
|
|
2155
|
+
if (Object.keys(roundtrip.strokeWidthByFqn).length > 0) options.strokeWidthByNodeId = roundtrip.strokeWidthByFqn;
|
|
2156
|
+
if (Object.keys(roundtrip.edgeWaypoints).length > 0) options.edgeWaypoints = roundtrip.edgeWaypoints;
|
|
2157
|
+
return options;
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Build export options for one view from .c4 source (parses source once).
|
|
2161
|
+
*
|
|
2162
|
+
* @param viewId - View id for layoutOverride lookup.
|
|
2163
|
+
* @param sourceContent - Full .c4 source (e.g. joined workspace files).
|
|
2164
|
+
* @param overrides - Optional overrides (e.g. compressed: false).
|
|
2165
|
+
* @returns GenerateDrawioOptions for this view.
|
|
2166
|
+
*/
|
|
2167
|
+
function buildDrawioExportOptionsFromSource(viewId, sourceContent, overrides) {
|
|
2168
|
+
return buildOptionsFromRoundtrip(viewId, sourceContent ? parseDrawioRoundtripComments(sourceContent) : null, overrides);
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Build export options per view id from .c4 source (parses source once for all views).
|
|
2172
|
+
*
|
|
2173
|
+
* @param viewIds - View ids to build options for.
|
|
2174
|
+
* @param sourceContent - Full .c4 source (e.g. joined workspace files).
|
|
2175
|
+
* @param overrides - Optional overrides (e.g. compressed: false).
|
|
2176
|
+
* @returns Record of viewId → GenerateDrawioOptions.
|
|
2177
|
+
*/
|
|
2178
|
+
function buildDrawioExportOptionsForViews(viewIds, sourceContent, overrides) {
|
|
2179
|
+
const roundtrip = sourceContent ? parseDrawioRoundtripComments(sourceContent) : null;
|
|
2180
|
+
return Object.fromEntries(viewIds.map((viewId) => [viewId, buildOptionsFromRoundtrip(viewId, roundtrip, overrides)]));
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Generate a draw.io editor URL that opens the given drawio XML pre-loaded.
|
|
2184
|
+
* Uses the `#create=` fragment with compressed XML data.
|
|
2185
|
+
*
|
|
2186
|
+
* @param xml - A .drawio XML string (output of generateDrawio / generateDrawioMulti).
|
|
2187
|
+
* @returns URL string like "https://app.diagrams.net/#create=..."
|
|
2188
|
+
*/
|
|
2189
|
+
function generateDrawioEditUrl(xml) {
|
|
2190
|
+
const base64 = compressDrawioDiagramXml(xml);
|
|
2191
|
+
const createObj = JSON.stringify({
|
|
2192
|
+
type: "xml",
|
|
2193
|
+
compressed: true,
|
|
2194
|
+
data: base64
|
|
2195
|
+
});
|
|
2196
|
+
return "https://app.diagrams.net/#create=" + encodeURIComponent(createObj);
|
|
2197
|
+
}
|
|
2198
|
+
const capitalizeFirstLetter$1 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
2199
|
+
const fqnName$1 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$1).join("");
|
|
2200
|
+
const nodeName$1 = (node) => {
|
|
2201
|
+
return fqnName$1(node.parent ? node.id.slice(node.parent.length + 1) : node.id);
|
|
2202
|
+
};
|
|
2203
|
+
const toSingleQuotes = (str) => str.replace(/\\?"/g, `'`);
|
|
2204
|
+
const mmdshape = ({ shape, title }) => {
|
|
2205
|
+
const label = `label: ${JSON.stringify(title)}`;
|
|
2206
|
+
switch (shape) {
|
|
2207
|
+
case "queue": return `@{ shape: horizontal-cylinder, ${label} }`;
|
|
2208
|
+
case "person": return `@{ icon: "fa:user", shape: rounded, ${label} }`;
|
|
2209
|
+
case "storage": return `@{ shape: disk, ${label} }`;
|
|
2210
|
+
case "cylinder": return `@{ shape: cylinder, ${label} }`;
|
|
2211
|
+
case "mobile":
|
|
2212
|
+
case "browser": return `@{ shape: rounded, ${label} }`;
|
|
2213
|
+
case "bucket": return `@{ shape: trap-t, ${label} }`;
|
|
2214
|
+
case "rectangle": return `@{ shape: rectangle, ${label} }`;
|
|
2215
|
+
case "document": return `@{ shape: doc, ${label} }`;
|
|
2216
|
+
case "component": return `@{ shape: rectangle, ${label} }`;
|
|
2217
|
+
default: nonexhaustive(shape);
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
function generateMermaid(viewmodel) {
|
|
2221
|
+
const view = viewmodel.$view;
|
|
2222
|
+
const { nodes, edges } = view;
|
|
2223
|
+
const names = /* @__PURE__ */ new Map();
|
|
2224
|
+
const printNode = (node, parentName) => {
|
|
2225
|
+
const name = nodeName$1(node);
|
|
2226
|
+
const fqnName = (parentName ? parentName + "." : "") + name;
|
|
2227
|
+
names.set(node.id, fqnName);
|
|
2228
|
+
const baseNode = new CompositeGeneratorNode();
|
|
2229
|
+
if (node.children.length > 0) {
|
|
2230
|
+
const label = toSingleQuotes(node.title);
|
|
2231
|
+
baseNode.append("subgraph ", fqnName, "[\"`", label, "`\"]", NL).indent({
|
|
2232
|
+
indentedChildren: [joinToNode(nodes.filter((n) => n.parent === node.id), (n) => printNode(n, fqnName), { appendNewLineIfNotEmpty: true })],
|
|
2233
|
+
indentation: 2
|
|
2234
|
+
}).append("end", NL);
|
|
2235
|
+
} else baseNode.append(fqnName, mmdshape(node));
|
|
2236
|
+
return baseNode;
|
|
2237
|
+
};
|
|
2238
|
+
const printEdge = (edge) => {
|
|
2239
|
+
return new CompositeGeneratorNode().append(names.get(edge.source), " -.", edge.label ? " \"`" + toSingleQuotes(edge.label) + "`\" .-" : "-", "> ", names.get(edge.target));
|
|
2240
|
+
};
|
|
2241
|
+
return toString(new CompositeGeneratorNode().append("---", NL, `title: ${JSON.stringify(toSingleQuotes(viewmodel.titleOrId))}`, NL, "---", NL).append("graph ", view.autoLayout.direction, NL).indent({
|
|
2242
|
+
indentedChildren: (indent) => {
|
|
2243
|
+
indent.append(joinToNode(nodes.filter((n) => isNullish(n.parent)), (n) => printNode(n), { appendNewLineIfNotEmpty: true })).appendIf(edges.length > 0, joinToNode(edges, (e) => printEdge(e), { appendNewLineIfNotEmpty: true }));
|
|
2244
|
+
},
|
|
2245
|
+
indentation: 2
|
|
2246
|
+
}));
|
|
479
2247
|
}
|
|
480
2248
|
function toUnion(elements) {
|
|
481
2249
|
if (elements.length === 0) return "never";
|
|
@@ -595,7 +2363,7 @@ function generatePuml(viewmodel) {
|
|
|
595
2363
|
const view = viewmodel.$view;
|
|
596
2364
|
const colors = viewmodel.$model.$styles.theme.colors;
|
|
597
2365
|
const { nodes, edges } = view;
|
|
598
|
-
const
|
|
2366
|
+
const elementColorProvider = (key) => (colorKey) => colorKey in colors ? colors[colorKey].elements[key] : void 0;
|
|
599
2367
|
const relationshipsColorProvider = (key) => (colorKey) => colorKey in colors ? colors[colorKey].relationships[key] : void 0;
|
|
600
2368
|
const names = /* @__PURE__ */ new Map();
|
|
601
2369
|
const printHeader = () => {
|
|
@@ -611,7 +2379,7 @@ function generatePuml(viewmodel) {
|
|
|
611
2379
|
const shape = pumlShape(node);
|
|
612
2380
|
const fqn = fqnName(node.id);
|
|
613
2381
|
return new CompositeGeneratorNode().append("skinparam ", shape, "<<", fqn, ">>", "{", NL).indent({
|
|
614
|
-
indentedChildren: (indent) => indent.append("BackgroundColor ", pumlColor(node.color,
|
|
2382
|
+
indentedChildren: (indent) => indent.append("BackgroundColor ", pumlColor(node.color, elementColorProvider("fill")), NL).append("FontColor ", pumlColor(node.color, elementColorProvider("hiContrast"), "#FFFFFF"), NL).append("BorderColor ", pumlColor(node.color, elementColorProvider("stroke")), NL),
|
|
615
2383
|
indentation: 2
|
|
616
2384
|
}).append("}", NL);
|
|
617
2385
|
};
|
|
@@ -629,7 +2397,7 @@ function generatePuml(viewmodel) {
|
|
|
629
2397
|
const fqn = fqnName(node.id);
|
|
630
2398
|
names.set(node.id, fqn);
|
|
631
2399
|
return new CompositeGeneratorNode().append("rectangle \"", label, "\" <<", fqn, ">> as ", fqn, " {", NL).indent({
|
|
632
|
-
indentedChildren: (indent) => indent.append("skinparam ", "RectangleBorderColor<<", fqn, ">> ", pumlColor(node.color,
|
|
2400
|
+
indentedChildren: (indent) => indent.append("skinparam ", "RectangleBorderColor<<", fqn, ">> ", pumlColor(node.color, elementColorProvider("fill")), NL).append("skinparam ", "RectangleFontColor<<", fqn, ">> ", pumlColor(node.color, elementColorProvider("fill")), NL).append("skinparam ", "RectangleBorderStyle<<", fqn, ">> ", "dashed", NL, NL).append(joinToNode(nodes.filter((n) => n.parent === node.id), (c) => c.children.length > 0 ? printBoundary(c) : printNode(c))),
|
|
633
2401
|
indentation: 2
|
|
634
2402
|
}).append("}", NL);
|
|
635
2403
|
};
|
|
@@ -650,6 +2418,9 @@ function generatePuml(viewmodel) {
|
|
|
650
2418
|
function generateViewId(views) {
|
|
651
2419
|
return joinToNode(views, (view) => expandToNode`${JSON5.stringify(view.id)}`, { separator: " | " });
|
|
652
2420
|
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Generate *.js file with views data
|
|
2423
|
+
*/
|
|
653
2424
|
function generateViewsDataJs(diagrams) {
|
|
654
2425
|
const views = Array.from(diagrams);
|
|
655
2426
|
const out = new CompositeGeneratorNode();
|
|
@@ -690,6 +2461,9 @@ function generateViewsDataJs(diagrams) {
|
|
|
690
2461
|
`.append(NL);
|
|
691
2462
|
return toString(out);
|
|
692
2463
|
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Generate *.ts file with views data
|
|
2466
|
+
*/
|
|
693
2467
|
function generateViewsDataTs(diagrams) {
|
|
694
2468
|
const views = Array.from(diagrams);
|
|
695
2469
|
const out = new CompositeGeneratorNode();
|
|
@@ -735,6 +2509,9 @@ function generateViewsDataTs(diagrams) {
|
|
|
735
2509
|
`.append(NL);
|
|
736
2510
|
return toString(out);
|
|
737
2511
|
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Generate *.d.ts
|
|
2514
|
+
*/
|
|
738
2515
|
function generateViewsDataDTs(diagrams) {
|
|
739
2516
|
const views = Array.from(diagrams);
|
|
740
2517
|
const out = new CompositeGeneratorNode();
|
|
@@ -763,6 +2540,9 @@ function generateViewsDataDTs(diagrams) {
|
|
|
763
2540
|
`.append(NL);
|
|
764
2541
|
return toString(out);
|
|
765
2542
|
}
|
|
2543
|
+
/**
|
|
2544
|
+
* @deprecated in favor packages/likec4/src/cli/codegen/react/index.ts
|
|
2545
|
+
*/
|
|
766
2546
|
function generateReactNext(views) {
|
|
767
2547
|
return {
|
|
768
2548
|
viewsData: {
|
|
@@ -925,4 +2705,4 @@ export {
|
|
|
925
2705
|
/* prettier-ignore-end */
|
|
926
2706
|
`.trimStart();
|
|
927
2707
|
}
|
|
928
|
-
export { generateD2, generateDrawio, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs, parseDrawioToLikeC4 };
|
|
2708
|
+
export { DEFAULT_DRAWIO_ALL_FILENAME, buildDrawioExportOptionsForViews, buildDrawioExportOptionsFromSource, generateD2, generateDrawio, generateDrawioEditUrl, generateDrawioMulti, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs, getAllDiagrams, parseDrawioRoundtripComments, parseDrawioToLikeC4, parseDrawioToLikeC4Multi };
|