Skip to content

Commit 1d14f89

Browse files
Merge pull request #1811 from finos/main
Merge main to release-1.1
2 parents 7a10589 + 1903723 commit 1d14f89

File tree

21 files changed

+245
-55
lines changed

21 files changed

+245
-55
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@
2020
"[json]": {
2121
"editor.defaultFormatter": "vscode.json-language-features"
2222
},
23-
"java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx4G -Xms100m -Xlog:disable"
23+
"java.jdt.ls.vmargs": "-XX:+UseG1GC -XX:+UseStringDeduplication -Xmx2G -Xms256m"
2424
}

calm-plugins/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "calm-vscode-plugin",
33
"displayName": "CALM Preview & Tools",
44
"description": "Live-visualize CALM architecture models, validate, and generate docs.",
5-
"version": "0.0.8",
5+
"version": "0.0.9",
66
"publisher": "FINOS",
77
"homepage": "https://calm.finos.org",
88
"repository": {

calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,15 @@ export class DocifyTabView {
211211
* Expected input format: Mermaid typically generates IDs like "flowchart-conference-website-123".
212212
* This function removes the "flowchart-" prefix and the trailing numeric suffix.
213213
*
214+
* For nodes with reserved words, the ID may be prefixed with "node_" to avoid Mermaid conflicts.
215+
* This function removes that prefix to get the original CALM node ID.
216+
*
214217
* Example:
215218
* Input: "flowchart-conference-website-123"
216219
* Output: "conference-website"
220+
*
221+
* Input: "flowchart-node_end-user-456"
222+
* Output: "end-user"
217223
*
218224
* @param mermaidId The Mermaid-generated element ID string.
219225
* @returns The extracted node ID, or null if extraction fails.
@@ -226,9 +232,12 @@ export class DocifyTabView {
226232
// Match everything except the last segment if it's purely numeric
227233
const match = cleaned.match(/^(.+?)-\d+$/)
228234
if (match) {
229-
return match[1]
235+
cleaned = match[1]
230236
}
231237

238+
// Remove the node_ prefix if it was added to avoid Mermaid reserved words
239+
cleaned = cleaned.replace(/^node_/, '')
240+
232241
// If no numeric suffix, return the cleaned ID
233242
return cleaned || null
234243
}

calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export default class MermaidRenderer {
8181
// Finally, check for any remaining HTML-encoded Mermaid blocks
8282
const htmlMermaidRegex = /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g
8383
while ((match = htmlMermaidRegex.exec(html)) !== null) {
84-
const mermaidCode = match[1].trim()
84+
// Decode HTML entities before rendering
85+
const encodedCode = match[1].trim()
86+
const mermaidCode = this.decodeHtmlEntities(encodedCode)
8587
try {
8688
// Generate a unique ID for this diagram
8789
const diagramId = `mermaid-${Math.random().toString(36).substr(2, 9)}`
@@ -116,6 +118,15 @@ export default class MermaidRenderer {
116118
</div>`
117119
}
118120

121+
/**
122+
* Decode HTML entities from markdown-it output using DOMParser for security
123+
*/
124+
private decodeHtmlEntities(text: string): string {
125+
const parser = new DOMParser()
126+
const doc = parser.parseFromString(text, 'text/html')
127+
return doc.body.textContent || ''
128+
}
129+
119130
/**
120131
* Initialize pan/zoom on a diagram after it's been rendered to the DOM
121132
* This should be called from the view after the HTML is inserted

calm-widgets/src/widget-helpers.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,45 @@ describe('Widget Helpers', () => {
8989
});
9090
});
9191

92+
describe('mermaidId helper', () => {
93+
it('returns sanitized identifiers for Mermaid', () => {
94+
expect(helpers.mermaidId('my-node')).toBe('my-node');
95+
expect(helpers.mermaidId('service')).toBe('service');
96+
});
97+
98+
it('prefixes IDs that are exactly reserved words', () => {
99+
expect(helpers.mermaidId('end')).toBe('node_end');
100+
expect(helpers.mermaidId('graph')).toBe('node_graph');
101+
expect(helpers.mermaidId('subgraph')).toBe('node_subgraph');
102+
expect(helpers.mermaidId('END')).toBe('node_END');
103+
});
104+
105+
it('prefixes IDs that contain reserved words at word boundaries', () => {
106+
expect(helpers.mermaidId('end-user')).toBe('node_end-user');
107+
expect(helpers.mermaidId('my-end-service')).toBe('node_my-end-service');
108+
expect(helpers.mermaidId('graph-node')).toBe('node_graph-node');
109+
expect(helpers.mermaidId('node-end')).toBe('node_node-end');
110+
});
111+
112+
it('does not prefix IDs where reserved word is part of a larger word', () => {
113+
expect(helpers.mermaidId('endpoint')).toBe('endpoint');
114+
expect(helpers.mermaidId('backend')).toBe('backend');
115+
expect(helpers.mermaidId('graphql')).toBe('graphql');
116+
});
117+
118+
it('sanitizes special characters', () => {
119+
expect(helpers.mermaidId('my/node')).toBe('my_node');
120+
expect(helpers.mermaidId('node with spaces')).toBe('node_with_spaces');
121+
});
122+
123+
it('handles non-string input', () => {
124+
expect(helpers.mermaidId(null)).toBe('node_empty');
125+
expect(helpers.mermaidId(undefined)).toBe('node_empty');
126+
expect(helpers.mermaidId(123)).toBe('node_empty');
127+
expect(helpers.mermaidId('')).toBe('node_empty');
128+
});
129+
});
130+
92131
describe('instanceOf helper', () => {
93132
it('returns true for matching constructor names', () => {
94133
const obj = new Date();

calm-widgets/src/widget-helpers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ export function registerGlobalTemplateHelpers(): Record<string, (...args: unknow
1111
return undefined;
1212
},
1313
json: (obj: unknown): string => JSON.stringify(obj, null, 2),
14+
mermaidId: (id: unknown): string => {
15+
if (typeof id !== 'string' || !id) return 'node_empty';
16+
17+
// Sanitize: replace non-word chars (except hyphen, colon, dot) with underscore
18+
const sanitized = id.replace(/[^\w\-:.]/g, '_');
19+
20+
// Mermaid reserved words that need prefixing
21+
const reservedWords = ['graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef',
22+
'style', 'linkStyle', 'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'];
23+
24+
// Check if any reserved word appears as a complete word in the ID
25+
// Word boundaries are: start of string, end of string, or delimiters (-, _, ., :)
26+
for (const reserved of reservedWords) {
27+
// Create regex to match the reserved word at word boundaries
28+
// \b doesn't work well with hyphens, so we explicitly check boundaries
29+
const pattern = new RegExp(
30+
`(^|[-_.:])${reserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
31+
'i'
32+
);
33+
34+
if (pattern.test(sanitized)) {
35+
return `node_${sanitized}`;
36+
}
37+
}
38+
39+
return sanitized;
40+
},
1441
instanceOf: (value: unknown, className: unknown): boolean =>
1542
typeof className === 'string' &&
1643
typeof value === 'object' &&

calm-widgets/src/widgets/block-architecture/block-architecture.hbs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,26 @@ classDef system fill:#fff8e1,stroke:#f9a825,stroke-width:2px,color:#000;
2424
{{#if ../renderNodeTypeShapes}}
2525
{{> typed-node.hbs}}
2626
{{else}}
27-
{{id}}["{{label}}"]:::node
27+
{{{mermaidId id}}}["{{label}}"]:::node
2828
{{/if}}
2929
{{#each interfaces}}
30-
{{id}}["{{label}}"]:::iface
30+
{{{mermaidId id}}}["{{label}}"]:::iface
3131
{{/each}}
3232
{{/each}}
3333

3434
{{!-- Edges --}}
3535
{{#each edges}}
36-
{{source}} -->{{#if label}}|{{label}}|{{/if}} {{target}}
36+
{{{mermaidId source}}} -->{{#if label}}|{{label}}|{{/if}} {{{mermaidId target}}}
3737
{{/each}}
3838

3939
{{!-- Dotted attachments (node -> interface) --}}
4040
{{#each attachments}}
41-
{{from}} -.- {{to}}
41+
{{{mermaidId from}}} -.- {{{mermaidId to}}}
4242
{{/each}}
4343

4444
{{!-- Highlights --}}
4545
{{#each highlightNodeIds}}
46-
class {{this}} highlight
46+
class {{{mermaidId this}}} highlight
4747
{{/each}}
4848
{{!-- Clickable links for containers + loose nodes --}}
4949
{{#if linkPrefix}}

calm-widgets/src/widgets/block-architecture/click-links.hbs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@
22
{{#if id}}
33
{{!-- Generate click for the node itself --}}
44
{{#if (lookup linkMap id)}}
5-
click {{id}} "{{lookup linkMap id}}" "Jump to {{label}}"
5+
click {{{mermaidId id}}} "{{lookup linkMap id}}" "Jump to {{label}}"
66
{{else if linkPrefix}}
7-
click {{id}} "{{linkPrefix}}{{id}}" "Jump to {{label}}"
7+
click {{{mermaidId id}}} "{{linkPrefix}}{{id}}" "Jump to {{label}}"
88
{{/if}}
99
{{!-- Generate clicks for interfaces --}}
1010
{{#each interfaces}}
1111
{{#if (lookup ../linkMap id)}}
12-
click {{id}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
12+
click {{{mermaidId id}}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
1313
{{else if ../linkPrefix}}
14-
click {{id}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
14+
click {{{mermaidId id}}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
1515
{{/if}}
1616
{{/each}}
1717
{{/if}}
1818

1919
{{!-- Handle container with nodes array --}}
2020
{{#each nodes}}
2121
{{#if (lookup ../linkMap id)}}
22-
click {{id}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
22+
click {{{mermaidId id}}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
2323
{{else if ../linkPrefix}}
24-
click {{id}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
24+
click {{{mermaidId id}}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
2525
{{/if}}
2626
{{#each interfaces}}
2727
{{#if (lookup ../../linkMap id)}}
28-
click {{id}} "{{lookup ../../linkMap id}}" "Jump to {{label}}"
28+
click {{{mermaidId id}}} "{{lookup ../../linkMap id}}" "Jump to {{label}}"
2929
{{else if ../../linkPrefix}}
30-
click {{id}} "{{../../linkPrefix}}{{id}}" "Jump to {{label}}"
30+
click {{{mermaidId id}}} "{{../../linkPrefix}}{{id}}" "Jump to {{label}}"
3131
{{/if}}
3232
{{/each}}
3333
{{/each}}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
{{!-- Only emit a subgraph if it has content --}}
22
{{#if (or (gt (length nodes) 0) (gt (length containers) 0))}}
3-
subgraph {{id}}["{{label}}"]
3+
subgraph {{{mermaidId id}}}["{{label}}"]
44
direction TB
55
{{#each nodes}}
66
{{#if @root.renderNodeTypeShapes}}
77
{{> typed-node.hbs}}
88
{{else}}
9-
{{id}}["{{label}}"]:::node
9+
{{{mermaidId id}}}["{{label}}"]:::node
1010
{{/if}}
1111
{{#each interfaces}}
12-
{{id}}["{{label}}"]:::iface
12+
{{{mermaidId id}}}["{{label}}"]:::iface
1313
{{/each}}
1414
{{/each}}
1515
{{#each containers}}
1616
{{> container.hbs}}
1717
{{/each}}
1818
end
19-
class {{id}} boundary
19+
class {{{mermaidId id}}} boundary
2020
{{/if}}

calm-widgets/src/widgets/block-architecture/core/utils.spec.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { prettyLabel, labelFor, sanitizeId, ifaceId, pickIface } from './utils';
2+
import { prettyLabel, labelFor, sanitizeId, ifaceId, pickIface, mermaidId } from './utils';
33
import {
44
CalmNodeCanonicalModel,
55
CalmNodeInterfaceCanonicalModel,
@@ -91,4 +91,42 @@ describe('utils', () => {
9191
expect(pickIface(ni)).toBeUndefined();
9292
});
9393
});
94+
95+
describe('mermaidId', () => {
96+
it('returns sanitized identifiers for Mermaid', () => {
97+
expect(mermaidId('my-node')).toBe('my-node');
98+
expect(mermaidId('service')).toBe('service');
99+
});
100+
101+
it('prefixes IDs that are exactly reserved words', () => {
102+
expect(mermaidId('end')).toBe('node_end');
103+
expect(mermaidId('graph')).toBe('node_graph');
104+
expect(mermaidId('subgraph')).toBe('node_subgraph');
105+
expect(mermaidId('END')).toBe('node_END'); // Case-insensitive check
106+
});
107+
108+
it('prefixes IDs that contain reserved words at word boundaries', () => {
109+
expect(mermaidId('end-user')).toBe('node_end-user');
110+
expect(mermaidId('my-end-service')).toBe('node_my-end-service');
111+
expect(mermaidId('graph-node')).toBe('node_graph-node');
112+
expect(mermaidId('node-end')).toBe('node_node-end');
113+
});
114+
115+
it('does not prefix IDs where reserved word is part of a larger word', () => {
116+
expect(mermaidId('endpoint')).toBe('endpoint');
117+
expect(mermaidId('backend')).toBe('backend');
118+
expect(mermaidId('graphql')).toBe('graphql');
119+
});
120+
121+
it('sanitizes special characters', () => {
122+
expect(mermaidId('node/with/slashes')).toBe('node_with_slashes');
123+
expect(mermaidId('node"with"quotes')).toBe('node_with_quotes');
124+
});
125+
126+
it('handles special cases', () => {
127+
expect(mermaidId('my service!')).toBe('my_service_');
128+
expect(mermaidId('node:with:colon')).toBe('node:with:colon');
129+
expect(mermaidId('')).toBe('node_empty');
130+
});
131+
});
94132
});

0 commit comments

Comments
 (0)