@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 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 { CompositeGeneratorNode, NL, expandToNode, joinToNode, toString } from "langium/generate";
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
- import { RichText } from "@likec4/core/types";
7
-
8
- //#region src/d2/generate-d2.ts
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
- //#endregion
109
- //#region src/model/generate-aux.ts
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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/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.48.0",
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.32.0",
40
+ "remeda": "^2.33.5",
41
41
  "json5": "^2.2.3",
42
42
  "langium": "3.5.0",
43
- "@likec4/core": "1.48.0",
44
- "@likec4/log": "1.48.0"
43
+ "@likec4/core": "1.49.0",
44
+ "@likec4/log": "1.49.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@types/node": "~22.19.7",
47
+ "@types/node": "~22.19.10",
48
48
  "typescript": "5.9.3",
49
- "obuild": "^0.4.20",
49
+ "obuild": "^0.4.27",
50
50
  "vitest": "4.0.18",
51
- "@likec4/tsconfig": "1.48.0",
51
+ "@likec4/tsconfig": "1.49.0",
52
52
  "@likec4/devops": "1.42.0"
53
53
  },
54
54
  "scripts": {
@@ -45,6 +45,7 @@ const d2shape = ({ shape }: Node) => {
45
45
  case 'storage': {
46
46
  return 'stored_data' as const
47
47
  }
48
+ case 'component':
48
49
  case 'bucket':
49
50
  case 'mobile':
50
51
  case 'browser': {
@@ -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, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&apos;')
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,2 @@
1
+ export { generateDrawio } from './generate-drawio'
2
+ export { parseDrawioToLikeC4, type DrawioCell } from './parse-drawio'
@@ -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(/&lt;/g, '<')
123
+ .replace(/&gt;/g, '>')
124
+ .replace(/&quot;/g, '"')
125
+ .replace(/&apos;/g, '\'')
126
+ .replace(/&amp;/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'
@@ -45,6 +45,9 @@ const mmdshape = ({ shape, title }: Node): string => {
45
45
  case 'document': {
46
46
  return `@{ shape: doc, ${label} }`
47
47
  }
48
+ case 'component': {
49
+ return `@{ shape: rectangle, ${label} }`
50
+ }
48
51
  default:
49
52
  nonexhaustive(shape)
50
53
  }
@@ -68,6 +68,9 @@ const pumlShape = ({ shape }: ComputedNode) => {
68
68
  case 'cylinder': {
69
69
  return 'database' as const
70
70
  }
71
+ case 'component': {
72
+ return 'component' as const
73
+ }
71
74
  case 'document':
72
75
  case 'mobile':
73
76
  case 'bucket':