living-documentation 8.8.0 → 8.10.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.
@@ -0,0 +1,499 @@
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(t('diagram.toast.drawio_label_rotation_dropped').replace('{count}', droppedLabelRotations));
43
+ }
44
+ } catch (err) {
45
+ console.error('[drawio-export]', err);
46
+ showToast(t('diagram.toast.drawio_export_error') + (err && err.message ? ': ' + err.message : ''));
47
+ }
48
+ }
49
+
50
+ // ── Snapshot current diagram (same shape as persistence.saveDiagram) ──────────
51
+
52
+ function snapshotCurrentDiagram() {
53
+ const positions = st.network.getPositions();
54
+ const nodes = (st.canonicalOrder || [])
55
+ .map((id) => st.nodes.get(id))
56
+ .filter(Boolean)
57
+ .map((n) => ({ ...n, x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y }));
58
+ const edges = st.edges.get().map((e) => ({ ...e }));
59
+ const titleEl = document.getElementById('diagramTitle');
60
+ const title = (titleEl && titleEl.value) || t('diagram.toast.untitled');
61
+ return { title, nodes, edges, edgesStraight: !!st.edgesStraight };
62
+ }
63
+
64
+ // ── Inline images as data URIs ────────────────────────────────────────────────
65
+
66
+ async function buildImageMap({ nodes }) {
67
+ const urls = new Set();
68
+ for (const n of nodes) {
69
+ if (n.imageSrc) urls.add(n.imageSrc);
70
+ if (n.shapeType === CUSTOM_SHAPE_TYPE) {
71
+ const def = getCustomShapeDefinition(n.customShapeId);
72
+ if (def && def.imageSrc) urls.add(def.imageSrc);
73
+ }
74
+ }
75
+ const map = new Map();
76
+ await Promise.all([...urls].map(async (url) => {
77
+ if (/^data:/i.test(url)) { map.set(url, url); return; }
78
+ try {
79
+ const res = await fetch(url);
80
+ if (!res.ok) throw new Error('HTTP ' + res.status);
81
+ const blob = await res.blob();
82
+ const dataUri = await blobToDataUri(blob);
83
+ map.set(url, dataUri);
84
+ } catch (_err) {
85
+ // Fallback: keep the original URL (will work only if server is reachable when drawio opens).
86
+ map.set(url, url);
87
+ }
88
+ }));
89
+ return map;
90
+ }
91
+
92
+ function blobToDataUri(blob) {
93
+ return new Promise((resolve, reject) => {
94
+ const reader = new FileReader();
95
+ reader.onload = () => resolve(reader.result);
96
+ reader.onerror = () => reject(reader.error || new Error('FileReader failed'));
97
+ reader.readAsDataURL(blob);
98
+ });
99
+ }
100
+
101
+ // ── XML generation ────────────────────────────────────────────────────────────
102
+
103
+ function diagramToDrawioXml(data, imageMap) {
104
+ const counters = { droppedLabelRotations: 0 };
105
+ const idMap = new Map();
106
+ let nextId = 2; // 0 and 1 are reserved for drawio root + layer
107
+ for (const n of data.nodes) {
108
+ if (n.shapeType === ANCHOR_SHAPE) continue;
109
+ idMap.set(n.id, String(nextId++));
110
+ }
111
+
112
+ // Group containers: one drawio cell per unique groupId, hosting its members.
113
+ const groups = buildGroups(data.nodes);
114
+ for (const g of groups.values()) {
115
+ g.cellId = String(nextId++);
116
+ }
117
+
118
+ const cells = [];
119
+ cells.push(`<mxCell id="0"/>`);
120
+ cells.push(`<mxCell id="${DRAWIO_LAYER}" parent="0"/>`);
121
+
122
+ // Group container cells (placed before their children so they're behind in z-order).
123
+ for (const g of groups.values()) {
124
+ cells.push(
125
+ `<mxCell id="${g.cellId}" value="" style="group;" vertex="1" connectable="0" parent="${DRAWIO_LAYER}">` +
126
+ `<mxGeometry x="${num(g.minX)}" y="${num(g.minY)}" width="${num(g.maxX - g.minX)}" height="${num(g.maxY - g.minY)}" as="geometry"/>` +
127
+ `</mxCell>`
128
+ );
129
+ }
130
+
131
+ // Nodes (in canonical z-order, oldest first).
132
+ for (const n of data.nodes) {
133
+ if (n.shapeType === ANCHOR_SHAPE) continue;
134
+ cells.push(nodeToCell(n, idMap, groups, imageMap, counters));
135
+ }
136
+
137
+ // Edges.
138
+ for (const e of data.edges) {
139
+ cells.push(edgeToCell(e, data, idMap, data.edgesStraight));
140
+ }
141
+
142
+ const diagramId = 'd' + Math.random().toString(36).slice(2, 12);
143
+ const xml =
144
+ `<?xml version="1.0" encoding="UTF-8"?>` +
145
+ `<mxfile host="living-documentation" type="device">` +
146
+ `<diagram id="${diagramId}" name="${xmlAttr(data.title)}">` +
147
+ `<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">` +
148
+ `<root>${cells.join('')}</root>` +
149
+ `</mxGraphModel></diagram></mxfile>`;
150
+
151
+ return { xml, droppedLabelRotations: counters.droppedLabelRotations };
152
+ }
153
+
154
+ // ── Groups ────────────────────────────────────────────────────────────────────
155
+
156
+ function buildGroups(nodes) {
157
+ const map = new Map();
158
+ for (const n of nodes) {
159
+ if (!n.groupId || n.shapeType === ANCHOR_SHAPE) continue;
160
+ const { W, H } = nodeSize(n);
161
+ const left = n.x - W / 2, top = n.y - H / 2;
162
+ const right = left + W, bottom = top + H;
163
+ const g = map.get(n.groupId) || { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, memberIds: [] };
164
+ g.minX = Math.min(g.minX, left);
165
+ g.minY = Math.min(g.minY, top);
166
+ g.maxX = Math.max(g.maxX, right);
167
+ g.maxY = Math.max(g.maxY, bottom);
168
+ g.memberIds.push(n.id);
169
+ map.set(n.groupId, g);
170
+ }
171
+ return map;
172
+ }
173
+
174
+ // ── Node → cell ───────────────────────────────────────────────────────────────
175
+
176
+ function nodeToCell(n, idMap, groups, imageMap, counters) {
177
+ const id = idMap.get(n.id);
178
+ const { W, H } = nodeSize(n);
179
+ const group = n.groupId && groups.get(n.groupId);
180
+ const parent = group ? group.cellId : DRAWIO_LAYER;
181
+ // drawio coords: top-left, relative to parent (group container or root layer).
182
+ const absX = n.x - W / 2;
183
+ const absY = n.y - H / 2;
184
+ const x = group ? absX - group.minX : absX;
185
+ const y = group ? absY - group.minY : absY;
186
+
187
+ const style = nodeStyle(n, imageMap, counters);
188
+ const label = n.label || '';
189
+ const hasMeta = hasLdMetadata(n);
190
+
191
+ const geom = `<mxGeometry x="${num(x)}" y="${num(y)}" width="${num(W)}" height="${num(H)}" as="geometry"/>`;
192
+
193
+ if (hasMeta) {
194
+ const attrs = ldAttributes(n);
195
+ return (
196
+ `<UserObject id="${id}" label="${xmlAttr(label)}"${attrs}>` +
197
+ `<mxCell style="${xmlAttr(style)}" vertex="1" parent="${parent}">${geom}</mxCell>` +
198
+ `</UserObject>`
199
+ );
200
+ }
201
+ return (
202
+ `<mxCell id="${id}" value="${xmlAttr(label)}" style="${xmlAttr(style)}" vertex="1" parent="${parent}">${geom}</mxCell>`
203
+ );
204
+ }
205
+
206
+ function nodeSize(n) {
207
+ const defaults = SHAPE_DEFAULTS[n.shapeType] || [100, 40];
208
+ const W = n.nodeWidth || defaults[0];
209
+ const H = n.shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
210
+ return { W, H };
211
+ }
212
+
213
+ function nodeStyle(n, imageMap, counters) {
214
+ const parts = [];
215
+ const colors = NODE_COLORS[n.colorKey] || NODE_COLORS['c-gray'];
216
+ let suppressFill = false;
217
+
218
+ switch (n.shapeType) {
219
+ case 'box':
220
+ // default rounded rectangle in drawio looks closer to ours with rounded=0.
221
+ parts.push('rounded=0', 'whiteSpace=wrap', 'html=1');
222
+ break;
223
+ case 'ellipse':
224
+ parts.push('ellipse', 'whiteSpace=wrap', 'html=1');
225
+ break;
226
+ case 'circle':
227
+ parts.push('ellipse', 'whiteSpace=wrap', 'html=1');
228
+ break;
229
+ case 'database':
230
+ parts.push('shape=cylinder3', 'whiteSpace=wrap', 'html=1', 'boundedLbl=1', 'backgroundOutline=1', 'size=15');
231
+ break;
232
+ case 'actor':
233
+ parts.push('shape=actor', 'whiteSpace=wrap', 'html=1');
234
+ break;
235
+ case 'post-it':
236
+ parts.push('shape=note', 'whiteSpace=wrap', 'html=1', 'size=12');
237
+ break;
238
+ case 'text-free':
239
+ parts.push('text', 'html=1', 'strokeColor=none', 'fillColor=none', 'whiteSpace=wrap');
240
+ suppressFill = true;
241
+ break;
242
+ case 'image': {
243
+ const src = n.imageSrc && imageMap.get(n.imageSrc);
244
+ if (src) parts.push('shape=image', 'imageAspect=0', 'image=' + drawioImageValue(src));
245
+ else parts.push('rounded=0', 'whiteSpace=wrap', 'html=1');
246
+ suppressFill = true;
247
+ break;
248
+ }
249
+ case CUSTOM_SHAPE_TYPE: {
250
+ const def = getCustomShapeDefinition(n.customShapeId);
251
+ const src = def && def.imageSrc && imageMap.get(def.imageSrc);
252
+ if (src) parts.push('shape=image', 'imageAspect=0', 'image=' + drawioImageValue(src));
253
+ else parts.push('rounded=0', 'whiteSpace=wrap', 'html=1');
254
+ suppressFill = true;
255
+ applyCustomShapeLabelPlacement(n, parts);
256
+ break;
257
+ }
258
+ default:
259
+ parts.push('rounded=0', 'whiteSpace=wrap', 'html=1');
260
+ }
261
+
262
+ if (!suppressFill) {
263
+ parts.push(`fillColor=${colors.bg}`, `strokeColor=${colors.border}`, `fontColor=${colors.font}`);
264
+ } else if (n.shapeType === 'text-free') {
265
+ parts.push(`fontColor=${colors.font}`);
266
+ }
267
+
268
+ if (typeof n.bgOpacity === 'number' && n.bgOpacity !== 1) {
269
+ parts.push(`opacity=${Math.round(n.bgOpacity * 100)}`);
270
+ }
271
+ if (n.fontSize) parts.push(`fontSize=${n.fontSize}`);
272
+ if (n.textAlign && !parts.some((p) => p.startsWith('align='))) parts.push(`align=${n.textAlign}`);
273
+ if (n.textValign && !parts.some((p) => p.startsWith('verticalAlign='))) parts.push(`verticalAlign=${n.textValign}`);
274
+ if (n.rotation) parts.push(`rotation=${num(radToDeg(n.rotation))}`);
275
+
276
+ // Arbitrage (b): labelRotation independent of rotation is dropped — count for the user toast.
277
+ if (n.labelRotation && Math.abs((n.labelRotation || 0) - (n.rotation || 0)) > 1e-3) {
278
+ counters.droppedLabelRotations++;
279
+ }
280
+
281
+ if (n.locked) parts.push('editable=0', 'movable=0', 'resizable=0', 'rotatable=0', 'deletable=0');
282
+
283
+ return parts.join(';') + ';';
284
+ }
285
+
286
+ function applyCustomShapeLabelPlacement(n, parts) {
287
+ const placement = n.labelPlacement || getCustomShapeLabelPlacement(n.customShapeId);
288
+ switch (placement) {
289
+ case 'above':
290
+ parts.push('verticalLabelPosition=top', 'verticalAlign=bottom', 'labelPosition=center', 'align=center');
291
+ break;
292
+ case 'below':
293
+ parts.push('verticalLabelPosition=bottom', 'verticalAlign=top', 'labelPosition=center', 'align=center');
294
+ break;
295
+ case 'left':
296
+ parts.push('labelPosition=left', 'align=right', 'verticalLabelPosition=middle', 'verticalAlign=middle');
297
+ break;
298
+ case 'right':
299
+ parts.push('labelPosition=right', 'align=left', 'verticalLabelPosition=middle', 'verticalAlign=middle');
300
+ break;
301
+ case 'center':
302
+ default:
303
+ parts.push('verticalLabelPosition=middle', 'verticalAlign=middle', 'labelPosition=center', 'align=center');
304
+ }
305
+ }
306
+
307
+ // ── Edge → cell ───────────────────────────────────────────────────────────────
308
+
309
+ function edgeToCell(e, data, idMap, edgesStraight) {
310
+ const fromNode = data.nodes.find((n) => n.id === e.from);
311
+ const toNode = data.nodes.find((n) => n.id === e.to);
312
+ const fromIsAnchor = fromNode && fromNode.shapeType === ANCHOR_SHAPE;
313
+ const toIsAnchor = toNode && toNode.shapeType === ANCHOR_SHAPE;
314
+ const isFreeArrow = fromIsAnchor && toIsAnchor;
315
+
316
+ const id = 'e' + e.id;
317
+ const parts = ['edgeStyle=none', 'rounded=0', 'html=1', 'jettySize=auto', 'orthogonalLoop=1'];
318
+
319
+ // Curved vs straight — drawio's `curved=1` enables Bezier; we accept the
320
+ // cosmetic drift versus our custom port-normal control points.
321
+ if (!edgesStraight) parts.push('curved=1');
322
+
323
+ // Arrow direction.
324
+ const dir = e.arrowDir || 'to';
325
+ if (dir === 'to') parts.push('endArrow=classic', 'startArrow=none');
326
+ else if (dir === 'from') parts.push('endArrow=none', 'startArrow=classic');
327
+ else if (dir === 'both') parts.push('endArrow=classic', 'startArrow=classic');
328
+ else parts.push('endArrow=none', 'startArrow=none');
329
+
330
+ if (e.dashes) parts.push('dashed=1');
331
+ if (e.edgeColor) parts.push(`strokeColor=${e.edgeColor}`);
332
+ if (e.edgeWidth) parts.push(`strokeWidth=${e.edgeWidth}`);
333
+ if (e.fontSize) parts.push(`fontSize=${e.fontSize}`);
334
+ if (e.edgeLocked) parts.push('editable=0', 'movable=0', 'deletable=0');
335
+
336
+ // Port-anchored attachment (exit/entry normalised 0..1 from node top-left).
337
+ if (e.fromPort && fromNode && !fromIsAnchor) {
338
+ const p = portToEntryExit(fromNode, e.fromPort);
339
+ if (p) parts.push(`exitX=${num(p.x)}`, `exitY=${num(p.y)}`, 'exitDx=0', 'exitDy=0', 'exitPerimeter=0');
340
+ }
341
+ if (e.toPort && toNode && !toIsAnchor) {
342
+ const p = portToEntryExit(toNode, e.toPort);
343
+ if (p) parts.push(`entryX=${num(p.x)}`, `entryY=${num(p.y)}`, 'entryDx=0', 'entryDy=0', 'entryPerimeter=0');
344
+ }
345
+
346
+ const style = parts.join(';') + ';';
347
+
348
+ const sourceAttr = !isFreeArrow && fromNode ? ` source="${idMap.get(e.from)}"` : '';
349
+ const targetAttr = !isFreeArrow && toNode ? ` target="${idMap.get(e.to)}"` : '';
350
+
351
+ let geometryInner = '';
352
+ if (isFreeArrow) {
353
+ // Floating endpoints — use the anchor pseudo-node positions directly.
354
+ geometryInner =
355
+ `<mxPoint x="${num(fromNode.x)}" y="${num(fromNode.y)}" as="sourcePoint"/>` +
356
+ `<mxPoint x="${num(toNode.x)}" y="${num(toNode.y)}" as="targetPoint"/>`;
357
+ }
358
+ const geometry = `<mxGeometry relative="1" as="geometry">${geometryInner}</mxGeometry>`;
359
+
360
+ const value = e.label ? xmlAttr(e.label) : '';
361
+ const edgeCell =
362
+ `<mxCell id="${id}" value="${value}" style="${xmlAttr(style)}" edge="1" parent="${DRAWIO_LAYER}"${sourceAttr}${targetAttr}>` +
363
+ geometry +
364
+ `</mxCell>`;
365
+
366
+ // Edge label child cell — only needed when we have a fixed wrap width, an
367
+ // explicit offset, or a non-zero label rotation. Otherwise the label rides
368
+ // on the parent edge directly (above).
369
+ const needsChildLabel = !!(
370
+ e.label &&
371
+ (e.edgeLabelWidth || e.edgeLabelOffsetX || e.edgeLabelOffsetY ||
372
+ (e.labelRotation && Math.abs(e.labelRotation) > 1e-3))
373
+ );
374
+
375
+ if (!needsChildLabel) return edgeCell;
376
+
377
+ // When using a child label, clear the parent edge's value so it's not drawn twice.
378
+ const edgeCellNoValue = edgeCell.replace(` value="${value}"`, ` value=""`);
379
+
380
+ const labelStyleParts = ['edgeLabel', 'html=1', 'align=center', 'verticalAlign=middle', 'resizable=0', 'points=[]'];
381
+ if (e.edgeLabelWidth) labelStyleParts.push('whiteSpace=wrap');
382
+ if (e.labelRotation) labelStyleParts.push(`rotation=${num(radToDeg(e.labelRotation))}`);
383
+ const labelStyle = labelStyleParts.join(';') + ';';
384
+
385
+ const labelWidth = e.edgeLabelWidth || 0;
386
+ const labelGeom =
387
+ `<mxGeometry x="0" y="0" relative="1" as="geometry">` +
388
+ `<mxPoint x="${num(e.edgeLabelOffsetX || 0)}" y="${num(e.edgeLabelOffsetY || 0)}" as="offset"/>` +
389
+ (labelWidth ? `<mxRectangle width="${num(labelWidth)}" height="20" as="alternateBounds"/>` : '') +
390
+ `</mxGeometry>`;
391
+
392
+ const labelCell =
393
+ `<mxCell id="${id}-lbl" value="${value}" style="${xmlAttr(labelStyle)}" vertex="1" connectable="0" parent="${id}">` +
394
+ labelGeom +
395
+ `</mxCell>`;
396
+
397
+ return edgeCellNoValue + labelCell;
398
+ }
399
+
400
+ // ── Port → normalised entry/exit (0..1 from node top-left) ────────────────────
401
+
402
+ const RECT_PORT_EXIT = {
403
+ N: [0.5, 0 ], NE: [1, 0 ], E: [1, 0.5], SE: [1, 1 ],
404
+ S: [0.5, 1 ], SW: [0, 1 ], W: [0, 0.5], NW: [0, 0 ],
405
+ };
406
+ const SQRT2_INV = 1 / Math.sqrt(2);
407
+ const CIRC_PORT_EXIT = {
408
+ N: [0.5, 0 ],
409
+ NE: [0.5 + SQRT2_INV / 2, 0.5 - SQRT2_INV / 2],
410
+ E: [1, 0.5 ],
411
+ SE: [0.5 + SQRT2_INV / 2, 0.5 + SQRT2_INV / 2],
412
+ S: [0.5, 1 ],
413
+ SW: [0.5 - SQRT2_INV / 2, 0.5 + SQRT2_INV / 2],
414
+ W: [0, 0.5 ],
415
+ NW: [0.5 - SQRT2_INV / 2, 0.5 - SQRT2_INV / 2],
416
+ };
417
+ const DATABASE_PORT_EXIT = {
418
+ N: [0.5, 0 ], NE: [1, 0.12],
419
+ E: [1, 0.5 ], SE: [1, 0.88],
420
+ S: [0.5, 1 ], SW: [0, 0.88],
421
+ W: [0, 0.5 ], NW: [0, 0.12],
422
+ };
423
+
424
+ function portToEntryExit(node, portKey) {
425
+ if (node.shapeType === CUSTOM_SHAPE_TYPE) {
426
+ const anchor = getCustomShapeAnchors(node.customShapeId).find((a) => a.id === portKey);
427
+ if (!anchor) return null;
428
+ return { x: anchor.x, y: anchor.y };
429
+ }
430
+ const table =
431
+ node.shapeType === 'database' ? DATABASE_PORT_EXIT :
432
+ (node.shapeType === 'ellipse' || node.shapeType === 'circle') ? CIRC_PORT_EXIT :
433
+ RECT_PORT_EXIT;
434
+ const xy = table[portKey];
435
+ return xy ? { x: xy[0], y: xy[1] } : null;
436
+ }
437
+
438
+ // ── UserObject attributes for semantic metadata + link ────────────────────────
439
+
440
+ function hasLdMetadata(n) {
441
+ return !!(n.nodeLink || n.description || n.kind || n.renderAs || (Array.isArray(n.evidence) && n.evidence.length));
442
+ }
443
+
444
+ function ldAttributes(n) {
445
+ const out = [];
446
+ if (n.nodeLink) out.push(` link="${xmlAttr(n.nodeLink)}"`);
447
+ if (n.description) out.push(` ld_description="${xmlAttr(n.description)}"`);
448
+ if (n.kind) out.push(` ld_kind="${xmlAttr(n.kind)}"`);
449
+ if (n.renderAs) out.push(` ld_renderAs="${xmlAttr(n.renderAs)}"`);
450
+ if (Array.isArray(n.evidence) && n.evidence.length) {
451
+ out.push(` ld_evidence="${xmlAttr(JSON.stringify(n.evidence))}"`);
452
+ }
453
+ return out.join('');
454
+ }
455
+
456
+ // ── Encoding helpers ──────────────────────────────────────────────────────────
457
+
458
+ function xmlAttr(s) {
459
+ return String(s == null ? '' : s)
460
+ .replace(/&/g, '&amp;')
461
+ .replace(/"/g, '&quot;')
462
+ .replace(/</g, '&lt;')
463
+ .replace(/>/g, '&gt;')
464
+ .replace(/\n/g, '&#10;')
465
+ .replace(/\r/g, '&#13;');
466
+ }
467
+
468
+ function num(v) {
469
+ if (!Number.isFinite(v)) return '0';
470
+ return Math.abs(v - Math.round(v)) < 1e-6 ? String(Math.round(v)) : v.toFixed(3);
471
+ }
472
+
473
+ function radToDeg(rad) {
474
+ return rad * 180 / Math.PI;
475
+ }
476
+
477
+ // drawio's style parser treats `;` as the property separator, which collides
478
+ // with the standard `data:image/png;base64,...` prefix. drawio accepts a
479
+ // comma-form (`data:image/png,base64,...`) as a workaround so the value can
480
+ // sit unescaped inside the style string.
481
+ function drawioImageValue(src) {
482
+ return src.replace(/^data:([^;,]+);base64,/i, 'data:$1,base64,');
483
+ }
484
+
485
+ function sanitiseFilename(s) {
486
+ return String(s || 'diagram').replace(/[^\w.\- ]+/g, '_').replace(/\s+/g, '_').slice(0, 80) || 'diagram';
487
+ }
488
+
489
+ function triggerDownload(content, filename) {
490
+ const blob = new Blob([content], { type: 'application/xml;charset=utf-8' });
491
+ const url = URL.createObjectURL(blob);
492
+ const a = document.createElement('a');
493
+ a.href = url;
494
+ a.download = filename;
495
+ document.body.appendChild(a);
496
+ a.click();
497
+ document.body.removeChild(a);
498
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
499
+ }
@@ -23,6 +23,7 @@ import { showToast } from './toast.js';
23
23
  import { t } from './t.js';
24
24
  import { initEvidenceMode, toggleEvidenceMode } from './evidence.js';
25
25
  import { customShapeIdFromTool, isCustomShapeTool } from './custom-shapes.js';
26
+ import { exportCurrentDiagramAsDrawio } from './drawio-export.js';
26
27
 
27
28
  const DIAGRAM_ID_COPY_FEEDBACK_MS = 1800;
28
29
 
@@ -195,6 +196,7 @@ document.getElementById('btnZoomReset').addEventListener('click', resetZoom);
195
196
 
196
197
  document.getElementById('btnDark').addEventListener('click', toggleDark);
197
198
  document.getElementById('btnDebug').addEventListener('click', toggleDebug);
199
+ document.getElementById('btnExportDrawio').addEventListener('click', exportCurrentDiagramAsDrawio);
198
200
  document.getElementById('btnSave').addEventListener('click', saveDiagram);
199
201
  document.getElementById('btnNewDiagram').addEventListener('click', newDiagram);
200
202
 
@@ -685,6 +685,19 @@
685
685
  class="hidden w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"
686
686
  ></div>
687
687
 
688
+ <button
689
+ id="btnExportDrawio"
690
+ class="tool-btn"
691
+ data-i18n-title="diagram.toolbar.export_drawio"
692
+ >
693
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
694
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
695
+ <polyline points="7 10 12 15 17 10"/>
696
+ <line x1="12" y1="15" x2="12" y2="3"/>
697
+ </svg>
698
+ </button>
699
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
700
+
688
701
  <button
689
702
  id="btnSave"
690
703
  disabled
@@ -499,6 +499,7 @@
499
499
  "diagram.toolbar.zoom_in": "Zoom +",
500
500
  "diagram.toolbar.fit": "Fit view",
501
501
  "diagram.toolbar.debug": "Debug overlay",
502
+ "diagram.toolbar.export_drawio": "Export as drawio (.drawio)",
502
503
  "diagram.toolbar.save": "Save",
503
504
 
504
505
  "diagram.sidebar.title": "Diagrams",
@@ -600,6 +601,9 @@
600
601
  "diagram.toast.confirm_delete": "Delete this diagram?",
601
602
  "diagram.toast.new_diagram_title": "New diagram",
602
603
  "diagram.toast.untitled": "Untitled",
604
+ "diagram.toast.drawio_exported": "Diagram exported as .drawio",
605
+ "diagram.toast.drawio_export_error": "drawio export failed",
606
+ "diagram.toast.drawio_label_rotation_dropped": "{count} label rotation(s) dropped — drawio cannot rotate labels independently from their shape",
603
607
  "diagram.toast.diagram_linked": "Diagram \"{title}\" created and linked",
604
608
 
605
609
  "shape_editor.show_in_diagram_label": "Show in diagram palette",
@@ -499,6 +499,7 @@
499
499
  "diagram.toolbar.zoom_in": "Zoom +",
500
500
  "diagram.toolbar.fit": "Ajuster la vue",
501
501
  "diagram.toolbar.debug": "Overlay debug",
502
+ "diagram.toolbar.export_drawio": "Exporter en drawio (.drawio)",
502
503
  "diagram.toolbar.save": "Enregistrer",
503
504
 
504
505
  "diagram.sidebar.title": "Diagrammes",
@@ -600,6 +601,9 @@
600
601
  "diagram.toast.confirm_delete": "Supprimer ce diagramme ?",
601
602
  "diagram.toast.new_diagram_title": "Nouveau diagramme",
602
603
  "diagram.toast.untitled": "Sans titre",
604
+ "diagram.toast.drawio_exported": "Diagramme exporté en .drawio",
605
+ "diagram.toast.drawio_export_error": "Échec de l'export drawio",
606
+ "diagram.toast.drawio_label_rotation_dropped": "{count} rotation(s) de label ignorée(s) — drawio ne permet pas de tourner un label indépendamment de sa forme",
603
607
  "diagram.toast.diagram_linked": "Diagramme \"{title}\" créé et lié",
604
608
 
605
609
  "shape_editor.show_in_diagram_label": "Afficher dans la palette du diagramme",
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "8.8.0",
4
- "description": "A CLI tool that serves a local Markdown documentation viewer",
3
+ "version": "8.10.0",
4
+ "description": "Local Markdown documentation hub with a built-in MCP server — coding agents create ADRs, draw diagrams and detect drift while you code.",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {
7
7
  "living-documentation": "dist/bin/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
11
- "README.md"
11
+ "images/",
12
+ "README.md",
13
+ "LICENSE"
12
14
  ],
13
15
  "scripts": {
14
16
  "build": "tsc && ts-node scripts/copy-assets.ts && chmod +x dist/bin/cli.js",
@@ -22,9 +24,21 @@
22
24
  },
23
25
  "keywords": [
24
26
  "documentation",
27
+ "living-documentation",
25
28
  "markdown",
26
29
  "viewer",
27
- "cli"
30
+ "cli",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "adr",
34
+ "architecture-decision-records",
35
+ "ai",
36
+ "claude",
37
+ "cursor",
38
+ "coding-agent",
39
+ "diagram",
40
+ "c4-model",
41
+ "drift-detection"
28
42
  ],
29
43
  "author": "Youssef MEDAGHRI-ALAOUI",
30
44
  "license": "AGPL-3.0",