mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-11-20 20:54:27 +01:00
Compare commits
7 Commits
sidv/iconi
...
feat/useca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfed700a58 | ||
|
|
b715d82458 | ||
|
|
5b2b3b8ae9 | ||
|
|
b7ff1920a9 | ||
|
|
58c06ed770 | ||
|
|
5e05f91b7d | ||
|
|
5fd06ccbac |
92
.build/antlr-cli.ts
Normal file
92
.build/antlr-cli.ts
Normal 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
5
.build/generateAntlr.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { generateFromConfig } from './antlr-cli.js';
|
||||
|
||||
export async function generateAntlr() {
|
||||
await generateFromConfig('./packages/parser/antlr-config.json');
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
|
||||
'packet',
|
||||
'architecture',
|
||||
'radar',
|
||||
'icons',
|
||||
'usecase',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Support edge animation in hand drawn look
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Resolved parsing error where direction TD was not recognized within subgraphs
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Correct viewBox casing and make SVGs responsive
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Improve participant parsing and prevent recursive loops on invalid syntax
|
||||
5
.changeset/deep-pumas-run.md
Normal file
5
.changeset/deep-pumas-run.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
chore: Fix mindmap rendering in docs and apply tidytree layout
|
||||
5
.changeset/four-eyes-wish.md
Normal file
5
.changeset/four-eyes-wish.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Ensure edge label color is applied when using classDef with edge IDs
|
||||
5
.changeset/moody-fans-try.md
Normal file
5
.changeset/moody-fans-try.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Resolve gantt chart crash due to invalid array length
|
||||
5
.changeset/proud-colts-smell.md
Normal file
5
.changeset/proud-colts-smell.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'mermaid': minor
|
||||
---
|
||||
|
||||
feat: Add IDs in architecture diagrams
|
||||
9
.changeset/revert-marked-dependency.md
Normal file
9
.changeset/revert-marked-dependency.md
Normal 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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': minor
|
||||
---
|
||||
|
||||
feat: allow to put notes in namespaces on classDiagram
|
||||
@@ -143,6 +143,9 @@ typeof
|
||||
typestr
|
||||
unshift
|
||||
urlsafe
|
||||
usecase
|
||||
Usecase
|
||||
USECASE
|
||||
verifymethod
|
||||
VERIFYMTHD
|
||||
WARN_DOCSDIR_DOESNT_MATCH
|
||||
|
||||
@@ -22,7 +22,6 @@ mermaidchart
|
||||
mermaidjs
|
||||
mindmap
|
||||
mindmaps
|
||||
mmdc
|
||||
mrtree
|
||||
multigraph
|
||||
nodesep
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
31
.github/workflows/validate-lockfile.yml
vendored
31
.github/workflows/validate-lockfile.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
});
|
||||
});
|
||||
425
cypress/integration/rendering/usecase.spec.ts
Normal file
425
cypress/integration/rendering/usecase.spec.ts
Normal 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
|
||||
`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -603,10 +603,6 @@
|
||||
</div>
|
||||
<div class="test">
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
classDiagram
|
||||
test ()--() test2
|
||||
</pre>
|
||||
|
||||
@@ -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
234
demos/usecase.html
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
|
||||
51
packages/examples/src/examples/usecase.ts
Normal file
51
packages/examples/src/examples/usecase.ts
Normal 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;
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\` |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -264,6 +264,9 @@ const config: RequiredDeep<MermaidConfig> = {
|
||||
radar: {
|
||||
...defaultConfigJson.radar,
|
||||
},
|
||||
usecase: {
|
||||
...defaultConfigJson.usecase,
|
||||
},
|
||||
treemap: {
|
||||
useMaxWidth: true,
|
||||
padding: 10,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'};}
|
||||
;
|
||||
|
||||
%%
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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})`);
|
||||
};
|
||||
|
||||
@@ -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)) }
|
||||
;
|
||||
|
||||
%%
|
||||
%%
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)*
|
||||
;
|
||||
|
||||
@@ -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, '_');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
87
packages/mermaid/src/diagrams/usecase/styles.ts
Normal file
87
packages/mermaid/src/diagrams/usecase/styles.ts
Normal 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;
|
||||
501
packages/mermaid/src/diagrams/usecase/usecase.spec.ts
Normal file
501
packages/mermaid/src/diagrams/usecase/usecase.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
397
packages/mermaid/src/diagrams/usecase/usecaseDb.ts
Normal file
397
packages/mermaid/src/diagrams/usecase/usecaseDb.ts
Normal 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,
|
||||
};
|
||||
22
packages/mermaid/src/diagrams/usecase/usecaseDetector.ts
Normal file
22
packages/mermaid/src/diagrams/usecase/usecaseDetector.ts
Normal 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,
|
||||
};
|
||||
12
packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts
Normal file
12
packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts
Normal 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,
|
||||
};
|
||||
58
packages/mermaid/src/diagrams/usecase/usecaseParser.ts
Normal file
58
packages/mermaid/src/diagrams/usecase/usecaseParser.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
48
packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
Normal file
48
packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
Normal 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 };
|
||||
40
packages/mermaid/src/diagrams/usecase/usecaseStyles.ts
Normal file
40
packages/mermaid/src/diagrams/usecase/usecaseStyles.ts
Normal 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;
|
||||
113
packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
Normal file
113
packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
5
packages/parser/antlr-config.json
Normal file
5
packages/parser/antlr-config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectName": "Mermaid",
|
||||
"grammars": [],
|
||||
"mode": "production"
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
100
pnpm-lock.yaml
generated
@@ -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
230
scripts/antlr-generate.mts
Normal 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
Reference in New Issue
Block a user