feat(dashboard): task description + summary render sanitized HTML + mermaid
Extract the markdown HTML/mermaid pipeline into shared markdownPipeline.tsx (sharedSanitizeSchema, sharedRehypePlugins, createMermaidCodeComponent). MailboxMessageContent now consumes it (unchanged behavior). TaskDetailModal's description + summary ReactMarkdown gain sharedRehypePlugins + the mermaid code component (merged with the existing file-path linkify), keeping the .markdown-body wrapper — so raw HTML renders, comments drop, scripts are sanitized, and mermaid blocks render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,52 +1,19 @@
|
||||
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";
|
||||
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
|
||||
|
||||
/*
|
||||
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:30:
|
||||
The sanitize schema, rehype plugin chain (rehype-raw -> rehype-sanitize), and the
|
||||
mermaid-aware code component now live in ./markdownPipeline so the task
|
||||
description + summary in TaskDetailModal share the exact same XSS posture. This
|
||||
component consumes those shared exports; see markdownPipeline.tsx for the rationale.
|
||||
*/
|
||||
|
||||
/*
|
||||
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>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
@@ -60,24 +27,9 @@ const mailboxMarkdownComponents: Components = {
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
/*
|
||||
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>
|
||||
);
|
||||
},
|
||||
// Code-block override: fenced ```mermaid renders as a diagram; all other code
|
||||
// keeps default rendering. See createMermaidCodeComponent in markdownPipeline.
|
||||
code: createMermaidCodeComponent("mailbox-mermaid-diagram"),
|
||||
// Open links in a new tab. Sanitize strips javascript: URLs and event handlers
|
||||
// before this runs, so href is safe.
|
||||
a: ({ children, ...props }) => (
|
||||
@@ -88,8 +40,6 @@ 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. */
|
||||
@@ -123,7 +73,7 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
|
||||
<div className={wrapperClass} data-testid={testId}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
rehypePlugins={sharedRehypePlugins}
|
||||
components={mailboxMarkdownComponents}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useColumnLabel } from "../i18n/labels";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
|
||||
import {
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
@@ -79,17 +80,30 @@ function isStringValue(value: unknown): value is string {
|
||||
return Object.prototype.toString.call(value) === STRING_OBJECT_TAG;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:30:
|
||||
The task DESCRIPTION (spec/prompt) + SUMMARY render via these components plus the
|
||||
shared rehype chain (sharedRehypePlugins) so they gain sanitized raw HTML
|
||||
(`<details>`/tables/`<kbd>`), drop HTML comments, and render ```mermaid diagrams —
|
||||
matching the shared markdown renderer. They KEEP their `.markdown-body` styling
|
||||
(NOT the `.mailbox-markdown` wrapper), so the look is unchanged for normal markdown.
|
||||
The file-path linkify `code` renderer is preserved as the fallback for non-mermaid
|
||||
code, so links AND html AND mermaid all work together.
|
||||
*/
|
||||
const markdownLinkifyCodeComponent: NonNullable<Components["code"]> = ({ children, ...props }) => {
|
||||
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && linkedChildren[0]?.constructor === String) {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
};
|
||||
|
||||
const markdownLinkifyComponents: Components = {
|
||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
|
||||
const linkedChildren = linkifyFilePaths(text);
|
||||
if (linkedChildren.length === 1 && linkedChildren[0]?.constructor === String) {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
return <code {...props}>{linkedChildren}</code>;
|
||||
},
|
||||
// Mermaid fences render as diagrams; all other code falls through to file-path linkify.
|
||||
code: createMermaidCodeComponent("task-detail-mermaid-diagram", markdownLinkifyCodeComponent),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -3565,7 +3579,7 @@ export function TaskDetailContent({
|
||||
<div className="detail-section detail-summary">
|
||||
<h4>{t("taskDetail.summary.heading", "Summary")}</h4>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
|
||||
{task.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -3833,7 +3847,7 @@ export function TaskDetailContent({
|
||||
<div className="spec-loading"><LoadingSpinner label={t("taskDetail.spec.loading", "Loading specification…")} /></div>
|
||||
) : workingTask.prompt ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
|
||||
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,15 @@ FN-6532 made Chat the default TaskDetailModal tab. Tests that assert Definition-
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
|
||||
// FNXC:Markdown 2026-06-23-03:30: Mock the heavy `mermaid` library so the shared
|
||||
// markdown pipeline's MermaidDiagram resolves without loading the real renderer.
|
||||
vi.mock("mermaid", () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
|
||||
},
|
||||
}));
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
makeTask,
|
||||
@@ -57,6 +66,61 @@ describe("TaskDetailModal", () => {
|
||||
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/App.tsx", { line: 12, col: undefined });
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:30:
|
||||
The task DESCRIPTION (spec/prompt) + SUMMARY now share the markdown pipeline's
|
||||
rehype-raw -> rehype-sanitize chain, so embedded raw HTML renders as real
|
||||
elements (not literal text), HTML comments drop, <script> is stripped, and
|
||||
```mermaid fences render diagrams — while keeping `.markdown-body` styling.
|
||||
*/
|
||||
it("renders raw HTML and mermaid in the description while stripping unsafe content", async () => {
|
||||
const prompt = [
|
||||
"# Prompt",
|
||||
"",
|
||||
"<details><summary>Disclosure title</summary>Hidden detail body.</details>",
|
||||
"",
|
||||
"<!-- secret comment -->",
|
||||
"",
|
||||
"<script>window.__pwned = true;</script>",
|
||||
"",
|
||||
"```mermaid",
|
||||
"graph TD; A-->B;",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
const { container } = render(
|
||||
<FileBrowserProvider openFile={vi.fn()}>
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ prompt })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>
|
||||
</FileBrowserProvider>,
|
||||
);
|
||||
|
||||
// Raw <details>/<summary> renders as a real disclosure element.
|
||||
const details = container.querySelector(".markdown-body details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details?.querySelector("summary")?.textContent).toBe("Disclosure title");
|
||||
expect(details?.textContent).toContain("Hidden detail body.");
|
||||
|
||||
// HTML comment is dropped, never shown as literal text.
|
||||
expect(container.textContent).not.toContain("secret comment");
|
||||
|
||||
// <script> is stripped by sanitize: not rendered and never executed.
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
|
||||
|
||||
// ```mermaid fence renders the diagram container (lazy MermaidDiagram).
|
||||
const diagram = await screen.findByTestId("task-detail-mermaid-diagram");
|
||||
expect(diagram).not.toBeNull();
|
||||
});
|
||||
|
||||
describe("provenance display", () => {
|
||||
it.each([
|
||||
["dashboard_ui", undefined, "Created via Dashboard"],
|
||||
|
||||
95
packages/dashboard/app/components/markdownPipeline.tsx
Normal file
95
packages/dashboard/app/components/markdownPipeline.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ReactElement } from "react";
|
||||
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 { MermaidDiagram } from "./MermaidDiagram";
|
||||
|
||||
/*
|
||||
FNXC:Markdown 2026-06-23-03:30:
|
||||
Shared markdown rendering pipeline. Both mailbox/chat bodies and the task
|
||||
DESCRIPTION (spec/prompt) + SUMMARY in TaskDetailModal need to render embedded
|
||||
raw HTML (`<details>`, `<summary>`, `<kbd>`, `<sub>`, tables), drop HTML comments
|
||||
(`<!-- -->`), and render ```mermaid blocks as diagrams. The sanitize schema +
|
||||
rehype plugin chain are defined ONCE here so every renderer shares the exact same
|
||||
XSS posture instead of duplicating (and drifting on) the allow list.
|
||||
|
||||
Pipeline (ORDER MATTERS): remark-gfm (added by the caller) -> 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 can 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:30:
|
||||
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.
|
||||
*/
|
||||
export const sharedSanitizeSchema: 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"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared rehype plugin chain enabling sanitized raw HTML.
|
||||
*
|
||||
* Raw must run before sanitize: parse HTML, then strip anything unsafe.
|
||||
* Pass this as `rehypePlugins` to any ReactMarkdown instance that should render
|
||||
* embedded HTML. The caller supplies `remarkPlugins` (typically `[remarkGfm]`).
|
||||
*/
|
||||
export const sharedRehypePlugins: PluggableList = [
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, sharedSanitizeSchema],
|
||||
];
|
||||
|
||||
/**
|
||||
* Factory for a mermaid-aware `code` component.
|
||||
*
|
||||
* FNXC:Markdown 2026-06-23-03:30:
|
||||
* 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)
|
||||
* falls through to `fallback` (the caller's existing `code` renderer, e.g. file-path
|
||||
* linkify) or default rendering when no fallback is given.
|
||||
*
|
||||
* @param testId data-testid for the rendered diagram (distinct per surface).
|
||||
* @param fallback the caller's `code` component for non-mermaid code.
|
||||
*/
|
||||
export function createMermaidCodeComponent(
|
||||
testId: string,
|
||||
fallback?: Components["code"],
|
||||
): NonNullable<Components["code"]> {
|
||||
return function MermaidAwareCode(props) {
|
||||
const { className, children } = props;
|
||||
if (className === "language-mermaid") {
|
||||
const chart = String(children ?? "").replace(/\n$/, "");
|
||||
return <MermaidDiagram chart={chart} testId={testId} />;
|
||||
}
|
||||
if (fallback) {
|
||||
const Fallback = fallback as (p: typeof props) => ReactElement;
|
||||
return <Fallback {...props} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user