@multiplatform.one/markdown 7.6.3 → 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.
- package/dist/cjs/components/Link.cjs +3 -1
- package/dist/cjs/components/Link.native.js +3 -1
- package/dist/cjs/components/Link.native.js.map +1 -1
- package/dist/cjs/mdxComponents.cjs +3 -1
- package/dist/cjs/mdxComponents.native.js +3 -1
- package/dist/cjs/mdxComponents.native.js.map +1 -1
- package/dist/cjs/renderer.cjs +6 -1
- package/dist/cjs/renderer.native.js +4 -1
- package/dist/cjs/renderer.native.js.map +1 -1
- package/dist/esm/components/Link.mjs +4 -2
- package/dist/esm/components/Link.mjs.map +1 -1
- package/dist/esm/components/Link.native.js +4 -2
- package/dist/esm/components/Link.native.js.map +1 -1
- package/dist/esm/mdxComponents.mjs +4 -2
- package/dist/esm/mdxComponents.mjs.map +1 -1
- package/dist/esm/mdxComponents.native.js +4 -2
- package/dist/esm/mdxComponents.native.js.map +1 -1
- package/dist/esm/renderer.mjs +6 -1
- package/dist/esm/renderer.mjs.map +1 -1
- package/dist/esm/renderer.native.js +4 -1
- package/dist/esm/renderer.native.js.map +1 -1
- package/dist/jsx/components/Link.mjs +4 -2
- package/dist/jsx/components/Link.mjs.map +1 -1
- package/dist/jsx/components/Link.native.js +3 -1
- package/dist/jsx/components/Link.native.js.map +1 -1
- package/dist/jsx/mdxComponents.mjs +4 -2
- package/dist/jsx/mdxComponents.mjs.map +1 -1
- package/dist/jsx/mdxComponents.native.js +3 -1
- package/dist/jsx/mdxComponents.native.js.map +1 -1
- package/dist/jsx/renderer.mjs +6 -1
- package/dist/jsx/renderer.mjs.map +1 -1
- package/dist/jsx/renderer.native.js +4 -1
- package/dist/jsx/renderer.native.js.map +1 -1
- package/package.json +9 -10
- package/src/Renderer.stories.tsx +147 -0
- package/src/components/Link.tsx +4 -2
- package/src/icons/Icons.stories.tsx +145 -0
- package/src/icons/icons.spec.tsx +98 -0
- package/src/mdxComponents.tsx +4 -2
- package/src/mermaid/MermaidBlock.spec.tsx +31 -0
- package/src/mermaid/MermaidBlockNative.stories.tsx +80 -0
- package/src/mermaid/MountDiagramSvg.stories.tsx +133 -0
- package/src/mermaid/RenderMermaidDiagram.stories.tsx +158 -0
- package/src/mermaid/mountDiagramSvg.spec.ts +111 -0
- package/src/mermaid/renderMermaidDiagram.spec.ts +21 -25
- package/src/renderer.mermaid.spec.tsx +69 -0
- package/src/renderer.spec.tsx +20 -0
- package/src/renderer.tsx +9 -2
- package/types/components/Link.d.ts.map +1 -1
- package/types/mdxComponents.d.ts.map +1 -1
- package/types/renderer.d.ts.map +1 -1
|
@@ -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
|
+
});
|
package/src/mdxComponents.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CodeInline, unwrapText } from "@multiplatform.one/components";
|
|
2
|
-
import { useResolvedKnobs } from "@multiplatform.one/theme";
|
|
2
|
+
import { FOCUS_RING_HALO_OFFSET, useResolvedKnobs } from "@multiplatform.one/theme";
|
|
3
3
|
import { tokenPx } from "./shared/tokenPx";
|
|
4
4
|
import { Link } from "@multiplatform.one/router";
|
|
5
5
|
import type { MDXComponents } from "mdx/types";
|
|
@@ -155,7 +155,9 @@ function MdxAnchor({ href = "", children, ...props }: { href?: string; children?
|
|
|
155
155
|
outlineColor: "$outlineColor",
|
|
156
156
|
outlineWidth: 2,
|
|
157
157
|
outlineStyle: "solid",
|
|
158
|
-
|
|
158
|
+
// LC-71 §4 halo: inline text link. Offset 0 puts the band across the
|
|
159
|
+
// line box, into the ascenders/descenders of the surrounding prose.
|
|
160
|
+
outlineOffset: FOCUS_RING_HALO_OFFSET,
|
|
159
161
|
}}
|
|
160
162
|
{...props}
|
|
161
163
|
>
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mountDiagramSvg spec (MPO-195). MermaidBlock.spec.tsx mocks this helper
|
|
3
|
+
* out, so until now nothing exercised the sanitizer itself. What it owns:
|
|
4
|
+
* inert parsing, executable-node removal, event-handler and javascript:
|
|
5
|
+
* stripping, the HTML fallback for markup that is not well-formed XML, and a
|
|
6
|
+
* false return that leaves the container alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { mountDiagramSvg } from "./mountDiagramSvg";
|
|
11
|
+
|
|
12
|
+
function host(): HTMLElement {
|
|
13
|
+
const el = document.createElement("div");
|
|
14
|
+
document.body.appendChild(el);
|
|
15
|
+
return el;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PLAIN = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" /></svg>';
|
|
19
|
+
|
|
20
|
+
describe("mountDiagramSvg", () => {
|
|
21
|
+
it("mounts a well-formed diagram as real SVG nodes and reports true", () => {
|
|
22
|
+
const container = host();
|
|
23
|
+
expect(mountDiagramSvg(container, PLAIN)).toBe(true);
|
|
24
|
+
const svg = container.querySelector("svg");
|
|
25
|
+
expect(svg).toBeTruthy();
|
|
26
|
+
expect(svg!.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
27
|
+
expect(container.querySelector("rect")).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("replaces previous content instead of appending to it", () => {
|
|
31
|
+
const container = host();
|
|
32
|
+
container.innerHTML = "<p>stale</p>";
|
|
33
|
+
mountDiagramSvg(container, PLAIN);
|
|
34
|
+
expect(container.querySelector("p")).toBeNull();
|
|
35
|
+
expect(container.childElementCount).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("removes script, iframe, object and embed nodes", () => {
|
|
39
|
+
const container = host();
|
|
40
|
+
mountDiagramSvg(
|
|
41
|
+
container,
|
|
42
|
+
'<svg xmlns="http://www.w3.org/2000/svg">' +
|
|
43
|
+
"<script>globalThis.__pwned = true;</script>" +
|
|
44
|
+
'<iframe src="https://example.com"></iframe>' +
|
|
45
|
+
'<object data="x.swf"></object>' +
|
|
46
|
+
'<embed src="x.swf" />' +
|
|
47
|
+
'<rect width="10" height="10" />' +
|
|
48
|
+
"</svg>",
|
|
49
|
+
);
|
|
50
|
+
expect(container.querySelector("script")).toBeNull();
|
|
51
|
+
expect(container.querySelector("iframe")).toBeNull();
|
|
52
|
+
expect(container.querySelector("object")).toBeNull();
|
|
53
|
+
expect(container.querySelector("embed")).toBeNull();
|
|
54
|
+
// the drawing that carried them survives
|
|
55
|
+
expect(container.querySelector("rect")).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("strips on* handlers from the root and from descendants", () => {
|
|
59
|
+
const container = host();
|
|
60
|
+
mountDiagramSvg(
|
|
61
|
+
container,
|
|
62
|
+
'<svg xmlns="http://www.w3.org/2000/svg" onload="globalThis.__pwned = true">' +
|
|
63
|
+
'<rect width="10" height="10" onclick="globalThis.__pwned = true" ONMOUSEOVER="x()" />' +
|
|
64
|
+
"</svg>",
|
|
65
|
+
);
|
|
66
|
+
const svg = container.querySelector("svg")!;
|
|
67
|
+
expect(svg.hasAttribute("onload")).toBe(false);
|
|
68
|
+
const rect = container.querySelector("rect")!;
|
|
69
|
+
expect(rect.hasAttribute("onclick")).toBe(false);
|
|
70
|
+
expect(rect.getAttributeNames().some((name) => name.toLowerCase() === "onmouseover")).toBe(
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("strips javascript: hrefs but keeps ordinary ones", () => {
|
|
76
|
+
const container = host();
|
|
77
|
+
mountDiagramSvg(
|
|
78
|
+
container,
|
|
79
|
+
'<svg xmlns="http://www.w3.org/2000/svg">' +
|
|
80
|
+
'<a href=" JavaScript:alert(1)"><text>bad</text></a>' +
|
|
81
|
+
'<a href="https://example.com"><text>good</text></a>' +
|
|
82
|
+
"</svg>",
|
|
83
|
+
);
|
|
84
|
+
const anchors = Array.from(container.querySelectorAll("a"));
|
|
85
|
+
expect(anchors).toHaveLength(2);
|
|
86
|
+
expect(anchors[0].hasAttribute("href")).toBe(false);
|
|
87
|
+
expect(anchors[1].getAttribute("href")).toBe("https://example.com");
|
|
88
|
+
// the defanged anchor's content is still drawn
|
|
89
|
+
expect(container.textContent).toContain("bad");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to lenient HTML parsing for markup that is not well-formed XML", () => {
|
|
93
|
+
const container = host();
|
|
94
|
+
const mounted = mountDiagramSvg(
|
|
95
|
+
container,
|
|
96
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>label</foreignObject></svg>',
|
|
97
|
+
);
|
|
98
|
+
expect(mounted).toBe(true);
|
|
99
|
+
expect(container.querySelector("svg")).toBeTruthy();
|
|
100
|
+
expect(container.textContent).toContain("label");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns false and leaves the container untouched when there is no svg", () => {
|
|
104
|
+
const container = host();
|
|
105
|
+
container.innerHTML = "<p>kept</p>";
|
|
106
|
+
expect(mountDiagramSvg(container, "not markup at all")).toBe(false);
|
|
107
|
+
expect(mountDiagramSvg(container, "")).toBe(false);
|
|
108
|
+
expect(mountDiagramSvg(container, "<div>html but no svg</div>")).toBe(false);
|
|
109
|
+
expect(container.querySelector("p")?.textContent).toBe("kept");
|
|
110
|
+
});
|
|
111
|
+
});
|