@itsthecsguy/linework 0.1.4 → 0.2.1

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.
@@ -175,6 +175,23 @@ export function sceneFromLayout(d, l) {
175
175
  vertices.push(...nodeChildren(d, n, box));
176
176
  }
177
177
  const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
178
+ const isDecision = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'diamond'; };
179
+ // How far an edge shares its first run with a sibling leaving the same point.
180
+ const sharedStem = (id, from) => {
181
+ const mine = l.edges.get(id).points;
182
+ let shared = 0;
183
+ for (const o of d.edges) {
184
+ const pts = o.id !== id && o.from === from && o.to !== from ? l.edges.get(o.id)?.points : undefined;
185
+ if (!pts || pts.length < 2 || mine.length < 2 || Math.hypot(pts[0].x - mine[0].x, pts[0].y - mine[0].y) > 1)
186
+ continue;
187
+ const dir = (p) => [Math.sign(Math.round(p[1].x - p[0].x)), Math.sign(Math.round(p[1].y - p[0].y))].join();
188
+ if (dir(pts) !== dir(mine))
189
+ continue;
190
+ const len = (p) => Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y);
191
+ shared = Math.max(shared, Math.min(len(pts), len(mine)));
192
+ }
193
+ return shared;
194
+ };
178
195
  const edges = d.edges.map((e) => {
179
196
  const le = l.edges.get(e.id);
180
197
  const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
@@ -197,7 +214,7 @@ export function sceneFromLayout(d, l) {
197
214
  ...base,
198
215
  style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
199
216
  points: le.points.slice(1, -1),
200
- labelX: le.label && e.label ? (labelSpot(le.points, le.label, l.direction) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
217
+ labelX: le.label && e.label ? ((isDecision(e.from) ? branchSpot(le.points, le.label, l.direction, sharedStem(e.id, e.from)) : undefined) ?? labelSpot(le.points, le.label, l.direction, [...l.nodes].filter(([id]) => id !== e.from && id !== e.to).map(([, b]) => b)) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
201
218
  };
202
219
  });
203
220
  alignLabels(d, l, edges);
@@ -334,7 +351,7 @@ export function toMxfile(d, l) {
334
351
  * source node, which reads as a caption of that node. Returns draw.io's -1..1
335
352
  * fraction, or undefined when no run is long enough.
336
353
  */
337
- function labelSpot(pts, label, dir) {
354
+ export function labelSpot(pts, label, dir, avoid = []) {
338
355
  let total = 0;
339
356
  let best;
340
357
  // Runs along the flow belong to one edge; runs across it are often shared
@@ -348,10 +365,57 @@ function labelSpot(pts, label, dir) {
348
365
  const along = dir ? vertical === flowVertical : true;
349
366
  const better = !best || (along && !best.along) || (along === best.along && room > best.room);
350
367
  if (room >= 0 && better)
351
- best = { room, at: total + len / 2, along };
368
+ best = { room, at: total + len / 2, along, a, b, start: total, len };
352
369
  total += len;
353
370
  }
354
- return best && total ? (best.at / total) * 2 - 1 : undefined;
371
+ if (!best || !total)
372
+ return undefined;
373
+ // Halfway along a long run can be right beside a box the edge only passes, where
374
+ // the label reads as that box's caption. Slide along the run to the nearest
375
+ // spot past the box, with a little air; stay in the middle when there is none.
376
+ const { a, b, start, len } = best;
377
+ const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
378
+ const half = (vertical ? label.h : label.w) / 2 + 10;
379
+ // Beside: level with the box along the run, and within 12px of it across the run.
380
+ const beside = (at, air) => {
381
+ const t = (at - start) / len, cx = a.x + t * (b.x - a.x), cy = a.y + t * (b.y - a.y);
382
+ const px = vertical ? 12 : air, py = vertical ? air : 12;
383
+ const r = { x: cx - label.w / 2 - px, y: cy - label.h / 2 - py, w: label.w + 2 * px, h: label.h + 2 * py };
384
+ return avoid.some((o) => r.x < o.x + o.w && r.x + r.w > o.x && r.y < o.y + o.h && r.y + r.h > o.y);
385
+ };
386
+ // Level by a few px or more: a label that only grazes the box's end is fine where it is.
387
+ if (beside(best.at, -4))
388
+ for (let step = 4; step <= len / 2 - half; step += 4)
389
+ for (const at of [best.at - step, best.at + step])
390
+ if (!beside(at, 4))
391
+ return (at / total) * 2 - 1;
392
+ return (best.at / total) * 2 - 1;
393
+ }
394
+ /**
395
+ * A branch label ("yes", "no") belongs to its decision, so it sits where the
396
+ * branch leaves the diamond, not halfway to the next step: on the run out of the
397
+ * diamond's point when that has room, otherwise at the start of the first run
398
+ * along the flow, or of a long arm that forks off a short stem. `stem` is how much of the first run a sibling branch shares;
399
+ * the label starts past it, so it doesn't cover the place where they part.
400
+ */
401
+ export function branchSpot(pts, label, dir, stem = 0) {
402
+ const flowVertical = dir === 'TB' || dir === 'BT';
403
+ const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
404
+ const total = lens.reduce((x, y) => x + y, 0);
405
+ let before = 0;
406
+ for (let i = 0; i < lens.length; i++) {
407
+ const a = pts[i], b = pts[i + 1], len = lens[i];
408
+ const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
409
+ const size = vertical ? label.h : label.w;
410
+ const lead = i === 0 && stem > 0 ? stem + 6 : 10;
411
+ // A long arm across the flow, straight after the stem out of the point, is
412
+ // nearer the diamond than the drop at its far end.
413
+ const arm = i === 1 && len >= 96 && lens[0] <= 24;
414
+ if ((i === 0 || arm || vertical === flowVertical) && len >= lead + size + 10)
415
+ return ((before + lead + size / 2) / total) * 2 - 1;
416
+ before += len;
417
+ }
418
+ return undefined;
355
419
  }
356
420
  // draw.io positions edge labels by fraction along the path, -1..1.
357
421
  // Project ELK's label centre onto the route and convert.
@@ -156,9 +156,16 @@ function nodeSizes(d, tm, fonts) {
156
156
  continue;
157
157
  }
158
158
  if (shape === 'diamond') {
159
- const t = textOf(n, fonts.maxText * 0.7);
159
+ // A diamond holds little text for its size. Past two lines, wrap wider:
160
+ // three lines in a wider diamond take less room than four in a tall one.
161
+ let t = textOf(n, fonts.maxText * 0.7);
162
+ if (t.lines > 2)
163
+ t = textOf(n, fonts.maxText);
160
164
  const w = snap(Math.max(128, t.width * 1.5 + 40));
161
- sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.5, t.lines * fonts.line * 2 + 24)) });
165
+ // The text takes about 0.6 of the width, which leaves it 0.4 of the height.
166
+ // Only that tall, and no taller: a diamond at half its width for a two-line
167
+ // question is the biggest thing in the flow, bigger than any step.
168
+ sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.36, t.lines * fonts.line * 2.5 + 24)) });
162
169
  continue;
163
170
  }
164
171
  const extra = shapeExtra(shape, !!n.icon);
@@ -274,6 +281,45 @@ async function run(d, dir, sizes, labels, groupMin, groupTop, order, pins = new
274
281
  if (call)
275
282
  flipped.add(pairs === 'reply' ? e.id : call.id);
276
283
  });
284
+ // The edge that closes a loop (a retry, "new findings, investigate again")
285
+ // points back at a box that was declared earlier and already appears in an
286
+ // earlier edge. Lay it out reversed, so the main path keeps running forward
287
+ // whichever branch ELK happens to walk first.
288
+ const index = new Map(d.nodes.map((n, i) => [n.id, i]));
289
+ const firstUse = new Map();
290
+ d.edges.forEach((e, i) => {
291
+ for (const id of [e.from, e.to])
292
+ if (!firstUse.has(id))
293
+ firstUse.set(id, i);
294
+ });
295
+ const back = d.edges
296
+ .map((e, i) => ({ e, i }))
297
+ .filter(({ e, i }) => e.from !== e.to && index.get(e.to) < index.get(e.from) && firstUse.get(e.to) < i)
298
+ // Call-and-reply pairs are handled above.
299
+ .filter(({ e }) => !d.edges.some((x) => x.from === e.to && x.to === e.from))
300
+ // Last written first: once the loop's closing edge is cut, an earlier
301
+ // backward edge inside the same loop is no longer part of a cycle and stays.
302
+ .reverse()
303
+ .map(({ e }) => e);
304
+ const cut = new Set();
305
+ const reaches = (from, to) => {
306
+ const seen = new Set([from]);
307
+ for (const q = [from]; q.length;) {
308
+ const cur = q.pop();
309
+ if (cur === to)
310
+ return true;
311
+ for (const x of d.edges)
312
+ if (x.from === cur && !cut.has(x.id) && !seen.has(x.to))
313
+ (seen.add(x.to), q.push(x.to));
314
+ }
315
+ return false;
316
+ };
317
+ for (const e of back) {
318
+ if (!reaches(e.to, e.from))
319
+ continue;
320
+ cut.add(e.id);
321
+ flipped.add(e.id);
322
+ }
277
323
  root.edges = d.edges.map((e, i) => {
278
324
  const src = pinFor(pins, e.from, e.id, 'from') ?? e.from, dst = pinFor(pins, e.to, e.id, 'to') ?? e.to;
279
325
  const flip = flipped.has(e.id);
@@ -137,47 +137,108 @@ export function polish(d, l, inp) {
137
137
  const b = nodes.get(n.id);
138
138
  const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
139
139
  const outs = edges.filter((e) => e.from === n.id && e.to !== n.id);
140
- // The run each edge settles into once it is clear of the diamond.
141
- const runX = (e) => {
142
- const p = e.pts;
140
+ // The run each edge settles into once it is clear of the diamond. ELK usually
141
+ // leaves on a short stub and turns; when it leaves straight down instead, the
142
+ // first segment is the run.
143
+ const runAt = (p) => {
144
+ if (p.length >= 2 && vert(p[0], p[1]) && p[1].y > b.y + b.h + 24)
145
+ return 0;
143
146
  for (let i = 1; i < p.length - 1; i++)
144
147
  if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
145
- return p[i].x;
146
- return p[p.length - 1].x;
148
+ return i;
149
+ return -1;
150
+ };
151
+ const runX = (e) => {
152
+ const i = runAt(e.pts);
153
+ return (i >= 0 ? e.pts[i] : e.pts[e.pts.length - 1]).x;
147
154
  };
155
+ // Points from the start of that run on.
148
156
  const tail = (e) => {
149
- // Points from the start of that run on.
150
- const p = e.pts;
151
- for (let i = 1; i < p.length - 1; i++)
152
- if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
153
- return p.slice(i);
154
- return p.slice(-1);
157
+ const i = runAt(e.pts);
158
+ return i >= 0 ? e.pts.slice(i) : e.pts.slice(-1);
155
159
  };
156
160
  const sorted = [...outs].sort((a, c) => runX(a) - runX(c));
157
161
  let bottomTaken = false;
162
+ // One branch per side point: a second one there would hide the first.
163
+ const taken = { left: false, right: false };
164
+ // A branch whose run sits under the diamond would have to fork off the bottom
165
+ // point next to its sibling. Move the run out past the side point instead, so
166
+ // each branch leaves from its own corner: side point, a short stub, then down.
167
+ const sideStep = (e, rest, left) => {
168
+ if (rest.length < 2)
169
+ return undefined;
170
+ const lab = l.edges.get(e.id)?.label;
171
+ // Far enough out that the branch label, centred on the run, clears the diamond.
172
+ const out = Math.max(24, lab ? Math.ceil(f.boxTo(lab).w / 2) + 8 : 0);
173
+ const beside = left ? b.x - out : b.x + b.w + out;
174
+ const end = rest[1], t = nodes.get(e.to);
175
+ // A run that drops straight into the target has to stay over the target.
176
+ const drops = rest.length === 2;
177
+ if (drops ? ['diamond', 'table'].includes(shapeOf(e.to) ?? '') || !near(end.y, t.y, 1) : !horiz(end, rest[2]) || end.y < b.y + b.h + 12)
178
+ return undefined;
179
+ // Into the target's middle when that is already clear of the diamond, else just beside the diamond.
180
+ const mid = t.x + t.w / 2;
181
+ const tries = drops && (left ? mid <= beside : mid >= beside) ? [mid, beside] : [beside];
182
+ const sideP = { x: left ? b.x : b.x + b.w, y: cy };
183
+ for (const nx of tries) {
184
+ if (drops && (nx < t.x + R || nx > t.x + t.w - R || usedAt(e.to, nx, e, 'top')))
185
+ continue;
186
+ const a = { x: nx, y: cy }, c = { x: nx, y: end.y };
187
+ if (!clear(sideP, a, [n.id]) || !clear(a, c, [n.id, e.to]) || !free(a, c, e))
188
+ continue;
189
+ if (!drops && !clear(c, rest[2], [n.id, e.to]))
190
+ continue;
191
+ return simplify([sideP, a, c, ...rest.slice(2)]);
192
+ }
193
+ return undefined;
194
+ };
158
195
  // The edge closest to straight down gets the bottom point.
159
- const straight = sorted.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
160
- for (const e of sorted) {
196
+ const nearest = (list) => list.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
197
+ // Or, failing that, the one whose target reaches under the bottom point, so it can drop straight.
198
+ const reaches = (e) => {
199
+ const t = nodes.get(e.to), rest = tail(e);
200
+ return rest.length === 2 && near(rest[1].y, t.y, 1) && !['diamond', 'table'].includes(shapeOf(e.to) ?? '') && cx >= t.x + 16 && cx <= t.x + t.w - 16;
201
+ };
202
+ const mid = nearest(sorted);
203
+ const straight = mid && Math.abs(runX(mid) - cx) < 12 ? mid : nearest(sorted.filter(reaches));
204
+ // The middle branch goes last: whether it can drop straight depends on where
205
+ // its siblings ended up, not on the stubs ELK gave them at the bottom point.
206
+ for (const e of [...sorted.filter((x) => x !== straight), ...(straight ? [straight] : [])]) {
161
207
  const x = runX(e), rest = tail(e);
162
208
  const bottom = { x: cx, y: b.y + b.h };
209
+ // A run that starts on the diamond itself: what follows is from its far end on.
210
+ const onEdge = rest[0].y <= b.y + b.h + 1 && rest.length > 1;
163
211
  if (e === straight && Math.abs(x - cx) < 12) {
164
- e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest.slice(1)].map((pt, i) => (i === 1 ? pt : pt)));
165
- if (rest.length && !near(rest[0].x, cx))
166
- e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest]);
212
+ const run = onEdge ? rest.slice(1) : rest;
213
+ e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run.slice(1)]);
214
+ if (!near(run[0].x, cx))
215
+ e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run]);
216
+ bottomTaken = true;
217
+ continue;
218
+ }
219
+ const t = nodes.get(e.to), floor = { x: cx, y: t.y };
220
+ if (e === straight && reaches(e) && !usedAt(e.to, cx, e, 'top') && clear(bottom, floor, [n.id, e.to]) && free(bottom, floor, e)) {
221
+ e.pts = [bottom, floor];
167
222
  bottomTaken = true;
168
223
  continue;
169
224
  }
170
225
  const left = x < cx;
171
226
  const sideP = { x: left ? b.x : b.x + b.w, y: cy };
172
227
  const corner = { x, y: cy };
173
- const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
174
- if (sideOk) {
175
- e.pts = simplify([sideP, corner, ...rest]);
176
- continue;
228
+ const key = left ? 'left' : 'right';
229
+ if (!taken[key]) {
230
+ const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
231
+ const pts = sideOk ? simplify([sideP, corner, ...rest]) : sideStep(e, rest, left);
232
+ if (pts) {
233
+ e.pts = pts;
234
+ taken[key] = true;
235
+ continue;
236
+ }
177
237
  }
178
238
  // Branches that both head down fork from the bottom point on a short stem.
179
- const y = Math.min(rest[0].y, b.y + b.h + 16);
180
- e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...rest]);
239
+ // Such a run turns onto the stem below the point.
240
+ const y = onEdge ? b.y + b.h + 16 : Math.min(rest[0].y, b.y + b.h + 16);
241
+ e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...(onEdge ? rest.slice(1) : rest)]);
181
242
  bottomTaken = true;
182
243
  }
183
244
  for (const e of edges.filter((x) => x.to === n.id && x.from !== n.id)) {
@@ -188,9 +249,38 @@ export function polish(d, l, inp) {
188
249
  e.pts[m - 1] = { x: cx, y: b.y };
189
250
  continue;
190
251
  }
252
+ // Arriving sideways and then dropping to a port beside the top point: carry
253
+ // the sideways run on to the middle. Stepping down first leaves a small kink.
254
+ const before = p[m - 3];
255
+ const across = { x: cx, y: prev.y };
256
+ if (before && horiz(before, prev) && !near(before.x, cx) && clear(prev, across, [e.from, n.id]) && clear(across, { x: cx, y: b.y }, [e.from, n.id])) {
257
+ e.pts = simplify([...p.slice(0, m - 2), across, { x: cx, y: b.y }]);
258
+ continue;
259
+ }
191
260
  const y = Math.max(prev.y, b.y - 16);
192
261
  e.pts = simplify([...p.slice(0, m - 1), { x: last.x, y }, { x: cx, y }, { x: cx, y: b.y }]);
193
262
  }
263
+ // Branches that join above a decision meet at one height, not a few px apart.
264
+ const joins = edges.flatMap((e) => {
265
+ const p = e.pts, m = p.length;
266
+ if (e.to !== n.id || e.from === n.id || m < 4)
267
+ return [];
268
+ const [up, a, c, top] = [p[m - 4], p[m - 3], p[m - 2], p[m - 1]];
269
+ const ok = vert(up, a) && up.y < a.y && horiz(a, c) && near(c.x, cx) && vert(c, top) && near(top.y, b.y, 1);
270
+ return ok ? [{ e, a, c }] : [];
271
+ });
272
+ if (joins.length > 1) {
273
+ const y = Math.max(...joins.map((j) => j.a.y));
274
+ for (const j of joins) {
275
+ if (near(j.a.y, y) || y - j.a.y > 24)
276
+ continue;
277
+ const na = { x: j.a.x, y }, nc = { x: cx, y };
278
+ if (!clear(na, nc, [j.e.from, n.id]))
279
+ continue;
280
+ const m = j.e.pts.length;
281
+ j.e.pts = simplify([...j.e.pts.slice(0, m - 3), na, nc, j.e.pts[m - 1]]);
282
+ }
283
+ }
194
284
  }
195
285
  dejog(true);
196
286
  // Self-loops: wide enough to read as a loop, label outside it.
@@ -63,9 +63,25 @@ export function check(cells) {
63
63
  if (p?.container && !inside(n.box, p.box, 2))
64
64
  findings.push({ level: 'warning', rule: 'outside-group', message: `${name(n)} sticks out of its group ${name(p)}` });
65
65
  }
66
+ // A box drawn inside a group reads as a member of it.
67
+ const groups = containers.filter((g) => !scaffold(g));
68
+ for (const n of nodes) {
69
+ const mine = ancestors(n.id);
70
+ for (const g of groups)
71
+ if (!mine.has(g.id) && inter(n.box, g.box) > 0.25 * n.box.w * n.box.h)
72
+ findings.push({ level: 'warning', rule: 'in-other-group', message: `${name(n)} sits inside ${name(g)} but isn't in that group; move it out, or put it in the group` });
73
+ }
66
74
  for (const e of edges) {
67
75
  const pts = e.points;
68
76
  const skip = new Set([e.source, e.target, ...ancestors(e.source), ...ancestors(e.target)]);
77
+ // A line through a group it has no end in looks like it connects to something there.
78
+ for (const g of groups) {
79
+ if (skip.has(g.id))
80
+ continue;
81
+ const r = shrink(g.box, 6);
82
+ if (pts.some((p, i) => i < pts.length - 1 && segHitsBox(p, pts[i + 1], r)))
83
+ findings.push({ level: 'warning', rule: 'edge-through-group', message: `edge ${edgeName(e, byId)} runs through ${name(g)}, which neither end is in` });
84
+ }
69
85
  for (const n of nodes) {
70
86
  if (skip.has(n.id))
71
87
  continue;
@@ -30,7 +30,7 @@ const PKG = (() => {
30
30
  }
31
31
  throw new Error('linework: bundled skill not found. Reinstall linework.');
32
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`;
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. There are no set, add or rm tools: for a small change, call show_diagram, edit its output and pass that to build_diagram, which keeps the layout the same way.\n\n`;
34
34
  export function createServer(opts) {
35
35
  let browser;
36
36
  const getBrowser = () => (browser ??= DrawioBrowser.launch({ scale: 2 }));
@@ -66,13 +66,13 @@ export function createServer(opts) {
66
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
67
  inputSchema: z.object({
68
68
  topic: z
69
- .enum(['overview', 'spec', 'style', 'google-docs'])
69
+ .enum(['overview', 'spec', 'style', 'google-docs', 'troubleshooting'])
70
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.'),
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. troubleshooting: what to do when a command fails.'),
72
72
  }),
73
73
  annotations: { readOnlyHint: true },
74
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];
75
+ const file = { overview: 'SKILL.md', spec: 'references/spec.md', style: 'references/style-guide.md', 'google-docs': 'references/google-docs.md', troubleshooting: 'references/troubleshooting.md' }[topic];
76
76
  const body = fs.readFileSync(path.join(PKG, 'skills', 'linework', file), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
77
77
  return { content: [text(TOOL_NAMES + body)] };
78
78
  });
@@ -137,8 +137,8 @@ export function createServer(opts) {
137
137
  return { content: [text([res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix', ...checkLines(res.check)].join('\n'))] };
138
138
  })());
139
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.',
140
+ title: 'Convert Mermaid to a linework spec',
141
+ description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to a linework spec. Returns the spec; review the guessed node kinds, then build it. With path, builds it straight away.',
142
142
  inputSchema: z.object({ source: z.string().describe('Mermaid source, without the ``` fence.'), path: pathArg.optional() }),
143
143
  }, (args) => wrap(async () => {
144
144
  const { importMermaid } = await import('../import/mermaid.js');
package/dist/patch.js ADDED
@@ -0,0 +1,167 @@
1
+ // One-line changes to an existing diagram: set a field, add or remove a box or
2
+ // an edge. Each reads the file's current spec (hand edits included), changes it
3
+ // and builds, so the layout merges the same way a show / edit / build round does.
4
+ import YAML, { isMap, isScalar, isSeq } from 'yaml';
5
+ import { build, show } from './api.js';
6
+ export class PatchError extends Error {
7
+ }
8
+ const TOP = new Set(['title', 'theme', 'direction']);
9
+ const TEXT = new Set(['label', 'tech', 'title']);
10
+ // "from ARROW to" or "from ARROW to: label". ER cardinalities count as arrows too.
11
+ const EDGE = /^(\S+)\s+(\S*(?:->|=>|--|\.\.)\S*|<->)\s+([^\s:]+)(?::\s*(.*))?$/;
12
+ function mapAt(doc, key, create = false) {
13
+ const m = doc.get(key);
14
+ if (isMap(m))
15
+ return m;
16
+ if (!create)
17
+ return undefined;
18
+ const made = doc.createNode({});
19
+ doc.set(key, made);
20
+ return made;
21
+ }
22
+ function seqAt(doc, key, create = false) {
23
+ const s = doc.get(key);
24
+ if (isSeq(s))
25
+ return s;
26
+ if (!create)
27
+ return undefined;
28
+ const made = doc.createNode([]);
29
+ doc.set(key, made);
30
+ return made;
31
+ }
32
+ const edgeEnds = (item) => {
33
+ if (isScalar(item) && typeof item.value === 'string') {
34
+ const m = EDGE.exec(item.value.trim());
35
+ return m ? { from: m[1], to: m[3] } : undefined;
36
+ }
37
+ // Unquoted, "a -> b: label" is a one-pair YAML map whose key is the edge.
38
+ if (isMap(item) && item.items.length === 1 && isScalar(item.items[0].key) && typeof item.items[0].key.value === 'string') {
39
+ const m = EDGE.exec(item.items[0].key.value.trim());
40
+ if (m)
41
+ return { from: m[1], to: m[3] };
42
+ }
43
+ if (isMap(item) && item.has('from') && item.has('to'))
44
+ return { from: String(item.get('from')), to: String(item.get('to')) };
45
+ return undefined;
46
+ };
47
+ /** Apply the ops to a spec's YAML text and return the new text. */
48
+ export function patchSpec(yaml, ops) {
49
+ const doc = YAML.parseDocument(yaml);
50
+ const sequence = doc.get('type') === 'sequence';
51
+ const nodesKey = sequence ? 'participants' : 'nodes';
52
+ const edgesKey = sequence ? 'messages' : 'edges';
53
+ const has = (key, id) => !!mapAt(doc, key)?.has(id);
54
+ const known = () => [...(mapAt(doc, nodesKey)?.items ?? []), ...(mapAt(doc, 'groups')?.items ?? [])].map((p) => String(isScalar(p.key) ? p.key.value : p.key)).join(', ');
55
+ for (const { op, args } of ops) {
56
+ if (!args.length)
57
+ throw new PatchError(`${op} needs at least one argument`);
58
+ for (const arg of args) {
59
+ if (op === 'set') {
60
+ const eq = arg.indexOf('=');
61
+ if (eq < 1)
62
+ throw new PatchError(`can't read "${arg}". Write it as id.field=value, for example api.label="Checkout API", or theme=dark.`);
63
+ const path = arg.slice(0, eq), raw = arg.slice(eq + 1);
64
+ const dot = path.lastIndexOf('.');
65
+ const field = dot < 0 ? path : path.slice(dot + 1);
66
+ const value = TEXT.has(field) ? raw : YAML.parse(raw);
67
+ if (dot < 0) {
68
+ if (!TOP.has(path))
69
+ throw new PatchError(`"${path}" isn't a diagram setting. Settings: title, theme, direction. For a box, write id.field=value.`);
70
+ if (raw === '')
71
+ doc.delete(path);
72
+ else
73
+ doc.set(path, value);
74
+ continue;
75
+ }
76
+ const id = path.slice(0, dot);
77
+ const key = has(nodesKey, id) ? nodesKey : has('groups', id) ? 'groups' : undefined;
78
+ if (!key)
79
+ throw new PatchError(`no box or group named "${id}". There are: ${known()}`);
80
+ const owner = mapAt(doc, key);
81
+ const found = owner.get(id, true);
82
+ let entry;
83
+ if (isMap(found))
84
+ entry = found;
85
+ else {
86
+ // "id: Label" and "id:" are short for a map.
87
+ const label = isScalar(found) && found.value != null ? { label: found.value } : {};
88
+ entry = doc.createNode(label, { flow: true });
89
+ owner.set(id, entry);
90
+ }
91
+ if (raw === '')
92
+ entry.delete(field);
93
+ else
94
+ entry.set(field, value);
95
+ }
96
+ else if (op === 'add') {
97
+ const text = arg.trim();
98
+ if (EDGE.test(text)) {
99
+ seqAt(doc, edgesKey, true).add(doc.createNode(text));
100
+ continue;
101
+ }
102
+ const group = /^group\s+/.test(text);
103
+ const parsed = YAML.parse(group ? text.replace(/^group\s+/, '') : text);
104
+ const entries = typeof parsed === 'string' ? [[parsed, {}]] : parsed && typeof parsed === 'object' ? Object.entries(parsed) : [];
105
+ if (!entries.length)
106
+ throw new PatchError(`can't read "${arg}". A box: "fraud: { label: Fraud check, in: vpc }". An edge: "api -> fraud: score". A group: "group vpc: { label: Production VPC }".`);
107
+ const key = group ? 'groups' : nodesKey;
108
+ for (const [id, v] of entries) {
109
+ if (has(nodesKey, id) || has('groups', id))
110
+ throw new PatchError(`"${id}" already exists. Use set to change it.`);
111
+ const val = typeof v === 'string' ? { label: v } : (v ?? {});
112
+ mapAt(doc, key, true).set(id, doc.createNode(val, { flow: true }));
113
+ }
114
+ }
115
+ else {
116
+ const text = arg.trim();
117
+ const m = EDGE.exec(text);
118
+ if (m) {
119
+ const list = seqAt(doc, edgesKey);
120
+ const i = list?.items.findIndex((it) => {
121
+ const e = edgeEnds(it);
122
+ return e && e.from === m[1] && e.to === m[3];
123
+ });
124
+ if (!list || i === undefined || i < 0)
125
+ throw new PatchError(`no edge from "${m[1]}" to "${m[3]}"`);
126
+ list.delete(i);
127
+ continue;
128
+ }
129
+ if (has(nodesKey, text)) {
130
+ mapAt(doc, nodesKey).delete(text);
131
+ const list = seqAt(doc, edgesKey);
132
+ if (list)
133
+ for (let i = list.items.length - 1; i >= 0; i--) {
134
+ const e = edgeEnds(list.items[i]);
135
+ if (e && (e.from === text || e.to === text))
136
+ list.delete(i);
137
+ }
138
+ }
139
+ else if (has('groups', text)) {
140
+ // Its members move up to the group it was in, or out to the top level.
141
+ const groups = mapAt(doc, 'groups');
142
+ const self = groups.get(text);
143
+ const up = isMap(self) ? self.get('in') : undefined;
144
+ groups.delete(text);
145
+ for (const k of [nodesKey, 'groups'])
146
+ for (const p of mapAt(doc, k)?.items ?? [])
147
+ if (isMap(p.value) && p.value.get('in') === text) {
148
+ if (up)
149
+ p.value.set('in', up);
150
+ else
151
+ p.value.delete('in');
152
+ }
153
+ if (!groups.items.length)
154
+ doc.delete('groups');
155
+ }
156
+ else
157
+ throw new PatchError(`no box, group or edge matches "${arg}". There are: ${known()}`);
158
+ }
159
+ }
160
+ }
161
+ return doc.toString({ lineWidth: 0 });
162
+ }
163
+ /** Change a diagram file in place. The existing layout is kept. */
164
+ export async function patchFile(file, ops, opts = {}) {
165
+ const yaml = patchSpec(show(file).yaml, ops);
166
+ return build(yaml, { ...opts, out: file });
167
+ }
package/dist/report.js CHANGED
@@ -8,6 +8,8 @@ export function buildLines(res, png) {
8
8
  parts.push(`placed ${m.placed.join(', ')}`);
9
9
  if (m.grown.length)
10
10
  parts.push(`grew ${m.grown.join(', ')}`);
11
+ if (m.shrunk.length)
12
+ parts.push(`closed up ${m.shrunk.join(', ')}`);
11
13
  if (m.removed.length)
12
14
  parts.push(`removed ${m.removed.join(', ')}`);
13
15
  if (m.rerouted)
@@ -243,6 +243,8 @@ export function parseSpec(raw) {
243
243
  const out = edges.filter((e) => e.from === n.id);
244
244
  if (out.some((e) => !e.label))
245
245
  warnings.push({ path: `nodes.${n.id}`, message: 'label every edge leaving a decision, e.g. "yes"/"no"' });
246
+ if (n.label.length > 40)
247
+ warnings.push({ path: `nodes.${n.id}`, message: `a diamond holds little text; shorten "${n.label.slice(0, 24)}..." to a short question and put the detail in the step before it` });
246
248
  }
247
249
  }
248
250
  highlightWarning(real, edges, warnings);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsthecsguy/linework",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Software diagrams an AI can write and a person can edit. YAML spec in, .drawio.svg out, editable in draw.io.",
5
5
  "keywords": [
6
6
  "diagram",
@@ -41,6 +41,7 @@
41
41
  "assets": "node scripts/copy-drawio-assets.mjs",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
43
  "prepack": "npm run build",
44
+ "release": "node scripts/release.mjs",
44
45
  "fonts": "node scripts/copy-fonts.mjs"
45
46
  },
46
47
  "dependencies": {