living-ai-documentation 1.0.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.
Files changed (203) hide show
  1. package/LICENSE +661 -0
  2. package/README.fr.md +344 -0
  3. package/README.md +344 -0
  4. package/dist/bin/cli.d.ts +3 -0
  5. package/dist/bin/cli.d.ts.map +1 -0
  6. package/dist/bin/cli.js +262 -0
  7. package/dist/bin/cli.js.map +1 -0
  8. package/dist/src/frontend/accuracy-gauge.js +70 -0
  9. package/dist/src/frontend/admin.html +1532 -0
  10. package/dist/src/frontend/annotations.js +585 -0
  11. package/dist/src/frontend/boot.js +101 -0
  12. package/dist/src/frontend/config.js +29 -0
  13. package/dist/src/frontend/confirm-modal.js +82 -0
  14. package/dist/src/frontend/context.html +1252 -0
  15. package/dist/src/frontend/dark-mode.js +20 -0
  16. package/dist/src/frontend/diagram/alignment.js +161 -0
  17. package/dist/src/frontend/diagram/clipboard.js +187 -0
  18. package/dist/src/frontend/diagram/constants.js +109 -0
  19. package/dist/src/frontend/diagram/custom-shapes.js +104 -0
  20. package/dist/src/frontend/diagram/debug.js +43 -0
  21. package/dist/src/frontend/diagram/drawio-export.js +649 -0
  22. package/dist/src/frontend/diagram/edge-panel.js +293 -0
  23. package/dist/src/frontend/diagram/edge-rendering.js +12 -0
  24. package/dist/src/frontend/diagram/evidence.js +146 -0
  25. package/dist/src/frontend/diagram/grid.js +78 -0
  26. package/dist/src/frontend/diagram/groups.js +102 -0
  27. package/dist/src/frontend/diagram/history.js +157 -0
  28. package/dist/src/frontend/diagram/image-name-modal.js +48 -0
  29. package/dist/src/frontend/diagram/image-upload.js +36 -0
  30. package/dist/src/frontend/diagram/label-editor.js +115 -0
  31. package/dist/src/frontend/diagram/link-panel.js +144 -0
  32. package/dist/src/frontend/diagram/main.js +364 -0
  33. package/dist/src/frontend/diagram/network.js +2214 -0
  34. package/dist/src/frontend/diagram/node-panel.js +389 -0
  35. package/dist/src/frontend/diagram/node-rendering.js +964 -0
  36. package/dist/src/frontend/diagram/persistence.js +168 -0
  37. package/dist/src/frontend/diagram/ports.js +421 -0
  38. package/dist/src/frontend/diagram/selection-overlay.js +387 -0
  39. package/dist/src/frontend/diagram/state.js +43 -0
  40. package/dist/src/frontend/diagram/t.js +3 -0
  41. package/dist/src/frontend/diagram/toast.js +21 -0
  42. package/dist/src/frontend/diagram/unlock-hold.js +206 -0
  43. package/dist/src/frontend/diagram/zoom.js +20 -0
  44. package/dist/src/frontend/diagram-link-modal.js +137 -0
  45. package/dist/src/frontend/diagram.html +1494 -0
  46. package/dist/src/frontend/documents.js +479 -0
  47. package/dist/src/frontend/export.js +338 -0
  48. package/dist/src/frontend/file-attach.js +178 -0
  49. package/dist/src/frontend/files-modal.js +243 -0
  50. package/dist/src/frontend/i18n/en.json +624 -0
  51. package/dist/src/frontend/i18n/fr.json +624 -0
  52. package/dist/src/frontend/i18n.js +32 -0
  53. package/dist/src/frontend/image-paste.js +126 -0
  54. package/dist/src/frontend/index.html +2806 -0
  55. package/dist/src/frontend/local-search.js +476 -0
  56. package/dist/src/frontend/metadata.js +318 -0
  57. package/dist/src/frontend/misc.js +92 -0
  58. package/dist/src/frontend/new-doc-modal.js +285 -0
  59. package/dist/src/frontend/new-folder-modal.js +169 -0
  60. package/dist/src/frontend/search.js +194 -0
  61. package/dist/src/frontend/shape-editor.html +685 -0
  62. package/dist/src/frontend/sidebar-helpers.js +96 -0
  63. package/dist/src/frontend/sidebar-resize.js +98 -0
  64. package/dist/src/frontend/sidebar.js +351 -0
  65. package/dist/src/frontend/snippet-detect.js +25 -0
  66. package/dist/src/frontend/snippet-table.js +85 -0
  67. package/dist/src/frontend/snippet-tree.js +94 -0
  68. package/dist/src/frontend/snippets.js +1146 -0
  69. package/dist/src/frontend/state.js +46 -0
  70. package/dist/src/frontend/utils.js +21 -0
  71. package/dist/src/frontend/validate.js +107 -0
  72. package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
  73. package/dist/src/frontend/wordcloud.js +693 -0
  74. package/dist/src/lib/config.d.ts +26 -0
  75. package/dist/src/lib/config.d.ts.map +1 -0
  76. package/dist/src/lib/config.js +195 -0
  77. package/dist/src/lib/config.js.map +1 -0
  78. package/dist/src/lib/hash.d.ts +2 -0
  79. package/dist/src/lib/hash.d.ts.map +1 -0
  80. package/dist/src/lib/hash.js +18 -0
  81. package/dist/src/lib/hash.js.map +1 -0
  82. package/dist/src/lib/metadata.d.ts +31 -0
  83. package/dist/src/lib/metadata.d.ts.map +1 -0
  84. package/dist/src/lib/metadata.js +128 -0
  85. package/dist/src/lib/metadata.js.map +1 -0
  86. package/dist/src/lib/parser.d.ts +11 -0
  87. package/dist/src/lib/parser.d.ts.map +1 -0
  88. package/dist/src/lib/parser.js +111 -0
  89. package/dist/src/lib/parser.js.map +1 -0
  90. package/dist/src/lib/status.d.ts +9 -0
  91. package/dist/src/lib/status.d.ts.map +1 -0
  92. package/dist/src/lib/status.js +72 -0
  93. package/dist/src/lib/status.js.map +1 -0
  94. package/dist/src/mcp/server.d.ts +3 -0
  95. package/dist/src/mcp/server.d.ts.map +1 -0
  96. package/dist/src/mcp/server.js +2046 -0
  97. package/dist/src/mcp/server.js.map +1 -0
  98. package/dist/src/mcp/tools/diagrams.d.ts +82 -0
  99. package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
  100. package/dist/src/mcp/tools/diagrams.js +594 -0
  101. package/dist/src/mcp/tools/diagrams.js.map +1 -0
  102. package/dist/src/mcp/tools/documents.d.ts +44 -0
  103. package/dist/src/mcp/tools/documents.d.ts.map +1 -0
  104. package/dist/src/mcp/tools/documents.js +186 -0
  105. package/dist/src/mcp/tools/documents.js.map +1 -0
  106. package/dist/src/mcp/tools/git.d.ts +10 -0
  107. package/dist/src/mcp/tools/git.d.ts.map +1 -0
  108. package/dist/src/mcp/tools/git.js +217 -0
  109. package/dist/src/mcp/tools/git.js.map +1 -0
  110. package/dist/src/mcp/tools/metadata.d.ts +57 -0
  111. package/dist/src/mcp/tools/metadata.d.ts.map +1 -0
  112. package/dist/src/mcp/tools/metadata.js +222 -0
  113. package/dist/src/mcp/tools/metadata.js.map +1 -0
  114. package/dist/src/mcp/tools/source.d.ts +29 -0
  115. package/dist/src/mcp/tools/source.d.ts.map +1 -0
  116. package/dist/src/mcp/tools/source.js +196 -0
  117. package/dist/src/mcp/tools/source.js.map +1 -0
  118. package/dist/src/routes/annotations.d.ts +3 -0
  119. package/dist/src/routes/annotations.d.ts.map +1 -0
  120. package/dist/src/routes/annotations.js +83 -0
  121. package/dist/src/routes/annotations.js.map +1 -0
  122. package/dist/src/routes/browse-source.d.ts +3 -0
  123. package/dist/src/routes/browse-source.d.ts.map +1 -0
  124. package/dist/src/routes/browse-source.js +79 -0
  125. package/dist/src/routes/browse-source.js.map +1 -0
  126. package/dist/src/routes/browse.d.ts +3 -0
  127. package/dist/src/routes/browse.d.ts.map +1 -0
  128. package/dist/src/routes/browse.js +91 -0
  129. package/dist/src/routes/browse.js.map +1 -0
  130. package/dist/src/routes/config.d.ts +3 -0
  131. package/dist/src/routes/config.d.ts.map +1 -0
  132. package/dist/src/routes/config.js +145 -0
  133. package/dist/src/routes/config.js.map +1 -0
  134. package/dist/src/routes/context.d.ts +3 -0
  135. package/dist/src/routes/context.d.ts.map +1 -0
  136. package/dist/src/routes/context.js +287 -0
  137. package/dist/src/routes/context.js.map +1 -0
  138. package/dist/src/routes/diagrams.d.ts +3 -0
  139. package/dist/src/routes/diagrams.d.ts.map +1 -0
  140. package/dist/src/routes/diagrams.js +69 -0
  141. package/dist/src/routes/diagrams.js.map +1 -0
  142. package/dist/src/routes/documents.d.ts +11 -0
  143. package/dist/src/routes/documents.d.ts.map +1 -0
  144. package/dist/src/routes/documents.js +450 -0
  145. package/dist/src/routes/documents.js.map +1 -0
  146. package/dist/src/routes/export.d.ts +3 -0
  147. package/dist/src/routes/export.d.ts.map +1 -0
  148. package/dist/src/routes/export.js +280 -0
  149. package/dist/src/routes/export.js.map +1 -0
  150. package/dist/src/routes/files.d.ts +3 -0
  151. package/dist/src/routes/files.d.ts.map +1 -0
  152. package/dist/src/routes/files.js +180 -0
  153. package/dist/src/routes/files.js.map +1 -0
  154. package/dist/src/routes/images.d.ts +3 -0
  155. package/dist/src/routes/images.d.ts.map +1 -0
  156. package/dist/src/routes/images.js +49 -0
  157. package/dist/src/routes/images.js.map +1 -0
  158. package/dist/src/routes/metadata.d.ts +3 -0
  159. package/dist/src/routes/metadata.d.ts.map +1 -0
  160. package/dist/src/routes/metadata.js +131 -0
  161. package/dist/src/routes/metadata.js.map +1 -0
  162. package/dist/src/routes/shape-libraries.d.ts +3 -0
  163. package/dist/src/routes/shape-libraries.d.ts.map +1 -0
  164. package/dist/src/routes/shape-libraries.js +118 -0
  165. package/dist/src/routes/shape-libraries.js.map +1 -0
  166. package/dist/src/routes/wordcloud.d.ts +3 -0
  167. package/dist/src/routes/wordcloud.d.ts.map +1 -0
  168. package/dist/src/routes/wordcloud.js +95 -0
  169. package/dist/src/routes/wordcloud.js.map +1 -0
  170. package/dist/src/server.d.ts +7 -0
  171. package/dist/src/server.d.ts.map +1 -0
  172. package/dist/src/server.js +93 -0
  173. package/dist/src/server.js.map +1 -0
  174. package/dist/starter-doc/.living-doc.json +52 -0
  175. package/dist/starter-doc/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  176. package/dist/starter-doc/AI/2026_01_01_how_to.md +112 -0
  177. package/dist/starter-doc/AI/PROJECT-INSTRUCTIONS.md +172 -0
  178. package/dist/starter-doc/AI/PROJECT-STACK.md +77 -0
  179. package/dist/starter-doc/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  180. package/dist/starter-doc/AI/default/AGENTS.md +31 -0
  181. package/dist/starter-doc/AI/default/CLAUDE.md +31 -0
  182. package/dist/starter-doc/AI/default/MEMORY.md +24 -0
  183. package/dist/starter-doc/AI/rules/no-magic-numbers.md +18 -0
  184. package/dist/starter-doc/AI/rules/track-current-work.md +23 -0
  185. package/dist/starter-doc/WORKLOG/current-task.md +57 -0
  186. package/dist/starter-doc-fr/.living-doc.json +52 -0
  187. package/dist/starter-doc-fr/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  188. package/dist/starter-doc-fr/AI/2026_01_01_how_to.md +100 -0
  189. package/dist/starter-doc-fr/AI/PROJECT-INSTRUCTIONS.md +172 -0
  190. package/dist/starter-doc-fr/AI/PROJECT-STACK.md +77 -0
  191. package/dist/starter-doc-fr/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  192. package/dist/starter-doc-fr/AI/default/AGENTS.md +31 -0
  193. package/dist/starter-doc-fr/AI/default/CLAUDE.md +31 -0
  194. package/dist/starter-doc-fr/AI/default/MEMORY.md +24 -0
  195. package/dist/starter-doc-fr/AI/rules/no-magic-numbers.md +18 -0
  196. package/dist/starter-doc-fr/AI/rules/track-current-work.md +23 -0
  197. package/dist/starter-doc-fr/WORKLOG/current-task.md +57 -0
  198. package/images/living_documentation.jpg +0 -0
  199. package/images/readme-extra-files.png +0 -0
  200. package/images/readme-filename-pattern.png +0 -0
  201. package/images/readme-intelligent-search-demo.jpg +0 -0
  202. package/images/readme-sidebar.png +0 -0
  203. package/package.json +72 -0
@@ -0,0 +1,649 @@
1
+ // ── drawio (mxGraph) XML export ───────────────────────────────────────────────
2
+ // Serialises the current diagram into a self-contained .drawio file.
3
+ //
4
+ // Mapping conventions:
5
+ // - vis-network coordinates are (cx, cy) centred; drawio uses top-left corner.
6
+ // - vis-network rotation is in radians; drawio uses degrees.
7
+ // - Port keys (N/NE/E/SE/S/SW/W/NW or custom anchor ids) become drawio
8
+ // exitX/exitY/entryX/entryY normalised from the node's top-left (0..1).
9
+ // - Free arrows (edges between two `shapeType: 'anchor'` pseudo-nodes) are
10
+ // emitted as drawio floating edges with explicit sourcePoint/targetPoint and
11
+ // no source/target attributes; the anchor pseudo-nodes themselves are dropped.
12
+ // - imageSrc paths (e.g. /images/foo.png) are inlined as data URIs at export
13
+ // time so the resulting file is self-contained outside the server.
14
+
15
+ import { st } from "./state.js";
16
+ import { NODE_COLORS } from "./constants.js";
17
+ import { SHAPE_DEFAULTS } from "./node-rendering.js";
18
+ import {
19
+ CUSTOM_SHAPE_TYPE,
20
+ getCustomShapeDefinition,
21
+ getCustomShapeAnchors,
22
+ getCustomShapeLabelPlacement,
23
+ } from "./custom-shapes.js";
24
+ import { showToast } from "./toast.js";
25
+ import { t } from "./t.js";
26
+
27
+ const DRAWIO_ROOT_PARENT = "1";
28
+ const DRAWIO_LAYER = "1";
29
+ const ANCHOR_SHAPE = "anchor";
30
+
31
+ // ── Public entry point ────────────────────────────────────────────────────────
32
+
33
+ export async function exportCurrentDiagramAsDrawio() {
34
+ if (!st.network || !st.nodes || !st.edges) return;
35
+ try {
36
+ const data = snapshotCurrentDiagram();
37
+ const imageMap = await buildImageMap(data);
38
+ const { xml, droppedLabelRotations } = diagramToDrawioXml(data, imageMap);
39
+ triggerDownload(xml, sanitiseFilename(data.title) + ".drawio");
40
+ showToast(t("diagram.toast.drawio_exported"));
41
+ if (droppedLabelRotations > 0) {
42
+ showToast(
43
+ t("diagram.toast.drawio_label_rotation_dropped").replace(
44
+ "{count}",
45
+ droppedLabelRotations,
46
+ ),
47
+ );
48
+ }
49
+ } catch (err) {
50
+ console.error("[drawio-export]", err);
51
+ showToast(
52
+ t("diagram.toast.drawio_export_error") +
53
+ (err && err.message ? ": " + err.message : ""),
54
+ );
55
+ }
56
+ }
57
+
58
+ // ── Snapshot current diagram (same shape as persistence.saveDiagram) ──────────
59
+
60
+ function snapshotCurrentDiagram() {
61
+ const positions = st.network.getPositions();
62
+ const nodes = (st.canonicalOrder || [])
63
+ .map((id) => st.nodes.get(id))
64
+ .filter(Boolean)
65
+ .map((n) => ({
66
+ ...n,
67
+ x: positions[n.id]?.x ?? n.x,
68
+ y: positions[n.id]?.y ?? n.y,
69
+ }));
70
+ const edges = st.edges.get().map((e) => ({ ...e }));
71
+ const titleEl = document.getElementById("diagramTitle");
72
+ const title = (titleEl && titleEl.value) || t("diagram.toast.untitled");
73
+ return { title, nodes, edges, edgesStraight: !!st.edgesStraight };
74
+ }
75
+
76
+ // ── Inline images as data URIs ────────────────────────────────────────────────
77
+
78
+ async function buildImageMap({ nodes }) {
79
+ const urls = new Set();
80
+ for (const n of nodes) {
81
+ if (n.imageSrc) urls.add(n.imageSrc);
82
+ if (n.shapeType === CUSTOM_SHAPE_TYPE) {
83
+ const def = getCustomShapeDefinition(n.customShapeId);
84
+ if (def && def.imageSrc) urls.add(def.imageSrc);
85
+ }
86
+ }
87
+ const map = new Map();
88
+ await Promise.all(
89
+ [...urls].map(async (url) => {
90
+ if (/^data:/i.test(url)) {
91
+ map.set(url, url);
92
+ return;
93
+ }
94
+ try {
95
+ const res = await fetch(url);
96
+ if (!res.ok) throw new Error("HTTP " + res.status);
97
+ const blob = await res.blob();
98
+ const dataUri = await blobToDataUri(blob);
99
+ map.set(url, dataUri);
100
+ } catch (_err) {
101
+ // Fallback: keep the original URL (will work only if server is reachable when drawio opens).
102
+ map.set(url, url);
103
+ }
104
+ }),
105
+ );
106
+ return map;
107
+ }
108
+
109
+ function blobToDataUri(blob) {
110
+ return new Promise((resolve, reject) => {
111
+ const reader = new FileReader();
112
+ reader.onload = () => resolve(reader.result);
113
+ reader.onerror = () =>
114
+ reject(reader.error || new Error("FileReader failed"));
115
+ reader.readAsDataURL(blob);
116
+ });
117
+ }
118
+
119
+ // ── XML generation ────────────────────────────────────────────────────────────
120
+
121
+ function diagramToDrawioXml(data, imageMap) {
122
+ const counters = { droppedLabelRotations: 0 };
123
+ const idMap = new Map();
124
+ let nextId = 2; // 0 and 1 are reserved for drawio root + layer
125
+ for (const n of data.nodes) {
126
+ if (n.shapeType === ANCHOR_SHAPE) continue;
127
+ idMap.set(n.id, String(nextId++));
128
+ }
129
+
130
+ // Group containers: one drawio cell per unique groupId, hosting its members.
131
+ const groups = buildGroups(data.nodes);
132
+ for (const g of groups.values()) {
133
+ g.cellId = String(nextId++);
134
+ }
135
+
136
+ const cells = [];
137
+ cells.push(`<mxCell id="0"/>`);
138
+ cells.push(`<mxCell id="${DRAWIO_LAYER}" parent="0"/>`);
139
+
140
+ // Group container cells (placed before their children so they're behind in z-order).
141
+ for (const g of groups.values()) {
142
+ cells.push(
143
+ `<mxCell id="${g.cellId}" value="" style="group;" vertex="1" connectable="0" parent="${DRAWIO_LAYER}">` +
144
+ `<mxGeometry x="${num(g.minX)}" y="${num(g.minY)}" width="${num(g.maxX - g.minX)}" height="${num(g.maxY - g.minY)}" as="geometry"/>` +
145
+ `</mxCell>`,
146
+ );
147
+ }
148
+
149
+ // Nodes (in canonical z-order, oldest first).
150
+ for (const n of data.nodes) {
151
+ if (n.shapeType === ANCHOR_SHAPE) continue;
152
+ cells.push(nodeToCell(n, idMap, groups, imageMap, counters));
153
+ }
154
+
155
+ // Edges.
156
+ for (const e of data.edges) {
157
+ cells.push(edgeToCell(e, data, idMap, data.edgesStraight));
158
+ }
159
+
160
+ const diagramId = "d" + Math.random().toString(36).slice(2, 12);
161
+ const xml =
162
+ `<?xml version="1.0" encoding="UTF-8"?>` +
163
+ `<mxfile host="living-ai-documentation" type="device">` +
164
+ `<diagram id="${diagramId}" name="${xmlAttr(data.title)}">` +
165
+ `<mxGraphModel dx="1422" dy="757" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">` +
166
+ `<root>${cells.join("")}</root>` +
167
+ `</mxGraphModel></diagram></mxfile>`;
168
+
169
+ return { xml, droppedLabelRotations: counters.droppedLabelRotations };
170
+ }
171
+
172
+ // ── Groups ────────────────────────────────────────────────────────────────────
173
+
174
+ function buildGroups(nodes) {
175
+ const map = new Map();
176
+ for (const n of nodes) {
177
+ if (!n.groupId || n.shapeType === ANCHOR_SHAPE) continue;
178
+ const { W, H } = nodeSize(n);
179
+ const left = n.x - W / 2,
180
+ top = n.y - H / 2;
181
+ const right = left + W,
182
+ bottom = top + H;
183
+ const g = map.get(n.groupId) || {
184
+ minX: Infinity,
185
+ minY: Infinity,
186
+ maxX: -Infinity,
187
+ maxY: -Infinity,
188
+ memberIds: [],
189
+ };
190
+ g.minX = Math.min(g.minX, left);
191
+ g.minY = Math.min(g.minY, top);
192
+ g.maxX = Math.max(g.maxX, right);
193
+ g.maxY = Math.max(g.maxY, bottom);
194
+ g.memberIds.push(n.id);
195
+ map.set(n.groupId, g);
196
+ }
197
+ return map;
198
+ }
199
+
200
+ // ── Node → cell ───────────────────────────────────────────────────────────────
201
+
202
+ function nodeToCell(n, idMap, groups, imageMap, counters) {
203
+ const id = idMap.get(n.id);
204
+ const { W, H } = nodeSize(n);
205
+ const group = n.groupId && groups.get(n.groupId);
206
+ const parent = group ? group.cellId : DRAWIO_LAYER;
207
+ // drawio coords: top-left, relative to parent (group container or root layer).
208
+ const absX = n.x - W / 2;
209
+ const absY = n.y - H / 2;
210
+ const x = group ? absX - group.minX : absX;
211
+ const y = group ? absY - group.minY : absY;
212
+
213
+ const style = nodeStyle(n, imageMap, counters);
214
+ const label = n.label || "";
215
+ const hasMeta = hasLdMetadata(n);
216
+
217
+ const geom = `<mxGeometry x="${num(x)}" y="${num(y)}" width="${num(W)}" height="${num(H)}" as="geometry"/>`;
218
+
219
+ if (hasMeta) {
220
+ const attrs = ldAttributes(n);
221
+ return (
222
+ `<UserObject id="${id}" label="${xmlAttr(label)}"${attrs}>` +
223
+ `<mxCell style="${xmlAttr(style)}" vertex="1" parent="${parent}">${geom}</mxCell>` +
224
+ `</UserObject>`
225
+ );
226
+ }
227
+ return `<mxCell id="${id}" value="${xmlAttr(label)}" style="${xmlAttr(style)}" vertex="1" parent="${parent}">${geom}</mxCell>`;
228
+ }
229
+
230
+ function nodeSize(n) {
231
+ const defaults = SHAPE_DEFAULTS[n.shapeType] || [100, 40];
232
+ const W = n.nodeWidth || defaults[0];
233
+ const H = n.shapeType === "circle" ? W : n.nodeHeight || defaults[1];
234
+ return { W, H };
235
+ }
236
+
237
+ function nodeStyle(n, imageMap, counters) {
238
+ const parts = [];
239
+ const colors = NODE_COLORS[n.colorKey] || NODE_COLORS["c-gray"];
240
+ let suppressFill = false;
241
+
242
+ switch (n.shapeType) {
243
+ case "box":
244
+ // default rounded rectangle in drawio looks closer to ours with rounded=0.
245
+ parts.push("rounded=0", "whiteSpace=wrap", "html=1");
246
+ break;
247
+ case "ellipse":
248
+ parts.push("ellipse", "whiteSpace=wrap", "html=1");
249
+ break;
250
+ case "circle":
251
+ parts.push("ellipse", "whiteSpace=wrap", "html=1");
252
+ break;
253
+ case "database":
254
+ parts.push(
255
+ "shape=cylinder3",
256
+ "whiteSpace=wrap",
257
+ "html=1",
258
+ "boundedLbl=1",
259
+ "backgroundOutline=1",
260
+ "size=15",
261
+ );
262
+ break;
263
+ case "actor":
264
+ parts.push("shape=actor", "whiteSpace=wrap", "html=1");
265
+ break;
266
+ case "post-it":
267
+ parts.push("shape=note", "whiteSpace=wrap", "html=1", "size=12");
268
+ break;
269
+ case "text-free":
270
+ parts.push(
271
+ "text",
272
+ "html=1",
273
+ "strokeColor=none",
274
+ "fillColor=none",
275
+ "whiteSpace=wrap",
276
+ );
277
+ suppressFill = true;
278
+ break;
279
+ case "image": {
280
+ const src = n.imageSrc && imageMap.get(n.imageSrc);
281
+ if (src)
282
+ parts.push(
283
+ "shape=image",
284
+ "imageAspect=0",
285
+ "image=" + drawioImageValue(src),
286
+ );
287
+ else parts.push("rounded=0", "whiteSpace=wrap", "html=1");
288
+ suppressFill = true;
289
+ break;
290
+ }
291
+ case CUSTOM_SHAPE_TYPE: {
292
+ const def = getCustomShapeDefinition(n.customShapeId);
293
+ const src = def && def.imageSrc && imageMap.get(def.imageSrc);
294
+ if (src)
295
+ parts.push(
296
+ "shape=image",
297
+ "imageAspect=0",
298
+ "image=" + drawioImageValue(src),
299
+ );
300
+ else parts.push("rounded=0", "whiteSpace=wrap", "html=1");
301
+ suppressFill = true;
302
+ applyCustomShapeLabelPlacement(n, parts);
303
+ break;
304
+ }
305
+ default:
306
+ parts.push("rounded=0", "whiteSpace=wrap", "html=1");
307
+ }
308
+
309
+ if (!suppressFill) {
310
+ parts.push(
311
+ `fillColor=${colors.bg}`,
312
+ `strokeColor=${colors.border}`,
313
+ `fontColor=${colors.font}`,
314
+ );
315
+ } else if (n.shapeType === "text-free") {
316
+ parts.push(`fontColor=${colors.font}`);
317
+ }
318
+
319
+ if (typeof n.bgOpacity === "number" && n.bgOpacity !== 1) {
320
+ parts.push(`opacity=${Math.round(n.bgOpacity * 100)}`);
321
+ }
322
+ if (n.fontSize) parts.push(`fontSize=${n.fontSize}`);
323
+ if (n.textAlign && !parts.some((p) => p.startsWith("align=")))
324
+ parts.push(`align=${n.textAlign}`);
325
+ if (n.textValign && !parts.some((p) => p.startsWith("verticalAlign=")))
326
+ parts.push(`verticalAlign=${n.textValign}`);
327
+ if (n.rotation) parts.push(`rotation=${num(radToDeg(n.rotation))}`);
328
+
329
+ // Arbitrage (b): labelRotation independent of rotation is dropped — count for the user toast.
330
+ if (
331
+ n.labelRotation &&
332
+ Math.abs((n.labelRotation || 0) - (n.rotation || 0)) > 1e-3
333
+ ) {
334
+ counters.droppedLabelRotations++;
335
+ }
336
+
337
+ if (n.locked)
338
+ parts.push(
339
+ "editable=0",
340
+ "movable=0",
341
+ "resizable=0",
342
+ "rotatable=0",
343
+ "deletable=0",
344
+ );
345
+
346
+ return parts.join(";") + ";";
347
+ }
348
+
349
+ function applyCustomShapeLabelPlacement(n, parts) {
350
+ const placement =
351
+ n.labelPlacement || getCustomShapeLabelPlacement(n.customShapeId);
352
+ switch (placement) {
353
+ case "above":
354
+ parts.push(
355
+ "verticalLabelPosition=top",
356
+ "verticalAlign=bottom",
357
+ "labelPosition=center",
358
+ "align=center",
359
+ );
360
+ break;
361
+ case "below":
362
+ parts.push(
363
+ "verticalLabelPosition=bottom",
364
+ "verticalAlign=top",
365
+ "labelPosition=center",
366
+ "align=center",
367
+ );
368
+ break;
369
+ case "left":
370
+ parts.push(
371
+ "labelPosition=left",
372
+ "align=right",
373
+ "verticalLabelPosition=middle",
374
+ "verticalAlign=middle",
375
+ );
376
+ break;
377
+ case "right":
378
+ parts.push(
379
+ "labelPosition=right",
380
+ "align=left",
381
+ "verticalLabelPosition=middle",
382
+ "verticalAlign=middle",
383
+ );
384
+ break;
385
+ case "center":
386
+ default:
387
+ parts.push(
388
+ "verticalLabelPosition=middle",
389
+ "verticalAlign=middle",
390
+ "labelPosition=center",
391
+ "align=center",
392
+ );
393
+ }
394
+ }
395
+
396
+ // ── Edge → cell ───────────────────────────────────────────────────────────────
397
+
398
+ function edgeToCell(e, data, idMap, edgesStraight) {
399
+ const fromNode = data.nodes.find((n) => n.id === e.from);
400
+ const toNode = data.nodes.find((n) => n.id === e.to);
401
+ const fromIsAnchor = fromNode && fromNode.shapeType === ANCHOR_SHAPE;
402
+ const toIsAnchor = toNode && toNode.shapeType === ANCHOR_SHAPE;
403
+ const isFreeArrow = fromIsAnchor && toIsAnchor;
404
+
405
+ const id = "e" + e.id;
406
+ const parts = [
407
+ "edgeStyle=none",
408
+ "rounded=0",
409
+ "html=1",
410
+ "jettySize=auto",
411
+ "orthogonalLoop=1",
412
+ ];
413
+
414
+ // Curved vs straight — drawio's `curved=1` enables Bezier; we accept the
415
+ // cosmetic drift versus our custom port-normal control points.
416
+ if (!edgesStraight) parts.push("curved=1");
417
+
418
+ // Arrow direction.
419
+ const dir = e.arrowDir || "to";
420
+ if (dir === "to") parts.push("endArrow=classic", "startArrow=none");
421
+ else if (dir === "from") parts.push("endArrow=none", "startArrow=classic");
422
+ else if (dir === "both") parts.push("endArrow=classic", "startArrow=classic");
423
+ else parts.push("endArrow=none", "startArrow=none");
424
+
425
+ if (e.dashes) parts.push("dashed=1");
426
+ if (e.edgeColor) parts.push(`strokeColor=${e.edgeColor}`);
427
+ if (e.edgeWidth) parts.push(`strokeWidth=${e.edgeWidth}`);
428
+ if (e.fontSize) parts.push(`fontSize=${e.fontSize}`);
429
+ if (e.edgeLocked) parts.push("editable=0", "movable=0", "deletable=0");
430
+
431
+ // Port-anchored attachment (exit/entry normalised 0..1 from node top-left).
432
+ if (e.fromPort && fromNode && !fromIsAnchor) {
433
+ const p = portToEntryExit(fromNode, e.fromPort);
434
+ if (p)
435
+ parts.push(
436
+ `exitX=${num(p.x)}`,
437
+ `exitY=${num(p.y)}`,
438
+ "exitDx=0",
439
+ "exitDy=0",
440
+ "exitPerimeter=0",
441
+ );
442
+ }
443
+ if (e.toPort && toNode && !toIsAnchor) {
444
+ const p = portToEntryExit(toNode, e.toPort);
445
+ if (p)
446
+ parts.push(
447
+ `entryX=${num(p.x)}`,
448
+ `entryY=${num(p.y)}`,
449
+ "entryDx=0",
450
+ "entryDy=0",
451
+ "entryPerimeter=0",
452
+ );
453
+ }
454
+
455
+ const style = parts.join(";") + ";";
456
+
457
+ const sourceAttr =
458
+ !isFreeArrow && fromNode ? ` source="${idMap.get(e.from)}"` : "";
459
+ const targetAttr =
460
+ !isFreeArrow && toNode ? ` target="${idMap.get(e.to)}"` : "";
461
+
462
+ let geometryInner = "";
463
+ if (isFreeArrow) {
464
+ // Floating endpoints — use the anchor pseudo-node positions directly.
465
+ geometryInner =
466
+ `<mxPoint x="${num(fromNode.x)}" y="${num(fromNode.y)}" as="sourcePoint"/>` +
467
+ `<mxPoint x="${num(toNode.x)}" y="${num(toNode.y)}" as="targetPoint"/>`;
468
+ }
469
+ const geometry = `<mxGeometry relative="1" as="geometry">${geometryInner}</mxGeometry>`;
470
+
471
+ const value = e.label ? xmlAttr(e.label) : "";
472
+ const edgeCell =
473
+ `<mxCell id="${id}" value="${value}" style="${xmlAttr(style)}" edge="1" parent="${DRAWIO_LAYER}"${sourceAttr}${targetAttr}>` +
474
+ geometry +
475
+ `</mxCell>`;
476
+
477
+ // Edge label child cell — only needed when we have a fixed wrap width, an
478
+ // explicit offset, or a non-zero label rotation. Otherwise the label rides
479
+ // on the parent edge directly (above).
480
+ const needsChildLabel = !!(
481
+ e.label &&
482
+ (e.edgeLabelWidth ||
483
+ e.edgeLabelOffsetX ||
484
+ e.edgeLabelOffsetY ||
485
+ (e.labelRotation && Math.abs(e.labelRotation) > 1e-3))
486
+ );
487
+
488
+ if (!needsChildLabel) return edgeCell;
489
+
490
+ // When using a child label, clear the parent edge's value so it's not drawn twice.
491
+ const edgeCellNoValue = edgeCell.replace(` value="${value}"`, ` value=""`);
492
+
493
+ const labelStyleParts = [
494
+ "edgeLabel",
495
+ "html=1",
496
+ "align=center",
497
+ "verticalAlign=middle",
498
+ "resizable=0",
499
+ "points=[]",
500
+ ];
501
+ if (e.edgeLabelWidth) labelStyleParts.push("whiteSpace=wrap");
502
+ if (e.labelRotation)
503
+ labelStyleParts.push(`rotation=${num(radToDeg(e.labelRotation))}`);
504
+ const labelStyle = labelStyleParts.join(";") + ";";
505
+
506
+ const labelWidth = e.edgeLabelWidth || 0;
507
+ const labelGeom =
508
+ `<mxGeometry x="0" y="0" relative="1" as="geometry">` +
509
+ `<mxPoint x="${num(e.edgeLabelOffsetX || 0)}" y="${num(e.edgeLabelOffsetY || 0)}" as="offset"/>` +
510
+ (labelWidth
511
+ ? `<mxRectangle width="${num(labelWidth)}" height="20" as="alternateBounds"/>`
512
+ : "") +
513
+ `</mxGeometry>`;
514
+
515
+ const labelCell =
516
+ `<mxCell id="${id}-lbl" value="${value}" style="${xmlAttr(labelStyle)}" vertex="1" connectable="0" parent="${id}">` +
517
+ labelGeom +
518
+ `</mxCell>`;
519
+
520
+ return edgeCellNoValue + labelCell;
521
+ }
522
+
523
+ // ── Port → normalised entry/exit (0..1 from node top-left) ────────────────────
524
+
525
+ const RECT_PORT_EXIT = {
526
+ N: [0.5, 0],
527
+ NE: [1, 0],
528
+ E: [1, 0.5],
529
+ SE: [1, 1],
530
+ S: [0.5, 1],
531
+ SW: [0, 1],
532
+ W: [0, 0.5],
533
+ NW: [0, 0],
534
+ };
535
+ const SQRT2_INV = 1 / Math.sqrt(2);
536
+ const CIRC_PORT_EXIT = {
537
+ N: [0.5, 0],
538
+ NE: [0.5 + SQRT2_INV / 2, 0.5 - SQRT2_INV / 2],
539
+ E: [1, 0.5],
540
+ SE: [0.5 + SQRT2_INV / 2, 0.5 + SQRT2_INV / 2],
541
+ S: [0.5, 1],
542
+ SW: [0.5 - SQRT2_INV / 2, 0.5 + SQRT2_INV / 2],
543
+ W: [0, 0.5],
544
+ NW: [0.5 - SQRT2_INV / 2, 0.5 - SQRT2_INV / 2],
545
+ };
546
+ const DATABASE_PORT_EXIT = {
547
+ N: [0.5, 0],
548
+ NE: [1, 0.12],
549
+ E: [1, 0.5],
550
+ SE: [1, 0.88],
551
+ S: [0.5, 1],
552
+ SW: [0, 0.88],
553
+ W: [0, 0.5],
554
+ NW: [0, 0.12],
555
+ };
556
+
557
+ function portToEntryExit(node, portKey) {
558
+ if (node.shapeType === CUSTOM_SHAPE_TYPE) {
559
+ const anchor = getCustomShapeAnchors(node.customShapeId).find(
560
+ (a) => a.id === portKey,
561
+ );
562
+ if (!anchor) return null;
563
+ return { x: anchor.x, y: anchor.y };
564
+ }
565
+ const table =
566
+ node.shapeType === "database"
567
+ ? DATABASE_PORT_EXIT
568
+ : node.shapeType === "ellipse" || node.shapeType === "circle"
569
+ ? CIRC_PORT_EXIT
570
+ : RECT_PORT_EXIT;
571
+ const xy = table[portKey];
572
+ return xy ? { x: xy[0], y: xy[1] } : null;
573
+ }
574
+
575
+ // ── UserObject attributes for semantic metadata + link ────────────────────────
576
+
577
+ function hasLdMetadata(n) {
578
+ return !!(
579
+ n.nodeLink ||
580
+ n.description ||
581
+ n.kind ||
582
+ n.renderAs ||
583
+ (Array.isArray(n.evidence) && n.evidence.length)
584
+ );
585
+ }
586
+
587
+ function ldAttributes(n) {
588
+ const out = [];
589
+ if (n.nodeLink) out.push(` link="${xmlAttr(n.nodeLink)}"`);
590
+ if (n.description) out.push(` ld_description="${xmlAttr(n.description)}"`);
591
+ if (n.kind) out.push(` ld_kind="${xmlAttr(n.kind)}"`);
592
+ if (n.renderAs) out.push(` ld_renderAs="${xmlAttr(n.renderAs)}"`);
593
+ if (Array.isArray(n.evidence) && n.evidence.length) {
594
+ out.push(` ld_evidence="${xmlAttr(JSON.stringify(n.evidence))}"`);
595
+ }
596
+ return out.join("");
597
+ }
598
+
599
+ // ── Encoding helpers ──────────────────────────────────────────────────────────
600
+
601
+ function xmlAttr(s) {
602
+ return String(s == null ? "" : s)
603
+ .replace(/&/g, "&amp;")
604
+ .replace(/"/g, "&quot;")
605
+ .replace(/</g, "&lt;")
606
+ .replace(/>/g, "&gt;")
607
+ .replace(/\n/g, "&#10;")
608
+ .replace(/\r/g, "&#13;");
609
+ }
610
+
611
+ function num(v) {
612
+ if (!Number.isFinite(v)) return "0";
613
+ return Math.abs(v - Math.round(v)) < 1e-6
614
+ ? String(Math.round(v))
615
+ : v.toFixed(3);
616
+ }
617
+
618
+ function radToDeg(rad) {
619
+ return (rad * 180) / Math.PI;
620
+ }
621
+
622
+ // drawio's style parser treats `;` as the property separator, which collides
623
+ // with the standard `data:image/png;base64,...` prefix. drawio accepts a
624
+ // comma-form (`data:image/png,base64,...`) as a workaround so the value can
625
+ // sit unescaped inside the style string.
626
+ function drawioImageValue(src) {
627
+ return src.replace(/^data:([^;,]+);base64,/i, "data:$1,base64,");
628
+ }
629
+
630
+ function sanitiseFilename(s) {
631
+ return (
632
+ String(s || "diagram")
633
+ .replace(/[^\w.\- ]+/g, "_")
634
+ .replace(/\s+/g, "_")
635
+ .slice(0, 80) || "diagram"
636
+ );
637
+ }
638
+
639
+ function triggerDownload(content, filename) {
640
+ const blob = new Blob([content], { type: "application/xml;charset=utf-8" });
641
+ const url = URL.createObjectURL(blob);
642
+ const a = document.createElement("a");
643
+ a.href = url;
644
+ a.download = filename;
645
+ document.body.appendChild(a);
646
+ a.click();
647
+ document.body.removeChild(a);
648
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
649
+ }