@multiplatform.one/markdown 7.7.0 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Markdown icons — the four hand-drawn SVGs the package ships and exports
3
+ * from its barrel: the copy affordance on MDXCodeBlock (Clipboard → Check),
4
+ * CheckCircle, and the heading anchor's Link.
5
+ *
6
+ * They are raw @tamagui/react-native-svg paths, not catalog icons, so they
7
+ * cannot consume `$colorN` token strings in a stroke attribute. useIconColor
8
+ * resolves the token to a concrete value first (SB-M-1206); pass a raw token
9
+ * and it still paints, which is what the ThemeTokens story shows.
10
+ */
11
+
12
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
13
+ import { Paragraph, XStack, YStack } from "tamagui";
14
+ import { CheckCircleIcon, CheckIcon, ClipboardIcon, LinkIcon } from "./index";
15
+
16
+ const meta: Meta = {
17
+ title: "Markdown/Icons",
18
+ parameters: {
19
+ status: { type: "beta" },
20
+ docs: {
21
+ description: {
22
+ component:
23
+ "ClipboardIcon, CheckIcon, CheckCircleIcon and LinkIcon. Size is a number in px (Clipboard/Check/CheckCircle default 16, Link defaults 12); color takes a concrete CSS color or a $theme token, which useIconColor resolves before it reaches the SVG stroke.",
24
+ },
25
+ },
26
+ },
27
+ };
28
+
29
+ export default meta;
30
+ type Story = StoryObj;
31
+
32
+ const icons = [
33
+ { name: "ClipboardIcon", Icon: ClipboardIcon },
34
+ { name: "CheckIcon", Icon: CheckIcon },
35
+ { name: "CheckCircleIcon", Icon: CheckCircleIcon },
36
+ { name: "LinkIcon", Icon: LinkIcon },
37
+ ] as const;
38
+
39
+ export const Main: Story = {
40
+ args: { size: 24, color: "$color12" },
41
+ argTypes: {
42
+ size: { control: { type: "range", min: 8, max: 64, step: 2 } },
43
+ color: {
44
+ control: "select",
45
+ options: ["$color12", "$color10", "$blue10", "$green10", "$red10", "#ff8800"],
46
+ },
47
+ },
48
+ render: (args: any) => (
49
+ <XStack gap="$6" padding="$4" alignItems="flex-start">
50
+ {icons.map(({ name, Icon }) => (
51
+ <YStack key={name} gap="$2" alignItems="center">
52
+ <Icon size={args.size} color={args.color} />
53
+ <Paragraph size="$1" color="$color10">
54
+ {name}
55
+ </Paragraph>
56
+ </YStack>
57
+ ))}
58
+ </XStack>
59
+ ),
60
+ };
61
+
62
+ /** The size ladder each icon is actually used at, plus the extremes. */
63
+ export const SizeLadder: Story = {
64
+ render: () => (
65
+ <YStack gap="$4" padding="$4">
66
+ {icons.map(({ name, Icon }) => (
67
+ <XStack key={name} gap="$4" alignItems="center">
68
+ <Paragraph size="$2" width={140}>
69
+ {name}
70
+ </Paragraph>
71
+ {[8, 12, 16, 24, 48].map((size) => (
72
+ <Icon key={`${name}-${size}`} size={size} />
73
+ ))}
74
+ </XStack>
75
+ ))}
76
+ </YStack>
77
+ ),
78
+ };
79
+
80
+ /**
81
+ * SB-M-1206: a `$colorN` string cannot go straight into a stroke attribute.
82
+ * Every icon here is given a raw token, so a regression in useIconColor shows
83
+ * up as invisible strokes rather than a subtle mis-tint.
84
+ */
85
+ export const ThemeTokens: Story = {
86
+ render: () => (
87
+ <YStack gap="$4" padding="$4">
88
+ {["$color12", "$color10", "$blue10", "$green10", "$red10"].map((token) => (
89
+ <XStack key={token} gap="$4" alignItems="center">
90
+ <Paragraph size="$2" width={100} color="$color10">
91
+ {token}
92
+ </Paragraph>
93
+ {icons.map(({ name, Icon }) => (
94
+ <Icon key={`${token}-${name}`} size={24} color={token} />
95
+ ))}
96
+ </XStack>
97
+ ))}
98
+ </YStack>
99
+ ),
100
+ };
101
+
102
+ /**
103
+ * In situ: Clipboard and Check are the two faces of the copy button on
104
+ * MDXCodeBlock, and Link is the anchor that appears beside a heading.
105
+ */
106
+ export const InSitu: Story = {
107
+ render: () => (
108
+ <YStack gap="$4" padding="$4" maxWidth={520}>
109
+ <XStack gap="$3" alignItems="center">
110
+ <ClipboardIcon size={16} color="$color11" />
111
+ <Paragraph size="$3">Copy — idle</Paragraph>
112
+ </XStack>
113
+ <XStack gap="$3" alignItems="center">
114
+ <CheckIcon size={16} color="$green10" />
115
+ <Paragraph size="$3">Copy — copied</Paragraph>
116
+ </XStack>
117
+ <XStack gap="$3" alignItems="center">
118
+ <CheckCircleIcon size={16} color="$green10" />
119
+ <Paragraph size="$3">A completed item</Paragraph>
120
+ </XStack>
121
+ <XStack gap="$2" alignItems="center">
122
+ <Paragraph size="$6" fontWeight="700">
123
+ A heading
124
+ </Paragraph>
125
+ <LinkIcon size={12} color="$color10" />
126
+ </XStack>
127
+ </YStack>
128
+ ),
129
+ };
130
+
131
+ /** A custom strokeWidth is CheckIcon's only extra knob. */
132
+ export const CheckStrokeWidth: Story = {
133
+ render: () => (
134
+ <XStack gap="$5" padding="$4" alignItems="center">
135
+ {[1, 1.5, 2.5, 4].map((strokeWidth) => (
136
+ <YStack key={strokeWidth} gap="$2" alignItems="center">
137
+ <CheckIcon size={32} strokeWidth={strokeWidth} />
138
+ <Paragraph size="$1" color="$color10">
139
+ {String(strokeWidth)}
140
+ </Paragraph>
141
+ </YStack>
142
+ ))}
143
+ </XStack>
144
+ ),
145
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Markdown icons spec (MPO-195). The four SVGs are exported from the package
3
+ * barrel and had no colocated spec. What matters here is the thing that has
4
+ * already broken once: a `$colorN` string cannot reach a raw SVG stroke
5
+ * attribute, so useIconColor must resolve it to a concrete value first
6
+ * (SB-M-1206). An unresolved token paints nothing and no test caught it.
7
+ */
8
+
9
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
10
+ import { describe, expect, it } from "vitest";
11
+ import { CheckCircleIcon, CheckIcon, ClipboardIcon, LinkIcon } from "./index";
12
+
13
+ const icons = [
14
+ { name: "ClipboardIcon", Icon: ClipboardIcon, defaultSize: "16" },
15
+ { name: "CheckIcon", Icon: CheckIcon, defaultSize: "16" },
16
+ { name: "CheckCircleIcon", Icon: CheckCircleIcon, defaultSize: "16" },
17
+ { name: "LinkIcon", Icon: LinkIcon, defaultSize: "12" },
18
+ ] as const;
19
+
20
+ function strokes(container: HTMLElement): string[] {
21
+ return Array.from(container.querySelectorAll("[stroke]")).map(
22
+ (el) => el.getAttribute("stroke") ?? "",
23
+ );
24
+ }
25
+
26
+ describe("markdown icons", () => {
27
+ for (const { name, Icon, defaultSize } of icons) {
28
+ it(`${name} renders an svg at its documented default size`, () => {
29
+ const { container } = renderWithProviders(<Icon />);
30
+ const svg = container.querySelector("svg");
31
+ expect(svg, name).toBeTruthy();
32
+ expect(svg!.getAttribute("width")).toBe(defaultSize);
33
+ expect(svg!.getAttribute("height")).toBe(defaultSize);
34
+ expect(svg!.getAttribute("viewBox")).toBe("0 0 24 24");
35
+ });
36
+
37
+ it(`${name} honours an explicit size`, () => {
38
+ const { container } = renderWithProviders(<Icon size={40} />);
39
+ const svg = container.querySelector("svg")!;
40
+ expect(svg.getAttribute("width")).toBe("40");
41
+ expect(svg.getAttribute("height")).toBe("40");
42
+ });
43
+
44
+ it(`${name} resolves a $token colour before it reaches the stroke (SB-M-1206)`, () => {
45
+ const { container } = renderWithProviders(<Icon color="$color10" />);
46
+ const painted = strokes(container);
47
+ expect(painted.length, name).toBeGreaterThan(0);
48
+ for (const stroke of painted) {
49
+ expect(stroke, name).not.toMatch(/^\$/);
50
+ expect(stroke, name).not.toBe("");
51
+ }
52
+ });
53
+
54
+ it(`${name} passes a concrete colour straight through`, () => {
55
+ const { container } = renderWithProviders(<Icon color="#ff8800" />);
56
+ for (const stroke of strokes(container)) expect(stroke).toBe("#ff8800");
57
+ });
58
+
59
+ it(`${name} paints a concrete stroke with no colour prop at all`, () => {
60
+ const { container } = renderWithProviders(<Icon />);
61
+ const painted = strokes(container);
62
+ expect(painted.length, name).toBeGreaterThan(0);
63
+ for (const stroke of painted) expect(stroke).not.toMatch(/^\$/);
64
+ });
65
+ }
66
+
67
+ it("an unknown $token degrades to a concrete colour rather than an empty stroke", () => {
68
+ const { container } = renderWithProviders(<CheckIcon color="$notARealToken" />);
69
+ for (const stroke of strokes(container)) {
70
+ expect(stroke).not.toMatch(/^\$/);
71
+ expect(stroke).not.toBe("");
72
+ }
73
+ });
74
+
75
+ it("CheckIcon takes a custom strokeWidth", () => {
76
+ const { container } = renderWithProviders(<CheckIcon strokeWidth={4} />);
77
+ expect(container.querySelector("path")!.getAttribute("stroke-width")).toBe("4");
78
+ });
79
+
80
+ it("CheckIcon defaults strokeWidth to 2.5, the other icons to 2", () => {
81
+ const check = renderWithProviders(<CheckIcon />);
82
+ expect(check.container.querySelector("path")!.getAttribute("stroke-width")).toBe("2.5");
83
+ const clipboard = renderWithProviders(<ClipboardIcon />);
84
+ expect(clipboard.container.querySelector("rect")!.getAttribute("stroke-width")).toBe("2");
85
+ });
86
+
87
+ it("ClipboardIcon and CheckCircleIcon draw their distinguishing shapes", () => {
88
+ const clipboard = renderWithProviders(<ClipboardIcon />);
89
+ expect(clipboard.container.querySelector("rect")).toBeTruthy();
90
+ const checkCircle = renderWithProviders(<CheckCircleIcon />);
91
+ expect(checkCircle.container.querySelector("circle")).toBeTruthy();
92
+ });
93
+
94
+ it("LinkIcon draws both halves of the broken chain", () => {
95
+ const { container } = renderWithProviders(<LinkIcon />);
96
+ expect(container.querySelectorAll("path")).toHaveLength(2);
97
+ });
98
+ });
@@ -61,10 +61,41 @@ describe("MermaidBlock", () => {
61
61
  });
62
62
  });
63
63
 
64
+ /**
65
+ * The native tier is its own module, not a branch of the web one (MPO-195).
66
+ * It never touches the engine, so it has no loading state, no error state and
67
+ * no diagram host — every input is source, shown as source.
68
+ */
64
69
  describe("MermaidBlock native fallback", () => {
65
70
  it("renders the source as a mermaid CodeBlock, never a diagram host", () => {
66
71
  const { container } = renderWithProviders(<NativeMermaidBlock>{FLOWCHART}</NativeMermaidBlock>);
67
72
  expect(container.querySelector('[data-mermaid-diagram="true"]')).toBeNull();
68
73
  expect(container.textContent).toContain("graph TD");
69
74
  });
75
+
76
+ it("never calls the engine or the mount helper", () => {
77
+ renderMermaidDiagram.mockClear();
78
+ mountDiagramSvg.mockClear();
79
+ renderWithProviders(<NativeMermaidBlock>{FLOWCHART}</NativeMermaidBlock>);
80
+ expect(renderMermaidDiagram).not.toHaveBeenCalled();
81
+ expect(mountDiagramSvg).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("shows invalid source verbatim with no error alert", () => {
85
+ const { container } = renderWithProviders(
86
+ <NativeMermaidBlock>{"graph TD\n A--"}</NativeMermaidBlock>,
87
+ );
88
+ expect(container.querySelector('[role="alert"]')).toBeNull();
89
+ expect(container.textContent).toContain("graph TD");
90
+ });
91
+
92
+ it("renders a code block for empty source instead of nothing", () => {
93
+ const { container } = renderWithProviders(<NativeMermaidBlock>{""}</NativeMermaidBlock>);
94
+ expect(container.querySelector('[data-mermaid-diagram="true"]')).toBeNull();
95
+ expect(container.firstElementChild).toBeTruthy();
96
+ });
97
+
98
+ it("is a different component from the web block", () => {
99
+ expect(NativeMermaidBlock).not.toBe(MermaidBlock);
100
+ });
70
101
  });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * MermaidBlock.native — the NATIVE fallback, rendered here on web so the
3
+ * fallback itself is inspectable.
4
+ *
5
+ * Markdown/Mermaid's NativeFallbackNote story described this file in prose
6
+ * and then rendered the web block instead, so nobody had ever looked at what
7
+ * native actually paints. The native tier has no DOM, mermaid needs one, so
8
+ * the fallback renders the diagram SOURCE as a mermaid-tagged CodeBlock.
9
+ * Importing the `.native` module explicitly is what makes that visible —
10
+ * the web resolver would otherwise hand back MermaidBlock.tsx.
11
+ */
12
+
13
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
14
+ import { Paragraph, YStack } from "tamagui";
15
+ import { MermaidBlock as NativeMermaidBlock } from "./MermaidBlock.native";
16
+
17
+ const FLOWCHART = `graph TD
18
+ A[Scan] --> B[Preview]
19
+ B --> C[Commit]`;
20
+
21
+ const SEQUENCE = `sequenceDiagram
22
+ Client->>Server: parse
23
+ Server-->>Client: mdast`;
24
+
25
+ const meta: Meta<typeof NativeMermaidBlock> = {
26
+ title: "Markdown/Mermaid Native",
27
+ component: NativeMermaidBlock,
28
+ parameters: {
29
+ status: { type: "beta" },
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "The native tier of MermaidBlock. No DOM means no mermaid engine, so the diagram source renders as a mermaid CodeBlock instead of an SVG. Same props as the web block, no diagram host and no error state — invalid source is just source.",
34
+ },
35
+ },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof NativeMermaidBlock>;
41
+
42
+ export const Main: Story = {
43
+ args: { children: FLOWCHART },
44
+ };
45
+
46
+ /** A second grammar: the fallback never parses, so any source is fine. */
47
+ export const Sequence: Story = {
48
+ args: { children: SEQUENCE },
49
+ };
50
+
51
+ /**
52
+ * The web block shows a parse error above the source for invalid input.
53
+ * Native has no parser, so the same input is shown verbatim with no alert.
54
+ */
55
+ export const InvalidSourceIsJustSource: Story = {
56
+ args: { children: "graph TD\n A--" },
57
+ };
58
+
59
+ /** Side by side with what the fallback is standing in for. */
60
+ export const AgainstTheWebPath: Story = {
61
+ render: () => (
62
+ <YStack gap="$3" maxWidth={700} padding="$4">
63
+ <Paragraph size="$2" color="$color10">
64
+ This is MermaidBlock.native.tsx, the tier iOS and Android resolve. The web tier of the same
65
+ component renders a live SVG for this source; see Markdown/Mermaid.
66
+ </Paragraph>
67
+ <NativeMermaidBlock>{FLOWCHART}</NativeMermaidBlock>
68
+ </YStack>
69
+ ),
70
+ };
71
+
72
+ /** Empty and single-line sources still paint a code block, never nothing. */
73
+ export const Edge: Story = {
74
+ render: () => (
75
+ <YStack gap="$4" maxWidth={700} padding="$4">
76
+ <NativeMermaidBlock>{""}</NativeMermaidBlock>
77
+ <NativeMermaidBlock>{"graph LR; A-->B"}</NativeMermaidBlock>
78
+ </YStack>
79
+ ),
80
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * mountDiagramSvg — the sanitizing mount step between the mermaid engine and
3
+ * the page. MermaidBlock hands it an SVG string; it parses that string INERT
4
+ * (a detached document, so nothing executes during parsing), strips
5
+ * executable elements and event handlers, and imports only the <svg> into the
6
+ * live document.
7
+ *
8
+ * The helper is exported from the `@multiplatform.one/markdown/mermaid`
9
+ * subpath, so callers that render diagrams themselves reach it directly.
10
+ * These stories mount real strings into real containers, including a hostile
11
+ * one, and show what survived.
12
+ */
13
+
14
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
15
+ import { useEffect, useRef, useState } from "react";
16
+ import { Paragraph, View, YStack } from "tamagui";
17
+ import { mountDiagramSvg } from "./mountDiagramSvg";
18
+
19
+ const meta: Meta = {
20
+ title: "Markdown/Mermaid Mount",
21
+ parameters: {
22
+ status: { type: "beta" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "mountDiagramSvg(container, svg) parses an SVG string inert, drops script/iframe/object/embed nodes, on* handlers and javascript: hrefs, then imports the <svg> into the container. Returns false and leaves the container untouched when the string holds no <svg> at all.",
27
+ },
28
+ },
29
+ },
30
+ };
31
+
32
+ export default meta;
33
+ type Story = StoryObj;
34
+
35
+ /** Mounts `svg` on mount and reports what mountDiagramSvg returned. */
36
+ function MountSpecimen({ label, svg }: { label: string; svg: string }) {
37
+ const hostRef = useRef<HTMLDivElement>(null);
38
+ const [result, setResult] = useState<{ mounted: boolean; html: string } | null>(null);
39
+
40
+ useEffect(() => {
41
+ if (!hostRef.current) return;
42
+ const mounted = mountDiagramSvg(hostRef.current, svg);
43
+ setResult({ mounted, html: hostRef.current.innerHTML });
44
+ }, [svg]);
45
+
46
+ return (
47
+ <YStack gap="$2">
48
+ <Paragraph fontWeight="600">{label}</Paragraph>
49
+ <View
50
+ borderWidth={1}
51
+ borderColor="$borderColor"
52
+ borderRadius="$4"
53
+ padding="$3"
54
+ backgroundColor="$color2"
55
+ >
56
+ <div ref={hostRef} data-mount-host="true" />
57
+ </View>
58
+ <Paragraph size="$1" color="$color10">
59
+ {result ? `returned ${result.mounted}` : "mounting…"}
60
+ </Paragraph>
61
+ <Paragraph size="$1" color="$color10" fontFamily="$mono">
62
+ {result ? result.html || "(container left empty)" : ""}
63
+ </Paragraph>
64
+ </YStack>
65
+ );
66
+ }
67
+
68
+ const plainDiagram = `<svg xmlns="http://www.w3.org/2000/svg" width="220" height="90" viewBox="0 0 220 90">
69
+ <rect x="4" y="4" width="92" height="42" rx="6" fill="none" stroke="#7c8ea0" stroke-width="2"/>
70
+ <text x="50" y="30" text-anchor="middle" fill="#7c8ea0" font-size="14">Parse</text>
71
+ <rect x="124" y="44" width="92" height="42" rx="6" fill="none" stroke="#7c8ea0" stroke-width="2"/>
72
+ <text x="170" y="70" text-anchor="middle" fill="#7c8ea0" font-size="14">Render</text>
73
+ <path d="M96 25 L170 25 L170 44" fill="none" stroke="#7c8ea0" stroke-width="2"/>
74
+ </svg>`;
75
+
76
+ const hostileDiagram = `<svg xmlns="http://www.w3.org/2000/svg" width="220" height="60" viewBox="0 0 220 60">
77
+ <script>window.__mermaidPwned = true;</script>
78
+ <rect x="4" y="4" width="212" height="52" rx="6" fill="none" stroke="#7c8ea0" stroke-width="2" onload="window.__mermaidPwned = true"/>
79
+ <a href="javascript:alert(1)"><text x="110" y="34" text-anchor="middle" fill="#7c8ea0" font-size="14">survives, defanged</text></a>
80
+ <iframe src="https://example.com"></iframe>
81
+ </svg>`;
82
+
83
+ const foreignObjectDiagram = `<svg xmlns="http://www.w3.org/2000/svg" width="220" height="60">
84
+ <foreignObject x="0" y="0" width="220" height="60">
85
+ <div>an html label, not well-formed XML
86
+ </foreignObject>
87
+ </svg>`;
88
+
89
+ export const Main: Story = {
90
+ render: () => (
91
+ <YStack maxWidth={700} padding="$4">
92
+ <MountSpecimen label="A well-formed diagram mounts as real SVG nodes" svg={plainDiagram} />
93
+ </YStack>
94
+ ),
95
+ };
96
+
97
+ /**
98
+ * Defense in depth on top of mermaid's own DOMPurify pass: the script, the
99
+ * iframe, the `onload` and the `javascript:` href are all gone, and the
100
+ * drawing that carried them still paints.
101
+ */
102
+ export const HostileMarkupIsStripped: Story = {
103
+ render: () => (
104
+ <YStack maxWidth={700} padding="$4">
105
+ <MountSpecimen
106
+ label="script / iframe / onload / javascript: href removed, drawing kept"
107
+ svg={hostileDiagram}
108
+ />
109
+ </YStack>
110
+ ),
111
+ };
112
+
113
+ /**
114
+ * XML parse first, lenient HTML parse as the fallback — mermaid emits html
115
+ * labels inside foreignObject that are not well-formed XML.
116
+ */
117
+ export const MalformedFallsBackToHtmlParsing: Story = {
118
+ render: () => (
119
+ <YStack maxWidth={700} padding="$4">
120
+ <MountSpecimen label="Unclosed html label inside foreignObject" svg={foreignObjectDiagram} />
121
+ </YStack>
122
+ ),
123
+ };
124
+
125
+ /** No <svg> anywhere: returns false, container untouched. */
126
+ export const NoSvgReturnsFalse: Story = {
127
+ render: () => (
128
+ <YStack maxWidth={700} padding="$4" gap="$5">
129
+ <MountSpecimen label="Plain text" svg="not markup at all" />
130
+ <MountSpecimen label="Empty string" svg="" />
131
+ </YStack>
132
+ ),
133
+ };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * renderMermaidDiagram — the canonical lazy mermaid engine (LC-16), driven
3
+ * directly rather than through MermaidBlock.
4
+ *
5
+ * MermaidBlock hides the whole contract behind one component, so nothing
6
+ * showed what the helper actually returns. It is exported from the
7
+ * dependency-free `@multiplatform.one/markdown/mermaid` subpath precisely so
8
+ * other surfaces can call it themselves — the rich-text editor's live
9
+ * code-block preview does. These stories render the result of that call: the
10
+ * `{ ok, svg }` / `{ ok, error }` union, the scheme-driven re-initialise, and
11
+ * the fontFamily option, with the SVG mounted through mountDiagramSvg so the
12
+ * engine's real output is on screen.
13
+ */
14
+
15
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
16
+ import { useEffect, useRef, useState } from "react";
17
+ import { Paragraph, View, XStack, YStack } from "tamagui";
18
+ import { mountDiagramSvg } from "./mountDiagramSvg";
19
+ import { renderMermaidDiagram, type MermaidRenderResult } from "./renderMermaidDiagram";
20
+
21
+ const meta: Meta = {
22
+ title: "Markdown/Mermaid Engine",
23
+ parameters: {
24
+ status: { type: "beta" },
25
+ docs: {
26
+ description: {
27
+ component:
28
+ "renderMermaidDiagram(code, { scheme, fontFamily }) dynamically imports the mermaid engine on first call, parses before rendering so a syntax error never leaves mermaid's error-bomb SVG behind, and resolves to { ok: true, svg } or { ok: false, error }. The engine is global, so it re-initialises only when scheme or fontFamily changes.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+ type Story = StoryObj;
36
+
37
+ const FLOWCHART = `graph TD
38
+ A[Fence] --> B[renderMermaidDiagram]
39
+ B --> C[mountDiagramSvg]
40
+ C --> D[Painted]`;
41
+
42
+ const INVALID = "graph TD\n A--";
43
+
44
+ /** Calls the engine once and shows the union it resolved to. */
45
+ function EngineSpecimen({
46
+ label,
47
+ code,
48
+ scheme,
49
+ fontFamily,
50
+ }: {
51
+ label: string;
52
+ code: string;
53
+ scheme?: "light" | "dark";
54
+ fontFamily?: string;
55
+ }) {
56
+ const hostRef = useRef<HTMLDivElement>(null);
57
+ const [result, setResult] = useState<MermaidRenderResult | null>(null);
58
+
59
+ useEffect(() => {
60
+ let cancelled = false;
61
+ setResult(null);
62
+ renderMermaidDiagram(code, { scheme, fontFamily }).then((next) => {
63
+ if (cancelled) return;
64
+ setResult(next);
65
+ if (next.ok && hostRef.current) mountDiagramSvg(hostRef.current, next.svg);
66
+ });
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [code, scheme, fontFamily]);
71
+
72
+ return (
73
+ <YStack gap="$2" flex={1}>
74
+ <Paragraph fontWeight="600">{label}</Paragraph>
75
+ <View
76
+ borderWidth={1}
77
+ borderColor="$borderColor"
78
+ borderRadius="$4"
79
+ padding="$3"
80
+ backgroundColor="$color2"
81
+ minHeight={80}
82
+ >
83
+ <div ref={hostRef} />
84
+ </View>
85
+ <Paragraph size="$1" color={result && !result.ok ? "$red11" : "$color10"} role="status">
86
+ {result === null
87
+ ? "awaiting the lazy engine…"
88
+ : result.ok
89
+ ? `ok: true · ${result.svg.length} chars of svg`
90
+ : `ok: false · ${result.error}`}
91
+ </Paragraph>
92
+ </YStack>
93
+ );
94
+ }
95
+
96
+ export const Main: Story = {
97
+ render: () => (
98
+ <YStack maxWidth={700} padding="$4">
99
+ <EngineSpecimen label="A flowchart through the engine" code={FLOWCHART} />
100
+ </YStack>
101
+ ),
102
+ };
103
+
104
+ /**
105
+ * Mermaid's config is global, so the helper re-initialises when the scheme
106
+ * changes. Both specimens render from the same source; only the theme differs.
107
+ */
108
+ export const SchemeSwitch: Story = {
109
+ render: () => (
110
+ <XStack gap="$4" padding="$4" maxWidth={900}>
111
+ <EngineSpecimen label="scheme: light" code={FLOWCHART} scheme="light" />
112
+ <EngineSpecimen label="scheme: dark" code={FLOWCHART} scheme="dark" />
113
+ </XStack>
114
+ ),
115
+ };
116
+
117
+ /**
118
+ * Axiom 6: parse() runs before render(), so invalid source resolves to
119
+ * { ok: false, error } carrying mermaid's own message, and nothing is
120
+ * mounted. The box stays empty on purpose — the caller shows the error.
121
+ */
122
+ export const InvalidSource: Story = {
123
+ render: () => (
124
+ <YStack maxWidth={700} padding="$4">
125
+ <EngineSpecimen label="Invalid source returns the parse message" code={INVALID} />
126
+ </YStack>
127
+ ),
128
+ };
129
+
130
+ /** fontFamily is forwarded into mermaid's theme config, and re-inits like scheme. */
131
+ export const FontFamily: Story = {
132
+ render: () => (
133
+ <XStack gap="$4" padding="$4" maxWidth={900}>
134
+ <EngineSpecimen label="engine default font" code={FLOWCHART} />
135
+ <EngineSpecimen
136
+ label='fontFamily: "Courier New, monospace"'
137
+ code={FLOWCHART}
138
+ fontFamily="Courier New, monospace"
139
+ />
140
+ </XStack>
141
+ ),
142
+ };
143
+
144
+ /** Other grammars go through the same call; only the source changes. */
145
+ export const OtherGrammars: Story = {
146
+ render: () => (
147
+ <YStack gap="$5" padding="$4" maxWidth={700}>
148
+ <EngineSpecimen
149
+ label="sequenceDiagram"
150
+ code={"sequenceDiagram\n Client->>Engine: render\n Engine-->>Client: svg"}
151
+ />
152
+ <EngineSpecimen
153
+ label="pie"
154
+ code={'pie title Coverage\n "Storied" : 27\n "Unstoried" : 24'}
155
+ />
156
+ </YStack>
157
+ ),
158
+ };