mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61549b35d5 |
@@ -301,9 +301,9 @@ function drawSourceConnectors(
|
||||
)
|
||||
}
|
||||
}
|
||||
fadeSourcePath(grid, connector, route.points, styles, occupancy)
|
||||
if (nodesById.has(route.edge.from)) fadeSourcePath(grid, connector, route.points, styles, occupancy)
|
||||
if (route.edge.sourceArrowhead && route.points[1]) {
|
||||
grid.setCell(sourcePoint.x, sourcePoint.y, diagramArrowHeadBetween(route.points[1], sourcePoint), "edge")
|
||||
grid.setCell(sourcePoint.x, sourcePoint.y, diagramArrowHeadBetween(sourcePoint, connector), "edge")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,7 +328,17 @@ export function drawFlowchartDiagramGrid(
|
||||
const bound = bounds.get(node.id)
|
||||
if (bound) drawNode(grid, node, bound, borderStyle)
|
||||
}
|
||||
drawSourceConnectors(grid, diagram, bounds, routes)
|
||||
drawSourceConnectors(
|
||||
grid,
|
||||
diagram,
|
||||
new Map([
|
||||
...bounds,
|
||||
...[...subgraphBounds].map(
|
||||
([id, frame]) => [id, { ...frame, lines: [] }] satisfies [string, FlowchartNodeBounds],
|
||||
),
|
||||
]),
|
||||
routes,
|
||||
)
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphLabel(grid, bound)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
|
||||
import { avoidFlowchartFrameBorders, flowchartSourceConnector, routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
@@ -58,7 +58,7 @@ function horizontalRankGaps(
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
for (const edge of nodeRankEdges(diagram)) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
@@ -82,7 +82,7 @@ function verticalRankGaps(
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
for (const edge of nodeRankEdges(diagram)) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
@@ -132,15 +132,7 @@ function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
const incoming = new Set<string>()
|
||||
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
||||
|
||||
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
|
||||
const rankEdges = diagram.edges.flatMap((edge) => {
|
||||
if (!edge.orderOnly) return [edge]
|
||||
const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
|
||||
const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
|
||||
return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
|
||||
})
|
||||
|
||||
for (const edge of rankEdges) {
|
||||
for (const edge of nodeRankEdges(diagram)) {
|
||||
const list = outgoing.get(edge.from) ?? []
|
||||
list.push(edge.to)
|
||||
outgoing.set(edge.from, list)
|
||||
@@ -187,6 +179,18 @@ function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
return ranks
|
||||
}
|
||||
|
||||
function nodeRankEdges(diagram: FlowchartDiagram): FlowchartEdge[] {
|
||||
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
|
||||
return diagram.edges.flatMap((edge) => {
|
||||
if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) return [edge]
|
||||
const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
|
||||
const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
|
||||
// A frame and its contents do not occupy separate ranks in their own scope.
|
||||
if (fromIds.some((id) => toIds.includes(id))) return []
|
||||
return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
|
||||
})
|
||||
}
|
||||
|
||||
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
||||
translateDiagramBounds(bounds, dx, dy)
|
||||
}
|
||||
@@ -219,14 +223,18 @@ function subgraphBoundFromChildren(
|
||||
id: string,
|
||||
label: string,
|
||||
children: readonly FlowchartBounds[],
|
||||
padding?: Partial<Record<"left" | "right" | "top" | "bottom", number>>,
|
||||
): FlowchartSubgraphBounds {
|
||||
const labelLines = splitDiagramLines(label)
|
||||
const labelHeight = labelLines.length
|
||||
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
||||
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
||||
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
||||
let left = Math.min(...children.map((child) => child.left)) - Math.max(SUBGRAPH_PADDING_X, padding?.left ?? 0)
|
||||
const top =
|
||||
Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight, padding?.top ?? 0)
|
||||
let right =
|
||||
Math.max(...children.map((child) => child.left + child.width)) + Math.max(SUBGRAPH_PADDING_X, padding?.right ?? 0)
|
||||
const bottom =
|
||||
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
||||
Math.max(...children.map((child) => child.top + child.height)) +
|
||||
Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight, padding?.bottom ?? 0)
|
||||
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
||||
|
||||
if (right - left < minWidth) {
|
||||
@@ -317,11 +325,24 @@ function chooseSubgraphLabelSide(
|
||||
bounds: FlowchartSubgraphBounds,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): FlowchartSubgraphBounds["labelSide"] {
|
||||
// Include source border contacts, not only the route's terminal cells.
|
||||
const overlaps = (slot: FlowchartBounds) =>
|
||||
routes.some(
|
||||
(route) =>
|
||||
routeOverlapsSlot(route, slot) ||
|
||||
(route.edge.from === bounds.id &&
|
||||
route.points[0] &&
|
||||
segmentOverlapsSlot(
|
||||
flowchartSourceConnector({ ...bounds, lines: [] }, route.points[0]),
|
||||
route.points[0],
|
||||
slot,
|
||||
)),
|
||||
)
|
||||
const topSlot = labelSlot(bounds, "top")
|
||||
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
||||
if (!overlaps(topSlot)) return "top"
|
||||
|
||||
const bottomSlot = labelSlot(bounds, "bottom")
|
||||
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
||||
return overlaps(bottomSlot) ? "top" : "bottom"
|
||||
}
|
||||
|
||||
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
||||
@@ -757,6 +778,9 @@ function separateTopLevelItems(
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
const needsFrameLayout =
|
||||
diagram.edges.some((edge) => subgraphById.has(edge.from) || subgraphById.has(edge.to)) ||
|
||||
subgraphs.some((subgraph) => collectSubgraphNodeIds(diagram, subgraph.id).size === 0)
|
||||
const topLevelSubgraphId = (id: string): string => {
|
||||
let current = subgraphById.get(id)
|
||||
while (current?.parentId) current = subgraphById.get(current.parentId)
|
||||
@@ -766,7 +790,7 @@ function separateTopLevelItems(
|
||||
if (subgraph.parentId) continue
|
||||
const bounds = subgraphBounds.get(subgraph.id)
|
||||
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
if (!bounds || nodeIds.size === 0) continue
|
||||
if (!bounds) continue
|
||||
items.push({ id: subgraph.id, bounds, nodeIds, rank: 0 })
|
||||
itemByEndpoint.set(subgraph.id, subgraph.id)
|
||||
for (const nodeId of nodeIds) {
|
||||
@@ -798,7 +822,7 @@ function separateTopLevelItems(
|
||||
if (bounds) translateBounds(bounds, dx, dy)
|
||||
}
|
||||
}
|
||||
if (hasLocalDirection) {
|
||||
if (hasLocalDirection && !needsFrameLayout) {
|
||||
const outgoing = new Map(items.map((item) => [item.id, new Set<string>()]))
|
||||
for (const edge of diagram.edges) {
|
||||
const from = itemByEndpoint.get(edge.from)
|
||||
@@ -831,8 +855,9 @@ function separateTopLevelItems(
|
||||
return moved
|
||||
}
|
||||
|
||||
const rankedItems = items.filter((item) => topLevelIds.has(item.id))
|
||||
const rankedItems = needsFrameLayout ? items : items.filter((item) => topLevelIds.has(item.id))
|
||||
if (rankedItems.length < 2) return false
|
||||
if (needsFrameLayout) gap = Math.max(gap, 2)
|
||||
|
||||
const itemById = new Map(rankedItems.map((item) => [item.id, item]))
|
||||
const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
|
||||
@@ -861,12 +886,30 @@ function separateTopLevelItems(
|
||||
let moved = false
|
||||
for (const rank of rankKeys) {
|
||||
const rankItems = itemsByRank.get(rank)!
|
||||
const rankGap = needsFrameLayout
|
||||
? Math.max(
|
||||
gap,
|
||||
...diagram.edges
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.label &&
|
||||
(subgraphById.has(edge.from) || subgraphById.has(edge.to)) &&
|
||||
(ranks.get(itemByEndpoint.get(edge.from) ?? "") ?? -1) <= rank &&
|
||||
(ranks.get(itemByEndpoint.get(edge.to) ?? "") ?? -1) > rank,
|
||||
)
|
||||
.map((edge) =>
|
||||
horizontal
|
||||
? flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
||||
: splitDiagramLines(edge.label).length + 2,
|
||||
),
|
||||
)
|
||||
: gap
|
||||
const start = Math.min(...rankItems.map(primaryStart))
|
||||
const end = Math.max(
|
||||
...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
|
||||
)
|
||||
if (cursor === undefined) {
|
||||
cursor = end + gap
|
||||
cursor = end + rankGap
|
||||
continue
|
||||
}
|
||||
const shift = Math.max(0, cursor - start)
|
||||
@@ -877,7 +920,7 @@ function separateTopLevelItems(
|
||||
moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
|
||||
}
|
||||
}
|
||||
cursor = end + shift + gap
|
||||
cursor = end + shift + rankGap
|
||||
}
|
||||
|
||||
for (const rank of rankKeys) {
|
||||
@@ -936,9 +979,11 @@ function layoutSubgraphs(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
previous?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): Map<string, FlowchartSubgraphBounds> {
|
||||
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
|
||||
for (const subgraph of [...subgraphs].reverse()) {
|
||||
const children: FlowchartBounds[] = []
|
||||
@@ -953,10 +998,79 @@ function layoutSubgraphs(
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
if (children.length > 0) {
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
||||
const memberIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
const contains = (id: string) => {
|
||||
if (memberIds.has(id)) return true
|
||||
let current = subgraphById.get(id)
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === subgraph.id) return true
|
||||
current = subgraphById.get(current.parentId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Empty frames have no node coordinates; separate their sibling scope before sizing its parent.
|
||||
if (
|
||||
subgraphs.some(
|
||||
(child) => child.parentId === subgraph.id && collectSubgraphNodeIds(diagram, child.id).size === 0,
|
||||
)
|
||||
) {
|
||||
let parent = subgraph.parentId ? subgraphById.get(subgraph.parentId) : undefined
|
||||
while (parent && !parent.direction) parent = parent.parentId ? subgraphById.get(parent.parentId) : undefined
|
||||
separateTopLevelItems(
|
||||
{
|
||||
direction: subgraph.direction ?? parent?.direction ?? diagram.direction,
|
||||
nodes: diagram.nodes.filter((node) => memberIds.has(node.id)),
|
||||
edges: diagram.edges.filter((edge) => contains(edge.from) && contains(edge.to)),
|
||||
subgraphs: subgraphs
|
||||
.filter((child) => contains(child.id))
|
||||
.map((child) => (child.parentId === subgraph.id ? { ...child, parentId: undefined } : child)),
|
||||
},
|
||||
nodeBounds,
|
||||
subgraphBounds,
|
||||
DEFAULT_MIN_NODE_GAP,
|
||||
)
|
||||
}
|
||||
const padding = { left: 0, right: 0, top: 0, bottom: 0 }
|
||||
for (const edge of diagram.edges) {
|
||||
if (edge.orderOnly) continue
|
||||
const source = edge.from === subgraph.id && contains(edge.to)
|
||||
const target = edge.to === subgraph.id && contains(edge.from)
|
||||
if (!source && !target) continue
|
||||
const direction = edgeDirection(diagram, edge)
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const reversed = direction === "BT" || direction === "RL"
|
||||
const side = horizontal ? (source !== reversed ? "left" : "right") : source !== reversed ? "top" : "bottom"
|
||||
const height = edge.label ? splitDiagramLines(edge.label).length : 0
|
||||
const width = edge.label ? flowchartLabelWidth(edge.label, visualLength) : 0
|
||||
padding[side] = Math.max(
|
||||
padding[side],
|
||||
horizontal && width > 0 ? flowchartHorizontalLabelRankGap(width) + 1 : Math.max(4, height + 3),
|
||||
)
|
||||
if (!horizontal && width > 0) padding.right = Math.max(padding.right, width + 1)
|
||||
if (horizontal && height > 1) padding.top = Math.max(padding.top, height + 1)
|
||||
}
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children, padding)
|
||||
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
subgraphBounds.set(subgraph.id, bound)
|
||||
continue
|
||||
}
|
||||
const lines = splitDiagramLines(subgraph.label)
|
||||
const width = Math.max(...lines.map(visualLength)) + 5
|
||||
const height = lines.length + 2
|
||||
subgraphBounds.set(
|
||||
subgraph.id,
|
||||
previous?.get(subgraph.id) ?? {
|
||||
id: subgraph.id,
|
||||
label: subgraph.label,
|
||||
labelSide: "top",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
centerX: Math.floor(width / 2),
|
||||
centerY: Math.floor(height / 2),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return subgraphBounds
|
||||
@@ -990,8 +1104,21 @@ function layoutFlowchartWithDirection(
|
||||
const bounds = ranked.bounds
|
||||
const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
|
||||
const directionAligned = responsiveFallback || responsive || ranked.wrapped
|
||||
// Frames are routing endpoints, never drawable nodes. Their members still determine rank ordering.
|
||||
const endpoints = (frames: ReadonlyMap<string, FlowchartSubgraphBounds>) =>
|
||||
new Map([
|
||||
...bounds,
|
||||
...[...frames].map(([id, frame]) => [id, { ...frame, lines: [] }] satisfies [string, FlowchartNodeBounds]),
|
||||
])
|
||||
const route = (frames?: ReadonlyMap<string, FlowchartSubgraphBounds>) =>
|
||||
routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), frames, targetWidth, directionAligned)
|
||||
routeFlowchartEdges(
|
||||
diagram,
|
||||
frames ? endpoints(frames) : bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
frames,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
@@ -1010,10 +1137,10 @@ function layoutFlowchartWithDirection(
|
||||
)
|
||||
if (moved) {
|
||||
routes = route()
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes, subgraphBounds)
|
||||
}
|
||||
routes = route(subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes, subgraphBounds)
|
||||
// Frame-aware routes can expand a group after initial separation. Move the expanded frame rigidly
|
||||
// before rerouting so external edges cannot grow it back across neighboring top-level items.
|
||||
const expanded = separateTopLevelItems(
|
||||
@@ -1023,8 +1150,11 @@ function layoutFlowchartWithDirection(
|
||||
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
||||
targetWidth,
|
||||
)
|
||||
if (expanded) routes = route(subgraphBounds)
|
||||
avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
|
||||
if (expanded || diagram.edges.some((edge) => subgraphBounds.has(edge.from) || subgraphBounds.has(edge.to))) {
|
||||
routes = route(subgraphBounds)
|
||||
}
|
||||
avoidFlowchartFrameBorders(routes, endpoints(subgraphBounds), subgraphBounds)
|
||||
for (const bound of subgraphBounds.values()) bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
}
|
||||
freezeRouteLabelPoints(routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
|
||||
@@ -7,20 +7,16 @@ import type {
|
||||
FlowchartSubgraph,
|
||||
} from "./types.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import {
|
||||
decodeMermaidText,
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
import { numberedMermaidLines, stripMermaidQuotes, type NumberedMermaidLine } from "../core/mermaid.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
|
||||
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
|
||||
// Deliberately more permissive than upstream Mermaid: path-like IDs may contain slashes.
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_./-]*"
|
||||
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
|
||||
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
|
||||
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle|click)\s+[A-Za-z_0-9]/i
|
||||
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
|
||||
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
|
||||
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
|
||||
@@ -32,7 +28,7 @@ const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
const CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
|
||||
const MAX_FLOWCHART_LINE_LENGTH = 10_000
|
||||
const EDGE_OPERATOR_RE =
|
||||
/(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
|
||||
/(<?-\.(?!->)(.+?)\.(?:->|-))|(<?(?:--|==|-\.))\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|<==>|<-\.->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
|
||||
|
||||
function normalizeDirection(value?: string): FlowchartDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
@@ -41,7 +37,7 @@ function normalizeDirection(value?: string): FlowchartDirection {
|
||||
}
|
||||
|
||||
function normalizeSubgraphId(value: string, index: number): string {
|
||||
const stripped = stripQuotes(value)
|
||||
const stripped = stripMermaidQuotes(value)
|
||||
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
|
||||
}
|
||||
|
||||
@@ -52,32 +48,32 @@ function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgrap
|
||||
.replace(/;$/, "")
|
||||
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
|
||||
if (withLabel) {
|
||||
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
|
||||
return { id: withLabel[1]!, label: stripMermaidQuotes(withLabel[2]!) }
|
||||
}
|
||||
|
||||
const label = stripQuotes(trimmed)
|
||||
const label = stripMermaidQuotes(trimmed)
|
||||
return { id: normalizeSubgraphId(trimmed, index), label }
|
||||
}
|
||||
|
||||
function parseNodeToken(token: string): FlowchartNode {
|
||||
const trimmed = token.trim().replace(/;$/, "")
|
||||
const database = trimmed.match(DATABASE_NODE_RE)
|
||||
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
|
||||
if (database) return { id: database[1]!, label: stripMermaidQuotes(database[2]!), shape: "database" }
|
||||
|
||||
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripMermaidQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
|
||||
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripMermaidQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
|
||||
const rounded = trimmed.match(ROUNDED_NODE_RE)
|
||||
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
|
||||
if (rounded) return { id: rounded[1]!, label: stripMermaidQuotes(rounded[2]!), shape: "rounded" }
|
||||
|
||||
const decision = trimmed.match(DECISION_NODE_RE)
|
||||
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
|
||||
if (decision) return { id: decision[1]!, label: stripMermaidQuotes(decision[2]!), shape: "decision" }
|
||||
|
||||
const box = trimmed.match(BOX_NODE_RE)
|
||||
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
|
||||
if (box) return { id: box[1]!, label: stripMermaidQuotes(box[2]!), shape: "box" }
|
||||
|
||||
return { id: trimmed, label: trimmed, shape: "box" }
|
||||
}
|
||||
@@ -167,7 +163,7 @@ function parseEdgeOperators(line: string): ParsedEdgeOperator[] {
|
||||
return {
|
||||
index: match.index,
|
||||
end: match.index + match[0].length,
|
||||
label: stripQuotes(labelRange ? line.slice(labelRange[0], labelRange[1]) : ""),
|
||||
label: stripMermaidQuotes(labelRange ? line.slice(labelRange[0], labelRange[1]) : ""),
|
||||
style: edgeStyleFromArrow(startArrow, endArrow),
|
||||
arrowhead: endArrow === "~~~" || endArrow.endsWith(">"),
|
||||
sourceArrowhead: startArrow.startsWith("<"),
|
||||
@@ -241,9 +237,12 @@ function hasInternalStatementSeparator(line: string): boolean {
|
||||
}
|
||||
|
||||
export function isMermaidFlowchartDiagram(content: string): boolean {
|
||||
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
// Detection runs before the renderer's raw-source fallback boundary; only parsing may throw.
|
||||
for (const source of flowchartLines(content, false)) return FLOWCHART_HEADER_RE.test(source.text)
|
||||
return false
|
||||
}
|
||||
|
||||
// Be lenient with LLM-generated labels and metadata, but never silently discard diagram structure.
|
||||
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
|
||||
const nodes = new Map<string, FlowchartNode>()
|
||||
const edges: FlowchartEdge[] = []
|
||||
@@ -251,7 +250,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
|
||||
let direction: FlowchartDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
for (const source of flowchartLines(content)) {
|
||||
const line = source.text
|
||||
if (line.length > MAX_FLOWCHART_LINE_LENGTH) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, "Flowchart statement is too long")
|
||||
@@ -263,9 +262,6 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
continue
|
||||
}
|
||||
|
||||
// Mermaid CSS styling does not apply to terminal theme rendering.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
const subgraphMatch = line.match(SUBGRAPH_RE)
|
||||
if (subgraphMatch) {
|
||||
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
|
||||
@@ -313,23 +309,11 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
]
|
||||
|
||||
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
|
||||
const unsupportedEndpoint = nodeTokens.find((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
return (
|
||||
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
|
||||
!isSupportedNodeToken(stripped)
|
||||
)
|
||||
})
|
||||
const unsupportedEndpoint = nodeTokens.find((token) => !isSupportedNodeToken(token))
|
||||
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
const chainNodeIds = nodeTokens.map((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
const chainNodeIds = nodeTokens.map((token) => ensureNode(nodes, stripNodeToken(token)).id)
|
||||
for (const nodeId of chainNodeIds) {
|
||||
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
|
||||
addNodeToSubgraph(currentSubgraph, nodeId)
|
||||
}
|
||||
for (let index = 0; index < edgeOperators.length; index++) {
|
||||
const operator = edgeOperators[index]!
|
||||
@@ -345,14 +329,18 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
if (isSupportedNodeToken(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
const node = ensureNode(nodes, stripNodeToken(line))
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Presentation does not apply to terminal rendering; structural uses of these IDs were parsed above.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
@@ -366,5 +354,41 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
)
|
||||
}
|
||||
|
||||
return { direction, nodes: [...nodes.values()], edges, subgraphs }
|
||||
// Resolve forward references to subgraphs without changing edge endpoint IDs.
|
||||
const subgraphIds = new Set(subgraphs.map((subgraph) => subgraph.id))
|
||||
for (const subgraph of subgraphs) subgraph.nodeIds = subgraph.nodeIds.filter((id) => !subgraphIds.has(id))
|
||||
return { direction, nodes: [...nodes.values()].filter((node) => !subgraphIds.has(node.id)), edges, subgraphs }
|
||||
}
|
||||
|
||||
function* flowchartLines(content: string, strict = true): Generator<NumberedMermaidLine> {
|
||||
let block: { source: NumberedMermaidLine; close: string } | undefined
|
||||
for (const source of numberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (!block) {
|
||||
const init = /^%%\{\s*init\s*:/i.test(line)
|
||||
const description = /^accDescr\s+\{/i.test(line)
|
||||
if (init || description) block = { source, close: init ? "}%%" : "}" }
|
||||
}
|
||||
if (block) {
|
||||
const end = line.indexOf(block.close)
|
||||
if (end !== -1) {
|
||||
if (line.slice(end + block.close.length).trim()) {
|
||||
if (!strict) return
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
block = undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!line || line.startsWith("%%") || /^(?:accTitle|accDescr)\s*:/i.test(line)) continue
|
||||
yield source
|
||||
}
|
||||
if (block && strict) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
block.source.lineNumber,
|
||||
block.source.text,
|
||||
`Unclosed metadata block; expected "${block.close}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +792,29 @@ function labelIntersectsRoutePaths(
|
||||
})
|
||||
}
|
||||
|
||||
function labelCutsOwnRoute(label: FlowchartEdgeLabelLayout | undefined, route: FlowchartEdgeRoute): boolean {
|
||||
if (!label) return false
|
||||
return label.lines.some((line, lineIndex) => {
|
||||
const bounds = {
|
||||
left: label.point.x,
|
||||
top: label.point.y + lineIndex,
|
||||
width: diagramTextWidth(line),
|
||||
height: 1,
|
||||
}
|
||||
return route.points.slice(1).some((to, index) => {
|
||||
const from = route.points[index]!
|
||||
if (!pathIntersectsBounds([from, to], bounds)) return false
|
||||
// Only a single-line label may interrupt a straight segment, without covering its ends or bends.
|
||||
return (
|
||||
label.height > 1 ||
|
||||
from.y !== to.y ||
|
||||
bounds.left <= Math.min(from.x, to.x) ||
|
||||
bounds.left + bounds.width - 1 >= Math.max(from.x, to.x)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly FlowchartEdgeLabelLayout[]): boolean {
|
||||
return labels.some((label) =>
|
||||
label.lines.some((line, lineIndex) => {
|
||||
@@ -879,13 +902,61 @@ function avoidNodeObstacles(
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
routeIndex: number,
|
||||
diagram: FlowchartDiagram,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartEdgeRoute {
|
||||
const allNodeBounds = [...bounds.values()]
|
||||
const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])]
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const contains = (subgraph: FlowchartSubgraph, nodeId: string): boolean =>
|
||||
subgraph.id === nodeId ||
|
||||
subgraph.nodeIds.includes(nodeId) ||
|
||||
subgraphs.some((child) => child.parentId === subgraph.id && contains(child, nodeId))
|
||||
const source = bounds.get(route.edge.from)
|
||||
const target = bounds.get(route.edge.to)
|
||||
const container =
|
||||
source && target && source.id !== target.id
|
||||
? subgraphs.find(
|
||||
(subgraph) =>
|
||||
(subgraph.id === source.id && contains(subgraph, target.id)) ||
|
||||
(subgraph.id === target.id && contains(subgraph, source.id)),
|
||||
)
|
||||
: undefined
|
||||
const containedFrame = container ? subgraphBounds?.get(container.id) : undefined
|
||||
if (containedFrame && source && target) {
|
||||
const isSource = containedFrame.id === source.id
|
||||
const member = isSource ? target : source
|
||||
const travel = direction === "BT" ? "up" : direction === "LR" ? "right" : direction === "RL" ? "left" : "down"
|
||||
const side = isSource ? oppositeSide(sideForDirection(travel)) : sideForDirection(travel)
|
||||
// The containing endpoint is a border, not a solid node. Its outside port faces the frame interior.
|
||||
const strip: FlowchartNodeBounds = {
|
||||
...containedFrame,
|
||||
lines: [],
|
||||
left: side === "right" ? containedFrame.left + containedFrame.width - 1 : containedFrame.left,
|
||||
top: side === "bottom" ? containedFrame.top + containedFrame.height - 1 : containedFrame.top,
|
||||
width: side === "left" || side === "right" ? 1 : containedFrame.width,
|
||||
height: side === "top" || side === "bottom" ? 1 : containedFrame.height,
|
||||
centerX:
|
||||
side === "left"
|
||||
? containedFrame.left
|
||||
: side === "right"
|
||||
? containedFrame.left + containedFrame.width - 1
|
||||
: member.centerX,
|
||||
centerY:
|
||||
side === "top"
|
||||
? containedFrame.top
|
||||
: side === "bottom"
|
||||
? containedFrame.top + containedFrame.height - 1
|
||||
: member.centerY,
|
||||
}
|
||||
bounds = new Map(bounds).set(strip.id, strip)
|
||||
route = {
|
||||
...route,
|
||||
points: edgePath(isSource ? strip : source, isSource ? target : strip, direction),
|
||||
labelPoint: undefined,
|
||||
}
|
||||
}
|
||||
const allNodeBounds = [...bounds.values()].filter(
|
||||
(bound) => !subgraphBounds?.has(bound.id) || bound.id === route.edge.from || bound.id === route.edge.to,
|
||||
)
|
||||
const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])]
|
||||
const owner = [...subgraphs].reverse().find((subgraph) => {
|
||||
if (!contains(subgraph, route.edge.from) || !contains(subgraph, route.edge.to)) return false
|
||||
const children = subgraphs.filter((child) => child.parentId === subgraph.id)
|
||||
@@ -895,7 +966,7 @@ function avoidNodeObstacles(
|
||||
!children.some((child) => contains(child, route.edge.from) && contains(child, route.edge.to))
|
||||
)
|
||||
})
|
||||
const ownerBounds = owner ? subgraphBounds?.get(owner.id) : undefined
|
||||
const ownerBounds = containedFrame ?? (owner ? subgraphBounds?.get(owner.id) : undefined)
|
||||
const leavesOwner = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
Boolean(
|
||||
ownerBounds &&
|
||||
@@ -919,6 +990,7 @@ function avoidNodeObstacles(
|
||||
return pathIntersectsBounds(candidate.points, bound, allowedContact)
|
||||
})
|
||||
const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
(containedFrame !== undefined && leavesOwner(candidate)) ||
|
||||
intersectsNode(candidate) ||
|
||||
allSubgraphBounds.some(
|
||||
(bound) =>
|
||||
@@ -944,6 +1016,11 @@ function avoidNodeObstacles(
|
||||
)
|
||||
}
|
||||
if (!intersectsObstacle(route)) return route
|
||||
// Move a multiline label before pushing an otherwise valid edge outside its group.
|
||||
if (subgraphBounds && labelHeight(route.edge) > 1 && !leavesOwner(route) && !intersectsRoutingObstacle(route)) {
|
||||
const relabeled = avoidLabelOverlap(route, otherRoutes, bounds, subgraphBounds)
|
||||
if (!intersectsObstacle(relabeled)) return relabeled
|
||||
}
|
||||
|
||||
const from = bounds.get(route.edge.from)
|
||||
const to = bounds.get(route.edge.to)
|
||||
@@ -1154,7 +1231,7 @@ function avoidLabelOverlap(
|
||||
includeLabelWidth = true,
|
||||
): FlowchartEdgeRoute {
|
||||
if (!route.edge.label) return route
|
||||
const nodeBounds = [...bounds.values()]
|
||||
const nodeBounds = [...bounds.values()].filter((bound) => !subgraphBounds?.has(bound.id))
|
||||
const frameBounds = [...(subgraphBounds?.values() ?? [])]
|
||||
const otherLabels = otherRoutes.flatMap((other) =>
|
||||
other.edge.label ? [flowchartRouteLabelLayout(other, diagramTextWidth)] : [],
|
||||
@@ -1181,6 +1258,7 @@ function avoidLabelOverlap(
|
||||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
labelIntersectsRoutePaths(label, otherRoutes) ||
|
||||
labelCutsOwnRoute(label, route) ||
|
||||
otherConnectorBounds.some((bound) => labelIntersectsBounds(label, bound))
|
||||
const current = flowchartRouteLabelLayout(route, diagramTextWidth)
|
||||
if (!intersectsObstacle(current)) return route
|
||||
@@ -1290,7 +1368,15 @@ export function routeFlowchartEdges(
|
||||
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
|
||||
}
|
||||
for (let index = routes.length - 1; index >= 0; index--) {
|
||||
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index, routedDiagram)
|
||||
routes[index] = avoidNodeObstacles(
|
||||
routes[index]!,
|
||||
routes,
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
index,
|
||||
routedDiagram,
|
||||
directionForEdge(routes[index]!.edge),
|
||||
)
|
||||
}
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
@@ -1419,11 +1505,24 @@ export function flowchartSourceConnector(
|
||||
from: FlowchartNodeBounds,
|
||||
sourcePoint: FlowchartPoint,
|
||||
): { x: number; y: number; char: string } {
|
||||
const side = sideForOutsidePoint(from, sourcePoint)
|
||||
const inside =
|
||||
sourcePoint.x > from.left &&
|
||||
sourcePoint.x < from.left + from.width - 1 &&
|
||||
sourcePoint.y > from.top &&
|
||||
sourcePoint.y < from.top + from.height - 1
|
||||
const side = inside
|
||||
? sourcePoint.x === from.left + 1
|
||||
? "left"
|
||||
: sourcePoint.x === from.left + from.width - 2
|
||||
? "right"
|
||||
: sourcePoint.y === from.top + 1
|
||||
? "top"
|
||||
: "bottom"
|
||||
: sideForOutsidePoint(from, sourcePoint)
|
||||
const connector = boundsSidePoint(from, side, "border")
|
||||
return {
|
||||
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
|
||||
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
|
||||
char: connectorChar(side),
|
||||
char: connectorChar(inside ? oppositeSide(side) : side),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { diagramArrowHeadBetween } from "../core/drawing.js"
|
||||
import { diagramBoundsFromRect, orthogonalPathPoints } from "../core/geometry.js"
|
||||
import { drawFlowchartDiagramGrid } from "../flowchart/drawing.js"
|
||||
import { flowchartRouteLabelLayout } from "../flowchart/labels.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "../flowchart/layout.js"
|
||||
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
|
||||
import { flowchartSourceConnector, routeFlowchartEdges } from "../flowchart/routing.js"
|
||||
import type { FlowchartNodeBounds, FlowchartSubgraphBounds } from "../flowchart/types.js"
|
||||
|
||||
test.each(
|
||||
(["TD", "BT", "LR", "RL"] as const).flatMap((direction) =>
|
||||
[false, true].flatMap((reverse) =>
|
||||
["", "one", "one<br/>two"].flatMap((label) =>
|
||||
[false, true].map((bidirectional) => ({ direction, reverse, label, bidirectional })),
|
||||
),
|
||||
),
|
||||
),
|
||||
)("routes contained endpoints $direction reverse=$reverse label=$label bidirectional=$bidirectional", (fixture) => {
|
||||
checkContainment(
|
||||
`flowchart ${fixture.direction}\nsubgraph G\nA\nend\n${fixture.reverse ? "A" : "G"} ${fixture.bidirectional ? "<-->" : "-->"}${fixture.label ? `|${fixture.label}|` : ""} ${fixture.reverse ? "G" : "A"}`,
|
||||
)
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["TD", "BT", "LR", "RL"] as const).flatMap((direction) =>
|
||||
[false, true].flatMap((reverse) => ["H", "A"].map((member) => ({ direction, reverse, member }))),
|
||||
),
|
||||
)("routes an ancestor $direction to nested $member reverse=$reverse", (fixture) => {
|
||||
checkContainment(
|
||||
`flowchart ${fixture.direction}\nsubgraph G\nsubgraph H\nA\nend\nend\n${fixture.reverse ? fixture.member : "G"} <-->|one<br/>two| ${fixture.reverse ? "G" : fixture.member}`,
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, true])("detours around a contained member without leaving the frame reverse=%s", (reverse) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(
|
||||
`flowchart TD\nsubgraph G\nA\nB\nend\n${reverse ? "A --> G" : "G --> A"}`,
|
||||
)
|
||||
const frame: FlowchartSubgraphBounds = {
|
||||
id: "G",
|
||||
label: "G",
|
||||
labelSide: reverse ? "top" : "bottom",
|
||||
...diagramBoundsFromRect(0, 0, 24, 20),
|
||||
}
|
||||
const bounds = new Map<string, FlowchartNodeBounds>([
|
||||
["G", { ...frame, lines: [] }],
|
||||
["A", { id: "A", lines: ["A"], ...diagramBoundsFromRect(9, reverse ? 4 : 10, 5, 3) }],
|
||||
["B", { id: "B", lines: ["B"], ...diagramBoundsFromRect(9, reverse ? 10 : 5, 5, 3) }],
|
||||
])
|
||||
const route = routeFlowchartEdges(diagram, bounds, undefined, new Map([["G", frame]]))[0]!
|
||||
expect(route.points.length).toBeGreaterThan(2)
|
||||
for (const point of orthogonalPathPoints(route.points)) {
|
||||
expect(point.x > frame.left && point.x < frame.left + frame.width - 1).toBe(true)
|
||||
expect(point.y > frame.top && point.y < frame.top + frame.height - 1).toBe(true)
|
||||
for (const id of ["A", "B"]) {
|
||||
const node = bounds.get(id)!
|
||||
expect(
|
||||
point.x >= node.left &&
|
||||
point.x < node.left + node.width &&
|
||||
point.y >= node.top &&
|
||||
point.y < node.top + node.height,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function checkContainment(source: string) {
|
||||
const diagram = parseMermaidFlowchartDiagram(source)
|
||||
const options = { compact: true }
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const grid = drawFlowchartDiagramGrid(diagram, options)
|
||||
const frame = layout.subgraphBounds.get("G")!
|
||||
expect(layout.routes).toHaveLength(1)
|
||||
const route = layout.routes[0]!
|
||||
const endpoints = new Map([
|
||||
...layout.bounds,
|
||||
...[...layout.subgraphBounds].map(
|
||||
([id, bound]) => [id, { ...bound, lines: [] }] satisfies [string, FlowchartNodeBounds],
|
||||
),
|
||||
])
|
||||
const start = route.points[0]!
|
||||
const end = route.points.at(-1)!
|
||||
const startContact = flowchartSourceConnector(endpoints.get(route.edge.from)!, start)
|
||||
const endContact = flowchartSourceConnector(endpoints.get(route.edge.to)!, end)
|
||||
expect(Math.abs(start.x - startContact.x) + Math.abs(start.y - startContact.y)).toBe(1)
|
||||
expect(Math.abs(end.x - endContact.x) + Math.abs(end.y - endContact.y)).toBe(1)
|
||||
expect(grid.getCell(startContact.x, startContact.y)?.char).toBe(startContact.char)
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(end, endContact))
|
||||
if (route.edge.sourceArrowhead) {
|
||||
expect(grid.getCell(start.x, start.y)?.char).toBe(diagramArrowHeadBetween(start, startContact))
|
||||
}
|
||||
const label = route.edge.label ? flowchartRouteLabelLayout(route, visualLength) : undefined
|
||||
for (const point of orthogonalPathPoints(route.points)) {
|
||||
expect(point.x > frame.left && point.x < frame.left + frame.width - 1).toBe(true)
|
||||
expect(point.y > frame.top && point.y < frame.top + frame.height - 1).toBe(true)
|
||||
for (const node of layout.bounds.values()) {
|
||||
expect(
|
||||
point.x >= node.left &&
|
||||
point.x < node.left + node.width &&
|
||||
point.y >= node.top &&
|
||||
point.y < node.top + node.height,
|
||||
).toBe(false)
|
||||
}
|
||||
const cell = grid.getCell(point.x, point.y)!
|
||||
if (label?.height === 1 && cell.style === "label") continue
|
||||
expect(cell.style).not.toBe("label")
|
||||
expect(cell.char).not.toBe(" ")
|
||||
}
|
||||
if (label) for (const line of label.lines) expect(grid.toString()).toContain(line.trim())
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { detectMermaidDiagram } from "../detect.js"
|
||||
import { isMermaidFlowchartDiagram, parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
|
||||
|
||||
describe("flowchart parser", () => {
|
||||
test("accepts slash IDs as sources, targets, and explicitly labeled nodes", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
src/api --> _pkg/worker[Worker (fast/slow), ready]
|
||||
_pkg/worker --> sink/archive`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "src/api", label: "src/api", shape: "box" },
|
||||
{ id: "_pkg/worker", label: "Worker (fast/slow), ready", shape: "box" },
|
||||
{ id: "sink/archive", label: "sink/archive", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "src/api", to: "_pkg/worker", label: "" },
|
||||
{ from: "_pkg/worker", to: "sink/archive", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("separates class suffixes from slash and dotted edge endpoint IDs", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
cli/tui:::ui-->node.id/src:::core
|
||||
node.id/src:::core-->cli/tui:::ui`)
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "cli/tui", label: "cli/tui", shape: "box" },
|
||||
{ id: "node.id/src", label: "node.id/src", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "cli/tui", to: "node.id/src", label: "" },
|
||||
{ from: "node.id/src", to: "cli/tui", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("strips class suffixes from standalone slash nodes before resolving labels and membership", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
subgraph Group
|
||||
cli/tui[CLI/TUI]:::ui
|
||||
node.id/src:::core
|
||||
cli/tui-->node.id/src
|
||||
end`)
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "cli/tui", label: "CLI/TUI", shape: "box" },
|
||||
{ id: "node.id/src", label: "node.id/src", shape: "box" },
|
||||
])
|
||||
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["cli/tui", "node.id/src"])
|
||||
expect(diagram.edges).toEqual([{ from: "cli/tui", to: "node.id/src", label: "" }])
|
||||
})
|
||||
|
||||
test("accepts slash subgraph IDs with and without explicit labels", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
subgraph pkg/outer[Outer (api/ui), services]
|
||||
subgraph pkg/inner
|
||||
pkg/node[Node]
|
||||
end
|
||||
end`)
|
||||
|
||||
expect(diagram.subgraphs).toEqual([
|
||||
{ id: "pkg/outer", label: "Outer (api/ui), services", nodeIds: [], parentId: undefined },
|
||||
{ id: "pkg/inner", label: "pkg/inner", nodeIds: ["pkg/node"], parentId: "pkg/outer" },
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["-->", "==>", "-.->", "---", "~~~", "<-->", "<==>", "<-.->"])(
|
||||
"separates adjacent %s operators from slash IDs",
|
||||
(operator) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR\na/src${operator}b/dst${operator}c/end`)
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual(["a/src", "b/dst", "c/end"])
|
||||
expect(diagram.edges.map((edge) => [edge.from, edge.to])).toEqual([
|
||||
["a/src", "b/dst"],
|
||||
["b/dst", "c/end"],
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["/src --> B", "A --> /dst", "/src[Label]", "1/src --> B", "A --> 1/dst"])(
|
||||
"rejects IDs without a letter or underscore first: %s",
|
||||
(statement) => {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow(MermaidSyntaxError)
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
["<-->", undefined],
|
||||
["<==>", "thick"],
|
||||
["<-.->", "dashed"],
|
||||
] as const)("retains both arrowheads and style for %s with optional pipe labels", (operator, style) => {
|
||||
for (const label of ["", "exchange (in/out), ready"]) {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR\nA${operator}${label ? `|${label}|` : ""}B`)
|
||||
expect(diagram.edges).toEqual([{ from: "A", to: "B", label, sourceArrowhead: true, ...(style ? { style } : {}) }])
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
["<-- exchange -->", undefined],
|
||||
["<== exchange ==>", "thick"],
|
||||
["<-. exchange .->", "dashed"],
|
||||
] as const)("retains bidirectional inline labels for %s", (operator, style) => {
|
||||
expect(parseMermaidFlowchartDiagram(`flowchart LR\nA ${operator} B`).edges).toEqual([
|
||||
{ from: "A", to: "B", label: "exchange", sourceArrowhead: true, ...(style ? { style } : {}) },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps unquoted parentheses, slashes, and commas in node and edge labels", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A[Request (GET/POST), ready] -->|Result (ok/error), done| B[Response (json/xml), sent]`)
|
||||
expect(diagram.nodes.map((node) => node.label)).toEqual(["Request (GET/POST), ready", "Response (json/xml), sent"])
|
||||
expect(diagram.edges[0]?.label).toBe("Result (ok/error), done")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"classDef highlight fill:#fff,stroke:#000;",
|
||||
"class A,B highlight;",
|
||||
"style A fill:#fff;",
|
||||
'style A fill:url("data:image/svg+xml;utf8,icon");',
|
||||
"linkStyle 0 stroke:#fff;",
|
||||
'click A "https://example.com/a;b" "Open; page" _blank;',
|
||||
'click A "https://example.com/a" "Open page" _blank',
|
||||
"click A callback",
|
||||
"accTitle: A useful diagram",
|
||||
"accDescr: A description (with punctuation), for everyone",
|
||||
"accDescr { A description on one line }",
|
||||
"accDescr {\nA description spanning\nseveral lines\n}",
|
||||
'%%{init: {"theme": "dark"}}%%',
|
||||
'%%{init: {\n"theme": "dark"\n}}%%',
|
||||
])("ignores nonstructural presentation and metadata: %s", (statement) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR\nA --> B\n${statement}\nB --> C`)
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual(["A", "B", "C"])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "A", to: "B", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("recognizes flowcharts with a preceding multiline init directive", () => {
|
||||
const source = `%%{init: {
|
||||
"theme": "dark"
|
||||
}}%%
|
||||
flowchart LR
|
||||
A --> B`
|
||||
expect(isMermaidFlowchartDiagram(source)).toBe(true)
|
||||
expect(detectMermaidDiagram(source)).toBe("flowchart")
|
||||
expect(parseMermaidFlowchartDiagram(source).direction).toBe("LR")
|
||||
})
|
||||
|
||||
test.each([
|
||||
'%%{init: {\n"theme": "dark"',
|
||||
'%%{init: {"theme": "dark"}}%% unexpected',
|
||||
"accDescr {\nDescription",
|
||||
"accDescr { Description } unexpected",
|
||||
])("does not throw during detection of malformed pre-header metadata: %s", (metadata) => {
|
||||
const source = `${metadata}\nflowchart LR\nA --> B`
|
||||
expect(isMermaidFlowchartDiagram(source)).toBe(false)
|
||||
expect(detectMermaidDiagram(source)).toBeUndefined()
|
||||
expect(() => parseMermaidFlowchartDiagram(source)).toThrow(MermaidSyntaxError)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'%%{init: {\n"theme": "dark"',
|
||||
'%%{init: {"theme": "dark"}}%% unexpected',
|
||||
"accDescr {\nDescription",
|
||||
"accDescr { Description } unexpected",
|
||||
])("leaves malformed post-header metadata validation to parsing: %s", (metadata) => {
|
||||
const source = `flowchart LR\nA --> B\n${metadata}`
|
||||
expect(detectMermaidDiagram(source)).toBe("flowchart")
|
||||
expect(() => parseMermaidFlowchartDiagram(source)).toThrow(MermaidSyntaxError)
|
||||
})
|
||||
|
||||
test.each(["classDef", "class", "style", "linkStyle", "click", "accTitle", "accDescr"])(
|
||||
"does not discard structural statements using the ID %s",
|
||||
(id) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR\n${id}[Label]\n${id} --> B`)
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual([id, "B"])
|
||||
expect(diagram.edges).toEqual([{ from: id, to: "B", label: "" }])
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
"A --o B",
|
||||
"A & B --> C",
|
||||
"class --o B",
|
||||
"class A -->",
|
||||
"style A fill:#fff; A --> B",
|
||||
"style A fill:#fff; C[New node]",
|
||||
"classDef highlight fill:#fff; subgraph Group",
|
||||
])("rejects unsupported structure rather than dropping it: %s", (statement) => {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow(MermaidSyntaxError)
|
||||
})
|
||||
|
||||
test("preserves source locations after multiline metadata", () => {
|
||||
expect(() => parseMermaidFlowchartDiagram('flowchart LR\n%%{init: {\n"theme": "dark"\n}}%%\nA --o B')).toThrow(
|
||||
'Unsupported syntax in flowchart diagram at line 5: "A --o B"',
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
"accDescr { Description } A --> B",
|
||||
"accDescr {\nDescription\n} A --> B",
|
||||
'%%{init: {"theme": "dark"}}%% A --> B',
|
||||
'%%{init: {\n"theme": "dark"\n}}%% A --> B',
|
||||
])("does not discard structure after metadata block endings: %s", (statement) => {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow(MermaidSyntaxError)
|
||||
})
|
||||
|
||||
test.each(["accDescr {\nDescription", '%%{init: {\n"theme": "dark"'])(
|
||||
"rejects unclosed metadata blocks: %s",
|
||||
(statement) => {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow("Unclosed metadata block")
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["-->", "~~~"])("resolves %s subgraph endpoints independent of declaration order", (operator) => {
|
||||
for (const before of [true, false]) {
|
||||
const declarations = "subgraph Group\nA\nend\nsubgraph Other\nB\nend"
|
||||
const connections = `Group${operator}Other${operator}C`
|
||||
const diagram = parseMermaidFlowchartDiagram(
|
||||
`flowchart TD\nsubgraph Parent\n${before ? `${connections}\n${declarations}` : `${declarations}\n${connections}`}\nend`,
|
||||
)
|
||||
expect(diagram.nodes.map((node) => node.id).sort()).toEqual(["A", "B", "C"])
|
||||
expect(diagram.subgraphs?.map((subgraph) => [subgraph.id, subgraph.nodeIds])).toEqual([
|
||||
["Parent", ["C"]],
|
||||
["Group", ["A"]],
|
||||
["Other", ["B"]],
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "Group", to: "Other", label: "", ...(operator === "~~~" ? { orderOnly: true } : {}) },
|
||||
{ from: "Other", to: "C", label: "", ...(operator === "~~~" ? { orderOnly: true } : {}) },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("removes explicitly labeled and standalone nodes that resolve to declared subgraphs", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
subgraph Parent
|
||||
Group[Temporary label] --> B
|
||||
Parent
|
||||
subgraph Group[Real group]
|
||||
Group
|
||||
A
|
||||
end
|
||||
end`)
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual(["B", "A"])
|
||||
expect(diagram.subgraphs?.map((subgraph) => [subgraph.id, subgraph.nodeIds])).toEqual([
|
||||
["Parent", ["B"]],
|
||||
["Group", ["A"]],
|
||||
])
|
||||
expect(diagram.edges).toEqual([{ from: "Group", to: "B", label: "" }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { diagramArrowHeadBetween } from "../core/drawing.js"
|
||||
import { orthogonalPathPoints } from "../core/geometry.js"
|
||||
import { drawFlowchartDiagramGrid } from "../flowchart/drawing.js"
|
||||
import { flowchartRouteLabelLayout } from "../flowchart/labels.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "../flowchart/layout.js"
|
||||
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
|
||||
import { renderFlowchartDiagram } from "../flowchart/render.js"
|
||||
import { flowchartSourceConnector } from "../flowchart/routing.js"
|
||||
import { expectDiagram } from "./diagram.js"
|
||||
|
||||
const architecture = `flowchart TD
|
||||
subgraph core["packages/core — one undifferentiated blob"]
|
||||
K["kernel<br/>sessions · runner · events<br/>capabilities · claims"]
|
||||
D["discovery<br/>config files · project markers<br/>plugins · MCP · auto-recovery"]
|
||||
K <-->|"nothing prevents<br/>either direction"| D
|
||||
end
|
||||
schema --> core
|
||||
protocol --> core
|
||||
core --> server
|
||||
server --> cli["cli / tui"]
|
||||
sdk -.->|"composes client+core+server"| server`
|
||||
|
||||
test.each(
|
||||
(["TD", "BT", "LR", "RL"] as const).flatMap((direction) => [true, false].map((before) => ({ direction, before }))),
|
||||
)("connects $direction edges to a subgraph boundary with forward references=$before", ({ direction, before }) => {
|
||||
const group = "subgraph core[Core]\nK[Kernel] --> D[Discovery]\nend"
|
||||
const edges = "schema --> core\ncore --> server"
|
||||
const source = `flowchart ${direction}\n${before ? `${edges}\n${group}` : `${group}\n${edges}`}`
|
||||
const diagram = parseMermaidFlowchartDiagram(source)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const frame = layout.subgraphBounds.get("core")!
|
||||
|
||||
expect(diagram.nodes.map((node) => node.id).sort()).toEqual(["D", "K", "schema", "server"])
|
||||
expect(layout.bounds.has("core")).toBe(false)
|
||||
expect(layout.routes).toHaveLength(3)
|
||||
for (const route of layout.routes.filter((route) => route.edge.from === "core" || route.edge.to === "core")) {
|
||||
const endpoint = route.edge.from === "core" ? route.points[0]! : route.points.at(-1)!
|
||||
const contact = flowchartSourceConnector({ ...frame, lines: [] }, endpoint)
|
||||
expect(Math.abs(contact.x - endpoint.x) + Math.abs(contact.y - endpoint.y)).toBe(1)
|
||||
expect(contact.x >= frame.left && contact.x < frame.left + frame.width).toBe(true)
|
||||
expect(contact.y >= frame.top && contact.y < frame.top + frame.height).toBe(true)
|
||||
expect(grid.getCell(contact.x, contact.y)?.style).toBe(route.edge.from === "core" ? "edge" : "group")
|
||||
for (const point of orthogonalPathPoints(route.points)) {
|
||||
expect(
|
||||
point.x > frame.left &&
|
||||
point.x < frame.left + frame.width - 1 &&
|
||||
point.y > frame.top &&
|
||||
point.y < frame.top + frame.height - 1,
|
||||
).toBe(false)
|
||||
}
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
}
|
||||
if (direction === "TD" && !before) {
|
||||
expectDiagram(grid.toString({ trimTop: true, trimBottom: true })).toEqualDiagram(`
|
||||
╭────────╮
|
||||
│ schema │
|
||||
╰────┬───╯
|
||||
│
|
||||
▼
|
||||
╭─ Core ────────╮
|
||||
│ ╭────────╮ │
|
||||
│ │ Kernel │ │
|
||||
│ ╰────┬───╯ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ╭───────────╮ │
|
||||
│ │ Discovery │ │
|
||||
│ ╰───────────╯ │
|
||||
╰───────┬───────╯
|
||||
│
|
||||
▼
|
||||
╭────────╮
|
||||
│ server │
|
||||
╰────────╯
|
||||
`)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["nothing prevents either direction", "nothing prevents<br/>either direction"])(
|
||||
"keeps a bidirectional label clear of its own bends and terminals: %s",
|
||||
(label) => {
|
||||
const source = `flowchart BT
|
||||
subgraph arch[Architecture]
|
||||
K["kernel<br/>sessions · runner · events<br/>capabilities · claims"]
|
||||
D["discovery<br/>config files · project markers<br/>plugins · MCP · auto-recovery"]
|
||||
K <-->|"${label}"| D
|
||||
K --> X
|
||||
end`
|
||||
const diagram = parseMermaidFlowchartDiagram(source)
|
||||
const options = { compact: true, layoutMaxWidth: 60 }
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const grid = drawFlowchartDiagramGrid(diagram, options)
|
||||
const route = layout.routes.find((route) => route.edge.to === "D")!
|
||||
const start = route.points[0]!
|
||||
const end = route.points.at(-1)!
|
||||
const connector = flowchartSourceConnector(layout.bounds.get("K")!, start)
|
||||
|
||||
expect(grid.getCell(start.x, start.y)?.char).toBe(diagramArrowHeadBetween(start, connector))
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
const placed = flowchartRouteLabelLayout(route, visualLength)
|
||||
for (const point of orthogonalPathPoints(route.points)) {
|
||||
const cell = grid.getCell(point.x, point.y)!
|
||||
if (placed.height === 1 && cell.style === "label") continue
|
||||
expect(cell.style).not.toBe("label")
|
||||
expect(cell.char).not.toBe(" ")
|
||||
}
|
||||
for (const line of placed.lines) expect(grid.toString()).toContain(line.trim())
|
||||
if (placed.height === 2) {
|
||||
expectDiagram(grid.toString({ trimTop: true, trimBottom: true })).toEqualDiagram(`
|
||||
╭─ Architecture ────────────────────────────────╮
|
||||
│ ╭────────────────────────────────╮ │
|
||||
│ │ discovery │ ╭───╮ │
|
||||
│ │ config files · project markers │ │ X │ │
|
||||
│ │ plugins · MCP · auto-recovery │ ╰───╯ │
|
||||
│ ╰────────────────────────────────╯ ▲ │
|
||||
│ ▲ │ │
|
||||
│ nothing prevents │ │ │
|
||||
│ either direction ╰────┬──────────────────╯ │
|
||||
│ ▼ │
|
||||
│ ╭──────────────┴─────────────╮ │
|
||||
│ │ kernel │ │
|
||||
│ │ sessions · runner · events │ │
|
||||
│ │ capabilities · claims │ │
|
||||
│ ╰────────────────────────────╯ │
|
||||
╰───────────────────────────────────────────────╯
|
||||
`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each([60, 120])("renders the architecture repro without phantom nodes or severed paths at width %s", (width) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(architecture)
|
||||
const options = { compact: true, layoutMaxWidth: width }
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const grid = drawFlowchartDiagramGrid(diagram, options)
|
||||
expect(layout.routes).toHaveLength(diagram.edges.length)
|
||||
expect(layout.bounds.has("core")).toBe(false)
|
||||
const frame = layout.subgraphBounds.get("core")!
|
||||
for (const route of layout.routes) {
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
if (!route.edge.sourceArrowhead) continue
|
||||
const start = route.points[0]!
|
||||
const connector = flowchartSourceConnector(layout.bounds.get(route.edge.from)!, start)
|
||||
expect(grid.getCell(start.x, start.y)?.char).toBe(diagramArrowHeadBetween(start, connector))
|
||||
for (const point of orthogonalPathPoints(route.points)) {
|
||||
expect(grid.getCell(point.x, point.y)?.style).not.toBe("label")
|
||||
expect(grid.getCell(point.x, point.y)?.char).not.toBe(" ")
|
||||
expect(
|
||||
point.x > frame.left &&
|
||||
point.x < frame.left + frame.width - 1 &&
|
||||
point.y > frame.top &&
|
||||
point.y < frame.top + frame.height - 1,
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
for (const id of ["schema", "protocol", "server", "cli", "sdk"]) {
|
||||
const node = layout.bounds.get(id)!
|
||||
expect(
|
||||
node.left < frame.left + frame.width &&
|
||||
node.left + node.width > frame.left &&
|
||||
node.top < frame.top + frame.height &&
|
||||
node.top + node.height > frame.top,
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders a single-line bidirectional label with connected stems on both sides", () => {
|
||||
expectDiagram(renderFlowchartDiagram("flowchart LR\nA <-->|exchange| B", { compact: true })).toEqualDiagram(`
|
||||
╭───╮ ╭───╮
|
||||
│ A ├◀── exchange ───▶│ B │
|
||||
╰───╯ ╰───╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders multiline horizontal labels above a continuous bidirectional edge", () => {
|
||||
expectDiagram(renderFlowchartDiagram("flowchart LR\nA <-->|first<br/>second| B", { compact: true })).toEqualDiagram(`
|
||||
first
|
||||
╭───╮ second ╭───╮
|
||||
│ A ├◀─────────────▶│ B │
|
||||
╰───╯ ╰───╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a bare slash ID as literal text", () => {
|
||||
expect(renderFlowchartDiagram("flowchart LR\nserver --> cli/tui")).toContain("cli/tui")
|
||||
})
|
||||
|
||||
test("keeps edges to an empty declared subgraph", () => {
|
||||
const source = "flowchart TD\nA --> G\nsubgraph G[Empty]\nend\nG --> B"
|
||||
const diagram = parseMermaidFlowchartDiagram(source)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
expect(layout.routes).toHaveLength(2)
|
||||
expect(layout.bounds.has("G")).toBe(false)
|
||||
expect(layout.subgraphBounds.has("G")).toBe(true)
|
||||
expect(grid.toString()).toContain("Empty")
|
||||
for (const route of layout.routes) {
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["", "exchange", "one<br/>two"])(
|
||||
"does not overwrite a frame's source connector with its title: %s",
|
||||
(label) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(
|
||||
`flowchart BT\nsubgraph G[Group title]\nA\nend\nG <-->${label ? `|${label}|` : ""} X`,
|
||||
)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const start = layout.routes[0]!.points[0]!
|
||||
const connector = flowchartSourceConnector({ ...layout.subgraphBounds.get("G")!, lines: [] }, start)
|
||||
expect(grid.getCell(connector.x, connector.y)?.style).toBe("edge")
|
||||
expect(grid.getCell(connector.x, connector.y)?.char).toBe(connector.char)
|
||||
expect(grid.toString()).toContain("Group title")
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["exchange", "one<br/>two"])("reserves label clearance between a horizontal frame and node: %s", (label) => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR\nsubgraph G[Group title]\nA\nend\nG <-->|${label}| X`)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const route = layout.routes[0]!
|
||||
expect(route.points).toHaveLength(2)
|
||||
const points = orthogonalPathPoints(route.points)
|
||||
expect(new Set(points.map((point) => `${point.x}:${point.y}`)).size).toBe(points.length)
|
||||
const placed = flowchartRouteLabelLayout(route, visualLength)
|
||||
for (const line of placed.lines) expect(grid.toString()).toContain(line.trim())
|
||||
const start = route.points[0]!
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(start.x, start.y)?.char).toBe("◀")
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe("▶")
|
||||
})
|
||||
|
||||
test.each(["TD", "BT", "LR", "RL"] as const)(
|
||||
"keeps boundary edges distinct around locally directed groups in %s",
|
||||
(direction) => {
|
||||
const local = direction === "LR" || direction === "RL" ? "TD" : "LR"
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart ${direction}
|
||||
subgraph G[Group]
|
||||
direction ${local}
|
||||
A --> B
|
||||
end
|
||||
Input --> G
|
||||
G -->|exchange| Output`)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
expect(layout.routes).toHaveLength(3)
|
||||
for (const route of layout.routes) {
|
||||
expect(route.points.length).toBeGreaterThanOrEqual(2)
|
||||
const points = orthogonalPathPoints(route.points)
|
||||
expect(new Set(points.map((point) => `${point.x}:${point.y}`)).size).toBe(points.length)
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
}
|
||||
expect(grid.toString()).toContain("exchange")
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps a nested empty frame separate from its parent's nodes", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram("flowchart TD\nsubgraph G\nsubgraph H[Empty]\nend\nA\nend\nX --> H")
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const empty = layout.subgraphBounds.get("H")!
|
||||
const node = layout.bounds.get("A")!
|
||||
expect(
|
||||
node.left < empty.left + empty.width &&
|
||||
node.left + node.width > empty.left &&
|
||||
node.top < empty.top + empty.height &&
|
||||
node.top + node.height > empty.top,
|
||||
).toBe(false)
|
||||
expect(grid.toString()).toContain("Empty")
|
||||
const route = layout.routes[0]!
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
})
|
||||
|
||||
test("separates nested empty frames and preserves their labeled boundary edge", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(
|
||||
"flowchart LR\nsubgraph G\nsubgraph H[First]\nend\nsubgraph I[Second]\nend\nH <-->|one<br/>two| I\nend",
|
||||
)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const first = layout.subgraphBounds.get("H")!
|
||||
const second = layout.subgraphBounds.get("I")!
|
||||
expect(first.left + first.width).toBeLessThan(second.left)
|
||||
for (const label of ["First", "Second", "one", "two"]) expect(grid.toString()).toContain(label)
|
||||
const points = orthogonalPathPoints(layout.routes[0]!.points)
|
||||
expect(new Set(points.map((point) => `${point.x}:${point.y}`)).size).toBe(points.length)
|
||||
})
|
||||
Reference in New Issue
Block a user