Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/c4-link-sanitization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mermaid': patch
---

fix(c4): sanitize a C4 `$link` before it becomes an `xlink:href`

A `$link` was stored and rendered exactly as written, so one carrying a
`javascript:` scheme reached the anchor. It now follows a flowchart
`click ... href`: it is sanitized, which also normalises the URL
(`$link="https://example.com"` renders as `https://example.com/`), unless
`securityLevel` is `loose`, where it is kept as written.
7 changes: 7 additions & 0 deletions .changeset/c4-unified-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'mermaid': minor
---

feat(c4): render C4 relationships through the unified edge rendering

C4 relationships are drawn by mermaid's shared edge renderer instead of bespoke SVG: a dashed line with an arrowhead (both ends for `BiRel`) and a label carrying the relationship name, its optional `[technology]` and its description. `UpdateRelStyle`'s `$offsetX`/`$offsetY` still nudge the label and `$textColor`/`$lineColor` still recolour it. Element geometry is unchanged - the line runs between the two shapes' boundary intersection points, so it stays in the gap between boxes.
5 changes: 5 additions & 0 deletions .changeset/c4-unified-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': minor
---

feat(c4): lay out C4 diagrams with the unified rendering pipeline
7 changes: 4 additions & 3 deletions e2e/diagrams/c4/nightly-check/link-attribute-not-rendered.mmd
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
---
description: >-
A Person carrying a $link="https://example.com" attribute — the current
renderer accepts it without erroring but does not render it as a
clickable anchor.
A Person carrying a $link="https://example.com" attribute. The unified
renderer wraps the node in an <a xlink:href> anchor (asserted in
e2e/rendering/c4/pr-check/c4-link.spec.js); the screenshot only records
how it looks.
---
C4Context
title Link attribute (not shown by current renderer)
Expand Down
37 changes: 37 additions & 0 deletions e2e/rendering/c4/nightly-check/c4-layout-config.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { diagramSvg, renderGraph } from '../../../helpers/util.ts';

test.describe('C4 UpdateLayoutConfig', () => {
// `UpdateLayoutConfig` is a no-op once the layout is dagre's: the pipeline never reads
// `$c4ShapeInRow`/`$c4BoundaryInRow`, so the same source lays out identically whatever
// they say. Asserted as an absence rather than left as two snapshots that happened to
// match, so that implementing the option - or restoring a grid - fails here loudly.
// The 2 and 4 shapes-per-row renders are fixtures under e2e/diagrams/c4/visual-check/.
test('does not change the layout (no longer honoured)', async ({ page }, testInfo) => {
const diagram = (shapesInRow) => `C4Context
title UpdateLayoutConfig ($c4ShapeInRow=${shapesInRow})
System(a, "A")
System(b, "B")
System(c, "C")
System(d, "D")
UpdateLayoutConfig($c4ShapeInRow="${shapesInRow}", $c4BoundaryInRow="1")
`;

const positions = () =>
diagramSvg(page)
.locator('.node')
.evaluateAll((nodes) =>
nodes.map((node) => {
const { x, y } = node.getBoundingClientRect();
return `${Math.round(x)},${Math.round(y)}`;
})
);

await renderGraph(page, testInfo, diagram(2), { screenshot: false });
const atTwo = await positions();
expect(atTwo.length).toBe(4);

await renderGraph(page, testInfo, diagram(4), { screenshot: false });
expect(await positions()).toEqual(atTwo);
});
});
44 changes: 44 additions & 0 deletions e2e/rendering/c4/pr-check/c4-link.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';
import { diagramSvg, renderGraph } from '../../../helpers/util.ts';

// The same sources are shown as fixtures under e2e/diagrams/c4/, so these tests render
// without a screenshot and only assert on the anchor a fixture cannot check.
test.describe('C4 $link', () => {
test('should render $link on an element as a hyperlink', async ({ page }, testInfo) => {
await renderGraph(
page,
testInfo,
`C4Context
title Link attribute
Person(p, "Person", "desc", $link="https://example.com")
System(s, "System", "desc")
Rel(p, s, "Uses")
`,
{ screenshot: false }
);

// The unified renderer wraps a linked node in an `svg:a`; it sets only
// `xlink:href`, and no `target` unless one was asked for. The URL is normalised
// by the same sanitizer a flowchart `click ... href` goes through.
const link = diagramSvg(page).locator('g.nodes a');
await expect(link).toHaveAttribute('xlink:href', 'https://example.com/');
await expect(link).not.toHaveAttribute('target');
});

test('should not carry a javascript: $link into the href', async ({ page }, testInfo) => {
await renderGraph(
page,
testInfo,
`C4Context
title Link attribute with an unsafe scheme
Person(p, "Person", "desc", $link="javascript:alert(1)")
System(s, "System", "desc")
Rel(p, s, "Uses")
`,
{ screenshot: false }
);

const link = diagramSvg(page).locator('g.nodes a');
await expect(link).not.toHaveAttribute('xlink:href', /^javascript:/);
});
});
40 changes: 35 additions & 5 deletions packages/mermaid/src/diagrams/c4/c4Db.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { getRequiredConfig } from '../../diagram-api/requiredConfig.js';
import { sanitizeText } from '../common/common.js';
import { formatUrl } from '../../utils.js';
import {
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
} from '../common/commonDb.js';
import type { C4Boundary, C4Rel, C4Shape } from './c4Types.js';
import type { LayoutData } from '../../rendering-util/types.js';
import { getData as buildLayoutData } from './c4LayoutData.js';

/**
* The parser may pass a plain string or an object with a single
Expand All @@ -27,6 +30,9 @@ const TEXT_FIELDS = new Set(['label', 'descr', 'techn', 'type']);
*
* Values may arrive as a raw positional value or a single `{ key: value }` named
* override; `undefined` is skipped so an earlier-set value is not clobbered.
*
* A `$link` becomes an `xlink:href` on the rendered element. As in flowchart and class
* diagrams it is sanitized unless `securityLevel` is `loose`, where it is kept as written.
*/
const assignAttributes = <Bag extends C4Shape | C4Boundary | C4Rel>(
bag: Bag,
Expand All @@ -36,16 +42,19 @@ const assignAttributes = <Bag extends C4Shape | C4Boundary | C4Rel>(
if (value === undefined) {
continue;
}
if (typeof value === 'object') {
const [key, val] = Object.entries(value)[0];
// A named override arrives as a single `{ key: value }`; a positional value keeps
// the field it was declared for.
const named = typeof value === 'object';
const [key, val] = named ? Object.entries(value)[0] : [field, value];
if (key === 'link') {
bag[key] = formatUrl(val, getConfig());
} else {
// Same rule as the positional text slots: a text field is stored as `{ text }`
// whichever slot it arrived through. `System_Boundary` and friends splice their
// kind in as a positional argument, so an explicit `$type` shifts along into this
// one and would otherwise land here as a bare string and overwrite the wrapped
// value the type slot just set.
bag[key] = TEXT_FIELDS.has(key) ? { text: val } : val;
} else {
bag[field] = value;
bag[key] = named && TEXT_FIELDS.has(key) ? { text: val } : val;
}
}
};
Expand All @@ -72,6 +81,7 @@ let wrapEnabled: boolean | undefined = false;
let c4ShapeInRow = 4;
let c4BoundaryInRow = 2;
let c4Type: string | undefined;
let direction = 'TB';

export const getC4Type = function () {
return c4Type;
Expand Down Expand Up @@ -731,6 +741,22 @@ export const autoWrap = function () {
return wrapEnabled;
};

export const setDirection = function (dir: string) {
direction = dir;
};

export const getDirection = function () {
return direction;
};

/** The parsed diagram as the unified rendering pipeline consumes it. */
export const getData = function (): LayoutData {
return buildLayoutData(
{ getC4ShapeArray, getBoundaries, getRels, getC4Type, getDirection },
getConfig()
);
};

export const clear = function () {
c4ShapeArray = [];
boundaries = [createGlobalBoundary()];
Expand All @@ -744,6 +770,7 @@ export const clear = function () {
wrapEnabled = false;
c4ShapeInRow = 4;
c4BoundaryInRow = 2;
direction = 'TB';
};

export const LINETYPE = {
Expand Down Expand Up @@ -825,5 +852,8 @@ export default {
PLACEMENT,
setTitle,
setC4Type,
setDirection,
getDirection,
getData,
// apply,
};
5 changes: 2 additions & 3 deletions packages/mermaid/src/diagrams/c4/c4Diagram.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-ignore: JISON doesn't support types
import parser from './parser/c4Diagram.jison';
import db from './c4Db.js';
import renderer from './c4Renderer.js';
import renderer from './c4Renderer-unified.js';
import styles from './styles.js';
import type { MermaidConfig } from '../../config.type.js';
import type { DiagramDefinition } from '../../diagram-api/types.js';
Expand All @@ -11,8 +11,7 @@ export const diagram: DiagramDefinition = {
db,
renderer,
styles,
init: ({ c4, wrap }: MermaidConfig) => {
renderer.setConf(c4);
init: ({ wrap }: MermaidConfig) => {
db.setWrap(wrap);
},
};
Loading
Loading