@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 +1 -1
- package/src/index.ts +1 -1
- package/src/loom-mermaid/diagrams/architecture.ts +131 -0
- package/src/loom-mermaid/graph-render.ts +2 -0
- package/src/loom-mermaid/graph.ts +4 -0
- package/src/loom-mermaid/index.ts +3 -3
- package/src/loom-mermaid/layout.ts +83 -9
- package/src/loom-mermaid/registry.ts +3 -0
package/package.json
CHANGED
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
|
+
}
|
|
@@ -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`),
|
|
18
|
-
* `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1875
|
-
?
|
|
1876
|
-
: to.rank
|
|
1877
|
-
?
|
|
1878
|
-
:
|
|
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
|
-
|
|
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,
|