mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 22:10:11 -04:00
fix(session-ui): upgrade DOMPurify and restore Mermaid rendering (#45773)
This commit is contained in:
@@ -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=="],
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 [
|
||||
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
|
||||
'<script>alert(1)</script><style>body { display: none }</style><img src="safe.png" onerror="alert(2)"><a href="javascript:alert(3)">unsafe</a>',
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow">external</a><a href="/local">local</a>',
|
||||
'<form id="location" name="document"><input name="cookie"></form>',
|
||||
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
|
||||
'<svg viewBox="0 0 10 10"><path d="M0 0L10 10" onload="alert(4)"></path><script>alert(5)</script></svg>',
|
||||
].map(sanitizeMarkdown)
|
||||
}, fixture)
|
||||
expect(result).toEqual([
|
||||
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
|
||||
'<img src="safe.png"><a>unsafe</a>',
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><a href="/local">local</a>',
|
||||
'<form name="user-content-document" id="user-content-location"><input name="user-content-cookie"></form>',
|
||||
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
|
||||
'<svg viewBox="0 0 10 10"><path d="M0 0L10 10"></path></svg>',
|
||||
])
|
||||
})
|
||||
|
||||
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 =
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow">external</a><img src="safe.png" onerror="alert(1)">'
|
||||
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["<a href=\'https://example.com\' target=\'_blank\'>External</a>"] --> 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(
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img src="safe.png">',
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -10,6 +10,8 @@ export type MarkdownCacheEntry = {
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, MarkdownCacheEntry>()
|
||||
// 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) {
|
||||
|
||||
@@ -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<HTMLElement>("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) {
|
||||
|
||||
@@ -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 }) => <MermaidTimeline streaming={args.streaming} />,
|
||||
}
|
||||
|
||||
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 (
|
||||
<section class="mx-auto flex w-full max-w-[840px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setCompleted((value) => !value)}>
|
||||
{completed() ? "Stream response" : "Complete response"}
|
||||
</button>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<SessionTimeline document={document()} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user