import { Fragment, type ReactNode } from "react"; type Props = { text: string; className?: string; }; /** * 聊天泡泡用輕量 markdown(不引入 react-markdown)。 * 一般回覆:每個實際換行都獨立成行,不會擠成一團。 * 另支援:標題、粗斜體、刪除線、行內/區塊 code、清單、引用、連結。 * HTML 一律當純文字,連結僅允許 http(s)/mailto。 */ export function MarkdownText({ text, className = "" }: Props) { const source = normalizeNewlines(text ?? ""); if (!source.trim()) { return
; } const blocks = parseBlocks(source); return (
{blocks.map((block, i) => ( {renderBlock(block)} ))}
); } type Block = | { type: "line"; text: string } | { type: "gap" } | { type: "h"; level: 1 | 2 | 3; text: string } | { type: "ul"; items: string[] } | { type: "ol"; items: string[] } | { type: "quote"; lines: string[] } | { type: "code"; lang: string; code: string } | { type: "hr" }; function normalizeNewlines(source: string): string { return source .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .replace(/\u2028/g, "\n") .replace(/\u2029/g, "\n\n"); } function parseBlocks(source: string): Block[] { const lines = source.split("\n"); const blocks: Block[] = []; let i = 0; /** 連續空行只算一段落間距一次,開頭不插 gap */ let pendingGap = false; let sawContent = false; const flushGap = () => { if (pendingGap && sawContent) { blocks.push({ type: "gap" }); } pendingGap = false; }; while (i < lines.length) { const line = lines[i] ?? ""; // fenced code const fence = line.match(/^```([\w-]*)\s*$/); if (fence) { flushGap(); const lang = fence[1] || ""; const body: string[] = []; i += 1; while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) { body.push(lines[i] ?? ""); i += 1; } if (i < lines.length) i += 1; // closing ``` blocks.push({ type: "code", lang, code: body.join("\n") }); sawContent = true; continue; } if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { flushGap(); blocks.push({ type: "hr" }); sawContent = true; i += 1; continue; } const heading = line.match(/^(#{1,3})\s+(.+)$/); if (heading) { flushGap(); const level = heading[1]!.length as 1 | 2 | 3; blocks.push({ type: "h", level, text: heading[2]!.trim() }); sawContent = true; i += 1; continue; } if (/^>\s?/.test(line)) { flushGap(); const quoteLines: string[] = []; while (i < lines.length && /^>\s?/.test(lines[i] ?? "")) { quoteLines.push((lines[i] ?? "").replace(/^>\s?/, "")); i += 1; } blocks.push({ type: "quote", lines: quoteLines }); sawContent = true; continue; } if (/^\s*[-*+]\s+/.test(line)) { flushGap(); const items: string[] = []; while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? "")) { items.push((lines[i] ?? "").replace(/^\s*[-*+]\s+/, "")); i += 1; } blocks.push({ type: "ul", items }); sawContent = true; continue; } if (/^\s*\d+\.\s+/.test(line)) { flushGap(); const items: string[] = []; while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? "")) { items.push((lines[i] ?? "").replace(/^\s*\d+\.\s+/, "")); i += 1; } blocks.push({ type: "ol", items }); sawContent = true; continue; } // 空行 → 段落間距(普通回覆的「空一行」) if (!line.trim()) { pendingGap = true; i += 1; continue; } // 一般文字:每一行獨立成行(聊天最重要) flushGap(); blocks.push({ type: "line", text: line }); sawContent = true; i += 1; } return blocks; } function renderBlock(block: Block): ReactNode { switch (block.type) { case "line": return (
{block.text ? renderInline(block.text) : "\u00a0"}
); case "gap": return
; case "h": { const Tag = `h${block.level}` as "h1" | "h2" | "h3"; return ( {renderInline(block.text)} ); } case "ul": return ( ); case "ol": return (
    {block.items.map((item, j) => (
  1. {renderInline(item)}
  2. ))}
); case "quote": return (
{block.lines.map((line, j) => (
{line ? renderInline(line) : "\u00a0"}
))}
); case "code": return (
          {block.code}
        
); case "hr": return
; default: return null; } } /** 行內:code → bold/italic/strike → links → plain */ function renderInline(text: string): ReactNode[] { const nodes: ReactNode[] = []; const codeSplit = text.split(/(`[^`\n]+`)/g); codeSplit.forEach((chunk, ci) => { if (!chunk) return; if (chunk.startsWith("`") && chunk.endsWith("`") && chunk.length >= 2) { nodes.push( {chunk.slice(1, -1)} , ); return; } nodes.push(...renderInlineDecorated(chunk, `t${ci}`)); }); return nodes; } function renderInlineDecorated(text: string, keyPrefix: string): ReactNode[] { const nodes: ReactNode[] = []; const re = /(\*\*[^*]+\*\*|\*[^*\n]+\*|~~[^~]+~~|\[([^\]]+)\]\(([^)\s]+)\))/g; let last = 0; let m: RegExpExecArray | null; let idx = 0; while ((m = re.exec(text)) !== null) { if (m.index > last) { nodes.push( {text.slice(last, m.index)}, ); } const token = m[0]; if (token.startsWith("**") && token.endsWith("**")) { nodes.push( {token.slice(2, -2)}, ); } else if (token.startsWith("~~") && token.endsWith("~~")) { nodes.push( {token.slice(2, -2)}, ); } else if (token.startsWith("*") && token.endsWith("*")) { nodes.push( {token.slice(1, -1)}, ); } else if (m[2] != null && m[3] != null) { const href = sanitizeHref(m[3]); if (href) { nodes.push( {m[2]} , ); } else { nodes.push( {m[2]}, ); } } last = m.index + token.length; } if (last < text.length) { nodes.push({text.slice(last)}); } return nodes; } function sanitizeHref(raw: string): string | null { const href = raw.trim(); if (/^https?:\/\//i.test(href)) return href; if (/^mailto:/i.test(href)) return href; return null; }