From 02a8a9fa76469dba3ba8c775483ef3070fda7d4e Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:41 +0200 Subject: [PATCH 1/8] feat(c4): add getData() LayoutData adapter for the unified renderer Converts the legacy C4 db state (c4ShapeArray / boundaries / rels) into the unified renderer LayoutData format: elements become nodes (db variants use cylinder, queue variants use h-cyl), boundaries become isGroup clusters with parentId nesting, and rels become edges. UpdateElementStyle and UpdateRelStyle map onto cssStyles/style/labelStyle preserving legacy semantics. Default fills come from the existing c4 config color keys so the palette matches the legacy renderer. Kept in a separate file so the legacy c4Db keeps its exact shape. Co-Authored-By: Claude Fable 5 --- .../src/diagrams/c4/c4LayoutData.spec.ts | 126 +++++++++ .../mermaid/src/diagrams/c4/c4LayoutData.ts | 240 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts create mode 100644 packages/mermaid/src/diagrams/c4/c4LayoutData.ts diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts new file mode 100644 index 00000000000..f8125e51531 --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts @@ -0,0 +1,126 @@ +// @ts-ignore: JISON doesn't support types +import c4 from './parser/c4Diagram.jison'; +import c4Db from './c4Db.js'; +import { getData } from './c4LayoutData.js'; +import { setConfig } from '../../config.js'; +import type { MermaidConfig } from '../../config.type.js'; + +setConfig({ + securityLevel: 'strict', +}); + +describe('C4 getData LayoutData adapter', () => { + beforeEach(() => { + c4.parser.yy = c4Db; + c4.parser.yy.clear(); + }); + + const parse = (text: string) => { + c4.parser.parse(text); + }; + + const data = () => getData(c4Db, { c4: {} } as MermaidConfig); + + it('maps elements to nodes with parentId from their boundary', () => { + parse(`C4Context +title System Context diagram for Internet Banking System +Enterprise_Boundary(b0, "BankBoundary0") { + Person(customerA, "Banking Customer A", "A customer of the bank.") + System(SystemAA, "Internet Banking System", "Allows customers to view information.") + Enterprise_Boundary(b1, "BankBoundary") { + System_Ext(SystemC, "E-mail system", "The internal Microsoft Exchange e-mail system.") + } +} +Person_Ext(customerC, "Banking Customer C") +`); + const { nodes } = data(); + const byId = new Map(nodes.map((n) => [n.id, n])); + + expect(byId.get('b0')).toMatchObject({ isGroup: true, parentId: undefined }); + expect(byId.get('b1')).toMatchObject({ isGroup: true, parentId: 'b0' }); + expect(byId.get('customerA')).toMatchObject({ isGroup: false, parentId: 'b0' }); + expect(byId.get('SystemAA')).toMatchObject({ isGroup: false, parentId: 'b0' }); + expect(byId.get('SystemC')).toMatchObject({ isGroup: false, parentId: 'b1' }); + expect(byId.get('customerC')?.parentId).toBeUndefined(); + }); + + it('maps relationship types to edges with correct arrows', () => { + parse(`C4Context +Person(a, "A") +System(b, "B") +System(c, "C") +BiRel(a, b, "Uses") +Rel(b, c, "Sends e-mails", "SMTP") +`); + const { edges } = data(); + expect(edges).toHaveLength(2); + expect(edges[0]).toMatchObject({ + start: 'a', + end: 'b', + arrowTypeStart: 'arrow_point', + arrowTypeEnd: 'arrow_point', + }); + expect(edges[1]).toMatchObject({ + start: 'b', + end: 'c', + arrowTypeStart: undefined, + arrowTypeEnd: 'arrow_point', + }); + expect(edges[1].label).toContain('Sends e-mails'); + expect(edges[1].label).toContain('SMTP'); + }); + + it('preserves UpdateElementStyle and UpdateRelStyle as css styles', () => { + parse(`C4Context +Person(a, "A") +System(b, "B") +Rel(a, b, "Uses") +UpdateElementStyle(a, $fontColor="red", $bgColor="grey", $borderColor="blue") +UpdateRelStyle(a, b, $textColor="blue", $lineColor="green", $offsetX="5") +`); + const { nodes, edges } = data(); + const a = nodes.find((n) => n.id === 'a'); + expect(a?.cssStyles).toEqual(expect.arrayContaining(['fill:grey', 'stroke:blue', 'color:red'])); + expect(edges[0].style).toEqual(expect.arrayContaining(['stroke:green'])); + expect(edges[0].labelStyle).toEqual(expect.arrayContaining(['color:blue'])); + }); + + it('applies the configured C4 palette per element type', () => { + parse(`C4Context +Person(a, "A") +System_Ext(b, "B") +`); + const { nodes } = getData(c4Db, { + c4: { person_bg_color: '#08427B', external_system_bg_color: '#999999' }, + } as MermaidConfig); + expect(nodes.find((n) => n.id === 'a')?.cssStyles).toEqual( + expect.arrayContaining(['fill:#08427B']) + ); + expect(nodes.find((n) => n.id === 'b')?.cssStyles).toEqual( + expect.arrayContaining(['fill:#999999']) + ); + }); + + it('maps db and queue element variants to dedicated shapes', () => { + parse(`C4Context +SystemDb(db1, "Database") +SystemQueue(q1, "Queue") +System(s1, "System") +`); + const { nodes } = data(); + expect(nodes.find((n) => n.id === 'db1')?.shape).toBe('cylinder'); + expect(nodes.find((n) => n.id === 'q1')?.shape).toBe('h-cyl'); + expect(nodes.find((n) => n.id === 's1')?.shape).toBe('rect'); + }); + + it('marks deployment nodes as group nodes', () => { + parse(`C4Deployment +Deployment_Node(n1, "AWS", "Cloud") { + Container(c1, "API", "Java") +} +`); + const { nodes } = data(); + expect(nodes.find((n) => n.id === 'n1')).toMatchObject({ isGroup: true }); + expect(nodes.find((n) => n.id === 'c1')).toMatchObject({ isGroup: false, parentId: 'n1' }); + }); +}); diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts new file mode 100644 index 00000000000..f7b1b1bd5ac --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts @@ -0,0 +1,240 @@ +import type { MermaidConfig } from '../../config.type.js'; +import type { ShapeID } from '../../rendering-util/rendering-elements/shapes.js'; +import type { Edge, LayoutData, Node } from '../../rendering-util/types.js'; + +/** + * Adapter that converts the legacy C4 db state (c4ShapeArray / boundaries / rels) + * into the unified renderer's LayoutData format. Lives in a separate file so the + * legacy c4Db keeps its exact shape (and so the JS to TS conversion in #7829 is + * not duplicated here). + */ + +interface C4Text { + text: string; +} + +interface C4Shape { + alias: string; + label: C4Text; + typeC4Shape: C4Text; + parentBoundary: string; + techn?: C4Text; + descr?: C4Text; + bgColor?: string; + fontColor?: string; + borderColor?: string; + link?: string; +} + +interface C4Boundary { + alias: string; + label: C4Text; + type: C4Text; + parentBoundary: string; + descr?: C4Text; + nodeType?: string; + bgColor?: string; + fontColor?: string; + borderColor?: string; + link?: string | null; +} + +interface C4Rel { + type: string; + from: string; + to: string; + label: C4Text; + techn?: C4Text; + descr?: C4Text; + textColor?: string; + lineColor?: string; +} + +interface C4Db { + getC4ShapeArray: (parentBoundary?: string) => C4Shape[]; + getBoundaries: (parentBoundary?: string) => C4Boundary[]; + getRels: () => C4Rel[]; +} + +const QUEUE_SHAPES = new Set([ + 'system_queue', + 'external_system_queue', + 'container_queue', + 'external_container_queue', + 'component_queue', + 'external_component_queue', +]); + +const DB_SHAPES = new Set([ + 'system_db', + 'external_system_db', + 'container_db', + 'external_container_db', + 'component_db', + 'external_component_db', +]); + +const getNodeShape = (typeC4Shape: string): ShapeID => { + if (DB_SHAPES.has(typeC4Shape)) { + return 'cylinder'; + } + if (QUEUE_SHAPES.has(typeC4Shape)) { + return 'h-cyl'; + } + return 'rect'; +}; + +/** + * The type line rendered above the element name, e.g. `<>`. + * Mirrors the legacy renderer which displays the C4-PlantUML stereotype. + */ +const typeLabel = (typeC4Shape: string): string => { + return typeC4Shape.replace(/^external_/, '').replace(/_/g, ' '); +}; + +const isExternal = (typeC4Shape: string): boolean => typeC4Shape.startsWith('external_'); + +const escapeHtml = (txt: string): string => + txt.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +const buildNodeLabel = (shape: C4Shape): string => { + const lines: string[] = []; + lines.push(`«${escapeHtml(typeLabel(shape.typeC4Shape.text))}»`); + lines.push(`${escapeHtml(shape.label.text)}`); + if (shape.techn?.text) { + lines.push(`[${escapeHtml(shape.techn.text)}]`); + } + if (shape.descr?.text) { + lines.push(escapeHtml(shape.descr.text)); + } + return lines.join('
'); +}; + +const buildBoundaryLabel = (boundary: C4Boundary): string => { + const lines: string[] = [`${escapeHtml(boundary.label.text)}`]; + const type = boundary.type?.text; + if (type && type !== 'system' && type !== 'container') { + lines.push(`[${escapeHtml(type)}]`); + } + return lines.join('
'); +}; + +const buildEdgeLabel = (rel: C4Rel): string => { + const lines: string[] = [`${escapeHtml(rel.label.text)}`]; + if (rel.techn?.text) { + lines.push(`[${escapeHtml(rel.techn.text)}]`); + } + if (rel.descr?.text) { + lines.push(`${escapeHtml(rel.descr.text)}`); + } + return lines.join('
'); +}; + +const elementCssStyles = ( + element: Pick +): string[] => { + const styles: string[] = []; + if (element.bgColor) { + styles.push(`fill:${element.bgColor}`); + } + if (element.borderColor) { + styles.push(`stroke:${element.borderColor}`); + } + if (element.fontColor) { + styles.push(`color:${element.fontColor}`); + } + return styles; +}; + +/** + * Default fill/stroke for an element type from the c4 config color keys + * (person_bg_color, external_system_border_color, ...), so the unified + * renderer keeps the exact palette of the legacy renderer. + */ +const configColorStyles = (typeC4Shape: string, c4Config: Record): string[] => { + const styles: string[] = []; + const bg = c4Config[`${typeC4Shape}_bg_color`]; + const border = c4Config[`${typeC4Shape}_border_color`]; + if (bg) { + styles.push(`fill:${bg}`); + } + if (border) { + styles.push(`stroke:${border}`); + } + return styles; +}; + +export const getData = (db: C4Db, config: MermaidConfig): LayoutData => { + const nodes: Node[] = []; + const edges: Edge[] = []; + const c4Config: Record = config.c4 ?? {}; + + const boundaries = db.getBoundaries().filter((boundary) => boundary.alias !== 'global'); + const boundaryAliases = new Set(boundaries.map((boundary) => boundary.alias)); + + const parentIdOf = (parentBoundary: string): string | undefined => + parentBoundary && parentBoundary !== 'global' && boundaryAliases.has(parentBoundary) + ? parentBoundary + : undefined; + + for (const boundary of boundaries) { + const isDeploymentNode = boundary.nodeType !== undefined; + nodes.push({ + id: boundary.alias, + label: buildBoundaryLabel(boundary), + isGroup: true, + shape: 'rect', + parentId: parentIdOf(boundary.parentBoundary), + cssClasses: isDeploymentNode ? 'c4-boundary c4-deployment-node' : 'c4-boundary', + cssStyles: elementCssStyles(boundary), + link: boundary.link ?? undefined, + look: config.look, + }); + } + + for (const shape of db.getC4ShapeArray()) { + const type = shape.typeC4Shape.text; + nodes.push({ + id: shape.alias, + label: buildNodeLabel(shape), + isGroup: false, + shape: getNodeShape(type), + parentId: parentIdOf(shape.parentBoundary), + cssClasses: `c4-shape c4-${type}${isExternal(type) ? ' c4-external' : ''}`, + cssStyles: [...configColorStyles(type, c4Config), ...elementCssStyles(shape)], + link: shape.link, + look: config.look, + }); + } + + db.getRels().forEach((rel, index) => { + const isBidirectional = rel.type === 'birel'; + const isBack = rel.type === 'rel_b'; + const style: string[] = []; + const labelStyle: string[] = []; + if (rel.lineColor) { + style.push(`stroke:${rel.lineColor}`); + } + if (rel.textColor) { + labelStyle.push(`color:${rel.textColor}`); + } + edges.push({ + id: `c4-edge-${index}-${rel.from}-${rel.to}`, + start: rel.from, + end: rel.to, + label: buildEdgeLabel(rel), + arrowTypeStart: isBidirectional || isBack ? 'arrow_point' : undefined, + arrowTypeEnd: isBack ? undefined : 'arrow_point', + style, + labelStyle, + classes: 'c4-rel', + look: config.look, + }); + }); + + return { + nodes, + edges, + config, + }; +}; From 70bf9974b7d656aaa53f28c85697acef80b982ce Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:42 +0200 Subject: [PATCH 2/8] feat(c4): unified renderer behind c4.useUnifiedRenderer config flag Adds an opt-in renderer for C4 diagrams that goes through the unified rendering pipeline (dagre by default, other algorithms such as ELK via registerLayoutLoaders), replacing the legacy statement-order grid with a real layout algorithm. The legacy row-based renderer stays the default, so existing diagrams render byte-identical unless the flag is set. - c4Renderer-unified.ts: renders getData() output via render() - c4Diagram.ts: dispatches between legacy and unified renderer - styles.js: CSS for the unified path (nodes, dashed boundary clusters, edges, edge labels, title), theme-variable driven - config.schema.yaml: new c4.useUnifiedRenderer flag (default false) Co-Authored-By: Claude Fable 5 --- packages/mermaid/src/config.type.ts | 7 ++ packages/mermaid/src/diagrams/c4/c4Diagram.ts | 18 +++- .../src/diagrams/c4/c4Renderer-unified.ts | 42 +++++++++ packages/mermaid/src/diagrams/c4/styles.js | 87 +++++++++++++++++++ .../mermaid/src/schemas/config.schema.yaml | 7 ++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/mermaid/src/diagrams/c4/c4Renderer-unified.ts diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 0843aa96487..b72fd5b7c75 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1364,6 +1364,13 @@ export interface C4DiagramConfig extends BaseDiagramConfig { * How many boundaries to place in each row. */ c4BoundaryInRow?: number; + /** + * When true, the C4 diagram is rendered through the unified rendering + * pipeline with a proper layout algorithm (dagre by default) instead of + * the legacy row-based renderer. + * + */ + useUnifiedRenderer?: boolean; /** * This sets the font size of Person shape for the diagram */ diff --git a/packages/mermaid/src/diagrams/c4/c4Diagram.ts b/packages/mermaid/src/diagrams/c4/c4Diagram.ts index 9557a0f708d..8ad774eb30e 100644 --- a/packages/mermaid/src/diagrams/c4/c4Diagram.ts +++ b/packages/mermaid/src/diagrams/c4/c4Diagram.ts @@ -2,14 +2,30 @@ import parser from './parser/c4Diagram.jison'; import db from './c4Db.js'; import renderer from './c4Renderer.js'; +import unifiedRenderer from './c4Renderer-unified.js'; import styles from './styles.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; import type { MermaidConfig } from '../../config.type.js'; import type { DiagramDefinition } from '../../diagram-api/types.js'; +/** + * Dispatches between the legacy row-based renderer (default) and the unified + * rendering pipeline (opt-in via the `c4.useUnifiedRenderer` config flag). + */ +const dispatchingRenderer = { + setConf: renderer.setConf, + draw: async (text: string, id: string, version: string, diag: any) => { + if (getConfig().c4?.useUnifiedRenderer) { + return unifiedRenderer.draw(text, id, version, diag); + } + return renderer.draw(text, id, version, diag); + }, +}; + export const diagram: DiagramDefinition = { parser, db, - renderer, + renderer: dispatchingRenderer, styles, init: ({ c4, wrap }: MermaidConfig) => { renderer.setConf(c4); diff --git a/packages/mermaid/src/diagrams/c4/c4Renderer-unified.ts b/packages/mermaid/src/diagrams/c4/c4Renderer-unified.ts new file mode 100644 index 00000000000..5ea3ed194da --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/c4Renderer-unified.ts @@ -0,0 +1,42 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import { log } from '../../logger.js'; +import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js'; +import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js'; +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; +import utils from '../../utils.js'; +import { getData } from './c4LayoutData.js'; + +/** + * Renders a C4 diagram through the unified rendering pipeline (dagre by + * default, other layout algorithms via registerLayoutLoaders). Selected via + * the `c4.useUnifiedRenderer` config flag; the legacy row-based renderer + * stays the default. + */ +export const draw = async function (_text: string, id: string, _version: string, diag: any) { + log.debug('Drawing C4 diagram (unified)', id); + const config = getConfig(); + const { securityLevel, layout } = config; + const c4Config = config.c4; + + const data4Layout = getData(diag.db, config); + + const svg = getDiagramElement(id, securityLevel); + + data4Layout.type = diag.type; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout); + data4Layout.direction = 'TB'; + data4Layout.nodeSpacing = c4Config?.c4ShapeMargin ?? 50; + data4Layout.rankSpacing = c4Config?.c4ShapeMargin ?? 50; + data4Layout.markers = ['point']; + data4Layout.diagramId = id; + + await render(data4Layout, svg); + + const padding = c4Config?.diagramMarginY ?? 10; + utils.insertTitle(svg, 'c4TitleText', padding, diag.db.getTitle()); + setupViewPortForSVG(svg, padding, 'c4', c4Config?.useMaxWidth ?? true); +}; + +export default { + draw, +}; diff --git a/packages/mermaid/src/diagrams/c4/styles.js b/packages/mermaid/src/diagrams/c4/styles.js index c24412b3c7b..fe494e515c5 100644 --- a/packages/mermaid/src/diagrams/c4/styles.js +++ b/packages/mermaid/src/diagrams/c4/styles.js @@ -3,6 +3,93 @@ const getStyles = (options) => stroke: ${options.personBorder}; fill: ${options.personBkg}; } + + /* Unified renderer (c4.useUnifiedRenderer) only - the legacy svgDraw + renderer emits none of the .node/.cluster/.label/.edgeLabel classes. */ + .label { + font-family: ${options.fontFamily}; + color: ${options.nodeTextColor || options.textColor}; + } + .label text, span { + fill: ${options.nodeTextColor || options.textColor}; + color: ${options.nodeTextColor || options.textColor}; + } + .node rect, + .node circle, + .node path { + fill: ${options.mainBkg}; + stroke: ${options.nodeBorder}; + stroke-width: 1px; + } + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + /* C4 elements show light text on saturated backgrounds */ + .c4-shape .label, + .c4-shape .label text, + .c4-shape .label span { + color: #ffffff; + fill: #ffffff; + } + .c4-shape .label small { + font-size: 0.75em; + } + + .arrowheadPath { + fill: ${options.arrowheadColor}; + } + .edgePath .path { + stroke: ${options.lineColor}; + stroke-width: 2px; + } + path.c4-rel, + .flowchart-link { + stroke: ${options.lineColor}; + fill: none; + } + .edgeLabel { + background-color: ${options.edgeLabelBackground}; + p { + background-color: ${options.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${options.edgeLabelBackground}; + fill: ${options.edgeLabelBackground}; + } + text-align: center; + } + .labelBkg { + background-color: ${options.edgeLabelBackground}; + } + + /* C4 boundaries are dashed, mostly-transparent clusters */ + .cluster rect { + fill: none; + stroke: ${options.nodeBorder}; + stroke-dasharray: 7 7; + stroke-width: 1px; + } + .cluster .cluster-label { + font-family: ${options.fontFamily}; + } + .cluster text { + fill: ${options.titleColor}; + } + .cluster span { + color: ${options.titleColor}; + } + + .c4TitleText { + text-anchor: middle; + font-size: 18px; + fill: ${options.textColor}; + font-family: ${options.fontFamily}; + } `; export default getStyles; diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 95f024eddd5..c6f199d0bf3 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -467,6 +467,13 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: integer default: 2 minimum: 0 + useUnifiedRenderer: + description: | + When true, the C4 diagram is rendered through the unified rendering + pipeline with a proper layout algorithm (dagre by default) instead of + the legacy row-based renderer. + type: boolean + default: false # --------------------------------------------------------------------- # # Default font values From d9595c32f50f3d924c22a6ce61fa835e0d2f7a9b Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:42 +0200 Subject: [PATCH 3/8] feat(c4): add c4-person shape for Person elements in unified renderer Renders C4 Person and Person_Ext elements with the classic C4 notation (circular head over a rounded box body) instead of a plain rectangle. The shape is registered in the shared shape registry as c4-person so other diagrams can reuse it. Co-Authored-By: Claude Fable 5 --- packages/mermaid/scripts/docs.spec.ts | 101 +++++++++--------- .../src/diagrams/c4/c4LayoutData.spec.ts | 4 +- .../mermaid/src/diagrams/c4/c4LayoutData.ts | 3 + .../rendering-elements/shapes.ts | 9 ++ .../rendering-elements/shapes/c4Person.ts | 58 ++++++++++ 5 files changed, 124 insertions(+), 51 deletions(-) create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/c4Person.ts diff --git a/packages/mermaid/scripts/docs.spec.ts b/packages/mermaid/scripts/docs.spec.ts index b9e3fb7ca17..a48fc5ee767 100644 --- a/packages/mermaid/scripts/docs.spec.ts +++ b/packages/mermaid/scripts/docs.spec.ts @@ -169,56 +169,57 @@ This Markdown should be kept. describe('buildShapeDoc', () => { it('should build shapesTable based on the shapeDefs', () => { expect(buildShapeDoc()).toMatchInlineSnapshot(` - "| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | - | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | - | Bang | Bang | \`bang\` | Bang | \`bang\` | - | Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` | - | Cloud | Cloud | \`cloud\` | cloud | \`cloud\` | - | Collate | Hourglass | \`hourglass\` | Represents a collate operation | \`collate\`, \`hourglass\` | - | Com Link | Lightning Bolt | \`bolt\` | Communication link | \`com-link\`, \`lightning-bolt\` | - | Comment | Curly Brace | \`brace\` | Adds a comment | \`brace-l\`, \`comment\` | - | Comment Right | Curly Brace | \`brace-r\` | Adds a comment | | - | Comment with braces on both sides | Curly Braces | \`braces\` | Adds a comment | | - | Data Input/Output | Lean Right | \`lean-r\` | Represents input or output | \`in-out\`, \`lean-right\` | - | Data Input/Output | Lean Left | \`lean-l\` | Represents output or input | \`lean-left\`, \`out-in\` | - | Data Store | Data Store | \`datastore\` | Data flow diagram data store | \`data-store\` | - | Database | Cylinder | \`cyl\` | Database storage | \`cylinder\`, \`database\`, \`db\` | - | Decision | Diamond | \`diam\` | Decision-making step | \`decision\`, \`diamond\`, \`question\` | - | Delay | Half-Rounded Rectangle | \`delay\` | Represents a delay | \`half-rounded-rectangle\` | - | Direct Access Storage | Horizontal Cylinder | \`h-cyl\` | Direct access storage | \`das\`, \`horizontal-cylinder\` | - | Disk Storage | Lined Cylinder | \`lin-cyl\` | Disk storage | \`disk\`, \`lined-cylinder\` | - | Display | Curved Trapezoid | \`curv-trap\` | Represents a display | \`curved-trapezoid\`, \`display\` | - | Divided Process | Divided Rectangle | \`div-rect\` | Divided process shape | \`div-proc\`, \`divided-process\`, \`divided-rectangle\` | - | Document | Document | \`doc\` | Represents a document | \`doc\`, \`document\` | - | Event | Rounded Rectangle | \`rounded\` | Represents an event | \`event\` | - | Extract | Triangle | \`tri\` | Extraction process | \`extract\`, \`triangle\` | - | Fork/Join | Filled Rectangle | \`fork\` | Fork or join in process flow | \`join\` | - | Internal Storage | Window Pane | \`win-pane\` | Internal storage | \`internal-storage\`, \`window-pane\` | - | Junction | Filled Circle | \`f-circ\` | Junction point | \`filled-circle\`, \`junction\` | - | Lined Document | Lined Document | \`lin-doc\` | Lined document | \`lined-document\` | - | Lined/Shaded Process | Lined Rectangle | \`lin-rect\` | Lined process shape | \`lin-proc\`, \`lined-process\`, \`lined-rectangle\`, \`shaded-process\` | - | Loop Limit | Trapezoidal Pentagon | \`notch-pent\` | Loop limit step | \`loop-limit\`, \`notched-pentagon\` | - | Manual File | Flipped Triangle | \`flip-tri\` | Manual file operation | \`flipped-triangle\`, \`manual-file\` | - | Manual Input | Sloped Rectangle | \`sl-rect\` | Manual input step | \`manual-input\`, \`sloped-rectangle\` | - | Manual Operation | Trapezoid Base Top | \`trap-t\` | Represents a manual task | \`inv-trapezoid\`, \`manual\`, \`trapezoid-top\` | - | Multi-Document | Stacked Document | \`docs\` | Multiple documents | \`documents\`, \`st-doc\`, \`stacked-document\` | - | Multi-Process | Stacked Rectangle | \`st-rect\` | Multiple processes | \`processes\`, \`procs\`, \`stacked-rectangle\` | - | Odd | Odd | \`odd\` | Odd shape | | - | Paper Tape | Flag | \`flag\` | Paper tape | \`paper-tape\` | - | Prepare Conditional | Hexagon | \`hex\` | Preparation or condition step | \`hexagon\`, \`prepare\` | - | Priority Action | Trapezoid Base Bottom | \`trap-b\` | Priority action | \`priority\`, \`trapezoid\`, \`trapezoid-bottom\` | - | Process | Rectangle | \`rect\` | Standard process shape | \`proc\`, \`process\`, \`rectangle\` | - | Start | Circle | \`circle\` | Starting point | \`circ\` | - | Start | Small Circle | \`sm-circ\` | Small starting point | \`small-circle\`, \`start\` | - | Stop | Double Circle | \`dbl-circ\` | Represents a stop point | \`double-circle\` | - | Stop | Framed Circle | \`fr-circ\` | Stop point | \`framed-circle\`, \`stop\` | - | Stored Data | Bow Tie Rectangle | \`bow-rect\` | Stored data | \`bow-tie-rectangle\`, \`stored-data\` | - | Subprocess | Framed Rectangle | \`fr-rect\` | Subprocess | \`framed-rectangle\`, \`subproc\`, \`subprocess\`, \`subroutine\` | - | Summary | Crossed Circle | \`cross-circ\` | Summary | \`crossed-circle\`, \`summary\` | - | Tagged Document | Tagged Document | \`tag-doc\` | Tagged document | \`tag-doc\`, \`tagged-document\` | - | Tagged Process | Tagged Rectangle | \`tag-rect\` | Tagged process | \`tag-proc\`, \`tagged-process\`, \`tagged-rectangle\` | - | Terminal Point | Stadium | \`stadium\` | Terminal point | \`pill\`, \`terminal\` | - | Text Block | Text Block | \`text\` | Text block | | + "| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | + | --------------------------------- | ---------------------- | -------------- | ------------------------------------------- | ---------------------------------------------------------------- | + | Bang | Bang | \`bang\` | Bang | \`bang\` | + | C4 Person | C4 Person | \`c4-person\` | Person in C4 model notation (head and body) | \`c4person\` | + | Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` | + | Cloud | Cloud | \`cloud\` | cloud | \`cloud\` | + | Collate | Hourglass | \`hourglass\` | Represents a collate operation | \`collate\`, \`hourglass\` | + | Com Link | Lightning Bolt | \`bolt\` | Communication link | \`com-link\`, \`lightning-bolt\` | + | Comment | Curly Brace | \`brace\` | Adds a comment | \`brace-l\`, \`comment\` | + | Comment Right | Curly Brace | \`brace-r\` | Adds a comment | | + | Comment with braces on both sides | Curly Braces | \`braces\` | Adds a comment | | + | Data Input/Output | Lean Right | \`lean-r\` | Represents input or output | \`in-out\`, \`lean-right\` | + | Data Input/Output | Lean Left | \`lean-l\` | Represents output or input | \`lean-left\`, \`out-in\` | + | Data Store | Data Store | \`datastore\` | Data flow diagram data store | \`data-store\` | + | Database | Cylinder | \`cyl\` | Database storage | \`cylinder\`, \`database\`, \`db\` | + | Decision | Diamond | \`diam\` | Decision-making step | \`decision\`, \`diamond\`, \`question\` | + | Delay | Half-Rounded Rectangle | \`delay\` | Represents a delay | \`half-rounded-rectangle\` | + | Direct Access Storage | Horizontal Cylinder | \`h-cyl\` | Direct access storage | \`das\`, \`horizontal-cylinder\` | + | Disk Storage | Lined Cylinder | \`lin-cyl\` | Disk storage | \`disk\`, \`lined-cylinder\` | + | Display | Curved Trapezoid | \`curv-trap\` | Represents a display | \`curved-trapezoid\`, \`display\` | + | Divided Process | Divided Rectangle | \`div-rect\` | Divided process shape | \`div-proc\`, \`divided-process\`, \`divided-rectangle\` | + | Document | Document | \`doc\` | Represents a document | \`doc\`, \`document\` | + | Event | Rounded Rectangle | \`rounded\` | Represents an event | \`event\` | + | Extract | Triangle | \`tri\` | Extraction process | \`extract\`, \`triangle\` | + | Fork/Join | Filled Rectangle | \`fork\` | Fork or join in process flow | \`join\` | + | Internal Storage | Window Pane | \`win-pane\` | Internal storage | \`internal-storage\`, \`window-pane\` | + | Junction | Filled Circle | \`f-circ\` | Junction point | \`filled-circle\`, \`junction\` | + | Lined Document | Lined Document | \`lin-doc\` | Lined document | \`lined-document\` | + | Lined/Shaded Process | Lined Rectangle | \`lin-rect\` | Lined process shape | \`lin-proc\`, \`lined-process\`, \`lined-rectangle\`, \`shaded-process\` | + | Loop Limit | Trapezoidal Pentagon | \`notch-pent\` | Loop limit step | \`loop-limit\`, \`notched-pentagon\` | + | Manual File | Flipped Triangle | \`flip-tri\` | Manual file operation | \`flipped-triangle\`, \`manual-file\` | + | Manual Input | Sloped Rectangle | \`sl-rect\` | Manual input step | \`manual-input\`, \`sloped-rectangle\` | + | Manual Operation | Trapezoid Base Top | \`trap-t\` | Represents a manual task | \`inv-trapezoid\`, \`manual\`, \`trapezoid-top\` | + | Multi-Document | Stacked Document | \`docs\` | Multiple documents | \`documents\`, \`st-doc\`, \`stacked-document\` | + | Multi-Process | Stacked Rectangle | \`st-rect\` | Multiple processes | \`processes\`, \`procs\`, \`stacked-rectangle\` | + | Odd | Odd | \`odd\` | Odd shape | | + | Paper Tape | Flag | \`flag\` | Paper tape | \`paper-tape\` | + | Prepare Conditional | Hexagon | \`hex\` | Preparation or condition step | \`hexagon\`, \`prepare\` | + | Priority Action | Trapezoid Base Bottom | \`trap-b\` | Priority action | \`priority\`, \`trapezoid\`, \`trapezoid-bottom\` | + | Process | Rectangle | \`rect\` | Standard process shape | \`proc\`, \`process\`, \`rectangle\` | + | Start | Circle | \`circle\` | Starting point | \`circ\` | + | Start | Small Circle | \`sm-circ\` | Small starting point | \`small-circle\`, \`start\` | + | Stop | Double Circle | \`dbl-circ\` | Represents a stop point | \`double-circle\` | + | Stop | Framed Circle | \`fr-circ\` | Stop point | \`framed-circle\`, \`stop\` | + | Stored Data | Bow Tie Rectangle | \`bow-rect\` | Stored data | \`bow-tie-rectangle\`, \`stored-data\` | + | Subprocess | Framed Rectangle | \`fr-rect\` | Subprocess | \`framed-rectangle\`, \`subproc\`, \`subprocess\`, \`subroutine\` | + | Summary | Crossed Circle | \`cross-circ\` | Summary | \`crossed-circle\`, \`summary\` | + | Tagged Document | Tagged Document | \`tag-doc\` | Tagged document | \`tag-doc\`, \`tagged-document\` | + | Tagged Process | Tagged Rectangle | \`tag-rect\` | Tagged process | \`tag-proc\`, \`tagged-process\`, \`tagged-rectangle\` | + | Terminal Point | Stadium | \`stadium\` | Terminal point | \`pill\`, \`terminal\` | + | Text Block | Text Block | \`text\` | Text block | | " `); }); diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts index f8125e51531..111e70053db 100644 --- a/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts @@ -101,16 +101,18 @@ System_Ext(b, "B") ); }); - it('maps db and queue element variants to dedicated shapes', () => { + it('maps element variants to dedicated shapes', () => { parse(`C4Context SystemDb(db1, "Database") SystemQueue(q1, "Queue") System(s1, "System") +Person(p1, "Person") `); const { nodes } = data(); expect(nodes.find((n) => n.id === 'db1')?.shape).toBe('cylinder'); expect(nodes.find((n) => n.id === 'q1')?.shape).toBe('h-cyl'); expect(nodes.find((n) => n.id === 's1')?.shape).toBe('rect'); + expect(nodes.find((n) => n.id === 'p1')?.shape).toBe('c4-person'); }); it('marks deployment nodes as group nodes', () => { diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts index f7b1b1bd5ac..21e80059353 100644 --- a/packages/mermaid/src/diagrams/c4/c4LayoutData.ts +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts @@ -75,6 +75,9 @@ const DB_SHAPES = new Set([ ]); const getNodeShape = (typeC4Shape: string): ShapeID => { + if (typeC4Shape === 'person' || typeC4Shape === 'external_person') { + return 'c4-person'; + } if (DB_SHAPES.has(typeC4Shape)) { return 'cylinder'; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 6fbafa769e8..93b5325d3eb 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -13,6 +13,7 @@ import { curlyBraceLeft } from './shapes/curlyBraceLeft.js'; import { curlyBraceRight } from './shapes/curlyBraceRight.js'; import { curlyBraces } from './shapes/curlyBraces.js'; import { curvedTrapezoid } from './shapes/curvedTrapezoid.js'; +import { c4Person } from './shapes/c4Person.js'; import { cylinder } from './shapes/cylinder.js'; import { datastore } from './shapes/datastore.js'; import { dividedRectangle } from './shapes/dividedRect.js'; @@ -142,6 +143,14 @@ export const shapesDefs = [ aliases: ['data-store'], handler: datastore, }, + { + semanticName: 'C4 Person', + name: 'C4 Person', + shortName: 'c4-person', + description: 'Person in C4 model notation (head and body)', + aliases: ['c4person'], + handler: c4Person, + }, { semanticName: 'Start', name: 'Circle', diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/c4Person.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/c4Person.ts new file mode 100644 index 00000000000..c3a059981a6 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/c4Person.ts @@ -0,0 +1,58 @@ +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { styles2String } from './handDrawnShapeStyles.js'; +import type { D3Selection } from '../../../types.js'; + +/** + * C4 person shape: a rounded box body with a circular head overlapping the + * top edge, as used in C4 model notation for Person / Person_Ext elements. + */ +export async function c4Person(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + + const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node)); + + const padding = node.padding ?? 12; + const w = Math.max(bbox.width + padding * 2, 80); + const bodyHeight = bbox.height + padding * 2; + const headRadius = Math.max(Math.min(w * 0.16, 24), 14); + const totalHeight = bodyHeight + headRadius; + + const bodyTop = -totalHeight / 2 + headRadius; + + const group = shapeSvg.insert('g', ':first-child').attr('class', 'basic label-container'); + + // Head first so the body covers its lower half. + group + .append('circle') + .attr('cx', 0) + .attr('cy', bodyTop) + .attr('r', headRadius) + .attr('style', nodeStyles); + + group + .append('rect') + .attr('x', -w / 2) + .attr('y', bodyTop) + .attr('width', w) + .attr('height', bodyHeight) + .attr('rx', 8) + .attr('ry', 8) + .attr('style', nodeStyles); + + updateNodeBounds(node, group); + + const bodyCenterY = bodyTop + bodyHeight / 2; + label.attr( + 'transform', + `translate(${-(bbox.width / 2) - (bbox.x - (bbox.left ?? 0))}, ${bodyCenterY - bbox.height / 2 - (bbox.y - (bbox.top ?? 0))})` + ); + + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +} From e0c27505a8c277bac309511db800d0f69051bca4 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:43 +0200 Subject: [PATCH 4/8] test(c4): add cypress rendering spec for the unified renderer Covers nested boundaries, UpdateElementStyle/UpdateRelStyle, db and queue shape variants, nested deployment nodes, and guards the rendered viewBox dimensions against layout blowups. Co-Authored-By: Claude Fable 5 --- .../rendering/c4/c4-unified.spec.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 cypress/integration/rendering/c4/c4-unified.spec.js diff --git a/cypress/integration/rendering/c4/c4-unified.spec.js b/cypress/integration/rendering/c4/c4-unified.spec.js new file mode 100644 index 00000000000..7450b5240ac --- /dev/null +++ b/cypress/integration/rendering/c4/c4-unified.spec.js @@ -0,0 +1,99 @@ +import { imgSnapshotTest } from '../../../helpers/util.ts'; + +const unified = { c4: { useUnifiedRenderer: true } }; + +describe('C4 diagram (unified renderer)', () => { + it('C4U.1 should render a C4Context diagram with nested boundaries', () => { + imgSnapshotTest( + ` + C4Context + title System Context diagram for Internet Banking System + + Enterprise_Boundary(b0, "BankBoundary0") { + Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.") + + System(SystemAA, "Internet Banking System", "Allows customers to view information about their bank accounts, and make payments.") + + Enterprise_Boundary(b1, "BankBoundary") { + System_Ext(SystemC, "E-mail system", "The internal Microsoft Exchange e-mail system.") + } + } + + BiRel(customerA, SystemAA, "Uses") + Rel(SystemAA, SystemC, "Sends e-mails", "SMTP") + Rel(SystemC, customerA, "Sends e-mails to") + `, + unified + ); + }); + it('C4U.2 should render element styles applied via UpdateElementStyle and UpdateRelStyle', () => { + imgSnapshotTest( + ` + C4Context + Person(customerA, "Banking Customer A", "A customer of the bank.") + System(SystemAA, "Internet Banking System", "Allows customers to make payments.") + Rel(customerA, SystemAA, "Uses") + UpdateElementStyle(customerA, $fontColor="red", $bgColor="grey", $borderColor="red") + UpdateRelStyle(customerA, SystemAA, $textColor="blue", $lineColor="blue") + `, + unified + ); + }); + it('C4U.3 should render db and queue shape variants', () => { + imgSnapshotTest( + ` + C4Container + Person(customer, "Customer", "A customer of the bank.") + Container(spa, "Single-Page App", "JavaScript, Angular", "Provides banking functionality.") + ContainerDb(database, "Database", "Oracle 12c", "Stores user information.") + ContainerQueue(queue, "Queue", "RabbitMQ", "Transaction events.") + Rel(customer, spa, "Uses", "HTTPS") + Rel(spa, database, "Reads from and writes to", "SQL/TCP") + Rel(spa, queue, "Publishes to", "AMQP") + `, + unified + ); + }); + it('C4U.4 should render a deployment diagram with nested deployment nodes', () => { + imgSnapshotTest( + ` + C4Deployment + title Deployment Diagram for Internet Banking System + + Deployment_Node(aws, "Amazon Web Services", "Cloud") { + Deployment_Node(ec2, "EC2", "Ubuntu 22.04") { + Container(api, "API Application", "Java, Spring", "Provides banking functionality via API.") + } + Deployment_Node(rds, "RDS", "Oracle 12c") { + ContainerDb(db, "Database", "Oracle", "Stores user information.") + } + } + Rel(api, db, "Reads from and writes to", "SQL/TCP") + `, + unified + ); + }); + it('C4U.5 should keep the rendered diagram within sane dimensions', () => { + imgSnapshotTest( + ` + C4Context + Enterprise_Boundary(b0, "BankBoundary0") { + Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.") + System(SystemAA, "Internet Banking System", "Allows customers to view information about their bank accounts, and make payments.") + System_Ext(SystemC, "E-mail system", "The internal Microsoft Exchange e-mail system.") + } + BiRel(customerA, SystemAA, "Uses") + Rel(SystemAA, SystemC, "Sends e-mails", "SMTP") + `, + unified + ); + cy.get('svg') + .last() + .should((svg) => { + const [, , width, height] = svg.attr('viewBox').split(' ').map(Number); + // Guard against the layout blowup seen in earlier rewrite attempts + expect(width).to.be.lessThan(2200); + expect(height).to.be.lessThan(2200); + }); + }); +}); From c49ff820edca9d9d3d65c59b66ef4220f427b8ee Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:43 +0200 Subject: [PATCH 5/8] chore(c4): add changeset for the opt-in unified renderer Co-Authored-By: Claude Fable 5 --- .changeset/c4-unified-renderer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/c4-unified-renderer.md diff --git a/.changeset/c4-unified-renderer.md b/.changeset/c4-unified-renderer.md new file mode 100644 index 00000000000..91bb8a67da4 --- /dev/null +++ b/.changeset/c4-unified-renderer.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(c4): opt-in unified renderer for C4 diagrams via the `c4.useUnifiedRenderer` config flag. C4 diagrams can now be laid out by a real layout algorithm (dagre by default, ELK via `@mermaid-js/layout-elk`) instead of the legacy statement-order grid, with a new `c4-person` shape for Person elements. The legacy renderer remains the default; existing diagrams are unaffected. From bb1157c7b82747b37d800b52f3fbebefc191b637 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:51:44 +0200 Subject: [PATCH 6/8] docs(c4): document the useUnifiedRenderer config flag Co-Authored-By: Claude Fable 5 --- docs/syntax/c4.md | 23 ++++++ docs/syntax/flowchart.md | 101 +++++++++++++------------ packages/mermaid/src/docs/syntax/c4.md | 23 ++++++ 3 files changed, 97 insertions(+), 50 deletions(-) diff --git a/docs/syntax/c4.md b/docs/syntax/c4.md index ae610d15254..9d621ccb680 100644 --- a/docs/syntax/c4.md +++ b/docs/syntax/c4.md @@ -210,6 +210,29 @@ UpdateRelStyle(customerA, bankA, $offsetY="60") ``` +## Unified renderer with automatic layout (v\+) + +An experimental opt-in renderer routes C4 diagrams through mermaid's unified +rendering pipeline, replacing the statement-order grid with a real layout +algorithm (dagre by default; ELK via `@mermaid-js/layout-elk` and `layout: elk`). +Person elements are drawn with the classic C4 person notation and nested +boundaries are laid out automatically. + +Enable it via configuration: + +```yaml +--- +config: + c4: + useUnifiedRenderer: true +--- +``` + +When the flag is not set, the legacy renderer is used and existing diagrams +render unchanged. `UpdateLayoutConfig`, `$offsetX` and `$offsetY` only affect +the legacy renderer; element and relationship colors set via +`UpdateElementStyle` and `UpdateRelStyle` are honored by both renderers. + ## C4 System Context Diagram (C4Context) ```mermaid-example diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md index 687992a719c..e1243130d75 100644 --- a/docs/syntax/flowchart.md +++ b/docs/syntax/flowchart.md @@ -322,56 +322,57 @@ This syntax creates a node A as a rectangle. It renders in the same way as `A["A Below is a comprehensive list of the newly introduced shapes and their corresponding semantic meanings, short names, and aliases: -| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | -| --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | -| Bang | Bang | `bang` | Bang | `bang` | -| Card | Notched Rectangle | `notch-rect` | Represents a card | `card`, `notched-rectangle` | -| Cloud | Cloud | `cloud` | cloud | `cloud` | -| Collate | Hourglass | `hourglass` | Represents a collate operation | `collate`, `hourglass` | -| Com Link | Lightning Bolt | `bolt` | Communication link | `com-link`, `lightning-bolt` | -| Comment | Curly Brace | `brace` | Adds a comment | `brace-l`, `comment` | -| Comment Right | Curly Brace | `brace-r` | Adds a comment | | -| Comment with braces on both sides | Curly Braces | `braces` | Adds a comment | | -| Data Input/Output | Lean Right | `lean-r` | Represents input or output | `in-out`, `lean-right` | -| Data Input/Output | Lean Left | `lean-l` | Represents output or input | `lean-left`, `out-in` | -| Data Store | Data Store | `datastore` | Data flow diagram data store | `data-store` | -| Database | Cylinder | `cyl` | Database storage | `cylinder`, `database`, `db` | -| Decision | Diamond | `diam` | Decision-making step | `decision`, `diamond`, `question` | -| Delay | Half-Rounded Rectangle | `delay` | Represents a delay | `half-rounded-rectangle` | -| Direct Access Storage | Horizontal Cylinder | `h-cyl` | Direct access storage | `das`, `horizontal-cylinder` | -| Disk Storage | Lined Cylinder | `lin-cyl` | Disk storage | `disk`, `lined-cylinder` | -| Display | Curved Trapezoid | `curv-trap` | Represents a display | `curved-trapezoid`, `display` | -| Divided Process | Divided Rectangle | `div-rect` | Divided process shape | `div-proc`, `divided-process`, `divided-rectangle` | -| Document | Document | `doc` | Represents a document | `doc`, `document` | -| Event | Rounded Rectangle | `rounded` | Represents an event | `event` | -| Extract | Triangle | `tri` | Extraction process | `extract`, `triangle` | -| Fork/Join | Filled Rectangle | `fork` | Fork or join in process flow | `join` | -| Internal Storage | Window Pane | `win-pane` | Internal storage | `internal-storage`, `window-pane` | -| Junction | Filled Circle | `f-circ` | Junction point | `filled-circle`, `junction` | -| Lined Document | Lined Document | `lin-doc` | Lined document | `lined-document` | -| Lined/Shaded Process | Lined Rectangle | `lin-rect` | Lined process shape | `lin-proc`, `lined-process`, `lined-rectangle`, `shaded-process` | -| Loop Limit | Trapezoidal Pentagon | `notch-pent` | Loop limit step | `loop-limit`, `notched-pentagon` | -| Manual File | Flipped Triangle | `flip-tri` | Manual file operation | `flipped-triangle`, `manual-file` | -| Manual Input | Sloped Rectangle | `sl-rect` | Manual input step | `manual-input`, `sloped-rectangle` | -| Manual Operation | Trapezoid Base Top | `trap-t` | Represents a manual task | `inv-trapezoid`, `manual`, `trapezoid-top` | -| Multi-Document | Stacked Document | `docs` | Multiple documents | `documents`, `st-doc`, `stacked-document` | -| Multi-Process | Stacked Rectangle | `st-rect` | Multiple processes | `processes`, `procs`, `stacked-rectangle` | -| Odd | Odd | `odd` | Odd shape | | -| Paper Tape | Flag | `flag` | Paper tape | `paper-tape` | -| Prepare Conditional | Hexagon | `hex` | Preparation or condition step | `hexagon`, `prepare` | -| Priority Action | Trapezoid Base Bottom | `trap-b` | Priority action | `priority`, `trapezoid`, `trapezoid-bottom` | -| Process | Rectangle | `rect` | Standard process shape | `proc`, `process`, `rectangle` | -| Start | Circle | `circle` | Starting point | `circ` | -| Start | Small Circle | `sm-circ` | Small starting point | `small-circle`, `start` | -| Stop | Double Circle | `dbl-circ` | Represents a stop point | `double-circle` | -| Stop | Framed Circle | `fr-circ` | Stop point | `framed-circle`, `stop` | -| Stored Data | Bow Tie Rectangle | `bow-rect` | Stored data | `bow-tie-rectangle`, `stored-data` | -| Subprocess | Framed Rectangle | `fr-rect` | Subprocess | `framed-rectangle`, `subproc`, `subprocess`, `subroutine` | -| Summary | Crossed Circle | `cross-circ` | Summary | `crossed-circle`, `summary` | -| Tagged Document | Tagged Document | `tag-doc` | Tagged document | `tag-doc`, `tagged-document` | -| Tagged Process | Tagged Rectangle | `tag-rect` | Tagged process | `tag-proc`, `tagged-process`, `tagged-rectangle` | -| Terminal Point | Stadium | `stadium` | Terminal point | `pill`, `terminal` | -| Text Block | Text Block | `text` | Text block | | +| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | +| --------------------------------- | ---------------------- | -------------- | ------------------------------------------- | ---------------------------------------------------------------- | +| Bang | Bang | `bang` | Bang | `bang` | +| C4 Person | C4 Person | `c4-person` | Person in C4 model notation (head and body) | `c4person` | +| Card | Notched Rectangle | `notch-rect` | Represents a card | `card`, `notched-rectangle` | +| Cloud | Cloud | `cloud` | cloud | `cloud` | +| Collate | Hourglass | `hourglass` | Represents a collate operation | `collate`, `hourglass` | +| Com Link | Lightning Bolt | `bolt` | Communication link | `com-link`, `lightning-bolt` | +| Comment | Curly Brace | `brace` | Adds a comment | `brace-l`, `comment` | +| Comment Right | Curly Brace | `brace-r` | Adds a comment | | +| Comment with braces on both sides | Curly Braces | `braces` | Adds a comment | | +| Data Input/Output | Lean Right | `lean-r` | Represents input or output | `in-out`, `lean-right` | +| Data Input/Output | Lean Left | `lean-l` | Represents output or input | `lean-left`, `out-in` | +| Data Store | Data Store | `datastore` | Data flow diagram data store | `data-store` | +| Database | Cylinder | `cyl` | Database storage | `cylinder`, `database`, `db` | +| Decision | Diamond | `diam` | Decision-making step | `decision`, `diamond`, `question` | +| Delay | Half-Rounded Rectangle | `delay` | Represents a delay | `half-rounded-rectangle` | +| Direct Access Storage | Horizontal Cylinder | `h-cyl` | Direct access storage | `das`, `horizontal-cylinder` | +| Disk Storage | Lined Cylinder | `lin-cyl` | Disk storage | `disk`, `lined-cylinder` | +| Display | Curved Trapezoid | `curv-trap` | Represents a display | `curved-trapezoid`, `display` | +| Divided Process | Divided Rectangle | `div-rect` | Divided process shape | `div-proc`, `divided-process`, `divided-rectangle` | +| Document | Document | `doc` | Represents a document | `doc`, `document` | +| Event | Rounded Rectangle | `rounded` | Represents an event | `event` | +| Extract | Triangle | `tri` | Extraction process | `extract`, `triangle` | +| Fork/Join | Filled Rectangle | `fork` | Fork or join in process flow | `join` | +| Internal Storage | Window Pane | `win-pane` | Internal storage | `internal-storage`, `window-pane` | +| Junction | Filled Circle | `f-circ` | Junction point | `filled-circle`, `junction` | +| Lined Document | Lined Document | `lin-doc` | Lined document | `lined-document` | +| Lined/Shaded Process | Lined Rectangle | `lin-rect` | Lined process shape | `lin-proc`, `lined-process`, `lined-rectangle`, `shaded-process` | +| Loop Limit | Trapezoidal Pentagon | `notch-pent` | Loop limit step | `loop-limit`, `notched-pentagon` | +| Manual File | Flipped Triangle | `flip-tri` | Manual file operation | `flipped-triangle`, `manual-file` | +| Manual Input | Sloped Rectangle | `sl-rect` | Manual input step | `manual-input`, `sloped-rectangle` | +| Manual Operation | Trapezoid Base Top | `trap-t` | Represents a manual task | `inv-trapezoid`, `manual`, `trapezoid-top` | +| Multi-Document | Stacked Document | `docs` | Multiple documents | `documents`, `st-doc`, `stacked-document` | +| Multi-Process | Stacked Rectangle | `st-rect` | Multiple processes | `processes`, `procs`, `stacked-rectangle` | +| Odd | Odd | `odd` | Odd shape | | +| Paper Tape | Flag | `flag` | Paper tape | `paper-tape` | +| Prepare Conditional | Hexagon | `hex` | Preparation or condition step | `hexagon`, `prepare` | +| Priority Action | Trapezoid Base Bottom | `trap-b` | Priority action | `priority`, `trapezoid`, `trapezoid-bottom` | +| Process | Rectangle | `rect` | Standard process shape | `proc`, `process`, `rectangle` | +| Start | Circle | `circle` | Starting point | `circ` | +| Start | Small Circle | `sm-circ` | Small starting point | `small-circle`, `start` | +| Stop | Double Circle | `dbl-circ` | Represents a stop point | `double-circle` | +| Stop | Framed Circle | `fr-circ` | Stop point | `framed-circle`, `stop` | +| Stored Data | Bow Tie Rectangle | `bow-rect` | Stored data | `bow-tie-rectangle`, `stored-data` | +| Subprocess | Framed Rectangle | `fr-rect` | Subprocess | `framed-rectangle`, `subproc`, `subprocess`, `subroutine` | +| Summary | Crossed Circle | `cross-circ` | Summary | `crossed-circle`, `summary` | +| Tagged Document | Tagged Document | `tag-doc` | Tagged document | `tag-doc`, `tagged-document` | +| Tagged Process | Tagged Rectangle | `tag-rect` | Tagged process | `tag-proc`, `tagged-process`, `tagged-rectangle` | +| Terminal Point | Stadium | `stadium` | Terminal point | `pill`, `terminal` | +| Text Block | Text Block | `text` | Text block | | ### Example Flowchart with New Shapes diff --git a/packages/mermaid/src/docs/syntax/c4.md b/packages/mermaid/src/docs/syntax/c4.md index 5def257f461..e1cca134e5b 100644 --- a/packages/mermaid/src/docs/syntax/c4.md +++ b/packages/mermaid/src/docs/syntax/c4.md @@ -154,6 +154,29 @@ UpdateRelStyle(customerA, bankA, $offsetY="60") ``` +## Unified renderer with automatic layout (v+) + +An experimental opt-in renderer routes C4 diagrams through mermaid's unified +rendering pipeline, replacing the statement-order grid with a real layout +algorithm (dagre by default; ELK via `@mermaid-js/layout-elk` and `layout: elk`). +Person elements are drawn with the classic C4 person notation and nested +boundaries are laid out automatically. + +Enable it via configuration: + +```yaml +--- +config: + c4: + useUnifiedRenderer: true +--- +``` + +When the flag is not set, the legacy renderer is used and existing diagrams +render unchanged. `UpdateLayoutConfig`, `$offsetX` and `$offsetY` only affect +the legacy renderer; element and relationship colors set via +`UpdateElementStyle` and `UpdateRelStyle` are honored by both renderers. + ## C4 System Context Diagram (C4Context) ```mermaid-example From 1eaef4f3b0c276e053fa26f699360b533779c57d Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 09:52:24 +0200 Subject: [PATCH 7/8] feat(c4): style elements and rels via AddElementTag and AddRelTag Add the C4-PlantUML macros AddElementTag(tagName, ?bgColor, ?fontColor, ?borderColor, ?shape) and AddRelTag(tagName, ?textColor, ?lineColor) to the grammar and store the definitions in the db (getElementTags/getRelTags, reset in clear). The unified renderer resolves the $tags of every element, boundary and rel against the defined tags ('+' separated per C4-PlantUML, commas accepted) and applies the tag colors between the per-type config palette and the per-element UpdateElementStyle/UpdateRelStyle overrides. Tag names are also exposed as c4-tag- css classes on the nodes for user styling. The legacy renderer ignores tags, as documented. Co-Authored-By: Claude Fable 5 --- .../rendering/c4/c4-unified.spec.js | 13 +++ docs/syntax/c4.md | 20 ++++ packages/mermaid/src/diagrams/c4/c4Db.ts | 78 +++++++++++++++- .../src/diagrams/c4/c4LayoutData.spec.ts | 52 +++++++++++ .../mermaid/src/diagrams/c4/c4LayoutData.ts | 79 +++++++++++++++- packages/mermaid/src/diagrams/c4/c4Types.ts | 20 ++++ .../src/diagrams/c4/parser/c4AddTag.spec.ts | 92 +++++++++++++++++++ .../src/diagrams/c4/parser/c4Diagram.jison | 4 + packages/mermaid/src/docs/syntax/c4.md | 20 ++++ 9 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 packages/mermaid/src/diagrams/c4/parser/c4AddTag.spec.ts diff --git a/cypress/integration/rendering/c4/c4-unified.spec.js b/cypress/integration/rendering/c4/c4-unified.spec.js index 7450b5240ac..36053bd482a 100644 --- a/cypress/integration/rendering/c4/c4-unified.spec.js +++ b/cypress/integration/rendering/c4/c4-unified.spec.js @@ -96,4 +96,17 @@ describe('C4 diagram (unified renderer)', () => { expect(height).to.be.lessThan(2200); }); }); + it('C4U.7 should style elements and rels via AddElementTag and AddRelTag', () => { + imgSnapshotTest( + ` + C4Context + AddElementTag(deprecated, $bgColor="grey", $fontColor="white", $borderColor="red") + AddRelTag(async, $textColor="green", $lineColor="green") + Person(customerA, "Banking Customer A", "A customer of the bank.") + System(SystemAA, "Internet Banking System", "Allows customers to make payments.", $tags="deprecated") + Rel(customerA, SystemAA, "Uses", $tags="async") + `, + unified + ); + }); }); diff --git a/docs/syntax/c4.md b/docs/syntax/c4.md index 9d621ccb680..d07b3357957 100644 --- a/docs/syntax/c4.md +++ b/docs/syntax/c4.md @@ -233,6 +233,26 @@ render unchanged. `UpdateLayoutConfig`, `$offsetX` and `$offsetY` only affect the legacy renderer; element and relationship colors set via `UpdateElementStyle` and `UpdateRelStyle` are honored by both renderers. +### Tags (v\+) + +`AddElementTag(tagName, ?bgColor, ?fontColor, ?borderColor)` and +`AddRelTag(tagName, ?textColor, ?lineColor)` define named styles, following +the C4-PlantUML macros of the same name. Elements and relationships opt in +with `$tags="name"`; combine multiple tags with `+`, e.g. +`$tags="v1.0+deprecated"`. Tag styles override the per-type defaults and are +themselves overridden by `UpdateElementStyle`/`UpdateRelStyle`. Each tag is +also added to the element's CSS classes as `c4-tag-` for custom +styling via `themeCSS` or `classDefs`. + +``` +C4Context +AddElementTag(deprecated, $bgColor="grey", $borderColor="red") +AddRelTag(async, $textColor="green", $lineColor="green") +Person(customerA, "Banking Customer A") +System(SystemAA, "Internet Banking System", $tags="deprecated") +Rel(customerA, SystemAA, "Uses", $tags="async") +``` + ## C4 System Context Diagram (C4Context) ```mermaid-example diff --git a/packages/mermaid/src/diagrams/c4/c4Db.ts b/packages/mermaid/src/diagrams/c4/c4Db.ts index 8122a1f5b37..a98a5de3e2b 100644 --- a/packages/mermaid/src/diagrams/c4/c4Db.ts +++ b/packages/mermaid/src/diagrams/c4/c4Db.ts @@ -7,7 +7,7 @@ import { getAccDescription, setAccDescription, } from '../common/commonDb.js'; -import type { C4Boundary, C4Rel, C4Shape } from './c4Types.js'; +import type { C4Boundary, C4ElementTag, C4Rel, C4RelTag, C4Shape } from './c4Types.js'; /** * The parser may pass a plain string or an object with a single @@ -55,6 +55,8 @@ let currentBoundaryParse = 'global'; let parentBoundaryParse = ''; let boundaries: C4Boundary[] = [createGlobalBoundary()]; let rels: C4Rel[] = []; +let elementTags: C4ElementTag[] = []; +let relTags: C4RelTag[] = []; let title = ''; let wrapEnabled: boolean | undefined = false; let c4ShapeInRow = 4; @@ -625,6 +627,74 @@ export const updateRelStyle = function ( } }; +const assignTagAttribute = function ( + tag: C4ElementTag | C4RelTag, + key: string, + value?: ParserAttribute | null +) { + if (value === undefined || value === null) { + return; + } + if (typeof value === 'object') { + const [k, v] = Object.entries(value)[0]; + tag[k] = v; + } else { + tag[key] = value; + } +}; + +const findOrCreateTag = function ( + tags: T[], + tagName: string +): T { + let tag = tags.find((tag) => tag.tagName === tagName); + if (tag === undefined) { + tag = { tagName } as T; + tags.push(tag); + } + return tag; +}; + +//tagName, ?bgColor, ?fontColor, ?borderColor, ?shape +export const addElementTag = function ( + tagName: string | null | undefined, + bgColor?: ParserAttribute | null, + fontColor?: ParserAttribute | null, + borderColor?: ParserAttribute | null, + shape?: ParserAttribute | null +) { + if (tagName === undefined || tagName === null) { + return; + } + const tag = findOrCreateTag(elementTags, tagName); + assignTagAttribute(tag, 'bgColor', bgColor); + assignTagAttribute(tag, 'fontColor', fontColor); + assignTagAttribute(tag, 'borderColor', borderColor); + assignTagAttribute(tag, 'shape', shape); +}; + +//tagName, ?textColor, ?lineColor +export const addRelTag = function ( + tagName: string | null | undefined, + textColor?: ParserAttribute | null, + lineColor?: ParserAttribute | null +) { + if (tagName === undefined || tagName === null) { + return; + } + const tag = findOrCreateTag(relTags, tagName); + assignTagAttribute(tag, 'textColor', textColor); + assignTagAttribute(tag, 'lineColor', lineColor); +}; + +export const getElementTags = function () { + return elementTags; +}; + +export const getRelTags = function () { + return relTags; +}; + //?c4ShapeInRow, ?c4BoundaryInRow export const updateLayoutConfig = function ( typeC4Shape: string, @@ -722,6 +792,8 @@ export const clear = function () { currentBoundaryParse = 'global'; boundaryParseStack = ['']; rels = []; + elementTags = []; + relTags = []; boundaryParseStack = ['']; title = ''; @@ -781,6 +853,10 @@ export default { addDeploymentNode, popBoundaryParseStack, addRel, + addElementTag, + addRelTag, + getElementTags, + getRelTags, updateElStyle, updateRelStyle, updateLayoutConfig, diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts index 111e70053db..dbcdb281004 100644 --- a/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.spec.ts @@ -115,6 +115,58 @@ Person(p1, "Person") expect(nodes.find((n) => n.id === 'p1')?.shape).toBe('c4-person'); }); + it('applies tag styles after type defaults and before UpdateElementStyle', () => { + parse(`C4Context +AddElementTag(v1.0, $bgColor="#111111", $fontColor="#eeeeee") +Person(a, "A", "desc", $tags="v1.0") +Person(b, "B", "desc", $tags="v1.0") +UpdateElementStyle(b, $bgColor="#222222") +`); + const { nodes } = getData(c4Db as any, { c4: { person_bg_color: '#000000' } } as any); + expect(nodes.find((n) => n.id === 'a')?.cssStyles).toEqual([ + 'fill:#000000', + 'fill:#111111', + 'color:#eeeeee', + ]); + expect(nodes.find((n) => n.id === 'b')?.cssStyles).toEqual([ + 'fill:#000000', + 'fill:#111111', + 'color:#eeeeee', + 'fill:#222222', + ]); + }); + + it('adds sanitized c4-tag css classes for every tag on an element', () => { + parse(`C4Context +Person(a, "A", "desc", $tags="v1.0+internal api") +`); + const cssClasses = data().nodes.find((n) => n.id === 'a')?.cssClasses; + expect(cssClasses).toContain('c4-tag-v1-0'); + expect(cssClasses).toContain('c4-tag-internal-api'); + }); + + it('applies rel tag styles before UpdateRelStyle overrides', () => { + parse(`C4Context +AddRelTag(async, $textColor="grey", $lineColor="grey") +Person(a, "A") +Person(b, "B") +Rel(a, b, "Uses", $tags="async") +UpdateRelStyle(a, b, $lineColor="green") +`); + const { edges } = data(); + expect(edges[0].style).toEqual(['stroke:grey', 'stroke:green']); + expect(edges[0].labelStyle).toEqual(['color:grey']); + }); + + it('ignores tags that have no AddElementTag definition', () => { + parse(`C4Context +Person(a, "A", "desc", $tags="undefinedTag") +`); + const a = data().nodes.find((n) => n.id === 'a'); + expect(a?.cssStyles).toEqual([]); + expect(a?.cssClasses).toContain('c4-tag-undefinedTag'); + }); + it('marks deployment nodes as group nodes', () => { parse(`C4Deployment Deployment_Node(n1, "AWS", "Cloud") { diff --git a/packages/mermaid/src/diagrams/c4/c4LayoutData.ts b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts index 21e80059353..7a25d60ad46 100644 --- a/packages/mermaid/src/diagrams/c4/c4LayoutData.ts +++ b/packages/mermaid/src/diagrams/c4/c4LayoutData.ts @@ -13,6 +13,8 @@ interface C4Text { text: string; } +type C4Tags = string | C4Text | null; + interface C4Shape { alias: string; label: C4Text; @@ -24,6 +26,7 @@ interface C4Shape { fontColor?: string; borderColor?: string; link?: string; + tags?: C4Tags; } interface C4Boundary { @@ -37,6 +40,7 @@ interface C4Boundary { fontColor?: string; borderColor?: string; link?: string | null; + tags?: C4Tags; } interface C4Rel { @@ -48,12 +52,29 @@ interface C4Rel { descr?: C4Text; textColor?: string; lineColor?: string; + tags?: C4Tags; +} + +export interface C4ElementTag { + tagName: string; + bgColor?: string; + fontColor?: string; + borderColor?: string; + shape?: string; +} + +export interface C4RelTag { + tagName: string; + textColor?: string; + lineColor?: string; } interface C4Db { getC4ShapeArray: (parentBoundary?: string) => C4Shape[]; getBoundaries: (parentBoundary?: string) => C4Boundary[]; getRels: () => C4Rel[]; + getElementTags: () => C4ElementTag[]; + getRelTags: () => C4RelTag[]; } const QUEUE_SHAPES = new Set([ @@ -149,6 +170,22 @@ const elementCssStyles = ( return styles; }; +/** + * Tag names assigned to an element or rel via $tags. C4-PlantUML separates + * multiple tags with `+` (e.g. "v1.0+deprecated"); commas are accepted too. + * The legacy db stores the value either as a plain string or wrapped in a + * `{ text }` object depending on which positional slot the kv landed in. + */ +const parseTagNames = (tags?: C4Tags): string[] => { + const text = typeof tags === 'string' ? tags : (tags?.text ?? ''); + return text + .split(/[+,]/) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); +}; + +const tagCssClass = (tagName: string): string => `c4-tag-${tagName.replace(/[^\w-]/g, '-')}`; + /** * Default fill/stroke for an element type from the c4 config color keys * (person_bg_color, external_system_border_color, ...), so the unified @@ -175,6 +212,19 @@ export const getData = (db: C4Db, config: MermaidConfig): LayoutData => { const boundaries = db.getBoundaries().filter((boundary) => boundary.alias !== 'global'); const boundaryAliases = new Set(boundaries.map((boundary) => boundary.alias)); + const elementTags = new Map(db.getElementTags().map((tag) => [tag.tagName, tag])); + const relTags = new Map(db.getRelTags().map((tag) => [tag.tagName, tag])); + + /** + * Styles from AddElementTag definitions; applied after the per-type config + * palette but before per-element UpdateElementStyle overrides. + */ + const elementTagStyles = (tagNames: string[]): string[] => + tagNames.flatMap((tagName) => { + const tag = elementTags.get(tagName); + return tag ? elementCssStyles(tag) : []; + }); + const parentIdOf = (parentBoundary: string): string | undefined => parentBoundary && parentBoundary !== 'global' && boundaryAliases.has(parentBoundary) ? parentBoundary @@ -182,14 +232,18 @@ export const getData = (db: C4Db, config: MermaidConfig): LayoutData => { for (const boundary of boundaries) { const isDeploymentNode = boundary.nodeType !== undefined; + const tagNames = parseTagNames(boundary.tags); nodes.push({ id: boundary.alias, label: buildBoundaryLabel(boundary), isGroup: true, shape: 'rect', parentId: parentIdOf(boundary.parentBoundary), - cssClasses: isDeploymentNode ? 'c4-boundary c4-deployment-node' : 'c4-boundary', - cssStyles: elementCssStyles(boundary), + cssClasses: [ + isDeploymentNode ? 'c4-boundary c4-deployment-node' : 'c4-boundary', + ...tagNames.map(tagCssClass), + ].join(' '), + cssStyles: [...elementTagStyles(tagNames), ...elementCssStyles(boundary)], link: boundary.link ?? undefined, look: config.look, }); @@ -197,14 +251,22 @@ export const getData = (db: C4Db, config: MermaidConfig): LayoutData => { for (const shape of db.getC4ShapeArray()) { const type = shape.typeC4Shape.text; + const tagNames = parseTagNames(shape.tags); nodes.push({ id: shape.alias, label: buildNodeLabel(shape), isGroup: false, shape: getNodeShape(type), parentId: parentIdOf(shape.parentBoundary), - cssClasses: `c4-shape c4-${type}${isExternal(type) ? ' c4-external' : ''}`, - cssStyles: [...configColorStyles(type, c4Config), ...elementCssStyles(shape)], + cssClasses: [ + `c4-shape c4-${type}${isExternal(type) ? ' c4-external' : ''}`, + ...tagNames.map(tagCssClass), + ].join(' '), + cssStyles: [ + ...configColorStyles(type, c4Config), + ...elementTagStyles(tagNames), + ...elementCssStyles(shape), + ], link: shape.link, look: config.look, }); @@ -215,6 +277,15 @@ export const getData = (db: C4Db, config: MermaidConfig): LayoutData => { const isBack = rel.type === 'rel_b'; const style: string[] = []; const labelStyle: string[] = []; + for (const tagName of parseTagNames(rel.tags)) { + const tag = relTags.get(tagName); + if (tag?.lineColor) { + style.push(`stroke:${tag.lineColor}`); + } + if (tag?.textColor) { + labelStyle.push(`color:${tag.textColor}`); + } + } if (rel.lineColor) { style.push(`stroke:${rel.lineColor}`); } diff --git a/packages/mermaid/src/diagrams/c4/c4Types.ts b/packages/mermaid/src/diagrams/c4/c4Types.ts index 8eb438bf382..b4354a8b7fb 100644 --- a/packages/mermaid/src/diagrams/c4/c4Types.ts +++ b/packages/mermaid/src/diagrams/c4/c4Types.ts @@ -110,6 +110,26 @@ export interface C4Rel { endPoint: C4Point; } +/** Style definition registered via `AddElementTag(tagName, ...)`. */ +export interface C4ElementTag { + /** The parser may set additional keys via `{ key: value }` shaped arguments. */ + [key: string]: unknown; + tagName: string; + bgColor?: string; + fontColor?: string; + borderColor?: string; + shape?: string; +} + +/** Style definition registered via `AddRelTag(tagName, ...)`. */ +export interface C4RelTag { + /** The parser may set additional keys via `{ key: value }` shaped arguments. */ + [key: string]: unknown; + tagName: string; + textColor?: string; + lineColor?: string; +} + /** * Font configuration as produced by the `*Font()` helpers of the C4 * configuration. The helpers always populate family/size/weight from the diff --git a/packages/mermaid/src/diagrams/c4/parser/c4AddTag.spec.ts b/packages/mermaid/src/diagrams/c4/parser/c4AddTag.spec.ts new file mode 100644 index 00000000000..1f1628f99e1 --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/parser/c4AddTag.spec.ts @@ -0,0 +1,92 @@ +import c4Db from '../c4Db.js'; +// @ts-ignore: JISON doesn't support types +import c4 from './c4Diagram.jison'; +import { setConfig } from '../../../config.js'; + +setConfig({ + securityLevel: 'strict', +}); + +describe('parsing C4 AddElementTag and AddRelTag', function () { + beforeEach(function () { + c4.parser.yy = c4Db; + c4.parser.yy.clear(); + }); + + it('should parse AddElementTag with positional arguments', function () { + c4.parser.parse(`C4Context +AddElementTag(v1.0, "#d73027", "#ffffff", "#a50026")`); + + expect(c4.parser.yy.getElementTags()).toEqual([ + { + tagName: 'v1.0', + bgColor: '#d73027', + fontColor: '#ffffff', + borderColor: '#a50026', + }, + ]); + }); + + it('should parse AddElementTag with named arguments', function () { + c4.parser.parse(`C4Context +AddElementTag(deprecated, $bgColor="grey", $borderColor="red", $shape="RoundedBoxShape")`); + + expect(c4.parser.yy.getElementTags()).toEqual([ + { + tagName: 'deprecated', + bgColor: 'grey', + borderColor: 'red', + shape: 'RoundedBoxShape', + }, + ]); + }); + + it('should merge repeated AddElementTag definitions for the same tag', function () { + c4.parser.parse(`C4Context +AddElementTag(v1.0, $bgColor="grey") +AddElementTag(v1.0, $fontColor="red")`); + + expect(c4.parser.yy.getElementTags()).toEqual([ + { + tagName: 'v1.0', + bgColor: 'grey', + fontColor: 'red', + }, + ]); + }); + + it('should parse AddRelTag with positional arguments', function () { + c4.parser.parse(`C4Context +AddRelTag(async, "blue", "green")`); + + expect(c4.parser.yy.getRelTags()).toEqual([ + { + tagName: 'async', + textColor: 'blue', + lineColor: 'green', + }, + ]); + }); + + it('should parse AddRelTag with named arguments', function () { + c4.parser.parse(`C4Context +AddRelTag(async, $lineColor="green")`); + + expect(c4.parser.yy.getRelTags()).toEqual([ + { + tagName: 'async', + lineColor: 'green', + }, + ]); + }); + + it('should reset defined tags on clear', function () { + c4.parser.parse(`C4Context +AddElementTag(v1.0, $bgColor="grey") +AddRelTag(async, $lineColor="green")`); + + c4.parser.yy.clear(); + expect(c4.parser.yy.getElementTags()).toEqual([]); + expect(c4.parser.yy.getRelTags()).toEqual([]); + }); +}); diff --git a/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison b/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison index f0ce80d33f3..2a27009e98c 100644 --- a/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison +++ b/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison @@ -156,6 +156,8 @@ accDescr\s*"{"\s* { this.begin("acc_descr_multiline");} "UpdateElementStyle" { this.begin("update_el_style"); return 'UPDATE_EL_STYLE';} "UpdateRelStyle" { this.begin("update_rel_style"); return 'UPDATE_REL_STYLE';} +"AddElementTag" { this.begin("update_el_style"); return 'ADD_ELEMENT_TAG';} +"AddRelTag" { this.begin("update_rel_style"); return 'ADD_REL_TAG';} "UpdateLayoutConfig" { this.begin("update_layout_config"); return 'UPDATE_LAYOUT_CONFIG';} <> return "EOF_IN_STRUCT"; @@ -306,6 +308,8 @@ diagramStatement | REL_INDEX attributes {$2.splice(0, 1); yy.addRel('rel', ...$2); $$=$2;} | UPDATE_EL_STYLE attributes {yy.updateElStyle('update_el_style', ...$2); $$=$2;} | UPDATE_REL_STYLE attributes {yy.updateRelStyle('update_rel_style', ...$2); $$=$2;} + | ADD_ELEMENT_TAG attributes {yy.addElementTag(...$2); $$=$2;} + | ADD_REL_TAG attributes {yy.addRelTag(...$2); $$=$2;} | UPDATE_LAYOUT_CONFIG attributes {yy.updateLayoutConfig('update_layout_config', ...$2); $$=$2;} ; diff --git a/packages/mermaid/src/docs/syntax/c4.md b/packages/mermaid/src/docs/syntax/c4.md index e1cca134e5b..2ab090306ff 100644 --- a/packages/mermaid/src/docs/syntax/c4.md +++ b/packages/mermaid/src/docs/syntax/c4.md @@ -177,6 +177,26 @@ render unchanged. `UpdateLayoutConfig`, `$offsetX` and `$offsetY` only affect the legacy renderer; element and relationship colors set via `UpdateElementStyle` and `UpdateRelStyle` are honored by both renderers. +### Tags (v+) + +`AddElementTag(tagName, ?bgColor, ?fontColor, ?borderColor)` and +`AddRelTag(tagName, ?textColor, ?lineColor)` define named styles, following +the C4-PlantUML macros of the same name. Elements and relationships opt in +with `$tags="name"`; combine multiple tags with `+`, e.g. +`$tags="v1.0+deprecated"`. Tag styles override the per-type defaults and are +themselves overridden by `UpdateElementStyle`/`UpdateRelStyle`. Each tag is +also added to the element's CSS classes as `c4-tag-` for custom +styling via `themeCSS` or `classDefs`. + +``` +C4Context +AddElementTag(deprecated, $bgColor="grey", $borderColor="red") +AddRelTag(async, $textColor="green", $lineColor="green") +Person(customerA, "Banking Customer A") +System(SystemAA, "Internet Banking System", $tags="deprecated") +Rel(customerA, SystemAA, "Uses", $tags="async") +``` + ## C4 System Context Diagram (C4Context) ```mermaid-example From b83e90849ac7c92009a082a9f71366058ba75817 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 12 Jun 2026 14:52:04 +0200 Subject: [PATCH 8/8] chore(c4): add changeset for AddElementTag and AddRelTag styling Co-Authored-By: Claude Fable 5 --- .changeset/c4-unified-tags.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/c4-unified-tags.md diff --git a/.changeset/c4-unified-tags.md b/.changeset/c4-unified-tags.md new file mode 100644 index 00000000000..59784808b56 --- /dev/null +++ b/.changeset/c4-unified-tags.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(c4): style elements and relationships via `AddElementTag` and `AddRelTag` in the C4 unified renderer. Tag styles (`$bgColor`, `$fontColor`, `$borderColor`, `$textColor`, `$lineColor`) are applied after per-type defaults and before `UpdateElementStyle`/`UpdateRelStyle` overrides, and every tag adds a sanitized `c4-tag-` css class for theming.