import { DocumentAST, DocumentBlock } from '../types/block'; import { recomputeNumbering, cleanHeadingContent } from './autoNumber'; const REG_PATTERN = /(?:GB(?:\/T)?\s*\d+(?:\.\d+)?(?:-\d{4})?|YY(?:\/T)?\s*\d+(?:\.\d+)?(?:-\d{4})?|ISO\s*\d+|IEC\s*\d+|MDR|FDA|NMPA)/g; function generateId(): string { return 'blk_' + Math.random().toString(36).substring(2, 10); } /** * markdownToBlockAST: 将 Markdown 文本解析为 Block AST 树 */ export function markdownToBlockAST( md: string, meta?: Partial ): DocumentAST { const safeMd = typeof md === 'string' ? md : ''; const lines = safeMd.split(/\r?\n/); const blocks: DocumentBlock[] = []; let i = 0; while (i < lines.length) { const rawLine = lines[i]; const trimmed = rawLine.trim(); // 空行跳过 if (trimmed === '') { i++; continue; } // 1. 分隔线 (Divider) if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) { blocks.push({ id: generateId(), type: 'divider', content: '', }); i++; continue; } // 2. 标题 (Heading # ~ ######) const headingMatch = rawLine.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { const level = headingMatch[1].length; const rawTitle = headingMatch[2].trim(); const cleanedTitle = cleanHeadingContent(rawTitle); blocks.push({ id: generateId(), type: 'heading', level, content: cleanedTitle || rawTitle, }); i++; continue; } // 3. 代码围栏 (Code Block: ```mermaid / ```math / ```) if (trimmed.startsWith('```')) { const lang = trimmed.slice(3).trim().toLowerCase(); const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].trim().startsWith('```')) { codeLines.push(lines[i]); i++; } if (i < lines.length && lines[i].trim().startsWith('```')) { i++; // 跳过结束 fence } const codeContent = codeLines.join('\n'); if (lang === 'mermaid') { blocks.push({ id: generateId(), type: 'mermaid', content: codeContent, properties: { caption: '系统拓扑与流转时序图', }, }); } else if (lang === 'math' || lang === 'latex') { blocks.push({ id: generateId(), type: 'math', content: codeContent, }); } else { // 普通段落代码块 blocks.push({ id: generateId(), type: 'paragraph', content: '```' + lang + '\n' + codeContent + '\n```', }); } continue; } // 4. 数学公式 ($$ ... $$) 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 && lines[i].trim().endsWith('$$')) { const last = lines[i].trim(); if (last !== '$$') { mathLines.push(last.replace(/\$\$$/, '').trim()); } i++; } } blocks.push({ id: generateId(), type: 'math', content: mathLines.join('\n'), }); continue; } // 5. 引用/合规警示块 (> ...) if (trimmed.startsWith('>')) { const calloutLines: string[] = []; while (i < lines.length && lines[i].trim().startsWith('>')) { calloutLines.push(lines[i].trim().replace(/^>\s?/, '')); i++; } const calloutText = calloutLines.join(' '); const regs = Array.from(calloutText.matchAll(REG_PATTERN), (m) => m[0]); blocks.push({ id: generateId(), type: 'callout', content: calloutText, properties: { calloutType: regs.length > 0 ? 'regulatory' : 'info', regulations: regs, }, }); continue; } // 6. GFM 表格 (| col1 | col2 |) if (trimmed.startsWith('|') && trimmed.endsWith('|')) { const tableRows: string[][] = []; while (i < lines.length && lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) { const curRow = lines[i].trim(); // 忽略表头分割线行 |---|---| if (!/^\|(?:\s*:?-+:?\s*\|)+$/.test(curRow)) { const cells = curRow .slice(1, -1) .split('|') .map((c) => c.trim()); tableRows.push(cells); } i++; } blocks.push({ id: generateId(), type: 'table', content: '', properties: { tableData: tableRows, caption: '技术参数与法规符合性对照表', }, }); continue; } // 7. 纯独立列表块 (List Block: 全部非空行均为列表语法) if (/^\s*(\d+\.|[-*+])\s+/.test(trimmed)) { const listLines: string[] = []; const isOrdered = /^\s*\d+\.\s+/.test(trimmed); let startNumber = 1; if (isOrdered) { const m = trimmed.match(/^\s*(\d+)\./); if (m) startNumber = parseInt(m[1], 10) || 1; } while ( i < lines.length && lines[i].trim() !== '' && /^\s*(\d+\.|[-*+])\s+/.test(lines[i].trim()) ) { listLines.push(lines[i]); i++; } const listItems = listLines .map((l) => l.replace(/^\s*(\d+\.|[-*+])\s+/, '').trim()) .filter(Boolean); blocks.push({ id: generateId(), type: 'list', content: listLines.join('\n'), properties: { isOrdered, startNumber, listItems, }, }); continue; } // 8. 常规段落 (Paragraph) const paraLines: string[] = []; while ( i < lines.length && lines[i].trim() !== '' && !lines[i].trim().startsWith('#') && !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('$$') && !lines[i].trim().startsWith('>') && !(lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) && !/^\s*(\d+\.|[-*+])\s+/.test(lines[i].trim()) && !/^(\*{3,}|-{3,}|_{3,})$/.test(lines[i].trim()) ) { paraLines.push(lines[i]); i++; } const paraContent = paraLines.join('\n'); const regs = Array.from(paraContent.matchAll(REG_PATTERN), (m) => m[0]); blocks.push({ id: generateId(), type: 'paragraph', content: paraContent, properties: { regulations: regs.length > 0 ? regs : undefined, }, }); } const rootBlock: DocumentBlock = { id: 'root-doc', type: 'document', content: '', children: blocks, }; // 执行自动重编号 recomputeNumbering(rootBlock); return { docId: meta?.docId || 'doc_' + Math.random().toString(36).substring(2, 8), title: meta?.title || (blocks[0]?.type === 'heading' ? blocks[0].content : '未命名受控文档'), taskKey: meta?.taskKey || 'feasibility_reg', version: meta?.version || 'V1.0', category: meta?.category || 'workflow', creatorId: meta?.creatorId, creatorName: meta?.creatorName, sharedAt: meta?.sharedAt, lifecycleState: meta?.lifecycleState || 'drafting', isClaimed: meta?.isClaimed ?? false, author: meta?.author || '', authorId: meta?.authorId, reviewer: meta?.reviewer || '', reviewerId: meta?.reviewerId, approver: meta?.approver || '', approverId: meta?.approverId, dutiesAuthoritative: meta?.dutiesAuthoritative, dutyPolicy: meta?.dutyPolicy, revisionEtag: meta?.revisionEtag, summary: meta?.summary || '由项目助手自动生成的受控交付物', updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16), rootBlock, }; }