Compare commits

..

7 Commits

Author SHA1 Message Date
omkarht
cfed700a58 chore: modified the file structure to implement the new way of antlr parsing
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-11-14 18:24:37 +05:30
omkarht
b715d82458 feat: add support for new arrow types and enhance use case diagram features
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-13 19:07:11 +05:30
omkarht
5b2b3b8ae9 feat: enhance use case diagram support with arrow types, class definitions and styles 2025-10-13 18:46:47 +05:30
omkarht
b7ff1920a9 fix: add ellipse shape to the shapes table in documentation
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-09 19:32:10 +05:30
omkarht
58c06ed770 fix: exclude generated files from circular dependency check
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-09 19:19:55 +05:30
omkarht
5e05f91b7d fix: remove TypeScript error suppression for broken ellipse rendering 2025-10-09 18:55:01 +05:30
omkarht
5fd06ccbac 6806: Adding support for the new use-case diagram type
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-09 18:48:11 +05:30
101 changed files with 5317 additions and 1973 deletions

92
.build/antlr-cli.ts Normal file
View File

@@ -0,0 +1,92 @@
/* eslint-disable no-console */
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, dirname } from 'node:path';
import { readFile, mkdir, access } from 'node:fs/promises';
import { existsSync } from 'node:fs';
const execAsync = promisify(exec);
interface AntlrGrammarConfig {
id: string;
grammar: string;
outputDir: string;
language: string;
generateVisitor?: boolean;
generateListener?: boolean;
}
interface AntlrConfig {
projectName: string;
grammars: AntlrGrammarConfig[];
mode: string;
}
export async function generateFromConfig(configFile: string): Promise<void> {
const configPath = resolve(configFile);
if (!existsSync(configPath)) {
throw new Error(`ANTLR config file not found: ${configPath}`);
}
const configContent = await readFile(configPath, 'utf-8');
const config: AntlrConfig = JSON.parse(configContent);
const configDir = dirname(configPath);
for (const grammarConfig of config.grammars) {
await generateGrammar(grammarConfig, configDir);
}
}
async function generateGrammar(grammarConfig: AntlrGrammarConfig, baseDir: string): Promise<void> {
const grammarFile = resolve(baseDir, grammarConfig.grammar);
const outputDir = resolve(baseDir, grammarConfig.outputDir);
// Check if grammar file exists
try {
await access(grammarFile);
} catch {
throw new Error(`Grammar file not found: ${grammarFile}`);
}
// Ensure output directory exists
await mkdir(outputDir, { recursive: true });
// Build ANTLR command arguments
// eslint-disable-next-line @cspell/spellchecker
const args = ['antlr-ng', `-Dlanguage=${grammarConfig.language}`];
if (grammarConfig.generateVisitor) {
args.push('--generate-visitor');
}
if (grammarConfig.generateListener) {
args.push('--generate-listener');
}
args.push('-o', `"${outputDir}"`, `"${grammarFile}"`);
const command = `npx ${args.join(' ')}`;
try {
await execAsync(command);
console.log(`Generated ANTLR files for ${grammarConfig.id}`);
} catch (error) {
console.error(`Failed to generate ANTLR files for ${grammarConfig.id}:`);
throw error;
}
}
// CLI interface
if (import.meta.url === `file://${process.argv[1]}`) {
const configFile = process.argv[2] || './packages/parser/antlr-config.json';
try {
await generateFromConfig(configFile);
console.log('ANTLR generation completed successfully!');
} catch (error) {
console.error('ANTLR generation failed:', error.message);
}
}

5
.build/generateAntlr.ts Normal file
View File

@@ -0,0 +1,5 @@
import { generateFromConfig } from './antlr-cli.js';
export async function generateAntlr() {
await generateFromConfig('./packages/parser/antlr-config.json');
}

View File

@@ -28,7 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
'packet',
'architecture',
'radar',
'icons',
'usecase',
] as const;
/**

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Support edge animation in hand drawn look

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Resolved parsing error where direction TD was not recognized within subgraphs

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Correct viewBox casing and make SVGs responsive

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Improve participant parsing and prevent recursive loops on invalid syntax

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
chore: Fix mindmap rendering in docs and apply tidytree layout

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
fix: Ensure edge label color is applied when using classDef with edge IDs

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
fix: Resolve gantt chart crash due to invalid array length

View File

@@ -0,0 +1,5 @@
---
'mermaid': minor
---
feat: Add IDs in architecture diagrams

View File

@@ -0,0 +1,9 @@
---
'mermaid': patch
---
chore: revert marked dependency from ^15.0.7 to ^16.0.0
- Reverted marked package version to ^16.0.0 for better compatibility
- This is a dependency update that maintains API compatibility
- All tests pass with the updated version

View File

@@ -1,5 +0,0 @@
---
'mermaid': minor
---
feat: allow to put notes in namespaces on classDiagram

View File

@@ -143,6 +143,9 @@ typeof
typestr
unshift
urlsafe
usecase
Usecase
USECASE
verifymethod
VERIFYMTHD
WARN_DOCSDIR_DOESNT_MATCH

View File

@@ -22,7 +22,6 @@ mermaidchart
mermaidjs
mindmap
mindmaps
mmdc
mrtree
multigraph
nodesep

View File

@@ -4,6 +4,7 @@ import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
import type { MermaidBuildOptions } from './util.js';
import { defaultOptions, getBuildConfig } from './util.js';
import { generateAntlr } from '../.build/generateAntlr.js';
const shouldVisualize = process.argv.includes('--visualize');
@@ -95,6 +96,7 @@ const buildTinyMermaid = async () => {
const main = async () => {
await generateLangium();
await generateAntlr();
await mkdir('stats', { recursive: true });
const packageNames = Object.keys(packageOptions) as (keyof typeof packageOptions)[];
// it should build `parser` before `mermaid` because it's a dependency

View File

@@ -4,6 +4,7 @@ import cors from 'cors';
import { context } from 'esbuild';
import type { Request, Response } from 'express';
import express from 'express';
import { execSync } from 'child_process';
import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
import { defaultOptions, getBuildConfig } from './util.js';
@@ -64,6 +65,28 @@ function eventsHandler(request: Request, response: Response) {
}
let timeoutID: NodeJS.Timeout | undefined = undefined;
let isGeneratingAntlr = false;
/**
* Generate ANTLR parser files from grammar files
*/
function generateAntlr(): void {
if (isGeneratingAntlr) {
console.log('⏳ ANTLR generation already in progress, skipping...');
return;
}
try {
isGeneratingAntlr = true;
console.log('🎯 ANTLR: Generating parser files...');
execSync('tsx scripts/antlr-generate.mts', { stdio: 'inherit' });
console.log('✅ ANTLR: Parser files generated successfully\n');
} catch (error) {
console.error('❌ ANTLR: Failed to generate parser files:', error);
} finally {
isGeneratingAntlr = false;
}
}
/**
* Debounce file change events to avoid rebuilding multiple times.
@@ -89,7 +112,7 @@ async function createServer() {
handleFileChange();
const app = express();
chokidar
.watch('**/src/**/*.{js,ts,langium,yaml,json}', {
.watch('**/src/**/*.{js,ts,g4,langium,yaml,json}', {
ignoreInitial: true,
ignored: [/node_modules/, /dist/, /docs/, /coverage/],
})
@@ -103,6 +126,9 @@ async function createServer() {
if (path.endsWith('.langium')) {
await generateLangium();
}
if (path.endsWith('.g4')) {
generateAntlr();
}
handleFileChange();
});

View File

@@ -1,7 +1,7 @@
name: Validate pnpm-lock.yaml
on:
pull_request_target:
pull_request:
paths:
- 'pnpm-lock.yaml'
- '**/package.json'
@@ -15,8 +15,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -57,41 +55,16 @@ jobs:
exit 1
fi
- name: Find existing lockfile validation comment
if: always()
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Lockfile Validation Failed'
- name: Comment on PR if validation failed
if: failure()
uses: peter-evans/create-or-update-comment@v5
uses: peter-evans/create-or-update-comment@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
❌ **Lockfile Validation Failed**
The following issue(s) were detected:
${{ steps.validate.outputs.errors }}
Please address these and push an update.
_Posted automatically by GitHub Actions_
- name: Delete comment if validation passed
if: success() && steps.find-comment.outputs.comment-id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: ${{ steps.find-comment.outputs.comment-id }},
});

5
.gitignore vendored
View File

@@ -51,5 +51,8 @@ demos/dev/**
tsx-0/**
vite.config.ts.timestamp-*
# autogenereated by langium-cli
# autogenereated by langium-cli and antlr-cli
generated/
# autogenereated by antlr-cli
.antlr/

View File

@@ -10,6 +10,7 @@ import type { TemplateType } from 'rollup-plugin-visualizer/dist/plugin/template
import istanbul from 'vite-plugin-istanbul';
import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
import { generateAntlr } from '../.build/generateAntlr.js';
const visualize = process.argv.includes('--visualize');
const watch = process.argv.includes('--watch');
@@ -123,6 +124,7 @@ const main = async () => {
};
await generateLangium();
await generateAntlr();
if (watch) {
await build(getBuildConfig({ minify: false, watch, core: false, entryName: 'parser' }));

View File

@@ -5,7 +5,7 @@ USER 0:0
RUN corepack enable \
&& corepack enable pnpm
RUN apk add --no-cache git~=2.43 \
RUN apk add --no-cache git~=2.43.4 \
&& git config --add --system safe.directory /mermaid
ENV NODE_OPTIONS="--max_old_space_size=8192"

View File

@@ -6,7 +6,6 @@ interface CypressConfig {
listUrl?: boolean;
listId?: string;
name?: string;
screenshot?: boolean;
}
type CypressMermaidConfig = MermaidConfig & CypressConfig;
@@ -91,7 +90,7 @@ export const renderGraph = (
export const openURLAndVerifyRendering = (
url: string,
{ screenshot = true, ...options }: CypressMermaidConfig,
options: CypressMermaidConfig,
validation?: any
): void => {
const name: string = (options.name ?? cy.state('runnable').fullTitle()).replace(/\s+/g, '-');
@@ -99,15 +98,12 @@ export const openURLAndVerifyRendering = (
cy.visit(url);
cy.window().should('have.property', 'rendered', true);
cy.get('svg').should('be.visible');
cy.get('svg').should('not.have.attr', 'viewbox');
if (validation) {
cy.get('svg').should(validation);
}
if (screenshot) {
verifyScreenshot(name);
}
verifyScreenshot(name);
};
export const verifyScreenshot = (name: string): void => {

View File

@@ -562,20 +562,6 @@ class C13["With Città foreign language"]
`
);
});
it('should add notes in namespaces', function () {
imgSnapshotTest(
`
classDiagram
note "This is a outer note"
note for C1 "This is a outer note for C1"
namespace Namespace1 {
note "This is a inner note"
note for C1 "This is a inner note for C1"
class C1
}
`
);
});
it('should render a simple class diagram with no members', () => {
imgSnapshotTest(
`

View File

@@ -709,20 +709,6 @@ class C13["With Città foreign language"]
`
);
});
it('should add notes in namespaces', function () {
imgSnapshotTest(
`
classDiagram
note "This is a outer note"
note for C1 "This is a outer note for C1"
namespace Namespace1 {
note "This is a inner note"
note for C1 "This is a inner note for C1"
class C1
}
`
);
});
it('should render a simple class diagram with no members', () => {
imgSnapshotTest(
`

View File

@@ -1029,19 +1029,4 @@ graph TD
}
);
});
it('FDH49: should add edge animation', () => {
renderGraph(
`
flowchart TD
A(["Start"]) L_A_B_0@--> B{"Decision"}
B --> C["Option A"] & D["Option B"]
style C stroke-width:4px,stroke-dasharray: 5
L_A_B_0@{ animation: slow }
L_B_D_0@{ animation: fast }`,
{ look: 'handDrawn', screenshot: false }
);
cy.get('path#L_A_B_0').should('have.class', 'edge-animation-slow');
cy.get('path#L_B_D_0').should('have.class', 'edge-animation-fast');
});
});

View File

@@ -774,21 +774,6 @@ describe('Graph', () => {
expect(svg).to.not.have.attr('style');
});
});
it('40: should add edge animation', () => {
renderGraph(
`
flowchart TD
A(["Start"]) L_A_B_0@--> B{"Decision"}
B --> C["Option A"] & D["Option B"]
style C stroke-width:4px,stroke-dasharray: 5
L_A_B_0@{ animation: slow }
L_B_D_0@{ animation: fast }`,
{ screenshot: false }
);
// Verify animation classes are applied to both edges
cy.get('path#L_A_B_0').should('have.class', 'edge-animation-slow');
cy.get('path#L_B_D_0').should('have.class', 'edge-animation-fast');
});
it('58: handle styling with style expressions', () => {
imgSnapshotTest(
`
@@ -988,19 +973,4 @@ graph TD
}
);
});
it('70: should render a subgraph with direction TD', () => {
imgSnapshotTest(
`
flowchart LR
subgraph A
direction TD
a --> b
end
`,
{
fontFamily: 'courier',
}
);
});
});

View File

@@ -1,240 +0,0 @@
import { imgSnapshotTest } from '../../helpers/util';
describe('Icons rendering tests', () => {
it('should render icon from config pack', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C[End]
`);
});
it('should render icons from different packs', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
simple-icons: "@iconify-json/simple-icons@1"
---
flowchart TB
A@{ icon: 'logos:aws', label: 'AWS' } --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C@{ icon: 'logos:kubernetes', label: 'K8s' }
C --> D@{ icon: 'simple-icons:github', label: 'GitHub' }
`);
});
it('should use custom CDN template', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
cdnTemplate: "https://cdn.jsdelivr.net/npm/\${packageSpec}/icons.json"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C[End]
`);
});
it('should use different allowed hosts', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
allowedHosts:
- cdn.jsdelivr.net
- unpkg.com
---
flowchart TB
A[Start] --> B@{ icon: 'logos:aws', label: 'AWS' }
`);
});
it('should render icon with label at top', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker Container', pos: 't' }
`);
});
it('should render icon with label at bottom', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:kubernetes', label: 'Kubernetes', pos: 'b' }
`);
});
it('should render icon with long label', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'This is a very long label for Docker container orchestration', h: 64 }
`);
});
it('should render large icon', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Large', h: 80, w: 80 }
`);
});
it('should render small icon', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Small', h: 32, w: 32 }
`);
});
it('should apply custom styles to icon shape', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Styled', form: 'square' }
B --> C[End]
style B fill:#0db7ed,stroke:#333,stroke-width:4px
`);
});
it('should use classDef with icons', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
classDef dockerIcon fill:#0db7ed,stroke:#fff,stroke-width:2px
classDef awsIcon fill:#FF9900,stroke:#fff,stroke-width:2px
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C@{ icon: 'logos:aws', label: 'AWS' }
B:::dockerIcon
C:::awsIcon
`);
});
it('should render in TB layout', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C[End]
`);
});
it('should render in LR layout', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart LR
A[Start] --> B@{ icon: 'logos:kubernetes', label: 'K8s' }
B --> C[End]
`);
});
it('should handle unknown icon gracefully', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'unknown:invalid', label: 'Unknown Icon' }
B --> C[End]
`);
});
it('should handle timeouts gracefully', () => {
imgSnapshotTest(`---
config:
icons:
timeout: 1
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:aws', label: 'Timeout' }
B --> C[End]
`);
});
it('should handle missing pack gracefully', () => {
imgSnapshotTest(`flowchart TB
A[Start] --> B@{ icon: 'missing:icon', label: 'Missing Pack Icon' }
`);
});
it('should render multiple icons in sequence', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:aws', label: 'AWS' }
B --> C@{ icon: 'logos:docker', label: 'Docker' }
C --> D@{ icon: 'logos:kubernetes', label: 'K8s' }
D --> E[End]
`);
});
it('should render icons in parallel branches', () => {
imgSnapshotTest(`---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
A --> C@{ icon: 'logos:kubernetes', label: 'K8s' }
B --> D[End]
C --> D
`);
});
});

View File

@@ -0,0 +1,425 @@
import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts';
describe('Usecase diagram', () => {
it('should render a simple usecase diagram with actors and use cases', () => {
imgSnapshotTest(
`
usecase
actor User
actor Admin
User --> Login
Admin --> "Manage Users"
User --> "View Profile"
`
);
});
it('should render usecase diagram with quoted actor names', () => {
imgSnapshotTest(
`usecase
actor "Customer Service"
actor "System Administrator"
"Customer Service" --> "Handle Tickets"
"System Administrator" --> "Manage System"
`
);
});
it('should render usecase diagram with different arrow types', () => {
imgSnapshotTest(
`usecase
actor User
actor Admin
User --> Login
Admin <-- Logout
User -- "View Data"
`
);
});
it('should render usecase diagram with edge labels', () => {
imgSnapshotTest(
`usecase
actor Developer
actor Manager
Developer --important--> "Write Code"
Manager --review--> "Code Review"
Developer --urgent--> Manager
`
);
});
it('should render usecase diagram with node ID syntax', () => {
imgSnapshotTest(
`usecase
actor User
User --> a(Login)
User --> b("View Profile")
User --> c("Update Settings")
`
);
});
it('should render usecase diagram with comma-separated actors', () => {
imgSnapshotTest(
`usecase
actor "Customer Service", "Technical Support", "Sales Team"
actor SystemAdmin
"Customer Service" --> "Handle Tickets"
"Technical Support" --> "Resolve Issues"
"Sales Team" --> "Process Orders"
SystemAdmin --> "Manage System"
`
);
});
it('should render usecase diagram with actor metadata', () => {
imgSnapshotTest(
`usecase
actor User@{ "type" : "primary", "icon" : "user" }
actor Admin@{ "type" : "secondary", "icon" : "admin" }
actor System@{ "type" : "hollow", "icon" : "system" }
User --> Login
Admin --> "Manage Users"
System --> "Process Data"
`
);
});
it('should render usecase diagram with system boundaries (rect type)', () => {
imgSnapshotTest(
`usecase
actor Admin, User
systemBoundary "Authentication"
Login
Logout
"Reset Password"
end
"Authentication"@{ type: rect }
Admin --> Login
User --> Login
User --> "Reset Password"
`
);
});
it('should render usecase diagram with system boundaries (package type)', () => {
imgSnapshotTest(
`usecase
actor Admin, User
systemBoundary "Authentication"
Login
Logout
"Reset Password"
end
"Authentication"@{ type: package }
Admin --> Login
User --> Login
User --> "Reset Password"
`
);
});
it('should render complex usecase diagram with all features', () => {
imgSnapshotTest(
`usecase
actor "Customer Service"@{ "type" : "primary", "icon" : "user" }
actor "System Admin"@{ "type" : "secondary", "icon" : "admin" }
actor "Database"@{ "type" : "hollow", "icon" : "database" }
systemBoundary "Customer Support System"
"Handle Tickets"
"View Customer Info"
end
"Customer Support System"@{ type: package }
systemBoundary "Administration"
"User Management"
"System Config"
end
"Customer Service" --priority--> "Handle Tickets"
"Customer Service" --> "View Customer Info"
"System Admin" --manage--> "User Management"
"System Admin" --> "System Config"
"Database" <-- "Handle Tickets"
"Database" <-- "View Customer Info"
"Database" <-- "User Management"
`
);
});
it('should render usecase diagram with actor-to-actor relationships', () => {
imgSnapshotTest(
`usecase
actor Manager
actor Developer
actor Tester
Manager --supervises--> Developer
Manager --coordinates--> Tester
Developer --collaborates--> Tester
Developer --> "Write Code"
Tester --> "Test Code"
Manager --> "Review Progress"
`
);
});
it('should render usecase diagram with mixed relationship types', () => {
imgSnapshotTest(
`usecase
actor User
actor Admin
User --> "Basic Login"
Admin --> "Advanced Login"
User --includes--> "View Profile"
Admin --extends--> "Manage Profiles"
"Basic Login" <-- "Advanced Login"
`
);
});
it('should render usecase diagram with long labels and text wrapping', () => {
imgSnapshotTest(
`usecase
actor "Customer Service Representative"
actor "System Administrator with Extended Privileges"
"Customer Service Representative" --Process--> "Handle Complex Customer Support Tickets"
"System Administrator with Extended Privileges" --> "Manage System Configuration and User Permissions"
`
);
});
it('should render usecase diagram with special characters in names', () => {
imgSnapshotTest(
`usecase
actor "User@Company.com"
actor "Admin (Level-1)"
"User@Company.com" --> a("Login & Authenticate")
"Admin (Level-1)" --> b("Manage Users & Permissions")
`
);
});
it('should render usecase diagram when useMaxWidth is true (default)', () => {
renderGraph(
`usecase
actor User
actor Admin
User --> Login
Admin --> "Manage System"
User --> "View Profile"
`,
{ usecase: { useMaxWidth: true } }
);
cy.get('svg').should((svg) => {
expect(svg).to.have.attr('width', '100%');
const style = svg.attr('style');
expect(style).to.match(/^max-width: [\d.]+px;$/);
});
});
it('should render usecase diagram when useMaxWidth is false', () => {
renderGraph(
`usecase
actor User
actor Admin
User --> Login
Admin --> "Manage System"
`,
{ usecase: { useMaxWidth: false } }
);
cy.get('svg').should((svg) => {
const width = parseFloat(svg.attr('width'));
expect(width).to.be.greaterThan(200);
expect(svg).to.not.have.attr('style');
});
});
it('should render empty usecase diagram', () => {
imgSnapshotTest(`usecase`);
});
it('should render usecase diagram with only actors', () => {
imgSnapshotTest(
`usecase
actor User
actor Admin
actor Guest
`
);
});
it('should render usecase diagram with implicit use case creation', () => {
imgSnapshotTest(
`usecase
actor User
User --> Login
User --> Register
User --> "Forgot Password"
`
);
});
it('should render usecase diagram with nested system boundaries', () => {
imgSnapshotTest(
`usecase
actor User
actor Admin
systemBoundary "Main System"
Login
Logout
"Create User"
"Delete User"
end
User --> Login
User --> Logout
Admin --> "Create User"
Admin --> "Delete User"
`
);
});
it('should render usecase diagram with multiple edge labels on same relationship', () => {
imgSnapshotTest(
`usecase
actor Developer
actor Manager
Developer --"code review"--> Manager
Developer --"status update"--> Manager
Manager --"feedback"--> Developer
Manager --"approval"--> Developer
`
);
});
it('should render usecase diagram with various actor icon types', () => {
imgSnapshotTest(
`usecase
actor User@{ "icon": "user" }
actor Admin@{ "icon": "admin" }
actor Database@{ "icon": "database" }
actor API@{ "icon": "api" }
actor Mobile@{ "icon": "mobile" }
actor Web@{ "icon": "web" }
User --> "Access System"
Admin --> "Manage System"
Database --> "Store Data"
API --> "Provide Services"
Mobile --> "Mobile Access"
Web --> "Web Access"
`
);
});
it('should render usecase diagram with mixed arrow directions and labels', () => {
imgSnapshotTest(
`usecase
actor User
actor System
actor Admin
User --request--> System
System --response--> User
System <--monitor-- Admin
Admin --configure--> System
User -- "direct access" -- Admin
`
);
});
it('should render usecase diagram with boundary-less use cases', () => {
imgSnapshotTest(
`usecase
actor User
actor Admin
systemBoundary "Secure Area"
"Admin Panel"
"User Management"
end
User --> "Public Login"
User --> "Guest Access"
Admin --> "Public Login"
Admin --> "Admin Panel"
Admin --> "User Management"
`
);
});
it('should render usecase diagram with complex metadata combinations', () => {
imgSnapshotTest(
`usecase
actor "Primary User"@{ "type": "primary", "icon": "user", "fillColor": "lightblue" }
actor "Secondary User"@{ "type": "secondary", "icon": "client", "strokeColor": "red" }
actor "System Service"@{ "type": "hollow", "icon": "service", "strokeWidth": "3" }
"Primary User" --"high priority"--> a("Critical Process")
"Secondary User" --"low priority"--> b("Background Task")
"System Service" --"automated"--> c("System Maintenance")
`
);
});
it('should render usecase diagram with Unicode characters', () => {
imgSnapshotTest(
`usecase
actor "用户"@{ "icon": "user" }
actor "管理员"@{ "icon": "admin" }
"用户" --"登录"--> "系统访问"
"管理员" --"管理"--> "用户管理"
"用户" --> "数据查看"
`
);
});
it('should render large usecase diagram with many elements', () => {
imgSnapshotTest(
`usecase
actor User1, User2, User3, User4
actor Admin1, Admin2
actor System1@{ "icon": "system" }
actor System2@{ "icon": "database" }
systemBoundary "Module A"
"Feature A1"
"Feature A2"
"Admin A1"
end
"Module A"@{ type: package }
systemBoundary "Module B"
"Feature B1"
"Feature B2"
"Admin B1"
end
User1 --> "Feature A1"
User2 --> "Feature A2"
Admin1 --> "Admin A1"
User3 --> "Feature B1"
User4 --> "Feature B2"
Admin2 --> "Admin B1"
System1 <-- "Feature A1"
System1 <-- "Feature B1"
System2 <-- "Admin A1"
System2 <-- "Admin B1"
User1 --"collaborates"--> User2
Admin1 --"supervises"--> Admin2
`
);
});
});

View File

@@ -603,10 +603,6 @@
</div>
<div class="test">
<pre class="mermaid">
---
config:
theme: dark
---
classDiagram
test ()--() test2
</pre>

View File

@@ -184,7 +184,6 @@
}
Admin --> Report : generates
</pre>
<hr />
<pre class="mermaid">
classDiagram
namespace Company.Project.Module {
@@ -241,20 +240,6 @@
Bike --> Square : "Logo Shape"
</pre>
<hr />
<pre class="mermaid">
classDiagram
note "This is a outer note"
note for Class1 "This is a outer note for Class1"
namespace ns {
note "This is a inner note"
note for Class1 "This is a inner note for Class1"
class Class1
class Class2
}
</pre>
<hr />
<script type="module">
import mermaid from './mermaid.esm.mjs';
mermaid.initialize({

234
demos/usecase.html Normal file
View File

@@ -0,0 +1,234 @@
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" rel="stylesheet" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Noto+Sans+SC&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Recursive:wght@300..1000&display=swap"
rel="stylesheet"
/>
<style>
.recursive-500 {
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
}
body {
/* background: rgb(221, 208, 208); */
/* background: #333; */
/* font-family: 'Arial'; */
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
/* color: white; */
/* font-size: 18px !important; */
}
.gridify.tiny {
background-image:
linear-gradient(transparent 11px, rgba(220, 220, 200, 0.8) 12px, transparent 12px),
linear-gradient(90deg, transparent 11px, rgba(220, 220, 200, 0.8) 12px, transparent 12px);
background-size:
100% 12px,
12px 100%;
}
.gridify.dots {
background-image: radial-gradient(
circle at center,
rgba(220, 220, 200, 0.8) 1px,
transparent 1px
);
background-size: 24px 24px;
}
h1 {
color: grey;
}
.mermaid2 {
display: none;
}
.mermaid svg {
font-size: 16px !important;
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
}
pre {
width: 100%;
/*box-shadow: 4px 4px 0px 0px #0000000F;*/
}
</style>
</head>
<body class="gridify dots">
<p class="mb-20">Test Diagram</p>
<div class="w-full h-64">
<pre id="diagram4" class="mermaid">
usecase
direction LR
actor User1, User2, User3, User4
actor Admin1, Admin2
actor System1@{ "icon": "bell" }
actor System2@{ "icon": "database" }
systemBoundary "Module A"
"Feature A1"
"Feature A2"
"Admin A1"
end
"Module A"@{ type: package }
systemBoundary "Module B"
"Feature B1"
"Feature B2"
"Admin B1"
end
User1 --important--> "Feature A1"
User2 --> "Feature A2"
Admin1 --> "Admin A1"
User3 --> "Feature B1"
User4 --> "Feature B2"
Admin2 --> "Admin B1"
System1 <-- "Feature A1"
System1 <-- "Feature B1"
System2 <-- "Admin A1"
System2 <-- "Admin B1"
User1 --"collaborates"--> User2
Admin1 --"supervises"--> Admin2
</pre
>
</div>
<script type="module">
import mermaid from './mermaid.esm.mjs';
import layouts from './mermaid-layout-elk.esm.mjs';
const staticBellIconPack = {
prefix: 'fa6-regular',
icons: {
bell: {
body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>',
width: 448,
},
},
width: 512,
height: 512,
};
mermaid.registerIconPacks([
{
name: 'logos',
loader: () =>
fetch('https://unpkg.com/@iconify-json/logos@1/icons.json').then((res) => res.json()),
},
{
name: 'fa',
loader: () =>
fetch('https://unpkg.com/@iconify-json/fa6-solid/icons.json').then((res) => res.json()),
},
]);
mermaid.registerLayoutLoaders(layouts);
mermaid.parseError = function (err, hash) {
console.error('Mermaid error: ', err);
};
window.callback = function () {
alert('A callback was triggered');
};
function callback() {
alert('It worked');
}
await mermaid.initialize({
startOnLoad: false,
theme: 'default',
// theme: 'forest',
// handDrawnSeed: 12,
// 'elk.nodePlacement.strategy': 'NETWORK_SIMPLEX',
layout: 'dagre',
// layout: 'elk',
// layout: 'fixed',
// htmlLabels: false,
flowchart: { titleTopMargin: 10 },
// fontFamily: 'Caveat',
// fontFamily: 'Kalam',
// fontFamily: 'courier',
fontFamily: 'Recursive',
sequence: {
actorFontFamily: 'courier',
noteFontFamily: 'courier',
messageFontFamily: 'courier',
},
kanban: {
htmlLabels: false,
},
fontSize: 16,
logLevel: 0,
securityLevel: 'loose',
callback,
});
// setTimeout(() => {
mermaid.init(undefined, document.querySelectorAll('.mermaid'));
// }, 1000);
mermaid.parseError = function (err, hash) {
console.error('In parse error:');
console.error(err);
};
</script>
</body>
</html>

View File

@@ -4,150 +4,15 @@
>
> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/icons.md](../../packages/mermaid/src/docs/config/icons.md).
# Icon Pack Configuration
# Registering icon pack in mermaid
Mermaid supports icons through Iconify-compatible icon packs. You can register icon packs either **declaratively via configuration** (recommended for most use cases) or **programmatically via JavaScript API** (for advanced/offline scenarios).
The icon packs available can be found at [icones.js.org](https://icones.js.org/).
We use the name defined when registering the icon pack, to override the prefix field of the iconify pack. This allows the user to use shorter names for the icons. It also allows us to load a particular pack only when it is used in a diagram.
## Declarative Configuration (v\<MERMAID_RELEASE_VERSION>+) (Recommended)
The easiest way to use icons in Mermaid is through declarative configuration. This works in browsers, CLI, Live Editor, and headless renders without requiring custom JavaScript.
### Basic Usage
Configure icon packs in your Mermaid config:
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C[End]
```
Or in JavaScript:
```js
mermaid.initialize({
icons: {
packs: {
logos: '@iconify-json/logos@1',
'simple-icons': '@iconify-json/simple-icons@1',
},
},
});
```
### Package Spec Format
Icon packs are specified using **package specs** with version pinning:
- Format: `@scope/package@<version>` or `package@<version>`
- **Must include at least a major version** (e.g., `@iconify-json/logos@1`)
- Minor and patch versions are optional (e.g., `@iconify-json/logos@1.2.3`)
**Important**: Only package specs are supported. Direct URLs are not allowed for security reasons.
### Configuration Options
```yaml
icons:
packs:
# Icon pack configuration
# Key: local name to use in diagrams
# Value: package spec with version
logos: '@iconify-json/logos@1'
'simple-icons': '@iconify-json/simple-icons@1'
# CDN template for resolving package specs
# Must contain ${packageSpec} placeholder
cdnTemplate: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json'
# Maximum file size in MB for icon pack JSON files
# Range: 1-10 MB, default: 5 MB
maxFileSizeMB: 5
# Network timeout in milliseconds for icon pack fetches
# Range: 1000-30000 ms, default: 5000 ms
timeout: 5000
# List of allowed hosts to fetch icons from
allowedHosts:
- 'unpkg.com'
- 'cdn.jsdelivr.net'
- 'npmjs.com'
```
### Security Features
- **Host allowlisting**: Only fetch from hosts in `allowedHosts`
- **Size limits**: Maximum file size enforced via `maxFileSizeMB`
- **Timeouts**: Network requests timeout after specified milliseconds
- **HTTPS only**: All remote fetches occur over HTTPS
- **Version pinning**: Package specs must include version for reproducibility
### Examples
#### Using Custom CDN Template
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
cdnTemplate: "https://unpkg.com/${packageSpec}/icons.json"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:aws', label: 'AWS' }
```
#### Multiple Icon Packs
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
"simple-icons": "@iconify-json/simple-icons@1"
---
flowchart TB
A@{ icon: 'logos:docker', label: 'Docker' } --> B@{ icon: 'simple-icons:github', label: 'GitHub' }
```
#### CLI Usage
Create a `mermaid.json` config file:
```json
{
"icons": {
"packs": {
"logos": "@iconify-json/logos@1"
}
}
}
```
Then use it with the CLI:
```bash
mmdc -i diagram.mmd -o diagram.svg --configFile mermaid.json
```
## Programmatic API (v11.1.0+) (Advanced)
For advanced scenarios or offline use, you can register icon packs programmatically:
### Using JSON File from CDN
Using JSON file directly from CDN:
```js
import mermaid from 'CDN/mermaid.esm.mjs';
mermaid.registerIconPacks([
{
name: 'logos',
@@ -157,15 +22,13 @@ mermaid.registerIconPacks([
]);
```
### Using Packages with Bundler
Install the icon pack:
Using packages and a bundler:
```bash
npm install @iconify-json/logos@1
```
#### With Lazy Loading
With lazy loading
```js
import mermaid from 'mermaid';
@@ -178,39 +41,15 @@ mermaid.registerIconPacks([
]);
```
#### Without Lazy Loading
Without lazy loading
```js
import mermaid from 'mermaid';
import { icons } from '@iconify-json/logos';
mermaid.registerIconPacks([
{
name: icons.prefix, // Use the prefix defined in the icon pack
name: icons.prefix, // To use the prefix defined in the icon pack
icons,
},
]);
```
## Finding Icon Packs
Icon packs available for use can be found at [iconify.design](https://icon-sets.iconify.design/) or [icones.js.org](https://icones.js.org/).
The pack name you register is the **local name** used in diagrams. It can differ from the pack's prefix, allowing you to:
- Use shorter names (e.g., register `@iconify-json/material-design-icons` as `mdi`)
- Load specific packs only when used in diagrams (lazy loading)
## Error Handling
If an icon cannot be found:
- The diagram still renders (non-fatal)
- A fallback icon is displayed
- A warning is logged (visible in CLI stderr or browser console)
## Licensing
Iconify JSON format is MIT licensed, but **individual icons may have varying licenses**. Please verify the licenses of the icon packs you configure before use in production.
Mermaid does **not** redistribute third-party icon assets in the core bundle.

View File

@@ -12,4 +12,4 @@
> `const` **configKeys**: `Set`<`string`>
Defined in: [packages/mermaid/src/defaultConfig.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L292)
Defined in: [packages/mermaid/src/defaultConfig.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L295)

View File

@@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string.
> `optional` **dompurifyConfig**: `Config`
Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213)
Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214)
---
@@ -179,7 +179,7 @@ See <https://developer.mozilla.org/en-US/docs/Web/CSS/font-family>
> `optional` **fontSize**: `number`
Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215)
Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216)
---
@@ -229,14 +229,6 @@ Defined in: [packages/mermaid/src/config.type.ts:124](https://github.com/mermaid
---
### icons?
> `optional` **icons**: `IconsConfig`
Defined in: [packages/mermaid/src/config.type.ts:223](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L223)
---
### journey?
> `optional` **journey**: `JourneyDiagramConfig`
@@ -300,7 +292,7 @@ Defines which main look to use for the diagram.
> `optional` **markdownAutoWrap**: `boolean`
Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216)
Defined in: [packages/mermaid/src/config.type.ts:217](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L217)
---
@@ -432,7 +424,7 @@ Defined in: [packages/mermaid/src/config.type.ts:198](https://github.com/mermaid
> `optional` **suppressErrorRendering**: `boolean`
Defined in: [packages/mermaid/src/config.type.ts:222](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L222)
Defined in: [packages/mermaid/src/config.type.ts:223](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L223)
Suppresses inserting 'Syntax error' diagram in the DOM.
This is useful when you want to control how to handle syntax errors in your application.
@@ -474,11 +466,19 @@ Defined in: [packages/mermaid/src/config.type.ts:196](https://github.com/mermaid
---
### usecase?
> `optional` **usecase**: `UsecaseDiagramConfig`
Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213)
---
### wrap?
> `optional` **wrap**: `boolean`
Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214)
Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215)
---

View File

@@ -12,7 +12,7 @@
> **ParseErrorFunction** = (`err`, `hash?`) => `void`
Defined in: [packages/mermaid/src/Diagram.ts:11](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/Diagram.ts#L11)
Defined in: [packages/mermaid/src/Diagram.ts:10](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/Diagram.ts#L10)
## Parameters

View File

@@ -47,7 +47,7 @@ Try the Ultimate AI, Mermaid, and Visual Diagramming Suite by creating an accoun
## Plans
- **Free** - A free plan that includes six diagrams.
- **Free** - A free plan that includes three diagrams.
- **Pro** - A paid plan that includes unlimited diagrams, access to the collaboration feature, and more.

View File

@@ -21,7 +21,7 @@ title: Animal example
classDiagram
note "From Duck till Zebra"
Animal <|-- Duck
note for Duck "can fly<br>can swim<br>can dive<br>can help in debugging"
note for Duck "can fly\ncan swim\ncan dive\ncan help in debugging"
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
@@ -50,7 +50,7 @@ title: Animal example
classDiagram
note "From Duck till Zebra"
Animal <|-- Duck
note for Duck "can fly<br>can swim<br>can dive<br>can help in debugging"
note for Duck "can fly\ncan swim\ncan dive\ncan help in debugging"
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age

View File

@@ -344,6 +344,7 @@ Below is a comprehensive list of the newly introduced shapes and their correspon
| Display | Curved Trapezoid | `curv-trap` | Represents a display | `curved-trapezoid`, `display` |
| Divided Process | Divided Rectangle | `div-rect` | Divided process shape | `div-proc`, `divided-process`, `divided-rectangle` |
| Document | Document | `doc` | Represents a document | `doc`, `document` |
| Ellipse | Ellipse | `ellipse` | Ellipse shape | `oval` |
| Event | Rounded Rectangle | `rounded` | Represents an event | `event` |
| Extract | Triangle | `tri` | Extraction process | `extract`, `triangle` |
| Fork/Join | Filled Rectangle | `fork` | Fork or join in process flow | `join` |

View File

@@ -15,8 +15,10 @@
"git graph"
],
"scripts": {
"build": "pnpm build:esbuild && pnpm build:types",
"build": "pnpm antlr:generate && pnpm build:esbuild && pnpm build:types",
"build:esbuild": "pnpm run -r clean && tsx .esbuild/build.ts",
"antlr:generate": "tsx scripts/antlr-generate.mts",
"antlr:watch": "tsx scripts/antlr-watch.mts",
"build:mermaid": "pnpm build:esbuild --mermaid",
"build:viz": "pnpm build:esbuild --visualize",
"build:types": "pnpm --filter mermaid types:build-config && tsx .build/types.ts",

View File

@@ -0,0 +1,51 @@
import type { DiagramMetadata } from '../types.js';
export default {
id: 'usecase',
name: 'Use Case Diagram',
description: 'Visualize system functionality and user interactions',
examples: [
{
title: 'Basic Use Case',
isDefault: true,
code: `usecase
actor User
actor Admin
User --> (Login)
User --> (View Profile)
Admin --> (Manage Users)
Admin --> (View Reports)`,
},
{
title: 'System Boundary',
code: `usecase
actor Customer
actor Support
SystemBoundary@{ type: rect } "E-commerce System" {
Customer --> (Browse Products)
Customer --> (Place Order)
Customer --> (Track Order)
}
SystemBoundary@{ type: package } "Admin Panel" {
Support --> (Process Orders)
Support --> (Handle Returns)
}`,
},
{
title: 'Actor Relationships',
code: `usecase
actor Developer1
actor Developer2
actor Manager
Developer1 --> (Write Code)
Developer2 --> (Review Code)
Manager --> (Approve Release)
Developer1 --> Developer2
Manager --> Developer1`,
},
],
} satisfies DiagramMetadata;

View File

@@ -21,6 +21,7 @@ import quadrantChart from './examples/quadrant-chart.js';
import packetDiagram from './examples/packet.js';
import blockDiagram from './examples/block.js';
import treemapDiagram from './examples/treemap.js';
import usecaseDiagram from './examples/usecase.js';
export const diagramData: DiagramMetadata[] = [
flowChart,
@@ -45,4 +46,5 @@ export const diagramData: DiagramMetadata[] = [
packetDiagram,
blockDiagram,
treemapDiagram,
usecaseDiagram,
];

View File

@@ -1,30 +1,5 @@
# mermaid
## 11.12.1
### Patch Changes
- [#7107](https://github.com/mermaid-js/mermaid/pull/7107) [`cbf8946`](https://github.com/mermaid-js/mermaid/commit/cbf89462acecac7a06f19843e8d48cb137df0753) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - fix: Updated the dependency dagre-d3-es to 7.0.13 to fix GHSA-cc8p-78qf-8p7q
## 11.12.0
### Minor Changes
- [#6921](https://github.com/mermaid-js/mermaid/pull/6921) [`764b315`](https://github.com/mermaid-js/mermaid/commit/764b315dc16d0359add7c6b8e3ef7592e9bdc09c) Thanks [@quilicicf](https://github.com/quilicicf)! - feat: Add IDs in architecture diagrams
### Patch Changes
- [#6950](https://github.com/mermaid-js/mermaid/pull/6950) [`a957908`](https://github.com/mermaid-js/mermaid/commit/a9579083bfba367a4f4678547ec37ed7b61b9f5b) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: Fix mindmap rendering in docs and apply tidytree layout
- [#6826](https://github.com/mermaid-js/mermaid/pull/6826) [`1d36810`](https://github.com/mermaid-js/mermaid/commit/1d3681053b9168354e48e5763023b6305cd1ca72) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Ensure edge label color is applied when using classDef with edge IDs
- [#6945](https://github.com/mermaid-js/mermaid/pull/6945) [`d318f1a`](https://github.com/mermaid-js/mermaid/commit/d318f1a13cd7429334a29c70e449074ec1cb9f68) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Resolve gantt chart crash due to invalid array length
- [#6918](https://github.com/mermaid-js/mermaid/pull/6918) [`cfe9238`](https://github.com/mermaid-js/mermaid/commit/cfe9238882cbe95416db1feea3112456a71b6aaf) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: revert marked dependency from ^15.0.7 to ^16.0.0
- Reverted marked package version to ^16.0.0 for better compatibility
- This is a dependency update that maintains API compatibility
- All tests pass with the updated version
## 11.11.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "mermaid",
"version": "11.12.1",
"version": "11.11.0",
"description": "Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.",
"type": "module",
"module": "./dist/mermaid.core.mjs",
@@ -34,6 +34,8 @@
"scripts": {
"clean": "rimraf dist",
"dev": "pnpm -w dev",
"antlr:generate": "tsx ../../scripts/antlr-generate.mts",
"antlr:watch": "tsx ../../scripts/antlr-watch.mts",
"docs:code": "typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup",
"docs:build": "rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts",
"docs:verify": "pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify",
@@ -47,7 +49,7 @@
"docs:verify-version": "tsx scripts/update-release-version.mts --verify",
"types:build-config": "tsx scripts/create-types-from-json-schema.mts",
"types:verify-config": "tsx scripts/create-types-from-json-schema.mts --verify",
"checkCircle": "npx madge --circular ./src",
"checkCircle": "npx madge --circular ./src --exclude '.*generated.*'",
"prepublishOnly": "pnpm docs:verify-version"
},
"repository": {
@@ -71,12 +73,13 @@
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "workspace:^",
"@types/d3": "^7.4.3",
"antlr4ng": "^3.0.7",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dagre-d3-es": "7.0.11",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",

View File

@@ -189,6 +189,7 @@ This Markdown should be kept.
| Display | Curved Trapezoid | \`curv-trap\` | Represents a display | \`curved-trapezoid\`, \`display\` |
| Divided Process | Divided Rectangle | \`div-rect\` | Divided process shape | \`div-proc\`, \`divided-process\`, \`divided-rectangle\` |
| Document | Document | \`doc\` | Represents a document | \`doc\`, \`document\` |
| Ellipse | Ellipse | \`ellipse\` | Ellipse shape | \`oval\` |
| Event | Rounded Rectangle | \`rounded\` | Represents an event | \`event\` |
| Extract | Triangle | \`tri\` | Extraction process | \`extract\`, \`triangle\` |
| Fork/Join | Filled Rectangle | \`fork\` | Fork or join in process flow | \`join\` |

View File

@@ -1,11 +1,10 @@
import * as configApi from './config.js';
import { detectType, getDiagramLoader } from './diagram-api/detectType.js';
import { getDiagram, registerDiagram } from './diagram-api/diagramAPI.js';
import type { DiagramDefinition, DiagramMetadata } from './diagram-api/types.js';
import { detectType, getDiagramLoader } from './diagram-api/detectType.js';
import { UnknownDiagramError } from './errors.js';
import { registerDiagramIconPacks } from './rendering-util/icons.js';
import type { DetailedError } from './utils.js';
import { encodeEntities } from './utils.js';
import type { DetailedError } from './utils.js';
import type { DiagramDefinition, DiagramMetadata } from './diagram-api/types.js';
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
export type ParseErrorFunction = (err: string | DetailedError | unknown, hash?: any) => void;
@@ -42,7 +41,6 @@ export class Diagram {
if (metadata.title) {
db.setDiagramTitle?.(metadata.title);
}
registerDiagramIconPacks(config.icons);
await parser.parse(text);
return new Diagram(type, text, db, parser, renderer);
}

View File

@@ -210,6 +210,7 @@ export interface MermaidConfig {
packet?: PacketDiagramConfig;
block?: BlockDiagramConfig;
radar?: RadarDiagramConfig;
usecase?: UsecaseDiagramConfig;
dompurifyConfig?: DOMPurifyConfiguration;
wrap?: boolean;
fontSize?: number;
@@ -220,7 +221,6 @@ export interface MermaidConfig {
*
*/
suppressErrorRendering?: boolean;
icons?: IconsConfig;
}
/**
* The object containing configurations specific for flowcharts
@@ -1625,44 +1625,48 @@ export interface RadarDiagramConfig extends BaseDiagramConfig {
curveTension?: number;
}
/**
* Configuration for icon packs and CDN template.
* Enables icons in browsers and CLI/headless renders without custom JavaScript.
*
* The object containing configurations specific for usecase diagrams.
*
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "IconsConfig".
* via the `definition` "UsecaseDiagramConfig".
*/
export interface IconsConfig {
export interface UsecaseDiagramConfig extends BaseDiagramConfig {
/**
* Icon pack configuration. Key is the local pack name.
* Value is a package spec with version that complies with Iconify standards.
* Package specs must include at least a major version (e.g., '@iconify-json/logos@1').
*
* Font size for actor labels
*/
packs?: {
[k: string]: string;
};
actorFontSize?: number;
/**
* URL template for resolving package specs (must contain ${packageSpec}).
* Used to build URLs for package specs in icons.packs.
*
* Font family for actor labels
*/
cdnTemplate?: string;
actorFontFamily?: string;
/**
* Maximum file size in MB for icon pack JSON files.
*
* Font weight for actor labels
*/
maxFileSizeMB?: number;
actorFontWeight?: string;
/**
* Network timeout in milliseconds for icon pack fetches.
*
* Font size for usecase labels
*/
timeout?: number;
usecaseFontSize?: number;
/**
* List of allowed hosts to fetch icons from
*
* Font family for usecase labels
*/
allowedHosts?: string[];
usecaseFontFamily?: string;
/**
* Font weight for usecase labels
*/
usecaseFontWeight?: string;
/**
* Margin around actors
*/
actorMargin?: number;
/**
* Margin around use cases
*/
usecaseMargin?: number;
/**
* Padding around the entire diagram
*/
diagramPadding?: number;
}
/**
* This interface was referenced by `MermaidConfig`'s JSON-Schema

View File

@@ -264,6 +264,9 @@ const config: RequiredDeep<MermaidConfig> = {
radar: {
...defaultConfigJson.radar,
},
usecase: {
...defaultConfigJson.usecase,
},
treemap: {
useMaxWidth: true,
padding: 10,

View File

@@ -28,6 +28,7 @@ import architecture from '../diagrams/architecture/architectureDetector.js';
import { registerLazyLoadedDiagrams } from './detectType.js';
import { registerDiagram } from './diagramAPI.js';
import { treemap } from '../diagrams/treemap/detector.js';
import { usecase } from '../diagrams/usecase/usecaseDetector.js';
import '../type.d.ts';
let hasLoadedDiagrams = false;
@@ -101,6 +102,7 @@ export const addDiagrams = () => {
xychart,
block,
radar,
treemap
treemap,
usecase
);
};

View File

@@ -17,7 +17,6 @@ import type {
ClassRelation,
ClassNode,
ClassNote,
ClassNoteMap,
ClassMap,
NamespaceMap,
NamespaceNode,
@@ -34,16 +33,15 @@ const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig());
export class ClassDB implements DiagramDB {
private relations: ClassRelation[] = [];
private classes: ClassMap = new Map<string, ClassNode>();
private classes = new Map<string, ClassNode>();
private readonly styleClasses = new Map<string, StyleClass>();
private notes: ClassNoteMap = new Map<string, ClassNote>();
private notes: ClassNote[] = [];
private interfaces: Interface[] = [];
// private static classCounter = 0;
private namespaces = new Map<string, NamespaceNode>();
private namespaceCounter = 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
private functions: Function[] = [];
private functions: any[] = [];
constructor() {
this.functions.push(this.setupToolTips.bind(this));
@@ -126,7 +124,7 @@ export class ClassDB implements DiagramDB {
annotations: [],
styles: [],
domId: MERMAID_DOM_ID_PREFIX + name + '-' + classCounter,
});
} as ClassNode);
classCounter++;
}
@@ -157,12 +155,12 @@ export class ClassDB implements DiagramDB {
public clear() {
this.relations = [];
this.classes = new Map<string, ClassNode>();
this.notes = new Map<string, ClassNote>();
this.classes = new Map();
this.notes = [];
this.interfaces = [];
this.functions = [];
this.functions.push(this.setupToolTips.bind(this));
this.namespaces = new Map<string, NamespaceNode>();
this.namespaces = new Map();
this.namespaceCounter = 0;
this.direction = 'TB';
commonClear();
@@ -180,12 +178,7 @@ export class ClassDB implements DiagramDB {
return this.relations;
}
public getNote(id: string | number): ClassNote {
const key = typeof id === 'number' ? `note${id}` : id;
return this.notes.get(key)!;
}
public getNotes(): ClassNoteMap {
public getNotes() {
return this.notes;
}
@@ -286,19 +279,16 @@ export class ClassDB implements DiagramDB {
}
}
public addNote(text: string, className: string): string {
const index = this.notes.size;
public addNote(text: string, className: string) {
const note = {
id: `note${index}`,
id: `note${this.notes.length}`,
class: className,
text: text,
index: index,
};
this.notes.set(note.id, note);
return note.id;
this.notes.push(note);
}
public cleanupLabel(label: string): string {
public cleanupLabel(label: string) {
if (label.startsWith(':')) {
label = label.substring(1);
}
@@ -364,7 +354,7 @@ export class ClassDB implements DiagramDB {
});
}
public getTooltip(id: string, namespace?: string): string | undefined {
public getTooltip(id: string, namespace?: string) {
if (namespace && this.namespaces.has(namespace)) {
return this.namespaces.get(namespace)!.classes.get(id)!.tooltip;
}
@@ -544,11 +534,10 @@ export class ClassDB implements DiagramDB {
this.namespaces.set(id, {
id: id,
classes: new Map<string, ClassNode>(),
notes: new Map<string, ClassNote>(),
children: new Map<string, NamespaceNode>(),
classes: new Map(),
children: {},
domId: MERMAID_DOM_ID_PREFIX + id + '-' + this.namespaceCounter,
});
} as NamespaceNode);
this.namespaceCounter++;
}
@@ -566,23 +555,16 @@ export class ClassDB implements DiagramDB {
*
* @param id - ID of the namespace to add
* @param classNames - IDs of the class to add
* @param noteNames - IDs of the notes to add
* @public
*/
public addClassesToNamespace(id: string, classNames: string[], noteNames: string[]) {
public addClassesToNamespace(id: string, classNames: string[]) {
if (!this.namespaces.has(id)) {
return;
}
for (const name of classNames) {
const { className } = this.splitClassNameAndType(name);
const classNode = this.getClass(className);
classNode.parent = id;
this.namespaces.get(id)!.classes.set(className, classNode);
}
for (const noteName of noteNames) {
const noteNode = this.getNote(noteName);
noteNode.parent = id;
this.namespaces.get(id)!.notes.set(noteName, noteNode);
this.classes.get(className)!.parent = id;
this.namespaces.get(id)!.classes.set(className, this.classes.get(className)!);
}
}
@@ -635,32 +617,36 @@ export class ClassDB implements DiagramDB {
const edges: Edge[] = [];
const config = getConfig();
for (const namespace of this.namespaces.values()) {
const node: Node = {
id: namespace.id,
label: namespace.id,
isGroup: true,
padding: config.class!.padding ?? 16,
// parent node must be one of [rect, roundedWithTitle, noteGroup, divider]
shape: 'rect',
cssStyles: [],
look: config.look,
};
nodes.push(node);
for (const namespaceKey of this.namespaces.keys()) {
const namespace = this.namespaces.get(namespaceKey);
if (namespace) {
const node: Node = {
id: namespace.id,
label: namespace.id,
isGroup: true,
padding: config.class!.padding ?? 16,
// parent node must be one of [rect, roundedWithTitle, noteGroup, divider]
shape: 'rect',
cssStyles: ['fill: none', 'stroke: black'],
look: config.look,
};
nodes.push(node);
}
}
for (const classNode of this.classes.values()) {
const node: Node = {
...classNode,
type: undefined,
isGroup: false,
parentId: classNode.parent,
look: config.look,
};
nodes.push(node);
for (const classKey of this.classes.keys()) {
const classNode = this.classes.get(classKey);
if (classNode) {
const node = classNode as unknown as Node;
node.parentId = classNode.parent;
node.look = config.look;
nodes.push(node);
}
}
for (const note of this.notes.values()) {
let cnt = 0;
for (const note of this.notes) {
cnt++;
const noteNode: Node = {
id: note.id,
label: note.text,
@@ -674,15 +660,14 @@ export class ClassDB implements DiagramDB {
`stroke: ${config.themeVariables.noteBorderColor}`,
],
look: config.look,
parentId: note.parent,
};
nodes.push(noteNode);
const noteClassId = this.classes.get(note.class)?.id;
const noteClassId = this.classes.get(note.class)?.id ?? '';
if (noteClassId) {
const edge: Edge = {
id: `edgeNote${note.index}`,
id: `edgeNote${cnt}`,
start: note.id,
end: noteClassId,
type: 'normal',
@@ -712,7 +697,7 @@ export class ClassDB implements DiagramDB {
nodes.push(interfaceNode);
}
let cnt = 0;
cnt = 0;
for (const classRelation of this.relations) {
cnt++;
const edge: Edge = {

View File

@@ -417,7 +417,7 @@ class C13["With Città foreign language"]
note "This is a keyword: ${keyword}. It truly is."
`;
parser.parse(str);
expect(classDb.getNote(0).text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
});
it.each(keywords)(
@@ -427,7 +427,7 @@ class C13["With Città foreign language"]
note "${keyword}"`;
parser.parse(str);
expect(classDb.getNote(0).text).toEqual(`${keyword}`);
expect(classDb.getNotes()[0].text).toEqual(`${keyword}`);
}
);
@@ -441,7 +441,7 @@ class C13["With Città foreign language"]
`;
parser.parse(str);
expect(classDb.getNote(0).text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
});
it.each(keywords)(
@@ -456,7 +456,7 @@ class C13["With Città foreign language"]
`;
parser.parse(str);
expect(classDb.getNote(0).text).toEqual(`${keyword}`);
expect(classDb.getNotes()[0].text).toEqual(`${keyword}`);
}
);

View File

@@ -8,7 +8,7 @@ import utils, { getEdgeId } from '../../utils.js';
import { interpolateToCurve, getStylesFromArray } from '../../utils.js';
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import common from '../common/common.js';
import type { ClassRelation, ClassMap, ClassNoteMap, NamespaceMap } from './classTypes.js';
import type { ClassRelation, ClassNote, ClassMap, NamespaceMap } from './classTypes.js';
import type { EdgeData } from '../../types.js';
const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig());
@@ -65,9 +65,6 @@ export const addNamespaces = function (
g.setNode(vertex.id, node);
addClasses(vertex.classes, g, _id, diagObj, vertex.id);
const classes: ClassMap = diagObj.db.getClasses();
const relations: ClassRelation[] = diagObj.db.getRelations();
addNotes(vertex.notes, g, relations.length + 1, classes, vertex.id);
log.info('setNode', node);
});
@@ -147,74 +144,69 @@ export const addClasses = function (
* @param classes - Classes
*/
export const addNotes = function (
notes: ClassNoteMap,
notes: ClassNote[],
g: graphlib.Graph,
startEdgeId: number,
classes: ClassMap,
parent?: string
classes: ClassMap
) {
log.info(notes);
[...notes.values()]
.filter((note) => note.parent === parent)
.forEach(function (vertex) {
const cssNoteStr = '';
notes.forEach(function (note, i) {
const vertex = note;
const styles = { labelStyle: '', style: '' };
const cssNoteStr = '';
const vertexText = vertex.text;
const styles = { labelStyle: '', style: '' };
const radius = 0;
const shape = 'note';
const node = {
labelStyle: styles.labelStyle,
shape: shape,
labelText: sanitizeText(vertexText),
noteData: vertex,
rx: radius,
ry: radius,
class: cssNoteStr,
style: styles.style,
id: vertex.id,
domId: vertex.id,
tooltip: '',
type: 'note',
// TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release
padding: getConfig().flowchart?.padding ?? getConfig().class?.padding,
};
g.setNode(vertex.id, node);
log.info('setNode', node);
const vertexText = vertex.text;
if (parent) {
g.setParent(vertex.id, parent);
}
const radius = 0;
const shape = 'note';
const node = {
labelStyle: styles.labelStyle,
shape: shape,
labelText: sanitizeText(vertexText),
noteData: vertex,
rx: radius,
ry: radius,
class: cssNoteStr,
style: styles.style,
id: vertex.id,
domId: vertex.id,
tooltip: '',
type: 'note',
// TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release
padding: getConfig().flowchart?.padding ?? getConfig().class?.padding,
};
g.setNode(vertex.id, node);
log.info('setNode', node);
if (!vertex.class || !classes.has(vertex.class)) {
return;
}
const edgeId = startEdgeId + vertex.index;
if (!vertex.class || !classes.has(vertex.class)) {
return;
}
const edgeId = startEdgeId + i;
const edgeData: EdgeData = {
id: `edgeNote${edgeId}`,
//Set relationship style and line type
classes: 'relation',
pattern: 'dotted',
// Set link type for rendering
arrowhead: 'none',
//Set edge extra labels
startLabelRight: '',
endLabelLeft: '',
//Set relation arrow types
arrowTypeStart: 'none',
arrowTypeEnd: 'none',
style: 'fill:none',
labelStyle: '',
curve: interpolateToCurve(conf.curve, curveLinear),
};
const edgeData: EdgeData = {
id: `edgeNote${edgeId}`,
//Set relationship style and line type
classes: 'relation',
pattern: 'dotted',
// Set link type for rendering
arrowhead: 'none',
//Set edge extra labels
startLabelRight: '',
endLabelLeft: '',
//Set relation arrow types
arrowTypeStart: 'none',
arrowTypeEnd: 'none',
style: 'fill:none',
labelStyle: '',
curve: interpolateToCurve(conf.curve, curveLinear),
};
// Add the edge to the graph
g.setEdge(vertex.id, vertex.class, edgeData, edgeId);
});
// Add the edge to the graph
g.setEdge(vertex.id, vertex.class, edgeData, edgeId);
});
};
/**
@@ -337,7 +329,7 @@ export const draw = async function (text: string, id: string, _version: string,
const namespaces: NamespaceMap = diagObj.db.getNamespaces();
const classes: ClassMap = diagObj.db.getClasses();
const relations: ClassRelation[] = diagObj.db.getRelations();
const notes: ClassNoteMap = diagObj.db.getNotes();
const notes: ClassNote[] = diagObj.db.getNotes();
log.info(relations);
addNamespaces(namespaces, g, id, diagObj);
addClasses(classes, g, id, diagObj);

View File

@@ -206,7 +206,7 @@ export const draw = function (text, id, _version, diagObj) {
);
});
const notes = diagObj.db.getNotes().values();
const notes = diagObj.db.getNotes();
notes.forEach(function (note) {
log.debug(`Adding note: ${JSON.stringify(note)}`);
const node = svgDraw.drawNote(diagram, note, conf, diagObj);

View File

@@ -5,7 +5,7 @@ export interface ClassNode {
id: string;
type: string;
label: string;
shape: 'classBox';
shape: string;
text: string;
cssClasses: string;
methods: ClassMember[];
@@ -149,8 +149,6 @@ export interface ClassNote {
id: string;
class: string;
text: string;
index: number;
parent?: string;
}
export interface ClassRelation {
@@ -179,7 +177,6 @@ export interface NamespaceNode {
id: string;
domId: string;
classes: ClassMap;
notes: ClassNoteMap;
children: NamespaceMap;
}
@@ -190,5 +187,4 @@ export interface StyleClass {
}
export type ClassMap = Map<string, ClassNode>;
export type ClassNoteMap = Map<string, ClassNote>;
export type NamespaceMap = Map<string, NamespaceNode>;

View File

@@ -275,8 +275,8 @@ statement
;
namespaceStatement
: namespaceIdentifier STRUCT_START classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $3[0], $3[1]); }
| namespaceIdentifier STRUCT_START NEWLINE classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $4[0], $4[1]); }
: namespaceIdentifier STRUCT_START classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $3); }
| namespaceIdentifier STRUCT_START NEWLINE classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $4); }
;
namespaceIdentifier
@@ -284,12 +284,9 @@ namespaceIdentifier
;
classStatements
: classStatement {$$=[[$1], []]}
| classStatement NEWLINE {$$=[[$1], []]}
| classStatement NEWLINE classStatements {$3[0].unshift($1); $$=$3}
| noteStatement {$$=[[], [$1]]}
| noteStatement NEWLINE {$$=[[], [$1]]}
| noteStatement NEWLINE classStatements {$3[1].unshift($1); $$=$3}
: classStatement {$$=[$1]}
| classStatement NEWLINE {$$=[$1]}
| classStatement NEWLINE classStatements {$3.unshift($1); $$=$3}
;
classStatement
@@ -336,8 +333,8 @@ relationStatement
;
noteStatement
: NOTE_FOR className noteText { $$ = yy.addNote($3, $2); }
| NOTE noteText { $$ = yy.addNote($2); }
: NOTE_FOR className noteText { yy.addNote($3, $2); }
| NOTE noteText { yy.addNote($2); }
;
classDefStatement

View File

@@ -13,30 +13,6 @@ const getStyles = (options) =>
}
.cluster-label text {
fill: ${options.titleColor};
}
.cluster-label span {
color: ${options.titleColor};
}
.cluster-label span p {
background-color: transparent;
}
.cluster rect {
fill: ${options.clusterBkg};
stroke: ${options.clusterBorder};
stroke-width: 1px;
}
.cluster text {
fill: ${options.titleColor};
}
.cluster span {
color: ${options.titleColor};
}
.nodeLabel, .edgeLabel {
color: ${options.classText};
}

View File

@@ -962,7 +962,6 @@ You have to call mermaid.initialize.`
case 'round':
return 'roundedRect';
case 'ellipse':
// @ts-expect-error -- Ellipses are broken, see https://github.com/mermaid-js/mermaid/issues/5976
return 'ellipse';
default:
return vertex.type;

View File

@@ -140,7 +140,6 @@ that id.
.*direction\s+BT[^\n]* return 'direction_bt';
.*direction\s+RL[^\n]* return 'direction_rl';
.*direction\s+LR[^\n]* return 'direction_lr';
.*direction\s+TD[^\n]* return 'direction_td';
[^\s\"]+\@(?=[^\{\"]) { return 'LINK_ID'; }
[0-9]+ return 'NUM';
@@ -627,8 +626,6 @@ direction
{ $$={stmt:'dir', value:'RL'};}
| direction_lr
{ $$={stmt:'dir', value:'LR'};}
| direction_td
{ $$={stmt:'dir', value:'TD'};}
;
%%

View File

@@ -309,21 +309,4 @@ describe('when parsing subgraphs', function () {
expect(subgraphA.nodes).toContain('a');
expect(subgraphA.nodes).not.toContain('c');
});
it('should correctly parse direction TD inside a subgraph', function () {
const res = flow.parser.parse(`
graph LR
subgraph WithTD
direction TD
A1 --> A2
end
`);
const subgraphs = flow.parser.yy.getSubGraphs();
expect(subgraphs.length).toBe(1);
const subgraph = subgraphs[0];
expect(subgraph.dir).toBe('TD');
expect(subgraph.nodes).toContain('A1');
expect(subgraph.nodes).toContain('A2');
});
});

View File

@@ -16,7 +16,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
const svgWidth = bitWidth * bitsPerRow + 2;
const svg: SVG = selectSvgElement(id);
svg.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
svg.attr('viewbox', `0 0 ${svgWidth} ${svgHeight}`);
configureSvgSize(svg, svgHeight, svgWidth, config.useMaxWidth);
for (const [word, packet] of words.entries()) {

View File

@@ -2,7 +2,6 @@ import type { Diagram } from '../../Diagram.js';
import type { RadarDiagramConfig } from '../../config.type.js';
import type { DiagramRenderer, DrawDefinition, SVG, SVGGroup } from '../../diagram-api/types.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import { configureSvgSize } from '../../setupGraphViewbox.js';
import type { RadarDB, RadarAxis, RadarCurve } from './types.js';
const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
@@ -54,9 +53,11 @@ const drawFrame = (svg: SVG, config: Required<RadarDiagramConfig>): SVGGroup =>
x: config.marginLeft + config.width / 2,
y: config.marginTop + config.height / 2,
};
configureSvgSize(svg, totalHeight, totalWidth, config.useMaxWidth ?? true);
svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
// Initialize the SVG
svg
.attr('viewbox', `0 0 ${totalWidth} ${totalHeight}`)
.attr('width', totalWidth)
.attr('height', totalHeight);
// g element to center the radar chart
return svg.append('g').attr('transform', `translate(${center.x}, ${center.y})`);
};

View File

@@ -32,14 +32,13 @@
<CONFIG>[^\}]+ { return 'CONFIG_CONTENT'; }
<CONFIG>\} { this.popState(); this.popState(); return 'CONFIG_END'; }
<ID>[^\<->\->:\n,;@\s]+(?=\@\{) { yytext = yytext.trim(); return 'ACTOR'; }
<ID>[^<>:\n,;@\s]+(?=\s+as\s) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; }
<ID>[^<>:\n,;@]+(?=\s*[\n;#]|$) { yytext = yytext.trim(); this.popState(); return 'ACTOR'; }
<ID>[^<>:\n,;@]*\<[^\n]* { this.popState(); return 'INVALID'; }
<ID>[^\<->\->:\n,;@]+?([\-]*[^\<->\->:\n,;@]+?)*?(?=((?!\n)\s)+"as"(?!\n)\s|[#\n;]|$) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; }
"box" { this.begin('LINE'); return 'box'; }
"participant" { this.begin('ID'); return 'participant'; }
"actor" { this.begin('ID'); return 'participant_actor'; }
"create" return 'create';
"destroy" { this.begin('ID'); return 'destroy'; }
<ID>[^<\->\->:\n,;]+?([\-]*[^<\->\->:\n,;]+?)*?(?=((?!\n)\s)+"as"(?!\n)\s|[#\n;]|$) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; }
<ALIAS>"as" { this.popState(); this.popState(); this.begin('LINE'); return 'AS'; }
<ALIAS>(?:) { this.popState(); this.popState(); return 'NEWLINE'; }
"loop" { this.begin('LINE'); return 'loop'; }
@@ -146,7 +145,6 @@ line
: SPACE statement { $$ = $2 }
| statement { $$ = $1 }
| NEWLINE { $$=[]; }
| INVALID { $$=[]; }
;
box_section
@@ -413,4 +411,4 @@ text2
: TXT {$$ = yy.parseMessage($1.trim().substring(1)) }
;
%%
%%

View File

@@ -2609,17 +2609,5 @@ Bob->>Alice:Got it!
expect(actors.get('E').type).toBe('entity');
expect(actors.get('E').description).toBe('E');
});
it('should handle fail parsing when alias token causes conflicts in participant definition', async () => {
let error = false;
try {
await Diagram.fromText(`
sequenceDiagram
participant SAS MyServiceWithMoreThan20Chars <br> service decription
`);
} catch (e) {
error = true;
}
expect(error).toBe(true);
});
});
});

View File

@@ -0,0 +1,90 @@
import { BaseErrorListener } from 'antlr4ng';
import type { RecognitionException, Recognizer } from 'antlr4ng';
/**
* Custom error listener for ANTLR usecase parser
* Captures syntax errors and provides detailed error messages
*/
export class UsecaseErrorListener extends BaseErrorListener {
private errors: { line: number; column: number; message: string; offendingSymbol?: any }[] = [];
syntaxError(
_recognizer: Recognizer<any>,
offendingSymbol: any,
line: number,
charPositionInLine: number,
message: string,
_e: RecognitionException | null
): void {
this.errors.push({
line,
column: charPositionInLine,
message,
offendingSymbol,
});
}
reportAmbiguity(): void {
// Optional: handle ambiguity reports
}
reportAttemptingFullContext(): void {
// Optional: handle full context attempts
}
reportContextSensitivity(): void {
// Optional: handle context sensitivity reports
}
getErrors(): { line: number; column: number; message: string; offendingSymbol?: any }[] {
return this.errors;
}
hasErrors(): boolean {
return this.errors.length > 0;
}
clear(): void {
this.errors = [];
}
/**
* Create a detailed error with JISON-compatible hash property
*/
createDetailedError(): Error {
if (this.errors.length === 0) {
return new Error('Unknown parsing error');
}
const firstError = this.errors[0];
const message = `Parse error on line ${firstError.line}: ${firstError.message}`;
const error = new Error(message);
// Add hash property for JISON compatibility
Object.assign(error, {
hash: {
line: firstError.line,
loc: {
first_line: firstError.line,
last_line: firstError.line,
first_column: firstError.column,
last_column: firstError.column,
},
text: firstError.offendingSymbol?.text ?? '',
token: firstError.offendingSymbol?.text ?? '',
expected: [],
},
});
return error;
}
/**
* Get all error messages as a single string
*/
getErrorMessages(): string {
return this.errors
.map((error) => `Line ${error.line}:${error.column} - ${error.message}`)
.join('\n');
}
}

View File

@@ -0,0 +1,65 @@
lexer grammar UsecaseLexer;
// Keywords
ACTOR: 'actor';
SYSTEM_BOUNDARY: 'systemBoundary';
END: 'end';
DIRECTION: 'direction';
CLASS_DEF: 'classDef';
CLASS: 'class';
STYLE: 'style';
USECASE: 'usecase';
// Direction keywords
TB: 'TB';
TD: 'TD';
BT: 'BT';
RL: 'RL';
LR: 'LR';
// System boundary types
PACKAGE: 'package';
RECT: 'rect';
TYPE: 'type';
// Arrow types (order matters - longer patterns first)
SOLID_ARROW: '-->';
BACK_ARROW: '<--';
CIRCLE_ARROW: '--o';
CIRCLE_ARROW_REVERSED: 'o--';
CROSS_ARROW: '--x';
CROSS_ARROW_REVERSED: 'x--';
LINE_SOLID: '--';
// Symbols
COMMA: ',';
AT: '@';
LBRACE: '{';
RBRACE: '}';
COLON: ':';
LPAREN: '(';
RPAREN: ')';
CLASS_SEPARATOR: ':::';
// Hash color (must come before HASH to avoid conflicts)
HASH_COLOR: '#' [a-fA-F0-9]+;
// Number with optional unit
NUMBER: [0-9]+ ('.' [0-9]+)? ([a-zA-Z]+)?;
// Identifier
IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*;
// String literals
STRING: '"' (~["\r\n])* '"' | '\'' (~['\r\n])* '\'';
// These tokens are defined last so they have lowest priority
// This ensures arrow tokens like '-->' are matched before DASH
DASH: '-';
DOT: '.';
PERCENT: '%';
// Whitespace and newlines
NEWLINE: [\r\n]+;
WS: [ \t]+ -> skip;

View File

@@ -0,0 +1,429 @@
import type { ParseTreeListener } from 'antlr4ng';
import { UsecaseParserCore } from './UsecaseParserCore.js';
import { log } from '../../../../logger.js';
import type { UsecaseDB } from '../../usecaseTypes.js';
/**
* Listener implementation that builds the usecase model
* Extends the core logic to ensure consistency with Visitor pattern
*/
export class UsecaseListener extends UsecaseParserCore implements ParseTreeListener {
constructor(db: UsecaseDB) {
super(db);
log.debug('👂 UsecaseListener: Constructor called');
}
// Standard ParseTreeListener methods
enterEveryRule = (ctx: any) => {
if (this.getEnvVar('ANTLR_DEBUG') === 'true') {
const ruleName = ctx.constructor.name;
log.debug('🔍 UsecaseListener: Entering rule:', ruleName);
}
};
exitEveryRule = (ctx: any) => {
if (this.getEnvVar('ANTLR_DEBUG') === 'true') {
const ruleName = ctx.constructor.name;
log.debug('🔍 UsecaseListener: Exiting rule:', ruleName);
}
};
visitTerminal = (_node: any) => {
// Optional: Handle terminal nodes
};
visitErrorNode = (_node: any) => {
log.debug('❌ UsecaseListener: Error node encountered');
};
// Actor statement
exitActorName = (ctx: any) => {
let actorName = '';
if (ctx.IDENTIFIER()) {
actorName = ctx.IDENTIFIER().getText();
} else if (ctx.STRING()) {
actorName = this.extractString(ctx.STRING().getText());
}
const actorId = this.generateId(actorName);
// Process metadata if present
let metadata: Record<string, string> | undefined;
if (ctx.metadata()) {
metadata = this.extractMetadata(ctx.metadata());
}
this.processActorStatement(actorId, actorName, metadata);
};
// Relationship statement
exitRelationshipStatement = (ctx: any) => {
let from = '';
let to = '';
let arrowType = 0;
let label: string | undefined;
// Get entity names
const entityNames = ctx.entityName();
if (entityNames && entityNames.length >= 2) {
from = this.extractEntityName(entityNames[0]);
to = this.extractEntityName(entityNames[1]);
} else if (ctx.actorDeclaration()) {
from = this.extractActorDeclaration(ctx.actorDeclaration());
if (entityNames && entityNames.length >= 1) {
to = this.extractEntityName(entityNames[0]);
}
}
// Get arrow type
const arrow = ctx.arrow();
if (arrow) {
const arrowResult = this.extractArrow(arrow);
arrowType = arrowResult.type;
label = arrowResult.label;
}
this.processRelationship(from, to, arrowType, label);
};
// System boundary statement
enterSystemBoundaryStatement = (ctx: any) => {
const boundaryName = ctx.systemBoundaryName();
let boundaryId = '';
let boundaryNameText = '';
if (boundaryName) {
if (boundaryName.IDENTIFIER()) {
boundaryNameText = boundaryName.IDENTIFIER().getText();
} else if (boundaryName.STRING()) {
boundaryNameText = this.extractString(boundaryName.STRING().getText());
}
boundaryId = this.generateId(boundaryNameText);
}
this.processSystemBoundaryStart(boundaryId, boundaryNameText);
};
exitSystemBoundaryStatement = (_ctx: any) => {
this.processSystemBoundaryEnd();
};
exitUsecaseInBoundary = (ctx: any) => {
let useCaseId = '';
let useCaseName = '';
let classes: string[] | undefined;
if (ctx.usecaseWithClass()) {
const withClass = ctx.usecaseWithClass();
if (withClass.IDENTIFIER()) {
const identifiers = withClass.IDENTIFIER();
if (Array.isArray(identifiers) && identifiers.length >= 2) {
useCaseId = identifiers[0].getText();
useCaseName = useCaseId;
classes = [identifiers[1].getText()];
}
} else if (withClass.STRING()) {
useCaseName = this.extractString(withClass.STRING().getText());
useCaseId = this.generateId(useCaseName);
const identifiers = withClass.IDENTIFIER();
if (identifiers) {
classes = [identifiers.getText()];
}
}
} else if (ctx.IDENTIFIER()) {
useCaseId = ctx.IDENTIFIER().getText();
useCaseName = useCaseId;
} else if (ctx.STRING()) {
useCaseName = this.extractString(ctx.STRING().getText());
useCaseId = this.generateId(useCaseName);
}
if (useCaseId && useCaseName) {
this.processUseCaseStatement(useCaseId, useCaseName, undefined, classes);
}
};
// System boundary type statement
exitSystemBoundaryTypeStatement = (ctx: any) => {
const boundaryName = ctx.systemBoundaryName();
let boundaryId = '';
if (boundaryName) {
if (boundaryName.IDENTIFIER()) {
boundaryId = boundaryName.IDENTIFIER().getText();
} else if (boundaryName.STRING()) {
boundaryId = this.generateId(this.extractString(boundaryName.STRING().getText()));
}
}
const typeContent = ctx.systemBoundaryTypeContent();
if (typeContent) {
const properties = typeContent.systemBoundaryTypeProperty();
const props = Array.isArray(properties) ? properties : [properties];
for (const prop of props) {
const type = prop.systemBoundaryType();
if (type) {
let typeValue: 'package' | 'rect' = 'rect';
if (type.PACKAGE()) {
typeValue = 'package';
} else if (type.RECT()) {
typeValue = 'rect';
}
this.processSystemBoundaryType(boundaryId, typeValue);
}
}
}
};
// Direction statement
exitDirectionStatement = (ctx: any) => {
const direction = ctx.direction();
if (direction) {
let directionText = '';
if (direction.TB()) {
directionText = 'TB';
} else if (direction.TD()) {
directionText = 'TD';
} else if (direction.BT()) {
directionText = 'BT';
} else if (direction.RL()) {
directionText = 'RL';
} else if (direction.LR()) {
directionText = 'LR';
}
this.processDirectionStatement(directionText);
}
};
// Class definition statement
exitClassDefStatement = (ctx: any) => {
let classId = '';
if (ctx.IDENTIFIER()) {
classId = ctx.IDENTIFIER().getText();
}
const styles: string[] = [];
const stylesOpt = ctx.stylesOpt();
if (stylesOpt) {
this.collectStyles(stylesOpt, styles);
}
this.processClassDefStatement(classId, styles);
};
// Class statement
exitClassStatement = (ctx: any) => {
const nodeList = ctx.nodeList();
const nodeIds: string[] = [];
if (nodeList) {
const identifiers = nodeList.IDENTIFIER();
const ids = Array.isArray(identifiers) ? identifiers : [identifiers];
for (const id of ids) {
nodeIds.push(id.getText());
}
}
let classId = '';
const identifiers = ctx.IDENTIFIER();
if (identifiers) {
const ids = Array.isArray(identifiers) ? identifiers : [identifiers];
if (ids.length > 0) {
classId = ids[ids.length - 1].getText();
}
}
this.processClassStatement(nodeIds, classId);
};
// Style statement
exitStyleStatement = (ctx: any) => {
let nodeId = '';
if (ctx.IDENTIFIER()) {
nodeId = ctx.IDENTIFIER().getText();
}
const styles: string[] = [];
const stylesOpt = ctx.stylesOpt();
if (stylesOpt) {
this.collectStyles(stylesOpt, styles);
}
this.processStyleStatement(nodeId, styles);
};
// Usecase statement
exitUsecaseStatement = (ctx: any) => {
const entityName = ctx.entityName();
if (entityName) {
const useCaseId = this.extractEntityName(entityName);
this.processUseCaseStatement(useCaseId, useCaseId);
}
};
// Helper methods
private extractMetadata(ctx: any): Record<string, string> {
const metadata: Record<string, string> = {};
const content = ctx.metadataContent();
if (content) {
const properties = content.metadataProperty();
const props = Array.isArray(properties) ? properties : [properties];
for (const prop of props) {
const strings = prop.STRING();
if (strings && strings.length >= 2) {
const key = this.extractString(strings[0].getText());
const value = this.extractString(strings[1].getText());
metadata[key] = value;
}
}
}
return metadata;
}
private extractEntityName(ctx: any): string {
if (ctx.nodeIdWithLabel()) {
const nodeId = ctx.nodeIdWithLabel();
if (nodeId.IDENTIFIER()) {
return nodeId.IDENTIFIER().getText();
}
}
if (ctx.IDENTIFIER()) {
const identifiers = ctx.IDENTIFIER();
if (Array.isArray(identifiers) && identifiers.length >= 2) {
return identifiers[0].getText();
}
return identifiers.getText ? identifiers.getText() : identifiers[0].getText();
}
if (ctx.STRING()) {
const strings = ctx.STRING();
const text = strings.getText ? strings.getText() : strings[0].getText();
return this.extractString(text);
}
return '';
}
private extractActorDeclaration(ctx: any): string {
const actorName = ctx.actorName();
if (actorName) {
if (actorName.IDENTIFIER()) {
return actorName.IDENTIFIER().getText();
} else if (actorName.STRING()) {
return this.extractString(actorName.STRING().getText());
}
}
return '';
}
private extractArrow(ctx: any): { type: number; label?: string } {
let arrowText = '';
let label: string | undefined;
if (ctx.labeledArrow()) {
const labeledArrow = ctx.labeledArrow();
const edgeLabel = labeledArrow.edgeLabel();
if (edgeLabel) {
if (edgeLabel.IDENTIFIER()) {
label = edgeLabel.IDENTIFIER().getText();
} else if (edgeLabel.STRING()) {
label = this.extractString(edgeLabel.STRING().getText());
}
}
if (labeledArrow.SOLID_ARROW()) {
arrowText = '-->';
} else if (labeledArrow.BACK_ARROW()) {
arrowText = '<--';
} else if (labeledArrow.CIRCLE_ARROW()) {
arrowText = '--o';
} else if (labeledArrow.CROSS_ARROW()) {
arrowText = '--x';
} else if (labeledArrow.CIRCLE_ARROW_REVERSED()) {
arrowText = 'o--';
} else if (labeledArrow.CROSS_ARROW_REVERSED()) {
arrowText = 'x--';
} else {
arrowText = '--';
}
} else {
if (ctx.SOLID_ARROW()) {
arrowText = '-->';
} else if (ctx.BACK_ARROW()) {
arrowText = '<--';
} else if (ctx.LINE_SOLID()) {
arrowText = '--';
} else if (ctx.CIRCLE_ARROW()) {
arrowText = '--o';
} else if (ctx.CROSS_ARROW()) {
arrowText = '--x';
} else if (ctx.CIRCLE_ARROW_REVERSED()) {
arrowText = 'o--';
} else if (ctx.CROSS_ARROW_REVERSED()) {
arrowText = 'x--';
}
}
return {
type: this.parseArrowType(arrowText),
label,
};
}
private collectStyles(ctx: any, styles: string[]): void {
if (!ctx) {
return;
}
const styleComponents = this.getAllStyleComponents(ctx);
for (const component of styleComponents) {
styles.push(component.getText());
}
}
private getAllStyleComponents(ctx: any): any[] {
const components: any[] = [];
if (ctx.style) {
const styleCtx = ctx.style();
if (styleCtx) {
this.collectStyleComponents(styleCtx, components);
}
}
if (ctx.stylesOpt) {
const stylesOptList = Array.isArray(ctx.stylesOpt()) ? ctx.stylesOpt() : [ctx.stylesOpt()];
for (const opt of stylesOptList) {
if (opt) {
this.collectStyleComponents(opt, components);
}
}
}
return components;
}
private collectStyleComponents(ctx: any, components: any[]): void {
if (!ctx) {
return;
}
if (ctx.styleComponent) {
const comp = ctx.styleComponent();
if (comp) {
components.push(comp);
}
}
if (ctx.style) {
const styleCtx = ctx.style();
if (styleCtx) {
this.collectStyleComponents(styleCtx, components);
}
}
}
}

View File

@@ -0,0 +1,210 @@
parser grammar UsecaseParser;
options {
tokenVocab = UsecaseLexer;
}
// Entry point
start: USECASE NEWLINE* statement* EOF;
// Statement types
statement
: actorStatement
| relationshipStatement
| systemBoundaryStatement
| systemBoundaryTypeStatement
| directionStatement
| classDefStatement
| classStatement
| styleStatement
| usecaseStatement
| NEWLINE
;
// Usecase statement (standalone entity)
usecaseStatement
: entityName NEWLINE*
;
// Actor statement
actorStatement
: ACTOR actorList NEWLINE*
;
actorList
: actorName (COMMA actorName)*
;
actorName
: (IDENTIFIER | STRING) metadata?
;
metadata
: AT LBRACE metadataContent RBRACE
;
metadataContent
: metadataProperty (COMMA metadataProperty)*
;
metadataProperty
: STRING COLON STRING
;
// Relationship statement
relationshipStatement
: entityName arrow entityName NEWLINE*
| actorDeclaration arrow entityName NEWLINE*
;
// System boundary statement
systemBoundaryStatement
: SYSTEM_BOUNDARY systemBoundaryName NEWLINE* systemBoundaryContent* END NEWLINE*
;
systemBoundaryName
: IDENTIFIER
| STRING
;
systemBoundaryContent
: usecaseInBoundary NEWLINE*
| NEWLINE
;
usecaseInBoundary
: usecaseWithClass
| IDENTIFIER
| STRING
;
usecaseWithClass
: IDENTIFIER CLASS_SEPARATOR IDENTIFIER
| STRING CLASS_SEPARATOR IDENTIFIER
;
// System boundary type statement
systemBoundaryTypeStatement
: systemBoundaryName AT LBRACE systemBoundaryTypeContent RBRACE NEWLINE*
;
systemBoundaryTypeContent
: systemBoundaryTypeProperty (COMMA systemBoundaryTypeProperty)*
;
systemBoundaryTypeProperty
: TYPE COLON systemBoundaryType
;
systemBoundaryType
: PACKAGE
| RECT
;
// Entity name (node reference)
entityName
: IDENTIFIER CLASS_SEPARATOR IDENTIFIER
| STRING CLASS_SEPARATOR IDENTIFIER
| IDENTIFIER
| STRING
| nodeIdWithLabel
;
// Actor declaration (inline)
actorDeclaration
: ACTOR actorName
;
// Node with label
nodeIdWithLabel
: IDENTIFIER LPAREN nodeLabel RPAREN
;
nodeLabel
: IDENTIFIER
| STRING
| nodeLabel IDENTIFIER
| nodeLabel STRING
;
// Arrow types
arrow
: SOLID_ARROW
| BACK_ARROW
| LINE_SOLID
| CIRCLE_ARROW
| CROSS_ARROW
| CIRCLE_ARROW_REVERSED
| CROSS_ARROW_REVERSED
| labeledArrow
;
labeledArrow
: LINE_SOLID edgeLabel SOLID_ARROW
| BACK_ARROW edgeLabel LINE_SOLID
| LINE_SOLID edgeLabel LINE_SOLID
| LINE_SOLID edgeLabel CIRCLE_ARROW
| LINE_SOLID edgeLabel CROSS_ARROW
| CIRCLE_ARROW_REVERSED edgeLabel LINE_SOLID
| CROSS_ARROW_REVERSED edgeLabel LINE_SOLID
;
edgeLabel
: IDENTIFIER
| STRING
;
// Direction statement
directionStatement
: DIRECTION direction NEWLINE*
;
direction
: TB
| TD
| BT
| RL
| LR
;
// Class definition statement
classDefStatement
: CLASS_DEF IDENTIFIER stylesOpt NEWLINE*
;
stylesOpt
: style
| stylesOpt COMMA style
;
style
: styleComponent
| style styleComponent
;
styleComponent
: IDENTIFIER
| NUMBER
| HASH_COLOR
| COLON
| STRING
| DASH
| DOT
| PERCENT
;
// Class statement
classStatement
: CLASS nodeList IDENTIFIER NEWLINE*
;
// Style statement
styleStatement
: STYLE IDENTIFIER stylesOpt NEWLINE*
;
// Node list
nodeList
: IDENTIFIER (COMMA IDENTIFIER)*
;

View File

@@ -0,0 +1,292 @@
import type {
UsecaseDB,
Actor,
UseCase,
SystemBoundary,
Relationship,
ClassDef,
ArrowType,
} from '../../usecaseTypes.js';
import { ARROW_TYPE } from '../../usecaseTypes.js';
import { log } from '../../../../logger.js';
/**
* Core shared logic for both Listener and Visitor patterns
* Contains all the proven parsing logic for usecase diagrams
*/
export class UsecaseParserCore {
protected db: UsecaseDB;
protected relationshipCounter = 0;
protected currentSystemBoundary: string | null = null;
protected currentSystemBoundaryUseCases: string[] = [];
constructor(db: UsecaseDB) {
this.db = db;
}
/**
* Browser-safe environment variable access
*/
protected getEnvVar(name: string): string | undefined {
try {
if (typeof process !== 'undefined' && process.env) {
return process.env[name];
}
} catch (_e) {
// process is not defined in browser
}
// Browser fallback
if (typeof window !== 'undefined' && (window as any).MERMAID_CONFIG) {
return (window as any).MERMAID_CONFIG[name];
}
return undefined;
}
/**
* Process actor statement
*/
protected processActorStatement(
actorId: string,
actorName: string,
metadata?: Record<string, string>
): void {
const actor: Actor = {
id: actorId,
name: actorName,
metadata,
};
this.db.addActor(actor);
log.debug(`Processed actor: ${actorId} (${actorName})`);
}
/**
* Process use case statement
*/
protected processUseCaseStatement(
useCaseId: string,
useCaseName: string,
nodeId?: string,
classes?: string[]
): void {
const useCase: UseCase = {
id: useCaseId,
name: useCaseName,
nodeId,
classes,
systemBoundary: this.currentSystemBoundary ?? undefined,
};
this.db.addUseCase(useCase);
// Add to current system boundary if we're inside one
if (this.currentSystemBoundary) {
this.currentSystemBoundaryUseCases.push(useCaseId);
}
log.debug(`Processed use case: ${useCaseId} (${useCaseName})`);
}
/**
* Process relationship statement
*/
protected processRelationship(from: string, to: string, arrowType: number, label?: string): void {
// Generate IDs for checking if entities exist
const fromId = this.generateId(from);
const toId = this.generateId(to);
// Ensure entities exist - if they're not actors, create them as use cases
if (!this.db.getActor(fromId) && !this.db.getUseCase(fromId)) {
this.db.addUseCase({ id: fromId, name: from });
log.debug(`Auto-created use case: ${fromId} (${from})`);
}
if (!this.db.getActor(toId) && !this.db.getUseCase(toId)) {
this.db.addUseCase({ id: toId, name: to });
log.debug(`Auto-created use case: ${toId} (${to})`);
}
const relationshipId = `rel_${this.relationshipCounter++}`;
// Determine relationship type based on arrow type and label
let type: 'association' | 'include' | 'extend' = 'association';
if (label) {
const lowerLabel = label.toLowerCase();
if (lowerLabel.includes('include')) {
type = 'include';
} else if (lowerLabel.includes('extend')) {
type = 'extend';
}
}
const relationship: Relationship = {
id: relationshipId,
from: fromId,
to: toId,
type,
arrowType: arrowType as ArrowType,
label,
};
this.db.addRelationship(relationship);
log.debug(`Processed relationship: ${fromId} -> ${toId} (${type})`);
}
/**
* Process system boundary start
*/
protected processSystemBoundaryStart(boundaryId: string, boundaryName: string): void {
this.currentSystemBoundary = boundaryId;
this.currentSystemBoundaryUseCases = [];
log.debug(`Started system boundary: ${boundaryId} (${boundaryName})`);
}
/**
* Process system boundary end
*/
protected processSystemBoundaryEnd(): void {
if (this.currentSystemBoundary) {
const systemBoundary: SystemBoundary = {
id: this.currentSystemBoundary,
name: this.currentSystemBoundary,
useCases: [...this.currentSystemBoundaryUseCases],
};
this.db.addSystemBoundary(systemBoundary);
log.debug(`Ended system boundary: ${this.currentSystemBoundary}`);
this.currentSystemBoundary = null;
this.currentSystemBoundaryUseCases = [];
}
}
/**
* Process system boundary type
*/
protected processSystemBoundaryType(boundaryId: string, type: 'package' | 'rect'): void {
const boundary = this.db.getSystemBoundary(boundaryId);
if (boundary) {
boundary.type = type;
log.debug(`Set system boundary type: ${boundaryId} -> ${type}`);
}
}
/**
* Process direction statement
*/
protected processDirectionStatement(direction: string): void {
const normalizedDirection = this.normalizeDirection(direction);
this.db.setDirection(normalizedDirection as any);
log.debug(`Set direction: ${normalizedDirection}`);
}
/**
* Normalize direction
*/
protected normalizeDirection(dir: string): string {
switch (dir) {
case 'TD':
return 'TB';
default:
return dir;
}
}
/**
* Process class definition statement
*/
protected processClassDefStatement(classId: string, styles: string[]): void {
const classDef: ClassDef = {
id: classId,
styles,
};
this.db.addClassDef(classDef);
log.debug(`Processed class definition: ${classId}`);
}
/**
* Process class statement (apply class to nodes)
*/
protected processClassStatement(nodeIds: string[], classId: string): void {
for (const nodeId of nodeIds) {
const useCase = this.db.getUseCase(nodeId);
if (useCase) {
if (!useCase.classes) {
useCase.classes = [];
}
if (!useCase.classes.includes(classId)) {
useCase.classes.push(classId);
}
log.debug(`Applied class ${classId} to use case ${nodeId}`);
}
}
}
/**
* Process style statement (apply styles directly to node)
*/
protected processStyleStatement(nodeId: string, styles: string[]): void {
const useCase = this.db.getUseCase(nodeId);
if (useCase) {
useCase.styles = styles;
log.debug(`Applied styles to use case ${nodeId}`);
}
const actor = this.db.getActor(nodeId);
if (actor) {
actor.styles = styles;
log.debug(`Applied styles to actor ${nodeId}`);
}
}
/**
* Extract text from string (remove quotes)
*/
protected extractString(text: string): string {
if (!text) {
return '';
}
// Remove surrounding quotes
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
return text.slice(1, -1);
}
return text;
}
/**
* Parse arrow type from token
*/
protected parseArrowType(arrowText: string): number {
switch (arrowText) {
case '-->':
return ARROW_TYPE.SOLID_ARROW;
case '<--':
return ARROW_TYPE.BACK_ARROW;
case '--':
return ARROW_TYPE.LINE_SOLID;
case '--o':
return ARROW_TYPE.CIRCLE_ARROW;
case '--x':
return ARROW_TYPE.CROSS_ARROW;
case 'o--':
return ARROW_TYPE.CIRCLE_ARROW_REVERSED;
case 'x--':
return ARROW_TYPE.CROSS_ARROW_REVERSED;
default:
return ARROW_TYPE.SOLID_ARROW;
}
}
/**
* Generate unique ID from name
*/
protected generateId(name: string): string {
return name.replace(/\W/g, '_');
}
}

View File

@@ -0,0 +1,533 @@
import { UsecaseParserCore } from './UsecaseParserCore.js';
import { log } from '../../../../logger.js';
import type { UsecaseDB } from '../../usecaseTypes.js';
/**
* Visitor implementation that builds the usecase model
* Uses the same core logic as the Listener for consistency
*/
export class UsecaseVisitor extends UsecaseParserCore {
private visitCount = 0;
constructor(db: UsecaseDB) {
super(db);
if (this.getEnvVar('ANTLR_DEBUG') === 'true') {
log.debug('🎯 UsecaseVisitor: Constructor called');
}
}
// Default visitor methods
visit(tree: any): any {
const shouldLog = this.getEnvVar('ANTLR_DEBUG') === 'true';
this.visitCount++;
if (shouldLog) {
log.debug(`🔍 UsecaseVisitor: Visiting node type: ${tree.constructor.name}`);
}
let result;
try {
result = tree.accept(this);
if (shouldLog) {
log.debug(`✅ UsecaseVisitor: Successfully visited ${tree.constructor.name}`);
}
} catch (error) {
log.error(`❌ UsecaseVisitor: Error visiting ${tree.constructor.name}:`, error);
throw error;
}
return result;
}
visitChildren(node: any): any {
if (!node) {
return null;
}
let result = null;
const n = node.getChildCount();
for (let i = 0; i < n; i++) {
const child = node.getChild(i);
if (child) {
const childResult = child.accept(this);
if (childResult !== null) {
result = childResult;
}
}
}
return result;
}
visitTerminal(_node: any): any {
return null;
}
visitErrorNode(_node: any): any {
log.error('❌ UsecaseVisitor: Error node encountered');
return null;
}
// Start rule
visitStart(ctx: any): any {
return this.visitChildren(ctx);
}
// Statement rule
visitStatement(ctx: any): any {
return this.visitChildren(ctx);
}
// Actor statement
visitActorStatement(ctx: any): any {
const actorList = ctx.actorList();
if (actorList) {
this.visitActorList(actorList);
}
return null;
}
visitActorList(ctx: any): any {
const actorNames = ctx.actorName();
if (actorNames) {
const names = Array.isArray(actorNames) ? actorNames : [actorNames];
for (const actorName of names) {
this.visitActorName(actorName);
}
}
return null;
}
visitActorName(ctx: any): any {
let actorName = '';
const identifier = ctx.IDENTIFIER();
if (identifier) {
actorName = identifier.getText();
} else {
const stringToken = ctx.STRING();
if (stringToken) {
actorName = this.extractString(stringToken.getText());
}
}
const actorId = this.generateId(actorName);
// Process metadata if present
let metadata: Record<string, string> | undefined;
const metadataCtx = ctx.metadata();
if (metadataCtx) {
metadata = this.visitMetadata(metadataCtx);
}
this.processActorStatement(actorId, actorName, metadata);
return null;
}
visitMetadata(ctx: any): Record<string, string> {
const metadata: Record<string, string> = {};
const content = ctx.metadataContent();
if (content) {
const properties = content.metadataProperty();
const props = Array.isArray(properties) ? properties : [properties];
for (const prop of props) {
const strings = prop.STRING();
if (strings && strings.length >= 2) {
const key = this.extractString(strings[0].getText());
const value = this.extractString(strings[1].getText());
metadata[key] = value;
}
}
}
return metadata;
}
// Relationship statement
visitRelationshipStatement(ctx: any): any {
let from = '';
let to = '';
let arrowType = 0;
let label: string | undefined;
// Get entity names
const entityNames = ctx.entityName();
if (entityNames && entityNames.length >= 2) {
from = this.visitEntityName(entityNames[0]);
to = this.visitEntityName(entityNames[1]);
} else if (ctx.actorDeclaration()) {
from = this.visitActorDeclaration(ctx.actorDeclaration());
if (entityNames && entityNames.length >= 1) {
to = this.visitEntityName(entityNames[0]);
}
}
// Get arrow type
const arrow = ctx.arrow();
if (arrow) {
const arrowResult = this.visitArrow(arrow);
arrowType = arrowResult.type;
label = arrowResult.label;
}
this.processRelationship(from, to, arrowType, label);
return null;
}
visitEntityName(ctx: any): string {
if (!ctx) {
return '';
}
const nodeIdWithLabel = ctx.nodeIdWithLabel();
if (nodeIdWithLabel) {
return this.visitNodeIdWithLabel(nodeIdWithLabel);
}
const identifiers = ctx.IDENTIFIER();
if (identifiers) {
if (Array.isArray(identifiers) && identifiers.length >= 2) {
// Has class separator (:::)
return identifiers[0].getText();
} else if (Array.isArray(identifiers) && identifiers.length === 1) {
return identifiers[0].getText();
}
}
const stringToken = ctx.STRING();
if (stringToken) {
return this.extractString(stringToken.getText());
}
return '';
}
visitActorDeclaration(ctx: any): string {
const actorName = ctx.actorName();
if (actorName) {
return this.visitActorName(actorName);
}
return '';
}
visitNodeIdWithLabel(ctx: any): string {
if (ctx.IDENTIFIER()) {
return ctx.IDENTIFIER().getText();
}
return '';
}
visitArrow(ctx: any): { type: number; label?: string } {
let arrowText = '';
let label: string | undefined;
if (ctx.labeledArrow()) {
const labeledArrow = ctx.labeledArrow();
const edgeLabel = labeledArrow.edgeLabel();
if (edgeLabel) {
if (edgeLabel.IDENTIFIER()) {
label = edgeLabel.IDENTIFIER().getText();
} else if (edgeLabel.STRING()) {
label = this.extractString(edgeLabel.STRING().getText());
}
}
// Determine arrow type from labeled arrow structure
if (labeledArrow.SOLID_ARROW()) {
arrowText = '-->';
} else if (labeledArrow.BACK_ARROW()) {
arrowText = '<--';
} else if (labeledArrow.CIRCLE_ARROW()) {
arrowText = '--o';
} else if (labeledArrow.CROSS_ARROW()) {
arrowText = '--x';
} else if (labeledArrow.CIRCLE_ARROW_REVERSED()) {
arrowText = 'o--';
} else if (labeledArrow.CROSS_ARROW_REVERSED()) {
arrowText = 'x--';
} else {
arrowText = '--';
}
} else {
// Simple arrow
if (ctx.SOLID_ARROW()) {
arrowText = '-->';
} else if (ctx.BACK_ARROW()) {
arrowText = '<--';
} else if (ctx.LINE_SOLID()) {
arrowText = '--';
} else if (ctx.CIRCLE_ARROW()) {
arrowText = '--o';
} else if (ctx.CROSS_ARROW()) {
arrowText = '--x';
} else if (ctx.CIRCLE_ARROW_REVERSED()) {
arrowText = 'o--';
} else if (ctx.CROSS_ARROW_REVERSED()) {
arrowText = 'x--';
}
}
return {
type: this.parseArrowType(arrowText),
label,
};
}
// System boundary statement
visitSystemBoundaryStatement(ctx: any): any {
const boundaryName = ctx.systemBoundaryName();
let boundaryId = '';
let boundaryNameText = '';
if (boundaryName) {
if (boundaryName.IDENTIFIER()) {
boundaryNameText = boundaryName.IDENTIFIER().getText();
} else if (boundaryName.STRING()) {
boundaryNameText = this.extractString(boundaryName.STRING().getText());
}
boundaryId = this.generateId(boundaryNameText);
}
this.processSystemBoundaryStart(boundaryId, boundaryNameText);
// Visit boundary content
const contents = ctx.systemBoundaryContent();
if (contents) {
const contentList = Array.isArray(contents) ? contents : [contents];
for (const content of contentList) {
this.visitSystemBoundaryContent(content);
}
}
this.processSystemBoundaryEnd();
return null;
}
visitSystemBoundaryContent(ctx: any): any {
const usecaseInBoundary = ctx.usecaseInBoundary();
if (usecaseInBoundary) {
this.visitUsecaseInBoundary(usecaseInBoundary);
}
return null;
}
visitUsecaseInBoundary(ctx: any): any {
let useCaseId = '';
let useCaseName = '';
let classes: string[] | undefined;
if (ctx.usecaseWithClass()) {
const withClass = ctx.usecaseWithClass();
if (withClass.IDENTIFIER()) {
const identifiers = withClass.IDENTIFIER();
if (Array.isArray(identifiers) && identifiers.length >= 2) {
useCaseId = identifiers[0].getText();
useCaseName = useCaseId;
classes = [identifiers[1].getText()];
}
} else if (withClass.STRING()) {
useCaseName = this.extractString(withClass.STRING().getText());
useCaseId = this.generateId(useCaseName);
const identifiers = withClass.IDENTIFIER();
if (identifiers) {
classes = [identifiers.getText()];
}
}
} else if (ctx.IDENTIFIER()) {
useCaseId = ctx.IDENTIFIER().getText();
useCaseName = useCaseId;
} else if (ctx.STRING()) {
useCaseName = this.extractString(ctx.STRING().getText());
useCaseId = this.generateId(useCaseName);
}
if (useCaseId && useCaseName) {
this.processUseCaseStatement(useCaseId, useCaseName, undefined, classes);
}
return null;
}
// System boundary type statement
visitSystemBoundaryTypeStatement(ctx: any): any {
const boundaryName = ctx.systemBoundaryName();
let boundaryId = '';
if (boundaryName) {
if (boundaryName.IDENTIFIER()) {
boundaryId = boundaryName.IDENTIFIER().getText();
} else if (boundaryName.STRING()) {
boundaryId = this.generateId(this.extractString(boundaryName.STRING().getText()));
}
}
const typeContent = ctx.systemBoundaryTypeContent();
if (typeContent) {
const properties = typeContent.systemBoundaryTypeProperty();
const props = Array.isArray(properties) ? properties : [properties];
for (const prop of props) {
const type = prop.systemBoundaryType();
if (type) {
let typeValue: 'package' | 'rect' = 'rect';
if (type.PACKAGE()) {
typeValue = 'package';
} else if (type.RECT()) {
typeValue = 'rect';
}
this.processSystemBoundaryType(boundaryId, typeValue);
}
}
}
return null;
}
// Direction statement
visitDirectionStatement(ctx: any): any {
const direction = ctx.direction();
if (direction) {
let directionText = '';
if (direction.TB()) {
directionText = 'TB';
} else if (direction.TD()) {
directionText = 'TD';
} else if (direction.BT()) {
directionText = 'BT';
} else if (direction.RL()) {
directionText = 'RL';
} else if (direction.LR()) {
directionText = 'LR';
}
this.processDirectionStatement(directionText);
}
return null;
}
// Class definition statement
visitClassDefStatement(ctx: any): any {
let classId = '';
if (ctx.IDENTIFIER()) {
classId = ctx.IDENTIFIER().getText();
}
const styles: string[] = [];
const stylesOpt = ctx.stylesOpt();
if (stylesOpt) {
this.collectStyles(stylesOpt, styles);
}
this.processClassDefStatement(classId, styles);
return null;
}
// Class statement
visitClassStatement(ctx: any): any {
const nodeList = ctx.nodeList();
const nodeIds: string[] = [];
if (nodeList) {
const identifiers = nodeList.IDENTIFIER();
const ids = Array.isArray(identifiers) ? identifiers : [identifiers];
for (const id of ids) {
nodeIds.push(id.getText());
}
}
let classId = '';
const identifiers = ctx.IDENTIFIER();
if (identifiers) {
const ids = Array.isArray(identifiers) ? identifiers : [identifiers];
if (ids.length > 0) {
classId = ids[ids.length - 1].getText();
}
}
this.processClassStatement(nodeIds, classId);
return null;
}
// Style statement
visitStyleStatement(ctx: any): any {
let nodeId = '';
if (ctx.IDENTIFIER()) {
nodeId = ctx.IDENTIFIER().getText();
}
const styles: string[] = [];
const stylesOpt = ctx.stylesOpt();
if (stylesOpt) {
this.collectStyles(stylesOpt, styles);
}
this.processStyleStatement(nodeId, styles);
return null;
}
// Usecase statement
visitUsecaseStatement(ctx: any): any {
const entityName = ctx.entityName();
if (entityName) {
const useCaseId = this.visitEntityName(entityName);
this.processUseCaseStatement(useCaseId, useCaseId);
}
return null;
}
// Helper method to collect styles
private collectStyles(ctx: any, styles: string[]): void {
if (!ctx) {
return;
}
// Visit all style components
const styleComponents = this.getAllStyleComponents(ctx);
for (const component of styleComponents) {
styles.push(component.getText());
}
}
private getAllStyleComponents(ctx: any): any[] {
const components: any[] = [];
if (ctx.style) {
const styleCtx = ctx.style();
if (styleCtx) {
this.collectStyleComponents(styleCtx, components);
}
}
if (ctx.stylesOpt) {
const stylesOptList = Array.isArray(ctx.stylesOpt()) ? ctx.stylesOpt() : [ctx.stylesOpt()];
for (const opt of stylesOptList) {
if (opt) {
this.collectStyleComponents(opt, components);
}
}
}
return components;
}
private collectStyleComponents(ctx: any, components: any[]): void {
if (!ctx) {
return;
}
if (ctx.styleComponent) {
const comp = ctx.styleComponent();
if (comp) {
components.push(comp);
}
}
if (ctx.style) {
const styleCtx = ctx.style();
if (styleCtx) {
this.collectStyleComponents(styleCtx, components);
}
}
}
}

View File

@@ -0,0 +1,181 @@
/**
* ANTLR-based Usecase Diagram Parser
*
* This is a proper ANTLR implementation using antlr-ng generated parser code.
* It provides the same interface as the existing parser for 100% compatibility.
*
* Follows the same structure as flowchart and sequence ANTLR parsers with both
* listener and visitor pattern support.
*/
import { CharStream, CommonTokenStream, ParseTreeWalker } from 'antlr4ng';
import { UsecaseLexer } from './generated/UsecaseLexer.js';
import { UsecaseParser } from './generated/UsecaseParser.js';
import { UsecaseListener } from './UsecaseListener.js';
import { UsecaseVisitor } from './UsecaseVisitor.js';
import { UsecaseErrorListener } from './UsecaseErrorListener.js';
import type { UsecaseDB } from '../../usecaseTypes.js';
import { log } from '../../../../logger.js';
/**
* Main ANTLR parser class that provides the same interface as the existing parser
*/
export class ANTLRUsecaseParser {
yy: UsecaseDB | null;
constructor() {
this.yy = null;
}
/**
* Parse usecase diagram input using ANTLR
*
* @param input - The usecase diagram text to parse
* @returns The database instance populated with parsed data
*/
parse(input: string): UsecaseDB {
if (!this.yy) {
throw new Error('Usecase ANTLR parser missing yy (database).');
}
const startTime = performance.now();
// Get environment variable helper
const getEnvVar = (name: string): string | undefined => {
try {
if (typeof process !== 'undefined' && process.env) {
return process.env[name];
}
} catch (_e) {
// process is not defined in browser
}
// Browser fallback
if (typeof window !== 'undefined' && (window as any).MERMAID_CONFIG) {
return (window as any).MERMAID_CONFIG[name];
}
return undefined;
};
const shouldLog = getEnvVar('ANTLR_DEBUG') === 'true';
if (shouldLog) {
log.debug('🎯 ANTLR Usecase Parser: Starting parse');
log.debug(`📝 Input length: ${input.length} characters`);
}
try {
// Reset database state
if (this.yy.clear) {
this.yy.clear();
}
// Create input stream and lexer
const inputStream = CharStream.fromString(input);
const lexer = new UsecaseLexer(inputStream);
// Add custom error listener to lexer
const lexerErrorListener = new UsecaseErrorListener();
lexer.removeErrorListeners();
lexer.addErrorListener(lexerErrorListener);
const tokenStream = new CommonTokenStream(lexer);
// Create parser
const parser = new UsecaseParser(tokenStream);
// Add custom error listener to parser
const parserErrorListener = new UsecaseErrorListener();
parser.removeErrorListeners();
parser.addErrorListener(parserErrorListener);
// Generate parse tree
if (shouldLog) {
log.debug('🌳 ANTLR Usecase Parser: Starting parse tree generation');
}
const tree = parser.start();
// Check for syntax errors
if (lexerErrorListener.hasErrors()) {
throw lexerErrorListener.createDetailedError();
}
if (parserErrorListener.hasErrors()) {
throw parserErrorListener.createDetailedError();
}
if (shouldLog) {
log.debug('✅ ANTLR Usecase Parser: Parse tree generated successfully');
}
// Check if we should use Visitor or Listener pattern
// Default to Visitor pattern (true) unless explicitly set to false
const useVisitorPattern = getEnvVar('USE_ANTLR_VISITOR') !== 'false';
if (shouldLog) {
log.debug('🔧 Usecase Parser: Pattern =', useVisitorPattern ? 'Visitor' : 'Listener');
}
if (useVisitorPattern) {
const visitor = new UsecaseVisitor(this.yy);
try {
visitor.visit(tree);
if (shouldLog) {
log.debug('✅ ANTLR Usecase Parser: Visitor completed successfully');
}
} catch (error) {
log.error(
'❌ ANTLR Usecase Parser: Visitor failed:',
error instanceof Error ? error.message : String(error)
);
log.error(
'❌ ANTLR Usecase Parser: Visitor stack:',
error instanceof Error ? error.stack : undefined
);
throw error;
}
} else {
const listener = new UsecaseListener(this.yy);
try {
ParseTreeWalker.DEFAULT.walk(listener, tree);
if (shouldLog) {
log.debug('✅ ANTLR Usecase Parser: Listener completed successfully');
}
} catch (error) {
log.error(
'❌ ANTLR Usecase Parser: Listener failed:',
error instanceof Error ? error.message : String(error)
);
log.error(
'❌ ANTLR Usecase Parser: Listener stack:',
error instanceof Error ? error.stack : undefined
);
throw error;
}
}
const totalTime = performance.now() - startTime;
if (shouldLog) {
log.debug(`⏱️ Total parse time: ${totalTime.toFixed(2)}ms`);
log.debug('✅ ANTLR Usecase Parser: Parse completed successfully');
}
return this.yy;
} catch (error) {
const totalTime = performance.now() - startTime;
log.error(`❌ ANTLR usecase parsing error after ${totalTime.toFixed(2)}ms:`, error);
log.error('📝 Input that caused error (first 500 chars):', input.substring(0, 500));
throw error;
}
}
// Provide the same interface as existing parser
setYY(yy: UsecaseDB) {
this.yy = yy;
}
}
// Export for compatibility with existing code
export const parser = new ANTLRUsecaseParser();
export default parser;

View File

@@ -0,0 +1,87 @@
const getStyles = (options: any) =>
`
.actor {
stroke: ${options.primaryColor};
fill: ${options.primaryColor};
}
.actor-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 14px;
font-weight: normal;
}
.usecase-actor-shape line {
stroke: ${options.actorBorder};
fill: ${options.actorBkg};
}
.usecase-actor-shape circle, line {
stroke: ${options.actorBorder};
fill: ${options.actorBkg};
stroke-width: 2px;
}
.usecase {
stroke: ${options.primaryColor};
fill: ${options.primaryColor};
}
.usecase-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 12px;
font-weight: normal;
}
/* Ellipse shape styling for use cases */
.usecase-element ellipse {
fill: ${options.mainBkg ?? '#ffffff'};
stroke: ${options.primaryColor};
stroke-width: 2px;
}
.usecase-element .label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 12px;
font-weight: normal;
text-anchor: middle;
dominant-baseline: central;
}
/* General ellipse styling */
.node ellipse {
fill: ${options.mainBkg ?? '#ffffff'};
stroke: ${options.nodeBorder ?? options.primaryColor};
stroke-width: 1px;
}
.relationship {
stroke: ${options.lineColor};
fill: none;
}
& .marker {
fill: ${options.lineColor};
stroke: ${options.lineColor};
}
.relationship-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 10px;
font-weight: normal;
}
.nodeLabel, .edgeLabel {
color: ${options.classText};
}
.system-boundary {
fill: ${options.clusterBkg};
stroke: ${options.clusterBorder};
stroke-width: 1px;
}
`;
export default getStyles;

View File

@@ -0,0 +1,501 @@
import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest';
import { Diagram } from '../../Diagram.js';
import { addDiagrams } from '../../diagram-api/diagram-orchestration.js';
import { db } from './usecaseDb.js';
beforeAll(async () => {
// Is required to load the useCase diagram
await Diagram.fromText('usecase\n actor TestActor');
});
/**
* UseCase diagrams require a basic d3 mock for rendering
*/
vi.mock('d3', () => {
const NewD3 = function (this: any) {
function returnThis(this: any) {
return this;
}
return {
append: function () {
return NewD3();
},
lower: returnThis,
attr: returnThis,
style: returnThis,
text: returnThis,
getBBox: function () {
return {
height: 10,
width: 20,
};
},
};
};
return {
select: function () {
return new (NewD3 as any)();
},
selectAll: function () {
return new (NewD3 as any)();
},
// TODO: In d3 these are CurveFactory types, not strings
curveBasis: 'basis',
curveBasisClosed: 'basisClosed',
curveBasisOpen: 'basisOpen',
curveBumpX: 'bumpX',
curveBumpY: 'bumpY',
curveBundle: 'bundle',
curveCardinalClosed: 'cardinalClosed',
curveCardinalOpen: 'cardinalOpen',
curveCardinal: 'cardinal',
curveCatmullRomClosed: 'catmullRomClosed',
curveCatmullRomOpen: 'catmullRomOpen',
curveCatmullRom: 'catmullRom',
curveLinear: 'linear',
curveLinearClosed: 'linearClosed',
curveMonotoneX: 'monotoneX',
curveMonotoneY: 'monotoneY',
curveNatural: 'natural',
curveStep: 'step',
curveStepAfter: 'stepAfter',
curveStepBefore: 'stepBefore',
};
});
// -------------------------------
addDiagrams();
describe('UseCase diagram with ANTLR parser', () => {
beforeEach(() => {
db.clear();
});
describe('when parsing basic actors', () => {
it('should parse a single actor', async () => {
const diagram = await Diagram.fromText(
`usecase
actor User`
);
expect(diagram).toBeDefined();
expect(diagram.type).toBe('usecase');
const actors = db.getActors();
expect(actors.size).toBe(1);
expect(actors.has('User')).toBe(true);
expect(actors.get('User')?.name).toBe('User');
});
it('should parse multiple actors', async () => {
await Diagram.fromText(
`usecase
actor User
actor Admin
actor Guest`
);
const actors = db.getActors();
expect(actors.size).toBe(3);
expect(actors.has('User')).toBe(true);
expect(actors.has('Admin')).toBe(true);
expect(actors.has('Guest')).toBe(true);
});
it('should parse actor with simple name', async () => {
await Diagram.fromText(
`usecase
actor SystemUser`
);
const actors = db.getActors();
expect(actors.size).toBe(1);
expect(actors.has('SystemUser')).toBe(true);
});
});
describe('when parsing use cases', () => {
it('should parse use cases from relationships', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login`
);
const useCases = db.getUseCases();
expect(useCases.size).toBe(1);
expect(useCases.has('Login')).toBe(true);
});
it('should parse multiple use cases from relationships', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login
User --> Logout
User --> Register`
);
const useCases = db.getUseCases();
expect(useCases.size).toBe(3);
expect(useCases.has('Login')).toBe(true);
expect(useCases.has('Logout')).toBe(true);
expect(useCases.has('Register')).toBe(true);
});
it('should parse use case from relationship', async () => {
await Diagram.fromText(
`usecase
actor User
User --> UserLoginProcess`
);
const useCases = db.getUseCases();
expect(useCases.size).toBe(1);
expect(useCases.has('UserLoginProcess')).toBe(true);
});
it('should parse use cases with quoted names', async () => {
await Diagram.fromText(
`usecase
actor "Customer Service"
actor "System Administrator"
"Customer Service" --> "Handle Tickets"
"System Administrator" --> "Manage System"`
);
const actors = db.getActors();
expect(actors.size).toBe(2);
// IDs are generated with underscores replacing spaces
expect(actors.has('Customer_Service')).toBe(true);
expect(actors.has('System_Administrator')).toBe(true);
// But names should preserve the original text
expect(actors.get('Customer_Service')?.name).toBe('Customer Service');
expect(actors.get('System_Administrator')?.name).toBe('System Administrator');
const useCases = db.getUseCases();
expect(useCases.size).toBe(2);
expect(useCases.has('Handle_Tickets')).toBe(true);
expect(useCases.has('Manage_System')).toBe(true);
expect(useCases.get('Handle_Tickets')?.name).toBe('Handle Tickets');
expect(useCases.get('Manage_System')?.name).toBe('Manage System');
});
});
describe('when parsing relationships', () => {
it('should parse actor to use case relationship', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login`
);
const relationships = db.getRelationships();
expect(relationships.length).toBe(1);
expect(relationships[0].from).toBe('User');
expect(relationships[0].to).toBe('Login');
expect(relationships[0].type).toBe('association');
});
it('should parse multiple relationships', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login
User --> Logout`
);
const relationships = db.getRelationships();
expect(relationships.length).toBe(2);
expect(relationships[0].from).toBe('User');
expect(relationships[0].to).toBe('Login');
expect(relationships[1].from).toBe('User');
expect(relationships[1].to).toBe('Logout');
});
it('should parse relationship with label', async () => {
await Diagram.fromText(
`usecase
actor Developer
Developer --important--> WriteCode`
);
const relationships = db.getRelationships();
expect(relationships.length).toBe(1);
expect(relationships[0].label).toBe('important');
});
it('should parse different arrow types', async () => {
await Diagram.fromText(
`usecase
actor User
actor Admin
User --> Login
Admin <-- Logout
User -- ViewData`
);
const relationships = db.getRelationships();
expect(relationships.length).toBe(3);
});
});
describe('when parsing system boundaries', () => {
it('should parse a system boundary', async () => {
await Diagram.fromText(
`usecase
actor Admin, User
systemBoundary "Authentication"
Login
Logout
end
Admin --> Login
User --> Login`
);
const boundaries = db.getSystemBoundaries();
expect(boundaries.size).toBeGreaterThan(0);
});
it('should parse use cases within system boundary', async () => {
await Diagram.fromText(
`usecase
actor User
systemBoundary "Authentication System"
Login
Logout
end
User --> Login`
);
const useCases = db.getUseCases();
expect(useCases.size).toBe(2);
expect(useCases.has('Login')).toBe(true);
expect(useCases.has('Logout')).toBe(true);
});
});
describe('when parsing direction', () => {
it('should handle TB direction', async () => {
await Diagram.fromText(
`usecase
direction TB
actor User`
);
expect(db.getDirection()).toBe('TB');
});
it('should handle LR direction', async () => {
await Diagram.fromText(
`usecase
direction LR
actor User`
);
expect(db.getDirection()).toBe('LR');
});
it('should normalize TD to TB', async () => {
await Diagram.fromText(
`usecase
direction TD
actor User`
);
expect(db.getDirection()).toBe('TB');
});
});
describe('when parsing actor metadata', () => {
it('should parse actor with metadata', async () => {
await Diagram.fromText(
`usecase
actor User@{ "type" : "primary", "icon" : "user" }
User --> Login`
);
const actors = db.getActors();
expect(actors.size).toBe(1);
const user = actors.get('User');
expect(user).toBeDefined();
expect(user?.metadata).toBeDefined();
});
it('should parse multiple actors with different metadata', async () => {
await Diagram.fromText(
`usecase
actor User@{ "type" : "primary", "icon" : "user" }
actor Admin@{ "type" : "secondary", "icon" : "admin" }
User --> Login
Admin --> ManageUsers`
);
const actors = db.getActors();
expect(actors.size).toBe(2);
});
});
describe('when parsing complex diagrams', () => {
it('should parse a complete authentication system', async () => {
await Diagram.fromText(
`usecase
actor User
actor Admin
systemBoundary "Authentication System"
Login
Logout
Register
ResetPassword
end
User --> Login
User --> Register
User --> Logout
Admin --> Login`
);
const actors = db.getActors();
const useCases = db.getUseCases();
const relationships = db.getRelationships();
expect(actors.size).toBe(2);
expect(useCases.size).toBe(4);
expect(relationships.length).toBe(4);
});
it('should parse diagram with multiple arrow types', async () => {
await Diagram.fromText(
`usecase
actor User
actor Admin
User --> Login
Admin <-- Logout
User -- ViewData`
);
const relationships = db.getRelationships();
expect(relationships.length).toBe(3);
});
it('should handle use case creation from relationships', async () => {
await Diagram.fromText(
`usecase
actor Developer
Developer --> LoginSystem
Developer --> Authentication`
);
const useCases = db.getUseCases();
expect(useCases.size).toBe(2);
expect(useCases.has('LoginSystem')).toBe(true);
expect(useCases.has('Authentication')).toBe(true);
});
});
describe('when parsing class definitions', () => {
it('should handle classDef', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login
classDef important fill:#f96,stroke:#333,stroke-width:4px
class Login important`
);
const classDefs = db.getClassDefs();
expect(classDefs.size).toBeGreaterThan(0);
});
});
describe('database methods', () => {
it('should clear all data', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login`
);
expect(db.getActors().size).toBe(1);
expect(db.getUseCases().size).toBe(1);
expect(db.getRelationships().length).toBe(1);
db.clear();
expect(db.getActors().size).toBe(0);
expect(db.getUseCases().size).toBe(0);
expect(db.getRelationships().length).toBe(0);
});
it('should get specific actor by id', async () => {
await Diagram.fromText(
`usecase
actor User
actor Admin`
);
const user = db.getActor('User');
expect(user).toBeDefined();
expect(user?.id).toBe('User');
expect(user?.name).toBe('User');
});
it('should get specific use case by id', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login
User --> Logout`
);
const login = db.getUseCase('Login');
expect(login).toBeDefined();
expect(login?.id).toBe('Login');
expect(login?.name).toBe('Login');
});
it('should return undefined for non-existent actor', () => {
const actor = db.getActor('NonExistent');
expect(actor).toBeUndefined();
});
it('should return undefined for non-existent use case', () => {
const useCase = db.getUseCase('NonExistent');
expect(useCase).toBeUndefined();
});
});
describe('getData method', () => {
it('should convert diagram data to LayoutData format', async () => {
await Diagram.fromText(
`usecase
actor User
User --> Login`
);
const data = db.getData();
expect(data).toBeDefined();
expect(data.nodes).toBeDefined();
expect(data.edges).toBeDefined();
expect(data.nodes.length).toBe(2);
expect(data.edges.length).toBe(1);
expect(data.type).toBe('usecase');
});
it('should include direction in layout data', async () => {
await Diagram.fromText(
`usecase
direction LR
actor User`
);
const data = db.getData();
expect(data.direction).toBe('LR');
});
});
});

View File

@@ -0,0 +1,397 @@
import { log } from '../../logger.js';
import {
setAccTitle,
getAccTitle,
setDiagramTitle,
getDiagramTitle,
getAccDescription,
setAccDescription,
clear as commonClear,
} from '../common/commonDb.js';
import type {
UsecaseFields,
UsecaseDB,
Actor,
UseCase,
SystemBoundary,
Relationship,
ActorMetadata,
Direction,
ClassDef,
} from './usecaseTypes.js';
import { DEFAULT_DIRECTION, ARROW_TYPE } from './usecaseTypes.js';
import type { RequiredDeep } from 'type-fest';
import type { UsecaseDiagramConfig } from '../../config.type.js';
import DEFAULT_CONFIG from '../../defaultConfig.js';
import { getConfig as getGlobalConfig } from '../../diagram-api/diagramAPI.js';
import type { LayoutData, Node, ClusterNode, Edge } from '../../rendering-util/types.js';
export const DEFAULT_USECASE_CONFIG: Required<UsecaseDiagramConfig> = DEFAULT_CONFIG.usecase;
export const DEFAULT_USECASE_DB: RequiredDeep<UsecaseFields> = {
actors: new Map(),
useCases: new Map(),
systemBoundaries: new Map(),
relationships: [],
classDefs: new Map(),
direction: DEFAULT_DIRECTION,
config: DEFAULT_USECASE_CONFIG,
} as const;
let actors = new Map<string, Actor>();
let useCases = new Map<string, UseCase>();
let systemBoundaries = new Map<string, SystemBoundary>();
let relationships: Relationship[] = [];
let classDefs = new Map<string, ClassDef>();
let direction: Direction = DEFAULT_DIRECTION;
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
const getConfig = (): Required<UsecaseDiagramConfig> => structuredClone(config);
const clear = (): void => {
actors = new Map();
useCases = new Map();
systemBoundaries = new Map();
relationships = [];
classDefs = new Map();
direction = DEFAULT_DIRECTION;
commonClear();
};
// Actor management
const addActor = (actor: Actor): void => {
if (!actor.id || !actor.name) {
throw new Error(
`Invalid actor: Actor must have both id and name. Received: ${JSON.stringify(actor)}`
);
}
if (!actors.has(actor.id)) {
actors.set(actor.id, actor);
log.debug(`Added actor: ${actor.id} (${actor.name})`);
} else {
log.debug(`Actor ${actor.id} already exists`);
}
};
const getActors = (): Map<string, Actor> => actors;
const getActor = (id: string): Actor | undefined => actors.get(id);
// UseCase management
const addUseCase = (useCase: UseCase): void => {
if (!useCase.id || !useCase.name) {
throw new Error(
`Invalid use case: Use case must have both id and name. Received: ${JSON.stringify(useCase)}`
);
}
if (!useCases.has(useCase.id)) {
useCases.set(useCase.id, useCase);
log.debug(`Added use case: ${useCase.id} (${useCase.name})`);
} else {
log.debug(`Use case ${useCase.id} already exists`);
}
};
const getUseCases = (): Map<string, UseCase> => useCases;
const getUseCase = (id: string): UseCase | undefined => useCases.get(id);
// SystemBoundary management
const addSystemBoundary = (systemBoundary: SystemBoundary): void => {
if (!systemBoundary.id || !systemBoundary.name) {
throw new Error(
`Invalid system boundary: System boundary must have both id and name. Received: ${JSON.stringify(systemBoundary)}`
);
}
if (!systemBoundaries.has(systemBoundary.id)) {
systemBoundaries.set(systemBoundary.id, systemBoundary);
log.debug(`Added system boundary: ${systemBoundary.name}`);
} else {
log.debug(`System boundary ${systemBoundary.id} already exists`);
}
};
const getSystemBoundaries = (): Map<string, SystemBoundary> => systemBoundaries;
const getSystemBoundary = (id: string): SystemBoundary | undefined => systemBoundaries.get(id);
// Relationship management
const addRelationship = (relationship: Relationship): void => {
// Validate relationship structure
if (!relationship.id || !relationship.from || !relationship.to) {
throw new Error(
`Invalid relationship: Relationship must have id, from, and to fields. Received: ${JSON.stringify(relationship)}`
);
}
if (!relationship.type) {
throw new Error(
`Invalid relationship: Relationship must have a type. Received: ${JSON.stringify(relationship)}`
);
}
// Validate relationship type
const validTypes = ['association', 'include', 'extend'];
if (!validTypes.includes(relationship.type)) {
throw new Error(
`Invalid relationship type: ${relationship.type}. Valid types are: ${validTypes.join(', ')}`
);
}
// Validate arrow type if present
if (relationship.arrowType !== undefined) {
const validArrowTypes = [0, 1, 2, 3, 4, 5, 6]; // SOLID_ARROW, BACK_ARROW, LINE_SOLID, CIRCLE_ARROW, CROSS_ARROW
if (!validArrowTypes.includes(relationship.arrowType)) {
throw new Error(
`Invalid arrow type: ${relationship.arrowType}. Valid arrow types are: ${validArrowTypes.join(', ')}`
);
}
}
relationships.push(relationship);
log.debug(
`Added relationship: ${relationship.from} -> ${relationship.to} (${relationship.type})`
);
};
const getRelationships = (): Relationship[] => relationships;
// ClassDef management
const addClassDef = (classDef: ClassDef): void => {
if (!classDef.id) {
throw new Error(
`Invalid classDef: ClassDef must have an id. Received: ${JSON.stringify(classDef)}`
);
}
classDefs.set(classDef.id, classDef);
log.debug(`Added classDef: ${classDef.id}`);
};
const getClassDefs = (): Map<string, ClassDef> => classDefs;
const getClassDef = (id: string): ClassDef | undefined => classDefs.get(id);
/**
* Get compiled styles from class definitions
* Similar to flowchart's getCompiledStyles method
*/
const getCompiledStyles = (classNames: string[]): string[] => {
let compiledStyles: string[] = [];
for (const className of classNames) {
const cssClass = classDefs.get(className);
if (cssClass?.styles) {
compiledStyles = [...compiledStyles, ...(cssClass.styles ?? [])].map((s) => s.trim());
}
}
return compiledStyles;
};
// Direction management
const setDirection = (dir: Direction): void => {
// Normalize TD to TB (same as flowchart)
if (dir === 'TD') {
direction = 'TB';
} else {
direction = dir;
}
log.debug('Direction set to:', direction);
};
const getDirection = (): Direction => direction;
// Convert usecase diagram data to LayoutData format for unified rendering
const getData = (): LayoutData => {
const globalConfig = getGlobalConfig();
const nodes: Node[] = [];
const edges: Edge[] = [];
// Convert actors to nodes
for (const actor of actors.values()) {
const classesArray = ['default', 'usecase-actor'];
const cssCompiledStyles = getCompiledStyles(classesArray);
// Determine which shape to use based on whether actor has an icon
const actorShape = actor.metadata?.icon ? 'usecaseActorIcon' : 'usecaseActor';
const node: Node = {
id: actor.id,
label: actor.name,
description: actor.description ? [actor.description] : undefined,
shape: actorShape, // Use icon shape if icon is present, otherwise stick figure
isGroup: false,
padding: 10,
look: globalConfig.look,
// Add metadata as data attributes for styling
cssClasses: `usecase-actor ${
actor.metadata && Object.keys(actor.metadata).length > 0
? Object.entries(actor.metadata)
.map(([key, value]) => `actor-${key}-${value}`)
.join(' ')
: ''
}`.trim(),
cssStyles: actor.styles ?? [], // Direct styles
cssCompiledStyles, // Compiled styles from class definitions
// Pass actor metadata to the shape handler
metadata: actor.metadata,
} as Node & { metadata?: ActorMetadata };
nodes.push(node);
}
// Convert use cases to nodes
for (const useCase of useCases.values()) {
// Build CSS classes string
let cssClasses = 'usecase-element';
const classesArray = ['default', 'usecase-element'];
if (useCase.classes && useCase.classes.length > 0) {
cssClasses += ' ' + useCase.classes.join(' ');
classesArray.push(...useCase.classes);
}
// Get compiled styles from class definitions
const cssCompiledStyles = getCompiledStyles(classesArray);
const node: Node = {
id: useCase.id,
label: useCase.name,
description: useCase.description ? [useCase.description] : undefined,
shape: 'ellipse', // Use ellipse shape for use cases
isGroup: false,
padding: 10,
look: globalConfig.look,
cssClasses,
cssStyles: useCase.styles ?? [], // Direct styles
cssCompiledStyles, // Compiled styles from class definitions
// If use case belongs to a system boundary, set parentId
...(useCase.systemBoundary && { parentId: useCase.systemBoundary }),
};
nodes.push(node);
}
// Convert system boundaries to group nodes
for (const boundary of systemBoundaries.values()) {
const classesArray = [
'default',
'system-boundary',
`system-boundary-${boundary.type ?? 'rect'}`,
];
const cssCompiledStyles = getCompiledStyles(classesArray);
const node: ClusterNode & { boundaryType?: string } = {
id: boundary.id,
label: boundary.name,
shape: 'usecaseSystemBoundary', // Use custom usecase system boundary cluster shape
isGroup: true, // System boundaries are clusters (containers for other nodes)
padding: 20,
look: globalConfig.look,
cssClasses: `system-boundary system-boundary-${boundary.type ?? 'rect'}`,
cssStyles: boundary.styles ?? [], // Direct styles
cssCompiledStyles, // Compiled styles from class definitions
// Pass boundary type to the shape handler
boundaryType: boundary.type,
};
nodes.push(node);
}
// Convert relationships to edges
relationships.forEach((relationship, index) => {
// Determine arrow types based on relationship.arrowType
let arrowTypeEnd = 'none';
let arrowTypeStart = 'none';
switch (relationship.arrowType) {
case ARROW_TYPE.SOLID_ARROW: // -->
arrowTypeEnd = 'arrow_point';
break;
case ARROW_TYPE.BACK_ARROW: // <--
arrowTypeStart = 'arrow_point';
break;
case ARROW_TYPE.CIRCLE_ARROW: // --o
arrowTypeEnd = 'arrow_circle';
break;
case ARROW_TYPE.CROSS_ARROW: // --x
arrowTypeEnd = 'arrow_cross';
break;
case ARROW_TYPE.CIRCLE_ARROW_REVERSED: // o--
arrowTypeStart = 'arrow_circle';
break;
case ARROW_TYPE.CROSS_ARROW_REVERSED: // x--
arrowTypeStart = 'arrow_cross';
break;
case ARROW_TYPE.LINE_SOLID: // --
// Both remain 'none'
break;
}
const edge: Edge = {
id: relationship.id || `edge-${index}`,
start: relationship.from,
end: relationship.to,
source: relationship.from,
target: relationship.to,
label: relationship.label,
labelpos: 'c', // Center label position for proper dagre layout
type: relationship.type,
arrowTypeEnd,
arrowTypeStart,
classes: `relationship relationship-${relationship.type}`,
look: globalConfig.look,
thickness: 'normal',
pattern: 'solid',
};
edges.push(edge);
});
return {
nodes,
edges,
config: globalConfig,
// Additional properties that layout algorithms might expect
type: 'usecase',
layoutAlgorithm: 'dagre', // Default layout algorithm
direction: getDirection(), // Use the current direction setting
nodeSpacing: 50, // Default node spacing
rankSpacing: 50, // Default rank spacing
markers: ['arrow_point'], // Arrow point markers used in usecase diagrams
};
};
export const db: UsecaseDB = {
getConfig,
clear,
setDiagramTitle,
getDiagramTitle,
setAccTitle,
getAccTitle,
setAccDescription,
getAccDescription,
addActor,
getActors,
getActor,
addUseCase,
getUseCases,
getUseCase,
addSystemBoundary,
getSystemBoundaries,
getSystemBoundary,
addRelationship,
getRelationships,
addClassDef,
getClassDefs,
getClassDef,
// Direction management
setDirection,
getDirection,
// Add getData method for unified rendering
getData,
};

View File

@@ -0,0 +1,22 @@
import type {
DiagramDetector,
DiagramLoader,
ExternalDiagramDefinition,
} from '../../diagram-api/types.js';
const id = 'usecase';
const detector: DiagramDetector = (txt) => {
return /^\s*usecase/.test(txt);
};
const loader: DiagramLoader = async () => {
const { diagram } = await import('./usecaseDiagram.js');
return { id, diagram };
};
export const usecase: ExternalDiagramDefinition = {
id,
detector,
loader,
};

View File

@@ -0,0 +1,12 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
import { parser } from './usecaseParser.js';
import { db } from './usecaseDb.js';
import { renderer } from './usecaseRenderer.js';
import styles from './styles.js';
export const diagram: DiagramDefinition = {
parser,
db,
renderer,
styles,
};

View File

@@ -0,0 +1,58 @@
// Import local ANTLR parser
import { log } from '../../logger.js';
import type { ParserDefinition } from '../../diagram-api/types.js';
import { db } from './usecaseDb.js';
// Import local ANTLR parser implementation
import antlrParser from './parser/antlr/antlr-parser.js';
/**
* Parse usecase diagram using local ANTLR parser
*/
const parseUsecaseWithLocalAntlr = (input: string) => {
// Set the database instance
antlrParser.yy = db;
// Parse and return the populated database
return antlrParser.parse(input);
};
export const parser: ParserDefinition = {
parse: (input: string): void => {
log.debug('Parsing usecase diagram with local ANTLR parser:', input);
try {
// Use local ANTLR parser
parseUsecaseWithLocalAntlr(input);
log.debug('ANTLR parsing completed successfully');
} catch (error) {
log.error('Error parsing usecase diagram:', error);
// Check if it's a UsecaseParseError from our ANTLR parser
if (
error &&
typeof error === 'object' &&
'name' in error &&
error.name === 'UsecaseParseError'
) {
// Re-throw the detailed error for better error reporting
throw error;
}
// For other errors, wrap them in a generic error
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const wrappedError = new Error(`Failed to parse usecase diagram: ${errorMessage}`);
// Add hash property for consistency with other diagram types
(wrappedError as any).hash = {
text: input.split('\n')[0] || '',
token: 'unknown',
line: '1',
loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
expected: ['valid usecase syntax'],
};
throw wrappedError;
}
},
};

View File

@@ -0,0 +1,48 @@
import type { DrawDefinition } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js';
import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js';
import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import utils from '../../utils.js';
import type { UsecaseDB } from './usecaseTypes.js';
/**
* Main draw function using unified rendering system
*/
const draw: DrawDefinition = async (_text, id, _version, diag) => {
log.info('Drawing usecase diagram (unified)', id);
const { securityLevel, usecase: conf, layout } = getConfig();
// The getData method provided in all supported diagrams is used to extract the data from the parsed structure
// into the Layout data format
const usecaseDb = diag.db as UsecaseDB;
const data4Layout = usecaseDb.getData();
// Create the root SVG - the element is the div containing the SVG element
const svg = getDiagramElement(id, securityLevel);
data4Layout.type = diag.type;
data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout);
data4Layout.nodeSpacing = 50; // Default node spacing
data4Layout.rankSpacing = 50; // Default rank spacing
data4Layout.markers = ['point', 'circle', 'cross']; // Support point, circle, and cross markers
data4Layout.diagramId = id;
log.debug('Usecase layout data:', data4Layout);
// Use the unified rendering system
await render(data4Layout, svg);
const padding = 8;
utils.insertTitle(
svg,
'usecaseDiagramTitleText',
0, // Default title top margin
usecaseDb.getDiagramTitle?.() ?? ''
);
setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? false);
};
export const renderer = { draw };

View File

@@ -0,0 +1,40 @@
const getStyles = (options: any) =>
`
.actor {
stroke: ${options.primaryColor};
fill: ${options.primaryColor};
}
.actor-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 14px;
font-weight: normal;
}
.usecase {
stroke: ${options.primaryColor};
fill: ${options.primaryColor};
}
.usecase-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 12px;
font-weight: normal;
}
.relationship {
stroke: ${options.primaryColor};
fill: ${options.primaryColor};
}
.relationship-label {
fill: ${options.primaryTextColor};
font-family: ${options.fontFamily};
font-size: 10px;
font-weight: normal;
}
`;
export default getStyles;

View File

@@ -0,0 +1,113 @@
import type { DiagramDB } from '../../diagram-api/types.js';
import type { UsecaseDiagramConfig } from '../../config.type.js';
import type { LayoutData } from '../../rendering-util/types.js';
export type ActorMetadata = Record<string, string>;
export interface Actor {
id: string;
name: string;
description?: string;
metadata?: ActorMetadata;
styles?: string[]; // Direct CSS styles applied to this actor
}
export interface UseCase {
id: string;
name: string;
description?: string;
nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)')
systemBoundary?: string; // Optional reference to system boundary
classes?: string[]; // CSS classes applied to this use case
styles?: string[]; // Direct CSS styles applied to this use case
}
export type SystemBoundaryType = 'package' | 'rect';
export interface SystemBoundary {
id: string;
name: string;
useCases: string[]; // Array of use case IDs within this boundary
type?: SystemBoundaryType; // Type of boundary rendering (default: 'rect')
styles?: string[]; // Direct CSS styles applied to this system boundary
}
// Arrow types for usecase diagrams (matching parser types)
export const ARROW_TYPE = {
SOLID_ARROW: 0, // -->
BACK_ARROW: 1, // <--
LINE_SOLID: 2, // --
CIRCLE_ARROW: 3, // --o
CROSS_ARROW: 4, // --x
CIRCLE_ARROW_REVERSED: 5, // o--
CROSS_ARROW_REVERSED: 6, // x--
} as const;
export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE];
export interface Relationship {
id: string;
from: string;
to: string;
type: 'association' | 'include' | 'extend';
arrowType: ArrowType;
label?: string;
}
// Direction types for usecase diagrams
export type Direction = 'TB' | 'TD' | 'BT' | 'RL' | 'LR';
export const DEFAULT_DIRECTION: Direction = 'LR';
export interface ClassDef {
id: string;
styles: string[];
}
export interface UsecaseFields {
actors: Map<string, Actor>;
useCases: Map<string, UseCase>;
systemBoundaries: Map<string, SystemBoundary>;
relationships: Relationship[];
classDefs: Map<string, ClassDef>;
direction: Direction;
config: Required<UsecaseDiagramConfig>;
}
export interface UsecaseDB extends DiagramDB {
getConfig: () => Required<UsecaseDiagramConfig>;
// Actor management
addActor: (actor: Actor) => void;
getActors: () => Map<string, Actor>;
getActor: (id: string) => Actor | undefined;
// UseCase management
addUseCase: (useCase: UseCase) => void;
getUseCases: () => Map<string, UseCase>;
getUseCase: (id: string) => UseCase | undefined;
// SystemBoundary management
addSystemBoundary: (systemBoundary: SystemBoundary) => void;
getSystemBoundaries: () => Map<string, SystemBoundary>;
getSystemBoundary: (id: string) => SystemBoundary | undefined;
// Relationship management
addRelationship: (relationship: Relationship) => void;
getRelationships: () => Relationship[];
// ClassDef management
addClassDef: (classDef: ClassDef) => void;
getClassDefs: () => Map<string, ClassDef>;
getClassDef: (id: string) => ClassDef | undefined;
// Direction management
setDirection: (direction: Direction) => void;
getDirection: () => Direction;
// Unified rendering support
getData: () => LayoutData;
// Utility methods
clear: () => void;
}

View File

@@ -1,147 +1,12 @@
# Icon Pack Configuration
# Registering icon pack in mermaid
Mermaid supports icons through Iconify-compatible icon packs. You can register icon packs either **declaratively via configuration** (recommended for most use cases) or **programmatically via JavaScript API** (for advanced/offline scenarios).
The icon packs available can be found at [icones.js.org](https://icones.js.org/).
We use the name defined when registering the icon pack, to override the prefix field of the iconify pack. This allows the user to use shorter names for the icons. It also allows us to load a particular pack only when it is used in a diagram.
## Declarative Configuration (v<MERMAID_RELEASE_VERSION>+) (Recommended)
The easiest way to use icons in Mermaid is through declarative configuration. This works in browsers, CLI, Live Editor, and headless renders without requiring custom JavaScript.
### Basic Usage
Configure icon packs in your Mermaid config:
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:docker', label: 'Docker' }
B --> C[End]
```
Or in JavaScript:
```js
mermaid.initialize({
icons: {
packs: {
logos: '@iconify-json/logos@1',
'simple-icons': '@iconify-json/simple-icons@1',
},
},
});
```
### Package Spec Format
Icon packs are specified using **package specs** with version pinning:
- Format: `@scope/package@<version>` or `package@<version>`
- **Must include at least a major version** (e.g., `@iconify-json/logos@1`)
- Minor and patch versions are optional (e.g., `@iconify-json/logos@1.2.3`)
**Important**: Only package specs are supported. Direct URLs are not allowed for security reasons.
### Configuration Options
```yaml
icons:
packs:
# Icon pack configuration
# Key: local name to use in diagrams
# Value: package spec with version
logos: '@iconify-json/logos@1'
'simple-icons': '@iconify-json/simple-icons@1'
# CDN template for resolving package specs
# Must contain ${packageSpec} placeholder
cdnTemplate: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json'
# Maximum file size in MB for icon pack JSON files
# Range: 1-10 MB, default: 5 MB
maxFileSizeMB: 5
# Network timeout in milliseconds for icon pack fetches
# Range: 1000-30000 ms, default: 5000 ms
timeout: 5000
# List of allowed hosts to fetch icons from
allowedHosts:
- 'unpkg.com'
- 'cdn.jsdelivr.net'
- 'npmjs.com'
```
### Security Features
- **Host allowlisting**: Only fetch from hosts in `allowedHosts`
- **Size limits**: Maximum file size enforced via `maxFileSizeMB`
- **Timeouts**: Network requests timeout after specified milliseconds
- **HTTPS only**: All remote fetches occur over HTTPS
- **Version pinning**: Package specs must include version for reproducibility
### Examples
#### Using Custom CDN Template
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
cdnTemplate: "https://unpkg.com/${packageSpec}/icons.json"
---
flowchart TB
A[Start] --> B@{ icon: 'logos:aws', label: 'AWS' }
```
#### Multiple Icon Packs
```yaml
---
config:
icons:
packs:
logos: "@iconify-json/logos@1"
"simple-icons": "@iconify-json/simple-icons@1"
---
flowchart TB
A@{ icon: 'logos:docker', label: 'Docker' } --> B@{ icon: 'simple-icons:github', label: 'GitHub' }
```
#### CLI Usage
Create a `mermaid.json` config file:
```json
{
"icons": {
"packs": {
"logos": "@iconify-json/logos@1"
}
}
}
```
Then use it with the CLI:
```bash
mmdc -i diagram.mmd -o diagram.svg --configFile mermaid.json
```
## Programmatic API (v11.1.0+) (Advanced)
For advanced scenarios or offline use, you can register icon packs programmatically:
### Using JSON File from CDN
Using JSON file directly from CDN:
```js
import mermaid from 'CDN/mermaid.esm.mjs';
mermaid.registerIconPacks([
{
name: 'logos',
@@ -151,15 +16,13 @@ mermaid.registerIconPacks([
]);
```
### Using Packages with Bundler
Install the icon pack:
Using packages and a bundler:
```bash
npm install @iconify-json/logos@1
```
#### With Lazy Loading
With lazy loading
```js
import mermaid from 'mermaid';
@@ -172,39 +35,15 @@ mermaid.registerIconPacks([
]);
```
#### Without Lazy Loading
Without lazy loading
```js
import mermaid from 'mermaid';
import { icons } from '@iconify-json/logos';
mermaid.registerIconPacks([
{
name: icons.prefix, // Use the prefix defined in the icon pack
name: icons.prefix, // To use the prefix defined in the icon pack
icons,
},
]);
```
## Finding Icon Packs
Icon packs available for use can be found at [iconify.design](https://icon-sets.iconify.design/) or [icones.js.org](https://icones.js.org/).
The pack name you register is the **local name** used in diagrams. It can differ from the pack's prefix, allowing you to:
- Use shorter names (e.g., register `@iconify-json/material-design-icons` as `mdi`)
- Load specific packs only when used in diagrams (lazy loading)
## Error Handling
If an icon cannot be found:
- The diagram still renders (non-fatal)
- A fallback icon is displayed
- A warning is logged (visible in CLI stderr or browser console)
## Licensing
Iconify JSON format is MIT licensed, but **individual icons may have varying licenses**. Please verify the licenses of the icon packs you configure before use in production.
Mermaid does **not** redistribute third-party icon assets in the core bundle.

View File

@@ -41,7 +41,7 @@ Try the Ultimate AI, Mermaid, and Visual Diagramming Suite by creating an accoun
## Plans
- **Free** - A free plan that includes six diagrams.
- **Free** - A free plan that includes three diagrams.
- **Pro** - A paid plan that includes unlimited diagrams, access to the collaboration feature, and more.

View File

@@ -15,7 +15,7 @@ title: Animal example
classDiagram
note "From Duck till Zebra"
Animal <|-- Duck
note for Duck "can fly<br>can swim<br>can dive<br>can help in debugging"
note for Duck "can fly\ncan swim\ncan dive\ncan help in debugging"
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age

View File

@@ -1,560 +0,0 @@
import type { IconifyJSON } from '@iconify/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MermaidConfig } from '../config.type.js';
import {
clearIconPacks,
getIconSVG,
isIconAvailable,
registerDiagramIconPacks,
registerIconPacks,
validatePackageVersion,
} from './icons.js';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('Icons Loading', () => {
// Mock objects for reuse
const mockIcons: IconifyJSON = {
prefix: 'test',
icons: {
'test-icon': {
body: '<path d="M0 0h24v24H0z"/>',
width: 24,
height: 24,
},
},
};
const mockIconsWithMultipleIcons: IconifyJSON = {
prefix: 'test',
icons: {
'test-icon': {
body: '<path d="M0 0h24v24H0z"/>',
width: 24,
height: 24,
},
'another-icon': {
body: '<path d="M12 12h12v12H12z"/>',
width: 24,
height: 24,
},
},
};
const mockFetchResponse = {
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}
if (name === 'content-length') {
return '1024';
}
return null;
},
},
json: () => Promise.resolve(mockIcons),
};
const mockFetchResponseLarge = {
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}
if (name === 'content-length') {
return '10485760'; // 10MB
}
return null;
},
},
json: () => Promise.resolve({}),
};
const mockFetchResponseWrongContentType = {
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'text/html';
}
return null;
},
},
json: () => Promise.resolve({}),
};
const mockFetchResponseInvalidJson = {
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}
return null;
},
},
json: () => Promise.resolve({}), // Missing prefix and icons
};
const mockFetchResponseHttpError = {
ok: false,
status: 404,
statusText: 'Not Found',
};
const mockGlobalIcons: IconifyJSON = {
prefix: 'global',
icons: {
'global-icon': {
body: '<path d="M0 0h24v24H0z"/>',
width: 24,
height: 24,
},
},
};
const mockEphemeralIcons: IconifyJSON = {
prefix: 'ephemeral',
icons: {
'ephemeral-icon': {
body: '<path d="M0 0h24v24H0z"/>',
width: 24,
height: 24,
},
},
};
beforeEach(() => {
vi.clearAllMocks();
// Clear icon manager state between tests
clearIconPacks();
});
describe('validatePackageVersion', () => {
const validPackages = [
'package@1',
'package@1.2.3',
'@scope/package@1',
'@scope/package@1.2.3',
'package@1.0.0-alpha.1',
'package@1.0.0+build.1',
'@iconify-json/my-icons-package@2.1.0',
'@scope@weird/package@1.0.0', // edge case: multiple @ symbols
];
const invalidPackages = [
'package', // no @
'@scope/package', // scoped without version
'package@', // empty version
'@scope/package@', // scoped empty version
'package@ ', // whitespace version
'', // empty string
'@', // just @
'@scope@weird/package@', // multiple @ with empty version
];
it.each(validPackages)('should accept "%s"', (packageName) => {
expect(() => validatePackageVersion(packageName)).not.toThrow();
});
it.each(invalidPackages)('should reject "%s"', (packageName) => {
expect(() => validatePackageVersion(packageName)).toThrow(
/must include at least a major version/
);
});
});
describe('registerIconPacks', () => {
it('should register sync icon packs', () => {
const iconLoaders = [
{
name: 'test',
icons: mockIcons,
},
];
expect(() => registerIconPacks(iconLoaders)).not.toThrow();
});
it('should register async icon packs', () => {
const iconLoaders = [
{
name: 'test',
loader: () => Promise.resolve(mockIcons),
},
];
expect(() => registerIconPacks(iconLoaders)).not.toThrow();
});
it('should throw error for invalid icon loaders', () => {
expect(() => registerIconPacks([{ name: '', icons: {} as IconifyJSON }])).toThrow(
'Invalid icon loader. Must have a "name" property with non-empty string value.'
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => registerIconPacks([{} as unknown as any])).toThrow(
'Invalid icon loader. Must have a "name" property with non-empty string value.'
);
});
});
describe('isIconAvailable', () => {
it('should return true for available icons', async () => {
registerIconPacks([
{
name: 'test',
icons: mockIcons,
},
]);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(true);
});
it('should return false for unavailable icons', async () => {
const available = await isIconAvailable('nonexistent:icon');
expect(available).toBe(false);
});
});
describe('getIconSVG', () => {
it('should return SVG for available icons', async () => {
registerIconPacks([
{
name: 'test',
icons: mockIcons,
},
]);
const svg = await getIconSVG('test:test-icon');
expect(svg).toContain('<svg');
expect(svg).toContain('<path d="M0 0h24v24H0z"></path>');
});
it('should return unknown icon SVG for unavailable icons', async () => {
const svg = await getIconSVG('nonexistent:icon');
expect(svg).toContain('<svg');
expect(svg).toContain('?'); // unknown icon contains a question mark
});
it('should apply customisations', async () => {
registerIconPacks([
{
name: 'test',
icons: mockIcons,
},
]);
const svg = await getIconSVG('test:test-icon', { width: 32, height: 32 });
expect(svg).toContain('width="32"');
expect(svg).toContain('height="32"');
});
});
describe('registerDiagramIconPacks', () => {
beforeEach(() => {
// Reset fetch mock
mockFetch.mockClear();
});
it('should register icon packs from config', () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
cdnTemplate: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json',
maxFileSizeMB: 5,
timeout: 5000,
allowedHosts: ['cdn.jsdelivr.net'],
};
expect(() => registerDiagramIconPacks(config)).not.toThrow();
});
it('should handle empty config', () => {
expect(() => registerDiagramIconPacks({})).not.toThrow();
expect(() => registerDiagramIconPacks(undefined)).not.toThrow();
});
it('should throw error for invalid package specs', () => {
const config: MermaidConfig['icons'] = {
packs: {
invalid: 'invalid-package-spec',
},
};
expect(() => registerDiagramIconPacks(config)).toThrow(
"Package name 'invalid-package-spec' must include at least a major version"
);
});
it('should throw error for direct URLs', () => {
const config: MermaidConfig['icons'] = {
packs: {
direct: 'https://example.com/icons.json',
},
};
expect(() => registerDiagramIconPacks(config)).toThrow(
"Invalid icon pack configuration for 'direct': Direct URLs are not allowed."
);
});
it('should throw error for invalid CDN template', () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
cdnTemplate: 'https://example.com/package.json', // missing ${packageSpec}
};
expect(() => registerDiagramIconPacks(config)).toThrow(
'CDN template must contain ${packageSpec} placeholder'
);
});
it('should throw error for disallowed hosts', () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
cdnTemplate: 'https://malicious.com/${packageSpec}/icons.json',
allowedHosts: ['cdn.jsdelivr.net'],
};
expect(() => registerDiagramIconPacks(config)).toThrow(
"Host 'malicious.com' is not in the allowed hosts list"
);
});
});
describe('Network fetching', () => {
it('should handle successful fetch', async () => {
mockFetch.mockResolvedValueOnce(mockFetchResponse);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(true);
});
it('should handle fetch errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValueOnce(mockFetchResponseHttpError);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should handle invalid JSON', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}
return null;
},
},
json: () => Promise.reject(new SyntaxError('Invalid JSON')),
});
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should handle wrong content type', async () => {
mockFetch.mockResolvedValueOnce(mockFetchResponseWrongContentType);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should handle file size limits', async () => {
mockFetch.mockResolvedValueOnce(mockFetchResponseLarge);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
maxFileSizeMB: 5,
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should handle timeout', async () => {
mockFetch.mockImplementationOnce(
() => new Promise((_, reject) => setTimeout(() => reject(new Error('AbortError')), 100))
);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
timeout: 50,
};
registerDiagramIconPacks(config);
// Wait for async loading
await new Promise((resolve) => setTimeout(resolve, 200));
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
it('should validate Iconify format', async () => {
mockFetch.mockResolvedValueOnce(mockFetchResponseInvalidJson);
const config: MermaidConfig['icons'] = {
packs: {
test: '@test/icons@1',
},
};
registerDiagramIconPacks(config);
const available = await isIconAvailable('test:test-icon');
expect(available).toBe(false);
});
});
describe('Configuration defaults', () => {
it('should use default CDN template', () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
};
expect(() => registerDiagramIconPacks(config)).not.toThrow();
});
it('should use default allowed hosts', () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
cdnTemplate: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json',
};
expect(() => registerDiagramIconPacks(config)).not.toThrow();
});
it('should use custom CDN template', async () => {
const config: MermaidConfig['icons'] = {
packs: {
logos: '@iconify-json/logos@1',
},
cdnTemplate: 'https://unpkg.com/${packageSpec}/icons.json',
allowedHosts: ['unpkg.com'],
};
mockFetch.mockResolvedValueOnce(mockFetchResponse);
expect(() => registerDiagramIconPacks(config)).not.toThrow();
// Trigger lazy loading by checking for an icon
await isIconAvailable('logos:some-icon');
// Verify that fetch was called with the correct unpkg URL
expect(mockFetch).toHaveBeenCalledWith(
'https://unpkg.com/@iconify-json/logos@1/icons.json',
expect.any(Object)
);
});
});
describe('Ephemeral vs Global icon managers', () => {
it('should prioritize ephemeral icon manager', async () => {
// Register global icons
registerIconPacks([
{
name: 'global',
icons: mockGlobalIcons,
},
]);
// Register ephemeral icons
registerDiagramIconPacks({
packs: {
ephemeral: '@ephemeral/icons@1',
},
});
// Mock fetch for ephemeral icons
mockFetch.mockResolvedValueOnce({
ok: true,
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}
return null;
},
},
json: () => Promise.resolve(mockEphemeralIcons),
});
// Both should be available
expect(await isIconAvailable('global:global-icon')).toBe(true);
expect(await isIconAvailable('ephemeral:ephemeral-icon')).toBe(true);
});
});
});

View File

@@ -1,8 +1,7 @@
import type { ExtendedIconifyIcon, IconifyIcon, IconifyJSON } from '@iconify/types';
import type { IconifyIconCustomisations } from '@iconify/utils';
import { getIconData, iconToHTML, iconToSVG, replaceIDs, stringToIcon } from '@iconify/utils';
import { defaultConfig, getConfig } from '../config.js';
import type { MermaidConfig } from '../config.type.js';
import { getConfig } from '../config.js';
import { sanitizeText } from '../diagrams/common/common.js';
import { log } from '../logger.js';
@@ -24,114 +23,66 @@ export const unknownIcon: IconifyIcon = {
width: 80,
};
class IconManager {
private iconsStore = new Map<string, IconifyJSON>();
private loaderStore = new Map<string, AsyncIconLoader['loader']>();
const iconsStore = new Map<string, IconifyJSON>();
const loaderStore = new Map<string, AsyncIconLoader['loader']>();
registerIconPacks(iconLoaders: IconLoader[]): void {
for (const iconLoader of iconLoaders) {
if (!iconLoader.name) {
throw new Error(
'Invalid icon loader. Must have a "name" property with non-empty string value.'
);
}
log.debug('Registering icon pack:', iconLoader.name);
if ('loader' in iconLoader) {
this.loaderStore.set(iconLoader.name, iconLoader.loader);
} else if ('icons' in iconLoader) {
this.iconsStore.set(iconLoader.name, iconLoader.icons);
} else {
log.error('Invalid icon loader:', iconLoader);
throw new Error('Invalid icon loader. Must have either "icons" or "loader" property.');
}
export const registerIconPacks = (iconLoaders: IconLoader[]) => {
for (const iconLoader of iconLoaders) {
if (!iconLoader.name) {
throw new Error(
'Invalid icon loader. Must have a "name" property with non-empty string value.'
);
}
log.debug('Registering icon pack:', iconLoader.name);
if ('loader' in iconLoader) {
loaderStore.set(iconLoader.name, iconLoader.loader);
} else if ('icons' in iconLoader) {
iconsStore.set(iconLoader.name, iconLoader.icons);
} else {
log.error('Invalid icon loader:', iconLoader);
throw new Error('Invalid icon loader. Must have either "icons" or "loader" property.');
}
}
};
clear(): void {
this.iconsStore.clear();
this.loaderStore.clear();
const getRegisteredIconData = async (iconName: string, fallbackPrefix?: string) => {
const data = stringToIcon(iconName, true, fallbackPrefix !== undefined);
if (!data) {
throw new Error(`Invalid icon name: ${iconName}`);
}
private async getRegisteredIconData(
iconName: string,
fallbackPrefix?: string
): Promise<ExtendedIconifyIcon> {
const data = stringToIcon(iconName, true, fallbackPrefix !== undefined);
if (!data) {
throw new Error(`Invalid icon name: ${iconName}`);
}
const prefix = data.prefix || fallbackPrefix;
if (!prefix) {
throw new Error(`Icon name must contain a prefix: ${iconName}`);
}
let icons = this.iconsStore.get(prefix);
if (!icons) {
const loader = this.loaderStore.get(prefix);
if (!loader) {
throw new Error(`Icon set not found: ${data.prefix}`);
}
try {
const loaded = await loader();
icons = { ...loaded, prefix };
this.iconsStore.set(prefix, icons);
} catch (e) {
log.error(e);
throw new Error(`Failed to load icon set: ${data.prefix}`);
}
}
const iconData = getIconData(icons, data.name);
if (!iconData) {
throw new Error(`Icon not found: ${iconName}`);
}
return iconData;
const prefix = data.prefix || fallbackPrefix;
if (!prefix) {
throw new Error(`Icon name must contain a prefix: ${iconName}`);
}
async isIconAvailable(iconName: string): Promise<boolean> {
let icons = iconsStore.get(prefix);
if (!icons) {
const loader = loaderStore.get(prefix);
if (!loader) {
throw new Error(`Icon set not found: ${data.prefix}`);
}
try {
await this.getRegisteredIconData(iconName);
return true;
} catch {
return false;
}
}
async getIconSVG(
iconName: string,
customisations?: IconifyIconCustomisations & { fallbackPrefix?: string },
extraAttributes?: Record<string, string>
): Promise<string> {
let iconData: ExtendedIconifyIcon;
try {
iconData = await this.getRegisteredIconData(iconName, customisations?.fallbackPrefix);
const loaded = await loader();
icons = { ...loaded, prefix };
iconsStore.set(prefix, icons);
} catch (e) {
log.error(e);
iconData = unknownIcon;
throw new Error(`Failed to load icon set: ${data.prefix}`);
}
const renderData = iconToSVG(iconData, customisations);
const svg = iconToHTML(replaceIDs(renderData.body), {
...renderData.attributes,
...extraAttributes,
});
return sanitizeText(svg, getConfig());
}
}
const globalIconManager = new IconManager();
const ephemeralIconManager = new IconManager();
export const registerIconPacks = (iconLoaders: IconLoader[]) =>
globalIconManager.registerIconPacks(iconLoaders);
export const clearIconPacks = () => {
globalIconManager.clear();
ephemeralIconManager.clear();
const iconData = getIconData(icons, data.name);
if (!iconData) {
throw new Error(`Icon not found: ${iconName}`);
}
return iconData;
};
export const isIconAvailable = async (iconName: string) => {
if (await ephemeralIconManager?.isIconAvailable(iconName)) {
try {
await getRegisteredIconData(iconName);
return true;
} catch {
return false;
}
return await globalIconManager.isIconAvailable(iconName);
};
export const getIconSVG = async (
@@ -139,180 +90,17 @@ export const getIconSVG = async (
customisations?: IconifyIconCustomisations & { fallbackPrefix?: string },
extraAttributes?: Record<string, string>
) => {
if (ephemeralIconManager && (await ephemeralIconManager.isIconAvailable(iconName))) {
return await ephemeralIconManager.getIconSVG(iconName, customisations, extraAttributes);
let iconData: ExtendedIconifyIcon;
try {
iconData = await getRegisteredIconData(iconName, customisations?.fallbackPrefix);
} catch (e) {
log.error(e);
iconData = unknownIcon;
}
return await globalIconManager.getIconSVG(iconName, customisations, extraAttributes);
const renderData = iconToSVG(iconData, customisations);
const svg = iconToHTML(replaceIDs(renderData.body), {
...renderData.attributes,
...extraAttributes,
});
return sanitizeText(svg, getConfig());
};
/**
* Validates that a package name includes at least a major version specification.
* @param packageName - The package name to validate (e.g., 'package\@1' or '\@scope/package\@1.0.0')
* @throws Error if the package name doesn't include a valid version
*/
export function validatePackageVersion(packageName: string): void {
// Accepts: package@1, @scope/package@1, package@1.2.3, @scope/package@1.2.3
// Rejects: package, @scope/package, package@, @scope/package@
const match = /^(?:@[^/]+\/)?[^@]+@\d/.exec(packageName);
if (!match) {
throw new Error(
`Package name '${packageName}' must include at least a major version (e.g., 'package@1' or '@scope/package@1.0.0')`
);
}
}
/**
* Fetches JSON data from a URL with proper error handling, size limits, and timeout
* @param url - The URL to fetch from
* @param maxFileSizeMB - Maximum file size in MB (default: 5)
* @param timeout - Network timeout in milliseconds (default: 5000)
* @returns Promise that resolves to the parsed JSON data
* @throws Error with descriptive message for various failure cases
*/
async function fetchIconsJson(
url: string,
maxFileSizeMB = 5,
timeout = 5000
): Promise<IconifyJSON> {
const controller = new AbortController();
const timeoutID = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`Failed to fetch icons from ${url}: ${response.status} ${response.statusText}`
);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new Error(`Expected JSON response from ${url}, got: ${contentType ?? 'unknown'}`);
}
const contentLength = response.headers.get('content-length');
if (contentLength) {
const sizeMB = parseInt(contentLength, 10) / (1024 * 1024);
if (sizeMB > maxFileSizeMB) {
throw new Error(
`Icon pack size (${sizeMB.toFixed(2)}MB) exceeds limit (${maxFileSizeMB}MB)`
);
}
}
const data = await response.json();
// Validate Iconify format
if (!data.prefix || !data.icons) {
throw new Error(`Invalid Iconify format: missing 'prefix' or 'icons' field`);
}
return data;
} catch (error) {
if (error instanceof TypeError) {
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms while fetching icons from ${url}`);
}
throw new TypeError(`Network error while fetching icons from ${url}: ${error.message}`);
} else if (error instanceof SyntaxError) {
throw new SyntaxError(`Invalid JSON response from ${url}: ${error.message}`);
}
throw error;
} finally {
clearTimeout(timeoutID);
}
}
/**
* Validates that a URL is from an allowed host
* @param url - The URL to validate
* @param allowedHosts - Array of allowed hosts
* @throws Error if the host is not in the allowed list
*/
function validateAllowedHost(url: string, allowedHosts: string[]): void {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
// Check if the hostname or any parent domain is in the allowed list
const isAllowed = allowedHosts.some((allowedHost) => {
return hostname === allowedHost || hostname.endsWith(`.${allowedHost}`);
});
if (!isAllowed) {
throw new Error(
`Host '${hostname}' is not in the allowed hosts list: ${allowedHosts.join(', ')}`
);
}
} catch (error) {
if (error instanceof TypeError) {
throw new Error(`Invalid URL format: ${url}`);
}
throw error;
}
}
/**
* Creates an icon loader based on package spec or URL with security validation
* @param name - The local pack name
* @param packageSpec - Package spec (e.g., '\@iconify-json/logos\@1') or HTTPS URL
* @param config - Icons configuration from MermaidConfig
* @returns IconLoader instance
* @throws Error for invalid configurations or security violations
*/
function getIconLoader(
name: string,
packageSpec: string,
config: MermaidConfig['icons']
): IconLoader {
const isUrl = packageSpec.startsWith('https://');
const allowedHosts = config?.allowedHosts ?? defaultConfig.icons?.allowedHosts ?? [];
const cdnTemplate = config?.cdnTemplate ?? defaultConfig.icons?.cdnTemplate ?? '';
const maxFileSizeMB = config?.maxFileSizeMB ?? defaultConfig.icons?.maxFileSizeMB ?? 0;
const timeout = config?.timeout ?? defaultConfig.icons?.timeout ?? 0;
if (isUrl) {
throw new Error('Direct URLs are not allowed.');
}
// Validate package version for package specs
validatePackageVersion(packageSpec);
// Build URL using CDN template
if (!cdnTemplate.includes('${packageSpec}')) {
throw new Error('CDN template must contain ${packageSpec} placeholder');
}
const url = cdnTemplate.replace('${packageSpec}', packageSpec);
// Validate the generated URL host
validateAllowedHost(url, allowedHosts);
return {
name,
loader: () => fetchIconsJson(url, maxFileSizeMB, timeout),
};
}
export function registerDiagramIconPacks(config: MermaidConfig['icons']): void {
const iconPacks: IconLoader[] = [];
for (const [name, packageSpec] of Object.entries(config?.packs ?? {})) {
try {
const iconPack = getIconLoader(name, packageSpec, config);
iconPacks.push(iconPack);
} catch (error) {
log.error(`Failed to create icon loader for '${name}':`, error);
throw new Error(
`Invalid icon pack configuration for '${name}': ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
ephemeralIconManager.clear();
if (iconPacks.length > 0) {
ephemeralIconManager.registerIconPacks(iconPacks);
}
}

View File

@@ -459,6 +459,173 @@ const divider = (parent, node) => {
return { cluster: shapeSvg, labelBBox: {} };
};
/**
* Custom cluster shape for usecase system boundaries
* Supports two types: 'rect' (dashed rectangle) and 'package' (UML package notation)
* @param {any} parent
* @param {any} node
* @returns {any} ShapeSvg
*/
const usecaseSystemBoundary = async (parent, node) => {
log.info('Creating usecase system boundary for ', node.id, node);
const siteConfig = getConfig();
const { handDrawnSeed } = siteConfig;
// Add outer g element
const shapeSvg = parent
.insert('g')
.attr('class', 'cluster usecase-system-boundary ' + node.cssClasses)
.attr('id', node.id)
.attr('data-look', node.look);
// Get boundary type from node metadata (default to 'rect')
const boundaryType = node.boundaryType || 'rect';
shapeSvg.attr('data-boundary-type', boundaryType);
const useHtmlLabels = evaluate(siteConfig.flowchart?.htmlLabels);
// Create the label
const labelEl = shapeSvg.insert('g').attr('class', 'cluster-label');
const text = await createText(labelEl, node.label, {
style: node.labelStyle,
useHtmlLabels,
isNode: true,
});
// Get the size of the label
let bbox = text.getBBox();
if (evaluate(siteConfig.flowchart?.htmlLabels)) {
const div = text.children[0];
const dv = select(text);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
// Calculate width with padding (similar to rect cluster)
const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width;
if (node.width <= bbox.width + node.padding) {
node.diff = (width - node.width) / 2 - node.padding;
} else {
node.diff = -node.padding;
}
const height = node.height;
// Use absolute coordinates from layout engine (like rect cluster does)
const x = node.x - width / 2;
const y = node.y - height / 2;
let boundaryRect;
const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig);
if (boundaryType === 'package') {
// Draw package-type boundary (rectangle with separate name box at top)
const nameBoxWidth = Math.max(80, bbox.width + 20);
const nameBoxHeight = 25;
if (node.look === 'handDrawn') {
const rc = rough.svg(shapeSvg);
const options = userNodeOverrides(node, {
stroke: 'black',
strokeWidth: 2,
fill: 'none',
seed: handDrawnSeed,
});
// Draw main boundary rectangle
const roughRect = rc.rectangle(x, y, width, height, options);
boundaryRect = shapeSvg.insert(() => roughRect, ':first-child');
// Draw name box at top-left
const roughNameBox = rc.rectangle(x, y - nameBoxHeight, nameBoxWidth, nameBoxHeight, options);
shapeSvg.insert(() => roughNameBox, ':first-child');
} else {
// Draw main boundary rectangle
boundaryRect = shapeSvg
.insert('rect', ':first-child')
.attr('x', x)
.attr('y', y)
.attr('width', width)
.attr('height', height)
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2);
// Draw name box at top-left
shapeSvg
.insert('rect', ':first-child')
.attr('x', x)
.attr('y', y - nameBoxHeight)
.attr('width', nameBoxWidth)
.attr('height', nameBoxHeight)
.attr('fill', 'white')
.attr('stroke', 'black')
.attr('stroke-width', 2);
}
// Position label in the center of the name box (using absolute coordinates)
// The name box is at (x, y - nameBoxHeight), so center the label there
labelEl.attr(
'transform',
`translate(${x + nameBoxWidth / 2 - bbox.width / 2}, ${y - nameBoxHeight})`
);
} else {
// Draw rect-type boundary (simple dashed rectangle)
if (node.look === 'handDrawn') {
const rc = rough.svg(shapeSvg);
const options = userNodeOverrides(node, {
stroke: 'black',
strokeWidth: 2,
fill: 'none',
strokeLineDash: [5, 5],
seed: handDrawnSeed,
});
const roughRect = rc.rectangle(x, y, width, height, options);
boundaryRect = shapeSvg.insert(() => roughRect, ':first-child');
} else {
// Draw dashed rectangle
boundaryRect = shapeSvg
.insert('rect', ':first-child')
.attr('x', x)
.attr('y', y)
.attr('width', width)
.attr('height', height)
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,5');
}
// Position label at top-left (using absolute coordinates, same as rect cluster)
labelEl.attr(
'transform',
`translate(${node.x - bbox.width / 2}, ${node.y - node.height / 2 + subGraphTitleTopMargin})`
);
}
// Get the bounding box of the boundary rectangle
const rectBox = boundaryRect.node().getBBox();
// Set node properties required by layout engine (similar to rect cluster)
node.offsetX = 0;
node.width = rectBox.width;
node.height = rectBox.height;
// Used by layout engine to position subgraph in parent
node.offsetY = bbox.height - node.padding / 2;
// Set intersection function for edge routing
node.intersect = function (point) {
return intersectRect(node, point);
};
// Return cluster object
return {
cluster: shapeSvg,
labelBBox: bbox,
};
};
const squareRect = rect;
const shapes = {
rect,
@@ -467,6 +634,7 @@ const shapes = {
noteGroup,
divider,
kanbanSection,
usecaseSystemBoundary,
};
let clusterElems = new Map();

View File

@@ -605,14 +605,6 @@ export const insertEdge = function (
const edgeStyles = Array.isArray(edge.style) ? edge.style : [edge.style];
let strokeColor = edgeStyles.find((style) => style?.startsWith('stroke:'));
let animationClass = '';
if (edge.animate) {
animationClass = 'edge-animation-fast';
}
if (edge.animation) {
animationClass = 'edge-animation-' + edge.animation;
}
let animatedEdge = false;
if (edge.look === 'handDrawn') {
const rc = rough.svg(elem);
@@ -628,13 +620,7 @@ export const insertEdge = function (
svgPath = select(svgPathNode)
.select('path')
.attr('id', edge.id)
.attr(
'class',
' ' +
strokeClasses +
(edge.classes ? ' ' + edge.classes : '') +
(animationClass ? ' ' + animationClass : '')
)
.attr('class', ' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : ''))
.attr('style', edgeStyles ? edgeStyles.reduce((acc, style) => acc + ';' + style, '') : '');
let d = svgPath.attr('d');
svgPath.attr('d', d);
@@ -642,6 +628,13 @@ export const insertEdge = function (
} else {
const stylesFromClasses = edgeClassStyles.join(';');
const styles = edgeStyles ? edgeStyles.reduce((acc, style) => acc + style + ';', '') : '';
let animationClass = '';
if (edge.animate) {
animationClass = ' edge-animation-fast';
}
if (edge.animation) {
animationClass = ' edge-animation-' + edge.animation;
}
const pathStyle =
(stylesFromClasses ? stylesFromClasses + ';' + styles + ';' : styles) +
@@ -653,10 +646,7 @@ export const insertEdge = function (
.attr('id', edge.id)
.attr(
'class',
' ' +
strokeClasses +
(edge.classes ? ' ' + edge.classes : '') +
(animationClass ? ' ' + animationClass : '')
' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : '') + (animationClass ?? '')
)
.attr('style', pathStyle);

View File

@@ -130,6 +130,7 @@ const lollipop = (elem, type, id) => {
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('circle')
.attr('stroke', 'black')
.attr('fill', 'transparent')
.attr('cx', 7)
.attr('cy', 7)
@@ -146,6 +147,7 @@ const lollipop = (elem, type, id) => {
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('circle')
.attr('stroke', 'black')
.attr('fill', 'transparent')
.attr('cx', 7)
.attr('cy', 7)

View File

@@ -14,6 +14,7 @@ import { curvedTrapezoid } from './shapes/curvedTrapezoid.js';
import { cylinder } from './shapes/cylinder.js';
import { dividedRectangle } from './shapes/dividedRect.js';
import { doublecircle } from './shapes/doubleCircle.js';
import { ellipse } from './shapes/ellipse.js';
import { filledCircle } from './shapes/filledCircle.js';
import { flippedTriangle } from './shapes/flippedTriangle.js';
import { forkJoin } from './shapes/forkJoin.js';
@@ -32,6 +33,8 @@ import { lean_right } from './shapes/leanRight.js';
import { lightningBolt } from './shapes/lightningBolt.js';
import { linedCylinder } from './shapes/linedCylinder.js';
import { linedWaveEdgedRect } from './shapes/linedWaveEdgedRect.js';
import { usecaseActor } from './shapes/usecaseActor.js';
import { usecaseActorIcon } from './shapes/usecaseActorIcon.js';
import { multiRect } from './shapes/multiRect.js';
import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js';
import { note } from './shapes/note.js';
@@ -115,6 +118,14 @@ export const shapesDefs = [
aliases: ['terminal', 'pill'],
handler: stadium,
},
{
semanticName: 'Ellipse',
name: 'Ellipse',
shortName: 'ellipse',
description: 'Ellipse shape',
aliases: ['oval'],
handler: ellipse,
},
{
semanticName: 'Subprocess',
name: 'Framed Rectangle',
@@ -507,6 +518,10 @@ const generateShapeMap = () => {
// Requirement diagram
requirementBox,
// Usecase diagram
usecaseActor,
usecaseActorIcon,
} as const;
const entries = [

View File

@@ -0,0 +1,60 @@
import rough from 'roughjs';
import type { Bounds, D3Selection, Point } from '../../../types.js';
import type { Node } from '../../types.js';
import intersect from '../intersect/index.js';
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js';
export async function ellipse<T extends SVGGraphicsElement>(parent: D3Selection<T>, node: Node) {
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node));
// Calculate ellipse dimensions with padding
const padding = halfPadding ?? 10;
const radiusX = bbox.width / 2 + padding * 2;
const radiusY = bbox.height / 2 + padding;
let ellipseElem;
const { cssStyles } = node;
if (node.look === 'handDrawn') {
// @ts-expect-error -- Passing a D3.Selection seems to work for some reason
const rc = rough.svg(shapeSvg);
const options = userNodeOverrides(node, {});
const roughNode = rc.ellipse(0, 0, radiusX * 2, radiusY * 2, options);
ellipseElem = shapeSvg.insert(() => roughNode, ':first-child');
ellipseElem.attr('class', 'basic label-container');
if (cssStyles) {
ellipseElem.attr('style', cssStyles);
}
} else {
ellipseElem = shapeSvg
.insert('ellipse', ':first-child')
.attr('class', 'basic label-container')
.attr('style', nodeStyles)
.attr('rx', radiusX)
.attr('ry', radiusY)
.attr('cx', 0)
.attr('cy', 0);
}
node.width = radiusX * 2;
node.height = radiusY * 2;
updateNodeBounds(node, ellipseElem);
node.calcIntersect = function (bounds: Bounds, point: Point) {
const rx = bounds.width / 2;
const ry = bounds.height / 2;
return intersect.ellipse(bounds, rx, ry, point);
};
node.intersect = function (point) {
return intersect.ellipse(node, radiusX, radiusY, point);
};
return shapeSvg;
}

View File

@@ -0,0 +1,168 @@
import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js';
import type { Node } from '../../types.js';
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import rough from 'roughjs';
import type { D3Selection } from '../../../types.js';
import intersect from '../intersect/index.js';
/**
* Get actor styling based on metadata
*/
const getActorStyling = (metadata?: Record<string, string>) => {
const defaults = {
fillColor: 'none',
strokeColor: 'black',
strokeWidth: 2,
type: 'solid',
};
if (!metadata) {
return defaults;
}
return {
fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
strokeColor: metadata.strokeColor || defaults.strokeColor,
strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
type: metadata.type || defaults.type,
};
};
/**
* Create stick figure path data
* This generates the SVG path for a stick figure centered at (x, y)
*/
const createStickFigurePathD = (x: number, y: number, scale = 1.5): string => {
// Base path template (centered at origin):
// M 0 -4 C 4.4183 -4 8 -7.5817 8 -12 C 8 -16.4183 4.4183 -20 0 -20 C -4.4183 -20 -8 -16.4183 -8 -12 C -8 -7.5817 -4.4183 -4 0 -4 Z M 0 -4 V 5 M -10 14.5 L 0 5 M 10 14.5 L 0 5 M -11 0 H 11
// Scale all coordinates
const s = (val: number) => val * scale;
// Translate the path to the desired position
return [
// Head (circle using cubic bezier curves)
`M ${x + s(0)} ${y + s(-4)}`,
`C ${x + s(4.4183)} ${y + s(-4)} ${x + s(8)} ${y + s(-7.5817)} ${x + s(8)} ${y + s(-12)}`,
`C ${x + s(8)} ${y + s(-16.4183)} ${x + s(4.4183)} ${y + s(-20)} ${x + s(0)} ${y + s(-20)}`,
`C ${x + s(-4.4183)} ${y + s(-20)} ${x + s(-8)} ${y + s(-16.4183)} ${x + s(-8)} ${y + s(-12)}`,
`C ${x + s(-8)} ${y + s(-7.5817)} ${x + s(-4.4183)} ${y + s(-4)} ${x + s(0)} ${y + s(-4)}`,
'Z',
// Body (vertical line from head to torso)
`M ${x + s(0)} ${y + s(-4)}`,
`V ${y + s(5)}`,
// Left leg
`M ${x + s(-10)} ${y + s(14.5)}`,
`L ${x + s(0)} ${y + s(5)}`,
// Right leg
`M ${x + s(10)} ${y + s(14.5)}`,
`L ${x + s(0)} ${y + s(5)}`,
// Arms (horizontal line)
`M ${x + s(-11)} ${y + s(0)}`,
`H ${x + s(11)}`,
].join(' ');
};
/**
* Draw traditional stick figure
*/
const drawStickFigure = (
actorGroup: D3Selection<SVGGElement>,
styling: ReturnType<typeof getActorStyling>,
node: Node
): void => {
const x = 0; // Center at origin
const y = -10; // Adjust vertical position
actorGroup.attr('class', 'usecase-actor-shape');
const pathData = createStickFigurePathD(x, y);
if (node.look === 'handDrawn') {
// @ts-expect-error -- Passing a D3.Selection seems to work for some reason
const rc = rough.svg(actorGroup);
const options = userNodeOverrides(node, {
stroke: styling.strokeColor,
strokeWidth: styling.strokeWidth,
fill: styling.fillColor,
});
// Draw the stick figure using the path
const stickFigure = rc.path(pathData, options);
actorGroup.insert(() => stickFigure, ':first-child');
} else {
// Draw the stick figure using standard SVG path
actorGroup
.append('path')
.attr('d', pathData)
.attr('fill', styling.fillColor)
.attr('stroke', styling.strokeColor)
.attr('stroke-width', styling.strokeWidth);
}
};
/**
* Custom shape handler for usecase actors (stick figure)
*/
export async function usecaseActor<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
node: Node
) {
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node));
// Get actor metadata from node
const metadata = (node as Node & { metadata?: Record<string, string> }).metadata;
const styling = getActorStyling(metadata);
// Create actor group
const actorGroup = shapeSvg.append('g');
// Add metadata as data attributes for CSS styling
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
actorGroup.attr(`data-${key}`, value);
});
}
// Draw stick figure
drawStickFigure(actorGroup, styling, node);
// Get the actual bounding box of the rendered actor
const actorBBox = actorGroup.node()?.getBBox();
const actorHeight = actorBBox?.height ?? 70;
// Actor name (always rendered below the figure)
const labelY = actorHeight / 2 + 15; // Position label below the figure
// Calculate label height from the actual text element
const labelBBox = label.node()?.getBBox() ?? { height: 20 };
const labelHeight = labelBBox.height + 10; // Space for label below
const totalHeight = actorHeight + labelHeight;
actorGroup.attr('transform', `translate(${0}, ${-totalHeight / 2 + 35})`);
label.attr(
'transform',
`translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2 - 15} )`
);
if (nodeStyles && node.look !== 'handDrawn') {
actorGroup.selectChildren('path').attr('style', nodeStyles);
}
// Update node bounds for layout - this will set node.width and node.height from the bounding box
updateNodeBounds(node, actorGroup);
// Override height to include label space
// Width is kept from updateNodeBounds as it correctly reflects the actor's visual width
node.height = totalHeight;
// Add intersect function for edge connection points
// Use rectangular intersection since the actor has a rectangular bounding box
node.intersect = function (point) {
return intersect.rect(node, point);
};
return shapeSvg;
}

View File

@@ -0,0 +1,156 @@
import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js';
import type { Node } from '../../types.js';
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import { getIconSVG } from '../../icons.js';
import rough from 'roughjs';
import type { D3Selection } from '../../../types.js';
import intersect from '../intersect/index.js';
/**
* Get actor styling based on metadata
*/
const getActorStyling = (metadata?: Record<string, string>) => {
const defaults = {
fillColor: 'none',
strokeColor: 'black',
strokeWidth: 2,
type: 'solid',
};
if (!metadata) {
return defaults;
}
return {
fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
strokeColor: metadata.strokeColor || defaults.strokeColor,
strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
type: metadata.type || defaults.type,
};
};
/**
* Draw actor with icon representation
*/
const drawActorWithIcon = async (
actorGroup: D3Selection<SVGGElement>,
iconName: string,
styling: ReturnType<typeof getActorStyling>,
node: Node
): Promise<void> => {
const x = 0; // Center at origin
const y = -10; // Adjust vertical position
const iconSize = 50; // Icon size
if (node.look === 'handDrawn') {
// @ts-expect-error -- Passing a D3.Selection seems to work for some reason
const rc = rough.svg(actorGroup);
const options = userNodeOverrides(node, {
stroke: styling.strokeColor,
strokeWidth: styling.strokeWidth,
fill: styling.fillColor === 'none' ? 'white' : styling.fillColor,
});
actorGroup.attr('class', 'usecase-icon');
// Create a rectangle background for the icon
const iconBg = rc.rectangle(x - 35, y - 40, 50, 50, options);
actorGroup.insert(() => iconBg, ':first-child');
} else {
// Create a rectangle background for the icon
actorGroup
.append('rect')
.attr('x', x - 27.5)
.attr('y', y - 42)
.attr('width', 55)
.attr('height', 55)
.attr('rx', 5)
.attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor)
.attr('stroke', styling.strokeColor)
.attr('stroke-width', styling.strokeWidth);
}
// Add icon using getIconSVG (like iconCircle.ts does)
const iconElem = actorGroup.append('g').attr('class', 'actor-icon');
iconElem.html(
`<g>${await getIconSVG(iconName, {
height: iconSize,
width: iconSize,
fallbackPrefix: 'fa',
})}</g>`
);
// Get icon bounding box for positioning
const iconBBox = iconElem.node()?.getBBox();
if (iconBBox) {
const iconWidth = iconBBox.width;
const iconHeight = iconBBox.height;
const iconX = iconBBox.x;
const iconY = iconBBox.y;
// Center the icon in the rectangle
iconElem.attr(
'transform',
`translate(${-iconWidth / 2 - iconX}, ${y - 15 - iconHeight / 2 - iconY})`
);
}
};
/**
* Custom shape handler for usecase actors with icons
*/
export async function usecaseActorIcon<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
node: Node
) {
const { labelStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node));
// Get actor metadata from node
const metadata = (node as Node & { metadata?: Record<string, string> }).metadata;
const styling = getActorStyling(metadata);
// Create actor group
const actorGroup = shapeSvg.append('g');
// Add metadata as data attributes for CSS styling
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
actorGroup.attr(`data-${key}`, value);
});
}
// Get icon name from metadata
const iconName = metadata?.icon ?? 'user';
await drawActorWithIcon(actorGroup, iconName, styling, node);
// Get the actual bounding box of the rendered actor icon
const actorBBox = actorGroup.node()?.getBBox();
const actorHeight = actorBBox?.height ?? 70;
// Actor name (always rendered below the figure)
const labelY = actorHeight / 2 + 15; // Position label below the figure
// Calculate label height from the actual text element
const labelBBox = label.node()?.getBBox() ?? { height: 20 };
const labelHeight = labelBBox.height + 10; // Space for label below
const totalHeight = actorHeight + labelHeight;
label.attr(
'transform',
`translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2 - 15})`
);
// Update node bounds for layout - this will set node.width and node.height from the bounding box
updateNodeBounds(node, actorGroup);
// Override height to include label space
// Width is kept from updateNodeBounds as it correctly reflects the actor's visual width
node.height = totalHeight;
// Add intersect function for edge connection points
// Use rectangular intersection for icon actors
node.intersect = function (point) {
return intersect.rect(node, point);
};
return shapeSvg;
}

View File

@@ -56,6 +56,7 @@ required:
- block
- look
- radar
- usecase
properties:
theme:
description: |
@@ -310,6 +311,8 @@ properties:
$ref: '#/$defs/BlockDiagramConfig'
radar:
$ref: '#/$defs/RadarDiagramConfig'
usecase:
$ref: '#/$defs/UsecaseDiagramConfig'
dompurifyConfig:
title: DOM Purify Configuration
description: Configuration options to pass to the `dompurify` library.
@@ -329,8 +332,6 @@ properties:
description: |
Suppresses inserting 'Syntax error' diagram in the DOM.
This is useful when you want to control how to handle syntax errors in your application.
icons:
$ref: '#/$defs/IconsConfig'
$defs: # JSON Schema definition (maybe we should move these to a separate file)
BaseDiagramConfig:
@@ -2331,6 +2332,57 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
maximum: 1
default: 0.17
UsecaseDiagramConfig:
title: Usecase Diagram Config
allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
description: The object containing configurations specific for usecase diagrams.
type: object
unevaluatedProperties: false
required:
- useMaxWidth
properties:
actorFontSize:
description: Font size for actor labels
type: number
minimum: 1
default: 14
actorFontFamily:
description: Font family for actor labels
type: string
default: '"Open Sans", sans-serif'
actorFontWeight:
description: Font weight for actor labels
type: string
default: 'normal'
usecaseFontSize:
description: Font size for usecase labels
type: number
minimum: 1
default: 12
usecaseFontFamily:
description: Font family for usecase labels
type: string
default: '"Open Sans", sans-serif'
usecaseFontWeight:
description: Font weight for usecase labels
type: string
default: 'normal'
actorMargin:
description: Margin around actors
type: number
minimum: 0
default: 50
usecaseMargin:
description: Margin around use cases
type: number
minimum: 0
default: 50
diagramPadding:
description: Padding around the entire diagram
type: number
minimum: 0
default: 20
FontCalculator:
title: Font Calculator
description: |
@@ -2370,49 +2422,3 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
description: The font weight to use.
type: ['string', 'number']
default: normal
IconsConfig:
title: Icons Config
description: |
Configuration for icon packs and CDN template.
Enables icons in browsers and CLI/headless renders without custom JavaScript.
type: object
properties:
packs:
description: |
Icon pack configuration. Key is the local pack name.
Value is a package spec with version that complies with Iconify standards.
Package specs must include at least a major version (e.g., '@iconify-json/logos@1').
type: object
additionalProperties:
type: string
cdnTemplate:
description: |
URL template for resolving package specs (must contain ${packageSpec}).
Used to build URLs for package specs in icons.packs.
type: string
pattern: '^https://.*\$\{packageSpec\}.*$'
default: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json'
maxFileSizeMB:
description: |
Maximum file size in MB for icon pack JSON files.
type: integer
default: 5
minimum: 1
maximum: 10
timeout:
description: |
Network timeout in milliseconds for icon pack fetches.
type: integer
default: 5000
minimum: 1000
maximum: 30000
allowedHosts:
description: |
List of allowed hosts to fetch icons from
type: array
items:
type: string
default:
- 'unpkg.com'
- 'cdn.jsdelivr.net'
- 'npmjs.com'

View File

@@ -35,11 +35,6 @@ export const sanitizeDirective = (args: any): void => {
continue;
}
if (key === 'icons') {
// Skip icons key as it is handled by the registerDiagramIconPacks function
continue;
}
// Recurse if an object
if (typeof args[key] === 'object') {
log.debug('sanitizing object', key);

View File

@@ -1,15 +1,5 @@
# @mermaid-js/parser
## 0.6.3
### Patch Changes
- [#7051](https://github.com/mermaid-js/mermaid/pull/7051) [`63df702`](https://github.com/mermaid-js/mermaid/commit/63df7021462e8dc1f2aaecb9c5febbbbde4c38e3) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - Add validation for negative values in pie charts:
Prevents crashes during parsing by validating values post-parsing.
Provides clearer, user-friendly error messages for invalid negative inputs.
## 0.6.2
### Patch Changes

View File

@@ -0,0 +1,5 @@
{
"projectName": "Mermaid",
"grammars": [],
"mode": "production"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@mermaid-js/parser",
"version": "0.6.3",
"version": "0.6.2",
"description": "MermaidJS parser",
"author": "Yokozuna59",
"contributors": [
@@ -19,7 +19,8 @@
"scripts": {
"clean": "rimraf dist src/language/generated",
"langium:generate": "langium generate",
"langium:watch": "langium generate --watch"
"langium:watch": "langium generate --watch",
"antlr:generate": "tsx ../../.build/antlr-cli.ts antlr-config.json"
},
"repository": {
"type": "git",
@@ -33,9 +34,11 @@
"ast"
],
"dependencies": {
"antlr4ng": "^3.0.7",
"langium": "3.3.1"
},
"devDependencies": {
"antlr-ng": "^1.0.10",
"chevrotain": "^11.0.3"
},
"files": [

View File

@@ -2,7 +2,8 @@ import type { LangiumParser, ParseResult } from 'langium';
import type { Info, Packet, Pie, Architecture, GitGraph, Radar, Treemap } from './index.js';
export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar;
export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | Treemap;
export type LangiumDiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | Treemap;
const parsers: Record<string, LangiumParser> = {};
const initializers = {
@@ -51,7 +52,7 @@ export async function parse(diagramType: 'gitGraph', text: string): Promise<GitG
export async function parse(diagramType: 'radar', text: string): Promise<Radar>;
export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>;
export async function parse<T extends DiagramAST>(
export async function parse<T extends LangiumDiagramAST>(
diagramType: keyof typeof initializers,
text: string
): Promise<T> {
@@ -59,6 +60,7 @@ export async function parse<T extends DiagramAST>(
if (!initializer) {
throw new Error(`Unknown diagram type: ${diagramType}`);
}
if (!parsers[diagramType]) {
await initializer();
}

View File

@@ -1,30 +1,5 @@
# mermaid
## 11.12.1
### Patch Changes
- [#7107](https://github.com/mermaid-js/mermaid/pull/7107) [`cbf8946`](https://github.com/mermaid-js/mermaid/commit/cbf89462acecac7a06f19843e8d48cb137df0753) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - fix: Updated the dependency dagre-d3-es to 7.0.13 to fix GHSA-cc8p-78qf-8p7q
## 11.12.0
### Minor Changes
- [#6921](https://github.com/mermaid-js/mermaid/pull/6921) [`764b315`](https://github.com/mermaid-js/mermaid/commit/764b315dc16d0359add7c6b8e3ef7592e9bdc09c) Thanks [@quilicicf](https://github.com/quilicicf)! - feat: Add IDs in architecture diagrams
### Patch Changes
- [#6950](https://github.com/mermaid-js/mermaid/pull/6950) [`a957908`](https://github.com/mermaid-js/mermaid/commit/a9579083bfba367a4f4678547ec37ed7b61b9f5b) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: Fix mindmap rendering in docs and apply tidytree layout
- [#6826](https://github.com/mermaid-js/mermaid/pull/6826) [`1d36810`](https://github.com/mermaid-js/mermaid/commit/1d3681053b9168354e48e5763023b6305cd1ca72) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Ensure edge label color is applied when using classDef with edge IDs
- [#6945](https://github.com/mermaid-js/mermaid/pull/6945) [`d318f1a`](https://github.com/mermaid-js/mermaid/commit/d318f1a13cd7429334a29c70e449074ec1cb9f68) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Resolve gantt chart crash due to invalid array length
- [#6918](https://github.com/mermaid-js/mermaid/pull/6918) [`cfe9238`](https://github.com/mermaid-js/mermaid/commit/cfe9238882cbe95416db1feea3112456a71b6aaf) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: revert marked dependency from ^15.0.7 to ^16.0.0
- Reverted marked package version to ^16.0.0 for better compatibility
- This is a dependency update that maintains API compatibility
- All tests pass with the updated version
## 11.11.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@mermaid-js/tiny",
"version": "11.12.1",
"version": "11.11.0",
"description": "Tiny version of mermaid",
"type": "commonjs",
"main": "./dist/mermaid.tiny.js",

100
pnpm-lock.yaml generated
View File

@@ -235,6 +235,9 @@ importers:
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
antlr4ng:
specifier: ^3.0.7
version: 3.0.16
cytoscape:
specifier: ^3.33.1
version: 3.33.1
@@ -251,8 +254,8 @@ importers:
specifier: ^0.12.3
version: 0.12.3
dagre-d3-es:
specifier: 7.0.13
version: 7.0.13
specifier: 7.0.11
version: 7.0.11
dayjs:
specifier: ^1.11.18
version: 1.11.18
@@ -535,10 +538,16 @@ importers:
packages/parser:
dependencies:
antlr4ng:
specifier: ^3.0.7
version: 3.0.16
langium:
specifier: 3.3.1
version: 3.3.1
devDependencies:
antlr-ng:
specifier: ^1.0.10
version: 1.0.10
chevrotain:
specifier: ^11.0.3
version: 11.0.3
@@ -3908,10 +3917,20 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
antlr-ng@1.0.10:
resolution: {integrity: sha512-fw3NdsQP3dabuZrDhKAMewrBsY5KSAcMrvhWBVDmHYegv5D51pypzCYK1PpjaRVKcVeP/5xKfqJY31TvXACOdA==}
hasBin: true
antlr4@4.11.0:
resolution: {integrity: sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w==}
engines: {node: '>=14'}
antlr4ng@3.0.15:
resolution: {integrity: sha512-VELFqTfcpGI2bj6ScMWuxM3FI6HOsojrgmnw3cCbUtsQ1DNOq32wJsjOt7vLvfIniyyuE1DIYegGcuFmn+jgyw==}
antlr4ng@3.0.16:
resolution: {integrity: sha512-DQuJkC7kX3xunfF4K2KsWTSvoxxslv+FQp/WHQZTJSsH2Ec3QfFmrxC3Nky2ok9yglXn6nHM4zUaVDxcN5f6kA==}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -4475,6 +4494,10 @@ packages:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@14.0.1:
resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==}
engines: {node: '>=20'}
@@ -4900,8 +4923,8 @@ packages:
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
engines: {node: '>=12'}
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
dagre-d3-es@7.0.11:
resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==}
dashdash@1.14.1:
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
@@ -5608,6 +5631,10 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-printf@1.6.10:
resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==}
engines: {node: '>=10.0'}
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
@@ -6076,6 +6103,10 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -7054,6 +7085,10 @@ packages:
lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@@ -7712,6 +7747,9 @@ packages:
package-manager-detector@1.3.0:
resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -8773,6 +8811,9 @@ packages:
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
engines: {node: '>=4'}
stringtemplate4ts@1.0.9:
resolution: {integrity: sha512-KYZm2bJlSjynG5Y+L46fkaKBQG6mhV6hb2RBA8dpx3/Vj6G4u7gwXNKYvaN9+QD5sj68/1srtSNDvqEso7MwsQ==}
strip-ansi@3.0.1:
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
engines: {node: '>=0.10.0'}
@@ -8960,6 +9001,9 @@ packages:
thunky@1.1.0:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -9208,10 +9252,16 @@ packages:
resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==}
engines: {node: '>=4'}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-property-aliases-ecmascript@2.2.0:
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
engines: {node: '>=4'}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -13966,8 +14016,19 @@ snapshots:
ansi-styles@6.2.3: {}
antlr-ng@1.0.10:
dependencies:
antlr4ng: 3.0.16
commander: 13.1.0
stringtemplate4ts: 1.0.9
unicode-properties: 1.4.1
antlr4@4.11.0: {}
antlr4ng@3.0.15: {}
antlr4ng@3.0.16: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -14576,6 +14637,8 @@ snapshots:
commander@12.1.0: {}
commander@13.1.0: {}
commander@14.0.1: {}
commander@2.20.3: {}
@@ -15161,7 +15224,7 @@ snapshots:
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-zoom: 3.0.0
dagre-d3-es@7.0.13:
dagre-d3-es@7.0.11:
dependencies:
d3: 7.9.0
lodash-es: 4.17.21
@@ -16056,6 +16119,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-printf@1.6.10: {}
fast-querystring@1.1.2:
dependencies:
fast-decode-uri-component: 1.0.1
@@ -16611,6 +16676,8 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
he@1.2.0: {}
highlight.js@10.7.3: {}
hookable@5.5.3: {}
@@ -17785,6 +17852,8 @@ snapshots:
lunr@2.3.9: {}
luxon@3.5.0: {}
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
@@ -18631,6 +18700,8 @@ snapshots:
package-manager-detector@1.3.0: {}
pako@0.2.9: {}
pako@1.0.11: {}
pako@2.1.0: {}
@@ -19887,6 +19958,13 @@ snapshots:
is-obj: 1.0.1
is-regexp: 1.0.0
stringtemplate4ts@1.0.9:
dependencies:
antlr4ng: 3.0.15
fast-printf: 1.6.10
he: 1.2.0
luxon: 3.5.0
strip-ansi@3.0.1:
dependencies:
ansi-regex: 2.1.1
@@ -20099,6 +20177,8 @@ snapshots:
thunky@1.1.0: {}
tiny-inflate@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -20322,8 +20402,18 @@ snapshots:
unicode-match-property-value-ecmascript@2.2.1: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-property-aliases-ecmascript@2.2.0: {}
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unicorn-magic@0.3.0: {}
unified@11.0.5:

230
scripts/antlr-generate.mts Normal file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env tsx
/* eslint-disable no-console */
/* cspell:disable */
import { execSync } from 'child_process';
import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, dirname, basename } from 'path';
/**
* Generic ANTLR generator script that finds all .g4 files and generates parsers
* Automatically creates generated folders and runs antlr4ng for each diagram type
*/
interface GrammarInfo {
lexerFile: string;
parserFile: string;
outputDir: string;
diagramType: string;
}
/**
* Recursively find all .g4 files in a directory
*/
function findG4Files(dir: string): string[] {
const files: string[] = [];
if (!existsSync(dir)) {
return files;
}
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...findG4Files(fullPath));
} else if (entry.endsWith('.g4')) {
files.push(fullPath);
}
}
return files;
}
/**
* Find all ANTLR grammar files in the diagrams directory
*/
function findGrammarFiles(): GrammarInfo[] {
const grammarFiles: GrammarInfo[] = [];
// Determine the correct path based on current working directory
const cwd = process.cwd();
let diagramsPath: string;
if (cwd.endsWith('/packages/mermaid')) {
// Running from mermaid package directory
diagramsPath = 'src/diagrams';
} else {
// Running from project root
diagramsPath = 'packages/mermaid/src/diagrams';
}
// Find all .g4 files
const g4Files = findG4Files(diagramsPath);
// Group by directory (each diagram should have a Lexer and Parser pair)
const grammarDirs = new Map<string, string[]>();
for (const file of g4Files) {
const dir = dirname(file);
if (!grammarDirs.has(dir)) {
grammarDirs.set(dir, []);
}
grammarDirs.get(dir)!.push(file);
}
// Process each directory
for (const [dir, files] of grammarDirs) {
const lexerFile = files.find((f) => f.includes('Lexer.g4'));
const parserFile = files.find((f) => f.includes('Parser.g4'));
if (lexerFile && parserFile) {
// Extract diagram type from path
const pathParts = dir.split('/');
const diagramIndex = pathParts.indexOf('diagrams');
const diagramType = diagramIndex >= 0 ? pathParts[diagramIndex + 1] : 'unknown';
grammarFiles.push({
lexerFile,
parserFile,
outputDir: join(dir, 'generated'),
diagramType,
});
} else {
console.warn(`⚠️ Incomplete grammar pair in ${dir}:`);
console.warn(` Lexer: ${lexerFile ?? 'MISSING'}`);
console.warn(` Parser: ${parserFile ?? 'MISSING'}`);
}
}
return grammarFiles;
}
/**
* Clean the generated directory
*/
function cleanGeneratedDir(outputDir: string): void {
try {
execSync(`rimraf "${outputDir}"`, { stdio: 'inherit' });
console.log(`🧹 Cleaned: ${outputDir}`);
} catch (error) {
console.warn(`⚠️ Failed to clean ${outputDir}:`, error);
}
}
/**
* Create the generated directory if it doesn't exist
*/
function ensureGeneratedDir(outputDir: string): void {
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
console.log(`📁 Created: ${outputDir}`);
}
}
/**
* Generate ANTLR files for a grammar pair
*/
function generateAntlrFiles(grammar: GrammarInfo): void {
const { lexerFile, parserFile, outputDir, diagramType } = grammar;
console.log(`\n🎯 Generating ANTLR files for ${diagramType} diagram...`);
console.log(` Lexer: ${basename(lexerFile)}`);
console.log(` Parser: ${basename(parserFile)}`);
console.log(` Output: ${outputDir}`);
try {
// Clean and create output directory
cleanGeneratedDir(outputDir);
ensureGeneratedDir(outputDir);
// Determine common header lib path for imported grammars
const cwd = process.cwd();
const commonLibPath = cwd.endsWith('/packages/mermaid')
? 'src/diagrams/common/parser/antlr'
: 'packages/mermaid/src/diagrams/common/parser/antlr';
// TODO: Use JS api instead of CLI
// Generate ANTLR files
const command = [
'pnpm',
'dlx',
'antlr-ng',
'-Dlanguage=TypeScript',
'-l',
'-v',
`--lib "${commonLibPath}"`,
`-o "${outputDir}"`,
`"${lexerFile}"`,
`"${parserFile}"`,
].join(' ');
console.log(` Command: ${command}`);
execSync(command, { stdio: 'inherit' });
console.log(`✅ Successfully generated ANTLR files for ${diagramType}`);
} catch (error) {
console.error(`❌ Failed to generate ANTLR files for ${diagramType}:`, error);
throw error;
}
}
/**
* Main function
*/
export function generateAntlr(): void {
console.log('🚀 ANTLR Generator - Finding and generating all grammar files...\n');
try {
// Find all grammar files
const grammarFiles = findGrammarFiles();
if (grammarFiles.length === 0) {
console.log(' No ANTLR grammar files found.');
return;
}
console.log(`📋 Found ${grammarFiles.length} diagram(s) with ANTLR grammars:`);
for (const grammar of grammarFiles) {
console.log(`${grammar.diagramType}`);
}
// Generate files for each grammar
let successCount = 0;
let failureCount = 0;
for (const grammar of grammarFiles) {
try {
generateAntlrFiles(grammar);
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to process ${grammar.diagramType}:`, error);
}
}
// Summary
console.log('\n📊 Generation Summary:');
console.log(` ✅ Successful: ${successCount}`);
console.log(` ❌ Failed: ${failureCount}`);
console.log(` 📁 Total: ${grammarFiles.length}`);
if (failureCount > 0) {
console.error('\n❌ Some ANTLR generations failed. Check the errors above.');
process.exit(1);
} else {
console.log('\n🎉 All ANTLR files generated successfully!');
}
} catch (error) {
console.error('❌ Fatal error during ANTLR generation:', error);
process.exit(1);
}
}
// Run the script
if (import.meta.url === `file://${process.argv[1]}`) {
generateAntlr();
}

Some files were not shown because too many files have changed in this diff Show More