From 6b795184bd55cccd7a3bf8ae75c26c670decde1f Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Sat, 1 Aug 2026 22:01:11 +0200 Subject: [PATCH] fix(c4): allow boundaries as relationship endpoints A Rel naming a boundary alias threw "references an unknown shape", because getC4Shape only looked at the element array. It now falls back to the boundaries, so a relationship can start or end at a boundary as reported in #4864. C4Element names the shape-or-boundary union that relationship drawing actually accepts, which keeps the renderer signatures honest without casts. A boundary also needs its own intersect function. Element shapes get one from the unified shape they are drawn with, and the renderer throws when an endpoint has none; boundaries are drawn by the legacy path, so they are given a rectangle intersect over their own box once its geometry is known. The shared intersect.rect helper measures from the centre while the C4 grid stores the top-left corner, so the centre is passed explicitly. Covered both ways: the db test asserts the endpoint resolves to the object in the boundaries array, and a rendering test draws a relationship targeting a boundary. --- .changeset/c4-boundary-relation-endpoint.md | 9 +++++ cypress/integration/rendering/c4/c4.spec.js | 16 +++++++++ packages/mermaid/src/diagrams/c4/c4Db.ts | 6 +++- .../mermaid/src/diagrams/c4/c4Renderer.ts | 36 +++++++++++++++---- packages/mermaid/src/diagrams/c4/c4Types.ts | 5 +++ .../src/diagrams/c4/parser/c4Boundary.spec.ts | 22 ++++++++++++ 6 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 .changeset/c4-boundary-relation-endpoint.md diff --git a/.changeset/c4-boundary-relation-endpoint.md b/.changeset/c4-boundary-relation-endpoint.md new file mode 100644 index 00000000000..77b4336ad03 --- /dev/null +++ b/.changeset/c4-boundary-relation-endpoint.md @@ -0,0 +1,9 @@ +--- +'mermaid': patch +--- + +fix(c4): allow boundaries as relationship endpoints + +A `Rel` (or `BiRel`/`Rel_*`) that referenced a boundary alias threw `references an unknown +shape`, because endpoint lookup only searched shapes and not boundaries. Boundary aliases now +resolve as relationship endpoints. Fixes #4864. diff --git a/cypress/integration/rendering/c4/c4.spec.js b/cypress/integration/rendering/c4/c4.spec.js index fe66b30a4ad..6794da7c3f3 100644 --- a/cypress/integration/rendering/c4/c4.spec.js +++ b/cypress/integration/rendering/c4/c4.spec.js @@ -177,4 +177,20 @@ C4Context {} ); }); + it('C4.9 should render a relationship whose endpoint is a boundary', () => { + imgSnapshotTest( + ` + C4Context + title Relationship to a boundary + + Container_Boundary(banking, "Internet Banking") { + Container(spa, "SPA", "JavaScript", "Front-end") + } + System_Ext(email, "Email System", "External mail") + + Rel(banking, email, "Forwards alerts") + `, + {} + ); + }); }); diff --git a/packages/mermaid/src/diagrams/c4/c4Db.ts b/packages/mermaid/src/diagrams/c4/c4Db.ts index 8122a1f5b37..f08f3522c43 100644 --- a/packages/mermaid/src/diagrams/c4/c4Db.ts +++ b/packages/mermaid/src/diagrams/c4/c4Db.ts @@ -679,7 +679,11 @@ export const getC4ShapeArray = function (parentBoundary?: string | null) { } }; export const getC4Shape = function (alias: string) { - return c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias); + // Relations may target a boundary as well as a shape, so fall back to boundaries (#4864). + return ( + c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias) ?? + boundaries.find((boundary) => boundary.alias === alias) + ); }; export const getC4ShapeKeys = function (parentBoundary?: string | null) { return Object.keys(getC4ShapeArray(parentBoundary)); diff --git a/packages/mermaid/src/diagrams/c4/c4Renderer.ts b/packages/mermaid/src/diagrams/c4/c4Renderer.ts index 5b5317a8909..4f2cf82a0f7 100644 --- a/packages/mermaid/src/diagrams/c4/c4Renderer.ts +++ b/packages/mermaid/src/diagrams/c4/c4Renderer.ts @@ -14,9 +14,19 @@ import type { Diagram } from '../../Diagram.js'; import type { C4DiagramConfig } from '../../config.type.js'; import type { SVG } from '../../diagram-api/types.js'; import type { TextDimensionConfig } from '../../types.js'; -import type { C4Boundary, C4DrawConfig, C4Font, C4Rel, C4Shape, C4Text } from './c4Types.js'; +import type { + C4Boundary, + C4DrawConfig, + C4Element, + C4Font, + C4Point, + C4Rel, + C4Shape, + C4Text, +} from './c4Types.js'; import { shapes } from '../../rendering-util/rendering-elements/shapes.js'; import { buildC4Node } from './c4ShapeAdapter.js'; +import intersect from '../../rendering-util/rendering-elements/intersect/index.js'; type C4DB = typeof c4Db; @@ -241,6 +251,18 @@ export const drawBoundary = function (diagram: SVG, boundary: C4Boundary, bounds boundary.y = starty; boundary.width = bounds.data.stopx! - startx; boundary.height = bounds.data.stopy! - starty; + // A boundary can be a relationship endpoint, and its box is a plain rectangle. + // intersect.rect measures from the centre, while the C4 grid stores the top-left corner. + boundary.intersect = (point: C4Point) => + intersect.rect( + { + x: boundary.x + boundary.width / 2, + y: boundary.y + boundary.height / 2, + width: boundary.width, + height: boundary.height, + }, + point + ); boundary.label.y = conf.c4ShapeMargin - 35; @@ -340,17 +362,17 @@ class Point { /* * Get the intersection of the line between the center point of a rectangle and a point outside the rectangle. */ -const getIntersectPoint = function (fromNode: C4Shape, endPoint: Point): Point | null { +const getIntersectPoint = function (fromNode: C4Element, endPoint: Point): Point | null { if (!fromNode.intersect) { throw new Error( - `C4 shape "${fromNode.alias}" has no intersect function. Please report this to https://github.com/mermaid-js/mermaid/issues` + `C4 element "${fromNode.alias}" has no intersect function. Please report this to https://github.com/mermaid-js/mermaid/issues` ); } const { x, y } = fromNode.intersect(endPoint); return new Point(x, y); }; -const getIntersectPoints = function (fromNode: C4Shape, endNode: C4Shape) { +const getIntersectPoints = function (fromNode: C4Element, endNode: C4Element) { const endIntersectPoint = { x: 0, y: 0 }; endIntersectPoint.x = endNode.x + endNode.width / 2; endIntersectPoint.y = endNode.y + endNode.height / 2; @@ -365,7 +387,7 @@ const getIntersectPoints = function (fromNode: C4Shape, endNode: C4Shape) { export const drawRels = function ( diagram: SVG, rels: C4Rel[], - getC4ShapeObj: (alias: string) => C4Shape | undefined, + getC4ShapeObj: (alias: string) => C4Element | undefined, diagObj: Diagram, diagramId: string ) { @@ -394,7 +416,9 @@ export const drawRels = function ( const fromNode = getC4ShapeObj(rel.from); const endNode = getC4ShapeObj(rel.to); if (!fromNode || !endNode) { - throw new Error(`C4 rel "${rel.from}" -> "${rel.to}" references an unknown shape`); + throw new Error( + `C4 rel "${rel.from}" -> "${rel.to}" references an unknown shape or boundary` + ); } const points = getIntersectPoints(fromNode, endNode); if (!points.startPoint || !points.endPoint) { diff --git a/packages/mermaid/src/diagrams/c4/c4Types.ts b/packages/mermaid/src/diagrams/c4/c4Types.ts index f33773e55e2..cd2646b497e 100644 --- a/packages/mermaid/src/diagrams/c4/c4Types.ts +++ b/packages/mermaid/src/diagrams/c4/c4Types.ts @@ -87,8 +87,13 @@ export interface C4Boundary { width: number; height: number; image: C4Image; + /* Should be set by the renderer once the boundary's box is known */ + intersect?: (point: C4Point) => C4Point; } +/** A relationship endpoint: shapes and boundaries share the geometry used to draw relations. */ +export type C4Element = C4Shape | C4Boundary; + export interface C4Rel { /** The parser may set additional keys via `{ key: value }` shaped arguments. */ [key: string]: unknown; diff --git a/packages/mermaid/src/diagrams/c4/parser/c4Boundary.spec.ts b/packages/mermaid/src/diagrams/c4/parser/c4Boundary.spec.ts index 4cf5562003b..636c86f9a12 100644 --- a/packages/mermaid/src/diagrams/c4/parser/c4Boundary.spec.ts +++ b/packages/mermaid/src/diagrams/c4/parser/c4Boundary.spec.ts @@ -108,4 +108,26 @@ System(SystemAA, "Internet Banking System") }, }); }); + + // Regression test for #4864: relations may target a boundary, so getC4Shape must resolve + // boundary aliases as well as shape aliases (the renderer uses it to find rel endpoints). + it('should resolve a boundary alias as a relation endpoint', function () { + c4.parser.parse(`C4Context +${macroName}(b1, "BankBoundary") { +System(SystemAA, "Internet Banking System") +} +Rel(b1, SystemAA, "Contains")`); + + const yy = c4.parser.yy; + // Identity rather than a matching alias: this proves the endpoint resolved from the + // boundaries array, not from a shape that happens to carry the same alias. + const boundary = yy.getBoundaries().find((b: { alias: string }) => b.alias === 'b1'); + expect(boundary).toBeDefined(); + expect(yy.getC4Shape('b1')).toBe(boundary); + expect(yy.getC4Shape('SystemAA')).toMatchObject({ + alias: 'SystemAA', + typeC4Shape: { text: 'system' }, + }); + expect(yy.getC4Shape('missing')).toBeUndefined(); + }); });