@itsthecsguy/linework 0.1.3 → 0.2.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/README.md +23 -1
- package/dist/api.js +74 -14
- package/dist/cli.js +76 -264
- package/dist/commands.js +346 -0
- package/dist/daemon.js +193 -0
- package/dist/drawio/merge.js +47 -8
- package/dist/layout/layout.js +44 -1
- package/dist/lint/check.js +16 -0
- package/dist/lint/fixlabels.js +22 -14
- package/dist/mcp/server.js +4 -4
- package/dist/patch.js +167 -0
- package/dist/render/browser.js +96 -32
- package/dist/render/fonts.js +65 -1
- package/dist/report.js +2 -0
- package/dist/spec/parse.js +2 -0
- package/package.json +1 -1
- package/skills/linework/SKILL.md +57 -200
- package/skills/linework/references/troubleshooting.md +11 -0
package/dist/layout/layout.js
CHANGED
|
@@ -156,7 +156,11 @@ function nodeSizes(d, tm, fonts) {
|
|
|
156
156
|
continue;
|
|
157
157
|
}
|
|
158
158
|
if (shape === 'diamond') {
|
|
159
|
-
|
|
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
165
|
sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.5, t.lines * fonts.line * 2 + 24)) });
|
|
162
166
|
continue;
|
|
@@ -274,6 +278,45 @@ async function run(d, dir, sizes, labels, groupMin, groupTop, order, pins = new
|
|
|
274
278
|
if (call)
|
|
275
279
|
flipped.add(pairs === 'reply' ? e.id : call.id);
|
|
276
280
|
});
|
|
281
|
+
// The edge that closes a loop (a retry, "new findings, investigate again")
|
|
282
|
+
// points back at a box that was declared earlier and already appears in an
|
|
283
|
+
// earlier edge. Lay it out reversed, so the main path keeps running forward
|
|
284
|
+
// whichever branch ELK happens to walk first.
|
|
285
|
+
const index = new Map(d.nodes.map((n, i) => [n.id, i]));
|
|
286
|
+
const firstUse = new Map();
|
|
287
|
+
d.edges.forEach((e, i) => {
|
|
288
|
+
for (const id of [e.from, e.to])
|
|
289
|
+
if (!firstUse.has(id))
|
|
290
|
+
firstUse.set(id, i);
|
|
291
|
+
});
|
|
292
|
+
const back = d.edges
|
|
293
|
+
.map((e, i) => ({ e, i }))
|
|
294
|
+
.filter(({ e, i }) => e.from !== e.to && index.get(e.to) < index.get(e.from) && firstUse.get(e.to) < i)
|
|
295
|
+
// Call-and-reply pairs are handled above.
|
|
296
|
+
.filter(({ e }) => !d.edges.some((x) => x.from === e.to && x.to === e.from))
|
|
297
|
+
// Last written first: once the loop's closing edge is cut, an earlier
|
|
298
|
+
// backward edge inside the same loop is no longer part of a cycle and stays.
|
|
299
|
+
.reverse()
|
|
300
|
+
.map(({ e }) => e);
|
|
301
|
+
const cut = new Set();
|
|
302
|
+
const reaches = (from, to) => {
|
|
303
|
+
const seen = new Set([from]);
|
|
304
|
+
for (const q = [from]; q.length;) {
|
|
305
|
+
const cur = q.pop();
|
|
306
|
+
if (cur === to)
|
|
307
|
+
return true;
|
|
308
|
+
for (const x of d.edges)
|
|
309
|
+
if (x.from === cur && !cut.has(x.id) && !seen.has(x.to))
|
|
310
|
+
(seen.add(x.to), q.push(x.to));
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
};
|
|
314
|
+
for (const e of back) {
|
|
315
|
+
if (!reaches(e.to, e.from))
|
|
316
|
+
continue;
|
|
317
|
+
cut.add(e.id);
|
|
318
|
+
flipped.add(e.id);
|
|
319
|
+
}
|
|
277
320
|
root.edges = d.edges.map((e, i) => {
|
|
278
321
|
const src = pinFor(pins, e.from, e.id, 'from') ?? e.from, dst = pinFor(pins, e.to, e.id, 'to') ?? e.to;
|
|
279
322
|
const flip = flipped.has(e.id);
|
package/dist/lint/check.js
CHANGED
|
@@ -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;
|
package/dist/lint/fixlabels.js
CHANGED
|
@@ -42,14 +42,19 @@ export function runSpots(pts, label) {
|
|
|
42
42
|
function labelEdges(findings) {
|
|
43
43
|
return [...new Set(findings.filter((f) => LABEL_RULES.has(f.rule) && f.labelEdges).flatMap((f) => f.labelEdges))];
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the fixed XML and its cells. Only label backgrounds change after the
|
|
47
|
+
* cells are read, and those don't move anything, so the cells stay valid for it.
|
|
48
|
+
*/
|
|
45
49
|
export async function fixLabels(b, xml, maxRounds = 6) {
|
|
46
50
|
let cur = xml;
|
|
47
|
-
|
|
51
|
+
const labelScore = (cells) => check(cells).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
52
|
+
let score = labelScore(await b.inspect(cur));
|
|
48
53
|
let moved = 0;
|
|
49
54
|
for (let round = 0; round < maxRounds && score > 0; round++) {
|
|
55
|
+
// The cells of `cur`, kept current as labels move: a move changes only that label's box.
|
|
50
56
|
const cells = await b.inspect(cur);
|
|
51
|
-
const
|
|
52
|
-
const targets = labelEdges(res.findings);
|
|
57
|
+
const targets = labelEdges(check(cells).findings);
|
|
53
58
|
let improved = false;
|
|
54
59
|
for (const id of targets) {
|
|
55
60
|
const doc = new DOMParser().parseFromString(cur, 'text/xml');
|
|
@@ -59,19 +64,21 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
59
64
|
const base = Number(g.getAttribute('x') ?? 0);
|
|
60
65
|
const cell = cells.find((c) => c.id === id);
|
|
61
66
|
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)))];
|
|
67
|
+
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))].map((x) => Number(x.toFixed(3)));
|
|
68
|
+
const boxes = await b.labelBoxes(cur, id, tries);
|
|
63
69
|
let best;
|
|
64
|
-
for (
|
|
65
|
-
|
|
66
|
-
const candidate = new XMLSerializer().serializeToString(doc);
|
|
67
|
-
const s = check(await b.inspect(candidate)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
70
|
+
for (let i = 0; i < tries.length; i++) {
|
|
71
|
+
const s = labelScore(cells.map((c) => (c.id === id ? { ...c, textBox: boxes[i] } : c)));
|
|
68
72
|
if (s < (best?.score ?? score))
|
|
69
|
-
best = {
|
|
73
|
+
best = { x: tries[i], score: s, box: boxes[i] };
|
|
70
74
|
if (s === 0)
|
|
71
75
|
break;
|
|
72
76
|
}
|
|
73
77
|
if (best) {
|
|
74
|
-
|
|
78
|
+
g.setAttribute('x', best.x.toFixed(3));
|
|
79
|
+
cur = new XMLSerializer().serializeToString(doc);
|
|
80
|
+
if (cell)
|
|
81
|
+
cell.textBox = best.box;
|
|
75
82
|
score = best.score;
|
|
76
83
|
moved++;
|
|
77
84
|
improved = true;
|
|
@@ -82,16 +89,17 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
82
89
|
if (!improved)
|
|
83
90
|
break;
|
|
84
91
|
}
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const cells = await b.inspect(cur);
|
|
93
|
+
cur = await matchLabelBackgrounds(b, cur, cells);
|
|
94
|
+
return { xml: cur, moved, cells };
|
|
87
95
|
}
|
|
88
96
|
/**
|
|
89
97
|
* A label's backing has to match what it sits on: the fill of the smallest group
|
|
90
98
|
* under it, or the canvas. Run after labels move, since a move can carry a label
|
|
91
99
|
* from the canvas onto a group or back.
|
|
92
100
|
*/
|
|
93
|
-
export async function matchLabelBackgrounds(b, xml) {
|
|
94
|
-
const cells = await b.inspect(xml);
|
|
101
|
+
export async function matchLabelBackgrounds(b, xml, known) {
|
|
102
|
+
const cells = known ?? (await b.inspect(xml));
|
|
95
103
|
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
96
104
|
const canvas = doc.getElementsByTagName('mxGraphModel')[0]?.getAttribute('background') ?? '#FFFFFF';
|
|
97
105
|
const styleOf = new Map();
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
});
|
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/render/browser.js
CHANGED
|
@@ -5,10 +5,10 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import http from 'node:http';
|
|
8
9
|
import { chromium } from 'playwright-core';
|
|
9
10
|
import { FONT_STACK } from '../drawio/theme.js';
|
|
10
11
|
import { FACES, embedFonts, fontFaceCss, fontsDir } from './fonts.js';
|
|
11
|
-
const ORIGIN = 'http://aiviz.local';
|
|
12
12
|
export function assetsDir() {
|
|
13
13
|
// dist/render/browser.js or src/render/browser.ts -> <pkg>/assets/drawio
|
|
14
14
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -90,6 +90,23 @@ window.aiviz = {
|
|
|
90
90
|
hasStencil: function (name) {
|
|
91
91
|
return mxStencilRegistry.getStencil(name) != null;
|
|
92
92
|
},
|
|
93
|
+
// Where an edge's label lands at each of several positions along the edge
|
|
94
|
+
// (draw.io's -1..1 x). Moving a label changes nothing else in the diagram, so
|
|
95
|
+
// one decode serves every candidate instead of one full inspect per try.
|
|
96
|
+
labelBoxes: function (xml, id, xs) {
|
|
97
|
+
return withGraph(xml, function (graph) {
|
|
98
|
+
var model = graph.getModel(), cell = model.getCell(id);
|
|
99
|
+
if (!cell) return xs.map(function () { return null; });
|
|
100
|
+
return xs.map(function (x) {
|
|
101
|
+
var g = cell.geometry.clone();
|
|
102
|
+
g.x = x;
|
|
103
|
+
model.setGeometry(cell, g);
|
|
104
|
+
var st = graph.view.getState(cell);
|
|
105
|
+
var tb = st && st.text && st.text.boundingBox;
|
|
106
|
+
return tb ? { x: tb.x, y: tb.y, w: tb.width, h: tb.height } : null;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
},
|
|
93
110
|
inspect: function (xml) {
|
|
94
111
|
return withGraph(xml, function (graph) {
|
|
95
112
|
var model = graph.getModel(), out = [];
|
|
@@ -127,38 +144,34 @@ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/htm
|
|
|
127
144
|
export class DrawioBrowser {
|
|
128
145
|
browser;
|
|
129
146
|
page;
|
|
130
|
-
|
|
147
|
+
server;
|
|
148
|
+
constructor(browser, page, server) {
|
|
131
149
|
this.browser = browser;
|
|
132
150
|
this.page = page;
|
|
151
|
+
this.server = server;
|
|
133
152
|
}
|
|
134
153
|
static async launch(opts = {}) {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
157
|
-
// Measuring before a font loads would size every box for the fallback font.
|
|
158
|
-
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
159
|
-
if (ok.includes(false))
|
|
160
|
-
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
161
|
-
return new DrawioBrowser(browser, page);
|
|
154
|
+
// Chromium starts while the asset server binds.
|
|
155
|
+
const [browser, server] = await Promise.all([launchChromium(), serveAssets()]);
|
|
156
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
157
|
+
try {
|
|
158
|
+
const page = await browser.newPage({ deviceScaleFactor: opts.scale ?? 2 });
|
|
159
|
+
// Nothing else may load: keeps rendering offline and deterministic.
|
|
160
|
+
const own = new RegExp(`^${origin.replace(/[.]/g, '\\.')}/`);
|
|
161
|
+
await page.route((url) => !own.test(url.href), (route) => route.abort());
|
|
162
|
+
await page.goto(`${origin}/host.html`);
|
|
163
|
+
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
164
|
+
// Measuring before a font loads would size every box for the fallback font.
|
|
165
|
+
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
166
|
+
if (ok.includes(false))
|
|
167
|
+
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
168
|
+
return new DrawioBrowser(browser, page, server);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
await browser.close().catch(() => { });
|
|
172
|
+
server.close();
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
162
175
|
}
|
|
163
176
|
measure(reqs) {
|
|
164
177
|
return this.page.evaluate((r) => window.aiviz.measure(r), reqs);
|
|
@@ -182,12 +195,63 @@ export class DrawioBrowser {
|
|
|
182
195
|
hasStencil(name) {
|
|
183
196
|
return this.page.evaluate((n) => window.aiviz.hasStencil(n), name);
|
|
184
197
|
}
|
|
198
|
+
// inspect is a pure function of the XML, and a build asks about the same XML
|
|
199
|
+
// more than once (lint, label fixing, the final check).
|
|
200
|
+
seen = new Map();
|
|
185
201
|
inspect(xml) {
|
|
186
|
-
|
|
202
|
+
let hit = this.seen.get(xml);
|
|
203
|
+
if (!hit) {
|
|
204
|
+
hit = this.page.evaluate((x) => window.aiviz.inspect(x), xml);
|
|
205
|
+
hit.catch(() => this.seen.delete(xml));
|
|
206
|
+
if (this.seen.size >= 8)
|
|
207
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
208
|
+
this.seen.set(xml, hit);
|
|
209
|
+
}
|
|
210
|
+
// Callers may change the cells; each gets its own copy.
|
|
211
|
+
return hit.then((cells) => structuredClone(cells));
|
|
187
212
|
}
|
|
188
|
-
|
|
189
|
-
|
|
213
|
+
/** Label boxes for edge `id` with its label at each draw.io position in `xs`. */
|
|
214
|
+
labelBoxes(xml, id, xs) {
|
|
215
|
+
return this.page.evaluate(([x, i, p]) => window.aiviz.labelBoxes(x, i, p), [xml, id, xs]);
|
|
190
216
|
}
|
|
217
|
+
async close() {
|
|
218
|
+
await this.browser.close();
|
|
219
|
+
this.server.closeAllConnections();
|
|
220
|
+
this.server.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The draw.io runtime is 16MB of script. Served from a local port, Chromium reads
|
|
225
|
+
* it straight off the socket; through Playwright's request interception every
|
|
226
|
+
* byte would cross the DevTools protocol first.
|
|
227
|
+
*/
|
|
228
|
+
function serveAssets() {
|
|
229
|
+
const dir = assetsDir();
|
|
230
|
+
const fonts = fontsDir();
|
|
231
|
+
const html = host();
|
|
232
|
+
const send = (res, status, type, body) => {
|
|
233
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
234
|
+
res.end(body);
|
|
235
|
+
};
|
|
236
|
+
const server = http.createServer((req, res) => {
|
|
237
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
238
|
+
if (pathname === '/' || pathname === '/host.html')
|
|
239
|
+
return send(res, 200, 'text/html', html);
|
|
240
|
+
if (pathname.startsWith('/aiviz-fonts/')) {
|
|
241
|
+
const f = FACES.find((x) => `/aiviz-fonts/${x.file}` === pathname);
|
|
242
|
+
return f ? send(res, 200, 'font/woff2', fs.readFileSync(path.join(fonts, f.file))) : send(res, 404, 'text/plain', '');
|
|
243
|
+
}
|
|
244
|
+
const file = path.join(dir, path.normalize(decodeURIComponent(pathname)));
|
|
245
|
+
if (!file.startsWith(dir + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
246
|
+
return send(res, 404, 'text/plain', '');
|
|
247
|
+
send(res, 200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
|
|
248
|
+
});
|
|
249
|
+
// A server that's only waiting on a browser that already exited shouldn't keep node alive.
|
|
250
|
+
server.unref();
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
server.once('error', reject);
|
|
253
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
254
|
+
});
|
|
191
255
|
}
|
|
192
256
|
/** Playwright-core's own installer, so the Chromium build always matches the version we run. */
|
|
193
257
|
export function browserInstallCommand() {
|
package/dist/render/fonts.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// SVG gets a subset of the faces it uses (only the glyphs in its text), so a
|
|
3
3
|
// diagram looks the same on GitHub, in a browser and in a doc, and text that fit
|
|
4
4
|
// when linework sized the boxes still fits wherever it is shown.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import subsetFont from 'subset-font';
|
|
8
10
|
import { assetsDir } from './browser.js';
|
|
@@ -54,7 +56,7 @@ export async function embedFonts(svg) {
|
|
|
54
56
|
text = [...new Set(text + text.toUpperCase() + ' ')].join('');
|
|
55
57
|
const rules = [];
|
|
56
58
|
for (const f of FACES.filter((x) => families.includes(x.family))) {
|
|
57
|
-
const sub = await
|
|
59
|
+
const sub = await subset(f, text);
|
|
58
60
|
rules.push(`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:normal;src:url(data:font/woff2;base64,${sub.toString('base64')}) format("woff2");}`);
|
|
59
61
|
}
|
|
60
62
|
const style = `<style id="aiviz-fonts" type="text/css">${rules.join('')}</style>`;
|
|
@@ -64,3 +66,65 @@ export async function embedFonts(svg) {
|
|
|
64
66
|
return svg.replace(/<defs[^>]*>/, (m) => m + style);
|
|
65
67
|
return svg.replace(/<svg\b[^>]*>/, (m) => `${m}<defs>${style}</defs>`);
|
|
66
68
|
}
|
|
69
|
+
// ---------- subset cache ----------
|
|
70
|
+
//
|
|
71
|
+
// Compressing a subset to WOFF2 is most of embedFonts' time, and rebuilding a
|
|
72
|
+
// diagram after a small edit usually needs the same characters again. A subset
|
|
73
|
+
// depends only on the font file and the set of characters (not their order), so
|
|
74
|
+
// that pair is the key. Kept in memory and in the user's own cache folder, never
|
|
75
|
+
// a shared temp dir: whatever is there ends up inside the SVGs.
|
|
76
|
+
const memo = new Map();
|
|
77
|
+
const fileHash = new Map();
|
|
78
|
+
/** Drop the in-memory copies, so the next lookup reads the disk cache. For tests. */
|
|
79
|
+
export function forgetSubsets() {
|
|
80
|
+
memo.clear();
|
|
81
|
+
}
|
|
82
|
+
function cacheDir() {
|
|
83
|
+
if (process.env.LINEWORK_NO_CACHE)
|
|
84
|
+
return undefined;
|
|
85
|
+
const home = os.homedir();
|
|
86
|
+
const base = process.env.XDG_CACHE_HOME ||
|
|
87
|
+
(process.platform === 'darwin' ? path.join(home, 'Library', 'Caches') : process.platform === 'win32' ? process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local') : path.join(home, '.cache'));
|
|
88
|
+
return path.join(base, 'linework', 'fonts');
|
|
89
|
+
}
|
|
90
|
+
async function subset(f, text) {
|
|
91
|
+
const font = read(f);
|
|
92
|
+
let fh = fileHash.get(f.file);
|
|
93
|
+
if (!fh)
|
|
94
|
+
fileHash.set(f.file, (fh = crypto.createHash('sha256').update(font).digest('hex')));
|
|
95
|
+
const chars = [...new Set(text)].sort().join('');
|
|
96
|
+
const key = crypto.createHash('sha256').update(`${fh}\0${chars}`).digest('hex').slice(0, 32);
|
|
97
|
+
const hit = memo.get(key);
|
|
98
|
+
if (hit)
|
|
99
|
+
return hit;
|
|
100
|
+
const dir = cacheDir();
|
|
101
|
+
const file = dir && path.join(dir, `${key}.woff2`);
|
|
102
|
+
let out;
|
|
103
|
+
try {
|
|
104
|
+
const b = file ? fs.readFileSync(file) : undefined;
|
|
105
|
+
// A WOFF2 file starts with its signature; anything else is a broken write.
|
|
106
|
+
if (b && b.subarray(0, 4).toString('latin1') === 'wOF2')
|
|
107
|
+
out = b;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// not cached yet
|
|
111
|
+
}
|
|
112
|
+
if (!out) {
|
|
113
|
+
out = await subsetFont(font, text, { targetFormat: 'woff2' });
|
|
114
|
+
if (file) {
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
118
|
+
fs.writeFileSync(tmp, out);
|
|
119
|
+
fs.renameSync(tmp, file);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A read-only or full disk only costs the cache.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (memo.size >= 64)
|
|
127
|
+
memo.delete(memo.keys().next().value);
|
|
128
|
+
memo.set(key, out);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
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)
|
package/dist/spec/parse.js
CHANGED
|
@@ -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);
|