Skip to content

Commit 30325d4

Browse files
committed
fix(c4): stop wrapping non-text named attributes that land in a text slot
A named attribute lands in a positional slot when the argument before it is omitted, and the handlers for the text slots (`label`, `descr`, `techn`, `type`) wrapped whatever arrived in `{ text: value }` regardless of which field it was actually for. So System(s, "S", $tags="cylinder") stored `tags` as `{ text: 'cylinder' }`, while System(s, "S", "desc", $tags="cylinder") stored the string. Consumers read `$tags` as a string - `resolveNodeShape` calls `.split(',')` on it - so the first form threw `shape.tags.split is not a function` and the diagram failed to render. Only a text field belongs in a `{ text }` wrapper, so the eleven slot handlers now consult one set of field names and assign anything else raw, matching what `assignAttributes` already did for the attributes that arrive in their own slots. Found while building `AddElementTag` support: the existing e2e case passes only because its fixture supplies a description before `$tags`, so the broken form was never exercised. Regression tests cover both forms of `$tags`, a named `$descr` (which must still be wrapped), `$sprite`/`$link`, and a relationship `$tags`; three of the five fail without this change.
1 parent 79252c6 commit 30325d4

3 files changed

Lines changed: 83 additions & 11 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'mermaid': patch
3+
---
4+
5+
fix(c4): stop wrapping non-text named attributes that land in a text slot
6+
7+
A named attribute such as `$tags` or `$sprite` can arrive in the positional
8+
slot of a text field when the argument before it is omitted. It was then
9+
stored as `{ text: value }` rather than a string, so `$tags` given without a
10+
description crashed rendering.

‎packages/mermaid/src/diagrams/c4/c4Db.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import type { C4Boundary, C4Rel, C4Shape } from './c4Types.js';
1515
*/
1616
type ParserAttribute = string | Record<string, string>;
1717

18+
/**
19+
* The fields a C4 element, boundary or relationship stores as `{ text }`. Everything else
20+
* a named attribute can set - `$tags`, `$sprite`, `$link`, colours, `$shape` - is a plain
21+
* string.
22+
*/
23+
const TEXT_FIELDS = new Set(['label', 'descr', 'techn', 'type']);
24+
1825
/**
1926
* Apply optional C4 attributes to `bag` from the parser.
2027
*
@@ -114,7 +121,7 @@ export const addRel = function (
114121
} else {
115122
if (typeof techn === 'object') {
116123
const [key, value] = Object.entries(techn)[0];
117-
rel[key] = { text: value };
124+
rel[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
118125
} else {
119126
rel.techn = { text: techn };
120127
}
@@ -125,7 +132,7 @@ export const addRel = function (
125132
} else {
126133
if (typeof descr === 'object') {
127134
const [key, value] = Object.entries(descr)[0];
128-
rel[key] = { text: value };
135+
rel[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
129136
} else {
130137
rel.descr = { text: descr };
131138
}
@@ -171,7 +178,7 @@ export const addPersonOrSystem = function (
171178
} else {
172179
if (typeof descr === 'object') {
173180
const [key, value] = Object.entries(descr)[0];
174-
personOrSystem[key] = { text: value };
181+
personOrSystem[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
175182
} else {
176183
personOrSystem.descr = { text: descr };
177184
}
@@ -220,7 +227,7 @@ export const addContainer = function (
220227
} else {
221228
if (typeof techn === 'object') {
222229
const [key, value] = Object.entries(techn)[0];
223-
container[key] = { text: value };
230+
container[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
224231
} else {
225232
container.techn = { text: techn };
226233
}
@@ -231,7 +238,7 @@ export const addContainer = function (
231238
} else {
232239
if (typeof descr === 'object') {
233240
const [key, value] = Object.entries(descr)[0];
234-
container[key] = { text: value };
241+
container[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
235242
} else {
236243
container.descr = { text: descr };
237244
}
@@ -280,7 +287,7 @@ export const addComponent = function (
280287
} else {
281288
if (typeof techn === 'object') {
282289
const [key, value] = Object.entries(techn)[0];
283-
component[key] = { text: value };
290+
component[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
284291
} else {
285292
component.techn = { text: techn };
286293
}
@@ -291,7 +298,7 @@ export const addComponent = function (
291298
} else {
292299
if (typeof descr === 'object') {
293300
const [key, value] = Object.entries(descr)[0];
294-
component[key] = { text: value };
301+
component[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
295302
} else {
296303
component.descr = { text: descr };
297304
}
@@ -339,7 +346,7 @@ export const addPersonOrSystemBoundary = function (
339346
} else {
340347
if (typeof type === 'object') {
341348
const [key, value] = Object.entries(type)[0];
342-
boundary[key] = { text: value };
349+
boundary[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
343350
} else {
344351
boundary.type = { text: type };
345352
}
@@ -390,7 +397,7 @@ export const addContainerBoundary = function (
390397
} else {
391398
if (typeof type === 'object') {
392399
const [key, value] = Object.entries(type)[0];
393-
boundary[key] = { text: value };
400+
boundary[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
394401
} else {
395402
boundary.type = { text: type };
396403
}
@@ -444,7 +451,7 @@ export const addDeploymentNode = function (
444451
} else {
445452
if (typeof type === 'object') {
446453
const [key, value] = Object.entries(type)[0];
447-
boundary[key] = { text: value };
454+
boundary[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
448455
} else {
449456
boundary.type = { text: type };
450457
}
@@ -455,7 +462,7 @@ export const addDeploymentNode = function (
455462
} else {
456463
if (typeof descr === 'object') {
457464
const [key, value] = Object.entries(descr)[0];
458-
boundary[key] = { text: value };
465+
boundary[key] = TEXT_FIELDS.has(key) ? { text: value } : value;
459466
} else {
460467
boundary.descr = { text: descr };
461468
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import c4Db from '../c4Db.js';
2+
// @ts-ignore: JISON doesn't support types
3+
import c4 from './c4Diagram.jison';
4+
import { setConfig } from '../../../config.js';
5+
6+
setConfig({ securityLevel: 'strict' });
7+
8+
/**
9+
* A named attribute can land in a positional slot when the argument before it is omitted.
10+
* Only the slot's own field is text, so the value must not be wrapped in `{ text }` just
11+
* because it arrived through a text slot - consumers read `$tags` and `$sprite` as strings.
12+
*/
13+
describe('named attributes arriving in a positional text slot', function () {
14+
beforeEach(function () {
15+
c4.parser.yy = c4Db;
16+
c4.parser.yy.clear();
17+
});
18+
19+
it('keeps $tags a string when the description is omitted', function () {
20+
c4.parser.parse(`C4Context\nSystem(s, "S", $tags="cylinder")`);
21+
22+
expect(c4.parser.yy.getC4ShapeArray()[0].tags).toBe('cylinder');
23+
});
24+
25+
it('keeps $tags a string when the description is given', function () {
26+
c4.parser.parse(`C4Context\nSystem(s, "S", "desc", $tags="cylinder")`);
27+
28+
const [shape] = c4.parser.yy.getC4ShapeArray();
29+
expect(shape.tags).toBe('cylinder');
30+
expect(shape.descr.text).toBe('desc');
31+
});
32+
33+
it('still stores the description as text when it is the one named', function () {
34+
c4.parser.parse(`C4Context\nSystem(s, "S", $descr="a description")`);
35+
36+
expect(c4.parser.yy.getC4ShapeArray()[0].descr.text).toBe('a description');
37+
});
38+
39+
it('keeps $sprite and $link strings on a container', function () {
40+
c4.parser.parse(`C4Container\nContainer(c, "C", $sprite="browser", $link="https://x.test")`);
41+
42+
const [shape] = c4.parser.yy.getC4ShapeArray();
43+
expect(shape.sprite).toBe('browser');
44+
expect(shape.link).toBe('https://x.test');
45+
});
46+
47+
it('keeps a relationship $tags a string when techn and descr are omitted', function () {
48+
c4.parser.parse(`C4Context
49+
Person(a, "A")
50+
Person(b, "B")
51+
Rel(a, b, "uses", $tags="async")`);
52+
53+
expect(c4.parser.yy.getRels()[0].tags).toBe('async');
54+
});
55+
});

0 commit comments

Comments
 (0)