Legacy Markdown Renderer
Render user-generated markdown safely with GFM prose, KaTeX math, sanitized HTML tables, Shiki code blocks, and interactive Mermaid diagrams.
Deprecated
This is the previous engine, kept installable for existing apps. New code uses Markdown Renderer, which renders fenced code with shadcn Code Block and Mermaid with Streamdown.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/legacy-markdown-renderer (pulls legacy-code-block and legacy-mermaid-renderer).
Legacy Markdown Renderer turns a markdown string into styled article content for comments, knowledge bases, AI chat, and other untrusted user input. It ships secure defaults (sanitized HTML when enabled, restricted links, images off by default), GFM via remark-gfm, KaTeX math, Shiki-highlighted fenced code through Legacy Code Block, and preview-first Mermaid diagrams through Legacy Mermaid Renderer.
When To Use
- Display user-authored or AI-generated markdown where XSS and open redirects are a concern.
- You need GFM (tables, task lists, strikethrough) plus polished code fences without wiring react-markdown yourself.
- Fenced
mermaidblocks should render as interactive diagrams (zoom, export, fullscreen), not plain highlighted text. - You want a single component for article prose, or a headless hook to reuse the same plugin and element map in a custom layout.
- Need phrasing markdown inside a label, chip, table cell, or button — use
LegacyInlineMarkdown(root<span>, no article/streaming). UseLegacyMarkdownRendererfor document prose and streaming replies. - Large static or host-segmented preview — use Markdown Document (virtualized segments; inherits this component’s KaTeX / HTML tables / image policy; not for streaming). Do not pass a 100k+ character
sourceto this component: space tokens are dropped and static input coalesces ~8k chars per instance, but every instance still runs on the main thread. - Knowledge-base / document-library List↔Detail peek of processed markdown — example: /showcases/knowledge-base-documents (Sheet Preview tab).
Features
| Area | Behavior |
|---|---|
| Prose | prose / dark:prose-invert on the root <article> (standard Tailwind Typography) |
| GFM | Tables (local horizontal scroll when wide), task lists, strikethrough, autolinks (via remark-gfm) |
| Math | $…$ / $$…$$, \(...\), and \[…\] via KaTeX by default; fenced and inline code stay literal. Set math={false} for currency-heavy text |
| HTML tables | Author <table> (incl. rowspan / colspan) renders through table chrome when allowHtml is on (default); $…$ inside cell text also becomes KaTeX |
| Block rhythm | Custom fences / tables / Mermaid use my-6 so gaps match prose; article resets first/last child margins |
| Inline code | Chip styling without Typography backticks by default (less noise). Set inlineCodeBackticks to restore the marks around inline code. Fenced blocks are unchanged |
| Code fences | Legacy Code Block with chrome and line numbers; mainstream + on-demand Shiki grammars; plain-first while highlighting |
| Mermaid fences | Legacy Mermaid Renderer (preview-first; errors switch to code view) |
| Links | http, https, mailto, and #fragment only; blocked schemes render as plain text |
| External links | Open in a new tab; rel includes nofollow ugc unless trustedLinks |
Images (LegacyMarkdownRenderer) | Off by default; opt in with allowImages, allowedImageHosts, allowedImagePrefixes, or allowDataImages. Default frame is imageVariant="outline" (flush bitmap). imageZoom is on by default for allowed images; imageZoom={false} disables. Click opens fullscreen Document Preview (not a max-w-3xl card). imageObjectFit controls how the bitmap fills the frame. |
| Streaming | Set isStreaming while the source is still growing: the incomplete-syntax cache holds unfinished images and links, remend repairs incomplete markdown, and finished content blocks stay memoized. Whitespace-only lexer tokens are not renderer instances. Incomplete images use the same Image loading preview as a URL that is still downloading. An unclosed \[ does not swallow the rest of the message |
| Large source | Static (never-streamed) input coalesces small tokens to ~8k characters per ReactMarkdown. Streaming keeps one content token per block so completed prefixes do not remount. Long / scrollable preview must use Markdown Document with a small chunkChars so off-screen segments never mount |
| Raw HTML | Parsed when allowHtml (default true), then sanitized; scripts and other dangerous tags never become DOM. Set allowHtml={false} to restore literal tag text |
| Extension | Append remarkPlugins / rehypePlugins; shallow-merge components |
Tailwind Typography
LegacyMarkdownRenderer applies standard prose / dark:prose-invert. Install @tailwindcss/typography and add @plugin "@tailwindcss/typography"; in your global CSS (Tailwind v4). No other f-ui-specific typography setup is required in a normal app layout.
Fumadocs doc sites only
If demos are wrapped in not-prose (as on this site), nested prose does not apply. This repo registers a second typography namespace — fui-md-prose — in src/styles/app.css. The component includes those classes for you; registry consumers can ignore this unless they use the same Fumadocs + not-prose demo pattern.
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/legacy-markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/legacy-markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/legacy-markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/legacy-markdown-rendererWith a namespace for the public registry only: pnpm dlx shadcn@latest add @f-ui/date-picker — legacy-markdown-renderer itself is @f-ui-plus/legacy-markdown-renderer, not on the public index.
registryDependencies: legacy-code-block, legacy-mermaid-renderer, document-preview. Runtime: react-markdown, remark-gfm, remark-math-extended, rehype-katex, katex, rehype-raw, rehype-sanitize, hast-util-sanitize, shiki, mermaid, hast-util-to-string, remend, marked.
Usage
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
const source = ["# Hello", "", "```ts", "const x = 1;", "```"].join("\n");
export function Page() {
return <LegacyMarkdownRenderer source={source} className="max-w-3xl" />;
}Examples
Live Editor
Edit the source on the left; the right pane updates with secure rendering. Try a javascript: link or raw <script> — they are stripped or shown as text, not executed.
Markdown source
Rendered
Markdown renderer
User-generated safe markdown with a normal link, a blocked scheme, and inline code without surrounding backticks.
List
- One
- Two
TypeScript
Mermaid
Raw angle brackets
"use client";
import { useState } from "react";
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
const DEFAULT_MARKDOWN = `# Markdown renderer
User-generated **safe** markdown with [a normal link](https://example.com), a [blocked scheme](javascript:alert(1)), and \`inline code\` without surrounding backticks.
## List
- One
- Two
## TypeScript
\`\`\`ts
const n: number = 42;
\`\`\`
## Mermaid
\`\`\`mermaid
flowchart LR
A[Start] --> B[End]
\`\`\`
## Raw angle brackets
<script>alert(1)</script>
`;
export function LegacyMarkdownRendererDemo() {
const [source, setSource] = useState(DEFAULT_MARKDOWN);
return (
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex min-h-[320px] flex-col gap-2">
<p className="text-muted-foreground text-xs font-medium">
Markdown source
</p>
<textarea
value={source}
onChange={(e) => setSource(e.target.value)}
className="border-input bg-background text-foreground focus-visible:ring-ring min-h-[280px] flex-1 resize-y rounded-md border p-3 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
spellCheck={false}
aria-label="Markdown source"
/>
</div>
<div className="flex min-h-[320px] flex-col gap-2">
<p className="text-muted-foreground text-xs font-medium">Rendered</p>
<div className="border-input bg-card min-h-[280px] flex-1 overflow-auto rounded-md border p-4">
<LegacyMarkdownRenderer source={source} />
</div>
</div>
</div>
);
}Inline Fragment
For bold, links, or inline code inside chrome that cannot host an <article>, use LegacyInlineMarkdown. Check the button label, chip, table cells, and the blocked javascript: line — phrasing stays inside the host with no article/prose margins. There is no inline mode prop on LegacyMarkdownRenderer.
"use client";
import { LegacyInlineMarkdown } from "@/components/f-ui/legacy-markdown-renderer/legacy-inline-markdown";
import { Button } from "@/components/ui/button";
/**
* Host surfaces where an `<article>` / prose shell would break layout:
* button label, chip, table cell, muted status line, blocked scheme.
*/
export function LegacyMarkdownRendererInlineFragmentDemo() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline">
<LegacyInlineMarkdown source={"Open **docs**"} />
</Button>
<span className="border-border bg-muted/40 inline-flex items-center rounded-md border px-2 py-0.5 text-xs">
<LegacyInlineMarkdown source={"Priority: `P1` — **ship today**"} />
</span>
<p className="text-muted-foreground text-sm">
Status:{" "}
<LegacyInlineMarkdown
source={"Ready — see [changelog](https://example.com)"}
trustedLinks
baseOrigin="https://app.local"
/>
</p>
</div>
<div className="border-border overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-border bg-muted/30 border-b text-left">
<th className="px-3 py-2 font-medium">Field</th>
<th className="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
<tr className="border-border border-b">
<td className="text-muted-foreground px-3 py-2">Owner</td>
<td className="px-3 py-2">
<LegacyInlineMarkdown
source={"Assigned to [Ada](https://example.com/ada)"}
trustedLinks
baseOrigin="https://app.local"
/>
</td>
</tr>
<tr>
<td className="text-muted-foreground px-3 py-2">Note</td>
<td className="px-3 py-2">
<LegacyInlineMarkdown source={"Use `LegacyInlineMarkdown` in cells — not an article"} />
</td>
</tr>
</tbody>
</table>
</div>
<p className="text-muted-foreground text-sm">
Blocked scheme (link text only):{" "}
<LegacyInlineMarkdown source={"[unsafe](javascript:alert(1))"} />
</p>
</div>
);
}Wide Tables
Multi-column GFM tables wrap in a local horizontal scroll container (w-max table inside overflow-x-auto). This demo uses a narrow card so the table must scroll instead of stretching the page — the same behavior in a chat measure.
Comparison across regions (scroll horizontally when the pane is narrow):
| Metric | North America | EMEA | APAC | LATAM | ANZ | Middle East | Africa | Nordics | Benelux | DACH |
|---|---|---|---|---|---|---|---|---|---|---|
| Active seats | 12,480 | 9,210 | 14,902 | 3,441 | 2,108 | 1,776 | 890 | 1,204 | 980 | 2,450 |
| Median latency (ms) | 48 | 62 | 91 | 110 | 74 | 88 | 130 | 41 | 55 | 58 |
| Error budget remaining | 99.2% | 98.7% | 97.9% | 99.0% | 99.4% | 98.1% | 97.2% | 99.6% | 99.1% | 98.8% |
| Last deploy window | 2026-07-24T18:00Z | 2026-07-24T16:30Z | 2026-07-25T02:15Z | 2026-07-24T22:00Z | 2026-07-25T01:00Z | 2026-07-24T20:45Z | 2026-07-24T19:10Z | 2026-07-24T15:00Z | 2026-07-24T14:20Z | 2026-07-24T13:55Z |
"use client";
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
/** Many columns + long cell text so the table exceeds a chat-like measure. */
const WIDE_TABLE = [
"Comparison across regions (scroll horizontally when the pane is narrow):",
"",
"| Metric | North America | EMEA | APAC | LATAM | ANZ | Middle East | Africa | Nordics | Benelux | DACH |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
"| Active seats | 12,480 | 9,210 | 14,902 | 3,441 | 2,108 | 1,776 | 890 | 1,204 | 980 | 2,450 |",
"| Median latency (ms) | 48 | 62 | 91 | 110 | 74 | 88 | 130 | 41 | 55 | 58 |",
"| Error budget remaining | 99.2% | 98.7% | 97.9% | 99.0% | 99.4% | 98.1% | 97.2% | 99.6% | 99.1% | 98.8% |",
"| Last deploy window | `2026-07-24T18:00Z` | `2026-07-24T16:30Z` | `2026-07-25T02:15Z` | `2026-07-24T22:00Z` | `2026-07-25T01:00Z` | `2026-07-24T20:45Z` | `2026-07-24T19:10Z` | `2026-07-24T15:00Z` | `2026-07-24T14:20Z` | `2026-07-24T13:55Z` |",
].join("\n");
/**
* Constrains width like a chat / docs measure so the wide GFM table must
* scroll locally instead of expanding the page.
*/
export function LegacyMarkdownRendererWideTableDemo() {
return (
<div className="border-input bg-card max-w-md overflow-hidden rounded-md border p-4">
<LegacyMarkdownRenderer source={WIDE_TABLE} />
</div>
);
}Block Rhythm
Paragraphs, tables, and fenced Legacy Code Blocks share one vertical rhythm (my-6 on custom blocks that opt out of Typography). Check the gaps above/below the table and the TypeScript fence — this demo keeps the table within the card; wide-table scroll is under Wide Tables above.
Before the table, a short paragraph should sit with even spacing above the grid.
| Region | Seats | Latency | Budget | Window | Owner | Notes |
|---|---|---|---|---|---|---|
| North America | 12,480 | 48ms | 99.2% | 18:00Z | platform | Primary |
| EMEA | 9,210 | 62ms | 98.7% | 16:30Z | platform | Failover |
| APAC | 14,902 | 91ms | 97.9% | 02:15Z | growth | Peak |
| LATAM | 3,441 | 110ms | 99.0% | 22:00Z | growth | Expanding |
After the table, the following TypeScript sample should keep the same vertical gap:
Closing sentence under the code block — spacing should match the gap above the fence.
"use client";
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
/**
* Mixed prose + wide table + code fence — verifies vertical rhythm (`my-6` on
* custom blocks) and local table horizontal scroll in one pass.
*/
const MIXED_SOURCE = [
"Before the table, a short paragraph should sit with even spacing above the grid.",
"",
"| Region | Seats | Latency | Budget | Window | Owner | Notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| North America | 12,480 | 48ms | 99.2% | `18:00Z` | platform | Primary |",
"| EMEA | 9,210 | 62ms | 98.7% | `16:30Z` | platform | Failover |",
"| APAC | 14,902 | 91ms | 97.9% | `02:15Z` | growth | Peak |",
"| LATAM | 3,441 | 110ms | 99.0% | `22:00Z` | growth | Expanding |",
"",
"After the table, the following TypeScript sample should keep the same vertical gap:",
"",
"```ts",
"export function summarize(rows: Row[]) {",
" return rows.reduce((n, r) => n + r.seats, 0);",
"}",
"```",
"",
"Closing sentence under the code block — spacing should match the gap above the fence.",
].join("\n");
export function LegacyMarkdownRendererBlockRhythmDemo() {
return (
<div className="border-input bg-card max-w-lg overflow-hidden rounded-md border p-4">
<LegacyMarkdownRenderer source={MIXED_SOURCE} />
</div>
);
}Math and HTML Tables
Default math and allowHtml turn $…$ / $$…$$ into KaTeX and render author <table> markup (including rowspan) instead of escaping it. Cell text like $N$ also renders as math. LegacyInlineMarkdown shares the math pipeline only — it does not parse raw HTML.
For the long / virtualized knowledge-base path (same pipeline inside scroll segments), see Markdown Document — Math and HTML Tables.
Euler’s identity inline: .
Parenthesis delimiters: .
Attention scales as:
Bracket display:
Parsed artifact table (HTML, not GFM pipes) — cell text like $N$ becomes KaTeX via dollar-lift:
| Symbol | Meaning | Typical size |
|---|---|---|
| Sequence length | ||
| Batch size (same row-span symbol) | ||
| Model width |
For the virtualized knowledge-base path with the same pipeline, open Markdown Document → Math and HTML Tables.
"use client";
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
/**
* MinerU / paper-style fixture: KaTeX in prose plus an HTML `<table>` whose
* cells contain `$…$` (opaque to remark-math until the dollar-lift pass).
*/
const MATH_HTML_TABLE_SOURCE = [
"Euler’s identity inline: $e^{i\\pi}+1=0$.",
"",
"Parenthesis delimiters: \\(a^2+b^2=c^2\\).",
"",
"Attention scales as:",
"",
"$$",
"\\mathrm{Attention}(Q,K,V)=\\mathrm{softmax}\\left(\\frac{QK^{\\top}}{\\sqrt{d_k}}\\right)V",
"$$",
"",
"Bracket display:",
"",
"\\[",
"\\mathcal{L}_{\\mathrm{CE}} = -\\sum_i y_i \\log \\hat{y}_i",
"\\]",
"",
"Parsed artifact table (HTML, not GFM pipes) — cell text like `$N$` becomes KaTeX via dollar-lift:",
"",
"<table>",
"<thead>",
"<tr><th>Symbol</th><th>Meaning</th><th>Typical size</th></tr>",
"</thead>",
"<tbody>",
'<tr><td rowspan="2">$N$</td><td>Sequence length</td><td>$512$</td></tr>',
"<tr><td>Batch size (same row-span symbol)</td><td>$32$</td></tr>",
"<tr><td>$d_{model}$</td><td>Model width</td><td colspan=\"1\">$768$</td></tr>",
"</tbody>",
"</table>",
"",
"For the **virtualized** knowledge-base path with the same pipeline, open **Markdown Document → Math and HTML Tables**.",
].join("\n");
export function LegacyMarkdownRendererMathHtmlTableDemo() {
return (
<div className="border-input bg-card max-w-lg overflow-hidden rounded-md border p-4">
<LegacyMarkdownRenderer source={MATH_HTML_TABLE_SOURCE} />
</div>
);
}Allow Images
Images are deny-by-default. Set allowImages to permit any safe http(s) URL, or tighten with allowedImageHosts / allowedImagePrefixes (and allowDataImages for data:image/…). Allowed images use an outline frame by default (subtle border, bitmap flush to the edge). This demo uses a hostname allowlist — compare the allowed picsum.photos image with the blocked example.com placeholder.
Images
Remote images are off by default. Use allowImages for all safe http(s) URLs, or restrict with allowedImageHosts / allowedImagePrefixes. This demo uses a hostname allowlist — picsum.photos renders; example.com stays blocked.
Image blocked: Blocked
"use client"
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer"
const SOURCE = [
"# Images",
"",
"Remote images are off by default. Use **`allowImages`** for all safe `http(s)` URLs, or restrict with **`allowedImageHosts`** / **`allowedImagePrefixes`**. This demo uses a hostname allowlist — `picsum.photos` renders; `example.com` stays blocked.",
"",
"",
"",
"",
].join("\n")
export function LegacyMarkdownRendererAllowedImagesDemo() {
return (
<LegacyMarkdownRenderer
source={SOURCE}
allowedImageHosts={["picsum.photos"]}
/>
)
}Image Zoom
Allowed images (this demo sets allowImages) open fullscreen Document Preview on click — a viewport overlay with zoom in/out, rotate left/right, flip, reset, and download. imageZoom is on by default; pass imageZoom={false} to keep the inline thumb only. Dismiss with Escape, the close control, or a mask click.
"use client"
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer"
export function LegacyMarkdownRendererImageZoomDemo() {
return (
<LegacyMarkdownRenderer
source={""}
allowImages
/>
)
}Streaming Source
Pass isStreaming while characters are still arriving. Incomplete emphasis is repaired mid-stream. This demo also opts into images so a late image line can load.
When Streaming Completes
Setting isStreaming only drives the incomplete-syntax cache. Turning it off flushes pending tokens and keeps the memoized block tree. Incomplete images use the same Image loading preview as a URL that is still downloading.
Streaming…
"use client";
import { useEffect, useState } from "react";
import { LegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/legacy-markdown-renderer";
import { Button } from "@/components/ui/button";
const FULL_SOURCE = [
"# Streaming source",
"",
"Incomplete **emphasis** closes only after more tokens arrive.",
"",
"",
"",
"Done.",
].join("\n");
const CHAR_INTERVAL_MS = 28;
export function LegacyMarkdownRendererStreamingDemo() {
const [visibleLength, setVisibleLength] = useState(0);
const [runId, setRunId] = useState(0);
useEffect(() => {
setVisibleLength(0);
const id = window.setInterval(() => {
setVisibleLength((prev) => {
if (prev >= FULL_SOURCE.length) {
window.clearInterval(id);
return prev;
}
return prev + 1;
});
}, CHAR_INTERVAL_MS);
return () => window.clearInterval(id);
}, [runId]);
const source = FULL_SOURCE.slice(0, visibleLength);
const isStreaming = visibleLength < FULL_SOURCE.length;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setRunId((n) => n + 1)}
>
Replay
</Button>
<p className="text-muted-foreground text-xs">
{isStreaming ? "Streaming…" : "Complete"}
</p>
</div>
<div className="border-input bg-card min-h-[200px] overflow-auto rounded-md border p-4">
<LegacyMarkdownRenderer source={source} isStreaming={isStreaming} allowImages />
</div>
</div>
);
}Chat hosts
When rendering a growing assistant reply inside a message list, pass
isStreaming={true} while tokens arrive so the incomplete-syntax cache,
remend, and block memoization stay active. Setting false when the
turn ends is now safe — pending tokens flush and the memoized tree stays.
See Message Scroller
for the shell and the assistant-first recipe; do not wrap long assistant
markdown in a solid Bubble by default.
Headless Usage
useLegacyMarkdownRenderer is the view-model: resolved className, secure components, and built-in remarkPlugins / rehypePlugins. Spread them onto ReactMarkdown when you own the outer layout or need multiple markdown regions in one tree.
isStreaming is honored by the LegacyMarkdownRenderer container only (remend + block split/memo). The headless hook does not preprocess or split — prepare the source yourself, or use the container.
Source length: 127 characters
Headless markdown
Wire useLegacyMarkdownRenderer to your own shell and ReactMarkdown.
"use client"
import ReactMarkdown from "react-markdown"
import { useLegacyMarkdownRenderer } from "@/components/f-ui/legacy-markdown-renderer/use-legacy-markdown-renderer"
const SOURCE = [
"# Headless markdown",
"",
"Wire **`useLegacyMarkdownRenderer`** to your own shell and **`ReactMarkdown`**.",
"",
"```ts",
"const ok = true",
"```",
].join("\n")
export function LegacyMarkdownRendererHeadlessDemo() {
const vm = useLegacyMarkdownRenderer({ source: SOURCE })
return (
<div>
<p>Source length: {SOURCE.length} characters</p>
<article>
<ReactMarkdown
remarkPlugins={vm.remarkPlugins}
rehypePlugins={vm.rehypePlugins}
components={vm.components}
>
{SOURCE}
</ReactMarkdown>
</article>
</div>
)
}The stock LegacyMarkdownRenderer container wraps this hook and adds streaming preprocessing when isStreaming is set.
Composition
LegacyMarkdownRenderer LegacyInlineMarkdown
└── useLegacyMarkdownRenderer └── createInlineMarkdownComponents
└── ReactMarkdown └── ReactMarkdown
├── remark-gfm + remark-math-extended ├── remark-gfm + remark-math-extended
├── rehype: sanitize children → mermaid ├── rehype: sanitize children
│ fences → raw → sanitize → dollar-lift │ → dollar-lift → KaTeX
│ → KaTeX │ (no rehype-raw / HTML tables)
└── full secure element map └── inline map (a, code, unwrap p)
├── Legacy Code Block / Legacy Mermaid Renderer / images
└── links (link-policy)
Markdown Document (large preview)
└── virtual window of MarkdownDocumentSegment*
└── LegacyMarkdownRenderer ← same math / allowHtml / GFM / Mermaid / imagesSecurity
| Threat | Default |
|---|---|
Raw HTML / <script> | Parsed only when allowHtml (default on), then sanitized — script, iframes, and event handlers never become DOM nodes |
HTML <img> vs markdown images | Sanitize allows img so markdown ![]() reaches MarkdownImage. That component is the policy gate: HTML <img> cannot bypass allowImages / host / prefix allowlists |
javascript: / exotic link schemes | Link text only; no <a href> |
External http(s) SEO | rel="noopener noreferrer nofollow ugc"; set trustedLinks to drop nofollow ugc |
Remote images (LegacyMarkdownRenderer) | Deny-by-default — blocked until allowImages, allowedImageHosts, allowedImagePrefixes, or allowDataImages; blocked/error states keep reserved height |
Mermaid source (LegacyMarkdownRenderer) | Parsed and rendered on the client only; invalid diagrams show an error and switch to code view |
LegacyInlineMarkdown shares default math but does not parse raw HTML. It also does not wire image or fence chrome — if block/image markdown slips through, default react-markdown <img> can load remote URLs and is not covered by the document deny-by-default policy above.
Override components only when you accept responsibility for the security trade-offs.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | (required) | Markdown string to render. |
className | string | — | Merged onto the outer <article> (after prose classes). |
proseClassName | string | — | Extra classes merged with prose dark:prose-invert max-w-none. |
trustedLinks | boolean | false | When true, external links omit nofollow ugc. |
math | boolean | true | When true, render $…$ / $$…$$ (and \(...\) / \[…\]) with KaTeX. Set false for currency-heavy text. |
inlineCodeBackticks | boolean | false | When true, keep Tailwind Typography backticks around inline code. Default hides them so tokens read as chips. Fenced blocks are unchanged. Forwarded by Markdown Document. |
allowHtml | boolean | true | When true, parse embedded HTML (e.g. <table>) then sanitize. When false, HTML tags appear as literal text. |
allowImages | boolean | false | When true, allow http: / https: images (dangerous schemes still blocked). |
allowedImageHosts | readonly string[] | — | Hostname allowlist (compatibility); matching hosts may render when other allow opts are off. |
allowedImagePrefixes | readonly string[] | — | URL prefix allowlist (e.g. https://cdn.example.com/). |
allowDataImages | boolean | false | When true, allow data:image/… sources. |
imageVariant | "outline" | "ghost" | "muted" | "outline" | Frame chrome. Default outline = subtle border; image sits flush (no inner padding). ghost = no border; muted = soft background. |
imageObjectFit | "contain" | "cover" | "contain" | How the bitmap fills the frame. Default contain shows the full image; cover fills a fixed aspect frame and may crop. |
imageZoom | boolean | true | When true (default), click a loaded allowed image to open Document Preview as a fullscreen overlay. Pass imageZoom={false} to disable. |
onImageError | (src: string, error: unknown) => void | — | Optional callback when an allowed image fails to load. |
isStreaming | boolean | false | Container-only: when true, the incomplete-syntax cache holds unfinished tokens, remend repairs incomplete markdown, and finished content blocks stay memoized (whitespace-only lexer tokens are skipped). Setting false flushes pending tokens and keeps the memoized block tree. Ignored by useLegacyMarkdownRenderer. |
defaultMermaidView | "code" | "preview" | "preview" | Initial tab for fenced Mermaid blocks. |
mermaidTheme | string | — | Overrides auto Mermaid theme (light→default, dark→dark). |
mermaidConfig | MermaidConfig | — | Passed to mermaid.initialize. |
onMermaidError | (err: unknown) => void | — | Optional logging hook for diagram failures. |
mermaidWheelZoom | "always" | "modifier" | "off" | "modifier" | Passed to embedded Legacy Mermaid Renderer as wheelZoom. Default keeps page scroll; hold Ctrl or ⌘ and scroll to zoom the diagram in place. |
baseOrigin | string | — | Origin for deciding if a link is external (target / rel). |
remarkPlugins | PluggableList | — | Appended after remark-gfm. |
rehypePlugins | PluggableList | — | Appended after built-in rehype plugins. |
components | Components | — | Shallow-merged over secure defaults. |
Hook
useLegacyMarkdownRenderer(props) returns { className, components, remarkPlugins, rehypePlugins } — the same inputs LegacyMarkdownRenderer passes to ReactMarkdown. The hook accepts shared props (including isStreaming) for typing convenience, but does not remend or split blocks; only the container does.
LegacyInlineMarkdown Props
Lightweight phrasing renderer. Same Plus install as LegacyMarkdownRenderer. Import from @/components/f-ui/legacy-markdown-renderer/legacy-inline-markdown.
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | (required) | Markdown string rendered as a fragment. |
className | string | — | Merged onto the root <span>. |
trustedLinks | boolean | false | When true, external links omit nofollow ugc. |
math | boolean | true | When true, render $…$ / $$…$$ with KaTeX (same default as LegacyMarkdownRenderer). |
baseOrigin | string | — | Origin for deciding if a link is external. |
remarkPlugins | PluggableList | — | Appended after remark-gfm. |
rehypePlugins | PluggableList | — | Appended after built-in sanitize. |
components | Components | — | Shallow-merged over inline defaults (a, inline code, unwrap p). |
Not supported on LegacyInlineMarkdown: allowHtml, isStreaming, proseClassName, image props, Mermaid props. Use LegacyMarkdownRenderer for document layouts, HTML tables, and streaming.