@yassimba/pi-loom-mermaid 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yassimba/pi-loom-mermaid",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Loom's advanced Mermaid renderer for Pi, with better routing and color.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -110,6 +110,6 @@ export function transformMermaidMarkdown(markdown: string, context: TransformCon
110
110
  export default function piLovelyMermaid(pi: ExtensionAPI): void {
111
111
  pi.registerMarkdownTransformer(transformMermaidMarkdown);
112
112
  pi.on("before_agent_start", (event) => ({
113
- systemPrompt: `${event.systemPrompt}\n\nUse fenced \`mermaid\` blocks; they render automatically in the user’s session. Use Mermaid proactively to explain relationships, flows, and changes. Choose by subject: flowchart for dependencies/decisions, sequence for interactions, state for lifecycles, ER/class for models, mindmap for hierarchies, timeline/git graph for history, pie for proportions. Use complementary diagrams when explaining multiple aspects. Keep diagram labels short. Mark changes (like diff but also other changes) with: :::red removed, :::green added, :::orange changed. In general prefer colored outlines to logically group things (if there are no changes involved).`,
113
+ systemPrompt: `${event.systemPrompt}\n\nUse fenced \`mermaid\` blocks; they render automatically in the user’s session. Use Mermaid proactively to explain relationships, flows, and changes. Choose by subject: architecture for deployed services, flowchart for dependencies/decisions, sequence for interactions, state for lifecycles, ER/class for models, mindmap for hierarchies, timeline/git graph for history, pie for proportions. Use complementary diagrams when explaining multiple aspects. Keep diagram labels short. Mark changes (like diff but also other changes) with: :::red removed, :::green added, :::orange changed. In general prefer colored outlines to logically group things (if there are no changes involved).`,
114
114
  }));
115
115
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `architecture-beta`: services and junctions inside nested groups.
3
+ *
4
+ * Built-in icons use stable Unicode stand-ins; custom Iconify names remain
5
+ * visible as text because a terminal cannot draw their SVGs.
6
+ */
7
+
8
+ import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS, type PortSide } from '../graph.ts'
9
+ import { cleanLabel } from '../labels.ts'
10
+ import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
11
+ import type { Diagram } from '../registry.ts'
12
+ import { firstWord, headerKind, statementsOf } from '../statements.ts'
13
+
14
+ export const architecture: Diagram = {
15
+ kind: 'architecture',
16
+ headers: ['architecture-beta'],
17
+ render(src, limits) {
18
+ const graph = parseArchitecture(src)
19
+ if (graph === null) return null
20
+ const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
21
+ if (canvas === null) return null
22
+ return { canvas, warnings: graph.warnings, classDefs: {} }
23
+ },
24
+ }
25
+
26
+ const DECLARATION = /^(group|service)\s+([^\s()[\]:{}]+)\(([^)]*)\)\[([^\]]*)\](?:\s+in\s+([^\s]+))?$/i
27
+ const JUNCTION = /^junction\s+([^\s:{}]+)(?:\s+in\s+([^\s]+))?$/i
28
+ const EDGE = /^([^\s:{}]+)(?:\{group\})?:([TBLR])\s*(<)?--(>)?\s*([TBLR]):([^\s:{}]+)(?:\{group\})?$/i
29
+ const SIDES: Record<string, PortSide> = { T: 'top', B: 'bottom', L: 'left', R: 'right' }
30
+ const ICONS: Record<string, string> = {
31
+ cloud: '☁',
32
+ database: '◉',
33
+ disk: '▰',
34
+ internet: '◎',
35
+ server: '▣',
36
+ }
37
+
38
+ function parseArchitecture(src: string): Graph | null {
39
+ const statements = statementsOf(src)
40
+ if (headerKind(statements) !== 'architecture-beta') return null
41
+
42
+ // Architecture has no global direction. LR gives grouped boundary edges the
43
+ // existing router's more precise inner-node anchors.
44
+ const graph = new Graph('right')
45
+ const groupIndex = new Map<string, number>()
46
+
47
+ for (const st of statements.slice(1)) {
48
+ const declaration = st.match(DECLARATION)
49
+ const junction = st.match(JUNCTION)
50
+ const edge = st.match(EDGE)
51
+
52
+ if (declaration) {
53
+ const [, kind, id, icon, rawLabel, parentId] = declaration
54
+ const parent = parentId === undefined ? null : groupIndex.get(parentId)
55
+ if (parentId !== undefined && parent === undefined) {
56
+ graph.drop(st)
57
+ } else if (kind.toLowerCase() === 'group') {
58
+ if (groupIndex.has(id)) {
59
+ graph.drop(st)
60
+ } else if (
61
+ graph.groups.length >= MAX_GROUPS ||
62
+ groupDepth(graph, parent ?? null) >= MAX_GROUP_DEPTH
63
+ ) {
64
+ graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
65
+ } else {
66
+ groupIndex.set(id, graph.groups.length)
67
+ graph.groups.push({ id, label: iconLabel(icon, rawLabel || id), parent: parent ?? null })
68
+ }
69
+ } else {
70
+ addNode(graph, id, iconLabel(icon, rawLabel || id), parent ?? null, 'rect')
71
+ }
72
+ } else if (junction) {
73
+ const [, id, parentId] = junction
74
+ const parent = parentId === undefined ? null : groupIndex.get(parentId)
75
+ if (parentId !== undefined && parent === undefined) graph.drop(st)
76
+ else addNode(graph, id, '•', parent ?? null, 'round')
77
+ } else if (edge) {
78
+ const [, fromId, fromPort, leftArrow, rightArrow, toPort, toId] = edge
79
+ const from = graph.index.get(fromId)
80
+ const to = graph.index.get(toId)
81
+ if (from === undefined || to === undefined) {
82
+ graph.drop(st)
83
+ } else {
84
+ graph.pushEdge({
85
+ from,
86
+ to,
87
+ label: null,
88
+ headFrom: leftArrow ? 'arrow' : 'none',
89
+ headTo: rightArrow ? 'arrow' : 'none',
90
+ line: 'solid',
91
+ fromSide: SIDES[fromPort.toUpperCase()],
92
+ toSide: SIDES[toPort.toUpperCase()],
93
+ })
94
+ }
95
+ } else if (firstWord(st).toLowerCase() !== 'title') {
96
+ graph.drop(st)
97
+ }
98
+
99
+ if (graph.truncated !== null) {
100
+ graph.warnings.push(`diagram truncated: ${graph.truncated}`)
101
+ break
102
+ }
103
+ }
104
+
105
+ return graph.nodes.length === 0 ? null : graph
106
+ }
107
+
108
+ function iconLabel(icon: string, rawLabel: string): string {
109
+ const name = cleanLabel(icon)
110
+ const mark = ICONS[name.toLowerCase()] ?? `[${name.split(':').at(-1)}]`
111
+ return `${mark} ${cleanLabel(rawLabel)}`
112
+ }
113
+
114
+ function addNode(
115
+ graph: Graph,
116
+ id: string,
117
+ label: string,
118
+ group: number | null,
119
+ shape: 'rect' | 'round',
120
+ ): void {
121
+ const previous = graph.curGroup
122
+ graph.curGroup = group
123
+ graph.nodeIndex(id, label, shape)
124
+ graph.curGroup = previous
125
+ }
126
+
127
+ function groupDepth(graph: Graph, parent: number | null): number {
128
+ let depth = 0
129
+ for (let at = parent; at !== null; at = graph.groups[at].parent) depth++
130
+ return depth
131
+ }
@@ -255,6 +255,8 @@ function buildScope(
255
255
  headTo: e.headTo,
256
256
  headFrom: e.headFrom,
257
257
  line: e.line,
258
+ fromSide: e.fromSide,
259
+ toSide: e.toSide,
258
260
  fromAnchor: anchorOf(f, e.from),
259
261
  toAnchor: anchorOf(t, e.to),
260
262
  }
@@ -26,6 +26,7 @@ export type Head =
26
26
  | 'diamondOpen'
27
27
 
28
28
  export type LineKind = 'solid' | 'dotted' | 'thick'
29
+ export type PortSide = 'top' | 'bottom' | 'left' | 'right'
29
30
 
30
31
  type Dir = 'down' | 'up' | 'right' | 'left'
31
32
 
@@ -66,6 +67,9 @@ export interface Edge {
66
67
  headTo: Head
67
68
  headFrom: Head
68
69
  line: LineKind
70
+ /** Exact attachment sides requested by diagrams such as `architecture-beta`. */
71
+ fromSide?: PortSide
72
+ toSide?: PortSide
69
73
  /**
70
74
  * Set on an end that stands for a subgraph frame: the inner node the
71
75
  * author actually named, as a box in the frame's sub-canvas coordinates.
@@ -14,9 +14,9 @@ export type { MermaidArt, Role, Span } from './types.ts'
14
14
  /**
15
15
  * Render a Mermaid source block as Unicode box-drawing art.
16
16
  *
17
- * Supported: `graph`/`flowchart` (including `subgraph`), `stateDiagram`,
18
- * `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`, `mindmap`,
19
- * `timeline` and `gitGraph`.
17
+ * Supported: `architecture-beta`, `graph`/`flowchart` (including `subgraph`),
18
+ * `stateDiagram`, `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`,
19
+ * `mindmap`, `timeline` and `gitGraph`.
20
20
  *
21
21
  * The diagram is laid out at whatever size it needs; `art.width` reports the
22
22
  * columns that turned out to be. Given `maxWidth`, a diagram wider than that
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Canvas } from './canvas.ts'
14
- import type { Anchor, Edge, LineKind } from './graph.ts'
14
+ import type { Anchor, Edge, LineKind, PortSide } from './graph.ts'
15
15
  import type { Graph } from './graph.ts'
16
16
  import { fitLabel, type Limits, wrapLabel } from './labels.ts'
17
17
  import { brandesKoepf, type LayeredGraph } from './placement.ts'
@@ -117,7 +117,7 @@ interface Port {
117
117
  wanted: number
118
118
  }
119
119
 
120
- type Side = 'top' | 'bottom' | 'left' | 'right'
120
+ type Side = PortSide
121
121
 
122
122
  function framePort(
123
123
  sub: Canvas,
@@ -1690,7 +1690,9 @@ function placeLr(
1690
1690
  })
1691
1691
  }
1692
1692
  const forward = (e: Edge): boolean => e.from !== e.to && ranks[e.to] > ranks[e.from]
1693
- let ends = resolve((i) => (forward(graph.edges[i]) ? ['right', 'left'] : null))
1693
+ const requestedSides = (e: Edge): [Side, Side] | null =>
1694
+ e.fromSide !== undefined && e.toSide !== undefined ? [e.fromSide, e.toSide] : null
1695
+ let ends = resolve((i) => requestedSides(graph.edges[i]) ?? (forward(graph.edges[i]) ? ['right', 'left'] : null))
1694
1696
  // A node whose incoming edges all leave their frames at one row off the
1695
1697
  // frame's centre sits that far off its own aligned position, so the
1696
1698
  // edges run straight rather than jog to it (`[*]` after a composite
@@ -1753,6 +1755,8 @@ function placeLr(
1753
1755
  })
1754
1756
  ends = resolve((i) => {
1755
1757
  const e = graph.edges[i]
1758
+ const requested = requestedSides(e)
1759
+ if (requested !== null) return requested
1756
1760
  if (e.from === e.to) return null
1757
1761
  if (ranks[e.to] === ranks[e.from] + 1 || edgeStraight[i]) return ['right', 'left']
1758
1762
  return ranks[e.to] < ranks[e.from] ? ['top', 'top'] : ['bottom', 'bottom']
@@ -1871,14 +1875,40 @@ function placeLr(
1871
1875
  const [from, to] = endsOf(i)
1872
1876
  const through = ends[i].flatMap((p, k) => (p === null ? [] : portAt(p, placed[k === 0 ? edge.from : edge.to]).through))
1873
1877
  const route =
1874
- to.rank === from.rank + 1
1875
- ? forwardRouteLr(from, to, edge, bandEnd[from.rank] + 1 + edgeBus[i], max, bundleOf(i) !== undefined)
1876
- : to.rank > from.rank && edgeStraight[i]
1877
- ? skipRouteLr(from, to, edge, skipRoute[i], max)
1878
- : laneRoute(from, to, edge, onTop(i) ? edgeLane[i] : laneBase + edgeLane[i], max, onTop(i), laneEntry(i, from, to))
1878
+ edge.fromSide !== undefined && edge.toSide !== undefined
1879
+ ? portRoute(from, to, edge.fromSide, edge.toSide)
1880
+ : to.rank === from.rank + 1
1881
+ ? forwardRouteLr(from, to, edge, bandEnd[from.rank] + 1 + edgeBus[i], max, bundleOf(i) !== undefined)
1882
+ : to.rank > from.rank && edgeStraight[i]
1883
+ ? skipRouteLr(from, to, edge, skipRoute[i], max)
1884
+ : laneRoute(from, to, edge, onTop(i) ? edgeLane[i] : laneBase + edgeLane[i], max, onTop(i), laneEntry(i, from, to))
1879
1885
  return through.length === 0 ? route : { ...route, through: [...(route.through ?? []), ...through] }
1880
1886
  })
1881
- return { canvasW, canvasH, routes }
1887
+
1888
+ if (!graph.edges.some((e) => e.fromSide !== undefined)) return { canvasW, canvasH, routes }
1889
+ // Exact side ports may face out of the outermost box. Two cells of margin
1890
+ // keep their first/last segments on-canvas at every nested group level.
1891
+ for (const p of placed) {
1892
+ p.x += 2
1893
+ p.y += 2
1894
+ p.cx += 2
1895
+ p.cy += 2
1896
+ }
1897
+ for (const route of routes) {
1898
+ route.points = route.points.map(([x, y]) => [x + 2, y + 2])
1899
+ route.labels = route.labels.map((label) => ({ ...label, row: label.row + 2, x: label.x + 2 }))
1900
+ if (route.laneLabel !== undefined) {
1901
+ route.laneLabel = {
1902
+ ...route.laneLabel,
1903
+ y: route.laneLabel.y + 2,
1904
+ lo: route.laneLabel.lo + 2,
1905
+ hi: route.laneLabel.hi + 2,
1906
+ }
1907
+ }
1908
+ if (route.through !== undefined)
1909
+ route.through = route.through.map(([x, y, kind]) => [x + 2, y + 2, kind])
1910
+ }
1911
+ return { canvasW: canvasW + 4, canvasH: canvasH + 4, routes }
1882
1912
  }
1883
1913
 
1884
1914
  // -------------------------------------------------------------------- canvas
@@ -2213,6 +2243,50 @@ function chainRoute(
2213
2243
  return { points, labels: chainLabel(edge, headRow, entryX, labelLeft, labelAt, max) }
2214
2244
  }
2215
2245
 
2246
+ /** An orthogonal route that leaves and enters the exact requested box sides. */
2247
+ function portRoute(from: Placed, to: Placed, fromSide: PortSide, toSide: PortSide): Route {
2248
+ const normal = (side: PortSide): [number, number] =>
2249
+ side === 'left' ? [-1, 0] : side === 'right' ? [1, 0] : side === 'top' ? [0, -1] : [0, 1]
2250
+ const border = (p: Placed, side: PortSide): [number, number] =>
2251
+ side === 'left'
2252
+ ? [p.x, p.cy]
2253
+ : side === 'right'
2254
+ ? [p.x + p.w - 1, p.cy]
2255
+ : side === 'top'
2256
+ ? [p.cx, p.y]
2257
+ : [p.cx, p.y + p.h - 1]
2258
+ const move = ([x, y]: [number, number], [dx, dy]: [number, number]): [number, number] => [x + dx, y + dy]
2259
+
2260
+ const fromNormal = normal(fromSide)
2261
+ const toNormal = normal(toSide)
2262
+ const start = border(from, fromSide)
2263
+ const startOut = move(start, fromNormal)
2264
+ const head = move(border(to, toSide), toNormal)
2265
+ const targetOut = move(head, toNormal)
2266
+ const fromHorizontal = fromNormal[0] !== 0
2267
+ const toHorizontal = toNormal[0] !== 0
2268
+ let middle: [number, number][]
2269
+
2270
+ if (fromHorizontal !== toHorizontal) {
2271
+ middle = [fromHorizontal ? [targetOut[0], startOut[1]] : [startOut[0], targetOut[1]]]
2272
+ } else if (fromHorizontal) {
2273
+ const direction = Math.sign(targetOut[0] - startOut[0])
2274
+ const direct = direction === fromNormal[0] && direction === -toNormal[0]
2275
+ const lane = Math.max(from.y + from.h, to.y + to.h) + 1
2276
+ middle = direct ? [] : [[startOut[0], lane], [targetOut[0], lane]]
2277
+ } else {
2278
+ const direction = Math.sign(targetOut[1] - startOut[1])
2279
+ const direct = direction === fromNormal[1] && direction === -toNormal[1]
2280
+ const lane = Math.max(from.x + from.w, to.x + to.w) + 1
2281
+ middle = direct ? [] : [[lane, startOut[1]], [lane, targetOut[1]]]
2282
+ }
2283
+
2284
+ const points = [start, startOut, ...middle, targetOut, head].filter(
2285
+ ([x, y], i, all) => i === 0 || x !== all[i - 1][0] || y !== all[i - 1][1],
2286
+ ) as [number, number][]
2287
+ return { points, labels: [] }
2288
+ }
2289
+
2216
2290
  /**
2217
2291
  * Adjacent ranks, left-to-right: out the right side, jog on the bus
2218
2292
  * column. The verb keeps its usual spot above the line; cardinalities hug
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Canvas } from './canvas.ts'
11
+ import { architecture } from './diagrams/architecture.ts'
11
12
  import { classDiagram } from './diagrams/class.ts'
12
13
  import { er } from './diagrams/er.ts'
13
14
  import { flowchart } from './diagrams/flowchart.ts'
@@ -22,6 +23,7 @@ import { headerKind, statementsOf } from './statements.ts'
22
23
 
23
24
  /** A diagram type this renderer draws. */
24
25
  export type DiagramKind =
26
+ | 'architecture'
25
27
  | 'flowchart'
26
28
  | 'state'
27
29
  | 'class'
@@ -52,6 +54,7 @@ export interface Diagram {
52
54
  }
53
55
 
54
56
  const DIAGRAMS: Diagram[] = [
57
+ architecture,
55
58
  flowchart,
56
59
  state,
57
60
  classDiagram,