import React from 'react'; import type { Components } from 'react-markdown'; import Mermaid from '../../Mermaid'; import { ANCHOR } from '../../../chat/styles/bubbleTokens'; import { CodeBlock, CodeBlockFallback } from './CodeBlock'; import { extractTextFromChildren } from './plainText'; /** Body / heading size classes per `size` level. */ const SIZE_CLASSES: Record<'xs' | 'sm' | 'base', { text: string; headingBase: string; headingSm: string; }> = { xs: { text: 'text-xs', headingBase: 'text-sm', headingSm: 'text-xs' }, sm: { text: 'text-sm', headingBase: 'text-base', headingSm: 'text-sm' }, base: { text: 'text-base', headingBase: 'text-lg', headingSm: 'text-base' }, }; /** * Build the chat-tuned markdown component map. * * Body text size follows `size` (xs/sm/base → 12/14/16px). Heading * sizes are scaled a notch above the body so inline-in-chat headings * stand out without dominating. */ export function createMarkdownComponents( isUser: boolean = false, size: 'xs' | 'sm' | 'base' = 'sm', codeTheme: 'dark' | 'light' = 'dark', ): Components { const { text: textSize, headingBase, headingSm } = SIZE_CLASSES[size]; // Code blocks / diagrams only have a binary compact mode — treat the // smallest text level as compact, larger ones as roomy. const isCompact = size === 'xs'; return { h1: ({ children }) => (

{children}

), h2: ({ children }) => (

{children}

), h3: ({ children }) => (

{children}

), h4: ({ children }) => (

{children}

), h5: ({ children }) => (
{children}
), h6: ({ children }) => (
{children}
), p: ({ children }) => (

{children}

), ul: ({ children }) => ( ), ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {children}
  • , // `target` / `rel` for external links are NOT set here — the // rehype-external-links plugin tags them on the rehype side, so // every `` that sanitize let through gets the same security // treatment regardless of which renderer (default vs linkRules // override) emitted it. Doing it twice here would just duplicate // attributes; doing it only here would miss the linkRules path. a: ({ href, children, ...rest }) => ( {children} ), pre: ({ children }) => { let codeContent = ''; let language = 'plaintext'; if (React.isValidElement(children)) { const child = children; if ( child.type === 'code' || (typeof child.type === 'function' && child.type.name === 'code') ) { const codeProps = child.props as { className?: string; children?: React.ReactNode; }; const rawClassName = codeProps.className; language = rawClassName?.replace(/language-/, '').trim() || 'plaintext'; codeContent = extractTextFromChildren(codeProps.children).trim(); } else { codeContent = extractTextFromChildren(children).trim(); } } else { codeContent = extractTextFromChildren(children).trim(); } if (!codeContent) { return (
    No content available
    ); } if (language === 'mermaid') { // Inline render fits the bubble width; the Mermaid component // owns its own click-to-fullscreen modal which sizes against // the viewport, so we don't cap it here. A previous version // hardcoded `max-w-[600px]` and that constraint leaked into // the fullscreen modal too — diagram rendered at 600px in the // middle of an empty viewport. return (
    ); } try { return ; } catch (error) { // eslint-disable-next-line no-console console.warn('CodeBlock failed, using fallback:', error); return ; } }, code: ({ children, className }) => { // Inside
    : let pre handle styling.
          if (className?.includes('language-')) {
            return {children};
          }
          // Inline `` uses the design system's `--code-inline`
          // token. One semantic chip surface across both themes; on a
          // user bubble we still palette-switch with `text-primary-
          // foreground` because the inline chip blends INTO the bubble
          // — there's no panel boundary like a fence has. We trade a
          // perfect "code surface" tone here for legible body inheritance,
          // matching ChatGPT's behaviour in coloured user bubbles.
          const inlineCodeClass = isUser
            ? 'bg-primary-foreground/15 text-primary-foreground'
            : 'bg-code-inline text-code-inline-foreground';
          return (
            
              {extractTextFromChildren(children)}
            
          );
        },
    
        // Modern chat convention drops italic on blockquotes — italic +
        // tight bubble = hard to read. Border-left at 2px (4px reads
        // heavy in a 320–480px bubble). On the saturated user bubble we
        // use a primary-foreground tint; on the assistant bubble we use
        // the muted-foreground role for de-emphasis.
        blockquote: ({ children }) => {
          const cls = isUser
            ? 'border-primary-foreground/40 text-primary-foreground/80'
            : 'border-border text-muted-foreground';
          return (
            
    {children}
    ); }, // Tables: outer wrapper handles overflow, inner `` // inherits the chat-density text size. Borders / header use // semantic tokens — `border-code-border` for the assistant // (matches the code-fence panel for visual cohesion when both // appear in the same reply); primary-foreground/N for the user // bubble so lines read against the saturated `bg-primary`. table: ({ children }) => (
    {children}
    ), thead: ({ children }) => ( {children} ), tbody: ({ children }) => {children}, tr: ({ children }) => ( {children} ), th: ({ children }) => { const borderCls = isUser ? 'border-primary-foreground/25' : 'border-border'; return ( {children} ); }, td: ({ children }) => {children}, // Soft separator. ChatGPT / Slack / Linear strip the visible // line, Claude.ai keeps a hairline. We follow Claude — present // but quiet. Palette switches by role so the hairline reads on // both surfaces. hr: () => (
    ), strong: ({ children }) => {children}, em: ({ children }) => {children}, }; }