import { useState, type ReactNode } from "react"; import { downloadImage } from "./download"; import { mediaName, mediaUrl } from "./media"; import { useStore } from "../state/store"; import { DownloadIcon, ToolIcon } from "../components/Icons"; // A deliberately small, dependency-free markdown renderer. It escapes all text // (no raw HTML) and supports the subset that matters for chat: fenced code // blocks, inline code, images (including data: URLs), links, bold, italic, and // autolinks. const INLINE = /(`[^`]+`)|(!\[[^\]]*\]\([^)]*\))|(\[[^\]]*\]\([^)]*\))|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(https?:\/\/[^\s<)]+)|(data:image\/[^\s)]+)|(MEDIA:\S+)/g; /** Splits `alt](url` (the inside of `![alt](url)` / `[text](url)`) into parts. */ function splitLink(inner: string): { label: string; url: string } { const idx = inner.indexOf("]("); if (idx === -1) return { label: inner, url: "" }; return { label: inner.slice(0, idx), url: inner.slice(idx + 2) }; } /** Some servers wrap long data URLs across lines; strip whitespace so the * browser can decode them. */ function cleanUrl(url: string): string { return url.startsWith("data:") ? url.replace(/\s+/g, "") : url; } /** A `MEDIA:` reference: an image if a media base URL is configured, * otherwise an attachment card. */ function MediaRef({ path }: { path: string }) { const baseUrl = useStore((s) => s.config.mediaBaseUrl); const prefix = useStore((s) => s.config.mediaPathPrefix); const [failed, setFailed] = useState(false); const url = mediaUrl(path, baseUrl, prefix); const name = mediaName(path); if (url && !failed) { return ( {name} setFailed(true)} /> ); } return ( {name} {path} ); } /** An inline image with a download button beneath it. */ function ImageFigure({ url, alt }: { url: string; alt: string }) { const src = cleanUrl(url); return ( {alt} ); } function renderInline(text: string, keyPrefix: string): ReactNode[] { const nodes: ReactNode[] = []; let last = 0; let i = 0; let m: RegExpExecArray | null; INLINE.lastIndex = 0; while ((m = INLINE.exec(text)) !== null) { if (m.index > last) nodes.push(text.slice(last, m.index)); const token = m[0]; const key = `${keyPrefix}-${i++}`; if (m[1]) { nodes.push({token.slice(1, -1)}); } else if (m[2]) { const { label, url } = splitLink(token.slice(2, -1)); nodes.push(); } else if (m[3]) { const { label, url } = splitLink(token.slice(1, -1)); nodes.push( {label} , ); } else if (m[4]) { nodes.push({token.slice(2, -2)}); } else if (m[5]) { nodes.push({token.slice(1, -1)}); } else if (m[6]) { nodes.push( {token} , ); } else if (m[7]) { nodes.push(); } else if (m[8]) { nodes.push(); } last = m.index + token.length; } if (last < text.length) nodes.push(text.slice(last)); return nodes; } export function renderMarkdown(text: string): ReactNode { const parts: ReactNode[] = []; const fence = /```([^\n`]*)\n?([\s\S]*?)```/g; let last = 0; let m: RegExpExecArray | null; let i = 0; while ((m = fence.exec(text)) !== null) { if (m.index > last) { parts.push({renderInline(text.slice(last, m.index), `t${i}`)}); } parts.push(
        {m[2].replace(/\n$/, "")}
      
, ); last = m.index + m[0].length; i++; } if (last < text.length) { parts.push({renderInline(text.slice(last), `t${i}`)}); } return parts; }