From 91929955ed1e4bada32abeaf1e76b275ce40bbd7 Mon Sep 17 00:00:00 2001 From: rocketstack-matt Date: Mon, 17 Nov 2025 15:35:34 +0000 Subject: [PATCH 1/7] fix(calm-widgets): update mermaid ID handling and sanitize identifiers in widgets --- calm-plugins/vscode/package.json | 2 +- .../preview/webview/mermaid-renderer.ts | 13 +++++- calm-widgets/src/widget-helpers.spec.ts | 39 ++++++++++++++++++ calm-widgets/src/widget-helpers.ts | 25 ++++++++++++ .../block-architecture/block-architecture.hbs | 10 ++--- .../block-architecture/click-links.hbs | 16 ++++---- .../widgets/block-architecture/container.hbs | 8 ++-- .../block-architecture/core/utils.spec.ts | 40 ++++++++++++++++++- .../widgets/block-architecture/core/utils.ts | 39 ++++++++++++++++++ .../widgets/block-architecture/typed-node.hbs | 32 +++++++-------- 10 files changed, 188 insertions(+), 36 deletions(-) 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/webview/mermaid-renderer.ts b/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts index 75416d74e..23fd7e8c7 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
+     */
+    private decodeHtmlEntities(text: string): string {
+        const textarea = document.createElement('textarea')
+        textarea.innerHTML = text
+        return textarea.value
+    }
+
     /**
      * 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..e30505e9d 100644
--- a/calm-widgets/src/widget-helpers.ts
+++ b/calm-widgets/src/widget-helpers.ts
@@ -11,6 +11,31 @@ 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
+            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;
+        },
         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..4536ad574 100644
--- a/calm-widgets/src/widgets/block-architecture/core/utils.ts
+++ b/calm-widgets/src/widgets/block-architecture/core/utils.ts
@@ -17,6 +17,45 @@ export const prettyLabel = (id: string) =>
  */
 export const sanitizeId = (s: string) => s.replace(new RegExp(String.raw`[^\w\-:.]`, 'g'), '_');
 
+/**
+ * Set of Mermaid reserved words that cannot be used as node identifiers
+ */
+const MERMAID_RESERVED_WORDS = [
+    'graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef', 'style', 'linkStyle',
+    'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'
+];
+
+/**
+ * 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.
+ */
+export const mermaidId = (s: string) => {
+    if (!s) return 'node_empty';
+
+    // First sanitize special characters
+    const sanitized = sanitizeId(s);
+
+    // 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 MERMAID_RESERVED_WORDS) {
+        const lowerReserved = reserved.toLowerCase();
+
+        // 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(
+            `(^|[-_.:])${lowerReserved.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

From cba86529455dcdcff7fea5d9e87ab31134b3c311 Mon Sep 17 00:00:00 2001
From: rocketstack-matt 
Date: Mon, 17 Nov 2025 15:57:17 +0000
Subject: [PATCH 2/7] fix(cli): update to use mermaidId for all relationship
 visualizations

---
 .../composed-of-relationship.hbs              |  6 ++---
 .../related-nodes/connects-relationship.hbs   |  2 +-
 .../deployed-in-relationship.hbs              |  6 ++---
 .../related-nodes/interacts-relationship.hbs  |  2 +-
 .../related-nodes/related-nodes-template.hbs  |  2 +-
 .../docusaurus/docusaurus-transformer.ts      | 27 +++++++++++++++++++
 .../docusaurus/relationships.hbs              | 14 +++++-----
 7 files changed, 43 insertions(+), 16 deletions(-)

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/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}}

From 328358e790192ba75dc3a544190dda4373c1e1fc Mon Sep 17 00:00:00 2001
From: rocketstack-matt 
Date: Mon, 17 Nov 2025 16:04:51 +0000
Subject: [PATCH 3/7] fix(calm-widgets): enhance mermaidId function and improve
 HTML entity decoding for security

---
 .../preview/webview/mermaid-renderer.ts       |  8 +++---
 calm-widgets/src/widget-helpers.ts            |  4 +++
 .../widgets/block-architecture/core/utils.ts  | 27 ++++++++-----------
 3 files changed, 19 insertions(+), 20 deletions(-)

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 23fd7e8c7..e69b817f3 100644
--- a/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts
+++ b/calm-plugins/vscode/src/features/preview/webview/mermaid-renderer.ts
@@ -119,12 +119,12 @@ export default class MermaidRenderer {
     }
 
     /**
-     * Decode HTML entities from markdown-it output
+     * Decode HTML entities from markdown-it output using DOMParser for security
      */
     private decodeHtmlEntities(text: string): string {
-        const textarea = document.createElement('textarea')
-        textarea.innerHTML = text
-        return textarea.value
+        const parser = new DOMParser()
+        const doc = parser.parseFromString(text, 'text/html')
+        return doc.body.textContent || ''
     }
 
     /**
diff --git a/calm-widgets/src/widget-helpers.ts b/calm-widgets/src/widget-helpers.ts
index e30505e9d..8541c00b3 100644
--- a/calm-widgets/src/widget-helpers.ts
+++ b/calm-widgets/src/widget-helpers.ts
@@ -1,3 +1,5 @@
+import { mermaidId as mermaidIdImpl } from './widgets/block-architecture/core/utils';
+
 export function registerGlobalTemplateHelpers(): Record unknown> {
     return {
         eq: (a: unknown, b: unknown): boolean => a === b,
@@ -22,8 +24,10 @@ export function registerGlobalTemplateHelpers(): Record
  */
 export const sanitizeId = (s: string) => s.replace(new RegExp(String.raw`[^\w\-:.]`, 'g'), '_');
 
-/**
- * Set of Mermaid reserved words that cannot be used as node identifiers
- */
-const MERMAID_RESERVED_WORDS = [
-    'graph', 'subgraph', 'end', 'click', 'call', 'class', 'classDef', 'style', 'linkStyle',
-    'direction', 'TB', 'BT', 'RL', 'LR', 'TD', 'BR'
-];
-
 /**
  * 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) => {
+export const mermaidId = (s: string): string => {
     if (!s) return 'node_empty';
 
-    // First sanitize special characters
+    // Sanitize: replace non-word chars (except hyphen, colon, dot) with underscore
     const sanitized = sanitizeId(s);
 
-    // 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 MERMAID_RESERVED_WORDS) {
-        const lowerReserved = reserved.toLowerCase();
+    // 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
-        // \b doesn't work well with hyphens, so we explicitly check boundaries
         const pattern = new RegExp(
-            `(^|[-_.:])${lowerReserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
+            `(^|[-_.:])${reserved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[-_.:])`,
             'i'
         );
 

From e136a31e51b8c4e667c01762782dfd1a7b890ad7 Mon Sep 17 00:00:00 2001
From: rocketstack-matt 
Date: Mon, 17 Nov 2025 16:08:06 +0000
Subject: [PATCH 4/7] fix(vscode): enhance ID extraction logic to handle
 reserved words and prefixes

---
 .../preview/docify-tab/view/docify-tab.view.ts        | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

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
     }

From 316cbe269e979035e4c97cbdd8902c4d0205df06 Mon Sep 17 00:00:00 2001
From: rocketstack-matt 
Date: Mon, 17 Nov 2025 16:09:15 +0000
Subject: [PATCH 5/7] fix(calm-widgets): remove unused import of mermaidId
 implementation

---
 calm-widgets/src/widget-helpers.ts | 2 --
 1 file changed, 2 deletions(-)

diff --git a/calm-widgets/src/widget-helpers.ts b/calm-widgets/src/widget-helpers.ts
index 8541c00b3..ea820063b 100644
--- a/calm-widgets/src/widget-helpers.ts
+++ b/calm-widgets/src/widget-helpers.ts
@@ -1,5 +1,3 @@
-import { mermaidId as mermaidIdImpl } from './widgets/block-architecture/core/utils';
-
 export function registerGlobalTemplateHelpers(): Record unknown> {
     return {
         eq: (a: unknown, b: unknown): boolean => a === b,

From e6539432c3f7e426957048240035718306f7afb8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" 
Date: Mon, 17 Nov 2025 17:59:59 +0000
Subject: [PATCH 6/7] ci(cli): release version 1.14.2

---
 cli/CHANGELOG.md | 5 +++++
 cli/package.json | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)

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": {

From f9499d7fc204dd5c435c343c8b9b9413e8a3e78f Mon Sep 17 00:00:00 2001
From: rocketstack-matt 
Date: Tue, 18 Nov 2025 09:24:42 +0000
Subject: [PATCH 7/7] fix(vscode): update Java VM arguments for improved
 performance on MacOS

---
 .vscode/settings.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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"
 }