feat(web): implement markdown block ast converters and auto-numbering engine

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 14:43:21 +08:00
co-authored by Cursor
parent 915b35232c
commit 4d9dfbb027
5 changed files with 481 additions and 1 deletions
+2 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "node --test"
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"lucide-react": "^1.16.0",
@@ -19,6 +19,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.0",
"tsx": "^4.23.13",
"typescript": "~5.9.3",
"vite": "^7.1.14"
}
+49
View File
@@ -0,0 +1,49 @@
import { DocumentBlock } from '../types/block';
/**
* recomputeNumbering: 动态单遍重算整个文档块树的章节多级编号及图表题注
* - 标题层级栈:1 -> 1.1 -> 1.1.1
* - 图表计数器:表 1-1、图 1-1
*/
export function recomputeNumbering(root: DocumentBlock): DocumentBlock {
const chapterStack: number[] = [0, 0, 0, 0, 0, 0];
let currentTopChapter = 1;
let tableCounter = 1;
let figureCounter = 1;
function traverse(block: DocumentBlock) {
if (block.type === 'heading') {
const level = Math.max(1, Math.min(6, block.level || 1));
chapterStack[level - 1] += 1;
for (let i = level; i < chapterStack.length; i++) {
chapterStack[i] = 0;
}
const numStr = chapterStack.slice(0, level).join('.');
block.numbering = numStr;
if (level === 1) {
currentTopChapter = chapterStack[0];
tableCounter = 1;
figureCounter = 1;
}
} else if (block.type === 'table') {
block.numbering = `表 ${currentTopChapter}-${tableCounter++}`;
} else if (block.type === 'mermaid') {
block.numbering = `图 ${currentTopChapter}-${figureCounter++}`;
}
if (block.children && block.children.length > 0) {
for (const child of block.children) {
traverse(child);
}
}
}
if (root.children) {
for (const child of root.children) {
traverse(child);
}
}
return root;
}
+85
View File
@@ -0,0 +1,85 @@
import { DocumentAST, DocumentBlock } from '../types/block';
/**
* blockASTToMarkdown: 将 Block AST 树无损还原为整洁的标准 Markdown 文本
*/
export function blockASTToMarkdown(ast: DocumentAST): string {
const chunks: string[] = [];
function serializeBlock(block: DocumentBlock) {
switch (block.type) {
case 'heading': {
const level = Math.max(1, Math.min(6, block.level || 1));
const prefix = '#'.repeat(level);
chunks.push(`${prefix} ${block.content}\n`);
break;
}
case 'paragraph': {
if (block.content) {
chunks.push(`${block.content}\n`);
}
break;
}
case 'divider': {
chunks.push('---\n');
break;
}
case 'callout': {
const typeTag = block.properties?.calloutType === 'regulatory' ? '合规依据' : '提示';
chunks.push(`> **[${typeTag}]** ${block.content}\n`);
break;
}
case 'mermaid': {
chunks.push('```mermaid\n' + block.content.trim() + '\n```\n');
break;
}
case 'math': {
chunks.push('$$\n' + block.content.trim() + '\n$$\n');
break;
}
case 'table': {
const data = block.properties?.tableData;
if (data && data.length > 0) {
const colCount = Math.max(...data.map((r) => r.length));
const lines: string[] = [];
// 表头行
const header = data[0];
const headerCells = Array.from({ length: colCount }, (_, idx) => header[idx] || '');
lines.push('| ' + headerCells.join(' | ') + ' |');
// 分隔线
const sep = Array.from({ length: colCount }, () => '---');
lines.push('| ' + sep.join(' | ') + ' |');
// 数据行
for (let r = 1; r < data.length; r++) {
const row = data[r];
const rowCells = Array.from({ length: colCount }, (_, idx) => row[idx] || '');
lines.push('| ' + rowCells.join(' | ') + ' |');
}
chunks.push(lines.join('\n') + '\n');
}
break;
}
case 'document': {
if (block.children) {
for (const child of block.children) {
serializeBlock(child);
}
}
break;
}
default: {
if (block.content) {
chunks.push(`${block.content}\n`);
}
break;
}
}
}
serializeBlock(ast.rootBlock);
return chunks.join('\n').trim() + '\n';
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { markdownToBlockAST } from './markdownToBlock';
import { blockASTToMarkdown } from './blockToMarkdown';
import { recomputeNumbering } from './autoNumber';
import { DocumentBlock } from '../types/block';
const SAMPLE_MARKDOWN = `# 4K超高清荧光内窥镜系统立项与可行性报告
本产品主要用于微创腹腔手术中的组织成像与荧光导航。遵循法规体系如下:
> **[合规依据]** 本项目需严格遵守 GB 9706.1-2020 与 YY 0569-2011 医用电气安全标准。
## 关键技术指标
| 参数项 | 指标要求 | 检验依据 |
| 光学分辨率 | 4K (3840x2160) | YY 0068.1 |
| 荧光波长 | 805nm 近红外 | 临床验证 |
### 电气安全与电磁兼容
设备需通过 GB 9706.1-2020 绝缘测试与漏电流测试。
\`\`\`mermaid
graph TD
A[主机] --> B[摄像视管]
B --> C[荧光滤光片]
\`\`\`
$$
E = mc^2
$$
---
`;
test('Markdown ➔ Block AST 解析测试', () => {
const ast = markdownToBlockAST(SAMPLE_MARKDOWN, {
docId: 'doc_feasibility_001',
taskKey: 'feasibility_reg',
});
assert.equal(ast.docId, 'doc_feasibility_001');
assert.equal(ast.rootBlock.children?.length, 10);
// 验证一级标题与自动编号
const h1 = ast.rootBlock.children?.[0] as DocumentBlock;
assert.equal(h1.type, 'heading');
assert.equal(h1.level, 1);
assert.equal(h1.numbering, '1');
assert.equal(h1.content, '4K超高清荧光内窥镜系统立项与可行性报告');
// 验证法规引证识别
const callout = ast.rootBlock.children?.[2] as DocumentBlock;
assert.equal(callout.type, 'callout');
assert.ok(callout.properties?.regulations?.includes('GB 9706.1-2020'));
// 验证二级标题编号
const h2 = ast.rootBlock.children?.[3] as DocumentBlock;
assert.equal(h2.type, 'heading');
assert.equal(h2.level, 2);
assert.equal(h2.numbering, '1.1');
// 验证表格与题注编号
const table = ast.rootBlock.children?.[4] as DocumentBlock;
assert.equal(table.type, 'table');
assert.equal(table.numbering, '表 1-1');
assert.equal(table.properties?.tableData?.length, 3); // 1 header + 2 rows
// 验证三级标题编号
const h3 = ast.rootBlock.children?.[5] as DocumentBlock;
assert.equal(h3.type, 'heading');
assert.equal(h3.level, 3);
assert.equal(h3.numbering, '1.1.1');
// 验证段落
const p2 = ast.rootBlock.children?.[6] as DocumentBlock;
assert.equal(p2.type, 'paragraph');
assert.ok(p2.properties?.regulations?.includes('GB 9706.1-2020'));
// 验证流程图编号
const mermaid = ast.rootBlock.children?.[7] as DocumentBlock;
assert.equal(mermaid.type, 'mermaid');
assert.equal(mermaid.numbering, '图 1-1');
});
test('动态重编号引擎测试 (Recompute Numbering)', () => {
const ast = markdownToBlockAST(SAMPLE_MARKDOWN);
const children = ast.rootBlock.children || [];
// 在开头插入一个新的一级标题
const newH1: DocumentBlock = {
id: 'new_h1',
type: 'heading',
level: 1,
content: '立项前言',
};
children.unshift(newH1);
recomputeNumbering(ast.rootBlock);
// 原先的 1 应该变为 2,1.1 变为 2.1
assert.equal(children[0].numbering, '1');
assert.equal(children[1].numbering, '2');
assert.equal(children[4].numbering, '2.1');
});
test('Block AST ➔ Markdown 双向无损往返还原测试', () => {
const ast = markdownToBlockAST(SAMPLE_MARKDOWN);
const outputMd = blockASTToMarkdown(ast);
assert.ok(outputMd.includes('# 4K超高清荧光内窥镜系统立项与可行性报告'));
assert.ok(outputMd.includes('| 参数项 | 指标要求 | 检验依据 |'));
assert.ok(outputMd.includes('```mermaid'));
assert.ok(outputMd.includes('$$'));
assert.ok(outputMd.includes('---'));
});
+228
View File
@@ -0,0 +1,228 @@
import { DocumentAST, DocumentBlock } from '../types/block';
import { recomputeNumbering } 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>
): DocumentAST {
const lines = md.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 titleText = headingMatch[2].trim();
blocks.push({
id: generateId(),
type: 'heading',
level,
content: titleText,
});
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. 常规段落 (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('|')) &&
!/^(\*{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',
lifecycleState: meta?.lifecycleState || 'drafting',
isClaimed: meta?.isClaimed ?? false,
author: meta?.author || '张工',
reviewer: meta?.reviewer || '李主管',
approver: meta?.approver || '王总监',
summary: meta?.summary || '由智能体小队自动生成的受控交付物',
updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16),
rootBlock,
};
}