Compare commits

..

55 Commits

Author SHA1 Message Date
Sidharth Vinod
f8a0a61b5e Merge branch 'develop' into sidv/iconifyNative 2025-10-30 02:30:15 +09:00
Sidharth Vinod
f28f3c25aa Merge pull request #7116 from mermaid-js/sidv/lockfileValidation
fix: Lockfile validation
2025-10-30 02:29:50 +09:00
Sidharth Vinod
525e630ebc fix: indentation 2025-10-30 02:01:45 +09:00
Sidharth Vinod
58137aa631 feat: Allow validation workflow to run on forks 2025-10-30 01:56:18 +09:00
Sidharth Vinod
e7719f14c5 fix: Prevent duplicate comments by validation workflow 2025-10-30 01:55:58 +09:00
Sidharth Vinod
35d9cead8a chore: Cleanup lockfile 2025-10-30 01:22:17 +09:00
Sidharth Vinod
928ae32063 docs: Update icons documentation 2025-10-30 01:20:53 +09:00
Sidharth Vinod
25d96a90de Merge branch 'sidv/iconifyNative' of https://github.com/mermaid-js/mermaid into sidv/iconifyNative
* 'sidv/iconifyNative' of https://github.com/mermaid-js/mermaid:
  [autofix.ci] apply automated fixes
2025-10-29 22:08:39 +09:00
Sidharth Vinod
c24a1fb1b9 test: Add test for timeout 2025-10-29 22:08:18 +09:00
autofix-ci[bot]
c607163999 [autofix.ci] apply automated fixes 2025-10-29 08:50:25 +00:00
Sidharth Vinod
df21885a27 test: Visual tests for icons 2025-10-29 17:43:19 +09:00
Sidharth Vinod
172030377f feat: Add support for icons packs via config 2025-10-29 17:11:50 +09:00
Sidharth Vinod
a23c2baed8 refactor: Convert icon manager into class 2025-10-29 01:38:42 +09:00
Ashish Jain
13baf51b35 Merge 11.12.1 back to develop 2025-10-28 11:23:19 +01:00
Justin Greywolf
9af985ba9b Merge pull request #5814 from kairi003/feature/4706_allow_notes_in_namespace
feat: allow to put notes in namespaces on classDiagram
2025-10-27 19:21:00 +00:00
Sidharth Vinod
762b44cf33 Merge pull request #7108 from mermaid-js/changeset-release/master
Version Packages
2025-10-27 18:35:34 +05:30
github-actions[bot]
02c0091106 Version Packages 2025-10-27 12:08:22 +00:00
Sidharth Vinod
16359adc33 Merge pull request #7107 from mermaid-js/patch/dagre-d3-es-7.0.13
fix: update dagre-d3-es to version 7.0.13
2025-10-27 17:35:36 +05:30
Shubham P
061632c580 Update .changeset/slick-wasps-bathe.md
Co-authored-by: Alois Klink <alois@mermaidchart.com>
2025-10-27 16:20:24 +05:30
shubhamparikh2704
cbf89462ac fix: update dagre-d3-es to version 7.0.13 2025-10-27 15:06:24 +05:30
kairi003
700aa100f2 fix: style broken caused by a mistake in merge 3f46c94a 2025-10-26 04:18:19 +09:00
kairi003
49103ea654 fix: update ClassDB and ClassTypes for improved type safety and consistency 2025-10-26 03:01:00 +09:00
kairi003
3f46c94ab2 Merge branch 'develop' into tmp/feature/4706_allow_notes_in_namespace 2025-10-26 02:39:38 +09:00
Knut Sveidqvist
fed8a523a4 Merge pull request #7076 from mermaid-js/6919-fix-incorrect-viewBox-casing
6919: correct viewBox casing in Radar & Packet
2025-10-21 12:20:04 +00:00
Knut Sveidqvist
33b4946e21 Merge branch 'develop' into 6919-fix-incorrect-viewBox-casing 2025-10-21 10:33:21 +02:00
Justin Greywolf
3d768f3adf Merge pull request #6975 from ilovelinux/fix/6974-ilovelinux-fix-classdiagram-example
Fix classDiagram example
2025-10-17 02:56:34 +00:00
darshanr0107
76e17ffd20 fix: add validation to ensure correct casing of 'viewBox' in all rendering tests
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-15 18:56:49 +05:30
darshanr0107
60f633101c chore: added changeset
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-13 19:14:22 +05:30
darshanr0107
7def6eecbf fix: correct viewBox casing in radar and packet diagram
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-10-13 18:15:38 +05:30
Antonio Spadaro
ac411a7d7e Fix class diagram example 2025-10-11 22:56:37 +02:00
Sidharth Vinod
ad82448084 Merge pull request #7053 from mermaid-js/changeset-release/master
Version Packages
2025-10-07 13:23:31 +05:30
github-actions[bot]
9498619d3c Version Packages 2025-10-07 07:36:05 +00:00
Sidharth Vinod
7a8557a1a2 Merge pull request #7036 from mermaid-js/knsv-patch-1
Update free plan diagram limit from 3 to 6
2025-10-07 13:03:39 +05:30
Sidharth Vinod
74863c94fb Merge pull request #7051 from mermaid-js/patch-parser-release
Pre Release
2025-10-07 12:34:22 +05:30
shubhamparikh2704
63df702146 chore: added changeset file 2025-10-07 10:47:16 +05:30
autofix-ci[bot]
421f8d4633 [autofix.ci] apply automated fixes 2025-10-03 07:41:08 +00:00
Knut Sveidqvist
bf6e1a594c Update free plan diagram limit from 3 to 6 2025-10-03 09:35:21 +02:00
Sidharth Vinod
0116b272b4 Merge pull request #6961 from mermaid-js/changeset-release/master
Version Packages
2025-09-18 10:50:42 +05:30
github-actions[bot]
634f3367da Version Packages 2025-09-17 17:48:06 +00:00
kairi
d60b09cafc Merge branch 'develop' into feature/4706_allow_notes_in_namespace 2024-12-01 09:20:39 +09:00
kairi003
875827f59b Merge branch 'develop' into feature/4706_allow_notes_in_namespace 2024-11-21 07:16:47 +09:00
kairi003
c4e08261b5 Merge branch 'develop' into feature/4706_allow_notes_in_namespace 2024-11-10 23:56:48 +09:00
kairi003
70f679d2fa add: e2e test for classDiagram-v3 2024-11-10 20:27:35 +09:00
kairi003
25c43fa439 add: horizontal rules to demos/classchart.html 2024-11-10 02:34:31 +09:00
kairi003
ec1c6325d4 refactor: Add type annotations and optimize redundant set loop 2024-11-10 02:34:31 +09:00
kairi003
309ff6be38 fix: Reimplement notes in namespaces for classRenderer-v3 (#5880) 2024-11-10 02:32:20 +09:00
kairi003
02d368df05 chore: relax git version constraint in Dockerfile to allow patch updates 2024-11-10 01:58:29 +09:00
kairi003
4ee1fe2ca4 Merge branch 'master' into feature/4706_allow_notes_in_namespace 2024-11-10 01:52:46 +09:00
kairi003
2dd29bee25 add changelogs 2024-09-08 02:13:33 +09:00
kairi003
259a508d8a add e2e test 2024-09-08 01:31:26 +09:00
kairi
1963064369 Merge branch 'develop' into feature/4706_allow_notes_in_namespace 2024-09-08 00:27:14 +09:00
kairi
a101ce803c Merge branch 'develop' into feature/4706_allow_notes_in_namespace 2024-09-04 14:14:19 +09:00
kairi003
fc0c7936d1 feat: Add support for adding notes to namespaces in class diagrams 2024-09-03 15:27:31 +09:00
kairi003
2d2add5b44 Change: use Map for notes 2024-09-03 15:22:59 +09:00
kairi003
58fd5ddbaf Add: demo for notes in namespace on classDialog 2024-09-03 15:22:41 +09:00
48 changed files with 1829 additions and 311 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Support ComponentQueue_Ext to prevent parsing error

View File

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

View File

@@ -1,7 +1,7 @@
name: Validate pnpm-lock.yaml
on:
pull_request:
pull_request_target:
paths:
- 'pnpm-lock.yaml'
- '**/package.json'
@@ -15,6 +15,8 @@ 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
@@ -55,16 +57,41 @@ 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@v4
uses: peter-evans/create-or-update-comment@v5
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 }},
});

View File

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

View File

@@ -99,6 +99,7 @@ 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);

View File

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

View File

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

View File

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

View File

@@ -184,6 +184,7 @@
}
Admin --> Report : generates
</pre>
<hr />
<pre class="mermaid">
classDiagram
namespace Company.Project.Module {
@@ -240,6 +241,20 @@
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({

View File

@@ -4,15 +4,150 @@
>
> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/icons.md](../../packages/mermaid/src/docs/config/icons.md).
# Registering icon pack in mermaid
# Icon Pack Configuration
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.
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).
Using JSON file directly from CDN:
## 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
```js
import mermaid from 'CDN/mermaid.esm.mjs';
mermaid.registerIconPacks([
{
name: 'logos',
@@ -22,13 +157,15 @@ mermaid.registerIconPacks([
]);
```
Using packages and a bundler:
### Using Packages with Bundler
Install the icon pack:
```bash
npm install @iconify-json/logos@1
```
With lazy loading
#### With Lazy Loading
```js
import mermaid from 'mermaid';
@@ -41,15 +178,39 @@ mermaid.registerIconPacks([
]);
```
Without lazy loading
#### Without Lazy Loading
```js
import mermaid from 'mermaid';
import { icons } from '@iconify-json/logos';
mermaid.registerIconPacks([
{
name: icons.prefix, // To use the prefix defined in the icon pack
name: icons.prefix, // Use the prefix defined in the icon pack
icons,
},
]);
```
## Finding Icon Packs
Icon packs available for use can be found at [iconify.design](https://icon-sets.iconify.design/) or [icones.js.org](https://icones.js.org/).
The pack name you register is the **local name** used in diagrams. It can differ from the pack's prefix, allowing you to:
- Use shorter names (e.g., register `@iconify-json/material-design-icons` as `mdi`)
- Load specific packs only when used in diagrams (lazy loading)
## Error Handling
If an icon cannot be found:
- The diagram still renders (non-fatal)
- A fallback icon is displayed
- A warning is logged (visible in CLI stderr or browser console)
## Licensing
Iconify JSON format is MIT licensed, but **individual icons may have varying licenses**. Please verify the licenses of the icon packs you configure before use in production.
Mermaid does **not** redistribute third-party icon assets in the core bundle.

View File

@@ -229,6 +229,14 @@ 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`

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "mermaid",
"version": "11.11.0",
"version": "11.12.1",
"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",
@@ -76,7 +76,7 @@
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.11",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",

View File

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

View File

@@ -220,6 +220,7 @@ export interface MermaidConfig {
*
*/
suppressErrorRendering?: boolean;
icons?: IconsConfig;
}
/**
* The object containing configurations specific for flowcharts
@@ -1623,6 +1624,46 @@ 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.
*
*
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "IconsConfig".
*/
export interface IconsConfig {
/**
* 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').
*
*/
packs?: {
[k: string]: string;
};
/**
* URL template for resolving package specs (must contain ${packageSpec}).
* Used to build URLs for package specs in icons.packs.
*
*/
cdnTemplate?: string;
/**
* Maximum file size in MB for icon pack JSON files.
*
*/
maxFileSizeMB?: number;
/**
* Network timeout in milliseconds for icon pack fetches.
*
*/
timeout?: number;
/**
* List of allowed hosts to fetch icons from
*
*/
allowedHosts?: string[];
}
/**
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "FontConfig".

View File

@@ -1,58 +0,0 @@
import c4Db from '../c4Db.js';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config.js';
setConfig({
securityLevel: 'strict',
});
describe.each([
['Component', 'component'],
['ComponentDb', 'component_db'],
['ComponentQueue', 'component_queue'],
['Component_Ext', 'external_component'],
['ComponentDb_Ext', 'external_component_db'],
['ComponentQueue_Ext', 'external_component_queue'],
])('parsing a C4 %s', function (macroName, elementName) {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should parse a C4 diagram with one Component correctly', function () {
c4.parser.parse(`C4Component
title Component diagram for Internet Banking Component
${macroName}(ComponentAA, "Internet Banking Component", "Technology", "Allows customers to view information about their bank accounts, and make payments.")`);
const yy = c4.parser.yy;
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape).toMatchObject({
alias: 'ComponentAA',
descr: {
text: 'Allows customers to view information about their bank accounts, and make payments.',
},
label: {
text: 'Internet Banking Component',
},
techn: {
text: 'Technology',
},
typeC4Shape: {
text: elementName,
},
});
});
it('should handle a trailing whitespaces after Component', function () {
const whitespace = ' ';
const rendered = c4.parser.parse(`C4Component${whitespace}
title Component diagram for Internet Banking Component${whitespace}
${macroName}(ComponentAA, "Internet Banking Component", "Technology", "Allows customers to view information about their bank accounts, and make payments.")${whitespace}`);
expect(rendered).toBe(true);
});
});

View File

@@ -158,10 +158,10 @@ accDescr\s*"{"\s* { this.begin("acc_descr_multiline");}
"UpdateRelStyle" { this.begin("update_rel_style"); return 'UPDATE_REL_STYLE';}
"UpdateLayoutConfig" { this.begin("update_layout_config"); return 'UPDATE_LAYOUT_CONFIG';}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext_queue,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config><<EOF>> return "EOF_IN_STRUCT";
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext_queue,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(][ ]*[,] { this.begin("attribute"); return "ATTRIBUTE_EMPTY";}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext_queue,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(] { this.begin("attribute"); }
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext_queue,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config,attribute>[)] { this.popState();this.popState();}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config><<EOF>> return "EOF_IN_STRUCT";
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(][ ]*[,] { this.begin("attribute"); return "ATTRIBUTE_EMPTY";}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(] { this.begin("attribute"); }
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext_queue,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config,attribute>[)] { this.popState();this.popState();}
<attribute>",," { return 'ATTRIBUTE_EMPTY';}
<attribute>"," { }

View File

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

View File

@@ -417,7 +417,7 @@ class C13["With Città foreign language"]
note "This is a keyword: ${keyword}. It truly is."
`;
parser.parse(str);
expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
expect(classDb.getNote(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.getNotes()[0].text).toEqual(`${keyword}`);
expect(classDb.getNote(0).text).toEqual(`${keyword}`);
}
);
@@ -441,7 +441,7 @@ class C13["With Città foreign language"]
`;
parser.parse(str);
expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`);
expect(classDb.getNote(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.getNotes()[0].text).toEqual(`${keyword}`);
expect(classDb.getNote(0).text).toEqual(`${keyword}`);
}
);

View File

@@ -8,7 +8,7 @@ import utils, { getEdgeId } from '../../utils.js';
import { interpolateToCurve, getStylesFromArray } from '../../utils.js';
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import common from '../common/common.js';
import type { ClassRelation, ClassNote, ClassMap, NamespaceMap } from './classTypes.js';
import type { ClassRelation, ClassMap, ClassNoteMap, NamespaceMap } from './classTypes.js';
import type { EdgeData } from '../../types.js';
const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig());
@@ -65,6 +65,9 @@ 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);
});
@@ -144,69 +147,74 @@ export const addClasses = function (
* @param classes - Classes
*/
export const addNotes = function (
notes: ClassNote[],
notes: ClassNoteMap,
g: graphlib.Graph,
startEdgeId: number,
classes: ClassMap
classes: ClassMap,
parent?: string
) {
log.info(notes);
notes.forEach(function (note, i) {
const vertex = note;
[...notes.values()]
.filter((note) => note.parent === parent)
.forEach(function (vertex) {
const cssNoteStr = '';
const cssNoteStr = '';
const styles = { labelStyle: '', style: '' };
const styles = { labelStyle: '', style: '' };
const vertexText = vertex.text;
const vertexText = vertex.text;
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 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 (parent) {
g.setParent(vertex.id, parent);
}
if (!vertex.class || !classes.has(vertex.class)) {
return;
}
const edgeId = startEdgeId + i;
if (!vertex.class || !classes.has(vertex.class)) {
return;
}
const edgeId = startEdgeId + vertex.index;
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);
});
};
/**
@@ -329,7 +337,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: ClassNote[] = diagObj.db.getNotes();
const notes: ClassNoteMap = diagObj.db.getNotes();
log.info(relations);
addNamespaces(namespaces, g, id, diagObj);
addClasses(classes, g, id, diagObj);

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ 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) => {
@@ -53,11 +54,9 @@ const drawFrame = (svg: SVG, config: Required<RadarDiagramConfig>): SVGGroup =>
x: config.marginLeft + config.width / 2,
y: config.marginTop + config.height / 2,
};
// Initialize the SVG
svg
.attr('viewbox', `0 0 ${totalWidth} ${totalHeight}`)
.attr('width', totalWidth)
.attr('height', totalHeight);
configureSvgSize(svg, totalHeight, totalWidth, config.useMaxWidth ?? true);
svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
// g element to center the radar chart
return svg.append('g').attr('transform', `translate(${center.x}, ${center.y})`);
};

View File

@@ -1,12 +1,147 @@
# Registering icon pack in mermaid
# Icon Pack Configuration
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.
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).
Using JSON file directly from CDN:
## 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
```js
import mermaid from 'CDN/mermaid.esm.mjs';
mermaid.registerIconPacks([
{
name: 'logos',
@@ -16,13 +151,15 @@ mermaid.registerIconPacks([
]);
```
Using packages and a bundler:
### Using Packages with Bundler
Install the icon pack:
```bash
npm install @iconify-json/logos@1
```
With lazy loading
#### With Lazy Loading
```js
import mermaid from 'mermaid';
@@ -35,15 +172,39 @@ mermaid.registerIconPacks([
]);
```
Without lazy loading
#### Without Lazy Loading
```js
import mermaid from 'mermaid';
import { icons } from '@iconify-json/logos';
mermaid.registerIconPacks([
{
name: icons.prefix, // To use the prefix defined in the icon pack
name: icons.prefix, // Use the prefix defined in the icon pack
icons,
},
]);
```
## Finding Icon Packs
Icon packs available for use can be found at [iconify.design](https://icon-sets.iconify.design/) or [icones.js.org](https://icones.js.org/).
The pack name you register is the **local name** used in diagrams. It can differ from the pack's prefix, allowing you to:
- Use shorter names (e.g., register `@iconify-json/material-design-icons` as `mdi`)
- Load specific packs only when used in diagrams (lazy loading)
## Error Handling
If an icon cannot be found:
- The diagram still renders (non-fatal)
- A fallback icon is displayed
- A warning is logged (visible in CLI stderr or browser console)
## Licensing
Iconify JSON format is MIT licensed, but **individual icons may have varying licenses**. Please verify the licenses of the icon packs you configure before use in production.
Mermaid does **not** redistribute third-party icon assets in the core bundle.

View File

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

View File

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

View File

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

View File

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

View File

@@ -329,6 +329,8 @@ 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:
@@ -2368,3 +2370,49 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
description: The font weight to use.
type: ['string', 'number']
default: normal
IconsConfig:
title: Icons Config
description: |
Configuration for icon packs and CDN template.
Enables icons in browsers and CLI/headless renders without custom JavaScript.
type: object
properties:
packs:
description: |
Icon pack configuration. Key is the local pack name.
Value is a package spec with version that complies with Iconify standards.
Package specs must include at least a major version (e.g., '@iconify-json/logos@1').
type: object
additionalProperties:
type: string
cdnTemplate:
description: |
URL template for resolving package specs (must contain ${packageSpec}).
Used to build URLs for package specs in icons.packs.
type: string
pattern: '^https://.*\$\{packageSpec\}.*$'
default: 'https://cdn.jsdelivr.net/npm/${packageSpec}/icons.json'
maxFileSizeMB:
description: |
Maximum file size in MB for icon pack JSON files.
type: integer
default: 5
minimum: 1
maximum: 10
timeout:
description: |
Network timeout in milliseconds for icon pack fetches.
type: integer
default: 5000
minimum: 1000
maximum: 30000
allowedHosts:
description: |
List of allowed hosts to fetch icons from
type: array
items:
type: string
default:
- 'unpkg.com'
- 'cdn.jsdelivr.net'
- 'npmjs.com'

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@mermaid-js/parser",
"version": "0.6.2",
"version": "0.6.3",
"description": "MermaidJS parser",
"author": "Yokozuna59",
"contributors": [

View File

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

View File

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

10
pnpm-lock.yaml generated
View File

@@ -251,8 +251,8 @@ importers:
specifier: ^0.12.3
version: 0.12.3
dagre-d3-es:
specifier: 7.0.11
version: 7.0.11
specifier: 7.0.13
version: 7.0.13
dayjs:
specifier: ^1.11.18
version: 1.11.18
@@ -4900,8 +4900,8 @@ packages:
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
engines: {node: '>=12'}
dagre-d3-es@7.0.11:
resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==}
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
dashdash@1.14.1:
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
@@ -15161,7 +15161,7 @@ snapshots:
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-zoom: 3.0.0
dagre-d3-es@7.0.11:
dagre-d3-es@7.0.13:
dependencies:
d3: 7.9.0
lodash-es: 4.17.21