feat(web): implement markdown block ast converters and auto-numbering engine
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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('---'));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user