diff --git a/bun.lock b/bun.lock index 34f00be6e44..e2b8abdfafc 100644 --- a/bun.lock +++ b/bun.lock @@ -730,7 +730,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "effect": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", @@ -934,7 +934,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "fuzzysort": "catalog:", "katex": "0.16.47", "luxon": "catalog:", @@ -1136,7 +1136,7 @@ "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.4", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", "effect": "4.0.0-rc.111", @@ -3852,7 +3852,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -6606,8 +6606,6 @@ "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], - "mermaid/dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], diff --git a/package.json b/package.json index eb48e504aaf..85808e57252 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", "effect": "4.0.0-rc.111", diff --git a/packages/session-ui/component-tests/markdown.fixture.tsx b/packages/session-ui/component-tests/markdown.fixture.tsx index c31096f901b..0b534470efe 100644 --- a/packages/session-ui/component-tests/markdown.fixture.tsx +++ b/packages/session-ui/component-tests/markdown.fixture.tsx @@ -3,6 +3,9 @@ import { render } from "solid-js/web" import { Markdown } from "../src/components/markdown" import { preloadMarkdown } from "../src/components/markdown-cache" +export { sanitizeMarkdown } from "../src/components/markdown-cache" +export { renderMermaidSvg } from "../src/components/markdown-mermaid" + export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) { if (options.cached) await preloadMarkdown(options.text, "markdown-test") const host = document.createElement("div") diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 850fbd0ed1c..707f0581c40 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -8,6 +8,79 @@ story.beforeEach(async ({ mount }) => { await expect(root.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") }) +story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { sanitizeMarkdown } = await import(fixture) + return [ + "

Safe formatting const x = 1

", + 'unsafe', + 'externallocal', + '
', + "x+1", + '', + ].map(sanitizeMarkdown) + }, fixture) + expect(result).toEqual([ + "

Safe formatting const x = 1

", + 'unsafe', + 'externallocal', + '
', + "x+1", + '', + ]) +}) + +story("keeps Markdown sanitization and link protections after Mermaid renders", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { renderMermaidSvg, sanitizeMarkdown } = await import(fixture) + const html = + 'external' + const before = sanitizeMarkdown(html) + const renders = [] + for (const source of [ + "flowchart LR\n A[Start] --> B[End]", + "sequenceDiagram\n Alice->>Bob: Hello", + '%%{init: {"flowchart": {"htmlLabels": true}}}%%\nflowchart LR\n A["External"] --> B[End]', + ]) { + const svg = await renderMermaidSvg(source) + const document = new DOMParser().parseFromString(svg, "image/svg+xml") + renders.push({ + root: document.documentElement.localName, + text: document.documentElement.textContent, + unsafe: document.querySelectorAll('script, [onerror], [onload], [href^="javascript:"]').length, + links: Array.from(document.querySelectorAll("a")).map((link) => ({ + href: link.getAttribute("href"), + target: link.getAttribute("target"), + rel: link.getAttribute("rel"), + })), + markdown: sanitizeMarkdown(html), + }) + } + return { + before, + renders, + invalid: await renderMermaidSvg("not a diagram"), + afterInvalid: sanitizeMarkdown(html), + } + }, fixture) + expect(result.before).toBe( + 'external', + ) + expect(result.renders).toEqual([ + { root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before }, + { root: "svg", text: expect.stringContaining("Hello"), unsafe: 0, links: [], markdown: result.before }, + { + root: "svg", + text: expect.stringContaining("External"), + unsafe: 0, + links: [{ href: "https://example.com", target: "_blank", rel: "noopener" }], + markdown: result.before, + }, + ]) + expect(result.invalid).toBeUndefined() + expect(result.afterInvalid).toBe(result.before) +}) + story("mounts cached completed Markdown with sanitized HTML and decorations", async ({ page }) => { await page.evaluate( async ({ fixture, text }) => { @@ -36,6 +109,7 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as "noopener noreferrer", ) await expect(markdown.locator("pre code")).toContainText("const answer = 42") + await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible() await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0) await harness.getByLabel("Markdown text").fill("## Replacement\n\n`new/file.ts`") @@ -53,6 +127,25 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as await expect(markdown).toHaveAttribute("data-markdown-ready", "") }) +story("renders cached Mermaid blocks and falls back to code for invalid diagrams", async ({ page }) => { + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ text: "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```", cached: true }) + }, fixture) + const harness = page.getByTestId("markdown-fixture") + const markdown = harness.locator('[data-component="markdown"]') + await expect(markdown.locator('[data-component="markdown-mermaid"] > svg')).toBeVisible() + await harness.getByRole("button", { name: "Toggle Markdown" }).click() + await expect(markdown).toHaveCount(0) + await harness.getByRole("button", { name: "Toggle Markdown" }).click() + await expect(markdown.locator('[data-component="markdown-mermaid"] > svg')).toBeVisible() + await harness.getByLabel("Markdown text").fill("```mermaid\nnot a diagram\n```") + await expect(markdown.locator('[data-component="markdown-mermaid"]')).toHaveCount(0) + await expect(markdown.locator("pre code")).toBeVisible() + await expect(markdown.locator("pre code")).toHaveText("not a diagram") + await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible() +}) + story("keeps live elements and selection when a stream completes and later changes", async ({ page }) => { await page.evaluate(async (fixture) => { const { mountMarkdown } = await import(fixture) diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index e7007d8830a..378eb8e63fa 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -1,5 +1,33 @@ import { expect, story } from "../../storybook/playwright/story" +for (const width of [1280, 390]) { + for (const streaming of [false, true]) { + story( + `renders Mermaid in the ${streaming ? "streaming" : "completed"} timeline at ${width}px`, + async ({ mount, page }) => { + await page.setViewportSize({ width, height: 900 }) + const root = await mount("current-session-mermaid--diagrams", { args: { streaming } }) + const timeline = root.locator('[data-component="session-timeline"]') + const diagrams = timeline.locator('[data-component="markdown-mermaid"] > svg') + await expect(diagrams).toHaveCount(2) + await expect(diagrams.nth(0)).toBeVisible() + await expect(diagrams.nth(0)).toContainText("Client") + await expect(diagrams.nth(1)).toBeVisible() + await expect(diagrams.nth(1)).toContainText("Send prompt") + await expect(timeline.locator('[data-mermaid-ready="true"]')).toHaveCount(2) + await expect(timeline.locator('[data-mermaid-ready="true"] > pre:visible')).toHaveCount(0) + if (streaming) { + await root.getByRole("button", { name: "Complete response" }).click() + await expect(timeline.locator('[data-markdown-complete="true"]')).toHaveCount(2) + await expect(diagrams).toHaveCount(2) + await expect(diagrams.nth(0)).toBeVisible() + await expect(diagrams.nth(1)).toBeVisible() + } + }, + ) + } +} + story("renders streamed reasoning without starting the app", async ({ mount }) => { const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text") await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible() diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1a6b0f9ee1d..1051d4ac564 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -74,7 +74,7 @@ "@solidjs/meta": "catalog:", "diff": "catalog:", "effect": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "catalog:", diff --git a/packages/session-ui/src/components/markdown-cache.tsx b/packages/session-ui/src/components/markdown-cache.tsx index 80cb3fa9e04..9da44b82eee 100644 --- a/packages/session-ui/src/components/markdown-cache.tsx +++ b/packages/session-ui/src/components/markdown-cache.tsx @@ -10,6 +10,8 @@ export type MarkdownCacheEntry = { const max = 200 const cache = new Map() +// Mermaid registers hooks on the shared instance that overwrite link attributes. +const purifier = typeof window !== "undefined" ? DOMPurify(window) : DOMPurify const config = { USE_PROFILES: { html: true, mathMl: true }, SANITIZE_NAMED_PROPS: true, @@ -19,8 +21,8 @@ const config = { ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"], } -if (typeof window !== "undefined" && DOMPurify.isSupported) { - DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { +if (typeof window !== "undefined" && purifier.isSupported) { + purifier.addHook("afterSanitizeAttributes", (node: Element) => { if (!(node instanceof HTMLAnchorElement)) return if (node.target !== "_blank") return @@ -33,8 +35,8 @@ if (typeof window !== "undefined" && DOMPurify.isSupported) { } export function sanitizeMarkdown(html: string) { - if (!DOMPurify.isSupported) return "" - return DOMPurify.sanitize(html, config) + if (!purifier.isSupported) return "" + return purifier.sanitize(html, config) } export function getCachedMarkdown(key: string) { diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index 19a6031dafc..bdc71ff442e 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -631,6 +631,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl const rendered = renderedMarkdown.get(next) // Keep live renderers in control of their DOM, including after completion. const source = rendered || block.mode === "live" ? document.createElement("div") : next + if (source === next) disposeCopyButtons(next) source.innerHTML = block.html markInlineCode(source) markCodeLinks(source) @@ -647,6 +648,23 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl raw: block.raw, }) } + if (block.mode !== "live") { + next.querySelectorAll("pre > code").forEach((code) => { + const pre = code.parentElement! + const wrapper = document.createElement("div") + wrapper.dataset.component = "markdown-code" + applyCodeMetadata( + wrapper, + Array.from(code.classList) + .find((name) => name.startsWith("language-")) + ?.slice(9), + ) + pre.replaceWith(wrapper) + wrapper.appendChild(pre) + wrapper.appendChild(createCopyButton(labels)) + decorateMermaid(wrapper, code, true) + }) + } if (existing) return if (!current) { diff --git a/packages/session-ui/src/timeline/mermaid.stories.tsx b/packages/session-ui/src/timeline/mermaid.stories.tsx new file mode 100644 index 00000000000..5cd028ba9a4 --- /dev/null +++ b/packages/session-ui/src/timeline/mermaid.stories.tsx @@ -0,0 +1,67 @@ +import { createMemo, createSignal } from "solid-js" +import type { SessionDocument } from "../document" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME } from "../storybook/current-session-fixtures" +import { CurrentSessionProviders } from "../storybook/current-session-story" +import { SessionTimeline } from "./session-timeline" + +export default { + title: "OpenCode/Conversation/Mermaid diagrams", + id: "current-session-mermaid", + component: SessionTimeline, + parameters: { layout: "fullscreen" }, +} + +export const Diagrams = { + args: { streaming: false }, + render: (args: { streaming: boolean }) => , +} + +function MermaidTimeline(props: { streaming: boolean }) { + const [completed, setCompleted] = createSignal(!props.streaming) + const document = createMemo( + (): SessionDocument => ({ + sessionID: CURRENT_SESSION_ID, + status: { type: completed() ? "idle" : "busy" }, + diffs: [], + messages: [ + { + id: "msg_mermaid_user", + type: "user", + text: "Show the request flow and sequence as Mermaid diagrams.", + time: { created: STORY_TIME }, + metadata: { agent: "build", model: STORY_MODEL }, + }, + { + id: "msg_mermaid_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + time: { created: STORY_TIME + 100, ...(completed() ? { completed: STORY_TIME + 1000 } : {}) }, + content: [ + { + type: "text", + text: [ + "## Request flow", + "```mermaid\nflowchart LR\n Client[Client] --> Server[Server]\n Server --> Model[Model]\n```", + "## Request sequence", + "```mermaid\nsequenceDiagram\n Client->>Server: Send prompt\n Server->>Model: Generate response\n Model-->>Client: Response\n" + + (completed() ? "```" : ""), + ].join("\n\n"), + ...(completed() ? {} : { state: { phase: "streaming" } }), + }, + ], + }, + ], + }), + ) + return ( +
+ + + + +
+ ) +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 773b63fd7ad..6962e81228a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -119,7 +119,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@shikijs/stream": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "fuzzysort": "catalog:", "katex": "0.16.47", "luxon": "catalog:",