Files
multica/packages/ui/markdown/Markdown.tsx
Naiyuan Qing 5a11232c47 feat(rich-content): unify Chat and Issue/Comment on one RichContent renderer (MUL-4922) (#5578)
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922)

Phase 1 of the RichContent convergence: collapse the duplicated security
base shared by the two product-level Markdown chains.

Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment
(packages/views/editor/readonly-content.tsx) each carried a verbatim fork
of the rehype-sanitize schema and urlTransform, and the forks had already
drifted: readonly whitelisted <mark> for `==highlight==`, chat did not.
A security-relevant allow-list maintained in two places means every future
XSS fix has to land twice, and missing one is a hole — this is the hardest
reason for the sweep, ahead of the user-visible feature drift.

- Extract markdownSanitizeSchema + markdownUrlTransform into
  packages/ui/markdown/sanitize.ts and export from the package index.
  Both chains now import the single copy; no local forks remain.
- The canonical schema is the union, so chat gains the <mark> tag name.
  This is the one intentional behavior delta: <mark> is inert and admits
  no attributes, and chat needs it anyway once ==highlight== converges.
- Annotate the schema as rehype-sanitize's Options: exporting it makes the
  previously-inferred hast-util-sanitize type unnameable across packages.

Adds a cross-surface contract test that runs one set of security fixtures
(script, event handlers, javascript: href, data:image vs data:text/html,
mark, slash://) through BOTH surfaces and asserts identical outcomes —
the mechanism that stops a third fork from growing back.

Code-block rendering is deliberately not asserted cross-surface yet: chat
highlights with Shiki, readonly with lowlight, so emitted class tokens
still differ. Converging them is the RichCodeBlock phase and needs the
highlight-engine decision first; only the schema-level allow-list is
shared here.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
pnpm vitest run in packages/views (228 files, 2665 tests, all passing).

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922)

Phase 2+3 of the convergence. Chat now renders agent output through the same
product renderer as Issue descriptions and Comments, so a ```mermaid fence an
agent emits is a diagram in Chat — not a dead code block.

Before this there were two product Markdown chains. Issue/Comment had Mermaid,
HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went
through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is
where agents emit the most diagrams, so the gap was most visible exactly where
it mattered.

Architecture
- packages/views/rich-content/ holds the ONE product renderer: a single
  ReactMarkdown pipeline, one sanitize config, one components map, one fenced
  -code dispatcher. Public API is content/attachments/density/phase — no
  `surface` prop, no renderMention override, no custom code-renderer hook,
  because each is a door a per-surface fork walks back through.
- `density` is CSS only; `phase` is lifecycle only. Neither switches parser,
  plugins or the semantic DOM, so all surfaces emit the same blocks.
- ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines);
  its consumers are untouched. views/common/markdown.tsx is deleted.
- Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and
  are imported by direct path, so Tiptap keeps reusing them and Chat does not
  pull in the editor's Tiptap graph. Moving them is a later mechanical commit.

Streaming fence gate
Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark
parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence
renders as source, so Mermaid never parses a partial diagram and no iframe is
created for HTML still arriving. Closedness — not `phase` — is the gate, so a
settled-but-malformed fence stays source too. The gate returns offsets only; it
never renders, because a gate that rendered would be the second renderer this
change exists to delete. Verified that source offsets survive rehype-raw +
sanitize + katex before relying on them.

Live -> persisted row identity
The live timeline moved out of Virtuoso's Footer into a real row keyed
`task:<taskId>`, and persisted assistant rows key on the same task instead of
`message.id`. Completion is now an in-place data swap through one
AssistantMessage component, so an already-rendered diagram or iframe stays
mounted and is not re-run. The process fold previously relied on that remount
to re-collapse, so the collapse is now expressed directly.

Notes
- Chat code blocks move from Shiki to lowlight (Howard's ratified choice),
  matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression
  carries over via code.css instead of utility classes.
- Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer
  but no data rows under jsdom's zero-height viewport, and the live timeline is
  a row now.

Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue
description, Comment), streaming open->closed->settled with mount counting,
live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and
data-URI security regressions, CommonMark fence edge cases (shorter closer,
tilde, indented, nested, in-list), and source-level import boundary guards.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 240 files / 2743 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922)

Two review follow-ups.

1. Dangling package export
`packages/views` still exported "./common/markdown" after the file was
deleted in the RichContent convergence. TypeScript resolves against the
source tree so typecheck and unit tests both passed; the subpath would only
fail when a consuming app bundled it. No consumer imports it, so this was
latent rather than broken in practice.

Removes the entry and adds packages/views/rich-content/package-exports.test.ts,
which walks every workspace package.json and asserts each export target
exists (wildcards checked by directory prefix). Scoped repo-wide because the
failure mode belongs to package.json, not to this package. Verified the guard
actually fails by re-adding the dangling entry before committing.

2. Near-viewport lazy shell for Mermaid / HTML
Implements the deferred performance contract rather than requesting an
exemption. LazyRichBlock defers each rich leaf until it is within 800px of
the viewport, then latches: once mounted it is never unmounted, so scrolling
past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the
viewer's pan/zoom state.

The stable-size requirement is handled by reserving the block's expected
height before AND after mount, so a block never measures 0px off-screen and
then jumps — the churn that makes a virtualized list mis-estimate item sizes.
The reservation is not a local guess: reservedMermaidHeightPx() reuses the
existing session layout cache (real height on a cache hit, else the documented
280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the
preview iframe's fixed h-[480px]. Both are exported from the leaves so there
is one source of truth per block type.

Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is
today's behaviour; rendering nothing would not be.

Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests
only exercise the eager fallback):
- Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on
  load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the
  latch. This is where the win is real.
- Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4
  shells was deferred. Stated rather than overclaimed.
- Live -> persisted handoff re-checked with the shell in place: scrollTop
  220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run
  reached the persisted state. Contract 1 is not regressed.

Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal
reserved height before and after mount, root margin exceeding the chat list's
own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 242 files / 2752 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922)

Three review blockers.

1. Hydration mismatch in the lazy shell
LazyRichBlock derived its initial `mounted` state from feature detection
inside useState. On the server (no window) that resolved true and rendered
the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present)
the hydration pass resolved false and rendered a placeholder — different
markup for the same component, and an SSR path that silently bypassed the
lazy gate. `"use client"` does not opt a component out of Next's server
render, so this was reachable.

Initial state is now unconditionally false on both sides. The eager fallback
for environments without IntersectionObserver moved into the effect, which
never runs on the server, so the first committed frame is identical
everywhere and the latch is unchanged.

The first version of the SSR test passed against the buggy component: jsdom
always provides `window`, so server and client took the same detection branch
and the mismatch was unobservable. The suite now removes IntersectionObserver
for the duration of renderToString to reproduce the real asymmetry. Verified
by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own
"Hydration failed" error.

2. Project mention lost keyboard access
The unified renderer inherited the readonly surface's `<span onClick>` around
ProjectChip, while Chat had previously used AppLink — so converging the
surfaces regressed Chat from a focusable anchor to a mouse-only span. A span
and an anchor are visually identical and behave the same under a mouse, which
is why only an assertion on the emitted element catches it.

Now rendered through AppLink, which also owns plain-click, modifier-click and
the desktop new-tab adapter, so none of that is reimplemented. The wrapper
keeps only stopPropagation, matching IssueMentionCard.

Adds project-mention-a11y.test.tsx using the REAL AppLink and
NavigationProvider — mocking AppLink to emit an anchor would assert the mock.
Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter
activation, click, and modifier-click labelling. All 6 fail on the old span.

3. Type suppressions in the production renderer
`as never` on the plugin lists and `as NonNullable<Components[...]>` on the
code/pre overrides silenced real type errors, against the repo's strict-TS
rule. Replaced with react-markdown's own types: RichCode/RichPre now derive
their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin
lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>`
so a bad plugin tuple still fails to compile.

Also removed the remaining casts this exposed: hast property reads went
through `as string` even though hast property values are a union, so a
non-string `data-*` attribute would have been typed as a lie — replaced with
a runtime-narrowing helper. `toHtml()` already returns string; its cast was
redundant. Node position reads are narrowed in one helper. No cast or
ts-ignore remains in production rich-content.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 243 files / 2780 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922)

Three review blockers.

1. Cached reserved height still reached the first frame
The previous fix made `mounted` deterministic but left the *height* reading
sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx()
inline. Server has no sessionStorage so it emitted 280px, while a browser with
a warm cache emitted the real height — a differing style="min-height:…" on the
frame React hydrates, which React reports as an attribute mismatch and does
not repair. Same bug class as the one already fixed, one layer up.

RichFenceBlock now splits into Mermaid/HTML components (so the height hook is
never conditional) and reserves the skeleton default on the first frame,
adopting the cached height in an effect. Zero-shift on a warm cache is kept;
only the read moves after hydration.

The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy
shell, bypassing this path — it could not have caught this. New tests drive the
real RichFenceBlock with a real prefilled sessionStorage entry, and simulate
the server by removing sessionStorage for the renderToString call (jsdom
provides one, so without that the "server" takes the browser branch and the
mismatch is invisible).

2. Wrong observer root, and a latch that died with the row
The IntersectionObserver set only rootMargin, so it clipped against the
viewport — but Chat scrolls inside its own element (Virtuoso
customScrollParent). Expanding the viewport box says nothing about a nested
scroller, so Chat blocks only loaded once already visible and the preload was
effectively dead there. Surfaces now publish their scroll container through
RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled
surfaces keep the viewport root.

Separately, the mount latch was component state, so Virtuoso recycling a row
discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped
viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved
to a module-level registry keyed by a hash of the content, bounded at 500
entries with oldest-first eviction. Restore happens in a layout effect (before
paint, so no placeholder flash) and never in initial state, keeping the
server/client first frame identical. Scope was not narrowed.

3. CDN config arriving late never reprocessed content
preprocessMarkdown read configStore.getState().cdnDomain imperatively and
RichContent's memo depended only on `content`, so content rendered before the
async config landed kept legacy CDN links as plain anchors permanently.
cdnDomain is now an explicit parameter: RichContent subscribes via
useConfigStore and puts it in the memo deps, while the Tiptap editor — which
genuinely preprocesses once at load — reads the store at its own call sites. No
fallback branch.

Each fix was verified by reinstating its bug and confirming the new tests fail
(2/5, 2/14 and 1/3 respectively) rather than trusting a green run.

Also fixes a false positive in the boundary guard: the Tiptap check read raw
file text instead of using the suite's stripComments helper, so a doc comment
mentioning RichContent counted as a violation.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 245 files / 2793 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:40:00 +08:00

458 lines
17 KiB
TypeScript

import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { FileText, Download } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style'
import { CodeBlock, InlineCode } from './CodeBlock'
import { isAllowedFileCardHref, preprocessFileCards } from './file-cards'
import { preprocessLinks } from './linkify'
import { preprocessIssueIdentifiers } from './issue-identifiers'
import { preprocessMentionShortcodes } from './mentions'
import { markdownSanitizeSchema, markdownUrlTransform } from './sanitize'
import 'katex/dist/katex.min.css'
import './markdown.css'
/**
* Render modes for markdown content:
*
* - 'terminal': Raw output with minimal formatting, control chars visible
* Best for: Debug output, raw logs, when you want to see exactly what's there
*
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
* Best for: Chat messages, inline content, when you want readability without clutter
*
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
* Best for: Documentation, long-form content, when presentation matters
*/
export type RenderMode = 'terminal' | 'minimal' | 'full'
export interface MarkdownProps {
children: string
/**
* Render mode controlling formatting level
* @default 'minimal'
*/
mode?: RenderMode
className?: string
/**
* Message ID for memoization (optional)
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
*/
id?: string
/**
* Callback when a URL is clicked
*/
onUrlClick?: (url: string) => void
/**
* Callback when a file path is clicked
*/
onFileClick?: (path: string) => void
/**
* Custom renderer for mention links (e.g. mention://issue/UUID).
* When not provided, mentions render as a simple styled span.
*/
renderMention?: (props: { type: string; id: string }) => React.ReactNode
/**
* CDN hostname for file card detection (e.g. "multica-static.copilothub.ai").
* When provided, enables file card preprocessing and rendering.
*/
cdnDomain?: string
/**
* Optional override for the image renderer. When provided, replaces the
* default `<img>` with constrained sizing. The views-package wrapper uses
* this to inject the unified `<Attachment>` component so chat messages get
* the same hover toolbar / lightbox / preview-modal treatment as comments.
*/
renderImage?: (props: { src: string; alt: string }) => React.ReactNode
/**
* Optional override for the file-card renderer. When provided, replaces
* the simplified card chrome (filename + download button) with whatever
* the caller supplies. Used the same way as `renderImage` to bridge into
* the views-package `<Attachment>` component.
*/
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
/**
* When true, bare issue identifiers (e.g. `MUL-123`, `TES-1`) are rewritten
* to `mention://issue/<identifier>` links so `renderMention` can resolve them
* to a navigable issue chip. Off by default — enable only on surfaces whose
* `renderMention` knows how to resolve an identifier (see the app wrapper in
* packages/views/common/markdown.tsx). Detection is markdown-aware: code,
* existing links, URLs, and file/path tokens are skipped.
*/
autolinkIssueIdentifiers?: boolean
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
/**
* Create custom components based on render mode
*/
function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void,
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
renderImage?: (props: { src: string; alt: string }) => React.ReactNode,
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode,
): Partial<Components> {
const baseComponents: Partial<Components> = {
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined
if (dataType === 'fileCard') {
const rawHref = (node?.properties?.dataHref as string) || ''
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
const filename = (node?.properties?.dataFilename as string) || ''
if (renderFileCard) {
return <>{renderFileCard({ href, filename })}</>
}
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={() => window.open(href, '_blank', 'noopener,noreferrer')}
>
<Download className="size-3.5" />
</button>
)}
</div>
)
}
return <div {...props}>{children}</div>
},
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => {
if (renderImage) {
return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })}</>
}
return (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
)
},
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://project/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/)
if (mentionMatch?.[1] && mentionMatch[2]) {
const type = mentionMatch[1]
const id = mentionMatch[2]
if (renderMention) {
// Let the custom renderer opt out for types it doesn't handle
// by returning null/undefined — we then fall through to the
// default styled span so nothing ever disappears silently.
const rendered = renderMention({ type, id })
if (rendered) return <>{rendered}</>
}
// Fallback: render as a simple styled span
return (
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)
}
return (
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)
}
if (href?.startsWith('slash://skill/')) {
return (
<span className="slash-command text-primary font-semibold mx-0.5">
{children}
</span>
)
}
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {
// Check if it's a file path
if (FILE_PATH_REGEX.test(href) && onFileClick) {
onFileClick(href)
} else if (onUrlClick) {
onUrlClick(href)
} else {
// Default: open in new window
window.open(href, '_blank', 'noopener,noreferrer')
}
}
}
return (
<a
href={href}
onClick={handleClick}
className="text-primary hover:underline cursor-pointer"
>
{children}
</a>
)
}
}
// Terminal mode: minimal formatting
if (mode === 'terminal') {
return {
...baseComponents,
// No special code handling - just monospace
code: ({ children }) => <code className={cn('font-mono', CODE_LIGATURE_CLASS)}>{children}</code>,
pre: ({ children }) => (
<pre className={cn('font-mono whitespace-pre-wrap my-2', CODE_LIGATURE_CLASS)}>
{children}
</pre>
),
// Minimal paragraph spacing
p: ({ children }) => <p className="my-1">{children}</p>,
// Simple lists
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
// Plain tables
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
td: ({ children }) => <td className="pr-4">{children}</td>
}
}
// Minimal mode: clean with syntax highlighting
if (mode === 'minimal') {
return {
...baseComponents,
// Inline code
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
// Block code - use CodeBlock with full mode
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
// Inline code
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Comfortable paragraph spacing
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-2 space-y-1 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Clean tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="min-w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b">{children}</thead>,
th: ({ children }) => (
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
),
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
// Headings - H1/H2 same size, differentiated by weight
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
),
// Horizontal rules
hr: () => <hr className="my-4 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>
}
}
// Full mode: rich styling
return {
...baseComponents,
// Full code blocks with copy button
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Rich paragraph spacing
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Beautiful tables
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-md border">
<table className="min-w-full divide-y divide-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
// Styled blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
{children}
</blockquote>
),
// Task lists (GFM)
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded border-muted-foreground"
/>
)
}
return <input type={type} />
},
// Horizontal rules
hr: () => <hr className="my-6 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
}
}
/**
* Markdown - Customizable markdown renderer with multiple render modes
*
* Features:
* - Three render modes: terminal, minimal, full
* - Syntax highlighting via Shiki
* - GFM support (tables, task lists, strikethrough)
* - Clickable links and file paths
* - Memoization for streaming performance
* - Pluggable mention rendering via renderMention prop
*/
export function Markdown({
children,
mode = 'minimal',
className,
onUrlClick,
onFileClick,
renderMention,
renderImage,
renderFileCard,
cdnDomain,
autolinkIssueIdentifiers
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
)
// Preprocess: convert mention shortcodes, bare issue identifiers, raw URLs,
// and file cards to renderable content. Issue-identifier autolinking runs
// BEFORE linkify/file-card so those passes treat the rewritten spans as
// existing markdown links and skip them.
const processedContent = React.useMemo(
() => {
let result = preprocessMentionShortcodes(children)
if (autolinkIssueIdentifiers) result = preprocessIssueIdentifiers(result)
result = preprocessLinks(result)
result = preprocessFileCards(result, cdnDomain ?? '')
return result
},
[children, cdnDomain, autolinkIssueIdentifiers]
)
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkBreaks,
[remarkGfm, { singleTilde: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownSanitizeSchema], rehypeKatex]}
urlTransform={markdownUrlTransform}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
/**
* MemoizedMarkdown - Optimized for streaming scenarios
*
* Splits content into blocks and memoizes each block separately,
* so only new/changed blocks re-render during streaming.
*/
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
// If id is provided, use it for memoization
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
)
}
// Otherwise compare content and mode
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'