import React from 'react'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Highlight, themes } from 'prism-react-renderer'; import type { Components } from 'react-markdown'; import { MentionBadge } from './MentionBadge'; // ─── Remark Plugin: Tag Bare Fenced Blocks ───────────────────────────────── // react-markdown v9 removed the `inline` prop from . Fenced blocks // without a language hint have no className, making them indistinguishable // from inline code. This plugin assigns `lang: 'text'` to bare fenced blocks // so that `className="language-text"` is always present on block-level code. interface MdastNode { type: string; lang?: string | null; children?: MdastNode[]; } function remarkDefaultCodeLang() { return (tree: MdastNode) => { walkTree(tree); }; } function walkTree(node: MdastNode) { if (node.type === 'code' && !node.lang) { node.lang = 'text'; } if (node.children) { for (const child of node.children) { walkTree(child); } } } // ─── Pre-processing: Mention Tokens ───────────────────────────────────────── // remark-parse mangles <@userId> (treats as autolink or escapes the angle // brackets) before remark plugins can see the text nodes. We solve this by // converting mention tokens to standard markdown links BEFORE the parser // runs. The `a` component override then detects the mention:// scheme. // Code spans and fenced blocks are matched first and preserved as-is. function preprocessMentions(raw: string): string { return raw.replace( /(```[\s\S]*?```|`[^`]+`)|<@([a-zA-Z0-9_-]+)>/g, (match, codeBlock: string | undefined, userId: string | undefined) => { if (codeBlock) return codeBlock; return `[@${userId}](mention://${userId})`; }, ); } // ─── URL Transform: Allow mention:// Scheme ───────────────────────────────── // react-markdown v9's defaultUrlTransform strips URLs with unknown protocols. // We whitelist mention:// so the `a` component override receives the full href. function urlTransform(url: string): string { if (url.startsWith('mention://')) return url; return defaultUrlTransform(url); } // ─── Custom Theme (Aether Drift) ────────────────────────────────────────── // Based on One Dark, tuned to match Aether Drift's elevated surface aesthetic. const aetherTheme = { ...themes.oneDark, plain: { ...themes.oneDark.plain, backgroundColor: 'rgb(var(--bg-elevated))', color: 'rgb(var(--text-message))', }, }; // ─── Markdown Component Overrides ────────────────────────────────────────── const REMARK_PLUGINS = [remarkGfm, remarkDefaultCodeLang]; function CodeBlock({ language, code }: { language: string; code: string }) { // 'text' means a bare fenced block with no language — render without highlighting if (language === 'text') { return (
        {code}
      
); } return ( {({ style, tokens, getLineProps, getTokenProps }) => (
          {tokens.map((line, i) => {
            const lineProps = getLineProps({ line });
            return (
              
{line.map((token, key) => ( ))}
); })}
)}
); } const MemoizedCodeBlock = React.memo(CodeBlock); function buildComponents(): Components { return { // Paragraphs → spans to avoid block nesting issues in chat messages p: ({ children }) => {children}, // Links & Mentions // remark-parse autolinks <@userId> into mailto:@userId before plugins run, // so we intercept that pattern here instead of using a remark plugin. a: ({ href, children }) => { if (href?.startsWith('mention://')) { return ; } const mentionMatch = href?.match(/^(?:mailto:)?@([a-zA-Z0-9_-]+)$/); if (mentionMatch) { return ; } return ( {children} ); }, // Fenced code blocks (have className from our remark plugin) vs inline code code: ({ className, children, ...rest }) => { const match = /language-(\w+)/.exec(className || ''); if (match) { const code = String(children).replace(/\n$/, ''); return ; } // Inline code return ( {children} ); }, // Override
 to be a minimal wrapper — the CodeBlock handles all styling
    pre: ({ children }) => <>{children},

    // Bold / Italic / Strikethrough
    strong: ({ children }) => {children},
    em: ({ children }) => {children},
    del: ({ children }) => {children},

    // Blockquotes
    blockquote: ({ children }) => (
      
{children}
), // Lists ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , // Headings — Discord renders these with slightly larger/bolder text h1: ({ children }) =>
    {children}
    , h2: ({ children }) =>
    {children}
    , h3: ({ children }) =>
    {children}
    , // Horizontal rules hr: () =>
    , // Images (in markdown content — not attachments) img: ({ src, alt }) => ( {alt ), // Tables (GFM) table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, tbody: ({ children }) => {children}, tr: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, }; } // Build once and reuse — the components object is static const MARKDOWN_COMPONENTS = buildComponents(); // ─── Public Component ────────────────────────────────────────────────────── interface MarkdownRendererProps { content: string; } export const MarkdownRenderer = React.memo(function MarkdownRenderer({ content }: MarkdownRendererProps) { return ( {preprocessMentions(content)} ); });