Skip to content

Commit d2edc90

Browse files
authored
feat: add custom Mermaid configuration support (#122)
* feat: add custom Mermaid configuration support * ci: add change set * fix: optimize mermaid config comparison and initialization
1 parent 266fa2b commit d2edc90

File tree

9 files changed

+228
-53
lines changed

9 files changed

+228
-53
lines changed

.changeset/tough-maps-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
feat: add custom Mermaid configuration support

apps/website/app/components/mermaid.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@ sequenceDiagram
4545
<Section
4646
description={
4747
<>
48-
Streamdown supports Mermaid diagrams, streaming as code blocks with a
49-
button to render them.
48+
Streamdown supports Mermaid diagrams with customizable themes. Current theme is "base".
5049
</>
5150
}
5251
markdown={mermaidExample}
52+
streamdownProps={{
53+
mermaidConfig: {
54+
theme: "base"
55+
}
56+
}}
5357
speed={60}
5458
title="Interactive Mermaid Diagrams"
5559
/>

apps/website/app/components/props.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ const props = [
6363
description:
6464
'The themes to use for code blocks. Defaults to ["github-light", "github-dark"].',
6565
},
66+
{
67+
name: "mermaidConfig",
68+
type: "MermaidConfig (from Mermaid)",
69+
description:
70+
"Custom configuration for Mermaid diagrams including theme, colors, fonts, and other rendering options. See Mermaid documentation for all available options.",
71+
},
6672
];
6773

6874
export const Props = () => (

packages/streamdown/README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Streamdown supports Mermaid diagrams using the `mermaid` language identifier:
5656

5757
```tsx
5858
import { Streamdown } from 'streamdown';
59+
import type { MermaidConfig } from 'mermaid';
5960

6061
export default function Page() {
6162
const markdown = `
@@ -84,7 +85,20 @@ sequenceDiagram
8485
\`\`\`
8586
`;
8687

87-
return <Streamdown>{markdown}</Streamdown>;
88+
// Optional: Customize Mermaid theme and colors
89+
const mermaidConfig: MermaidConfig = {
90+
theme: 'dark',
91+
themeVariables: {
92+
primaryColor: '#ff0000',
93+
primaryTextColor: '#fff'
94+
}
95+
};
96+
97+
return (
98+
<Streamdown mermaidConfig={mermaidConfig}>
99+
{markdown}
100+
</Streamdown>
101+
);
88102
}
89103
```
90104

@@ -150,7 +164,8 @@ Streamdown accepts all the same props as react-markdown, plus additional streami
150164
| `allowedImagePrefixes` | `array` | `['*']` | Allowed image URL prefixes |
151165
| `allowedLinkPrefixes` | `array` | `['*']` | Allowed link URL prefixes |
152166
| `defaultOrigin` | `string` | - | Default origin to use for relative URLs in links and images |
153-
| `shikiTheme` | `BundledTheme` (from Shiki) | `github-light` | The theme to use for code blocks |
167+
| `shikiTheme` | `[BundledTheme, BundledTheme]` | `['github-light', 'github-dark']` | The light and dark themes to use for code blocks |
168+
| `mermaidConfig` | `MermaidConfig` | - | Custom configuration for Mermaid diagrams (theme, colors, etc.) |
154169

155170
## Architecture
156171

packages/streamdown/__tests__/mermaid.test.tsx

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { render } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { MermaidConfig } from "mermaid";
34
import { Mermaid } from "../lib/mermaid";
45

56
// Mock mermaid
7+
const mockInitialize = vi.fn();
8+
const mockRender = vi.fn().mockResolvedValue({ svg: "<svg>Test SVG</svg>" });
9+
610
vi.mock("mermaid", () => ({
711
default: {
8-
initialize: vi.fn(),
9-
render: vi.fn(),
12+
initialize: mockInitialize,
13+
render: mockRender,
1014
},
1115
}));
1216

1317
describe("Mermaid", () => {
18+
beforeEach(() => {
19+
// Clear mock calls before each test
20+
mockInitialize.mockClear();
21+
mockRender.mockClear();
22+
});
23+
1424
it("renders without crashing", () => {
1525
const { container } = render(<Mermaid chart="graph TD; A-->B" />);
1626
expect(container.firstChild).toBeDefined();
@@ -24,4 +34,98 @@ describe("Mermaid", () => {
2434
const mermaidContainer = container.firstChild as HTMLElement;
2535
expect(mermaidContainer.className).toContain("custom-class");
2636
});
37+
38+
it("initializes with custom config", async () => {
39+
const customConfig: MermaidConfig = {
40+
theme: "dark",
41+
themeVariables: {
42+
primaryColor: "#ff0000",
43+
primaryTextColor: "#ffffff",
44+
},
45+
fontFamily: "Arial, sans-serif",
46+
} as MermaidConfig;
47+
48+
render(<Mermaid chart="graph TD; A-->B" config={customConfig} />);
49+
50+
// Wait for initialization
51+
await vi.waitFor(() => {
52+
expect(mockInitialize).toHaveBeenCalled();
53+
});
54+
55+
// Check that initialize was called with the custom config
56+
const initializeCall = mockInitialize.mock.calls[0][0];
57+
expect(initializeCall.theme).toBe("dark");
58+
expect(initializeCall.themeVariables?.primaryColor).toBe("#ff0000");
59+
expect(initializeCall.fontFamily).toBe("Arial, sans-serif");
60+
});
61+
62+
it("initializes with default config when none provided", async () => {
63+
render(<Mermaid chart="graph TD; A-->B" />);
64+
65+
// Wait for initialization
66+
await vi.waitFor(() => {
67+
expect(mockInitialize).toHaveBeenCalled();
68+
});
69+
70+
// Check that initialize was called with default config
71+
const initializeCall = mockInitialize.mock.calls[0][0];
72+
expect(initializeCall.theme).toBe("default");
73+
expect(initializeCall.securityLevel).toBe("strict");
74+
expect(initializeCall.fontFamily).toBe("monospace");
75+
});
76+
77+
it("accepts different config values", () => {
78+
const config1: MermaidConfig = {
79+
theme: "forest",
80+
} as MermaidConfig;
81+
82+
const { rerender } = render(<Mermaid chart="graph TD; A-->B" config={config1} />);
83+
84+
// Should render without error
85+
expect(mockRender).toBeDefined();
86+
87+
const config2: MermaidConfig = {
88+
theme: "dark",
89+
fontFamily: "Arial",
90+
} as MermaidConfig;
91+
92+
// Should be able to rerender with different config
93+
rerender(<Mermaid chart="graph TD; A-->B" config={config2} />);
94+
95+
// Should still render without error
96+
expect(mockRender).toBeDefined();
97+
});
98+
99+
it("handles complex config objects with functions", () => {
100+
const config: MermaidConfig = {
101+
theme: "dark",
102+
themeVariables: {
103+
primaryColor: "#ff0000",
104+
primaryTextColor: "#ffffff",
105+
},
106+
fontFamily: "Arial",
107+
} as MermaidConfig;
108+
109+
const { container } = render(<Mermaid chart="graph TD; A-->B" config={config} />);
110+
111+
// Should render without error even with complex config
112+
expect(container.firstChild).toBeTruthy();
113+
});
114+
115+
it("supports multiple components with different configs", async () => {
116+
const config1: MermaidConfig = { theme: "forest" } as MermaidConfig;
117+
const config2: MermaidConfig = { theme: "dark" } as MermaidConfig;
118+
119+
// Render first component
120+
const { rerender } = render(<Mermaid chart="graph TD; A-->B" config={config1} />);
121+
122+
await vi.waitFor(() => expect(mockInitialize).toHaveBeenCalledTimes(1));
123+
expect(mockInitialize.mock.calls[0][0].theme).toBe("forest");
124+
125+
// Render second component with different config
126+
rerender(<Mermaid chart="graph TD; X-->Y" config={config2} />);
127+
128+
await vi.waitFor(() => expect(mockInitialize).toHaveBeenCalledTimes(2));
129+
expect(mockInitialize.mock.calls[1][0].theme).toBe("dark");
130+
});
27131
});

packages/streamdown/__tests__/streamdown.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
3+
import type { MermaidConfig } from "mermaid";
34
import { Streamdown } from "../index";
45

56
// Mock the dependencies
@@ -230,4 +231,33 @@ And an incomplete [link
230231
expect(allText).toContain("bold");
231232
expect(allText).toContain("italic");
232233
});
234+
235+
it("should accept mermaidConfig prop", () => {
236+
const mermaidConfig: MermaidConfig = {
237+
theme: "dark",
238+
themeVariables: {
239+
primaryColor: "#ff0000",
240+
},
241+
} as MermaidConfig;
242+
243+
const { container } = render(
244+
<Streamdown mermaidConfig={mermaidConfig}>
245+
Test content
246+
</Streamdown>
247+
);
248+
249+
// Just verify it renders without error when mermaidConfig is provided
250+
expect(container.firstElementChild).toBeTruthy();
251+
});
252+
253+
it("should render without mermaidConfig", () => {
254+
const { container } = render(
255+
<Streamdown>
256+
Test content
257+
</Streamdown>
258+
);
259+
260+
// Just verify it renders without error when mermaidConfig is not provided
261+
expect(container.firstElementChild).toBeTruthy();
262+
});
233263
});

packages/streamdown/index.tsx

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import "katex/dist/katex.min.css";
1010
import hardenReactMarkdownImport from "harden-react-markdown";
1111
import type { Options as RemarkGfmOptions } from "remark-gfm";
1212
import type { Options as RemarkMathOptions } from "remark-math";
13+
import type { MermaidConfig } from "mermaid";
1314
import { components as defaultComponents } from "./lib/components";
1415
import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
1516
import { parseIncompleteMarkdown } from "./lib/parse-incomplete-markdown";
1617
import { cn } from "./lib/utils";
1718

19+
export type { MermaidConfig } from "mermaid";
20+
1821
type HardenReactMarkdownProps = Options & {
1922
defaultOrigin?: string;
2023
allowedLinkPrefixes?: string[];
@@ -34,13 +37,16 @@ export type StreamdownProps = HardenReactMarkdownProps & {
3437
parseIncompleteMarkdown?: boolean;
3538
className?: string;
3639
shikiTheme?: [BundledTheme, BundledTheme];
40+
mermaidConfig?: MermaidConfig;
3741
};
3842

3943
export const ShikiThemeContext = createContext<[BundledTheme, BundledTheme]>([
4044
"github-light" as BundledTheme,
4145
"github-dark" as BundledTheme,
4246
]);
4347

48+
export const MermaidConfigContext = createContext<MermaidConfig | undefined>(undefined);
49+
4450
type BlockProps = HardenReactMarkdownProps & {
4551
content: string;
4652
shouldParseIncompleteMarkdown: boolean;
@@ -81,6 +87,7 @@ export const Streamdown = memo(
8187
remarkPlugins,
8288
className,
8389
shikiTheme = ["github-light", "github-dark"],
90+
mermaidConfig,
8491
...props
8592
}: StreamdownProps) => {
8693
// Parse the children to remove incomplete markdown tokens if enabled
@@ -97,29 +104,31 @@ export const Streamdown = memo(
97104

98105
return (
99106
<ShikiThemeContext.Provider value={shikiTheme}>
100-
<div className={cn("space-y-4", className)} {...props}>
101-
{blocks.map((block, index) => (
102-
<Block
103-
allowedImagePrefixes={allowedImagePrefixes}
104-
allowedLinkPrefixes={allowedLinkPrefixes}
105-
components={{
106-
...defaultComponents,
107-
...components,
108-
}}
109-
content={block}
110-
defaultOrigin={defaultOrigin}
111-
// biome-ignore lint/suspicious/noArrayIndexKey: "required"
112-
key={`${generatedId}-block_${index}`}
113-
rehypePlugins={[rehypeKatexPlugin, ...(rehypePlugins ?? [])]}
114-
remarkPlugins={[
115-
[remarkGfm, remarkGfmOptions],
116-
[remarkMath, remarkMathOptions],
117-
...(remarkPlugins ?? []),
118-
]}
119-
shouldParseIncompleteMarkdown={shouldParseIncompleteMarkdown}
120-
/>
121-
))}
122-
</div>
107+
<MermaidConfigContext.Provider value={mermaidConfig}>
108+
<div className={cn("space-y-4", className)} {...props}>
109+
{blocks.map((block, index) => (
110+
<Block
111+
allowedImagePrefixes={allowedImagePrefixes}
112+
allowedLinkPrefixes={allowedLinkPrefixes}
113+
components={{
114+
...defaultComponents,
115+
...components,
116+
}}
117+
content={block}
118+
defaultOrigin={defaultOrigin}
119+
// biome-ignore lint/suspicious/noArrayIndexKey: "required"
120+
key={`${generatedId}-block_${index}`}
121+
rehypePlugins={[rehypeKatexPlugin, ...(rehypePlugins ?? [])]}
122+
remarkPlugins={[
123+
[remarkGfm, remarkGfmOptions],
124+
[remarkMath, remarkMathOptions],
125+
...(remarkPlugins ?? []),
126+
]}
127+
shouldParseIncompleteMarkdown={shouldParseIncompleteMarkdown}
128+
/>
129+
))}
130+
</div>
131+
</MermaidConfigContext.Provider>
123132
</ShikiThemeContext.Provider>
124133
);
125134
},

packages/streamdown/lib/components.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import {
22
type DetailedHTMLProps,
33
type HTMLAttributes,
44
isValidElement,
5+
useContext,
56
} from "react";
67
import type { ExtraProps, Options } from "react-markdown";
78
import type { BundledLanguage } from "shiki";
9+
import { MermaidConfigContext } from "../index";
810
import {
911
CodeBlock,
1012
CodeBlockCopyButton,
@@ -59,6 +61,7 @@ const CodeComponent = ({
5961
}
6062

6163
if (language === "mermaid") {
64+
const mermaidConfig = useContext(MermaidConfigContext);
6265
return (
6366
<div
6467
className={cn(
@@ -71,7 +74,7 @@ const CodeComponent = ({
7174
<CodeBlockDownloadButton code={code} language={language} />
7275
<CodeBlockCopyButton code={code} />
7376
</div>
74-
<Mermaid chart={code} />
77+
<Mermaid chart={code} config={mermaidConfig} />
7578
</div>
7679
);
7780
}

0 commit comments

Comments
 (0)