Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions __tests__/tools/mermaid.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
"description": "Theme for the diagram (optional). Default is 'default'.",
"default": "default"
},
"look": {
"type": "string",
"enum": ["classic", "handDrawn"],
"description": "Visual style for the diagram (optional). 'classic' is the default Mermaid look; 'handDrawn' produces a sketchy, Excalidraw-like rendering (Mermaid v11+).",
"default": "classic"
},
"backgroundColor": {
"type": "string",
"description": "Background color for the diagram (optional). Default is 'white'.",
Expand Down
42 changes: 42 additions & 0 deletions __tests__/utils/mermaidUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { inflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { createMermaidInkUrl } from "../../src/utils/mermaidUrl";

function decodePayload(url: string): {
code: string;
mermaid: { theme: string; look: string };
} {
const encoded = url.split("pako:")[1];
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const buffer = Buffer.from(padded, "base64");
return JSON.parse(inflateSync(buffer).toString("utf-8"));
}

describe("createMermaidInkUrl", () => {
const sample = "graph TD;A-->B;";

it("defaults to classic look when not specified", () => {
const url = createMermaidInkUrl(sample, "img");
expect(url.startsWith("https://mermaid.ink/img/pako:")).toBe(true);
const payload = decodePayload(url);
expect(payload.mermaid.theme).toBe("default");
expect(payload.mermaid.look).toBe("classic");
expect(payload.code).toBe(sample);
});

it("propagates handDrawn look into the mermaid.ink payload", () => {
const url = createMermaidInkUrl(sample, "svg", "forest", "handDrawn");
expect(url.startsWith("https://mermaid.ink/svg/pako:")).toBe(true);
const payload = decodePayload(url);
expect(payload.mermaid.theme).toBe("forest");
expect(payload.mermaid.look).toBe("handDrawn");
});

it("uses the requested variant in the URL path", () => {
const svgUrl = createMermaidInkUrl(sample, "svg");
const imgUrl = createMermaidInkUrl(sample, "img");
expect(svgUrl).toMatch(/^https:\/\/mermaid\.ink\/svg\//);
expect(imgUrl).toMatch(/^https:\/\/mermaid\.ink\/img\//);
});
});
12 changes: 10 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,23 @@ function setupToolHandlers(server: McpServer): void {
);
}

const { mermaid, theme, backgroundColor, outputType = "base64" } = args;
const {
mermaid,
theme,
look,
backgroundColor,
outputType = "base64",
} = result.data;
Logger.info(
`Rendering diagram (outputType=${outputType}, theme=${theme ?? "default"})`,
`Rendering diagram (outputType=${outputType}, theme=${theme ?? "default"}, look=${look ?? "classic"})`,
);
const { id, svg, screenshot } = await withRetry(
() =>
renderMermaid(
mermaid as string,
theme as string,
backgroundColor as string,
look as "classic" | "handDrawn" | undefined,
),
{ maxAttempts: 3, delayMs: 500 },
);
Expand Down Expand Up @@ -99,6 +106,7 @@ function setupToolHandlers(server: McpServer): void {
mermaid as string,
variant,
(theme as string) || "default",
(look as "classic" | "handDrawn" | undefined) || "classic",
);
return {
content: [
Expand Down
7 changes: 7 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ C-->D;.`)
.describe("Theme for the diagram (optional). Default is 'default'.")
.optional()
.default("default"),
look: z
.enum(["classic", "handDrawn"])
.describe(
"Visual style for the diagram (optional). 'classic' is the default Mermaid look; 'handDrawn' produces a sketchy, Excalidraw-like rendering (Mermaid v11+).",
)
.optional()
.default("classic"),
backgroundColor: z
.string()
.describe(
Expand Down
9 changes: 7 additions & 2 deletions src/utils/mermaidUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@ function encodeMermaidToBase64Url(mermaid: string): string {

/**
* Creates a public mermaid.ink URL for the given mermaid definition.
* The payload must be a JSON object `{ code, mermaid: { theme } }` as expected by mermaid.ink.
* The payload is a JSON object `{ code, mermaid: { theme, look } }` as expected by mermaid.ink.
* `look` defaults to `"classic"`; pass `"handDrawn"` for a sketch-style rendering.
*/
export function createMermaidInkUrl(
mermaid: string,
variant: "svg" | "img",
theme = "default",
look: "classic" | "handDrawn" = "classic",
): string {
Comment thread
smorand marked this conversation as resolved.
const payload = JSON.stringify({ code: mermaid, mermaid: { theme } });
const payload = JSON.stringify({
code: mermaid,
mermaid: { theme, look },
});
const encoded = encodeMermaidToBase64Url(payload);
return `https://mermaid.ink/${variant}/pako:${encoded}`;
}
5 changes: 4 additions & 1 deletion src/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function renderMermaid(
mermaid: string,
theme = "default",
backgroundColor = "white",
look: "classic" | "handDrawn" = "classic",
): Promise<RenderResult> {
if (!renderer) renderer = createMermaidRenderer();
const cssContent = `svg { background: ${backgroundColor}; }`;
Expand All @@ -31,8 +32,10 @@ export async function renderMermaid(
screenshot: true,
css: cssTmpPath,
mermaidConfig: {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// biome-ignore lint/suspicious/noExplicitAny: Mermaid accepts string theme values here, but the renderer boundary does not expose a precise type for this config property.
theme: theme as any,
// biome-ignore lint/suspicious/noExplicitAny: Mermaid accepts "classic" | "handDrawn" for `look`, but the renderer boundary does not expose a precise type for this config property.
look: look as any,
},
});
const r0 = r[0] as PromiseSettledResult<RenderResult>;
Expand Down
Loading