Files
multica/packages/ui/markdown/Markdown.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +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-body">{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-body">{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-body">{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-title-sm font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-title-sm font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-body 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-body">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-body">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-title-sm font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-title-sm font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-body font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-body 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'