Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

474 linesCodeBlameRaw
1/**
2 * The docs' diagrams, drawn from data. A diagram is a spec in
3 * src/diagrams/*.ts; <Diagram name="..."> (components/Diagram.astro) draws
4 * it as an inline SVG coloured by the g1t tokens, so it follows the light
5 * and dark themes, and each page's Markdown twin ([...slug].md.ts) writes
6 * the same spec out as text. Change the spec, and the picture and the text
7 * change together.
8 *
9 * Two kinds:
10 *
11 * - a graph: boxes at fixed places, grouped in frames, joined by arrows
12 * routed at right angles between their sides;
13 * - a sequence: actors across the top, numbered messages down the page,
14 * which the prose refers to by number.
15 *
16 * Text in labels may hold `code` in backticks, drawn in the mono face.
17 */
18
19/** How a box or an arrow is coloured. */
20export type Tone = "default" | "accent" | "store" | "external" | "success" | "warn";
21
22export type Side = "top" | "bottom" | "left" | "right";
23
24export type GraphNode = {
25 id: string;
26 x: number;
27 y: number;
28 w?: number;
29 h?: number;
30 title: string;
31 /** A second line, smaller: usually a path in the repository. */
32 sub?: string;
33 tone?: Tone;
34 /** A database or a bucket, drawn as a cylinder. */
35 shape?: "box" | "store";
36};
37
38export type GraphGroup = {
39 /** Set to let arrows start or end on the frame itself. */
40 id?: string;
41 x: number;
42 y: number;
43 w: number;
44 h: number;
45 label: string;
46 tone?: Tone;
47};
48
49export type GraphEdge = {
50 from: string;
51 to: string;
52 label?: string;
53 /** Asynchronous: an event, a queue, a stream. Drawn dashed. */
54 async?: boolean;
55 tone?: Tone;
56 fromSide?: Side;
57 toSide?: Side;
58 /** Where along the side the arrow leaves or arrives, -0.5 to 0.5 of its length. */
59 fromAt?: number;
60 toAt?: number;
61 /** Where the bend sits, for an arrow that turns twice: an x or a y. */
62 bend?: number;
63 /** Arrowheads at both ends. */
64 both?: boolean;
65 /** Where the label's centre goes, when the middle of the longest leg is a bad place. */
66 labelAt?: [number, number];
67};
68
69export type Graph = {
70 kind: "graph";
71 caption: string;
72 width: number;
73 height: number;
74 groups?: GraphGroup[];
75 nodes: GraphNode[];
76 edges: GraphEdge[];
77};
78
79export type SequenceActor = { id: string; title: string; sub?: string; tone?: Tone };
80
81export type SequenceStep =
82 /** A message from one actor to another, or to itself. */
83 | { from: string; to: string; label: string; reply?: boolean; async?: boolean }
84 /** A note across one or more actors. */
85 | { note: string; over: string[] }
86 /** A line across the whole diagram, naming a phase. */
87 | { phase: string };
88
89export type Sequence = {
90 kind: "sequence";
91 caption: string;
92 actors: SequenceActor[];
93 steps: SequenceStep[];
94 /** Wider than the default 760 when there are many actors. */
95 width?: number;
96};
97
98export type Spec = Graph | Sequence;
99
100// ── Text ───────────────────────────────────────────────────────────────────
101
102const esc = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
103
104/** Text with `code` spans as tspans in the mono face. */
105function rich(text: string): string {
106 return text
107 .split("`")
108 .map((part, i) => (i % 2 ? `<tspan class="d-mono">${esc(part)}</tspan>` : esc(part)))
109 .join("");
110}
111
112/** About how wide a label is, in user units, at the given size: wide enough for a backing pill. */
113export function textWidth(text: string, size: number): number {
114 let width = 0;
115 text.split("`").forEach((part, i) => {
116 width += part.length * size * (i % 2 ? 0.6 : 0.54);
117 });
118 return width;
119}
120
121/** Text without its backticks, for the Markdown twin and the accessible description. */
122const plain = (text: string) => text.replace(/`/g, "");
123
124// ── Graph ──────────────────────────────────────────────────────────────────
125
126const NODE_W = 150;
127const NODE_H = 52;
128
129type Box = { x: number; y: number; w: number; h: number };
130
131function box(node: GraphNode): Box {
132 return { x: node.x, y: node.y, w: node.w ?? NODE_W, h: node.h ?? NODE_H };
133}
134
135function anchor(b: Box, side: Side, at = 0): [number, number] {
136 switch (side) {
137 case "top":
138 return [b.x + b.w / 2 + at * b.w, b.y];
139 case "bottom":
140 return [b.x + b.w / 2 + at * b.w, b.y + b.h];
141 case "left":
142 return [b.x, b.y + b.h / 2 + at * b.h];
143 case "right":
144 return [b.x + b.w, b.y + b.h / 2 + at * b.h];
145 }
146}
147
148/** The sides an arrow leaves and enters by, when the spec does not say. */
149function sides(a: Box, b: Box): [Side, Side] {
150 if (b.y >= a.y + a.h) return ["bottom", "top"];
151 if (b.y + b.h <= a.y) return ["top", "bottom"];
152 return b.x >= a.x ? ["right", "left"] : ["left", "right"];
153}
154
155const vertical = (side: Side) => side === "top" || side === "bottom";
156
157/** The corners an arrow passes through, at right angles. */
158export function route(edge: GraphEdge, a: Box, b: Box): [number, number][] {
159 const [auto1, auto2] = sides(a, b);
160 const s1 = edge.fromSide ?? auto1;
161 const s2 = edge.toSide ?? auto2;
162 const [x1, y1] = anchor(a, s1, edge.fromAt);
163 const [x2, y2] = anchor(b, s2, edge.toAt);
164 if (vertical(s1) && vertical(s2)) {
165 if (Math.abs(x1 - x2) < 0.5)
166 return [
167 [x1, y1],
168 [x2, y2],
169 ];
170 const ym = edge.bend ?? (y1 + y2) / 2;
171 return [
172 [x1, y1],
173 [x1, ym],
174 [x2, ym],
175 [x2, y2],
176 ];
177 }
178 if (!vertical(s1) && !vertical(s2)) {
179 if (Math.abs(y1 - y2) < 0.5)
180 return [
181 [x1, y1],
182 [x2, y2],
183 ];
184 const xm = edge.bend ?? (x1 + x2) / 2;
185 return [
186 [x1, y1],
187 [xm, y1],
188 [xm, y2],
189 [x2, y2],
190 ];
191 }
192 // One of each: a single turn.
193 return vertical(s1)
194 ? [
195 [x1, y1],
196 [x1, y2],
197 [x2, y2],
198 ]
199 : [
200 [x1, y1],
201 [x2, y1],
202 [x2, y2],
203 ];
204}
205
206/** A path through the points with its corners rounded. */
207function rounded(points: [number, number][], radius = 7): string {
208 let d = `M${points[0][0]},${points[0][1]}`;
209 for (let i = 1; i < points.length - 1; i++) {
210 const [px, py] = points[i - 1];
211 const [cx, cy] = points[i];
212 const [nx, ny] = points[i + 1];
213 const inLen = Math.hypot(cx - px, cy - py);
214 const outLen = Math.hypot(nx - cx, ny - cy);
215 const r = Math.min(radius, inLen / 2, outLen / 2);
216 const ax = cx - ((cx - px) / inLen) * r;
217 const ay = cy - ((cy - py) / inLen) * r;
218 const bx = cx + ((nx - cx) / outLen) * r;
219 const by = cy + ((ny - cy) / outLen) * r;
220 d += ` L${ax},${ay} Q${cx},${cy} ${bx},${by}`;
221 }
222 const [lx, ly] = points[points.length - 1];
223 return `${d} L${lx},${ly}`;
224}
225
226/** The middle of the arrow's longest leg. */
227function labelPoint(points: [number, number][]): [number, number] {
228 let best = 0;
229 let at: [number, number] = points[0];
230 for (let i = 1; i < points.length; i++) {
231 const [ax, ay] = points[i - 1];
232 const [bx, by] = points[i];
233 const len = Math.hypot(bx - ax, by - ay);
234 if (len > best) {
235 best = len;
236 at = [(ax + bx) / 2, (ay + by) / 2];
237 }
238 }
239 return at;
240}
241
242function pill(x: number, y: number, text: string, size: number, cls: string): string {
243 const w = textWidth(text, size) + 12;
244 const h = size + 8;
245 return (
246 `<rect class="${cls}-bg" x="${x - w / 2}" y="${y - h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>` +
247 `<text class="${cls}" x="${x}" y="${y}" font-size="${size}" text-anchor="middle" dominant-baseline="central">${rich(text)}</text>`
248 );
249}
250
251function markers(id: string): string {
252 const tones: Tone[] = ["default", "accent", "success", "warn"];
253 return (
254 "<defs>" +
255 tones
256 .map(
257 (tone) =>
258 `<marker id="${id}-arrow-${tone}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
259 `<path d="M1,1 L9,5 L1,9 z" class="d-head d-tone-${tone}"/></marker>`,
260 )
261 .join("") +
262 "</defs>"
263 );
264}
265
266function renderNode(node: GraphNode): string {
267 const b = box(node);
268 const tone = node.tone ?? (node.shape === "store" ? "store" : "default");
269 let shape: string;
270 if (node.shape === "store") {
271 // A cylinder: the body, then the rim of its top.
272 const ry = 6;
273 shape =
274 `<path class="d-node d-tone-${tone}" d="M${b.x},${b.y + ry} A${b.w / 2},${ry} 0 0 1 ${b.x + b.w},${b.y + ry} V${b.y + b.h - ry} A${b.w / 2},${ry} 0 0 1 ${b.x},${b.y + b.h - ry} Z"/>` +
275 `<path class="d-rim d-tone-${tone}" d="M${b.x},${b.y + ry} A${b.w / 2},${ry} 0 0 0 ${b.x + b.w},${b.y + ry}"/>`;
276 } else {
277 shape = `<rect class="d-node d-tone-${tone}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="9"/>`;
278 }
279 const cx = b.x + b.w / 2;
280 const top = node.shape === "store" ? 6 : 0;
281 const cy = b.y + top / 2 + b.h / 2;
282 const text = node.sub
283 ? `<text class="d-title" x="${cx}" y="${cy - 8}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>` +
284 `<text class="d-sub" x="${cx}" y="${cy + 9}" text-anchor="middle" dominant-baseline="central">${rich(node.sub)}</text>`
285 : `<text class="d-title" x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>`;
286 return `<g class="d-box">${shape}${text}</g>`;
287}
288
289export function renderGraph(id: string, graph: Graph): string {
290 const boxes = new Map<string, Box>(graph.nodes.map((node) => [node.id, box(node)]));
291 for (const g of graph.groups ?? []) if (g.id) boxes.set(g.id, g);
292 const groups = (graph.groups ?? [])
293 .map(
294 (g) =>
295 `<rect class="d-group d-tone-${g.tone ?? "default"}" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="14"/>` +
296 `<text class="d-group-label d-tone-${g.tone ?? "default"}" x="${g.x + 14}" y="${g.y + 18}" dominant-baseline="central">${rich(g.label)}</text>`,
297 )
298 .join("");
299 const edges: string[] = [];
300 const labels: string[] = [];
301 for (const edge of graph.edges) {
302 const from = boxes.get(edge.from);
303 const to = boxes.get(edge.to);
304 if (!from || !to) throw new Error(`Diagram ${id}: an arrow joins ${edge.from} to ${edge.to}, and one of them is not a box.`);
305 const points = route(edge, from, to);
306 const tone = edge.tone ?? "default";
307 const marker = `url(#${id}-arrow-${tone})`;
308 edges.push(
309 `<path class="d-edge d-tone-${tone}${edge.async ? " d-async" : ""}" d="${rounded(points)}" marker-end="${marker}"${edge.both ? ` marker-start="${marker}"` : ""}/>`,
310 );
311 if (edge.label) {
312 const [lx, ly] = edge.labelAt ?? labelPoint(points);
313 labels.push(pill(lx, ly, edge.label, 10.5, "d-label"));
314 }
315 }
316 return (
317 `<svg viewBox="0 0 ${graph.width} ${graph.height}" width="${graph.width}" height="${graph.height}" role="img" aria-labelledby="${id}-title ${id}-desc" class="d-svg">` +
318 `<title id="${id}-title">${esc(graph.caption)}</title>` +
319 `<desc id="${id}-desc">${esc(describe(graph))}</desc>` +
320 markers(id) +
321 groups +
322 edges.join("") +
323 graph.nodes.map(renderNode).join("") +
324 labels.join("") +
325 "</svg>"
326 );
327}
328
329// ── Sequence ───────────────────────────────────────────────────────────────
330
331const SEQ_HEAD = 50;
332const SEQ_ROW = 42;
333
334export function renderSequence(id: string, seq: Sequence): string {
335 const width = seq.width ?? 760;
336 const margin = 16;
337 const col = (width - margin * 2) / seq.actors.length;
338 const xs = new Map(seq.actors.map((actor, i) => [actor.id, margin + col * (i + 0.5)]));
339 const at = (actor: string) => {
340 const x = xs.get(actor);
341 if (x === undefined) throw new Error(`Diagram ${id}: no actor named ${actor}.`);
342 return x;
343 };
344 const headW = Math.min(col - 14, 168);
345
346 const body: string[] = [];
347 let y = SEQ_HEAD + 26;
348 let n = 0;
349 for (const step of seq.steps) {
350 if ("phase" in step) {
351 y += 4;
352 body.push(`<line class="d-phase" x1="${margin}" y1="${y}" x2="${width - margin}" y2="${y}"/>`);
353 body.push(pill(width / 2, y, step.phase, 10.5, "d-phase-label"));
354 y += 30;
355 continue;
356 }
357 if ("note" in step) {
358 const sx = step.over.map(at);
359 const left = Math.max(margin, Math.min(...sx) - Math.min(col / 2 - 8, 92));
360 const right = Math.min(width - margin, Math.max(...sx) + Math.min(col / 2 - 8, 92));
361 body.push(`<rect class="d-note" x="${left}" y="${y - 12}" width="${right - left}" height="26" rx="6"/>`);
362 body.push(`<text class="d-note-text" x="${(left + right) / 2}" y="${y + 1}" text-anchor="middle" dominant-baseline="central">${rich(step.note)}</text>`);
363 y += 38;
364 continue;
365 }
366 n++;
367 const x1 = at(step.from);
368 const x2 = at(step.to);
369 const cls = `d-msg${step.reply || step.async ? " d-async" : ""}${step.reply ? " d-reply" : ""}`;
370 const marker = `url(#${id}-arrow-${step.reply ? "default" : "accent"})`;
371 if (x1 === x2) {
372 // To itself: a loop out to the right and back.
373 const loop = 30;
374 body.push(`<path class="${cls}" d="M${x1},${y} H${x1 + loop} V${y + 16} H${x1 + 3}" marker-end="${marker}"/>`);
375 body.push(badge(x1 - 14, y + 8, n));
376 body.push(`<text class="d-msg-label" x="${x1 + loop + 8}" y="${y + 8}" dominant-baseline="central">${rich(step.label)}</text>`);
377 y += SEQ_ROW + 10;
378 continue;
379 }
380 const dir = x2 > x1 ? 1 : -1;
381 body.push(`<line class="${cls}" x1="${x1}" y1="${y}" x2="${x2 - dir * 3}" y2="${y}" marker-end="${marker}"/>`);
382 body.push(`<text class="d-msg-label" x="${(x1 + x2) / 2}" y="${y - 9}" text-anchor="middle">${rich(step.label)}</text>`);
383 body.push(badge(x1 - dir * 13, y, n));
384 y += SEQ_ROW;
385 }
386 const height = y + 4;
387
388 const heads = seq.actors
389 .map((actor) => {
390 const x = at(actor.id);
391 const tone = actor.tone ?? "default";
392 const title = actor.sub
393 ? `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2 - 8}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>` +
394 `<text class="d-sub" x="${x}" y="${SEQ_HEAD / 2 + 9}" text-anchor="middle" dominant-baseline="central">${rich(actor.sub)}</text>`
395 : `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>`;
396 return (
397 `<line class="d-lifeline" x1="${x}" y1="${SEQ_HEAD}" x2="${x}" y2="${height}"/>` +
398 `<rect class="d-node d-tone-${tone}" x="${x - headW / 2}" y="1" width="${headW}" height="${SEQ_HEAD - 2}" rx="9"/>` +
399 title
400 );
401 })
402 .join("");
403
404 return (
405 `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${id}-title ${id}-desc" class="d-svg">` +
406 `<title id="${id}-title">${esc(seq.caption)}</title>` +
407 `<desc id="${id}-desc">${esc(describe(seq))}</desc>` +
408 markers(id) +
409 heads +
410 body.join("") +
411 "</svg>"
412 );
413}
414
415function badge(x: number, y: number, n: number): string {
416 return (
417 `<circle class="d-badge" cx="${x}" cy="${y}" r="8"/>` +
418 `<text class="d-badge-text" x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central">${n}</text>`
419 );
420}
421
422// ── As text ────────────────────────────────────────────────────────────────
423
424/**
425 * The diagram as Markdown: for the page's Markdown twin, which agents and
426 * "Copy page" read. A graph lists its boxes by frame, then its arrows; a
427 * sequence lists its messages with the numbers the picture shows.
428 */
429export function toMarkdown(spec: Spec): string {
430 const lines = [`**Diagram: ${spec.caption}**`, ""];
431 if (spec.kind === "sequence") {
432 const name = new Map(spec.actors.map((a) => [a.id, plain(a.title)]));
433 let n = 0;
434 for (const step of spec.steps) {
435 if ("phase" in step) lines.push("", `*${plain(step.phase)}*`, "");
436 else if ("note" in step) lines.push(` - Note (${step.over.map((id) => name.get(id)).join(", ")}): ${step.note}`);
437 else {
438 n++;
439 const arrow = step.reply ? "⇠" : step.async ? "⇢" : "→";
440 lines.push(`${n}. ${name.get(step.from)} ${arrow} ${name.get(step.to)}: ${step.label}`);
441 }
442 }
443 return lines.join("\n");
444 }
445 const inGroup = (node: GraphNode, g: GraphGroup) =>
446 node.x >= g.x && node.y >= g.y && node.x + (node.w ?? NODE_W) <= g.x + g.w && node.y + (node.h ?? NODE_H) <= g.y + g.h;
447 const named = (node: GraphNode) => `${node.title}${node.sub ? ` (${node.sub})` : ""}`;
448 const placed = new Set<string>();
449 for (const g of spec.groups ?? []) {
450 const members = spec.nodes.filter((node) => !placed.has(node.id) && inGroup(node, g));
451 if (!members.length) continue;
452 members.forEach((node) => placed.add(node.id));
453 lines.push(`- ${g.label}: ${members.map(named).join("; ")}`);
454 }
455 const loose = spec.nodes.filter((node) => !placed.has(node.id));
456 if (loose.length) lines.push(`- ${loose.map(named).join("; ")}`);
457 lines.push("", "Arrows:", "");
458 const title = new Map(spec.nodes.map((node) => [node.id, plain(node.title)]));
459 for (const g of spec.groups ?? []) if (g.id) title.set(g.id, plain(g.label));
460 for (const edge of spec.edges) {
461 const arrow = edge.both ? "↔" : edge.async ? "⇢" : "→";
462 lines.push(`- ${title.get(edge.from)} ${arrow} ${title.get(edge.to)}${edge.label ? `: ${edge.label}` : ""}${edge.async ? " (asynchronous)" : ""}`);
463 }
464 return lines.join("\n");
465}
466
467/** One paragraph for the SVG's <desc>: the text twin without its Markdown. */
468function describe(spec: Spec): string {
469 return plain(toMarkdown(spec).replace(/\*\*/g, "").replace(/\*/g, "")).split("\n").filter(Boolean).join(" ");
470}
471
472export function render(id: string, spec: Spec): string {
473 return spec.kind === "graph" ? renderGraph(id, spec) : renderSequence(id, spec);
474}