@itsthecsguy/linework 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +164 -2
- package/assets/drawio/LICENSE +201 -0
- package/assets/drawio/VERSION +1 -0
- package/assets/drawio/favicon.ico +0 -0
- package/assets/drawio/images/1x1.png +0 -0
- package/assets/drawio/images/2x2.png +0 -0
- package/assets/drawio/images/3x3.png +0 -0
- package/assets/drawio/images/ajax-loader.gif +0 -0
- package/assets/drawio/images/android-chrome-512x512.png +0 -0
- package/assets/drawio/images/apple-touch-icon.png +0 -0
- package/assets/drawio/images/aui-wait.gif +0 -0
- package/assets/drawio/images/broken.png +0 -0
- package/assets/drawio/images/browserconfig.xml +9 -0
- package/assets/drawio/images/close.png +0 -0
- package/assets/drawio/images/cloud-connector-atlas.png +0 -0
- package/assets/drawio/images/collapsed.gif +0 -0
- package/assets/drawio/images/delete.png +0 -0
- package/assets/drawio/images/document-google-drive-icon-80.png +0 -0
- package/assets/drawio/images/draw-search.png +0 -0
- package/assets/drawio/images/drawio.svg +19 -0
- package/assets/drawio/images/drawlogo-color.svg +50 -0
- package/assets/drawio/images/drawlogo-gray.svg +18 -0
- package/assets/drawio/images/drawlogo-text-bottom.svg +75 -0
- package/assets/drawio/images/drawlogo.svg +18 -0
- package/assets/drawio/images/drawlogo128.png +0 -0
- package/assets/drawio/images/drawlogo144.png +0 -0
- package/assets/drawio/images/drawlogo256.png +0 -0
- package/assets/drawio/images/drawlogo48-gray.png +0 -0
- package/assets/drawio/images/drawlogo48.png +0 -0
- package/assets/drawio/images/drawlogo80.png +0 -0
- package/assets/drawio/images/droptarget.png +0 -0
- package/assets/drawio/images/edit.gif +0 -0
- package/assets/drawio/images/edit16.png +0 -0
- package/assets/drawio/images/edit32.png +0 -0
- package/assets/drawio/images/edit80.png +0 -0
- package/assets/drawio/images/embed-icon.png +0 -0
- package/assets/drawio/images/expanded.gif +0 -0
- package/assets/drawio/images/facebook.png +0 -0
- package/assets/drawio/images/favicon-16x16.png +0 -0
- package/assets/drawio/images/favicon-32x32.png +0 -0
- package/assets/drawio/images/file.png +0 -0
- package/assets/drawio/images/folder.png +0 -0
- package/assets/drawio/images/gadget-screenshot.png +0 -0
- package/assets/drawio/images/gadget-thumb.png +0 -0
- package/assets/drawio/images/github-logo-white.svg +6 -0
- package/assets/drawio/images/github-logo.svg +17 -0
- package/assets/drawio/images/gitlab-logo-white.svg +1 -0
- package/assets/drawio/images/gitlab-logo.svg +1 -0
- package/assets/drawio/images/glyphicons_blogger.png +0 -0
- package/assets/drawio/images/glyphicons_facebook.png +0 -0
- package/assets/drawio/images/glyphicons_github.png +0 -0
- package/assets/drawio/images/glyphicons_google.png +0 -0
- package/assets/drawio/images/glyphicons_star.png +0 -0
- package/assets/drawio/images/glyphicons_twitter.png +0 -0
- package/assets/drawio/images/glyphicons_youtube.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.svg +121 -0
- package/assets/drawio/images/google-drive-logo-white.svg +9 -0
- package/assets/drawio/images/google-drive-logo.svg +17 -0
- package/assets/drawio/images/google-share.png +0 -0
- package/assets/drawio/images/icon-192-maskable.png +0 -0
- package/assets/drawio/images/icon-192.png +0 -0
- package/assets/drawio/images/icon-512-maskable.png +0 -0
- package/assets/drawio/images/icon-512.png +0 -0
- package/assets/drawio/images/icon-search.svg +21 -0
- package/assets/drawio/images/img-hi-res.png +0 -0
- package/assets/drawio/images/img-lo-res.png +0 -0
- package/assets/drawio/images/list.svg +16 -0
- package/assets/drawio/images/locked.png +0 -0
- package/assets/drawio/images/log.png +0 -0
- package/assets/drawio/images/logo-confluence.png +0 -0
- package/assets/drawio/images/logo-flat-small.png +0 -0
- package/assets/drawio/images/logo-flat.png +0 -0
- package/assets/drawio/images/logo-jira.png +0 -0
- package/assets/drawio/images/logo-small.gif +0 -0
- package/assets/drawio/images/logo-white.png +0 -0
- package/assets/drawio/images/logo.png +0 -0
- package/assets/drawio/images/manifest.json +25 -0
- package/assets/drawio/images/move.png +0 -0
- package/assets/drawio/images/notion-logo-white.svg +1 -0
- package/assets/drawio/images/notion-logo.svg +1 -0
- package/assets/drawio/images/onedrive-connector-atlas.png +0 -0
- package/assets/drawio/images/onedrive-connector-atlas.svg +248 -0
- package/assets/drawio/images/onedrive-logo-white.svg +29 -0
- package/assets/drawio/images/onedrive-logo.svg +29 -0
- package/assets/drawio/images/osa_database.png +0 -0
- package/assets/drawio/images/osa_drive-harddisk.png +0 -0
- package/assets/drawio/images/plant-uml-atlas.png +0 -0
- package/assets/drawio/images/samepage-icon-color.svg +37 -0
- package/assets/drawio/images/save.png +0 -0
- package/assets/drawio/images/sidebar-android.png +0 -0
- package/assets/drawio/images/sidebar-archimate.png +0 -0
- package/assets/drawio/images/sidebar-arrows.png +0 -0
- package/assets/drawio/images/sidebar-arrows2.png +0 -0
- package/assets/drawio/images/sidebar-basic.png +0 -0
- package/assets/drawio/images/sidebar-c4.png +0 -0
- package/assets/drawio/images/sidebar-cabinets.png +0 -0
- package/assets/drawio/images/sidebar-cumulus.png +0 -0
- package/assets/drawio/images/sidebar-dfd.png +0 -0
- package/assets/drawio/images/sidebar-dynamics365.png +0 -0
- package/assets/drawio/images/sidebar-eip.png +0 -0
- package/assets/drawio/images/sidebar-er.png +0 -0
- package/assets/drawio/images/sidebar-floorplans.png +0 -0
- package/assets/drawio/images/sidebar-flowchart.png +0 -0
- package/assets/drawio/images/sidebar-gcp3.png +0 -0
- package/assets/drawio/images/sidebar-general.png +0 -0
- package/assets/drawio/images/sidebar-kubernetes.png +0 -0
- package/assets/drawio/images/sidebar-leanmapping.png +0 -0
- package/assets/drawio/images/sidebar-mockups.png +0 -0
- package/assets/drawio/images/sidebar-network.png +0 -0
- package/assets/drawio/images/sidebar-network2.png +0 -0
- package/assets/drawio/images/sidebar-openstack.png +0 -0
- package/assets/drawio/images/sidebar-sitemap.png +0 -0
- package/assets/drawio/images/sidebar-threatmodeling.png +0 -0
- package/assets/drawio/images/sidebar-uml.png +0 -0
- package/assets/drawio/images/sketch-logo128.png +0 -0
- package/assets/drawio/images/spin.gif +0 -0
- package/assets/drawio/images/sprites.png +0 -0
- package/assets/drawio/images/stop-flat-icon-80.png +0 -0
- package/assets/drawio/images/submenu.gif +0 -0
- package/assets/drawio/images/touch-submenu.png +0 -0
- package/assets/drawio/images/transparent.gif +0 -0
- package/assets/drawio/images/trello-logo-white-orange.svg +6 -0
- package/assets/drawio/images/trello-logo-white.svg +6 -0
- package/assets/drawio/images/trello-logo.svg +1 -0
- package/assets/drawio/images/tweet.png +0 -0
- package/assets/drawio/images/update16.png +0 -0
- package/assets/drawio/images/update32.png +0 -0
- package/assets/drawio/images/update80.png +0 -0
- package/assets/drawio/images/youtube.png +0 -0
- package/assets/drawio/img/clipart/Battery_0_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_100_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_50_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_75_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_allstates_128x128.png +0 -0
- package/assets/drawio/img/clipart/Bluetooth_128x128.png +0 -0
- package/assets/drawio/img/clipart/Earth_globe_128x128.png +0 -0
- package/assets/drawio/img/clipart/Empty_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Full_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Gear_128x128.png +0 -0
- package/assets/drawio/img/clipart/Keys_128x128.png +0 -0
- package/assets/drawio/img/clipart/Lock_128x128.png +0 -0
- package/assets/drawio/img/clipart/Mouse_Pointer_128x128.png +0 -0
- package/assets/drawio/img/clipart/Plug_128x128.png +0 -0
- package/assets/drawio/img/clipart/Ships_Wheel_128x128.png +0 -0
- package/assets/drawio/img/clipart/Star_128x128.png +0 -0
- package/assets/drawio/img/clipart/Tire_128x128.png +0 -0
- package/assets/drawio/index.html +92 -0
- package/assets/drawio/js/PostConfig.js +7 -0
- package/assets/drawio/js/PreConfig.js +13 -0
- package/assets/drawio/js/app.min.js +18466 -0
- package/assets/drawio/js/bootstrap.js +428 -0
- package/assets/drawio/js/diagramly/LibavoidRouting.js +4459 -0
- package/assets/drawio/js/export-init.js +21 -0
- package/assets/drawio/js/extensions.min.js +10478 -0
- package/assets/drawio/js/libavoid-js/LICENSE +505 -0
- package/assets/drawio/js/libavoid-js/libavoid-routing.js +1633 -0
- package/assets/drawio/js/libavoid-js/libavoid.min.js +59 -0
- package/assets/drawio/js/main.js +22 -0
- package/assets/drawio/js/rough/rough.min.js +1 -0
- package/assets/drawio/js/shapes-14-6-5.min.js +3457 -0
- package/assets/drawio/js/stencils.min.js +447 -0
- package/assets/drawio/mxgraph/css/common.css +147 -0
- package/assets/drawio/resources/dia.txt +2901 -0
- package/assets/drawio/styles/dark-default.xml +109 -0
- package/assets/drawio/styles/default-old.xml +109 -0
- package/assets/drawio/styles/default.xml +203 -0
- package/assets/drawio/styles/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- package/assets/drawio/styles/grapheditor.css +4501 -0
- package/assets/drawio/styles/high-contrast.css +128 -0
- package/assets/drawio/styles/thumb_horz.png +0 -0
- package/assets/drawio/styles/thumb_vertical.png +0 -0
- package/assets/fonts/ibm-plex-mono-LICENSE.txt +93 -0
- package/assets/fonts/ibm-plex-sans-LICENSE.txt +93 -0
- package/assets/fonts/kalam-400.woff2 +0 -0
- package/assets/fonts/kalam-700.woff2 +0 -0
- package/assets/fonts/kalam-LICENSE.txt +93 -0
- package/assets/fonts/plex-mono-400.woff2 +0 -0
- package/assets/fonts/plex-mono-600.woff2 +0 -0
- package/assets/fonts/plex-sans-400.woff2 +0 -0
- package/assets/fonts/plex-sans-600.woff2 +0 -0
- package/dist/api.js +181 -0
- package/dist/cli.js +262 -0
- package/dist/drawio/icons.js +121 -0
- package/dist/drawio/merge.js +309 -0
- package/dist/drawio/read.js +180 -0
- package/dist/drawio/show.js +501 -0
- package/dist/drawio/theme.js +425 -0
- package/dist/drawio/write.js +300 -0
- package/dist/edit/server.js +212 -0
- package/dist/git.js +46 -0
- package/dist/import/mermaid.js +569 -0
- package/dist/layout/layout.js +212 -0
- package/dist/layout/sequence.js +333 -0
- package/dist/layout/text.js +73 -0
- package/dist/lint/check.js +184 -0
- package/dist/lint/fixlabels.js +128 -0
- package/dist/mcp/server.js +239 -0
- package/dist/publish.js +121 -0
- package/dist/render/browser.js +210 -0
- package/dist/render/fonts.js +66 -0
- package/dist/report.js +36 -0
- package/dist/spec/model.js +53 -0
- package/dist/spec/parse.js +497 -0
- package/package.json +71 -4
- package/skills/linework/SKILL.md +214 -0
- package/skills/linework/examples/billing-er.drawio.svg +4 -0
- package/skills/linework/examples/billing-er.yaml +31 -0
- package/skills/linework/examples/checkout.drawio.svg +4 -0
- package/skills/linework/examples/checkout.yaml +28 -0
- package/skills/linework/examples/pay-sequence.drawio.svg +4 -0
- package/skills/linework/examples/pay-sequence.yaml +28 -0
- package/skills/linework/examples/payment-state.drawio.svg +4 -0
- package/skills/linework/examples/payment-state.yaml +22 -0
- package/skills/linework/examples/refund-flow.drawio.svg +4 -0
- package/skills/linework/examples/refund-flow.yaml +29 -0
- package/skills/linework/examples/serverless-aws.drawio.svg +4 -0
- package/skills/linework/examples/serverless-aws.yaml +23 -0
- package/skills/linework/references/google-docs.md +46 -0
- package/skills/linework/references/spec.md +312 -0
- package/skills/linework/references/style-guide.md +53 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const inter = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
2
|
+
const shrink = (b, d) => ({ x: b.x + d, y: b.y + d, w: Math.max(0, b.w - 2 * d), h: Math.max(0, b.h - 2 * d) });
|
|
3
|
+
const inside = (a, b, tol = 1) => a.x >= b.x - tol && a.y >= b.y - tol && a.x + a.w <= b.x + b.w + tol && a.y + a.h <= b.y + b.h + tol;
|
|
4
|
+
function segHitsBox(a, b, r) {
|
|
5
|
+
// Liang–Barsky clip
|
|
6
|
+
let t0 = 0, t1 = 1;
|
|
7
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
8
|
+
const edges = [[-dx, a.x - r.x], [dx, r.x + r.w - a.x], [-dy, a.y - r.y], [dy, r.y + r.h - a.y]];
|
|
9
|
+
for (const [p, q] of edges) {
|
|
10
|
+
if (p === 0) {
|
|
11
|
+
if (q < 0)
|
|
12
|
+
return false;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const t = q / p;
|
|
16
|
+
if (p < 0) {
|
|
17
|
+
if (t > t1)
|
|
18
|
+
return false;
|
|
19
|
+
if (t > t0)
|
|
20
|
+
t0 = t;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
if (t < t0)
|
|
24
|
+
return false;
|
|
25
|
+
if (t < t1)
|
|
26
|
+
t1 = t;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return t1 - t0 > 1e-6;
|
|
30
|
+
}
|
|
31
|
+
function segsCross(a, b, c, d) {
|
|
32
|
+
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
|
|
33
|
+
const near = (p, q) => Math.abs(p.x - q.x) < 1 && Math.abs(p.y - q.y) < 1;
|
|
34
|
+
if (near(a, c) || near(a, d) || near(b, c) || near(b, d))
|
|
35
|
+
return false;
|
|
36
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
37
|
+
}
|
|
38
|
+
export function check(cells) {
|
|
39
|
+
const findings = [];
|
|
40
|
+
const byId = new Map(cells.map((c) => [c.id, c]));
|
|
41
|
+
const name = (c) => (c.label ? `"${c.label.slice(0, 40)}"` : c.id);
|
|
42
|
+
// Sequence scaffolding (lifelines, frames, activation bars, guards) isn't an obstacle.
|
|
43
|
+
const scaffold = (c) => c.shape === 'umlLifeline' || c.shape === 'umlFrame' || ['bar', 'guard', 'frame'].includes(c.aivizType ?? '');
|
|
44
|
+
const nodes = cells.filter((c) => c.vertex && !c.container && !c.text && !scaffold(c));
|
|
45
|
+
const containers = cells.filter((c) => c.vertex && c.container);
|
|
46
|
+
const edges = cells.filter((c) => c.edge && c.points && c.points.length >= 2);
|
|
47
|
+
function ancestors(id) {
|
|
48
|
+
const s = new Set();
|
|
49
|
+
for (let p = id ? byId.get(id)?.parent : null; p; p = byId.get(p)?.parent ?? null)
|
|
50
|
+
s.add(p);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
for (let i = 0; i < nodes.length; i++)
|
|
54
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
55
|
+
const a = nodes[i], b = nodes[j];
|
|
56
|
+
if (ancestors(a.id).has(b.id) || ancestors(b.id).has(a.id))
|
|
57
|
+
continue;
|
|
58
|
+
if (inter(a.box, b.box) > 4)
|
|
59
|
+
findings.push({ level: 'error', rule: 'overlap', message: `${name(a)} and ${name(b)} overlap` });
|
|
60
|
+
}
|
|
61
|
+
for (const n of [...nodes, ...containers]) {
|
|
62
|
+
const p = n.parent ? byId.get(n.parent) : undefined;
|
|
63
|
+
if (p?.container && !inside(n.box, p.box, 2))
|
|
64
|
+
findings.push({ level: 'warning', rule: 'outside-group', message: `${name(n)} sticks out of its group ${name(p)}` });
|
|
65
|
+
}
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
const pts = e.points;
|
|
68
|
+
const skip = new Set([e.source, e.target, ...ancestors(e.source), ...ancestors(e.target)]);
|
|
69
|
+
for (const n of nodes) {
|
|
70
|
+
if (skip.has(n.id))
|
|
71
|
+
continue;
|
|
72
|
+
const r = shrink(n.box, 3);
|
|
73
|
+
for (let i = 0; i < pts.length - 1; i++)
|
|
74
|
+
if (segHitsBox(pts[i], pts[i + 1], r)) {
|
|
75
|
+
findings.push({ level: 'error', rule: 'edge-through-node', message: `edge ${edgeName(e, byId)} passes through ${name(n)}`, edge: e.id });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// A route that doubles back through its own source or target (stale waypoints after a move).
|
|
80
|
+
for (const endId of [e.source, e.target]) {
|
|
81
|
+
const n = endId ? byId.get(endId) : undefined;
|
|
82
|
+
if (!n || n.container)
|
|
83
|
+
continue;
|
|
84
|
+
const r = shrink(n.box, 4);
|
|
85
|
+
// Skip the segment that attaches to this node: on diamonds and cylinders it
|
|
86
|
+
// legitimately starts inside the bounding box.
|
|
87
|
+
const isSource = endId === e.source;
|
|
88
|
+
const back = pts.slice(0, -1).some((p, i) => {
|
|
89
|
+
if (isSource ? i === 0 : i === pts.length - 2)
|
|
90
|
+
return false;
|
|
91
|
+
const q = pts[i + 1];
|
|
92
|
+
const mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
93
|
+
return mid.x > r.x && mid.x < r.x + r.w && mid.y > r.y && mid.y < r.y + r.h;
|
|
94
|
+
});
|
|
95
|
+
if (back) {
|
|
96
|
+
findings.push({ level: 'error', rule: 'edge-through-endpoint', message: `edge ${edgeName(e, byId)} doubles back through ${name(n)}`, edge: e.id });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const n of nodes) {
|
|
102
|
+
if (!n.textBox || !n.label)
|
|
103
|
+
continue;
|
|
104
|
+
if (n.textBox.w > n.box.w + 2 || n.textBox.h > n.box.h + 2)
|
|
105
|
+
findings.push({ level: 'warning', rule: 'label-overflow', message: `label of ${name(n)} doesn't fit its shape; shorten it` });
|
|
106
|
+
}
|
|
107
|
+
const edgeLabels = edges.filter((e) => e.textBox && e.label);
|
|
108
|
+
const grow = (b, m) => ({ x: b.x - m, y: b.y - m, w: b.w + 2 * m, h: b.h + 2 * m });
|
|
109
|
+
for (const e of edgeLabels) {
|
|
110
|
+
// A label touching a box reads as part of it, so keep a few px clear.
|
|
111
|
+
for (const n of nodes)
|
|
112
|
+
if (inter(e.textBox, grow(n.box, 3)) > 4)
|
|
113
|
+
findings.push({ level: 'warning', rule: 'label-on-node', message: `label "${e.label}" on edge ${edgeName(e, byId)} covers ${name(n)}`, labelEdges: [e.id] });
|
|
114
|
+
// A label sitting on another edge, or on a group's border, hides part of that line.
|
|
115
|
+
const tb = grow(e.textBox, -2);
|
|
116
|
+
const lines = [
|
|
117
|
+
...edges.filter((o) => o !== e).map((o) => ({ what: `edge ${edgeName(o, byId)}`, pts: o.points, box: tb })),
|
|
118
|
+
...containers.filter((g) => !scaffold(g)).map((g) => ({
|
|
119
|
+
box: grow(e.textBox, 3),
|
|
120
|
+
what: `the border of ${name(g)}`,
|
|
121
|
+
pts: [
|
|
122
|
+
{ x: g.box.x, y: g.box.y },
|
|
123
|
+
{ x: g.box.x + g.box.w, y: g.box.y },
|
|
124
|
+
{ x: g.box.x + g.box.w, y: g.box.y + g.box.h },
|
|
125
|
+
{ x: g.box.x, y: g.box.y + g.box.h },
|
|
126
|
+
{ x: g.box.x, y: g.box.y },
|
|
127
|
+
],
|
|
128
|
+
})),
|
|
129
|
+
];
|
|
130
|
+
for (const o of lines) {
|
|
131
|
+
const hit = o.pts.some((p, k) => {
|
|
132
|
+
const q = o.pts[k + 1];
|
|
133
|
+
if (!q)
|
|
134
|
+
return false;
|
|
135
|
+
const x1 = Math.min(p.x, q.x), x2 = Math.max(p.x, q.x), y1 = Math.min(p.y, q.y), y2 = Math.max(p.y, q.y);
|
|
136
|
+
return x1 <= o.box.x + o.box.w && x2 >= o.box.x && y1 <= o.box.y + o.box.h && y2 >= o.box.y;
|
|
137
|
+
});
|
|
138
|
+
if (hit) {
|
|
139
|
+
findings.push({ level: 'warning', rule: 'label-on-edge', message: `label "${e.label}" on edge ${edgeName(e, byId)} sits on ${o.what}`, labelEdges: [e.id] });
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (let i = 0; i < edgeLabels.length; i++)
|
|
145
|
+
for (let j = i + 1; j < edgeLabels.length; j++)
|
|
146
|
+
if (inter(edgeLabels[i].textBox, edgeLabels[j].textBox) > 6)
|
|
147
|
+
findings.push({ level: 'warning', rule: 'label-collision', message: `edge labels "${edgeLabels[i].label}" and "${edgeLabels[j].label}" overlap`, labelEdges: [edgeLabels[j].id, edgeLabels[i].id] });
|
|
148
|
+
let crossings = 0;
|
|
149
|
+
for (let i = 0; i < edges.length; i++)
|
|
150
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
151
|
+
const a = edges[i].points, b = edges[j].points;
|
|
152
|
+
for (let s = 0; s < a.length - 1; s++)
|
|
153
|
+
for (let t = 0; t < b.length - 1; t++)
|
|
154
|
+
if (segsCross(a[s], a[s + 1], b[t], b[t + 1]))
|
|
155
|
+
crossings++;
|
|
156
|
+
}
|
|
157
|
+
const real = nodes.filter((n) => n.box.w > 30 || n.box.h > 30);
|
|
158
|
+
if (real.length > 20)
|
|
159
|
+
findings.push({ level: 'warning', rule: 'too-big', message: `${real.length} nodes; consider splitting into two diagrams` });
|
|
160
|
+
if (crossings > Math.max(3, edges.length / 3))
|
|
161
|
+
findings.push({ level: 'warning', rule: 'crossings', message: `${crossings} edge crossings; try direction: LR/TB, reordering nodes, or splitting` });
|
|
162
|
+
const all = cells.filter((c) => c.vertex).map((c) => c.box);
|
|
163
|
+
const minX = Math.min(...all.map((b) => b.x)), maxX = Math.max(...all.map((b) => b.x + b.w));
|
|
164
|
+
const minY = Math.min(...all.map((b) => b.y)), maxY = Math.max(...all.map((b) => b.y + b.h));
|
|
165
|
+
// A doc column is ~800px; past this, text shrinks below readable size when embedded.
|
|
166
|
+
if (maxX - minX > 1400)
|
|
167
|
+
findings.push({ level: 'warning', rule: 'too-wide', message: `${Math.round(maxX - minX)}px wide; embedded in a doc, its text shrinks to about ${Math.round((800 / (maxX - minX)) * 13)}px. Try direction: TB, fewer nodes per row, or splitting it` });
|
|
168
|
+
return {
|
|
169
|
+
findings: dedupe(findings),
|
|
170
|
+
stats: { nodes: nodes.filter((n) => !ancestors(n.id).size || !nodes.some((p) => ancestors(n.id).has(p.id))).length + cells.filter((c) => c.shape === 'umlLifeline').length, edges: edges.length, crossings, width: Math.round(maxX - minX), height: Math.round(maxY - minY) },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function edgeName(e, byId) {
|
|
174
|
+
const lab = (id) => (id ? byId.get(id)?.label || id : '?');
|
|
175
|
+
return `${lab(e.source)} -> ${lab(e.target)}`;
|
|
176
|
+
}
|
|
177
|
+
function dedupe(f) {
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
return f.filter((x) => (seen.has(x.message) ? false : (seen.add(x.message), true)));
|
|
180
|
+
}
|
|
181
|
+
/** Edge cell ids whose routes are broken in a way rerouting fixes. */
|
|
182
|
+
export function brokenEdges(res) {
|
|
183
|
+
return [...new Set(res.findings.filter((f) => f.edge).map((f) => f.edge))];
|
|
184
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Slide edge labels along their edges until they stop colliding with other labels
|
|
2
|
+
// or sitting on nodes. Only label positions change; routes and nodes stay put.
|
|
3
|
+
// Spots in the middle of a straight run come first, so a label never ends up
|
|
4
|
+
// wedged into a bend when there's a clear stretch of line for it.
|
|
5
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
6
|
+
import { check } from './check.js';
|
|
7
|
+
const OFFSETS = [-0.35, 0.35, -0.65, 0.65, -0.15, 0.15, -0.85, 0.85];
|
|
8
|
+
const LABEL_RULES = new Set(['label-collision', 'label-on-node', 'label-on-edge']);
|
|
9
|
+
function edgeGeometry(doc, id) {
|
|
10
|
+
const objs = doc.getElementsByTagName('object');
|
|
11
|
+
for (let i = 0; i < objs.length; i++) {
|
|
12
|
+
const o = objs[i];
|
|
13
|
+
if (o.getAttribute('id') !== id)
|
|
14
|
+
continue;
|
|
15
|
+
return o.getElementsByTagName('mxGeometry')[0] ?? undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Label positions (draw.io's -1..1 along the path) on straight runs, roomiest run first. */
|
|
20
|
+
export function runSpots(pts, label) {
|
|
21
|
+
const segs = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
24
|
+
const a = pts[i], b = pts[i + 1];
|
|
25
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
26
|
+
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
27
|
+
segs.push({ start: total, len, need: (vertical ? label.h : label.w) / 2 + 10 });
|
|
28
|
+
total += len;
|
|
29
|
+
}
|
|
30
|
+
if (!total)
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const sg of segs.filter((x) => x.len >= 2 * x.need).sort((x, y) => y.len - x.len))
|
|
34
|
+
for (const t of [0.5, 0.3, 0.7, 0.15, 0.85]) {
|
|
35
|
+
const at = sg.start + Math.min(sg.len - sg.need, Math.max(sg.need, t * sg.len));
|
|
36
|
+
const f = (at / total) * 2 - 1;
|
|
37
|
+
if (!out.some((o) => Math.abs(o - f) * total < 16))
|
|
38
|
+
out.push(f);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function labelEdges(findings) {
|
|
43
|
+
return [...new Set(findings.filter((f) => LABEL_RULES.has(f.rule) && f.labelEdges).flatMap((f) => f.labelEdges))];
|
|
44
|
+
}
|
|
45
|
+
export async function fixLabels(b, xml, maxRounds = 6) {
|
|
46
|
+
let cur = xml;
|
|
47
|
+
let score = check(await b.inspect(cur)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
48
|
+
let moved = 0;
|
|
49
|
+
for (let round = 0; round < maxRounds && score > 0; round++) {
|
|
50
|
+
const cells = await b.inspect(cur);
|
|
51
|
+
const res = check(cells);
|
|
52
|
+
const targets = labelEdges(res.findings);
|
|
53
|
+
let improved = false;
|
|
54
|
+
for (const id of targets) {
|
|
55
|
+
const doc = new DOMParser().parseFromString(cur, 'text/xml');
|
|
56
|
+
const g = edgeGeometry(doc, id);
|
|
57
|
+
if (!g)
|
|
58
|
+
continue;
|
|
59
|
+
const base = Number(g.getAttribute('x') ?? 0);
|
|
60
|
+
const cell = cells.find((c) => c.id === id);
|
|
61
|
+
const spots = cell?.points && cell.textBox ? runSpots(cell.points, cell.textBox) : [];
|
|
62
|
+
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))];
|
|
63
|
+
let best;
|
|
64
|
+
for (const x of tries) {
|
|
65
|
+
g.setAttribute('x', x.toFixed(3));
|
|
66
|
+
const candidate = new XMLSerializer().serializeToString(doc);
|
|
67
|
+
const s = check(await b.inspect(candidate)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
68
|
+
if (s < (best?.score ?? score))
|
|
69
|
+
best = { xml: candidate, score: s };
|
|
70
|
+
if (s === 0)
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (best) {
|
|
74
|
+
cur = best.xml;
|
|
75
|
+
score = best.score;
|
|
76
|
+
moved++;
|
|
77
|
+
improved = true;
|
|
78
|
+
if (score === 0)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!improved)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
cur = await matchLabelBackgrounds(b, cur);
|
|
86
|
+
return { xml: cur, moved };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A label's backing has to match what it sits on: the fill of the smallest group
|
|
90
|
+
* under it, or the canvas. Run after labels move, since a move can carry a label
|
|
91
|
+
* from the canvas onto a group or back.
|
|
92
|
+
*/
|
|
93
|
+
export async function matchLabelBackgrounds(b, xml) {
|
|
94
|
+
const cells = await b.inspect(xml);
|
|
95
|
+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
96
|
+
const canvas = doc.getElementsByTagName('mxGraphModel')[0]?.getAttribute('background') ?? '#FFFFFF';
|
|
97
|
+
const styleOf = new Map();
|
|
98
|
+
const objs = doc.getElementsByTagName('object');
|
|
99
|
+
for (let i = 0; i < objs.length; i++) {
|
|
100
|
+
const cell = objs[i].getElementsByTagName('mxCell')[0];
|
|
101
|
+
if (cell)
|
|
102
|
+
styleOf.set(objs[i].getAttribute('id') ?? '', cell);
|
|
103
|
+
}
|
|
104
|
+
const fillOf = (id) => /(?:^|;)fillColor=([^;]+)/.exec(styleOf.get(id)?.getAttribute('style') ?? '')?.[1];
|
|
105
|
+
const groups = cells.filter((c) => c.aivizType === 'group');
|
|
106
|
+
let changed = false;
|
|
107
|
+
for (const e of cells.filter((c) => c.edge && c.textBox && c.label)) {
|
|
108
|
+
const el = styleOf.get(e.id);
|
|
109
|
+
const style = el?.getAttribute('style') ?? '';
|
|
110
|
+
if (!el || !/labelBackgroundColor=/.test(style))
|
|
111
|
+
continue;
|
|
112
|
+
const cx = e.textBox.x + e.textBox.w / 2, cy = e.textBox.y + e.textBox.h / 2;
|
|
113
|
+
let bg = canvas, area = Infinity;
|
|
114
|
+
for (const g of groups) {
|
|
115
|
+
const r = g.box, f = fillOf(g.id);
|
|
116
|
+
if (cx < r.x || cx > r.x + r.w || cy < r.y || cy > r.y + r.h || !f || f === 'none' || r.w * r.h >= area)
|
|
117
|
+
continue;
|
|
118
|
+
bg = f;
|
|
119
|
+
area = r.w * r.h;
|
|
120
|
+
}
|
|
121
|
+
const next = style.replace(/labelBackgroundColor=[^;]*/, `labelBackgroundColor=${bg}`);
|
|
122
|
+
if (next !== style) {
|
|
123
|
+
el.setAttribute('style', next);
|
|
124
|
+
changed = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return changed ? new XMLSerializer().serializeToString(doc) : xml;
|
|
128
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// `linework mcp`: the same operations as the CLI, over MCP stdio, for chat apps
|
|
2
|
+
// and harnesses without a shell. Tools return text the model can act on, plus a
|
|
3
|
+
// PNG preview so models that see images can check their work.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import * as z from 'zod';
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
9
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
10
|
+
import { build, checkFile, listIcons, show, tidy } from '../api.js';
|
|
11
|
+
import { readDrawio } from '../drawio/read.js';
|
|
12
|
+
import { DrawioBrowser } from '../render/browser.js';
|
|
13
|
+
import { SpecError } from '../spec/parse.js';
|
|
14
|
+
import { buildLines, checkLines } from '../report.js';
|
|
15
|
+
import { PROVIDERS } from '../drawio/icons.js';
|
|
16
|
+
const INSTRUCTIONS = `linework draws software diagrams (architecture, flow, state, sequence, ER) as .drawio.svg files. They render in Markdown, HTML and GitHub like any SVG, and people can open them in draw.io and edit them by hand.
|
|
17
|
+
|
|
18
|
+
You write a small YAML spec; linework does layout and styling. Never write coordinates, SVG or draw.io XML.
|
|
19
|
+
- Call linework_guide once before your first diagram. It has the spec format and the style rules.
|
|
20
|
+
- build_diagram writes the file and returns lint results and a PNG preview. Fix every check error, look at the preview, rebuild until it reads well.
|
|
21
|
+
- To change an existing diagram, call show_diagram first, edit the YAML it returns (keep its "# linework-base:" line), then build_diagram to the same path. Hand edits made in draw.io survive.
|
|
22
|
+
- Never pass relayout: true unless the user asks; it throws away their hand-made layout.
|
|
23
|
+
- Embed the .drawio.svg in docs, not the PNG.`;
|
|
24
|
+
const PKG = (() => {
|
|
25
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
for (let i = 0; i < 4; i++) {
|
|
27
|
+
if (fs.existsSync(path.join(dir, 'skills', 'linework', 'SKILL.md')))
|
|
28
|
+
return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
throw new Error('linework: bundled skill not found. Reinstall linework.');
|
|
32
|
+
})();
|
|
33
|
+
const TOOL_NAMES = `In this MCP server the CLI commands in the guide are tools: build -> build_diagram, show -> show_diagram, check -> check_diagram, render -> render_diagram, tidy -> tidy_diagram, import -> import_mermaid, icons -> list_icons, edit -> edit_diagram, publish -> publish_diagram. Pass the spec text to build_diagram directly instead of writing a file.\n\n`;
|
|
34
|
+
export function createServer(opts) {
|
|
35
|
+
let browser;
|
|
36
|
+
const getBrowser = () => (browser ??= DrawioBrowser.launch({ scale: 2 }));
|
|
37
|
+
// One page does all the work, so calls run one at a time.
|
|
38
|
+
let queue = Promise.resolve();
|
|
39
|
+
const serial = (fn) => {
|
|
40
|
+
const next = queue.then(fn, fn);
|
|
41
|
+
queue = next.catch(() => { });
|
|
42
|
+
return next;
|
|
43
|
+
};
|
|
44
|
+
const editors = new Map();
|
|
45
|
+
const resolvePath = (p) => (path.isAbsolute(p) ? p : path.resolve(opts.root, p));
|
|
46
|
+
const text = (t) => ({ type: 'text', text: t });
|
|
47
|
+
const wrap = (fn) => async () => {
|
|
48
|
+
try {
|
|
49
|
+
return await serial(fn);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e instanceof SpecError)
|
|
53
|
+
return { isError: true, content: [text(['spec error:', ...e.issues.map((i) => ` ${i.path ? i.path + ': ' : ''}${i.message}`)].join('\n'))] };
|
|
54
|
+
return { isError: true, content: [text(`error: ${e.message}`)] };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const preview = async (file) => {
|
|
58
|
+
const b = await getBrowser();
|
|
59
|
+
const png = await b.renderPng(readDrawio(fs.readFileSync(file, 'utf8')).xml);
|
|
60
|
+
return { type: 'image', data: png.toString('base64'), mimeType: 'image/png' };
|
|
61
|
+
};
|
|
62
|
+
const server = new McpServer({ name: 'linework', version: version() }, { instructions: INSTRUCTIONS, capabilities: { tools: {} } });
|
|
63
|
+
const pathArg = z.string().describe(`Diagram file, e.g. docs/diagrams/checkout.drawio.svg. Relative paths resolve against ${opts.root}.`);
|
|
64
|
+
server.registerTool('linework_guide', {
|
|
65
|
+
title: 'How to write linework diagrams',
|
|
66
|
+
description: 'Returns the linework guide: diagram types, the YAML spec format with examples, and the style rules. Read it before your first diagram.',
|
|
67
|
+
inputSchema: z.object({
|
|
68
|
+
topic: z
|
|
69
|
+
.enum(['overview', 'spec', 'style', 'google-docs'])
|
|
70
|
+
.default('overview')
|
|
71
|
+
.describe('overview: workflow and cheat sheet. spec: the full spec reference. style: what makes a good diagram. google-docs: getting diagrams into Google Docs.'),
|
|
72
|
+
}),
|
|
73
|
+
annotations: { readOnlyHint: true },
|
|
74
|
+
}, async ({ topic }) => {
|
|
75
|
+
const file = { overview: 'SKILL.md', spec: 'references/spec.md', style: 'references/style-guide.md', 'google-docs': 'references/google-docs.md' }[topic];
|
|
76
|
+
const body = fs.readFileSync(path.join(PKG, 'skills', 'linework', file), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
77
|
+
return { content: [text(TOOL_NAMES + body)] };
|
|
78
|
+
});
|
|
79
|
+
server.registerTool('build_diagram', {
|
|
80
|
+
title: 'Build a diagram',
|
|
81
|
+
description: 'Lay out a YAML spec and write it as a .drawio.svg. If the file exists and someone edited it by hand, the spec is merged in and their layout is kept. Returns what changed, lint results and a PNG preview.',
|
|
82
|
+
inputSchema: z.object({
|
|
83
|
+
spec: z.string().describe('The YAML spec. See linework_guide.'),
|
|
84
|
+
path: pathArg,
|
|
85
|
+
relayout: z.boolean().default(false).describe('Throw away the existing layout, hand edits included. Only when the user asks.'),
|
|
86
|
+
preview: z.boolean().default(true).describe('Return a PNG preview.'),
|
|
87
|
+
}),
|
|
88
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
89
|
+
}, (args) => wrap(async () => {
|
|
90
|
+
const out = resolvePath(args.path);
|
|
91
|
+
const res = await build(args.spec, { out, relayout: args.relayout, browser: await getBrowser() });
|
|
92
|
+
const content = [text(buildLines(res).join('\n'))];
|
|
93
|
+
if (args.preview)
|
|
94
|
+
content.push(await preview(out));
|
|
95
|
+
return { content };
|
|
96
|
+
})());
|
|
97
|
+
server.registerTool('show_diagram', {
|
|
98
|
+
title: 'Show a diagram as a spec',
|
|
99
|
+
description: 'Returns the YAML spec of an existing .drawio.svg, including edits people made by hand in draw.io. Edit this output and pass it to build_diagram to change the diagram.',
|
|
100
|
+
inputSchema: z.object({ path: pathArg }),
|
|
101
|
+
annotations: { readOnlyHint: true },
|
|
102
|
+
}, (args) => wrap(async () => {
|
|
103
|
+
const res = show(resolvePath(args.path));
|
|
104
|
+
const notes = res.notes.map((n) => `note: ${n}`);
|
|
105
|
+
if (res.edits.length)
|
|
106
|
+
notes.push(`note: edited by hand since the last build. build_diagram keeps those positions and merges your changes in.`);
|
|
107
|
+
return { content: [text(res.yaml + (notes.length ? '\n' + notes.join('\n') : ''))] };
|
|
108
|
+
})());
|
|
109
|
+
server.registerTool('check_diagram', {
|
|
110
|
+
title: 'Lint a diagram',
|
|
111
|
+
description: 'Lints the layout of a .drawio.svg: overlapping shapes, edges through nodes, labels that collide or overflow.',
|
|
112
|
+
inputSchema: z.object({ path: pathArg }),
|
|
113
|
+
annotations: { readOnlyHint: true },
|
|
114
|
+
}, (args) => wrap(async () => ({ content: [text(checkLines(await checkFile(resolvePath(args.path), await getBrowser())).join('\n'))] }))());
|
|
115
|
+
server.registerTool('render_diagram', {
|
|
116
|
+
title: 'Render a diagram to PNG',
|
|
117
|
+
description: 'Returns a PNG of a .drawio.svg so you can look at it. Optionally also writes the PNG to a file, e.g. for Google Docs or slides.',
|
|
118
|
+
inputSchema: z.object({ path: pathArg, out: z.string().optional().describe('Also write the PNG here.') }),
|
|
119
|
+
annotations: { readOnlyHint: false },
|
|
120
|
+
}, (args) => wrap(async () => {
|
|
121
|
+
const img = await preview(resolvePath(args.path));
|
|
122
|
+
if (args.out) {
|
|
123
|
+
const out = resolvePath(args.out);
|
|
124
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
125
|
+
fs.writeFileSync(out, Buffer.from(img.data, 'base64'));
|
|
126
|
+
}
|
|
127
|
+
return { content: [img, ...(args.out ? [text(`wrote ${resolvePath(args.out)}`)] : [])] };
|
|
128
|
+
})());
|
|
129
|
+
server.registerTool('tidy_diagram', {
|
|
130
|
+
title: 'Reroute broken edges',
|
|
131
|
+
description: 'Reroutes edges that pass through shapes, e.g. after someone moved shapes by hand. Nothing else in the file changes.',
|
|
132
|
+
inputSchema: z.object({ path: pathArg }),
|
|
133
|
+
annotations: { idempotentHint: true },
|
|
134
|
+
}, (args) => wrap(async () => {
|
|
135
|
+
const file = resolvePath(args.path);
|
|
136
|
+
const res = await tidy(file, await getBrowser());
|
|
137
|
+
return { content: [text([res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix', ...checkLines(res.check)].join('\n'))] };
|
|
138
|
+
})());
|
|
139
|
+
server.registerTool('import_mermaid', {
|
|
140
|
+
title: 'Convert Mermaid to an linework spec',
|
|
141
|
+
description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to an linework spec. Returns the spec; review the guessed node kinds, then build it. With path, builds it straight away.',
|
|
142
|
+
inputSchema: z.object({ source: z.string().describe('Mermaid source, without the ``` fence.'), path: pathArg.optional() }),
|
|
143
|
+
}, (args) => wrap(async () => {
|
|
144
|
+
const { importMermaid } = await import('../import/mermaid.js');
|
|
145
|
+
const { toYaml } = await import('../drawio/show.js');
|
|
146
|
+
const res = importMermaid(args.source);
|
|
147
|
+
const yaml = toYaml(res.diagram, 'imported from Mermaid');
|
|
148
|
+
const lines = [yaml, ...res.warnings.map((w) => `import: ${w}`)];
|
|
149
|
+
const content = [];
|
|
150
|
+
if (args.path) {
|
|
151
|
+
const out = resolvePath(args.path);
|
|
152
|
+
const built = await build(yaml, { out, browser: await getBrowser() });
|
|
153
|
+
lines.push('', ...buildLines(built));
|
|
154
|
+
content.push(text(lines.join('\n')), await preview(out));
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
content.push(text(lines.join('\n')));
|
|
158
|
+
return { content };
|
|
159
|
+
})());
|
|
160
|
+
server.registerTool('list_icons', {
|
|
161
|
+
title: 'Search cloud icons',
|
|
162
|
+
description: 'Lists icon names for architecture nodes (icon: aws.lambda). Pass a filter word to search, e.g. provider aws, filter "queue".',
|
|
163
|
+
inputSchema: z.object({ provider: z.enum(Object.keys(PROVIDERS)), filter: z.string().optional() }),
|
|
164
|
+
annotations: { readOnlyHint: true },
|
|
165
|
+
}, (args) => wrap(async () => {
|
|
166
|
+
const names = await listIcons(args.provider, args.filter, await getBrowser());
|
|
167
|
+
return { content: [text(names.map((n) => `${args.provider}.${n}`).join('\n') || 'no matches')] };
|
|
168
|
+
})());
|
|
169
|
+
server.registerTool('edit_diagram', {
|
|
170
|
+
title: 'Open a diagram in draw.io',
|
|
171
|
+
description: "Opens the diagram in the bundled draw.io editor in the user's browser, served locally. When they save, the file updates. When they say they're done, call show_diagram to pick up their changes.",
|
|
172
|
+
inputSchema: z.object({ path: pathArg, open: z.boolean().default(true).describe('Open the browser. False just returns the URL.') }),
|
|
173
|
+
}, (args) => wrap(async () => {
|
|
174
|
+
const { startEditor, openInBrowser } = await import('../edit/server.js');
|
|
175
|
+
const file = resolvePath(args.path);
|
|
176
|
+
const existing = editors.get(file);
|
|
177
|
+
if (existing) {
|
|
178
|
+
if (args.open)
|
|
179
|
+
openInBrowser(existing.url);
|
|
180
|
+
return { content: [text(`already open at ${existing.url}`)] };
|
|
181
|
+
}
|
|
182
|
+
const session = await startEditor(file);
|
|
183
|
+
editors.set(file, session);
|
|
184
|
+
void session.done.then(() => editors.delete(file));
|
|
185
|
+
const opened = args.open && openInBrowser(session.url);
|
|
186
|
+
return { content: [text(`${opened ? 'Opened' : 'Open'} ${session.url} to edit ${file}. Saving in the editor (Ctrl/Cmd+S) writes the file.`)] };
|
|
187
|
+
})());
|
|
188
|
+
server.registerTool('publish_diagram', {
|
|
189
|
+
title: 'Publish diagrams to Google Drive',
|
|
190
|
+
description: "Copies diagrams into the user's Google Drive folder (Google Drive for desktop) at a fixed path, so draw.io's Google Docs add-on can insert them and refresh them. pull: true copies back a version someone edited from Docs. See linework_guide topic google-docs.",
|
|
191
|
+
inputSchema: z.object({
|
|
192
|
+
paths: z.array(z.string()).min(1).describe('Diagram files to publish.'),
|
|
193
|
+
to: z.string().optional().describe('Publish into this synced folder instead of the detected Google Drive folder.'),
|
|
194
|
+
pull: z.boolean().default(false).describe('Copy the Drive versions back over the local files.'),
|
|
195
|
+
}),
|
|
196
|
+
}, (args) => wrap(async () => {
|
|
197
|
+
const { publish } = await import('../publish.js');
|
|
198
|
+
const items = publish(args.paths.map(resolvePath), { to: args.to && resolvePath(args.to), pull: args.pull, cwd: opts.root });
|
|
199
|
+
const lines = items.map((i) => i.action === 'conflict'
|
|
200
|
+
? `skipped ${i.file}: the Drive copy was edited after the last publish. Ask the user whether to pull it (pull: true) or overwrite it.`
|
|
201
|
+
: `${i.action} ${i.action === 'pulled' ? `${i.dest} -> ${i.file}` : `${i.file} -> ${i.dest}`}`);
|
|
202
|
+
if (items.some((i) => i.action === 'published'))
|
|
203
|
+
lines.push('', 'Tell the user: in Google Docs, Extensions > draw.io > Insert diagrams to add it, or Extensions > draw.io > Update diagrams if it is already in the doc.');
|
|
204
|
+
return { content: [text(lines.join('\n'))] };
|
|
205
|
+
})());
|
|
206
|
+
const dispose = async () => {
|
|
207
|
+
for (const e of editors.values())
|
|
208
|
+
await e.close();
|
|
209
|
+
if (browser)
|
|
210
|
+
await (await browser.catch(() => undefined))?.close();
|
|
211
|
+
};
|
|
212
|
+
return { server, dispose };
|
|
213
|
+
}
|
|
214
|
+
function version() {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')).version;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return '0.0.0';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function runStdio(opts) {
|
|
223
|
+
let disposers = [];
|
|
224
|
+
serveStdio(() => {
|
|
225
|
+
const { server, dispose } = createServer(opts);
|
|
226
|
+
disposers.push(dispose);
|
|
227
|
+
return server;
|
|
228
|
+
});
|
|
229
|
+
const shutdown = async () => {
|
|
230
|
+
const d = disposers;
|
|
231
|
+
disposers = [];
|
|
232
|
+
await Promise.allSettled(d.map((f) => f()));
|
|
233
|
+
process.exit(0);
|
|
234
|
+
};
|
|
235
|
+
process.stdin.on('end', shutdown);
|
|
236
|
+
process.stdin.on('close', shutdown);
|
|
237
|
+
process.on('SIGINT', shutdown);
|
|
238
|
+
process.on('SIGTERM', shutdown);
|
|
239
|
+
}
|