mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(editor): Linear-style issue identifier autolink (MUL-4241) Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue chips and can be typed/pasted into a real mention, instead of staying inert text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the Phase 3 batch resolve API is intentionally deferred. Phase 1 — readonly render autolink - Pure, markdown-aware detector `preprocessIssueIdentifiers` in @multica/ui/markdown rewrites bare identifiers to `[MUL-123](mention://issue/MUL-123)`, skipping code, existing links, URLs, and file/path tokens. Runs before linkify/file-card. - `isIssueIdentifier` distinguishes a bare identifier from a real mention UUID at render time (a UUID never matches the identifier pattern). - Chat markdown and comment/description readonly both resolve identifiers to a real issue via a workspace-scoped, exact-match TanStack Query (`issueIdentifierOptions`), rendering a chip on a hit and plain text on a miss / cross-workspace / while loading. The exact `identifier ===` filter enforces the workspace prefix, since the backend search matches by number. - Autolink is opt-in per surface; the shared editable preprocess pipeline is untouched so editable content is never rewritten with fake mentions. Phase 2 — editable editor input/paste - Async ProseMirror plugin resolves a completed identifier (boundary typed after it, or found in pasted text) and swaps it for an issue mention node, serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine user edits seed candidates (programmatic setContent is gated), so opening existing content never rewrites it. Resolver injected from the setup layer; no React hooks inside the extension. Tests: detector (code/link/url/path skips, dedupe), core resolver (exact match, wrong-prefix miss, empty response, key shape), chat + readonly render (hit/miss/code/canonical), and the editable extension (type/paste/miss/ inline-code/mount-safe/incomplete-token). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241) Howard final-review blocker: the async resolve rescanned the whole document for every occurrence of the resolved identifier, so completing a new `MUL-1` also rewrote a pre-existing `MUL-1` the user never touched (persisted-content rewrite; violated "opening existing content is not rewritten"). Fix: capture the specific candidate range(s) a user transaction introduces — the token before the caret when typing, the tokens inside the pasted slice on paste — into plugin state, mapping each range forward on every subsequent transaction. After async resolve, replace ONLY those mapped ranges, verifying each still holds exactly that identifier with intact boundaries and no code/link mark. No document-wide scan by identifier. Also skip link-marked text at capture so an existing link label is never converted. Regression tests: (1) typing a new MUL-1 converts only the new occurrence, not a pre-existing identical one; (2) paste converts identifiers inside the paste range but leaves an identical one outside it untouched; (3) an identifier already carrying an explicit link mark is not replaced. 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> Co-authored-by: multica-agent <github@multica.ai>
515 lines
20 KiB
TypeScript
515 lines
20 KiB
TypeScript
import * as React from 'react'
|
|
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
|
import rehypeKatex from 'rehype-katex'
|
|
import rehypeRaw from 'rehype-raw'
|
|
import rehypeSanitize, { defaultSchema } 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 '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
|
|
}
|
|
|
|
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
|
// and Multica's internal mention/slash protocols.
|
|
const sanitizeSchema = {
|
|
...defaultSchema,
|
|
protocols: {
|
|
...defaultSchema.protocols,
|
|
href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'slash'],
|
|
// Permit inline data-URI images (QR codes, charts, base64 screenshots).
|
|
// The scheme gate only allows `data:` through here; attributes.img below
|
|
// narrows it to image/* so non-image data URIs are still rejected.
|
|
src: [...(defaultSchema.protocols?.src ?? []), 'data'],
|
|
},
|
|
attributes: {
|
|
...defaultSchema.attributes,
|
|
div: [
|
|
...(defaultSchema.attributes?.div ?? []),
|
|
'dataType',
|
|
'dataHref',
|
|
'dataFilename',
|
|
],
|
|
code: [
|
|
...(defaultSchema.attributes?.code ?? []),
|
|
['className', /^language-/],
|
|
['className', /^math-/],
|
|
['className', /^hljs/],
|
|
],
|
|
img: [
|
|
// Drop the default plain `src` entry so the value allow-list below is the
|
|
// one findDefinition resolves — it returns the first match by name, so a
|
|
// bare `src` string would otherwise shadow (and disable) the allow-list.
|
|
...(defaultSchema.attributes?.img ?? []).filter(
|
|
(attr) => (typeof attr === 'string' ? attr : attr[0]) !== 'src',
|
|
),
|
|
'alt',
|
|
// Allow inline data:image/* URIs while leaving every other src form
|
|
// (http/https/site-relative) exactly as before: the negative lookahead
|
|
// keeps all non-data values, and data: is narrowed to images only.
|
|
['src', /^data:image\//i, /^(?!data:)/i],
|
|
],
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Custom URL transform that allows Multica internal protocols while keeping
|
|
* the default security for all other URLs.
|
|
*/
|
|
function urlTransform(url: string): string {
|
|
if (url.startsWith('mention://')) return url
|
|
if (url.startsWith('slash://skill/')) return url
|
|
// Allow inline data:image/* URIs — defaultUrlTransform strips every data: URL
|
|
// to '', which would blank the src even after rehype-sanitize keeps it. Kept
|
|
// in sync with the image/* narrowing in sanitizeSchema (protocols.src +
|
|
// attributes.img) so both gates agree on what a valid inline image is.
|
|
if (/^data:image\//i.test(url)) return url
|
|
return defaultUrlTransform(url)
|
|
}
|
|
|
|
|
|
// 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, sanitizeSchema], rehypeKatex]}
|
|
urlTransform={urlTransform}
|
|
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'
|