import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport'; import { sanitizeLinkHref, stripUnsafeHtml } from './sanitize'; /** * 将规范化 Markdown 解析为 Reply Document IR * 不依赖 DOM,可在浏览器、Web Worker 以及 Node/SSR 环境下运行 */ export function parseReplyMarkdown(markdown: string, defaultTitle?: string): ReplyDocumentIR { const cleanMd = stripUnsafeHtml(markdown || '').replace(/\r\n/g, '\n'); const lines = cleanMd.split('\n'); const nodes: ReplyNode[] = []; let title = defaultTitle || ''; let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); // 空行跳过 if (trimmed === '') { i++; continue } // 1. 代码块与 Mermaid 围栏 if (trimmed.startsWith('```')) { const info = trimmed.slice(3).trim(); const isMermaid = info === 'mermaid'; const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].trim().startsWith('```')) { codeLines.push(lines[i]); i++; } i++; // 跳过闭合 ``` const rawCode = codeLines.join('\n'); if (isMermaid) { nodes.push({ type: 'mermaid', source: rawCode }); } else { nodes.push({ type: 'code', language: info || undefined, value: rawCode }); } continue; } // 2. 独立块级数学公式 $$ ... $$ if (trimmed.startsWith('$$')) { const mathLines: string[] = []; if (trimmed.length > 2 && trimmed.endsWith('$$')) { // 单行 $$公式$$ mathLines.push(trimmed.slice(2, -2).trim()); i++; } else { i++; while (i < lines.length && !lines[i].trim().endsWith('$$')) { mathLines.push(lines[i]); i++; } if (i < lines.length) { const last = lines[i].trim(); mathLines.push(last.slice(0, -2).trim()); i++; } } nodes.push({ type: 'math', display: true, latex: mathLines.join('\n').trim() }); continue; } // 3. 分割线 if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) { nodes.push({ type: 'divider' }); i++; continue; } // 4. 标题 (# ~ ######) const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6; const headingText = headingMatch[2].trim(); if (!title && (level === 1 || level === 2)) { title = headingText.replace(/[*_`~]/g, ''); } nodes.push({ type: 'heading', level, children: parseInlineContent(headingText), }); i++; continue; } // 5. 引用块 (> ...) if (trimmed.startsWith('>')) { const quoteLines: string[] = []; while (i < lines.length && lines[i].trim().startsWith('>')) { quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); i++; } // 递归解析引用块内子节点 const subIR = parseReplyMarkdown(quoteLines.join('\n')); nodes.push({ type: 'blockquote', children: subIR.nodes, }); continue; } // 6. 表格 (| ... |) if (trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.includes('|')) { const tableLines: string[] = []; while (i < lines.length && lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) { tableLines.push(lines[i].trim()); i++; } if (tableLines.length >= 2) { const headerCols = splitTableRow(tableLines[0]); // 第二行通常是分隔线 |---|---| let startIndex = 1; if (/^\|(\s*:?-+:?\s*\|)+$/.test(tableLines[1])) { startIndex = 2; } const rows: ReplyInline[][][] = []; for (let r = startIndex; r < tableLines.length; r++) { const cells = splitTableRow(tableLines[r]); rows.push(cells.map((c) => parseInlineContent(c))); } nodes.push({ type: 'table', header: headerCols.map((c) => parseInlineContent(c)), rows, }); continue; } } // 7. 列表项 (有序 1. 或 无序 - / * ) const listOrderedMatch = trimmed.match(/^(\d+)\.\s+(.+)$/); const listUnorderedMatch = trimmed.match(/^[-*+]\s+(.+)$/); if (listOrderedMatch || listUnorderedMatch) { const isOrdered = !!listOrderedMatch; const items: ReplyInline[][] = []; while (i < lines.length) { const curTrim = lines[i].trim(); const mOrder = curTrim.match(/^(\d+)\.\s+(.+)$/); const mUnorder = curTrim.match(/^[-*+]\s+(.+)$/); if (isOrdered && mOrder) { items.push(parseInlineContent(mOrder[2])); i++; } else if (!isOrdered && mUnorder) { items.push(parseInlineContent(mUnorder[1])); i++; } else if (curTrim === '') { // 列表中的空行探测后续是否还是同类列表项 if (i + 1 < lines.length) { const nextTrim = lines[i + 1].trim(); if ((isOrdered && nextTrim.match(/^(\d+)\.\s+/)) || (!isOrdered && nextTrim.match(/^[-*+]\s+/))) { i++; continue; } } break; } else { break; } } nodes.push({ type: 'list', ordered: isOrdered, items, }); continue; } // 8. 普通段落 (汇聚连续非空普通文本行) const paraLines: string[] = []; while (i < lines.length) { const cur = lines[i]; const curTrim = cur.trim(); if ( curTrim === '' || curTrim.startsWith('#') || curTrim.startsWith('```') || curTrim.startsWith('$$') || curTrim.startsWith('>') || (curTrim.startsWith('|') && curTrim.endsWith('|')) || curTrim.match(/^(\d+)\.\s+/) || curTrim.match(/^[-*+]\s+/) || /^(\*{3,}|-{3,}|_{3,})$/.test(curTrim) ) { break; } paraLines.push(curTrim); i++; } if (paraLines.length > 0) { const paraText = paraLines.join(' '); if (!title) { const plain = paraText.replace(/[*_`~]/g, ''); title = plain.length > 30 ? plain.slice(0, 30) : plain; } nodes.push({ type: 'paragraph', children: parseInlineContent(paraText), }); } } if (!title) { title = '智能体回复摘录'; } return { title, nodes }; } /** * 分割 Markdown 表格行 (| cell1 | cell2 |) */ function splitTableRow(rowStr: string): string[] { let inner = rowStr.trim(); if (inner.startsWith('|')) inner = inner.slice(1); if (inner.endsWith('|')) inner = inner.slice(0, -1); return inner.split('|').map((s) => s.trim()); } /** * 解析行内内容:粗体、斜体、删除线、行内代码、行内公式、超链接 */ export function parseInlineContent(text: string): ReplyInline[] { const inlines: ReplyInline[] = []; let remaining = text; while (remaining.length > 0) { // 1. 行内公式 $...$ const mathMatch = remaining.match(/^\$([^$]+)\$/); if (mathMatch) { inlines.push({ type: 'math', latex: mathMatch[1] }); remaining = remaining.slice(mathMatch[0].length); continue; } // 2. 行内代码 `...` const codeMatch = remaining.match(/^`([^`]+)`/); if (codeMatch) { inlines.push({ type: 'code', value: codeMatch[1] }); remaining = remaining.slice(codeMatch[0].length); continue; } // 3. 超链接 [text](url) const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); if (linkMatch) { const safeHref = sanitizeLinkHref(linkMatch[2]); inlines.push({ type: 'link', href: safeHref, children: [{ type: 'text', value: linkMatch[1] }], }); remaining = remaining.slice(linkMatch[0].length); continue; } // 4. 粗斜体 ***text*** 或 ___text___ const boldItalicMatch = remaining.match(/^(\*\*\*|___)(.+?)\1/); if (boldItalicMatch) { inlines.push({ type: 'text', value: boldItalicMatch[2], bold: true, italic: true, }); remaining = remaining.slice(boldItalicMatch[0].length); continue; } // 5. 粗体 **text** 或 __text__ const boldMatch = remaining.match(/^(\*\*|__)(.+?)\1/); if (boldMatch) { inlines.push({ type: 'text', value: boldMatch[2], bold: true, }); remaining = remaining.slice(boldMatch[0].length); continue; } // 6. 斜体 *text* 或 _text_ const italicMatch = remaining.match(/^(\*|_)([^*_]+?)\1/); if (italicMatch) { inlines.push({ type: 'text', value: italicMatch[2], italic: true, }); remaining = remaining.slice(italicMatch[0].length); continue; } // 7. 删除线 ~~text~~ const strikeMatch = remaining.match(/^~~(.+?)~~/); if (strikeMatch) { inlines.push({ type: 'text', value: strikeMatch[1], strike: true, }); remaining = remaining.slice(strikeMatch[0].length); continue; } // 8. 普通文本(直到遇到下一个特殊标记符号) const nextSpecialIndex = remaining.search(/[\$`\[*_~]/); if (nextSpecialIndex === -1) { inlines.push({ type: 'text', value: remaining }); break; } else if (nextSpecialIndex > 0) { inlines.push({ type: 'text', value: remaining.slice(0, nextSpecialIndex) }); remaining = remaining.slice(nextSpecialIndex); } else { // 当前第一个字符是未匹配成功的标记符号(如单个 * 或 $),作为普通文本吃掉 inlines.push({ type: 'text', value: remaining[0] }); remaining = remaining.slice(1); } } return inlines; }