diff --git a/.vscode/settings.json b/.vscode/settings.json index 8892119f7..bf80de01a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,5 @@ "[json]": { "editor.defaultFormatter": "vscode.json-language-features" }, - "java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx4G -Xms100m -Xlog:disable" + "java.jdt.ls.vmargs": "-XX:+UseG1GC -XX:+UseStringDeduplication -Xmx2G -Xms256m" } diff --git a/calm-plugins/vscode/package.json b/calm-plugins/vscode/package.json index ce1e9d4ea..5f6a1db74 100644 --- a/calm-plugins/vscode/package.json +++ b/calm-plugins/vscode/package.json @@ -2,7 +2,7 @@ "name": "calm-vscode-plugin", "displayName": "CALM Preview & Tools", "description": "Live-visualize CALM architecture models, validate, and generate docs.", - "version": "0.0.8", + "version": "0.0.9", "publisher": "FINOS", "homepage": "https://calm.finos.org", "repository": { diff --git a/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts b/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts index 9a2099332..353be89e4 100644 --- a/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts +++ b/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts @@ -211,9 +211,15 @@ export class DocifyTabView { * Expected input format: Mermaid typically generates IDs like "flowchart-conference-website-123". * This function removes the "flowchart-" prefix and the trailing numeric suffix. * + * For nodes with reserved words, the ID may be prefixed with "node_" to avoid Mermaid conflicts. + * This function removes that prefix to get the original CALM node ID. + * * Example: * Input: "flowchart-conference-website-123" * Output: "conference-website" + * + * Input: "flowchart-node_end-user-456" + * Output: "end-user" * * @param mermaidId The Mermaid-generated element ID string. * @returns The extracted node ID, or null if extraction fails. @@ -226,9 +232,12 @@ export class DocifyTabView { // Match everything except the last segment if it's purely numeric const match = cleaned.match(/^(.+?)-\d+$/) if (match) { - return match[1] + cleaned = match[1] } + // Remove the node_ prefix if it was added to avoid Mermaid reserved words + cleaned = cleaned.replace(/^node_/, '') + // If no numeric suffix, return the cleaned ID return cleaned || null } diff --git a/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts b/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts index 75416d74e..e69b817f3 100644 --- a/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts +++ b/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts @@ -81,7 +81,9 @@ export default class MermaidRenderer { // Finally, check for any remaining HTML-encoded Mermaid blocks const htmlMermaidRegex = /
([\s\S]*?)<\/code><\/pre>/g
while ((match = htmlMermaidRegex.exec(html)) !== null) {
- const mermaidCode = match[1].trim()
+ // Decode HTML entities before rendering
+ const encodedCode = match[1].trim()
+ const mermaidCode = this.decodeHtmlEntities(encodedCode)
try {
// Generate a unique ID for this diagram
const diagramId = `mermaid-${Math.random().toString(36).substr(2, 9)}`
@@ -116,6 +118,15 @@ export default class MermaidRenderer {
`
}
+ /**
+ * Decode HTML entities from markdown-it output using DOMParser for security
+ */
+ private decodeHtmlEntities(text: string): string {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(text, 'text/html')
+ return doc.body.textContent || ''
+ }
+
/**
* Initialize pan/zoom on a diagram after it's been rendered to the DOM
* This should be called from the view after the HTML is inserted
diff --git a/calm-widgets/src/widget-helpers.spec.ts b/calm-widgets/src/widget-helpers.spec.ts
index 04fd57b65..6365e472d 100644
--- a/calm-widgets/src/widget-helpers.spec.ts
+++ b/calm-widgets/src/widget-helpers.spec.ts
@@ -89,6 +89,45 @@ describe('Widget Helpers', () => {
});
});
+ describe('mermaidId helper', () => {
+ it('returns sanitized identifiers for Mermaid', () => {
+ expect(helpers.mermaidId('my-node')).toBe('my-node');
+ expect(helpers.mermaidId('service')).toBe('service');
+ });
+
+ it('prefixes IDs that are exactly reserved words', () => {
+ expect(helpers.mermaidId('end')).toBe('node_end');
+ expect(helpers.mermaidId('graph')).toBe('node_graph');
+ expect(helpers.mermaidId('subgraph')).toBe('node_subgraph');
+ expect(helpers.mermaidId('END')).toBe('node_END');
+ });
+
+ it('prefixes IDs that contain reserved words at word boundaries', () => {
+ expect(helpers.mermaidId('end-user')).toBe('node_end-user');
+ expect(helpers.mermaidId('my-end-service')).toBe('node_my-end-service');
+ expect(helpers.mermaidId('graph-node')).toBe('node_graph-node');
+ expect(helpers.mermaidId('node-end')).toBe('node_node-end');
+ });
+
+ it('does not prefix IDs where reserved word is part of a larger word', () => {
+ expect(helpers.mermaidId('endpoint')).toBe('endpoint');
+ expect(helpers.mermaidId('backend')).toBe('backend');
+ expect(helpers.mermaidId('graphql')).toBe('graphql');
+ });
+
+ it('sanitizes special characters', () => {
+ expect(helpers.mermaidId('my/node')).toBe('my_node');
+ expect(helpers.mermaidId('node with spaces')).toBe('node_with_spaces');
+ });
+
+ it('handles non-string input', () => {
+ expect(helpers.mermaidId(null)).toBe('node_empty');
+ expect(helpers.mermaidId(undefined)).toBe('node_empty');
+ expect(helpers.mermaidId(123)).toBe('node_empty');
+ expect(helpers.mermaidId('')).toBe('node_empty');
+ });
+ });
+
describe('instanceOf helper', () => {
it('returns true for matching constructor names', () => {
const obj = new Date();
diff --git a/calm-widgets/src/widget-helpers.ts b/calm-widgets/src/widget-helpers.ts
index 316300c53..ea820063b 100644
--- a/calm-widgets/src/widget-helpers.ts
+++ b/calm-widgets/src/widget-helpers.ts
@@ -11,6 +11,33 @@ export function registerGlobalTemplateHelpers(): Record JSON.stringify(obj, null, 2),
+ mermaidId: (id: unknown): string => {
+ if (typeof id !== 'string' || !id) return 'node_empty';
+
+ // Sanitize: replace non-word chars (except hyphen, colon, dot) with underscore
+ const sanitized = id.replace(/[^\w\-:.]/g, '_');
+
+ // Mermaid reserved words that need prefixing
+ const reservedWords = ['graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef',
+ 'style', 'linkStyle', 'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'];
+
+ // Check if any reserved word appears as a complete word in the ID
+ // Word boundaries are: start of string, end of string, or delimiters (-, _, ., :)
+ for (const reserved of reservedWords) {
+ // Create regex to match the reserved word at word boundaries
+ // \b doesn't work well with hyphens, so we explicitly check boundaries
+ const pattern = new RegExp(
+ `(^|[-_.:])${reserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
+ 'i'
+ );
+
+ if (pattern.test(sanitized)) {
+ return `node_${sanitized}`;
+ }
+ }
+
+ return sanitized;
+ },
instanceOf: (value: unknown, className: unknown): boolean =>
typeof className === 'string' &&
typeof value === 'object' &&
diff --git a/calm-widgets/src/widgets/block-architecture/block-architecture.hbs b/calm-widgets/src/widgets/block-architecture/block-architecture.hbs
index 39abd880b..92b00ac73 100644
--- a/calm-widgets/src/widgets/block-architecture/block-architecture.hbs
+++ b/calm-widgets/src/widgets/block-architecture/block-architecture.hbs
@@ -24,26 +24,26 @@ classDef system fill:#fff8e1,stroke:#f9a825,stroke-width:2px,color:#000;
{{#if ../renderNodeTypeShapes}}
{{> typed-node.hbs}}
{{else}}
- {{id}}["{{label}}"]:::node
+ {{{mermaidId id}}}["{{label}}"]:::node
{{/if}}
{{#each interfaces}}
- {{id}}["{{label}}"]:::iface
+ {{{mermaidId id}}}["{{label}}"]:::iface
{{/each}}
{{/each}}
{{!-- Edges --}}
{{#each edges}}
- {{source}} -->{{#if label}}|{{label}}|{{/if}} {{target}}
+ {{{mermaidId source}}} -->{{#if label}}|{{label}}|{{/if}} {{{mermaidId target}}}
{{/each}}
{{!-- Dotted attachments (node -> interface) --}}
{{#each attachments}}
- {{from}} -.- {{to}}
+ {{{mermaidId from}}} -.- {{{mermaidId to}}}
{{/each}}
{{!-- Highlights --}}
{{#each highlightNodeIds}}
- class {{this}} highlight
+ class {{{mermaidId this}}} highlight
{{/each}}
{{!-- Clickable links for containers + loose nodes --}}
{{#if linkPrefix}}
diff --git a/calm-widgets/src/widgets/block-architecture/click-links.hbs b/calm-widgets/src/widgets/block-architecture/click-links.hbs
index f72d314a4..465f8d78c 100644
--- a/calm-widgets/src/widgets/block-architecture/click-links.hbs
+++ b/calm-widgets/src/widgets/block-architecture/click-links.hbs
@@ -2,16 +2,16 @@
{{#if id}}
{{!-- Generate click for the node itself --}}
{{#if (lookup linkMap id)}}
-click {{id}} "{{lookup linkMap id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{lookup linkMap id}}" "Jump to {{label}}"
{{else if linkPrefix}}
-click {{id}} "{{linkPrefix}}{{id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{linkPrefix}}{{id}}" "Jump to {{label}}"
{{/if}}
{{!-- Generate clicks for interfaces --}}
{{#each interfaces}}
{{#if (lookup ../linkMap id)}}
-click {{id}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
{{else if ../linkPrefix}}
-click {{id}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
{{/if}}
{{/each}}
{{/if}}
@@ -19,15 +19,15 @@ click {{id}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
{{!-- Handle container with nodes array --}}
{{#each nodes}}
{{#if (lookup ../linkMap id)}}
-click {{id}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{lookup ../linkMap id}}" "Jump to {{label}}"
{{else if ../linkPrefix}}
-click {{id}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{../linkPrefix}}{{id}}" "Jump to {{label}}"
{{/if}}
{{#each interfaces}}
{{#if (lookup ../../linkMap id)}}
-click {{id}} "{{lookup ../../linkMap id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{lookup ../../linkMap id}}" "Jump to {{label}}"
{{else if ../../linkPrefix}}
-click {{id}} "{{../../linkPrefix}}{{id}}" "Jump to {{label}}"
+click {{{mermaidId id}}} "{{../../linkPrefix}}{{id}}" "Jump to {{label}}"
{{/if}}
{{/each}}
{{/each}}
diff --git a/calm-widgets/src/widgets/block-architecture/container.hbs b/calm-widgets/src/widgets/block-architecture/container.hbs
index abdd8acff..eb0d329df 100644
--- a/calm-widgets/src/widgets/block-architecture/container.hbs
+++ b/calm-widgets/src/widgets/block-architecture/container.hbs
@@ -1,20 +1,20 @@
{{!-- Only emit a subgraph if it has content --}}
{{#if (or (gt (length nodes) 0) (gt (length containers) 0))}}
- subgraph {{id}}["{{label}}"]
+ subgraph {{{mermaidId id}}}["{{label}}"]
direction TB
{{#each nodes}}
{{#if @root.renderNodeTypeShapes}}
{{> typed-node.hbs}}
{{else}}
- {{id}}["{{label}}"]:::node
+ {{{mermaidId id}}}["{{label}}"]:::node
{{/if}}
{{#each interfaces}}
- {{id}}["{{label}}"]:::iface
+ {{{mermaidId id}}}["{{label}}"]:::iface
{{/each}}
{{/each}}
{{#each containers}}
{{> container.hbs}}
{{/each}}
end
- class {{id}} boundary
+ class {{{mermaidId id}}} boundary
{{/if}}
\ No newline at end of file
diff --git a/calm-widgets/src/widgets/block-architecture/core/utils.spec.ts b/calm-widgets/src/widgets/block-architecture/core/utils.spec.ts
index 7d5f7c995..13f578f0c 100644
--- a/calm-widgets/src/widgets/block-architecture/core/utils.spec.ts
+++ b/calm-widgets/src/widgets/block-architecture/core/utils.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { prettyLabel, labelFor, sanitizeId, ifaceId, pickIface } from './utils';
+import { prettyLabel, labelFor, sanitizeId, ifaceId, pickIface, mermaidId } from './utils';
import {
CalmNodeCanonicalModel,
CalmNodeInterfaceCanonicalModel,
@@ -91,4 +91,42 @@ describe('utils', () => {
expect(pickIface(ni)).toBeUndefined();
});
});
+
+ describe('mermaidId', () => {
+ it('returns sanitized identifiers for Mermaid', () => {
+ expect(mermaidId('my-node')).toBe('my-node');
+ expect(mermaidId('service')).toBe('service');
+ });
+
+ it('prefixes IDs that are exactly reserved words', () => {
+ expect(mermaidId('end')).toBe('node_end');
+ expect(mermaidId('graph')).toBe('node_graph');
+ expect(mermaidId('subgraph')).toBe('node_subgraph');
+ expect(mermaidId('END')).toBe('node_END'); // Case-insensitive check
+ });
+
+ it('prefixes IDs that contain reserved words at word boundaries', () => {
+ expect(mermaidId('end-user')).toBe('node_end-user');
+ expect(mermaidId('my-end-service')).toBe('node_my-end-service');
+ expect(mermaidId('graph-node')).toBe('node_graph-node');
+ expect(mermaidId('node-end')).toBe('node_node-end');
+ });
+
+ it('does not prefix IDs where reserved word is part of a larger word', () => {
+ expect(mermaidId('endpoint')).toBe('endpoint');
+ expect(mermaidId('backend')).toBe('backend');
+ expect(mermaidId('graphql')).toBe('graphql');
+ });
+
+ it('sanitizes special characters', () => {
+ expect(mermaidId('node/with/slashes')).toBe('node_with_slashes');
+ expect(mermaidId('node"with"quotes')).toBe('node_with_quotes');
+ });
+
+ it('handles special cases', () => {
+ expect(mermaidId('my service!')).toBe('my_service_');
+ expect(mermaidId('node:with:colon')).toBe('node:with:colon');
+ expect(mermaidId('')).toBe('node_empty');
+ });
+ });
});
diff --git a/calm-widgets/src/widgets/block-architecture/core/utils.ts b/calm-widgets/src/widgets/block-architecture/core/utils.ts
index 7f9ea80c0..1c83dfdc9 100644
--- a/calm-widgets/src/widgets/block-architecture/core/utils.ts
+++ b/calm-widgets/src/widgets/block-architecture/core/utils.ts
@@ -17,6 +17,40 @@ export const prettyLabel = (id: string) =>
*/
export const sanitizeId = (s: string) => s.replace(new RegExp(String.raw`[^\w\-:.]`, 'g'), '_');
+/**
+ * Sanitize an identifier for safe use in Mermaid diagrams.
+ * Replaces special characters and prefixes reserved words to avoid conflicts.
+ * Checks if any reserved word appears as a word boundary within the ID.
+ *
+ * Note: Implementation is in widget-helpers.ts as it's also used as a Handlebars helper.
+ * This re-export provides a TypeScript-friendly interface for programmatic use.
+ */
+export const mermaidId = (s: string): string => {
+ if (!s) return 'node_empty';
+
+ // Sanitize: replace non-word chars (except hyphen, colon, dot) with underscore
+ const sanitized = sanitizeId(s);
+
+ // Mermaid reserved words that need prefixing
+ const reservedWords = ['graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef',
+ 'style', 'linkStyle', 'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'];
+
+ // Check if any reserved word appears as a complete word in the ID
+ for (const reserved of reservedWords) {
+ // Create regex to match the reserved word at word boundaries
+ const pattern = new RegExp(
+ `(^|[-_.:])${reserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
+ 'i'
+ );
+
+ if (pattern.test(sanitized)) {
+ return `node_${sanitized}`;
+ }
+ }
+
+ return sanitized;
+};
+
/**
* Build a unique id for a node-interface pair. Combines the node id and interface key using a stable separator and sanitizes
* the interface key portion so the resulting id is safe for use in DOM/keys.
diff --git a/calm-widgets/src/widgets/block-architecture/typed-node.hbs b/calm-widgets/src/widgets/block-architecture/typed-node.hbs
index e88a55c4a..501e62c94 100644
--- a/calm-widgets/src/widgets/block-architecture/typed-node.hbs
+++ b/calm-widgets/src/widgets/block-architecture/typed-node.hbs
@@ -3,39 +3,39 @@
{{#with (lookup @root.nodeTypeMap nodeType)}}
{{!-- Use the mapped type for rendering --}}
{{#if (eq this 'actor')}}
-{{../id}}([👤 {{../label}}]):::actor
+{{{mermaidId ../id}}}([👤 {{../label}}]):::actor
{{else if (eq this 'database')}}
-{{../id}}[(🗄️ {{../label}})]:::database
+{{{mermaidId ../id}}}[(🗄️ {{../label}})]:::database
{{else if (eq this 'webclient')}}
-{{../id}}[[💻 {{../label}}]]:::webclient
+{{{mermaidId ../id}}}[[💻 {{../label}}]]:::webclient
{{else if (eq this 'messagebus')}}
-{{../id}}@{shape: h-cyl, label: "📨 {{../label}}"}
-class {{../id}} messagebus
+{{{mermaidId ../id}}}@{shape: h-cyl, label: "📨 {{../label}}"}
+class {{{mermaidId ../id}}} messagebus
{{else if (eq this 'service')}}
-{{../id}}[/"⚙️ {{../label}}"/]:::service
+{{{mermaidId ../id}}}[/"⚙️ {{../label}}"/]:::service
{{else if (eq this 'system')}}
-{{../id}}[🏢 {{../label}}]:::system
+{{{mermaidId ../id}}}[🏢 {{../label}}]:::system
{{else}}
{{!-- Unknown mapped type, use default --}}
-{{../id}}["{{../label}}"]:::node
+{{{mermaidId ../id}}}["{{../label}}"]:::node
{{/if}}
{{else}}
{{!-- No mapping, use direct node type or default --}}
{{#if (eq nodeType 'actor')}}
-{{id}}([👤 {{label}}]):::actor
+{{{mermaidId id}}}([👤 {{label}}]):::actor
{{else if (eq nodeType 'database')}}
-{{id}}[(🗄️ {{label}})]:::database
+{{{mermaidId id}}}[(🗄️ {{label}})]:::database
{{else if (eq nodeType 'webclient')}}
-{{id}}[[💻 {{label}}]]:::webclient
+{{{mermaidId id}}}[[💻 {{label}}]]:::webclient
{{else if (eq nodeType 'messagebus')}}
-{{id}}@{shape: h-cyl, label: "📨 {{label}}"}
-class {{id}} messagebus
+{{{mermaidId id}}}@{shape: h-cyl, label: "📨 {{label}}"}
+class {{{mermaidId id}}} messagebus
{{else if (eq nodeType 'service')}}
-{{id}}[/"⚙️ {{label}}"/]:::service
+{{{mermaidId id}}}[/"⚙️ {{label}}"/]:::service
{{else if (eq nodeType 'system')}}
-{{id}}[🏢 {{label}}]:::system
+{{{mermaidId id}}}[🏢 {{label}}]:::system
{{else}}
{{!-- Default rectangle for unknown types --}}
-{{id}}["{{label}}"]:::node
+{{{mermaidId id}}}["{{label}}"]:::node
{{/if}}
{{/with}}
\ No newline at end of file
diff --git a/calm-widgets/src/widgets/related-nodes/composed-of-relationship.hbs b/calm-widgets/src/widgets/related-nodes/composed-of-relationship.hbs
index 7f377f417..663c75ce5 100644
--- a/calm-widgets/src/widgets/related-nodes/composed-of-relationship.hbs
+++ b/calm-widgets/src/widgets/related-nodes/composed-of-relationship.hbs
@@ -2,18 +2,18 @@
{{!-- Path 1: Show what's composed OF this container (when viewing from container perspective) --}}
{{#if (eq container ../id)}}
{{#each nodes}}
-{{../container}} -- Composed Of --> {{this}};
+{{{mermaidId ../container}}} -- Composed Of --> {{{mermaidId this}}};
{{/each}}
{{/if}}
{{!-- Path 2: Show what container this node is composed OF (when viewing from node perspective) --}}
{{#each nodes}}
{{#if (eq this ../../id)}}
-{{../container}} -- Composed Of --> {{this}};
+{{{mermaidId ../container}}} -- Composed Of --> {{{mermaidId this}}};
{{/if}}
{{/each}}
{{else}}
{{!-- Path 3: Show all composition relationships (no specific focus) --}}
{{#each nodes}}
-{{../container}} -- Composed Of --> {{this}};
+{{{mermaidId ../container}}} -- Composed Of --> {{{mermaidId this}}};
{{/each}}
{{/if}}
diff --git a/calm-widgets/src/widgets/related-nodes/connects-relationship.hbs b/calm-widgets/src/widgets/related-nodes/connects-relationship.hbs
index 5135593d1..f194adaa8 100644
--- a/calm-widgets/src/widgets/related-nodes/connects-relationship.hbs
+++ b/calm-widgets/src/widgets/related-nodes/connects-relationship.hbs
@@ -1 +1 @@
-{{source.node}} -- Connects --> {{destination.node}};
+{{{mermaidId source.node}}} -- Connects --> {{{mermaidId destination.node}}};
diff --git a/calm-widgets/src/widgets/related-nodes/deployed-in-relationship.hbs b/calm-widgets/src/widgets/related-nodes/deployed-in-relationship.hbs
index 0cadd4f3d..2b3a38515 100644
--- a/calm-widgets/src/widgets/related-nodes/deployed-in-relationship.hbs
+++ b/calm-widgets/src/widgets/related-nodes/deployed-in-relationship.hbs
@@ -2,18 +2,18 @@
{{!-- Path 1: Show what's deployed in this container (when viewing from container perspective) --}}
{{#if (eq container ../id)}}
{{#each nodes}}
-{{this}} -- Deployed In --> {{../container}};
+{{{mermaidId this}}} -- Deployed In --> {{{mermaidId ../container}}};
{{/each}}
{{/if}}
{{!-- Path 2: Show what container this node is deployed IN (when viewing from node perspective) --}}
{{#each nodes}}
{{#if (eq this ../../id)}}
-{{this}} -- Deployed In --> {{../container}};
+{{{mermaidId this}}} -- Deployed In --> {{{mermaidId ../container}}};
{{/if}}
{{/each}}
{{else}}
{{!-- Path 3: Show all deployment relationships (no specific focus) --}}
{{#each nodes}}
-{{this}} -- Deployed In --> {{../container}};
+{{{mermaidId this}}} -- Deployed In --> {{{mermaidId ../container}}};
{{/each}}
{{/if}}
diff --git a/calm-widgets/src/widgets/related-nodes/interacts-relationship.hbs b/calm-widgets/src/widgets/related-nodes/interacts-relationship.hbs
index 1e8800542..2ae9292f4 100644
--- a/calm-widgets/src/widgets/related-nodes/interacts-relationship.hbs
+++ b/calm-widgets/src/widgets/related-nodes/interacts-relationship.hbs
@@ -1,3 +1,3 @@
{{#each nodes}}
-{{../actor}} -- Interacts --> {{this}};
+{{{mermaidId ../actor}}} -- Interacts --> {{{mermaidId this}}};
{{/each}}
diff --git a/calm-widgets/src/widgets/related-nodes/related-nodes-template.hbs b/calm-widgets/src/widgets/related-nodes/related-nodes-template.hbs
index 63024a2ba..9e04b1c89 100644
--- a/calm-widgets/src/widgets/related-nodes/related-nodes-template.hbs
+++ b/calm-widgets/src/widgets/related-nodes/related-nodes-template.hbs
@@ -1,7 +1,7 @@
```mermaid
graph TD;
{{#if id}}
-{{id}}[{{id}}]:::highlight;
+{{{mermaidId id}}}[{{id}}]:::highlight;
{{/if}}
{{#each relatedRelationships}}
{{#if (eq kind "interacts")}}
diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md
index ed30cdcfc..81bb5e354 100644
--- a/cli/CHANGELOG.md
+++ b/cli/CHANGELOG.md
@@ -5,6 +5,11 @@ All notable changes to the CALM CLI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.14.2] - 2025-11-17
+
+### Changed
+- Manual release triggered
+
## [1.14.1] - 2025-11-11
### Changed
diff --git a/cli/package.json b/cli/package.json
index 096ac05a6..ab8b404c9 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@finos/calm-cli",
- "version": "1.14.1",
+ "version": "1.14.2",
"description": "A set of tools for interacting with the Common Architecture Language Model (CALM)",
"homepage": "https://calm.finos.org",
"repository": {
diff --git a/shared/src/docify/template-bundles/docusaurus/docusaurus-transformer.ts b/shared/src/docify/template-bundles/docusaurus/docusaurus-transformer.ts
index a28ed5283..7e42f0e1e 100644
--- a/shared/src/docify/template-bundles/docusaurus/docusaurus-transformer.ts
+++ b/shared/src/docify/template-bundles/docusaurus/docusaurus-transformer.ts
@@ -82,6 +82,12 @@ export default class DocusaurusTransformer implements CalmTemplateTransformer {
registerTemplateHelpers(): Record unknown> {
+ // Mermaid reserved words that need to be prefixed
+ const MERMAID_RESERVED_WORDS = [
+ 'graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef', 'style',
+ 'linkStyle', 'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'
+ ];
+
return {
eq: (a, b) => a === b,
lookup: (obj, key: any) => obj?.[key],
@@ -98,6 +104,27 @@ export default class DocusaurusTransformer implements CalmTemplateTransformer {
.replace(/^-+|-+$/g, ''), // Remove leading or trailing hyphens
isObject: (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value),
isArray: (value: unknown) => Array.isArray(value),
+ mermaidId: (s: unknown) => {
+ if (typeof s !== 'string' || !s) return 'node_empty';
+
+ // Sanitize special characters
+ const sanitized = s.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
+
+ // Check if any reserved word appears at word boundaries
+ for (const reserved of MERMAID_RESERVED_WORDS) {
+ const lowerReserved = reserved.toLowerCase();
+ const pattern = new RegExp(
+ `(^|[-_.:])${lowerReserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
+ 'i'
+ );
+
+ if (pattern.test(sanitized)) {
+ return `node_${sanitized}`;
+ }
+ }
+
+ return sanitized;
+ },
notEmpty: (value: unknown): boolean => {
if (value == null) return false;
diff --git a/shared/src/docify/template-bundles/docusaurus/relationships.hbs b/shared/src/docify/template-bundles/docusaurus/relationships.hbs
index 6ccfbf013..b7d27e1ad 100644
--- a/shared/src/docify/template-bundles/docusaurus/relationships.hbs
+++ b/shared/src/docify/template-bundles/docusaurus/relationships.hbs
@@ -1,33 +1,33 @@
```mermaid
graph TD;
-{{id}}[{{id}}]:::highlight;
+{{{mermaidId id}}}[{{id}}]:::highlight;
{{#each relatedRelationships}}
{{#if (eq relationshipType.kind "interacts")}}
{{#each relationshipType.nodes}}
-{{../relationshipType.actor}} -- Interacts --> {{this}};
+{{{mermaidId ../relationshipType.actor}}} -- Interacts --> {{{mermaidId this}}};
{{/each}}
{{else if (eq relationshipType.kind "connects")}}
-{{relationshipType.source.node}} -- Connects --> {{relationshipType.destination.node}};
+{{{mermaidId relationshipType.source.node}}} -- Connects --> {{{mermaidId relationshipType.destination.node}}};
{{else if (eq relationshipType.kind "composed-of")}}
{{#if (eq relationshipType.container ../id)}}
{{#each relationshipType.nodes}}
-{{../relationshipType.container}} -- Composed Of --> {{this}};
+{{{mermaidId ../relationshipType.container}}} -- Composed Of --> {{{mermaidId this}}};
{{/each}}
{{/if}}
{{#each relationshipType.nodes}}
{{#if (eq this ../../id)}}
-{{../relationshipType.container}} -- Composed Of --> {{this}};
+{{{mermaidId ../relationshipType.container}}} -- Composed Of --> {{{mermaidId this}}};
{{/if}}
{{/each}}
{{else if (eq relationshipType.kind "deployed-in")}}
{{#if (eq relationshipType.container ../id)}}
{{#each relationshipType.nodes}}
-{{../relationshipType.container}} -- Deployed In --> {{this}};
+{{{mermaidId ../relationshipType.container}}} -- Deployed In --> {{{mermaidId this}}};
{{/each}}
{{/if}}
{{#each relationshipType.nodes}}
{{#if (eq this ../../id)}}
-{{../relationshipType.container}} -- Deployed In --> {{this}};
+{{{mermaidId ../relationshipType.container}}} -- Deployed In --> {{{mermaidId this}}};
{{/if}}
{{/each}}
{{/if}}