feat: enhance Streamdown plugins for code and math rendering in MarkdownViewer component

This commit is contained in:
Anish Sarkar 2026-02-13 02:34:19 +05:30
parent f96e7e11c6
commit 207b9e0ed3
4 changed files with 202 additions and 1063 deletions

View file

@ -208,3 +208,5 @@ button {
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'; @source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js'; @source '../node_modules/streamdown/dist/*.js';
@source '../node_modules/@streamdown/code/dist/*.js';
@source '../node_modules/@streamdown/math/dist/*.js';

View file

@ -1,13 +1,68 @@
import Image from "next/image"; import Image from "next/image";
import { Streamdown, type StreamdownProps } from "streamdown"; import { Streamdown, type StreamdownProps } from "streamdown";
import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const code = createCodePlugin({
themes: ["nord", "nord"]
});
const math = createMathPlugin({
singleDollarTextMath: true,
});
interface MarkdownViewerProps { interface MarkdownViewerProps {
content: string; content: string;
className?: string; className?: string;
} }
/**
* If the entire content is wrapped in a single ```markdown or ```md
* code fence, strip the fence so the inner markdown renders properly.
*/
function stripOuterMarkdownFence(content: string): string {
const trimmed = content.trim();
const match = trimmed.match(
/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/
);
return match ? match[1] : content;
}
/**
* Convert various LaTeX delimiter styles to the dollar-sign syntax
* that remark-math understands, and normalise edge-cases that
* commonly appear in LLM-generated markdown.
*
* \[...\] $$ ... $$ (block / display math)
* \(...\) $ ... $ (inline math)
* same-line $$$$ $ ... $ (inline math display math
* can't live inside table cells)
* `$$$$` $$ $$ (strip wrapping backtick code)
* `$$` $ $ (strip wrapping backtick code)
*/
function convertLatexDelimiters(content: string): string {
// 1. Block math: \[...\] → $$...$$
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => {
return `$$${inner}$$`;
});
// 2. Inline math: \(...\) → $...$
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => {
return `$${inner}$`;
});
// 3. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
// 4. Same-line $$...$$ → $...$ (inline math) so it works inside table cells.
// True display math has $$ on its own line, so this only affects inline usage.
content = content.replace(/\$\$([^\n]+?)\$\$/g, (_match, inner) => {
return `$${inner}$`;
});
return content;
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) { export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
const components: StreamdownProps["components"] = { const components: StreamdownProps["components"] = {
p: ({ children, ...props }) => ( p: ({ children, ...props }) => (
<p className="my-2" {...props}> <p className="my-2" {...props}>
@ -62,28 +117,31 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
/> />
), ),
table: ({ ...props }) => ( table: ({ ...props }) => (
<div className="overflow-x-auto my-4"> <div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
<table className="min-w-full divide-y divide-border" {...props} /> <table className="w-full divide-y divide-border" {...props} />
</div> </div>
), ),
th: ({ ...props }) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />, th: ({ ...props }) => <th className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0" {...props} />,
td: ({ ...props }) => <td className="px-3 py-2 border-t border-border" {...props} />, td: ({ ...props }) => <td className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0" {...props} />,
}; };
return ( return (
<div <div
className={cn( className={cn(
"prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_table]:block [&_table]:overflow-x-auto", "max-w-none overflow-hidden",
"[&_[data-streamdown=code-block-header]]:!bg-transparent",
"[&_[data-streamdown=code-block]>*]:!border-none [&_[data-streamdown=code-block]>*]:![box-shadow:none]",
"[&_[data-streamdown=code-block-download-button]]:!hidden",
className className
)} )}
> >
<Streamdown <Streamdown
components={components} components={components}
shikiTheme={["github-light", "github-dark"]} plugins={{ code, math }}
controls={{ code: true }} controls={{ code: true }}
mode="static" mode="static"
> >
{content} {processedContent}
</Streamdown> </Streamdown>
</div> </div>
); );

View file

@ -57,6 +57,8 @@
"@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"@tanstack/query-core": "^5.90.7", "@tanstack/query-core": "^5.90.7",
"@tanstack/react-query": "^5.90.7", "@tanstack/react-query": "^5.90.7",
@ -79,6 +81,7 @@
"geist": "^1.4.2", "geist": "^1.4.2",
"jotai": "^2.15.1", "jotai": "^2.15.1",
"jotai-tanstack-query": "^0.11.0", "jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lucide-react": "^0.477.0", "lucide-react": "^0.477.0",
"motion": "^12.23.22", "motion": "^12.23.22",
"next": "^16.1.0", "next": "^16.1.0",
@ -101,7 +104,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"streamdown": "^1.6.10", "streamdown": "^2.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",

File diff suppressed because it is too large Load diff