@likec4/generators 1.48.0 → 1.49.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 +22 -2
- package/dist/index.mjs +382 -43
- package/package.json +7 -7
- package/src/d2/generate-d2.ts +1 -0
- package/src/drawio/generate-drawio.ts +219 -0
- package/src/drawio/index.ts +2 -0
- package/src/drawio/parse-drawio.ts +324 -0
- package/src/index.ts +2 -0
- package/src/mmd/generate-mmd.ts +3 -0
- package/src/puml/generate-puml.ts +3 -0
package/dist/index.d.mts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import { DiagramView } from "@likec4/core";
|
|
2
1
|
import { aux } from "@likec4/core/types";
|
|
2
|
+
import { DiagramView } from "@likec4/core";
|
|
3
3
|
import { AnyLikeC4Model, LikeC4Model, LikeC4ViewModel } from "@likec4/core/model";
|
|
4
4
|
|
|
5
|
+
//#region src/drawio/generate-drawio.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Generate DrawIO (mxGraph) XML from a layouted LikeC4 view.
|
|
8
|
+
* Preserves positions, hierarchy, colors, descriptions and technology so the diagram
|
|
9
|
+
* can be opened and edited in draw.io with full compatibility.
|
|
10
|
+
*
|
|
11
|
+
* @param viewmodel - Layouted LikeC4 view model (from model.view(id))
|
|
12
|
+
* @returns DrawIO .drawio XML string
|
|
13
|
+
*/
|
|
14
|
+
declare function generateDrawio(viewmodel: LikeC4ViewModel<aux.Unknown>): string;
|
|
15
|
+
//#endregion
|
|
5
16
|
//#region src/d2/generate-d2.d.ts
|
|
6
17
|
declare function generateD2(viewmodel: LikeC4ViewModel<aux.Unknown>): string;
|
|
7
18
|
//#endregion
|
|
8
19
|
//#region src/mmd/generate-mmd.d.ts
|
|
9
20
|
declare function generateMermaid(viewmodel: LikeC4ViewModel<aux.Unknown>): string;
|
|
10
21
|
//#endregion
|
|
22
|
+
//#region src/drawio/parse-drawio.d.ts
|
|
23
|
+
/**
|
|
24
|
+
* Convert DrawIO XML to LikeC4 source (.c4) string.
|
|
25
|
+
* - Vertices become model elements (actor/container); hierarchy from parent refs.
|
|
26
|
+
* - Edges become relations (->).
|
|
27
|
+
* - Root diagram cells (parent "1") are top-level; others are nested by parent.
|
|
28
|
+
*/
|
|
29
|
+
declare function parseDrawioToLikeC4(xml: string): string;
|
|
30
|
+
//#endregion
|
|
11
31
|
//#region src/model/generate-likec4-model.d.ts
|
|
12
32
|
declare function generateLikeC4Model(model: LikeC4Model<any>, options?: {
|
|
13
33
|
useCorePackage?: boolean;
|
|
@@ -56,4 +76,4 @@ declare function generateViewsDataTs(diagrams: Iterable<DiagramView>): string;
|
|
|
56
76
|
*/
|
|
57
77
|
declare function generateViewsDataDTs(diagrams: Iterable<DiagramView>): string;
|
|
58
78
|
//#endregion
|
|
59
|
-
export { generateD2, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs };
|
|
79
|
+
export { generateD2, generateDrawio, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs, parseDrawioToLikeC4 };
|
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,169 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RichText, flattenMarkdownOrString } from "@likec4/core/types";
|
|
2
2
|
import { isEmptyish, isNullish, keys, map, pipe, values } from "remeda";
|
|
3
|
+
import { CompositeGeneratorNode, NL, expandToNode, joinToNode, toString } from "langium/generate";
|
|
3
4
|
import { nonexhaustive } from "@likec4/core";
|
|
4
5
|
import JSON5 from "json5";
|
|
5
6
|
import { compareNatural, invariant, sortNaturalByFqn } from "@likec4/core/utils";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const DEFAULT_ELEMENT_COLORS = {
|
|
8
|
+
primary: {
|
|
9
|
+
fill: "#3b82f6",
|
|
10
|
+
stroke: "#2563eb"
|
|
11
|
+
},
|
|
12
|
+
gray: {
|
|
13
|
+
fill: "#6b7280",
|
|
14
|
+
stroke: "#4b5563"
|
|
15
|
+
},
|
|
16
|
+
green: {
|
|
17
|
+
fill: "#22c55e",
|
|
18
|
+
stroke: "#16a34a"
|
|
19
|
+
},
|
|
20
|
+
red: {
|
|
21
|
+
fill: "#ef4444",
|
|
22
|
+
stroke: "#dc2626"
|
|
23
|
+
},
|
|
24
|
+
blue: {
|
|
25
|
+
fill: "#3b82f6",
|
|
26
|
+
stroke: "#2563eb"
|
|
27
|
+
},
|
|
28
|
+
indigo: {
|
|
29
|
+
fill: "#6366f1",
|
|
30
|
+
stroke: "#4f46e5"
|
|
31
|
+
},
|
|
32
|
+
muted: {
|
|
33
|
+
fill: "#9ca3af",
|
|
34
|
+
stroke: "#6b7280"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const DEFAULT_EDGE_COLOR = "#6b7280";
|
|
38
|
+
function escapeXml(unsafe) {
|
|
39
|
+
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
40
|
+
}
|
|
41
|
+
function drawioShape(shape) {
|
|
42
|
+
switch (shape) {
|
|
43
|
+
case "person": return "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;";
|
|
44
|
+
case "rectangle": return "shape=rectangle;";
|
|
45
|
+
case "browser": return "shape=rectangle;rounded=1;";
|
|
46
|
+
case "mobile": return "shape=rectangle;rounded=1;";
|
|
47
|
+
case "cylinder": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
48
|
+
case "queue": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
49
|
+
case "storage": return "shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;";
|
|
50
|
+
case "bucket": return "shape=rectangle;rounded=1;";
|
|
51
|
+
case "document": return "shape=document;whiteSpace=wrap;html=1;boundedLbl=1;";
|
|
52
|
+
case "component": return "shape=component;";
|
|
53
|
+
default: return "shape=rectangle;";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function getElementColors(viewmodel, color) {
|
|
57
|
+
const styles = "$styles" in viewmodel && viewmodel.$styles ? viewmodel.$styles : null;
|
|
58
|
+
if (styles) try {
|
|
59
|
+
const values = styles.colors(color);
|
|
60
|
+
return {
|
|
61
|
+
fill: values.elements.fill,
|
|
62
|
+
stroke: values.elements.stroke
|
|
63
|
+
};
|
|
64
|
+
} catch {}
|
|
65
|
+
return DEFAULT_ELEMENT_COLORS[color] ?? DEFAULT_ELEMENT_COLORS["primary"];
|
|
66
|
+
}
|
|
67
|
+
function getEdgeStrokeColor(viewmodel, color) {
|
|
68
|
+
const styles = "$styles" in viewmodel && viewmodel.$styles ? viewmodel.$styles : null;
|
|
69
|
+
if (styles) try {
|
|
70
|
+
return styles.colors(color).relationships.line;
|
|
71
|
+
} catch {}
|
|
72
|
+
return DEFAULT_EDGE_COLOR;
|
|
73
|
+
}
|
|
74
|
+
function generateDrawio(viewmodel) {
|
|
75
|
+
const view = viewmodel.$view;
|
|
76
|
+
const { nodes, edges } = view;
|
|
77
|
+
const rootId = "0";
|
|
78
|
+
const defaultParentId = "1";
|
|
79
|
+
const nodeIds = /* @__PURE__ */ new Map();
|
|
80
|
+
let cellId = 2;
|
|
81
|
+
const getCellId = (nodeId) => {
|
|
82
|
+
let id = nodeIds.get(nodeId);
|
|
83
|
+
if (!id) {
|
|
84
|
+
id = String(cellId++);
|
|
85
|
+
nodeIds.set(nodeId, id);
|
|
86
|
+
}
|
|
87
|
+
return id;
|
|
88
|
+
};
|
|
89
|
+
const vertexCells = [];
|
|
90
|
+
const edgeCells = [];
|
|
91
|
+
const sortedNodes = [...nodes].sort((a, b) => {
|
|
92
|
+
if (isNullish(a.parent) && isNullish(b.parent)) return 0;
|
|
93
|
+
if (isNullish(a.parent)) return -1;
|
|
94
|
+
if (isNullish(b.parent)) return 1;
|
|
95
|
+
if (a.parent === b.parent) return 0;
|
|
96
|
+
if (a.id.startsWith(b.id + ".")) return 1;
|
|
97
|
+
if (b.id.startsWith(a.id + ".")) return -1;
|
|
98
|
+
return 0;
|
|
99
|
+
});
|
|
100
|
+
const getBBox = (n) => {
|
|
101
|
+
const d = n;
|
|
102
|
+
return {
|
|
103
|
+
x: typeof d.x === "number" ? d.x : Array.isArray(d.position) ? d.position[0] : 0,
|
|
104
|
+
y: typeof d.y === "number" ? d.y : Array.isArray(d.position) ? d.position[1] : 0,
|
|
105
|
+
width: typeof d.width === "number" ? d.width : d.size?.width ?? 120,
|
|
106
|
+
height: typeof d.height === "number" ? d.height : d.size?.height ?? 60
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
for (const node of sortedNodes) {
|
|
110
|
+
const id = getCellId(node.id);
|
|
111
|
+
const parentId = node.parent ? getCellId(node.parent) : defaultParentId;
|
|
112
|
+
const label = escapeXml(node.title);
|
|
113
|
+
const shapeStyle = drawioShape(node.shape);
|
|
114
|
+
const { x, y, width, height } = getBBox(node);
|
|
115
|
+
const elemColors = getElementColors(viewmodel, node.color);
|
|
116
|
+
const colorStyle = elemColors != null ? `fillColor=${elemColors.fill};strokeColor=${elemColors.stroke};fontColor=${elemColors.stroke};` : "";
|
|
117
|
+
const description = node.description && flattenMarkdownOrString(node.description);
|
|
118
|
+
const desc = isEmptyish(description) ? "" : escapeXml(description);
|
|
119
|
+
const technology = node.technology && flattenMarkdownOrString(node.technology);
|
|
120
|
+
const tech = isEmptyish(technology) ? "" : escapeXml(technology);
|
|
121
|
+
const userData = desc !== "" || tech !== "" ? `<mxUserObject><data key="likec4Description">${desc}</data><data key="likec4Technology">${tech}</data></mxUserObject>\n ` : "";
|
|
122
|
+
vertexCells.push(`<mxCell id="${id}" value="${label}" style="${shapeStyle}${colorStyle}verticalAlign=middle;align=center;overflow=fill;spacingLeft=2;spacingRight=2;spacingTop=2;spacingBottom=2;" vertex="1" parent="${parentId}">
|
|
123
|
+
${userData}<mxGeometry x="${Math.round(x)}" y="${Math.round(y)}" width="${Math.round(width)}" height="${Math.round(height)}" as="geometry" />
|
|
124
|
+
</mxCell>`);
|
|
125
|
+
}
|
|
126
|
+
for (const edge of edges) {
|
|
127
|
+
const id = String(cellId++);
|
|
128
|
+
const sourceId = getCellId(edge.source);
|
|
129
|
+
const targetId = getCellId(edge.target);
|
|
130
|
+
const label = edge.label ? escapeXml(edge.label) : "";
|
|
131
|
+
const strokeColor = getEdgeStrokeColor(viewmodel, edge.color);
|
|
132
|
+
const dashStyle = edge.line === "dashed" ? "dashed=1;" : edge.line === "dotted" ? "dashed=1;dashPattern=1 1;" : "";
|
|
133
|
+
edgeCells.push(`<mxCell id="${id}" value="${label}" style="endArrow=block;html=1;rounded=0;exitX=1;exitY=0.5;entryX=0;entryY=0.5;strokeColor=${strokeColor};${dashStyle}" edge="1" parent="${defaultParentId}" source="${sourceId}" target="${targetId}">
|
|
134
|
+
<mxGeometry relative="1" as="geometry" />
|
|
135
|
+
</mxCell>`);
|
|
136
|
+
}
|
|
137
|
+
let bounds = {
|
|
138
|
+
x: 0,
|
|
139
|
+
y: 0,
|
|
140
|
+
width: 800,
|
|
141
|
+
height: 600
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
const b = viewmodel.bounds;
|
|
145
|
+
if (b != null && typeof b.x === "number") bounds = b;
|
|
146
|
+
} catch {}
|
|
147
|
+
const allCells = [
|
|
148
|
+
`<mxCell id="${defaultParentId}" vertex="1" parent="${rootId}">
|
|
149
|
+
<mxGeometry x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" as="geometry" />
|
|
150
|
+
</mxCell>`,
|
|
151
|
+
...vertexCells,
|
|
152
|
+
...edgeCells
|
|
153
|
+
].join("\n");
|
|
154
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
+
<mxfile host="LikeC4" modified="${(/* @__PURE__ */ new Date()).toISOString()}" agent="LikeC4" version="1.0" etag="" type="device">
|
|
156
|
+
<diagram name="${escapeXml(view.id)}" id="likec4-${escapeXml(view.id)}">
|
|
157
|
+
<mxGraphModel dx="800" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale=1 pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
|
158
|
+
<root>
|
|
159
|
+
<mxCell id="${rootId}" />
|
|
160
|
+
${allCells}
|
|
161
|
+
</root>
|
|
162
|
+
</mxGraphModel>
|
|
163
|
+
</diagram>
|
|
164
|
+
</mxfile>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
9
167
|
const capitalizeFirstLetter$2 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
10
168
|
const fqnName$2 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$2).join("");
|
|
11
169
|
const nodeName$2 = (node) => {
|
|
@@ -27,6 +185,7 @@ const d2shape = ({ shape }) => {
|
|
|
27
185
|
case "document": return shape;
|
|
28
186
|
case "person": return "c4-person";
|
|
29
187
|
case "storage": return "stored_data";
|
|
188
|
+
case "component":
|
|
30
189
|
case "bucket":
|
|
31
190
|
case "mobile":
|
|
32
191
|
case "browser": return "rectangle";
|
|
@@ -52,9 +211,6 @@ function generateD2(viewmodel) {
|
|
|
52
211
|
};
|
|
53
212
|
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
213
|
}
|
|
55
|
-
|
|
56
|
-
//#endregion
|
|
57
|
-
//#region src/mmd/generate-mmd.ts
|
|
58
214
|
const capitalizeFirstLetter$1 = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
59
215
|
const fqnName$1 = (nodeId) => nodeId.split(".").map(capitalizeFirstLetter$1).join("");
|
|
60
216
|
const nodeName$1 = (node) => {
|
|
@@ -73,6 +229,7 @@ const mmdshape = ({ shape, title }) => {
|
|
|
73
229
|
case "bucket": return `@{ shape: trap-t, ${label} }`;
|
|
74
230
|
case "rectangle": return `@{ shape: rectangle, ${label} }`;
|
|
75
231
|
case "document": return `@{ shape: doc, ${label} }`;
|
|
232
|
+
case "component": return `@{ shape: rectangle, ${label} }`;
|
|
76
233
|
default: nonexhaustive(shape);
|
|
77
234
|
}
|
|
78
235
|
};
|
|
@@ -104,9 +261,222 @@ function generateMermaid(viewmodel) {
|
|
|
104
261
|
indentation: 2
|
|
105
262
|
}));
|
|
106
263
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
264
|
+
function getAttr(attrs, name) {
|
|
265
|
+
const re = new RegExp(`${name}="([^"]*)"`, "i");
|
|
266
|
+
const m = attrs.match(re);
|
|
267
|
+
return m ? m[1] : void 0;
|
|
268
|
+
}
|
|
269
|
+
function parseNum(s) {
|
|
270
|
+
if (s === void 0 || s === "") return void 0;
|
|
271
|
+
const n = Number.parseFloat(s);
|
|
272
|
+
return Number.isNaN(n) ? void 0 : n;
|
|
273
|
+
}
|
|
274
|
+
function parseStyle(style) {
|
|
275
|
+
const map = /* @__PURE__ */ new Map();
|
|
276
|
+
if (!style) return map;
|
|
277
|
+
for (const part of style.split(";")) {
|
|
278
|
+
const eq = part.indexOf("=");
|
|
279
|
+
if (eq > 0) {
|
|
280
|
+
const k = part.slice(0, eq).trim();
|
|
281
|
+
const v = part.slice(eq + 1).trim();
|
|
282
|
+
if (k && v) map.set(k.toLowerCase(), v);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return map;
|
|
286
|
+
}
|
|
287
|
+
function parseUserData(fullTag) {
|
|
288
|
+
const out = {};
|
|
289
|
+
const descMatch = fullTag.match(/<data\s+key="likec4Description"[^>]*>([\s\S]*?)<\/data>/i);
|
|
290
|
+
if (descMatch?.[1]) out.description = decodeXmlEntities(descMatch[1].trim());
|
|
291
|
+
const techMatch = fullTag.match(/<data\s+key="likec4Technology"[^>]*>([\s\S]*?)<\/data>/i);
|
|
292
|
+
if (techMatch?.[1]) out.technology = decodeXmlEntities(techMatch[1].trim());
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
function parseDrawioXml(xml) {
|
|
296
|
+
const cells = [];
|
|
297
|
+
const mxCellRe = /<mxCell\s+([^>]+?)(?:\s*\/>|>([\s\S]*?)<\/mxCell>)/gi;
|
|
298
|
+
const geomAttr = (tag, name) => getAttr(tag, name);
|
|
299
|
+
let m;
|
|
300
|
+
while ((m = mxCellRe.exec(xml)) !== null) {
|
|
301
|
+
const attrs = m[1] ?? "";
|
|
302
|
+
const inner = m[2] ?? "";
|
|
303
|
+
const id = getAttr(attrs, "id");
|
|
304
|
+
if (!id) continue;
|
|
305
|
+
const valueRaw = getAttr(attrs, "value");
|
|
306
|
+
const parent = getAttr(attrs, "parent");
|
|
307
|
+
const source = getAttr(attrs, "source");
|
|
308
|
+
const target = getAttr(attrs, "target");
|
|
309
|
+
const vertex = getAttr(attrs, "vertex") === "1";
|
|
310
|
+
const edge = getAttr(attrs, "edge") === "1";
|
|
311
|
+
const style = getAttr(attrs, "style");
|
|
312
|
+
const geomMatch = m[0].match(/<mxGeometry[^>]*>/i);
|
|
313
|
+
const geomStr = geomMatch ? geomMatch[0] : "";
|
|
314
|
+
const styleMap = parseStyle(style ?? void 0);
|
|
315
|
+
const userData = parseUserData(inner);
|
|
316
|
+
const x = parseNum(geomAttr(geomStr, "x"));
|
|
317
|
+
const y = parseNum(geomAttr(geomStr, "y"));
|
|
318
|
+
const width = parseNum(geomAttr(geomStr, "width"));
|
|
319
|
+
const height = parseNum(geomAttr(geomStr, "height"));
|
|
320
|
+
const fillColor = styleMap.get("fillcolor") ?? styleMap.get("fillColor");
|
|
321
|
+
const strokeColor = styleMap.get("strokecolor") ?? styleMap.get("strokeColor");
|
|
322
|
+
const cell = {
|
|
323
|
+
id,
|
|
324
|
+
...valueRaw != null && valueRaw !== "" ? { value: decodeXmlEntities(valueRaw) } : {},
|
|
325
|
+
...parent != null && parent !== "" ? { parent } : {},
|
|
326
|
+
...source != null && source !== "" ? { source } : {},
|
|
327
|
+
...target != null && target !== "" ? { target } : {},
|
|
328
|
+
vertex,
|
|
329
|
+
edge,
|
|
330
|
+
...style != null && style !== "" ? { style } : {},
|
|
331
|
+
...x !== void 0 ? { x } : {},
|
|
332
|
+
...y !== void 0 ? { y } : {},
|
|
333
|
+
...width !== void 0 ? { width } : {},
|
|
334
|
+
...height !== void 0 ? { height } : {},
|
|
335
|
+
...fillColor !== void 0 ? { fillColor } : {},
|
|
336
|
+
...strokeColor !== void 0 ? { strokeColor } : {},
|
|
337
|
+
...userData.description != null ? { description: userData.description } : {},
|
|
338
|
+
...userData.technology != null ? { technology: userData.technology } : {}
|
|
339
|
+
};
|
|
340
|
+
cells.push(cell);
|
|
341
|
+
}
|
|
342
|
+
return cells;
|
|
343
|
+
}
|
|
344
|
+
function decodeXmlEntities(s) {
|
|
345
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&");
|
|
346
|
+
}
|
|
347
|
+
function inferKind(style) {
|
|
348
|
+
if (!style) return "container";
|
|
349
|
+
const s = style.toLowerCase();
|
|
350
|
+
if (s.includes("umlactor") || s.includes("shape=person")) return "actor";
|
|
351
|
+
if (s.includes("swimlane") || s.includes("shape=rectangle") && s.includes("rounded")) return "system";
|
|
352
|
+
return "container";
|
|
353
|
+
}
|
|
354
|
+
function toId(name) {
|
|
355
|
+
return name.trim().replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]/g, "").replace(/^[0-9]/, "_$&") || "element";
|
|
356
|
+
}
|
|
357
|
+
function parseDrawioToLikeC4(xml) {
|
|
358
|
+
const cells = parseDrawioXml(xml);
|
|
359
|
+
const byId = /* @__PURE__ */ new Map();
|
|
360
|
+
for (const c of cells) byId.set(c.id, c);
|
|
361
|
+
const vertices = cells.filter((c) => c.vertex && c.id !== "1");
|
|
362
|
+
const edges = cells.filter((c) => c.edge && c.source && c.target);
|
|
363
|
+
const rootId = "1";
|
|
364
|
+
const idToFqn = /* @__PURE__ */ new Map();
|
|
365
|
+
const idToCell = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const v of vertices) idToCell.set(v.id, v);
|
|
367
|
+
const usedNames = /* @__PURE__ */ new Set();
|
|
368
|
+
function uniqueName(base) {
|
|
369
|
+
let name = toId(base || "element");
|
|
370
|
+
let n = name;
|
|
371
|
+
let i = 0;
|
|
372
|
+
while (usedNames.has(n)) n = `${name}_${++i}`;
|
|
373
|
+
usedNames.add(n);
|
|
374
|
+
return n;
|
|
375
|
+
}
|
|
376
|
+
for (const v of vertices) if (v.parent === rootId || !v.parent) {
|
|
377
|
+
const name = uniqueName(v.value ?? v.id);
|
|
378
|
+
idToFqn.set(v.id, name);
|
|
379
|
+
}
|
|
380
|
+
let changed = true;
|
|
381
|
+
while (changed) {
|
|
382
|
+
changed = false;
|
|
383
|
+
for (const v of vertices) {
|
|
384
|
+
if (idToFqn.has(v.id)) continue;
|
|
385
|
+
const parent = v.parent ? idToFqn.get(v.parent) : null;
|
|
386
|
+
if (parent != null) {
|
|
387
|
+
const local = uniqueName(v.value ?? v.id);
|
|
388
|
+
idToFqn.set(v.id, `${parent}.${local}`);
|
|
389
|
+
changed = true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const v of vertices) if (!idToFqn.has(v.id)) idToFqn.set(v.id, uniqueName(v.value ?? v.id));
|
|
394
|
+
const hexToCustomName = /* @__PURE__ */ new Map();
|
|
395
|
+
let customColorIndex = 0;
|
|
396
|
+
for (const v of vertices) {
|
|
397
|
+
const fill = v.fillColor?.trim();
|
|
398
|
+
if (fill && /^#[0-9A-Fa-f]{3,8}$/.test(fill)) {
|
|
399
|
+
if (!hexToCustomName.has(fill)) hexToCustomName.set(fill, `drawio_color_${++customColorIndex}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const lines = [];
|
|
403
|
+
if (hexToCustomName.size > 0) {
|
|
404
|
+
lines.push("specification {");
|
|
405
|
+
for (const [hex, name] of hexToCustomName) lines.push(` color ${name} ${hex}`);
|
|
406
|
+
lines.push("}");
|
|
407
|
+
lines.push("");
|
|
408
|
+
}
|
|
409
|
+
lines.push("model {");
|
|
410
|
+
lines.push("");
|
|
411
|
+
const children = /* @__PURE__ */ new Map();
|
|
412
|
+
const roots = [];
|
|
413
|
+
for (const [cellId, fqn] of idToFqn) {
|
|
414
|
+
const cell = idToCell.get(cellId);
|
|
415
|
+
if (!cell) continue;
|
|
416
|
+
if (cell.parent === rootId || !cell.parent) roots.push({
|
|
417
|
+
cellId,
|
|
418
|
+
fqn
|
|
419
|
+
});
|
|
420
|
+
else {
|
|
421
|
+
const parentFqn = idToFqn.get(cell.parent);
|
|
422
|
+
if (parentFqn != null) {
|
|
423
|
+
const list = children.get(parentFqn) ?? [];
|
|
424
|
+
list.push({
|
|
425
|
+
cellId,
|
|
426
|
+
fqn
|
|
427
|
+
});
|
|
428
|
+
children.set(parentFqn, list);
|
|
429
|
+
} else roots.push({
|
|
430
|
+
cellId,
|
|
431
|
+
fqn
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function emitElement(cellId, fqn, indent) {
|
|
436
|
+
const cell = idToCell.get(cellId);
|
|
437
|
+
if (!cell) return;
|
|
438
|
+
const kind = inferKind(cell.style);
|
|
439
|
+
const title = cell.value && cell.value.trim() || fqn.split(".").pop() || "Element";
|
|
440
|
+
const name = fqn.split(".").pop();
|
|
441
|
+
const pad = " ".repeat(indent);
|
|
442
|
+
const desc = cell.description?.trim();
|
|
443
|
+
const tech = cell.technology?.trim();
|
|
444
|
+
const colorName = cell.fillColor && /^#[0-9A-Fa-f]{3,8}$/.test(cell.fillColor.trim()) ? hexToCustomName.get(cell.fillColor.trim()) : void 0;
|
|
445
|
+
if (kind === "actor") lines.push(`${pad}${name} = actor '${title.replace(/'/g, "''")}'`);
|
|
446
|
+
else if (kind === "system") lines.push(`${pad}${name} = system '${title.replace(/'/g, "''")}'`);
|
|
447
|
+
else lines.push(`${pad}${name} = container '${title.replace(/'/g, "''")}'`);
|
|
448
|
+
const childList = children.get(fqn);
|
|
449
|
+
if (childList && childList.length > 0 || desc || tech || colorName) {
|
|
450
|
+
lines.push(`${pad}{`);
|
|
451
|
+
if (colorName) lines.push(`${pad} style { color ${colorName} }`);
|
|
452
|
+
if (desc) lines.push(`${pad} description '${desc.replace(/'/g, "''")}'`);
|
|
453
|
+
if (tech) lines.push(`${pad} technology '${tech.replace(/'/g, "''")}'`);
|
|
454
|
+
if (childList && childList.length > 0) for (const ch of childList) emitElement(ch.cellId, ch.fqn, indent + 1);
|
|
455
|
+
lines.push(`${pad}}`);
|
|
456
|
+
} else {
|
|
457
|
+
lines.push(`${pad}{`);
|
|
458
|
+
lines.push(`${pad}}`);
|
|
459
|
+
}
|
|
460
|
+
lines.push("");
|
|
461
|
+
}
|
|
462
|
+
for (const { cellId, fqn } of roots) emitElement(cellId, fqn, 1);
|
|
463
|
+
for (const e of edges) {
|
|
464
|
+
const src = idToFqn.get(e.source);
|
|
465
|
+
const tgt = idToFqn.get(e.target);
|
|
466
|
+
if (!src || !tgt) continue;
|
|
467
|
+
const label = e.value && e.value.trim() ? ` '${e.value.replace(/'/g, "''")}'` : "";
|
|
468
|
+
lines.push(` ${src} -> ${tgt}${label}`);
|
|
469
|
+
}
|
|
470
|
+
lines.push("}");
|
|
471
|
+
lines.push("");
|
|
472
|
+
lines.push("views {");
|
|
473
|
+
lines.push(" view index {");
|
|
474
|
+
lines.push(" include *");
|
|
475
|
+
lines.push(" }");
|
|
476
|
+
lines.push("}");
|
|
477
|
+
lines.push("");
|
|
478
|
+
return lines.join("\n");
|
|
479
|
+
}
|
|
110
480
|
function toUnion(elements) {
|
|
111
481
|
if (elements.length === 0) return "never";
|
|
112
482
|
return elements.sort(compareNatural).map((v) => ` | ${JSON.stringify(v)}`).join("\n").trimStart();
|
|
@@ -159,9 +529,6 @@ export type $Tags = readonly $Aux['Tag'][]
|
|
|
159
529
|
export type $MetadataKey = $Aux['MetadataKey']
|
|
160
530
|
`.trimStart();
|
|
161
531
|
}
|
|
162
|
-
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region src/model/generate-likec4-model.ts
|
|
165
532
|
function generateLikeC4Model(model, options = {}) {
|
|
166
533
|
const aux = generateAux(model, options);
|
|
167
534
|
const { useCorePackage = false } = options;
|
|
@@ -185,9 +552,6 @@ export const likec4model: LikeC4Model<$Aux> = new LikeC4Model(${JSON5.stringify(
|
|
|
185
552
|
/* prettier-ignore-end */
|
|
186
553
|
`.trimStart();
|
|
187
554
|
}
|
|
188
|
-
|
|
189
|
-
//#endregion
|
|
190
|
-
//#region src/puml/generate-puml.ts
|
|
191
555
|
const capitalizeFirstLetter = (value) => value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
|
192
556
|
const fqnName = (nodeId) => {
|
|
193
557
|
return nodeId.split(/[.-]/).map(capitalizeFirstLetter).join("");
|
|
@@ -218,6 +582,7 @@ const pumlShape = ({ shape }) => {
|
|
|
218
582
|
case "person": return shape;
|
|
219
583
|
case "storage":
|
|
220
584
|
case "cylinder": return "database";
|
|
585
|
+
case "component": return "component";
|
|
221
586
|
case "document":
|
|
222
587
|
case "mobile":
|
|
223
588
|
case "bucket":
|
|
@@ -282,18 +647,9 @@ function generatePuml(viewmodel) {
|
|
|
282
647
|
};
|
|
283
648
|
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
649
|
}
|
|
285
|
-
|
|
286
|
-
//#endregion
|
|
287
|
-
//#region src/views-data-ts/generateViewId.ts
|
|
288
650
|
function generateViewId(views) {
|
|
289
651
|
return joinToNode(views, (view) => expandToNode`${JSON5.stringify(view.id)}`, { separator: " | " });
|
|
290
652
|
}
|
|
291
|
-
|
|
292
|
-
//#endregion
|
|
293
|
-
//#region src/views-data-ts/generate-views-data.ts
|
|
294
|
-
/**
|
|
295
|
-
* Generate *.js file with views data
|
|
296
|
-
*/
|
|
297
653
|
function generateViewsDataJs(diagrams) {
|
|
298
654
|
const views = Array.from(diagrams);
|
|
299
655
|
const out = new CompositeGeneratorNode();
|
|
@@ -334,9 +690,6 @@ function generateViewsDataJs(diagrams) {
|
|
|
334
690
|
`.append(NL);
|
|
335
691
|
return toString(out);
|
|
336
692
|
}
|
|
337
|
-
/**
|
|
338
|
-
* Generate *.ts file with views data
|
|
339
|
-
*/
|
|
340
693
|
function generateViewsDataTs(diagrams) {
|
|
341
694
|
const views = Array.from(diagrams);
|
|
342
695
|
const out = new CompositeGeneratorNode();
|
|
@@ -382,9 +735,6 @@ function generateViewsDataTs(diagrams) {
|
|
|
382
735
|
`.append(NL);
|
|
383
736
|
return toString(out);
|
|
384
737
|
}
|
|
385
|
-
/**
|
|
386
|
-
* Generate *.d.ts
|
|
387
|
-
*/
|
|
388
738
|
function generateViewsDataDTs(diagrams) {
|
|
389
739
|
const views = Array.from(diagrams);
|
|
390
740
|
const out = new CompositeGeneratorNode();
|
|
@@ -413,12 +763,6 @@ function generateViewsDataDTs(diagrams) {
|
|
|
413
763
|
`.append(NL);
|
|
414
764
|
return toString(out);
|
|
415
765
|
}
|
|
416
|
-
|
|
417
|
-
//#endregion
|
|
418
|
-
//#region src/react-next/generate-react-next.ts
|
|
419
|
-
/**
|
|
420
|
-
* @deprecated in favor packages/likec4/src/cli/codegen/react/index.ts
|
|
421
|
-
*/
|
|
422
766
|
function generateReactNext(views) {
|
|
423
767
|
return {
|
|
424
768
|
viewsData: {
|
|
@@ -519,9 +863,6 @@ function generateIndex() {
|
|
|
519
863
|
dts: toString(dts)
|
|
520
864
|
};
|
|
521
865
|
}
|
|
522
|
-
|
|
523
|
-
//#endregion
|
|
524
|
-
//#region src/react/generate-react-types.ts
|
|
525
866
|
function generateReactTypes(model, options = {}) {
|
|
526
867
|
const { useCorePackage = false } = options;
|
|
527
868
|
invariant(!model.isParsed(), "can not generate react types for parsed model");
|
|
@@ -584,6 +925,4 @@ export {
|
|
|
584
925
|
/* prettier-ignore-end */
|
|
585
926
|
`.trimStart();
|
|
586
927
|
}
|
|
587
|
-
|
|
588
|
-
//#endregion
|
|
589
|
-
export { generateD2, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs };
|
|
928
|
+
export { generateD2, generateDrawio, generateLikeC4Model, generateMermaid, generatePuml, generateReactNext, generateReactTypes, generateViewsDataDTs, generateViewsDataJs, generateViewsDataTs, parseDrawioToLikeC4 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@likec4/generators",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bugs": "https://github.com/likec4/likec4/issues",
|
|
6
6
|
"homepage": "https://likec4.dev",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"remeda": "^2.
|
|
40
|
+
"remeda": "^2.33.5",
|
|
41
41
|
"json5": "^2.2.3",
|
|
42
42
|
"langium": "3.5.0",
|
|
43
|
-
"@likec4/core": "1.
|
|
44
|
-
"@likec4/log": "1.
|
|
43
|
+
"@likec4/core": "1.49.0",
|
|
44
|
+
"@likec4/log": "1.49.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@types/node": "~22.19.
|
|
47
|
+
"@types/node": "~22.19.10",
|
|
48
48
|
"typescript": "5.9.3",
|
|
49
|
-
"obuild": "^0.4.
|
|
49
|
+
"obuild": "^0.4.27",
|
|
50
50
|
"vitest": "4.0.18",
|
|
51
|
-
"@likec4/tsconfig": "1.
|
|
51
|
+
"@likec4/tsconfig": "1.49.0",
|
|
52
52
|
"@likec4/devops": "1.42.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
package/src/d2/generate-d2.ts
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { LikeC4ViewModel } from '@likec4/core/model'
|
|
2
|
+
import type { aux, DiagramNode, NodeId, ProcessedView } from '@likec4/core/types'
|
|
3
|
+
import { flattenMarkdownOrString } from '@likec4/core/types'
|
|
4
|
+
import { isEmptyish, isNullish as isNil } from 'remeda'
|
|
5
|
+
|
|
6
|
+
type View = ProcessedView<aux.Unknown>
|
|
7
|
+
type Node = View['nodes'][number]
|
|
8
|
+
type Edge = View['edges'][number]
|
|
9
|
+
|
|
10
|
+
/** Default hex colors when view model has no $styles (e.g. in tests). Aligns with common theme colors. */
|
|
11
|
+
const DEFAULT_ELEMENT_COLORS: Record<string, { fill: string; stroke: string }> = {
|
|
12
|
+
primary: { fill: '#3b82f6', stroke: '#2563eb' },
|
|
13
|
+
gray: { fill: '#6b7280', stroke: '#4b5563' },
|
|
14
|
+
green: { fill: '#22c55e', stroke: '#16a34a' },
|
|
15
|
+
red: { fill: '#ef4444', stroke: '#dc2626' },
|
|
16
|
+
blue: { fill: '#3b82f6', stroke: '#2563eb' },
|
|
17
|
+
indigo: { fill: '#6366f1', stroke: '#4f46e5' },
|
|
18
|
+
muted: { fill: '#9ca3af', stroke: '#6b7280' },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_EDGE_COLOR = '#6b7280'
|
|
22
|
+
|
|
23
|
+
function escapeXml(unsafe: string): string {
|
|
24
|
+
return unsafe
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map LikeC4 shape to DrawIO style. DrawIO uses shape=rectangle, ellipse, cylinder, etc.
|
|
34
|
+
*/
|
|
35
|
+
function drawioShape(shape: Node['shape']): string {
|
|
36
|
+
switch (shape) {
|
|
37
|
+
case 'person':
|
|
38
|
+
return 'shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;'
|
|
39
|
+
case 'rectangle':
|
|
40
|
+
return 'shape=rectangle;'
|
|
41
|
+
case 'browser':
|
|
42
|
+
return 'shape=rectangle;rounded=1;'
|
|
43
|
+
case 'mobile':
|
|
44
|
+
return 'shape=rectangle;rounded=1;'
|
|
45
|
+
case 'cylinder':
|
|
46
|
+
return 'shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;'
|
|
47
|
+
case 'queue':
|
|
48
|
+
return 'shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;'
|
|
49
|
+
case 'storage':
|
|
50
|
+
return 'shape=cylinder3;whiteSpace=wrap;boundedLbl=1;backgroundOutline=1;size=15;'
|
|
51
|
+
case 'bucket':
|
|
52
|
+
return 'shape=rectangle;rounded=1;'
|
|
53
|
+
case 'document':
|
|
54
|
+
return 'shape=document;whiteSpace=wrap;html=1;boundedLbl=1;'
|
|
55
|
+
case 'component':
|
|
56
|
+
return 'shape=component;'
|
|
57
|
+
default:
|
|
58
|
+
return 'shape=rectangle;'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getElementColors(
|
|
63
|
+
viewmodel: LikeC4ViewModel<aux.Unknown>,
|
|
64
|
+
color: string,
|
|
65
|
+
): { fill: string; stroke: string } | undefined {
|
|
66
|
+
const styles = '$styles' in viewmodel && viewmodel.$styles ? viewmodel.$styles : null
|
|
67
|
+
if (styles) {
|
|
68
|
+
try {
|
|
69
|
+
const values = styles.colors(color)
|
|
70
|
+
return {
|
|
71
|
+
fill: values.elements.fill as string,
|
|
72
|
+
stroke: values.elements.stroke as string,
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// custom color or missing
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return DEFAULT_ELEMENT_COLORS[color] ?? DEFAULT_ELEMENT_COLORS['primary']
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getEdgeStrokeColor(viewmodel: LikeC4ViewModel<aux.Unknown>, color: string): string {
|
|
82
|
+
const styles = '$styles' in viewmodel && viewmodel.$styles ? viewmodel.$styles : null
|
|
83
|
+
if (styles) {
|
|
84
|
+
try {
|
|
85
|
+
const values = styles.colors(color)
|
|
86
|
+
return values.relationships.line as string
|
|
87
|
+
} catch {
|
|
88
|
+
// custom color or missing
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return DEFAULT_EDGE_COLOR
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate DrawIO (mxGraph) XML from a layouted LikeC4 view.
|
|
96
|
+
* Preserves positions, hierarchy, colors, descriptions and technology so the diagram
|
|
97
|
+
* can be opened and edited in draw.io with full compatibility.
|
|
98
|
+
*
|
|
99
|
+
* @param viewmodel - Layouted LikeC4 view model (from model.view(id))
|
|
100
|
+
* @returns DrawIO .drawio XML string
|
|
101
|
+
*/
|
|
102
|
+
export function generateDrawio(viewmodel: LikeC4ViewModel<aux.Unknown>): string {
|
|
103
|
+
const view = viewmodel.$view
|
|
104
|
+
const { nodes, edges } = view
|
|
105
|
+
|
|
106
|
+
const rootId = '0'
|
|
107
|
+
const defaultParentId = '1'
|
|
108
|
+
|
|
109
|
+
const nodeIds = new Map<NodeId, string>()
|
|
110
|
+
let cellId = 2
|
|
111
|
+
|
|
112
|
+
const getCellId = (nodeId: NodeId): string => {
|
|
113
|
+
let id = nodeIds.get(nodeId)
|
|
114
|
+
if (!id) {
|
|
115
|
+
id = String(cellId++)
|
|
116
|
+
nodeIds.set(nodeId, id)
|
|
117
|
+
}
|
|
118
|
+
return id
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const vertexCells: string[] = []
|
|
122
|
+
const edgeCells: string[] = []
|
|
123
|
+
|
|
124
|
+
const sortedNodes = [...nodes].sort((a, b) => {
|
|
125
|
+
if (isNil(a.parent) && isNil(b.parent)) return 0
|
|
126
|
+
if (isNil(a.parent)) return -1
|
|
127
|
+
if (isNil(b.parent)) return 1
|
|
128
|
+
if (a.parent === b.parent) return 0
|
|
129
|
+
if (a.id.startsWith(b.id + '.')) return 1
|
|
130
|
+
if (b.id.startsWith(a.id + '.')) return -1
|
|
131
|
+
return 0
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/** Support both BBox (x,y,width,height) and legacy position/size used in some mocks */
|
|
135
|
+
const getBBox = (n: View['nodes'][number]) => {
|
|
136
|
+
const d = n as DiagramNode & { position?: [number, number]; size?: { width: number; height: number } }
|
|
137
|
+
const x = typeof d.x === 'number' ? d.x : (Array.isArray(d.position) ? d.position[0] : 0)
|
|
138
|
+
const y = typeof d.y === 'number' ? d.y : (Array.isArray(d.position) ? d.position[1] : 0)
|
|
139
|
+
const width = typeof d.width === 'number' ? d.width : (d.size?.width ?? 120)
|
|
140
|
+
const height = typeof d.height === 'number' ? d.height : (d.size?.height ?? 60)
|
|
141
|
+
return { x, y, width, height }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const node of sortedNodes) {
|
|
145
|
+
const id = getCellId(node.id)
|
|
146
|
+
const parentId = node.parent ? getCellId(node.parent) : defaultParentId
|
|
147
|
+
const label = escapeXml(node.title)
|
|
148
|
+
const shapeStyle = drawioShape(node.shape)
|
|
149
|
+
const { x, y, width, height } = getBBox(node)
|
|
150
|
+
|
|
151
|
+
const elemColors = getElementColors(viewmodel, node.color)
|
|
152
|
+
const colorStyle = elemColors != null
|
|
153
|
+
? `fillColor=${elemColors.fill};strokeColor=${elemColors.stroke};fontColor=${elemColors.stroke};`
|
|
154
|
+
: ''
|
|
155
|
+
|
|
156
|
+
const description = node.description && flattenMarkdownOrString(node.description)
|
|
157
|
+
const desc = isEmptyish(description) ? '' : escapeXml(description)
|
|
158
|
+
const technology = node.technology && flattenMarkdownOrString(node.technology)
|
|
159
|
+
const tech = isEmptyish(technology) ? '' : escapeXml(technology)
|
|
160
|
+
const userData = desc !== '' || tech !== ''
|
|
161
|
+
? `<mxUserObject><data key="likec4Description">${desc}</data><data key="likec4Technology">${tech}</data></mxUserObject>\n `
|
|
162
|
+
: ''
|
|
163
|
+
|
|
164
|
+
vertexCells.push(
|
|
165
|
+
`<mxCell id="${id}" value="${label}" style="${shapeStyle}${colorStyle}verticalAlign=middle;align=center;overflow=fill;spacingLeft=2;spacingRight=2;spacingTop=2;spacingBottom=2;" vertex="1" parent="${parentId}">
|
|
166
|
+
${userData}<mxGeometry x="${Math.round(x)}" y="${Math.round(y)}" width="${Math.round(width)}" height="${
|
|
167
|
+
Math.round(height)
|
|
168
|
+
}" as="geometry" />
|
|
169
|
+
</mxCell>`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const edge of edges as Edge[]) {
|
|
174
|
+
const id = String(cellId++)
|
|
175
|
+
const sourceId = getCellId(edge.source)
|
|
176
|
+
const targetId = getCellId(edge.target)
|
|
177
|
+
const label = edge.label ? escapeXml(edge.label) : ''
|
|
178
|
+
const strokeColor = getEdgeStrokeColor(viewmodel, edge.color)
|
|
179
|
+
const dashStyle = edge.line === 'dashed' ? 'dashed=1;' : edge.line === 'dotted' ? 'dashed=1;dashPattern=1 1;' : ''
|
|
180
|
+
edgeCells.push(
|
|
181
|
+
`<mxCell id="${id}" value="${label}" style="endArrow=block;html=1;rounded=0;exitX=1;exitY=0.5;entryX=0;entryY=0.5;strokeColor=${strokeColor};${dashStyle}" edge="1" parent="${defaultParentId}" source="${sourceId}" target="${targetId}">
|
|
182
|
+
<mxGeometry relative="1" as="geometry" />
|
|
183
|
+
</mxCell>`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let bounds: { x: number; y: number; width: number; height: number } = {
|
|
188
|
+
x: 0,
|
|
189
|
+
y: 0,
|
|
190
|
+
width: 800,
|
|
191
|
+
height: 600,
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const b = viewmodel.bounds
|
|
195
|
+
if (b != null && typeof b.x === 'number') bounds = b
|
|
196
|
+
} catch {
|
|
197
|
+
// View not layouted (e.g. in tests); use default canvas size
|
|
198
|
+
}
|
|
199
|
+
const allCells = [
|
|
200
|
+
`<mxCell id="${defaultParentId}" vertex="1" parent="${rootId}">
|
|
201
|
+
<mxGeometry x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" as="geometry" />
|
|
202
|
+
</mxCell>`,
|
|
203
|
+
...vertexCells,
|
|
204
|
+
...edgeCells,
|
|
205
|
+
].join('\n')
|
|
206
|
+
|
|
207
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
208
|
+
<mxfile host="LikeC4" modified="${new Date().toISOString()}" agent="LikeC4" version="1.0" etag="" type="device">
|
|
209
|
+
<diagram name="${escapeXml(view.id)}" id="likec4-${escapeXml(view.id)}">
|
|
210
|
+
<mxGraphModel dx="800" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale=1 pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
|
211
|
+
<root>
|
|
212
|
+
<mxCell id="${rootId}" />
|
|
213
|
+
${allCells}
|
|
214
|
+
</root>
|
|
215
|
+
</mxGraphModel>
|
|
216
|
+
</diagram>
|
|
217
|
+
</mxfile>
|
|
218
|
+
`
|
|
219
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse DrawIO (mxGraph) XML and generate LikeC4 source code.
|
|
3
|
+
* Extracts vertices as elements and edges as relations; preserves colors, descriptions,
|
|
4
|
+
* technology and other compatible attributes for full bidirectional compatibility.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface DrawioCell {
|
|
8
|
+
id: string
|
|
9
|
+
value?: string
|
|
10
|
+
parent?: string
|
|
11
|
+
source?: string
|
|
12
|
+
target?: string
|
|
13
|
+
vertex?: boolean
|
|
14
|
+
edge?: boolean
|
|
15
|
+
style?: string
|
|
16
|
+
x?: number
|
|
17
|
+
y?: number
|
|
18
|
+
width?: number
|
|
19
|
+
height?: number
|
|
20
|
+
/** From style fillColor= or mxUserObject */
|
|
21
|
+
fillColor?: string
|
|
22
|
+
strokeColor?: string
|
|
23
|
+
/** From mxUserObject data likec4Description / likec4Technology (exported by LikeC4) */
|
|
24
|
+
description?: string
|
|
25
|
+
technology?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getAttr(attrs: string, name: string): string | undefined {
|
|
29
|
+
const re = new RegExp(`${name}="([^"]*)"`, 'i')
|
|
30
|
+
const m = attrs.match(re)
|
|
31
|
+
return m ? m[1] : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseNum(s: string | undefined): number | undefined {
|
|
35
|
+
if (s === undefined || s === '') return undefined
|
|
36
|
+
const n = Number.parseFloat(s)
|
|
37
|
+
return Number.isNaN(n) ? undefined : n
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse DrawIO style string (semicolon-separated key=value) into a map. */
|
|
41
|
+
function parseStyle(style: string | undefined): Map<string, string> {
|
|
42
|
+
const map = new Map<string, string>()
|
|
43
|
+
if (!style) return map
|
|
44
|
+
for (const part of style.split(';')) {
|
|
45
|
+
const eq = part.indexOf('=')
|
|
46
|
+
if (eq > 0) {
|
|
47
|
+
const k = part.slice(0, eq).trim()
|
|
48
|
+
const v = part.slice(eq + 1).trim()
|
|
49
|
+
if (k && v) map.set(k.toLowerCase(), v)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return map
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Extract LikeC4 custom data from mxUserObject/data inside cell XML. */
|
|
56
|
+
function parseUserData(fullTag: string): { description?: string; technology?: string } {
|
|
57
|
+
const out: { description?: string; technology?: string } = {}
|
|
58
|
+
const descMatch = fullTag.match(/<data\s+key="likec4Description"[^>]*>([\s\S]*?)<\/data>/i)
|
|
59
|
+
if (descMatch?.[1]) out.description = decodeXmlEntities(descMatch[1].trim())
|
|
60
|
+
const techMatch = fullTag.match(/<data\s+key="likec4Technology"[^>]*>([\s\S]*?)<\/data>/i)
|
|
61
|
+
if (techMatch?.[1]) out.technology = decodeXmlEntities(techMatch[1].trim())
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Simple XML parser for DrawIO mxCell elements. Extracts cells with id, value, parent,
|
|
67
|
+
* source, target, vertex, edge, geometry, style colors and LikeC4 user data.
|
|
68
|
+
*/
|
|
69
|
+
function parseDrawioXml(xml: string): DrawioCell[] {
|
|
70
|
+
const cells: DrawioCell[] = []
|
|
71
|
+
const mxCellRe = /<mxCell\s+([^>]+?)(?:\s*\/>|>([\s\S]*?)<\/mxCell>)/gi
|
|
72
|
+
const geomAttr = (tag: string, name: string) => getAttr(tag, name)
|
|
73
|
+
let m
|
|
74
|
+
while ((m = mxCellRe.exec(xml)) !== null) {
|
|
75
|
+
const attrs = m[1] ?? ''
|
|
76
|
+
const inner = m[2] ?? ''
|
|
77
|
+
const id = getAttr(attrs, 'id')
|
|
78
|
+
if (!id) continue
|
|
79
|
+
const valueRaw = getAttr(attrs, 'value')
|
|
80
|
+
const parent = getAttr(attrs, 'parent')
|
|
81
|
+
const source = getAttr(attrs, 'source')
|
|
82
|
+
const target = getAttr(attrs, 'target')
|
|
83
|
+
const vertex = getAttr(attrs, 'vertex') === '1'
|
|
84
|
+
const edge = getAttr(attrs, 'edge') === '1'
|
|
85
|
+
const style = getAttr(attrs, 'style')
|
|
86
|
+
const fullTag = m[0]
|
|
87
|
+
const geomMatch = fullTag.match(/<mxGeometry[^>]*>/i)
|
|
88
|
+
const geomStr = geomMatch ? geomMatch[0] : ''
|
|
89
|
+
const styleMap = parseStyle(style ?? undefined)
|
|
90
|
+
const userData = parseUserData(inner)
|
|
91
|
+
const x = parseNum(geomAttr(geomStr, 'x'))
|
|
92
|
+
const y = parseNum(geomAttr(geomStr, 'y'))
|
|
93
|
+
const width = parseNum(geomAttr(geomStr, 'width'))
|
|
94
|
+
const height = parseNum(geomAttr(geomStr, 'height'))
|
|
95
|
+
const fillColor = styleMap.get('fillcolor') ?? styleMap.get('fillColor')
|
|
96
|
+
const strokeColor = styleMap.get('strokecolor') ?? styleMap.get('strokeColor')
|
|
97
|
+
const cell: DrawioCell = {
|
|
98
|
+
id,
|
|
99
|
+
...(valueRaw != null && valueRaw !== '' ? { value: decodeXmlEntities(valueRaw) } : {}),
|
|
100
|
+
...(parent != null && parent !== '' ? { parent } : {}),
|
|
101
|
+
...(source != null && source !== '' ? { source } : {}),
|
|
102
|
+
...(target != null && target !== '' ? { target } : {}),
|
|
103
|
+
vertex,
|
|
104
|
+
edge,
|
|
105
|
+
...(style != null && style !== '' ? { style } : {}),
|
|
106
|
+
...(x !== undefined ? { x } : {}),
|
|
107
|
+
...(y !== undefined ? { y } : {}),
|
|
108
|
+
...(width !== undefined ? { width } : {}),
|
|
109
|
+
...(height !== undefined ? { height } : {}),
|
|
110
|
+
...(fillColor !== undefined ? { fillColor } : {}),
|
|
111
|
+
...(strokeColor !== undefined ? { strokeColor } : {}),
|
|
112
|
+
...(userData.description != null ? { description: userData.description } : {}),
|
|
113
|
+
...(userData.technology != null ? { technology: userData.technology } : {}),
|
|
114
|
+
}
|
|
115
|
+
cells.push(cell)
|
|
116
|
+
}
|
|
117
|
+
return cells
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decodeXmlEntities(s: string): string {
|
|
121
|
+
return s
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, '\'')
|
|
126
|
+
.replace(/&/g, '&')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Infer LikeC4 element kind from DrawIO shape style.
|
|
131
|
+
*/
|
|
132
|
+
function inferKind(style: string | undefined): 'actor' | 'system' | 'container' | 'component' {
|
|
133
|
+
if (!style) return 'container'
|
|
134
|
+
const s = style.toLowerCase()
|
|
135
|
+
if (s.includes('umlactor') || s.includes('shape=person')) return 'actor'
|
|
136
|
+
if (s.includes('swimlane') || s.includes('shape=rectangle') && s.includes('rounded')) return 'system'
|
|
137
|
+
return 'container'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sanitize a string for use as LikeC4 identifier (element name).
|
|
142
|
+
*/
|
|
143
|
+
function toId(name: string): string {
|
|
144
|
+
return name
|
|
145
|
+
.trim()
|
|
146
|
+
.replace(/\s+/g, '_')
|
|
147
|
+
.replace(/[^a-zA-Z0-9_.-]/g, '')
|
|
148
|
+
.replace(/^[0-9]/, '_$&') || 'element'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convert DrawIO XML to LikeC4 source (.c4) string.
|
|
153
|
+
* - Vertices become model elements (actor/container); hierarchy from parent refs.
|
|
154
|
+
* - Edges become relations (->).
|
|
155
|
+
* - Root diagram cells (parent "1") are top-level; others are nested by parent.
|
|
156
|
+
*/
|
|
157
|
+
export function parseDrawioToLikeC4(xml: string): string {
|
|
158
|
+
const cells = parseDrawioXml(xml)
|
|
159
|
+
const byId = new Map<string, DrawioCell>()
|
|
160
|
+
for (const c of cells) {
|
|
161
|
+
byId.set(c.id, c)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const vertices = cells.filter(c => c.vertex && c.id !== '1')
|
|
165
|
+
const edges = cells.filter(c => c.edge && c.source && c.target)
|
|
166
|
+
|
|
167
|
+
// Build hierarchy: root is parent "1". Assign FQN by traversing parent chain.
|
|
168
|
+
const rootId = '1'
|
|
169
|
+
const idToFqn = new Map<string, string>()
|
|
170
|
+
const idToCell = new Map<string, DrawioCell>()
|
|
171
|
+
for (const v of vertices) {
|
|
172
|
+
idToCell.set(v.id, v)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Assign FQNs: use value as base name, ensure uniqueness. Flatten for simplicity if no clear hierarchy.
|
|
176
|
+
const usedNames = new Set<string>()
|
|
177
|
+
function uniqueName(base: string): string {
|
|
178
|
+
let name = toId(base || 'element')
|
|
179
|
+
let n = name
|
|
180
|
+
let i = 0
|
|
181
|
+
while (usedNames.has(n)) {
|
|
182
|
+
n = `${name}_${++i}`
|
|
183
|
+
}
|
|
184
|
+
usedNames.add(n)
|
|
185
|
+
return n
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const v of vertices) {
|
|
189
|
+
if (v.parent === rootId || !v.parent) {
|
|
190
|
+
const name = uniqueName(v.value ?? v.id)
|
|
191
|
+
idToFqn.set(v.id, name)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// If we have parent refs that are not root, build hierarchy (e.g. parent is another vertex)
|
|
196
|
+
let changed = true
|
|
197
|
+
while (changed) {
|
|
198
|
+
changed = false
|
|
199
|
+
for (const v of vertices) {
|
|
200
|
+
if (idToFqn.has(v.id)) continue
|
|
201
|
+
const parent = v.parent ? idToFqn.get(v.parent) : null
|
|
202
|
+
if (parent != null) {
|
|
203
|
+
const local = uniqueName(v.value ?? v.id)
|
|
204
|
+
idToFqn.set(v.id, `${parent}.${local}`)
|
|
205
|
+
changed = true
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Any remaining vertices (orphans) get top-level names
|
|
211
|
+
for (const v of vertices) {
|
|
212
|
+
if (!idToFqn.has(v.id)) {
|
|
213
|
+
idToFqn.set(v.id, uniqueName(v.value ?? v.id))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Collect unique hex colors from vertices for specification customColors
|
|
218
|
+
const hexToCustomName = new Map<string, string>()
|
|
219
|
+
let customColorIndex = 0
|
|
220
|
+
for (const v of vertices) {
|
|
221
|
+
const fill = v.fillColor?.trim()
|
|
222
|
+
if (fill && /^#[0-9A-Fa-f]{3,8}$/.test(fill)) {
|
|
223
|
+
if (!hexToCustomName.has(fill)) {
|
|
224
|
+
hexToCustomName.set(fill, `drawio_color_${++customColorIndex}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const lines: string[] = []
|
|
230
|
+
|
|
231
|
+
if (hexToCustomName.size > 0) {
|
|
232
|
+
lines.push('specification {')
|
|
233
|
+
for (const [hex, name] of hexToCustomName) {
|
|
234
|
+
lines.push(` color ${name} ${hex}`)
|
|
235
|
+
}
|
|
236
|
+
lines.push('}')
|
|
237
|
+
lines.push('')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
lines.push('model {')
|
|
241
|
+
lines.push('')
|
|
242
|
+
|
|
243
|
+
const children = new Map<string, Array<{ cellId: string; fqn: string }>>()
|
|
244
|
+
const roots: Array<{ cellId: string; fqn: string }> = []
|
|
245
|
+
for (const [cellId, fqn] of idToFqn) {
|
|
246
|
+
const cell = idToCell.get(cellId)
|
|
247
|
+
if (!cell) continue
|
|
248
|
+
if (cell.parent === rootId || !cell.parent) {
|
|
249
|
+
roots.push({ cellId, fqn })
|
|
250
|
+
} else {
|
|
251
|
+
const parentFqn = idToFqn.get(cell.parent)
|
|
252
|
+
if (parentFqn != null) {
|
|
253
|
+
const list = children.get(parentFqn) ?? []
|
|
254
|
+
list.push({ cellId, fqn })
|
|
255
|
+
children.set(parentFqn, list)
|
|
256
|
+
} else {
|
|
257
|
+
roots.push({ cellId, fqn })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function emitElement(cellId: string, fqn: string, indent: number): void {
|
|
263
|
+
const cell = idToCell.get(cellId)
|
|
264
|
+
if (!cell) return
|
|
265
|
+
const kind = inferKind(cell.style)
|
|
266
|
+
const title = (cell.value && cell.value.trim()) || fqn.split('.').pop() || 'Element'
|
|
267
|
+
const name = fqn.split('.').pop()!
|
|
268
|
+
const pad = ' '.repeat(indent)
|
|
269
|
+
const desc = cell.description?.trim()
|
|
270
|
+
const tech = cell.technology?.trim()
|
|
271
|
+
const colorName = cell.fillColor && /^#[0-9A-Fa-f]{3,8}$/.test(cell.fillColor.trim())
|
|
272
|
+
? hexToCustomName.get(cell.fillColor.trim())
|
|
273
|
+
: undefined
|
|
274
|
+
|
|
275
|
+
if (kind === 'actor') {
|
|
276
|
+
lines.push(`${pad}${name} = actor '${title.replace(/'/g, '\'\'')}'`)
|
|
277
|
+
} else if (kind === 'system') {
|
|
278
|
+
lines.push(`${pad}${name} = system '${title.replace(/'/g, '\'\'')}'`)
|
|
279
|
+
} else {
|
|
280
|
+
lines.push(`${pad}${name} = container '${title.replace(/'/g, '\'\'')}'`)
|
|
281
|
+
}
|
|
282
|
+
const childList = children.get(fqn)
|
|
283
|
+
const hasBody = (childList && childList.length > 0) || desc || tech || colorName
|
|
284
|
+
if (hasBody) {
|
|
285
|
+
lines.push(`${pad}{`)
|
|
286
|
+
if (colorName) lines.push(`${pad} style { color ${colorName} }`)
|
|
287
|
+
if (desc) lines.push(`${pad} description '${desc.replace(/'/g, '\'\'')}'`)
|
|
288
|
+
if (tech) lines.push(`${pad} technology '${tech.replace(/'/g, '\'\'')}'`)
|
|
289
|
+
if (childList && childList.length > 0) {
|
|
290
|
+
for (const ch of childList) {
|
|
291
|
+
emitElement(ch.cellId, ch.fqn, indent + 1)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
lines.push(`${pad}}`)
|
|
295
|
+
} else {
|
|
296
|
+
lines.push(`${pad}{`)
|
|
297
|
+
lines.push(`${pad}}`)
|
|
298
|
+
}
|
|
299
|
+
lines.push('')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const { cellId, fqn } of roots) {
|
|
303
|
+
emitElement(cellId, fqn, 1)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const e of edges) {
|
|
307
|
+
const src = idToFqn.get(e.source!)
|
|
308
|
+
const tgt = idToFqn.get(e.target!)
|
|
309
|
+
if (!src || !tgt) continue
|
|
310
|
+
const label = (e.value && e.value.trim()) ? ` '${e.value.replace(/'/g, '\'\'')}'` : ''
|
|
311
|
+
lines.push(` ${src} -> ${tgt}${label}`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
lines.push('}')
|
|
315
|
+
lines.push('')
|
|
316
|
+
lines.push('views {')
|
|
317
|
+
lines.push(' view index {')
|
|
318
|
+
lines.push(' include *')
|
|
319
|
+
lines.push(' }')
|
|
320
|
+
lines.push('}')
|
|
321
|
+
lines.push('')
|
|
322
|
+
|
|
323
|
+
return lines.join('\n')
|
|
324
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export { generateDrawio } from './drawio/generate-drawio'
|
|
1
2
|
export { generateD2 } from './d2/generate-d2'
|
|
2
3
|
export { generateMermaid } from './mmd/generate-mmd'
|
|
4
|
+
export { parseDrawioToLikeC4 } from './drawio/parse-drawio'
|
|
3
5
|
export { generateLikeC4Model } from './model/generate-likec4-model'
|
|
4
6
|
export { generatePuml } from './puml/generate-puml'
|
|
5
7
|
export { generateReactNext } from './react-next/generate-react-next'
|
package/src/mmd/generate-mmd.ts
CHANGED