Skip to content

Commit 29352cf

Browse files
authored
fix(chat): render marimo HTML styled in mo.ui.chat (#9847) (#9888)
Fixes #9847. The `mo.ui.chat` component does not render marimo Markdown/HTML the way the rest of the notebook does — marimo admonitions, in particular, come out unstyled. When a model returns a rich object, the backend serializes it to HTML via `as_html(response).text` (`marimo/_plugins/ui/_impl/chat/chat.py`). For an admonition, `mo.md(...)` produces: ```html <span class="markdown prose dark:prose-invert contents"><div class="admonition error"> <p class="admonition-title">Error</span> <span class="paragraph">Nope!</span> </div></span> ``` The frontend renders assistant text parts with `MarkdownRenderer`, which wraps **Streamdown**. Streamdown sanitizes with `rehype-sanitize` using the default GitHub schema, which strips `class` from most elements. The `admonition`/`prose` classes that carry all the styling are removed, so the content renders as plain text. The marimo CSS itself is fine and global (`frontend/src/css/admonition.css`); it just never matches because the class names are gone. ## Fix Configure Streamdown's rehype pipeline to **preserve class names**. We extend `rehype-sanitize`'s default schema to allow `className` on every element and pass it via `rehypePlugins`, reusing Streamdown's other defaults (`rehype-raw` to parse the HTML, `rehype-harden` to lock down links/images). Scripts, inline styles, and unsafe URLs are still sanitized away — we only stop discarding class names, which are not an execution vector. This is a single-renderer fix: backend-rendered marimo HTML (admonitions, prose, etc.) now styles correctly through Streamdown, and normal streamed LLM markdown is unchanged. Interactive marimo UI elements (`<marimo-...>`) still route to marimo's HTML renderer in `chat-display.tsx`, since they're custom elements that Streamdown can neither keep nor hydrate. ## Fix malformed paragraph tags `mo.md(...)` also emitted malformed HTML for admonitions — note the `<p class="admonition-title">Error</span>` above. `_md` rewrote `<p>` to `<span class="paragraph">` to allow nested block elements, but the opening replacement only matched bare `<p>` while the closing replacement matched every `</p>`, so an attributed paragraph (the admonition title) was left with a mismatched close tag. Browsers auto-correct this, but the stricter sanitize/parse path does not. We now rewrite only attribute-less `<p>...</p>` pairs, so attributed paragraphs keep a balanced `</p>`.
1 parent efd920d commit 29352cf

7 files changed

Lines changed: 122 additions & 7 deletions

File tree

‎frontend/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"react-vega": "^8.0.0",
142142
"react-virtuoso": "^4.18.1",
143143
"reactflow": "^11.11.4",
144+
"rehype-sanitize": "^6.0.0",
144145
"remark-gfm": "^4.0.1",
145146
"reveal.js": "^6.0.0",
146147
"rpc-anywhere": "^1.7.0",

‎frontend/src/components/chat/chat-display.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ export const renderUIMessage = ({
6060

6161
switch (part.type) {
6262
case "text":
63-
// Streamdown sanitizes the HTML which strips out marimo elements
64-
// So instead, we render the HTML with our custom renderer.
63+
// Streamdown strips marimo elements, so render them with our own HTML
64+
// renderer. Other markdown (incl. mo.md HTML) goes through Streamdown.
6565
if (part.text.includes("<marimo-")) {
6666
return (
6767
<React.Fragment key={index}>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import { describe, expect, it } from "vitest";
4+
import { MarkdownRenderer } from "../markdown-renderer";
5+
6+
describe("MarkdownRenderer", () => {
7+
// Regression test for https://github.com/marimo-team/marimo/issues/9847
8+
it("preserves class names on raw HTML (e.g. marimo admonitions)", async () => {
9+
const { container } = render(
10+
<MarkdownRenderer
11+
content={
12+
'<div class="admonition error">' +
13+
'<p class="admonition-title">Error</p>' +
14+
"<p>Nope!</p>" +
15+
"</div>"
16+
}
17+
/>,
18+
);
19+
20+
await waitFor(() => {
21+
expect(screen.getByText("Nope!")).toBeInTheDocument();
22+
});
23+
24+
const admonition = container.querySelector(".admonition.error");
25+
expect(admonition).toBeInTheDocument();
26+
expect(container.querySelector(".admonition-title")).toBeInTheDocument();
27+
});
28+
29+
it("strips unsafe tags while keeping class names", async () => {
30+
const { container } = render(
31+
<MarkdownRenderer
32+
content={'<div class="safe"><script>alert(1)</script>hello</div>'}
33+
/>,
34+
);
35+
36+
await waitFor(() => {
37+
expect(screen.getByText("hello")).toBeInTheDocument();
38+
});
39+
40+
expect(container.querySelector(".safe")).toBeInTheDocument();
41+
expect(container.querySelector("script")).not.toBeInTheDocument();
42+
});
43+
});

‎frontend/src/components/markdown/markdown-renderer.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { EditorView } from "@codemirror/view";
44
import { useAtomValue } from "jotai";
55
import { BetweenHorizontalStartIcon } from "lucide-react";
66
import { memo, Suspense, useState } from "react";
7-
import { Streamdown, type StreamdownProps } from "streamdown";
7+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
8+
import {
9+
defaultRehypePlugins,
10+
Streamdown,
11+
type StreamdownProps,
12+
} from "streamdown";
813
import { Button, type ButtonProps } from "@/components/ui/button";
914
import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
1015
import { useCellActions } from "@/core/cells/cells";
@@ -148,6 +153,23 @@ const CopyButton: React.FC<ButtonProps> = ({ onClick, ...props }) => {
148153
);
149154
};
150155

156+
// Allow `className` on every element; the default GitHub schema strips it,
157+
// which drops the styling on marimo HTML like `mo.md(...)` admonitions.
158+
const sanitizeSchema = {
159+
...defaultSchema,
160+
attributes: {
161+
...defaultSchema.attributes,
162+
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className"],
163+
},
164+
};
165+
166+
// Keep Streamdown's other rehype plugins (raw, harden) so scripts and unsafe
167+
// URLs are still sanitized.
168+
const REHYPE_PLUGINS: StreamdownProps["rehypePlugins"] = Object.values({
169+
...defaultRehypePlugins,
170+
sanitize: [rehypeSanitize, sanitizeSchema],
171+
});
172+
151173
type Components = StreamdownProps["components"];
152174

153175
const COMPONENTS: Components = {
@@ -187,6 +209,7 @@ export const MarkdownRenderer = memo(({ content }: { content: string }) => {
187209
<Streamdown
188210
components={COMPONENTS}
189211
plugins={plugins}
212+
rehypePlugins={REHYPE_PLUGINS}
190213
className="mo-markdown-renderer"
191214
>
192215
{content}

‎marimo/_output/md.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,16 @@ def __init__(
269269

270270
# markdown.markdown appends a newline, hence strip
271271
html_text = _render_markdown(text).strip()
272-
# replace <p> tags with <span> as HTML doesn't allow nested <div>s in <p>s
273-
html_text = html_text.replace(
274-
"<p>", '<span class="paragraph">'
275-
).replace("</p>", "</span>")
272+
# replace <p> tags with <span> as HTML doesn't allow nested <div>s in
273+
# <p>s, including the admonition title's <p> so its </p> stays matched
274+
html_text = (
275+
html_text.replace(
276+
'<p class="admonition-title">',
277+
'<span class="admonition-title">',
278+
)
279+
.replace("<p>", '<span class="paragraph">')
280+
.replace("</p>", "</span>")
281+
)
276282

277283
if apply_markdown_class:
278284
classes = ["markdown", "prose", "dark:prose-invert", "contents"]

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/_output/test_md.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,45 @@ def test_md_nested_list_preserves_multi_paragraph() -> None:
278278
)
279279

280280

281+
def test_md_admonition_paragraph_tags_balanced() -> None:
282+
# Regression for https://github.com/marimo-team/marimo/issues/9847
283+
input_text = """/// error
284+
Nope!
285+
///"""
286+
287+
result = _md(input_text, apply_markdown_class=False).text
288+
assert result == snapshot(
289+
"""\
290+
<div class="admonition error">
291+
<span class="admonition-title">Error</span>
292+
<span class="paragraph">Nope!</span>
293+
</div>\
294+
"""
295+
)
296+
assert '<p class="admonition-title"' not in result
297+
assert "<p>" not in result
298+
assert "</p>" not in result
299+
300+
301+
@pytest.mark.parametrize(
302+
"kind", ["note", "warning", "danger", "tip", "error", "caution"]
303+
)
304+
def test_md_admonition_well_formed(kind: str) -> None:
305+
result = _md(f"/// {kind}\nBody text\n///").text
306+
assert f'<div class="admonition {kind}">' in result
307+
assert '<span class="admonition-title">' in result
308+
assert '<span class="paragraph">Body text</span>' in result
309+
assert "<p>" not in result
310+
assert "</p>" not in result
311+
312+
313+
def test_md_admonition_custom_title_well_formed() -> None:
314+
result = _md("/// note | My Title\nbody here\n///").text
315+
assert '<span class="admonition-title">My Title</span>' in result
316+
assert "<p>" not in result
317+
assert "</p>" not in result
318+
319+
281320
def test_md_nested_list_with_inline_elements() -> None:
282321
# Inline elements like bold/italic/code should survive p-unwrapping
283322
input_text = """- **Bold item**

0 commit comments

Comments
 (0)