Skip to content
Merged
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
2 changes: 1 addition & 1 deletion calm-plugins/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ export default class MermaidRenderer {
// Finally, check for any remaining HTML-encoded Mermaid blocks
const htmlMermaidRegex = /<pre><code class="language-mermaid">([\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)}`
Expand Down Expand Up @@ -116,6 +118,15 @@ export default class MermaidRenderer {
</div>`
}

/**
* 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
Expand Down
39 changes: 39 additions & 0 deletions calm-widgets/src/widget-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense for now. I guess we need to enhance all of this to calm validate a lot of this stuff anyways for the null case.

});
});

describe('instanceOf helper', () => {
it('returns true for matching constructor names', () => {
const obj = new Date();
Expand Down
27 changes: 27 additions & 0 deletions calm-widgets/src/widget-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ export function registerGlobalTemplateHelpers(): Record<string, (...args: unknow
return undefined;
},
json: (obj: unknown): string => 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' &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
16 changes: 8 additions & 8 deletions calm-widgets/src/widgets/block-architecture/click-links.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,32 @@
{{#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}}

{{!-- 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}}
Expand Down
8 changes: 4 additions & 4 deletions calm-widgets/src/widgets/block-architecture/container.hbs
Original file line number Diff line number Diff line change
@@ -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}}
40 changes: 39 additions & 1 deletion calm-widgets/src/widgets/block-architecture/core/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
});
});
});
34 changes: 34 additions & 0 deletions calm-widgets/src/widgets/block-architecture/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 16 additions & 16 deletions calm-widgets/src/widgets/block-architecture/typed-node.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Loading
Loading