@likec4/generators 1.48.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 +131 -3
- package/dist/index.mjs +2152 -33
- package/package.json +10 -8
- package/src/d2/generate-d2.ts +1 -0
- package/src/drawio/constants.ts +64 -0
- package/src/drawio/generate-drawio.ts +1366 -0
- package/src/drawio/index.ts +19 -0
- package/src/drawio/parse-drawio.ts +1836 -0
- package/src/drawio/xml-utils.ts +32 -0
- package/src/index.ts +16 -0
- package/src/mmd/generate-mmd.ts +3 -0
- package/src/puml/generate-puml.ts +9 -6
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { CompositeGeneratorNode, NL, expandToNode, joinToNode, toString } from "langium/generate";
|
|
2
2
|
import { isEmptyish, isNullish, keys, map, pipe, values } from "remeda";
|
|
3
|
-
import { nonexhaustive } from "@likec4/core";
|
|
3
|
+
import { LikeC4Styles, nonexhaustive } from "@likec4/core";
|
|
4
|
+
import { RichText, flattenMarkdownOrString } from "@likec4/core/types";
|
|
5
|
+
import pako from "pako";
|
|
4
6
|
import JSON5 from "json5";
|
|
5
7
|
import { compareNatural, invariant, sortNaturalByFqn } from "@likec4/core/utils";
|
|
6
|
-
import { RichText } from "@likec4/core/types";
|
|
7
|
-
|
|
8
|
-
//#region src/d2/generate-d2.ts
|
|
9
8
|
const capitalizeFirstLetter$2 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
10
9
|
const fqnName$2 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$2).join("");
|
|
11
10
|
const nodeName$2 = (node) => {
|
|
@@ -27,6 +26,7 @@ const d2shape = ({ shape }) => {
|
|
|
27
26
|
case "document": return shape;
|
|
28
27
|
case "person": return "c4-person";
|
|
29
28
|
case "storage": return "stored_data";
|
|
29
|
+
case "component":
|
|
30
30
|
case "bucket":
|
|
31
31
|
case "mobile":
|
|
32
32
|
case "browser": return "rectangle";
|
|
@@ -52,9 +52,2149 @@ function generateD2(viewmodel) {
|
|
|
52
52
|
};
|
|
53
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 })));
|
|
54
54
|
}
|
|
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("&", "&");
|
|
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=". */
|
|
148
|
+
function getAttr(attrs, name) {
|
|
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;
|
|
181
|
+
}
|
|
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);
|
|
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
|
+
*/
|
|
214
|
+
function parseStyle(style) {
|
|
215
|
+
const map = /* @__PURE__ */ new Map();
|
|
216
|
+
if (!style) return map;
|
|
217
|
+
for (const part of style.split(";")) {
|
|
218
|
+
const eq = part.indexOf("=");
|
|
219
|
+
if (eq > 0) {
|
|
220
|
+
const k = part.slice(0, eq).trim();
|
|
221
|
+
const v = part.slice(eq + 1).trim();
|
|
222
|
+
if (k && v) map.set(k.toLowerCase(), v);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return map;
|
|
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. */
|
|
286
|
+
function parseUserData(fullTag) {
|
|
287
|
+
const all = parseAllUserData(fullTag);
|
|
288
|
+
const out = {};
|
|
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);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
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,
|
|
411
|
+
vertex,
|
|
412
|
+
edge,
|
|
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);
|
|
496
|
+
}
|
|
497
|
+
return cells;
|
|
498
|
+
}
|
|
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
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Infer LikeC4 shape from DrawIO style when possible (cylinder, document, etc.). */
|
|
535
|
+
function inferShape(style) {
|
|
536
|
+
if (!style) return void 0;
|
|
537
|
+
const s = style.toLowerCase();
|
|
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";
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Sanitize a string for use as LikeC4 identifier (element name).
|
|
544
|
+
*/
|
|
545
|
+
function toId(name) {
|
|
546
|
+
return name.trim().replaceAll(/\s+/g, "_").replaceAll(/[^\w-]/g, "").replace(/^[0-9]/, "_$&") || "element";
|
|
547
|
+
}
|
|
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) => {
|
|
551
|
+
let name = toId(base || "element");
|
|
552
|
+
let n = name;
|
|
553
|
+
let i = 0;
|
|
554
|
+
while (usedNames.has(n)) n = `${name}_${++i}`;
|
|
555
|
+
usedNames.add(n);
|
|
556
|
+
return n;
|
|
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)));
|
|
563
|
+
let changed = true;
|
|
564
|
+
while (changed) {
|
|
565
|
+
changed = false;
|
|
566
|
+
for (const v of elementVertices) {
|
|
567
|
+
if (idToFqn.has(v.id)) continue;
|
|
568
|
+
const parent = v.parent ? idToFqn.get(v.parent) : null;
|
|
569
|
+
if (parent != null) {
|
|
570
|
+
idToFqn.set(v.id, `${parent}.${uniqueName(baseName(v))}`);
|
|
571
|
+
changed = true;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
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) {
|
|
579
|
+
const hexToCustomName = /* @__PURE__ */ new Map();
|
|
580
|
+
let customColorIndex = 0;
|
|
581
|
+
for (const v of elementVertices) {
|
|
582
|
+
const fill = v.fillColor?.trim();
|
|
583
|
+
if (fill && /^#[0-9A-Fa-f]{3,8}$/.test(fill)) {
|
|
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}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
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}`);
|
|
594
|
+
}
|
|
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
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
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;
|
|
628
|
+
}
|
|
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;
|
|
635
|
+
}
|
|
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) {
|
|
876
|
+
const src = idToFqn.get(e.source);
|
|
877
|
+
const tgt = idToFqn.get(e.target);
|
|
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
|
+
}
|
|
882
|
+
}
|
|
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 {
|
|
55
1244
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
2198
|
const capitalizeFirstLetter$1 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
59
2199
|
const fqnName$1 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$1).join("");
|
|
60
2200
|
const nodeName$1 = (node) => {
|
|
@@ -73,6 +2213,7 @@ const mmdshape = ({ shape, title }) => {
|
|
|
73
2213
|
case "bucket": return `@{ shape: trap-t, ${label} }`;
|
|
74
2214
|
case "rectangle": return `@{ shape: rectangle, ${label} }`;
|
|
75
2215
|
case "document": return `@{ shape: doc, ${label} }`;
|
|
2216
|
+
case "component": return `@{ shape: rectangle, ${label} }`;
|
|
76
2217
|
default: nonexhaustive(shape);
|
|
77
2218
|
}
|
|
78
2219
|
};
|
|
@@ -104,9 +2245,6 @@ function generateMermaid(viewmodel) {
|
|
|
104
2245
|
indentation: 2
|
|
105
2246
|
}));
|
|
106
2247
|
}
|
|
107
|
-
|
|
108
|
-
//#endregion
|
|
109
|
-
//#region src/model/generate-aux.ts
|
|
110
2248
|
function toUnion(elements) {
|
|
111
2249
|
if (elements.length === 0) return "never";
|
|
112
2250
|
return elements.sort(compareNatural).map((v) => ` | ${JSON.stringify(v)}`).join("\n").trimStart();
|
|
@@ -159,9 +2297,6 @@ export type $Tags = readonly $Aux['Tag'][]
|
|
|
159
2297
|
export type $MetadataKey = $Aux['MetadataKey']
|
|
160
2298
|
`.trimStart();
|
|
161
2299
|
}
|
|
162
|
-
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region src/model/generate-likec4-model.ts
|
|
165
2300
|
function generateLikeC4Model(model, options = {}) {
|
|
166
2301
|
const aux = generateAux(model, options);
|
|
167
2302
|
const { useCorePackage = false } = options;
|
|
@@ -185,9 +2320,6 @@ export const likec4model: LikeC4Model<$Aux> = new LikeC4Model(${JSON5.stringify(
|
|
|
185
2320
|
/* prettier-ignore-end */
|
|
186
2321
|
`.trimStart();
|
|
187
2322
|
}
|
|
188
|
-
|
|
189
|
-
//#endregion
|
|
190
|
-
//#region src/puml/generate-puml.ts
|
|
191
2323
|
const capitalizeFirstLetter = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
192
2324
|
const fqnName = (nodeId) => {
|
|
193
2325
|
return nodeId.split(/[.-]/).map(capitalizeFirstLetter).join("");
|
|
@@ -218,6 +2350,7 @@ const pumlShape = ({ shape }) => {
|
|
|
218
2350
|
case "person": return shape;
|
|
219
2351
|
case "storage":
|
|
220
2352
|
case "cylinder": return "database";
|
|
2353
|
+
case "component": return "component";
|
|
221
2354
|
case "document":
|
|
222
2355
|
case "mobile":
|
|
223
2356
|
case "bucket":
|
|
@@ -230,7 +2363,7 @@ function generatePuml(viewmodel) {
|
|
|
230
2363
|
const view = viewmodel.$view;
|
|
231
2364
|
const colors = viewmodel.$model.$styles.theme.colors;
|
|
232
2365
|
const { nodes, edges } = view;
|
|
233
|
-
const
|
|
2366
|
+
const elementColorProvider = (key) => (colorKey) => colorKey in colors ? colors[colorKey].elements[key] : void 0;
|
|
234
2367
|
const relationshipsColorProvider = (key) => (colorKey) => colorKey in colors ? colors[colorKey].relationships[key] : void 0;
|
|
235
2368
|
const names = /* @__PURE__ */ new Map();
|
|
236
2369
|
const printHeader = () => {
|
|
@@ -246,7 +2379,7 @@ function generatePuml(viewmodel) {
|
|
|
246
2379
|
const shape = pumlShape(node);
|
|
247
2380
|
const fqn = fqnName(node.id);
|
|
248
2381
|
return new CompositeGeneratorNode().append("skinparam ", shape, "<<", fqn, ">>", "{", NL).indent({
|
|
249
|
-
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),
|
|
250
2383
|
indentation: 2
|
|
251
2384
|
}).append("}", NL);
|
|
252
2385
|
};
|
|
@@ -264,7 +2397,7 @@ function generatePuml(viewmodel) {
|
|
|
264
2397
|
const fqn = fqnName(node.id);
|
|
265
2398
|
names.set(node.id, fqn);
|
|
266
2399
|
return new CompositeGeneratorNode().append("rectangle \"", label, "\" <<", fqn, ">> as ", fqn, " {", NL).indent({
|
|
267
|
-
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))),
|
|
268
2401
|
indentation: 2
|
|
269
2402
|
}).append("}", NL);
|
|
270
2403
|
};
|
|
@@ -282,15 +2415,9 @@ function generatePuml(viewmodel) {
|
|
|
282
2415
|
};
|
|
283
2416
|
return toString(new CompositeGeneratorNode().append("@startuml", NL).append(printHeader(), NL).append(printTheme(), NL).append(joinToNode(nodes.filter((n) => n.children.length == 0), (n) => printStereotypes(n), { appendNewLineIfNotEmpty: true })).append(joinToNode(nodes.filter((n) => isNullish(n.parent)), (n) => n.children.length > 0 ? printBoundary(n) : printNode(n), { appendNewLineIfNotEmpty: true })).appendIf(edges.length > 0, NL, joinToNode(edges, (e) => printEdge(e), { appendNewLineIfNotEmpty: true })).append(`@enduml`, NL));
|
|
284
2417
|
}
|
|
285
|
-
|
|
286
|
-
//#endregion
|
|
287
|
-
//#region src/views-data-ts/generateViewId.ts
|
|
288
2418
|
function generateViewId(views) {
|
|
289
2419
|
return joinToNode(views, (view) => expandToNode`${JSON5.stringify(view.id)}`, { separator: " | " });
|
|
290
2420
|
}
|
|
291
|
-
|
|
292
|
-
//#endregion
|
|
293
|
-
//#region src/views-data-ts/generate-views-data.ts
|
|
294
2421
|
/**
|
|
295
2422
|
* Generate *.js file with views data
|
|
296
2423
|
*/
|
|
@@ -413,9 +2540,6 @@ function generateViewsDataDTs(diagrams) {
|
|
|
413
2540
|
`.append(NL);
|
|
414
2541
|
return toString(out);
|
|
415
2542
|
}
|
|
416
|
-
|
|
417
|
-
//#endregion
|
|
418
|
-
//#region src/react-next/generate-react-next.ts
|
|
419
2543
|
/**
|
|
420
2544
|
* @deprecated in favor packages/likec4/src/cli/codegen/react/index.ts
|
|
421
2545
|
*/
|
|
@@ -519,9 +2643,6 @@ function generateIndex() {
|
|
|
519
2643
|
dts: toString(dts)
|
|
520
2644
|
};
|
|
521
2645
|
}
|
|
522
|
-
|
|
523
|
-
//#endregion
|
|
524
|
-
//#region src/react/generate-react-types.ts
|
|
525
2646
|
function generateReactTypes(model, options = {}) {
|
|
526
2647
|
const { useCorePackage = false } = options;
|
|
527
2648
|
invariant(!model.isParsed(), "can not generate react types for parsed model");
|
|
@@ -584,6 +2705,4 @@ export {
|
|
|
584
2705
|
/* prettier-ignore-end */
|
|
585
2706
|
`.trimStart();
|
|
586
2707
|
}
|
|
587
|
-
|
|
588
|
-
//#endregion
|
|
589
|
-
export { generateD2, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs };
|
|
2708
|
+
export { DEFAULT_DRAWIO_ALL_FILENAME, buildDrawioExportOptionsForViews, buildDrawioExportOptionsFromSource, generateD2, generateDrawio, generateDrawioEditUrl, generateDrawioMulti, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs, getAllDiagrams, parseDrawioRoundtripComments, parseDrawioToLikeC4, parseDrawioToLikeC4Multi };
|