Compare commits

..

17 Commits

Author SHA1 Message Date
Sidharth Vinod
0b4e621e75 Merge branch 'sidv/rough' of https://github.com/mermaid-js/mermaid into sidv/rough
* 'sidv/rough' of https://github.com/mermaid-js/mermaid:
  [autofix.ci] apply automated fixes
2025-11-05 01:31:16 +07:00
Sidharth Vinod
ffa727084c test: verify handDrawn look for every diagram type 2025-11-05 01:31:06 +07:00
autofix-ci[bot]
55cea7e9df [autofix.ci] apply automated fixes 2025-11-04 17:41:33 +00:00
Sidharth Vinod
33dfa5a425 Merge branch 'develop' into sidv/rough
* develop: (50 commits)
  chore: forbid use of `viewbox` in code
  chore: Add missing clean scripts
  chore: Remove NPM_TOKEN from release workflow
  [autofix.ci] apply automated fixes
  chore: Add speccharts to cspell
  Refactor GitHub Actions workflow for lockfile validation
  feat: Allow validation workflow to run on forks
  fix: Prevent duplicate comments by validation workflow
  chore: Cleanup lockfile
  Add speccharts to `integrations-community.md`
  Version Packages
  Update .changeset/slick-wasps-bathe.md
  fix: update dagre-d3-es to version 7.0.13
  fix: style broken caused by a mistake in merge 3f46c94a
  fix: update ClassDB and ClassTypes for improved type safety and consistency
  fix: add validation to ensure correct casing of 'viewBox' in all rendering tests
  chore: added changeset
  fix: correct viewBox casing in radar and packet diagram
  Fix class diagram example
  fix: failing argos on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
  ...
2025-11-05 00:38:17 +07:00
Sidharth Vinod
a2476bf7e8 chore: Add changeset 2025-11-05 00:25:03 +07:00
Sidharth Vinod
f4e2b0dead feat: Mark diagrams capable of handDrawn 2025-11-05 00:22:14 +07:00
Sidharth Vinod
6f29330dc8 feat: Support look for all diagrams 2025-11-05 00:21:46 +07:00
Sidharth Vinod
31345ff241 feat: Add capabilities support to diagrams 2025-11-05 00:21:26 +07:00
Sidharth Vinod
6546aed482 chore: forbid use of viewbox in code 2025-11-05 00:07:30 +07:00
Sidharth Vinod
287a9a3fcb chore: Add missing clean scripts
Co-authored-by: Alois Klink <alois@aloisklink.com>
2025-11-04 21:21:58 +07:00
Sidharth Vinod
7f5160fa4d chore: Remove NPM_TOKEN from release workflow 2025-11-04 21:12:10 +07:00
Sidharth Vinod
62d2c6505e Merge pull request #7109 from arnaudrenaud/add-speccharts
docs: Add speccharts to `integrations-community.md`
2025-11-04 11:57:01 +00:00
autofix-ci[bot]
974236bbb8 [autofix.ci] apply automated fixes 2025-11-04 11:48:23 +00:00
Sidharth Vinod
cf0d1248a4 chore: Add speccharts to cspell 2025-11-04 18:42:42 +07:00
Alois Klink
ebefbd87a8 Merge pull request #7127 from mermaid-js/sidharthv96-patch-2
Refactor GitHub Actions workflow for lockfile validation
2025-11-04 07:43:17 +00:00
Sidharth Vinod
1e7b71a085 Refactor GitHub Actions workflow for lockfile validation
Removed Node.js setup step and pnpm action version.

Co-authored-by: Alois Klink <alois@mermaidchart.com>
2025-11-03 23:30:27 -08:00
Arnaud Renaud
c7f8a11ded Add speccharts to integrations-community.md 2025-10-27 14:16:35 +01:00
38 changed files with 406 additions and 1546 deletions

View File

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

View File

@@ -0,0 +1,8 @@
---
'mermaid': minor
---
feat: Support `handDrawn` look for all diagrams
This uses svg2roughjs on diagrams that do not natively support roughjs.
So your mileage may vary.

View File

@@ -1,3 +1,5 @@
!viewbox
# It should be viewBox
# This file contains coding related terms
ALPHANUM
antiscript

View File

@@ -64,6 +64,7 @@ rscratch
shiki
Slidev
sparkline
speccharts
sphinxcontrib
ssim
stylis

View File

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

View File

@@ -42,5 +42,4 @@ jobs:
publish: pnpm changeset:publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true

View File

@@ -18,13 +18,6 @@ jobs:
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
with:
node-version: 20
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Validate pnpm-lock.yaml entries
id: validate # give this step an ID so we can reference its outputs
run: |

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');
// cspell:ignore viewbox
cy.get('svg').should('not.have.attr', 'viewbox');
if (validation) {

View File

@@ -0,0 +1,9 @@
import { urlSnapshotTest } from '../../helpers/util.ts';
describe('Hand Draw', () => {
it('should render the hand drawn look for all diagrams', () => {
urlSnapshotTest('http://localhost:9000/handDrawn.html', {
logLevel: 1,
});
});
});

View File

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

View File

@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hand Drawn Diagrams</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px;
display: flex;
flex-direction: column;
gap: 48px;
}
h1,
h2,
h3,
h4 {
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 {
font-size: 32px;
}
h2 {
font-size: 24px;
margin-bottom: 12px;
}
h3 {
font-size: 18px;
}
main {
display: flex;
flex-direction: column;
gap: 64px;
}
section.diagram-group {
display: flex;
flex-direction: column;
gap: 32px;
}
.diagram-example {
display: flex;
flex-direction: column;
gap: 16px;
}
.diagram-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.diagram-panel {
flex: 1 1 420px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.diagram-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.08em;
}
@media (max-width: 960px) {
body {
padding: 20px;
}
.diagram-row {
flex-direction: column;
}
}
</style>
</head>
<body>
<h1>Hand-drawn Look Comparison</h1>
<main id="app"></main>
<script type="module">
import mermaid from './mermaid.esm.mjs';
import { diagramData } from './mermaid-examples.esm.mjs';
mermaid.initialize({ startOnLoad: false });
const app = document.getElementById('app');
const seen = new Set();
const ensureObject = (value) =>
value && typeof value === 'object' && !Array.isArray(value) ? value : {};
let renderIndex = 0;
const renderDiagram = async (target, source, overrides = {}) => {
const id = `diagram-${renderIndex++}`;
const config = {
startOnLoad: false,
...overrides,
};
mermaid.initialize(config);
try {
const { svg, bindFunctions } = await mermaid.render(id, source);
target.innerHTML = svg;
if (typeof bindFunctions === 'function') {
bindFunctions(target);
}
} catch (error) {
console.error('Failed to render diagram', { id, error });
target.innerHTML = '';
const pre = document.createElement('pre');
pre.className = 'diagram-error';
pre.textContent = String(error);
target.appendChild(pre);
}
};
const createPanel = (label) => {
const panel = document.createElement('article');
panel.className = 'diagram-panel';
const heading = document.createElement('div');
heading.className = 'diagram-label';
heading.textContent = label;
panel.appendChild(heading);
const surface = document.createElement('div');
surface.className = 'diagram-surface';
panel.appendChild(surface);
return { panel, surface };
};
const bootstrap = async () => {
const fragment = document.createDocumentFragment();
const groups = diagramData.filter((group) => {
if (!group || seen.has(group.id)) {
return false;
}
seen.add(group.id);
return Array.isArray(group.examples) && group.examples.length > 0;
});
for (const group of groups) {
const groupSection = document.createElement('section');
groupSection.className = 'diagram-group';
const groupHeading = document.createElement('h2');
groupHeading.textContent = group.name ?? group.id;
groupSection.appendChild(groupHeading);
for (const example of group.examples) {
const exampleWrapper = document.createElement('div');
exampleWrapper.className = 'diagram-example';
const exampleHeading = document.createElement('h3');
exampleHeading.textContent = example.title ?? 'Example';
exampleWrapper.appendChild(exampleHeading);
const row = document.createElement('div');
row.className = 'diagram-row';
const defaultPanel = createPanel('Default config');
const handPanel = createPanel('config.look: handDrawn');
row.append(defaultPanel.panel, handPanel.panel);
exampleWrapper.appendChild(row);
groupSection.appendChild(exampleWrapper);
await renderDiagram(defaultPanel.surface, example.code);
await renderDiagram(handPanel.surface, example.code, {
look: 'handDrawn',
handDrawnSeed: 42,
});
}
fragment.appendChild(groupSection);
}
app.appendChild(fragment);
if (window.Cypress) {
window.rendered = true;
}
};
bootstrap().catch((error) => {
console.error('Failed to bootstrap hand-drawn comparison page', error);
});
</script>
</body>
</html>

View File

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

View File

@@ -10,7 +10,7 @@
# Interface: ExternalDiagramDefinition
Defined in: [packages/mermaid/src/diagram-api/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L96)
Defined in: [packages/mermaid/src/diagram-api/types.ts:101](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L101)
## Properties
@@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:96](https://github.com/me
> **detector**: `DiagramDetector`
Defined in: [packages/mermaid/src/diagram-api/types.ts:98](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L98)
Defined in: [packages/mermaid/src/diagram-api/types.ts:103](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L103)
---
@@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:98](https://github.com/me
> **id**: `string`
Defined in: [packages/mermaid/src/diagram-api/types.ts:97](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L97)
Defined in: [packages/mermaid/src/diagram-api/types.ts:102](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L102)
---
@@ -34,4 +34,4 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:97](https://github.com/me
> **loader**: `DiagramLoader`
Defined in: [packages/mermaid/src/diagram-api/types.ts:99](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L99)
Defined in: [packages/mermaid/src/diagram-api/types.ts:104](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L104)

View File

@@ -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`

View File

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

View File

@@ -12,4 +12,4 @@
> **SVG** = `d3.Selection`<`SVGSVGElement`, `unknown`, `Element` | `null`, `unknown`>
Defined in: [packages/mermaid/src/diagram-api/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L128)
Defined in: [packages/mermaid/src/diagram-api/types.ts:133](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L133)

View File

@@ -12,4 +12,4 @@
> **SVGGroup** = `d3.Selection`<`SVGGElement`, `unknown`, `Element` | `null`, `unknown`>
Defined in: [packages/mermaid/src/diagram-api/types.ts:130](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L130)
Defined in: [packages/mermaid/src/diagram-api/types.ts:135](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L135)

View File

@@ -272,6 +272,7 @@ Communication tools and platforms
- [reveal.js-mermaid-plugin](https://github.com/ludwick/reveal.js-mermaid-plugin)
- [Reveal CK](https://github.com/jedcn/reveal-ck)
- [reveal-ck-mermaid-plugin](https://github.com/tmtm/reveal-ck-mermaid-plugin)
- [speccharts: Turn your test suites into specification diagrams](https://github.com/arnaudrenaud/speccharts)
- [Vitepress Plugin](https://github.com/sametcn99/vitepress-mermaid-renderer)
<!--- cspell:ignore Blazorade HueHive --->

View File

@@ -18,7 +18,9 @@
"elk",
"mermaid"
],
"scripts": {},
"scripts": {
"clean": "rimraf dist"
},
"repository": {
"type": "git",
"url": "https://github.com/mermaid-js/mermaid"

View File

@@ -19,7 +19,9 @@
"mermaid",
"layout"
],
"scripts": {},
"scripts": {
"clean": "rimraf dist"
},
"repository": {
"type": "git",
"url": "https://github.com/mermaid-js/mermaid"

View File

@@ -85,6 +85,7 @@
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"svg2roughjs": "^3.2.1",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
},

View File

@@ -1,11 +1,10 @@
import * as configApi from './config.js';
import { detectType, getDiagramLoader } from './diagram-api/detectType.js';
import { getDiagram, registerDiagram } from './diagram-api/diagramAPI.js';
import type { DiagramDefinition, DiagramMetadata } from './diagram-api/types.js';
import { detectType, getDiagramLoader } from './diagram-api/detectType.js';
import { UnknownDiagramError } from './errors.js';
import { registerDiagramIconPacks } from './rendering-util/icons.js';
import type { DetailedError } from './utils.js';
import { encodeEntities } from './utils.js';
import type { DetailedError } from './utils.js';
import type { DiagramDefinition, DiagramMetadata } from './diagram-api/types.js';
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
export type ParseErrorFunction = (err: string | DetailedError | unknown, hash?: any) => void;
@@ -31,7 +30,7 @@ export class Diagram {
const { id, diagram } = await loader();
registerDiagram(id, diagram);
}
const { db, parser, renderer, init } = getDiagram(type);
const { db, parser, renderer, init, capabilities } = getDiagram(type);
if (parser.parser) {
// The parser.parser.yy is only present in JISON parsers. So, we'll only set if required.
parser.parser.yy = db;
@@ -42,9 +41,8 @@ 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);
return new Diagram(type, text, db, parser, renderer, capabilities);
}
private constructor(
@@ -52,7 +50,8 @@ export class Diagram {
public text: string,
public db: DiagramDefinition['db'],
public parser: DiagramDefinition['parser'],
public renderer: DiagramDefinition['renderer']
public renderer: DiagramDefinition['renderer'],
public capabilities: DiagramDefinition['capabilities']
) {}
async render(id: string, version: string) {

View File

@@ -220,7 +220,6 @@ export interface MermaidConfig {
*
*/
suppressErrorRendering?: boolean;
icons?: IconsConfig;
}
/**
* The object containing configurations specific for flowcharts
@@ -1624,46 +1623,6 @@ 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

@@ -75,10 +75,15 @@ export interface DiagramRenderer {
) => Map<string, DiagramStyleClassDef>;
}
export interface DiagramCapabilities {
handDrawn?: boolean;
}
export interface DiagramDefinition {
db: DiagramDB;
renderer: DiagramRenderer;
parser: ParserDefinition;
capabilities?: DiagramCapabilities;
styles?: any;
init?: (config: MermaidConfig) => void;
injectUtils?: (

View File

@@ -10,6 +10,9 @@ export const diagram: DiagramDefinition = {
get db() {
return new ClassDB();
},
capabilities: {
handDrawn: true,
},
renderer,
styles,
init: (cnf) => {

View File

@@ -2,13 +2,16 @@
import erParser from './parser/erDiagram.jison';
import { ErDB } from './erDb.js';
import * as renderer from './erRenderer-unified.js';
import erStyles from './styles.js';
import styles from './styles.js';
export const diagram = {
parser: erParser,
get db() {
return new ErDB();
},
capabilities: {
handDrawn: true,
},
renderer,
styles: erStyles,
styles,
};

View File

@@ -13,6 +13,9 @@ export const diagram = {
return new FlowDB();
},
renderer,
capabilities: {
handDrawn: true,
},
styles: flowStyles,
init: (cnf: MermaidConfig) => {
if (!cnf.flowchart) {

View File

@@ -43,7 +43,7 @@ export const draw: DrawDefinition = (text, id, _version, diagObj) => {
const group: SVGGroup = svg.append('g');
group.attr('transform', 'translate(' + pieWidth / 2 + ',' + height / 2 + ')');
const { themeVariables } = globalConfig;
const { themeVariables, look } = globalConfig;
let [outerStrokeWidth] = parseFontSize(themeVariables.pieOuterStrokeWidth);
outerStrokeWidth ??= 2;
@@ -59,12 +59,14 @@ export const draw: DrawDefinition = (text, id, _version, diagObj) => {
.innerRadius(radius * textPosition)
.outerRadius(radius * textPosition);
group
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', radius + outerStrokeWidth / 2)
.attr('class', 'pieOuterCircle');
if (look !== 'handDrawn') {
group
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', radius + outerStrokeWidth / 2)
.attr('class', 'pieOuterCircle');
}
const sections: Sections = db.getSections();
const arcs: d3.PieArcDatum<D3Section>[] = createPieArcs(sections);

View File

@@ -10,6 +10,9 @@ export const diagram: DiagramDefinition = {
get db() {
return new RequirementDB();
},
capabilities: {
handDrawn: true,
},
renderer,
styles,
};

View File

@@ -10,6 +10,9 @@ export const diagram: DiagramDefinition = {
get db() {
return new StateDB(2);
},
capabilities: {
handDrawn: true,
},
renderer,
styles,
init: (cnf) => {

View File

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

View File

@@ -267,6 +267,7 @@ Communication tools and platforms
- [reveal.js-mermaid-plugin](https://github.com/ludwick/reveal.js-mermaid-plugin)
- [Reveal CK](https://github.com/jedcn/reveal-ck)
- [reveal-ck-mermaid-plugin](https://github.com/tmtm/reveal-ck-mermaid-plugin)
- [speccharts: Turn your test suites into specification diagrams](https://github.com/arnaudrenaud/speccharts)
- [Vitepress Plugin](https://github.com/sametcn99/vitepress-mermaid-renderer)
<!--- cspell:ignore Blazorade HueHive --->

View File

@@ -7,7 +7,7 @@ import { select } from 'd3';
import { compile, serialize, stringify } from 'stylis';
import DOMPurify from 'dompurify';
import isEmpty from 'lodash-es/isEmpty.js';
import packageJson from '../package.json' assert { type: 'json' };
import packageJson from '../package.json' with { type: 'json' };
import { addSVGa11yTitleDescription, setA11yDiagramInfo } from './accessibility.js';
import assignWithDepth from './assignWithDepth.js';
import * as configApi from './config.js';
@@ -445,6 +445,29 @@ const render = async function (
log.debug('config.arrowMarkerAbsolute', config.arrowMarkerAbsolute);
svgCode = cleanUpSvgCode(svgCode, isSandboxed, evaluate(config.arrowMarkerAbsolute));
if (config.look === 'handDrawn' && !diag.capabilities?.handDrawn && includeLargeFeatures) {
const { OutputType, Svg2Roughjs } = await import('svg2roughjs');
const svg2roughjs = new Svg2Roughjs(enclosingDivID_selector, OutputType.SVG, {
seed: config.handDrawnSeed,
});
const graphDiv = document.querySelector<SVGSVGElement>(idSelector)!;
svg2roughjs.svg = graphDiv;
await svg2roughjs.sketch();
graphDiv.remove();
const sketch = document.querySelector<SVGSVGElement>(`${enclosingDivID_selector} > svg`);
if (!sketch) {
throw new Error('sketch not found');
}
const height = sketch.getAttribute('height');
const width = sketch.getAttribute('width');
sketch.setAttribute('id', id);
sketch.removeAttribute('height');
sketch.setAttribute('width', '100%');
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
svgCode = sketch.outerHTML;
}
if (isSandboxed) {
const svgEl = root.select(enclosingDivID_selector + ' svg').node();

View File

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

View File

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

View File

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

View File

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

23
pnpm-lock.yaml generated
View File

@@ -277,6 +277,9 @@ importers:
stylis:
specifier: ^4.3.6
version: 4.3.6
svg2roughjs:
specifier: ^3.2.1
version: 3.2.1
ts-dedent:
specifier: ^2.2.0
version: 2.2.0
@@ -8857,6 +8860,13 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svg-pathdata@6.0.3:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
svg2roughjs@3.2.1:
resolution: {integrity: sha512-38OYXS0/otPfF3sdutfv1u+iZ7bvLIptaDTNHQh0hBjOYm5KsObAhWdVqUsmWKk85xt7XWOD8obyrqkELq7apw==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -8963,6 +8973,9 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -19955,6 +19968,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svg-pathdata@6.0.3: {}
svg2roughjs@3.2.1:
dependencies:
roughjs: 4.6.6(patch_hash=3543d47108cb41b68ec6a671c0e1f9d0cfe2ce524fea5b0992511ae84c3c6b64)
svg-pathdata: 6.0.3
tinycolor2: 1.6.0
symbol-tree@3.2.4: {}
synckit@0.11.11:
@@ -20101,6 +20122,8 @@ snapshots:
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@0.3.2: {}
tinyexec@1.0.1: {}