mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-10-30 02:14:15 +01:00
Compare commits
9 Commits
renovate/p
...
sidv/iconi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8a0a61b5e | ||
|
|
525e630ebc | ||
|
|
928ae32063 | ||
|
|
25d96a90de | ||
|
|
c24a1fb1b9 | ||
|
|
c607163999 | ||
|
|
df21885a27 | ||
|
|
172030377f | ||
|
|
a23c2baed8 |
@@ -28,6 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
|
||||
'packet',
|
||||
'architecture',
|
||||
'radar',
|
||||
'icons',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ mermaidchart
|
||||
mermaidjs
|
||||
mindmap
|
||||
mindmaps
|
||||
mmdc
|
||||
mrtree
|
||||
multigraph
|
||||
nodesep
|
||||
|
||||
240
cypress/integration/rendering/icons.spec.ts
Normal file
240
cypress/integration/rendering/icons.spec.ts
Normal 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
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dompurify": "^3.2.7",
|
||||
"dompurify": "^3.2.5",
|
||||
"katex": "^0.16.22",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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.
|
||||
|
||||
560
packages/mermaid/src/rendering-util/icons.spec.ts
Normal file
560
packages/mermaid/src/rendering-util/icons.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -257,8 +257,8 @@ importers:
|
||||
specifier: ^1.11.18
|
||||
version: 1.11.18
|
||||
dompurify:
|
||||
specifier: ^3.2.7
|
||||
version: 3.3.0
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.6
|
||||
katex:
|
||||
specifier: ^0.16.22
|
||||
version: 0.16.22
|
||||
@@ -5166,8 +5166,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.0:
|
||||
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
|
||||
dompurify@3.2.6:
|
||||
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
@@ -13803,7 +13803,7 @@ snapshots:
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
color-string: 2.1.2
|
||||
dompurify: 3.3.0
|
||||
dompurify: 3.2.6
|
||||
highlight.js: 10.7.3
|
||||
html-to-image: 1.11.13
|
||||
immer: 10.1.3
|
||||
@@ -15416,7 +15416,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.0:
|
||||
dompurify@3.2.6:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
|
||||
Reference in New Issue
Block a user