'use client'; /** * Unified content renderer for markdown + @[type:id:label] mentions. * Used across room chat, issues, and PR comments. * * Features (enabled via props): * - enableLinkPreviews: inline rich link previews for issues, PRs, commits, repos * - enableCodeRefs: inline code reference blocks (file.rs:42 style) * * Mentions are protected from markdown parsing by replacing them with * zero-width-space-delimited placeholder tokens before rendering, * then restored as styled interactive elements after. */ import { memo, useMemo, useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { cn } from '@/lib/utils'; import { extractMentions, type MentionType, type MentionSpan, } from '@/lib/mention'; import { MentionBadge } from './MentionBadge'; import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect'; import { detectLinkType, extractUrls, type UnfurlResult } from '@/lib/link-unfurl'; import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser'; interface ContentRendererProps { content: string; /** Called when a mention is clicked. Provides (type, id, label). */ onMentionClick?: (type: MentionType, id: string, label: string) => void; /** Show rich link preview cards for detected URLs */ enableLinkPreviews?: boolean; /** Show inline code reference blocks for "file.rs:42" style references */ enableCodeRefs?: boolean; /** Additional CSS classes for the wrapper div. */ className?: string; /** Base URL for code reference navigation (e.g. /repository/ns/repo) */ repoBaseUrl?: string; /** Branch for code reference navigation */ branch?: string; } export const ContentRenderer = memo(function ContentRenderer({ content, onMentionClick, enableLinkPreviews = false, enableCodeRefs = false, className, repoBaseUrl, branch = 'main', }: ContentRendererProps) { const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]); // Pre-detect standalone URLs and code refs for inline rendering const { urls, codeRefs } = useMemo(() => { if (!enableLinkPreviews && !enableCodeRefs) return { urls: new Map(), codeRefs: [] }; const urlMap = new Map(); const refs: CodeRef[] = []; // Extract standalone URLs (not inside markdown links) const urlMatches = extractUrls(content); for (const { url } of urlMatches) { const result = detectLinkType(url); if (result && result.type !== 'external') { urlMap.set(url, result); } } // Extract code refs if (enableCodeRefs) { const refMatches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? []; for (const match of refMatches) { const parsed = parseCodeRef(match); if (parsed) refs.push(parsed); } } return { urls: urlMap, codeRefs: refs }; }, [content, enableLinkPreviews, enableCodeRefs]); return (
} > {safeContent}
); }); interface RendererOptions { enableLinkPreviews: boolean; enableCodeRefs: boolean; urls: Map; codeRefs: CodeRef[]; repoBaseUrl?: string; branch?: string; } function buildComponents( mentions: MentionSpan[], onMentionClick: ContentRendererProps['onMentionClick'], opts: RendererOptions, ) { const { repoBaseUrl, branch } = opts; return { p: ({ children }: { children: React.ReactNode }) => (

{restoreInNode(children, mentions, onMentionClick)}

), li: ({ children }: { children: React.ReactNode }) => (
  • {restoreInNode(children, mentions, onMentionClick)}
  • ), strong: ({ children }: { children: React.ReactNode }) => ( {restoreInNode(children, mentions, onMentionClick)} ), em: ({ children }: { children: React.ReactNode }) => ( {restoreInNode(children, mentions, onMentionClick)} ), code: ({ className, children, ...props }: React.ComponentProps<'code'>) => { const isBlock = typeof className === 'string' && className.includes('language-'); if (isBlock) { // Enhanced fenced code block — try to detect language const langTag = typeof className === 'string' ? className.replace('language-', '') : undefined; const langInfo = langTag ? getLanguageFromTag(langTag) : detectLanguage(String(children)); const langLabel = langInfo?.label ?? langTag; return ( ); } return ( {children} ); }, pre: ({ children }: React.ComponentProps<'pre'>) => { // Extract language from nested code element const codeEl = children as React.ReactElement<{ className?: string; children?: string }>; const langTag = codeEl?.props?.className?.replace('language-', ''); return ( ); }, }; } /** Code block with language badge and optional line numbers */ function EnhancedCodeBlock({ code, language, repoBaseUrl: _repoBaseUrl, branch: _branch, }: { code: string; language?: string; repoBaseUrl?: string; branch?: string; }) { const [copied, setCopied] = useState(false); const langInfo = language ? getLanguageFromTag(language) : detectLanguage(code); const displayLang = langInfo?.label ?? language ?? null; const handleCopy = async () => { try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; return (
    {/* Header */}
    {displayLang && ( {displayLang} )}
    {/* Code */}
            {code}
          
    ); } // ── Helpers ──────────────────────────────────────────────────────────────── /** Recursively restore mention placeholders inside text nodes. */ function restoreInNode( children: React.ReactNode, mentions: MentionSpan[], onMentionClick?: (type: MentionType, id: string, label: string) => void, ): React.ReactNode { if (typeof children === 'string') { return restoreMentionsText(children, mentions, onMentionClick); } if (Array.isArray(children)) { return children.map((child, i) => ( {restoreInNode(child, mentions, onMentionClick)} )); } return children; } /** Restore mention placeholders inside a single text string into React elements. */ function restoreMentionsText( text: string, mentions: MentionSpan[], onMentionClick?: (type: MentionType, id: string, label: string) => void, ): React.ReactNode { const parts: React.ReactNode[] = []; let lastIndex = 0; const PLACEHOLDER_RE = /​MENTION_(\d+)​/g; let match: RegExpExecArray | null; while ((match = PLACEHOLDER_RE.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } const idx = parseInt(match[1], 10); const m = mentions[idx]; if (m) { parts.push( , ); } lastIndex = PLACEHOLDER_RE.lastIndex; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return parts.length > 0 ? parts : text; }