feat(dashboard): shared markdown renders embedded HTML (sanitized) + mermaid diagrams
MailboxMessageContent pipeline gains rehype-raw -> rehype-sanitize (GitHub-like allow list: details/summary/kbd/tables/etc; strips script/style/iframe/on*/javascript:) so raw HTML in markdown renders and HTML comments are dropped. Fenced ```mermaid blocks render via a lazy-loaded MermaidDiagram component (dynamic import keeps mermaid out of the main bundle; theme-aware; falls back to the raw block on parse error). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/markdown-raw-html-and-mermaid.md
Normal file
5
.changeset/markdown-raw-html-and-mermaid.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
The shared markdown renderer (GitHub PR/issue bodies + comments, mailbox, chat) now renders embedded raw HTML and mermaid diagrams. Raw HTML (`<details>`/`<summary>`, `<kbd>`, `<sub>`, tables) renders as real elements via `rehype-raw`, with `rehype-sanitize` stripping XSS (script/style/iframe, event handlers, `javascript:` URLs) since these bodies come from GitHub; HTML comments (`<!-- -->`) are dropped. Fenced ```mermaid blocks render as actual diagrams via a lazy-loaded `mermaid` import (kept out of the main bundle, loaded only when a diagram is present), falling back to the raw code block on parse error and following the dashboard theme.
|
||||
@@ -1,8 +1,51 @@
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import type { Options as SanitizeSchema } from "rehype-sanitize";
|
||||
import type { Components } from "react-markdown";
|
||||
import type { PluggableList } from "unified";
|
||||
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
import { MermaidDiagram } from "./MermaidDiagram";
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:15:
|
||||
GitHub PR/issue bodies + comments (and mailbox/chat) embed raw HTML (`<details>`,
|
||||
`<summary>`, `<kbd>`, `<sub>`, tables), HTML comments (`<!-- -->`), and ```mermaid
|
||||
blocks. Previously raw HTML was escaped to literal text and mermaid showed as code.
|
||||
|
||||
Pipeline (ORDER MATTERS): remark-gfm -> rehype-raw -> rehype-sanitize.
|
||||
- rehype-raw parses embedded HTML into the hast tree so it renders as real elements.
|
||||
It also DROPS HTML comments by default, so `<!-- ... -->` never appears in output.
|
||||
- rehype-sanitize runs AFTER raw to strip XSS: <script>/<style>/<iframe>, event
|
||||
handlers (onClick etc.), and javascript: URLs. Because these bodies come from
|
||||
GitHub (untrusted), sanitize is mandatory — raw without sanitize would be an XSS
|
||||
hole. Running sanitize last guarantees nothing injected via raw survives.
|
||||
*/
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:15:
|
||||
Sanitize schema = rehype-sanitize defaultSchema (a conservative GitHub-like allow
|
||||
list that already permits details/summary/kbd/sub/sup/b/i/em/strong/a/img/code/pre/
|
||||
tables/br/hr/blockquote/lists/headings/span/div and strips script/style/event
|
||||
handlers/javascript: URLs) EXTENDED to ensure the `className` attribute survives on
|
||||
common elements (needed for our `language-*` code fences and styled wrappers). We do
|
||||
NOT widen tagNames beyond defaults, so script/style/iframe stay stripped.
|
||||
*/
|
||||
const mailboxSanitizeSchema: SanitizeSchema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
// Preserve className on code/span/div/pre so language fences + wrapper styling work.
|
||||
code: [...(defaultSchema.attributes?.code ?? []), "className"],
|
||||
span: [...(defaultSchema.attributes?.span ?? []), "className"],
|
||||
div: [...(defaultSchema.attributes?.div ?? []), "className"],
|
||||
pre: [...(defaultSchema.attributes?.pre ?? []), "className"],
|
||||
// `<details open>` disclosure state should round-trip.
|
||||
details: [...(defaultSchema.attributes?.details ?? []), "open"],
|
||||
},
|
||||
};
|
||||
|
||||
const mailboxMarkdownComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
@@ -17,8 +60,26 @@ const mailboxMarkdownComponents: Components = {
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
// Open links in a new tab. ReactMarkdown does not allow raw HTML by default,
|
||||
// so the rendered output here is safe.
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:15:
|
||||
Code-block override: a fenced ```mermaid block arrives as `<code class="language-mermaid">`.
|
||||
Render it via <MermaidDiagram>, which lazy-imports mermaid so the heavy library is
|
||||
only pulled in when a diagram is present. All other code (inline + other languages)
|
||||
keeps the default rendering.
|
||||
*/
|
||||
code: ({ className, children, ...props }) => {
|
||||
if (className === "language-mermaid") {
|
||||
const chart = String(children ?? "").replace(/\n$/, "");
|
||||
return <MermaidDiagram chart={chart} testId="mailbox-mermaid-diagram" />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// Open links in a new tab. Sanitize strips javascript: URLs and event handlers
|
||||
// before this runs, so href is safe.
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
@@ -26,6 +87,10 @@ const mailboxMarkdownComponents: Components = {
|
||||
),
|
||||
};
|
||||
|
||||
const remarkPlugins: PluggableList = [remarkGfm];
|
||||
// Raw must run before sanitize: parse HTML, then strip anything unsafe.
|
||||
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeSanitize, mailboxSanitizeSchema]];
|
||||
|
||||
interface MailboxMessageContentProps {
|
||||
/** Raw message body. Rendered as GitHub-flavored markdown. */
|
||||
content: string;
|
||||
@@ -38,9 +103,10 @@ interface MailboxMessageContentProps {
|
||||
/**
|
||||
* Renders a mailbox message body as GitHub-flavored markdown.
|
||||
*
|
||||
* Uses ReactMarkdown defaults (no raw HTML) so untrusted message content is
|
||||
* safe. Plain-text messages render unchanged (markdown is a strict superset
|
||||
* for the formatting we care about — bold, lists, code, links, tables).
|
||||
* Supports embedded raw HTML (details/summary/kbd/sub/tables) via rehype-raw, with
|
||||
* rehype-sanitize stripping XSS (script/style/iframe/event-handlers/javascript:).
|
||||
* Fenced ```mermaid blocks render as diagrams via the lazy-loaded MermaidDiagram.
|
||||
* HTML comments (`<!-- -->`) are dropped and never rendered.
|
||||
*
|
||||
* Memoized because mailbox detail panes can re-render on selection / SSE
|
||||
* updates while the underlying message body is unchanged.
|
||||
@@ -55,7 +121,11 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
|
||||
: "mailbox-markdown";
|
||||
return (
|
||||
<div className={wrapperClass} data-testid={testId}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mailboxMarkdownComponents}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={mailboxMarkdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -371,6 +371,35 @@
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:15:
|
||||
Wrappers for embedded raw HTML + mermaid diagrams. `<details>` from GitHub bodies
|
||||
needs a clickable summary affordance; mermaid SVGs should scroll horizontally rather
|
||||
than overflow the message column. Theme tokens only — no hard-coded colors.
|
||||
*/
|
||||
.mailbox-markdown details {
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
}
|
||||
|
||||
.mailbox-markdown summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-mermaid {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mailbox-mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mailbox-reply-context-wrapper {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
95
packages/dashboard/app/components/MermaidDiagram.tsx
Normal file
95
packages/dashboard/app/components/MermaidDiagram.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:15:
|
||||
GitHub PR/issue bodies and comments embed ```mermaid fenced blocks. Render them
|
||||
as real diagrams instead of literal code. The `mermaid` library is heavy (~600kb+
|
||||
of parser/renderer), so it is LAZY-LOADED via `await import("mermaid")` only when a
|
||||
mermaid block is actually present — keeping it out of the main dashboard bundle.
|
||||
|
||||
Race/unmount safety: each render gets a unique element id, an incrementing render
|
||||
token guards against overlapping async renders (theme/chart change mid-flight), and
|
||||
an `unmounted` flag prevents state updates after teardown. On parse error we fall
|
||||
back to the raw fenced code block so a malformed diagram never crashes the message.
|
||||
*/
|
||||
|
||||
let mermaidIdCounter = 0;
|
||||
|
||||
/** Theme follows the dashboard token: `data-theme="light"` => mermaid `default`, else `dark`. */
|
||||
function resolveMermaidTheme(): "dark" | "default" {
|
||||
if (typeof document === "undefined") return "default";
|
||||
return document.documentElement.dataset.theme === "light" ? "default" : "dark";
|
||||
}
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
/** Raw mermaid source from the fenced ```mermaid block. */
|
||||
chart: string;
|
||||
/** Optional data-testid for test selectors. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mermaid diagram from raw mermaid source.
|
||||
*
|
||||
* Lazy-imports `mermaid` inside an effect, calls `mermaid.render` to produce an
|
||||
* SVG string, and injects it. On any parse/render failure, falls back to the raw
|
||||
* code block so the surrounding message keeps rendering.
|
||||
*/
|
||||
export const MermaidDiagram = memo(function MermaidDiagram({
|
||||
chart,
|
||||
testId,
|
||||
}: MermaidDiagramProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unmounted = false;
|
||||
// Bump the token per effect run; only the latest run is allowed to commit.
|
||||
const renderToken = ++mermaidIdCounter;
|
||||
const elementId = `mermaid-${renderToken}`;
|
||||
|
||||
setErrored(false);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaid = mermaidModule.default;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: resolveMermaidTheme(),
|
||||
securityLevel: "strict",
|
||||
});
|
||||
const { svg } = await mermaid.render(elementId, chart);
|
||||
if (unmounted || renderToken !== mermaidIdCounter) return;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = svg;
|
||||
}
|
||||
} catch {
|
||||
if (unmounted || renderToken !== mermaidIdCounter) return;
|
||||
setErrored(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
if (errored) {
|
||||
// Fallback: show the raw mermaid source as a normal code block.
|
||||
return (
|
||||
<pre className="mailbox-markdown-pre mailbox-mermaid-fallback" data-testid={testId}>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="mailbox-mermaid"
|
||||
data-testid={testId}
|
||||
aria-label="Mermaid diagram"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { render, cleanup, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
import { MailboxMessageContent } from "../MailboxMessageContent";
|
||||
|
||||
// FNXC:Markdown 2026-06-23-03:15: Mock the heavy `mermaid` library so the mermaid
|
||||
// rendering tests do not pull in the real parser/renderer bundle. The component
|
||||
// lazy-imports `mermaid` (default export), so we mock the module default.
|
||||
vi.mock("mermaid", () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -71,11 +81,10 @@ describe("MailboxMessageContent", () => {
|
||||
expect(table?.querySelectorAll("tbody td")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does NOT execute raw HTML in messages", () => {
|
||||
it("sanitizes raw <script> out of messages (no execution, no element)", () => {
|
||||
const content = "<script>window.__pwned = true;</script>Hello";
|
||||
const { container } = render(<MailboxMessageContent content={content} />);
|
||||
// ReactMarkdown defaults disallow raw HTML — the <script> tag should be
|
||||
// rendered as escaped text, not as a real script element.
|
||||
// rehype-raw parses HTML, but rehype-sanitize strips <script> before render.
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(
|
||||
(globalThis as unknown as { __pwned?: boolean }).__pwned,
|
||||
@@ -83,6 +92,53 @@ describe("MailboxMessageContent", () => {
|
||||
expect(container.textContent).toContain("Hello");
|
||||
});
|
||||
|
||||
it("renders raw <details>/<summary> as a working disclosure element", () => {
|
||||
const content =
|
||||
"<details><summary>More info</summary>Hidden body text here.</details>";
|
||||
const { container } = render(<MailboxMessageContent content={content} />);
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details?.querySelector("summary")?.textContent).toBe("More info");
|
||||
expect(details?.textContent).toContain("Hidden body text here.");
|
||||
});
|
||||
|
||||
it("renders other safe raw HTML (kbd/sub) as real elements", () => {
|
||||
const content = "Press <kbd>Cmd</kbd> and H<sub>2</sub>O.";
|
||||
const { container } = render(<MailboxMessageContent content={content} />);
|
||||
expect(container.querySelector("kbd")?.textContent).toBe("Cmd");
|
||||
expect(container.querySelector("sub")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
it("does NOT render HTML comments in the output", () => {
|
||||
const content = "Before<!-- secret hidden note -->After";
|
||||
const { container } = render(<MailboxMessageContent content={content} />);
|
||||
expect(container.innerHTML).not.toContain("secret hidden note");
|
||||
expect(container.innerHTML).not.toContain("<!--");
|
||||
expect(container.textContent).toContain("Before");
|
||||
expect(container.textContent).toContain("After");
|
||||
});
|
||||
|
||||
it("strips javascript: URLs and event handlers from raw HTML", () => {
|
||||
const content = '<a href="javascript:alert(1)" onclick="alert(2)">click</a>';
|
||||
const { container } = render(<MailboxMessageContent content={content} />);
|
||||
const link = container.querySelector("a");
|
||||
// sanitize drops the javascript: href and the onclick handler.
|
||||
expect(link?.getAttribute("href") ?? "").not.toContain("javascript:");
|
||||
expect(link?.getAttribute("onclick")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a ```mermaid block as the MermaidDiagram container", async () => {
|
||||
const content = "```mermaid\ngraph TD; A-->B;\n```";
|
||||
render(<MailboxMessageContent content={content} />);
|
||||
const diagram = await screen.findByTestId("mailbox-mermaid-diagram");
|
||||
expect(diagram).toBeInTheDocument();
|
||||
expect(diagram).toHaveClass("mailbox-mermaid");
|
||||
// The mocked mermaid.render SVG is injected into the container.
|
||||
await waitFor(() => {
|
||||
expect(diagram.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards testId to the wrapper", () => {
|
||||
render(<MailboxMessageContent content="x" testId="mailbox-message-body" />);
|
||||
expect(screen.getByTestId("mailbox-message-body")).toBeInTheDocument();
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"multer": "^2.1.1",
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -138,6 +139,8 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
706
pnpm-lock.yaml
generated
706
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user