Compare commits

...
Author SHA1 Message Date
Kit Langton 61549b35d5 fix(merman): connect subgraph edges and preserve labeled paths 2026-08-27 12:10:26 -04:00
7 changed files with 998 additions and 80 deletions
+13 -3
View File
@@ -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)
+159 -29
View File
@@ -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)]
+65 -41
View File
@@ -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}"`,
)
}
}
+106 -7
View File
@@ -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)
})