fix: 优化markdown解析器

This commit is contained in:
imeepos
2025-07-22 15:04:37 +08:00
parent 31d97834dc
commit f92d9a7c39
24 changed files with 5702 additions and 703 deletions

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { markdownService } from '../services/markdownService';
import {
MarkdownParseResult,
MarkdownNode,
OutlineItem,
LinkInfo,
ValidationResult,
MarkdownParserConfig,
ValidationSeverity,
MarkdownNodeType,
} from '../types/markdown';
/**
* Markdown解析渲染器属性
*/
interface MarkdownParserRendererProps {
/** Markdown文本内容 */
content: string;
/** 解析器配置 */
config?: MarkdownParserConfig;
/** 是否显示大纲 */
showOutline?: boolean;
/** 是否显示链接列表 */
showLinks?: boolean;
/** 是否显示验证结果 */
showValidation?: boolean;
/** 是否显示位置信息 */
showPositionInfo?: boolean;
/** 是否启用实时解析 */
enableRealTimeParsing?: boolean;
/** 自定义样式类名 */
className?: string;
/** 点击节点时的回调 */
onNodeClick?: (node: MarkdownNode) => void;
/** 解析完成时的回调 */
onParseComplete?: (result: MarkdownParseResult) => void;
}
/**
* 基于Tree-sitter的Markdown解析渲染器组件
* 支持原文引用、位置信息显示和交互式节点选择
*/
export const MarkdownParserRenderer: React.FC<MarkdownParserRendererProps> = ({
content,
config,
showOutline = false,
showLinks = false,
showValidation = false,
showPositionInfo = false,
enableRealTimeParsing = false,
className = '',
onNodeClick,
onParseComplete,
}) => {
const [parseResult, setParseResult] = useState<MarkdownParseResult | null>(null);
const [outline, setOutline] = useState<OutlineItem[]>([]);
const [links, setLinks] = useState<LinkInfo[]>([]);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<MarkdownNode | null>(null);
/**
* 解析Markdown内容
*/
const parseContent = useCallback(async () => {
if (!content.trim()) {
setParseResult(null);
setOutline([]);
setLinks([]);
setValidation(null);
return;
}
setIsLoading(true);
setError(null);
try {
// 解析主要内容
const result = await markdownService.parseMarkdown(content, config);
setParseResult(result);
onParseComplete?.(result);
// 提取大纲
if (showOutline) {
const outlineItems = await markdownService.extractOutline(content);
setOutline(outlineItems);
}
// 提取链接
if (showLinks) {
const linkItems = await markdownService.extractLinks(content);
setLinks(linkItems);
}
// 验证文档
if (showValidation) {
const validationResult = await markdownService.validateMarkdown(content);
setValidation(validationResult);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally {
setIsLoading(false);
}
}, [content, config, showOutline, showLinks, showValidation, onParseComplete]);
/**
* 实时解析效果
*/
useEffect(() => {
if (enableRealTimeParsing) {
const timeoutId = setTimeout(parseContent, 300); // 防抖
return () => clearTimeout(timeoutId);
}
}, [content, enableRealTimeParsing, parseContent]);
/**
* 初始解析
*/
useEffect(() => {
if (!enableRealTimeParsing) {
parseContent();
}
}, [parseContent, enableRealTimeParsing]);
/**
* 渲染单个节点
*/
const renderNode = useCallback((node: MarkdownNode, depth: number = 0): React.ReactNode => {
const handleNodeClick = () => {
setSelectedNode(node);
onNodeClick?.(node);
};
const isSelected = selectedNode === node;
const nodeClasses = `
markdown-node
markdown-node-${node.node_type.toLowerCase()}
${isSelected ? 'selected' : ''}
${showPositionInfo ? 'with-position' : ''}
cursor-pointer hover:bg-gray-100 transition-colors
`.trim();
const positionInfo = showPositionInfo ? (
<span className="position-info text-xs text-gray-500 ml-2">
[{markdownService.formatRange(node.range.start, node.range.end)}]
</span>
) : null;
switch (node.node_type) {
case MarkdownNodeType.Heading:
const level = parseInt(node.attributes.level || '1');
const HeadingTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements;
return (
<HeadingTag
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
{markdownService.extractTextContent(node)}
{positionInfo}
</HeadingTag>
);
case MarkdownNodeType.Paragraph:
return (
<p
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</p>
);
case MarkdownNodeType.CodeBlock:
const language = node.attributes.language || 'text';
return (
<pre
key={`${node.range.start.line}-${node.range.start.column}`}
className={`${nodeClasses} bg-gray-100 p-4 rounded overflow-x-auto`}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
<code className={`language-${language}`}>
{markdownService.extractTextContent(node)}
</code>
{positionInfo}
</pre>
);
case MarkdownNodeType.Link:
const url = node.attributes.url || '#';
const title = node.attributes.title;
return (
<a
key={`${node.range.start.line}-${node.range.start.column}`}
href={url}
title={title}
className={`${nodeClasses} text-blue-600 hover:text-blue-800 underline`}
onClick={(e) => {
e.preventDefault();
handleNodeClick();
}}
style={{ marginLeft: `${depth * 20}px` }}
>
{markdownService.extractTextContent(node)}
{positionInfo}
</a>
);
case MarkdownNodeType.Image:
const src = node.attributes.src || '';
const alt = node.attributes.alt || '';
return (
<img
key={`${node.range.start.line}-${node.range.start.column}`}
src={src}
alt={alt}
className={`${nodeClasses} max-w-full h-auto`}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
/>
);
case MarkdownNodeType.List:
const ListTag = node.content.trim().startsWith('1.') ? 'ol' : 'ul';
return (
<ListTag
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</ListTag>
);
case MarkdownNodeType.ListItem:
return (
<li
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</li>
);
case MarkdownNodeType.Emphasis:
return (
<em
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</em>
);
case MarkdownNodeType.Strong:
return (
<strong
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</strong>
);
case MarkdownNodeType.Text:
return (
<span
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
>
{node.content}
{positionInfo}
</span>
);
default:
return (
<div
key={`${node.range.start.line}-${node.range.start.column}`}
className={nodeClasses}
onClick={handleNodeClick}
style={{ marginLeft: `${depth * 20}px` }}
>
{node.children.map((child, index) => renderNode(child, depth + 1))}
{positionInfo}
</div>
);
}
}, [selectedNode, showPositionInfo, onNodeClick]);
/**
* 渲染大纲
*/
const renderOutline = useMemo(() => {
if (!showOutline || outline.length === 0) return null;
return (
<div className="markdown-outline mb-4 p-4 bg-gray-50 rounded">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="space-y-1">
{outline.map((item, index) => (
<li
key={index}
className="cursor-pointer hover:text-blue-600"
style={{ marginLeft: `${(item.level - 1) * 16}px` }}
onClick={() => {
// 可以添加滚动到对应位置的逻辑
console.log('Navigate to:', item);
}}
>
<span className="text-sm text-gray-500">H{item.level}</span>
<span className="ml-2">{item.title}</span>
{showPositionInfo && (
<span className="text-xs text-gray-400 ml-2">
[Line {item.range.start.line + 1}]
</span>
)}
</li>
))}
</ul>
</div>
);
}, [showOutline, outline, showPositionInfo]);
/**
* 渲染链接列表
*/
const renderLinks = useMemo(() => {
if (!showLinks || links.length === 0) return null;
return (
<div className="markdown-links mb-4 p-4 bg-blue-50 rounded">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="space-y-2">
{links.map((link, index) => (
<li key={index} className="flex items-center space-x-2">
<span className="text-sm text-gray-500">
{link.link_type === 'Image' ? '🖼️' : '🔗'}
</span>
<a
href={link.url}
className="text-blue-600 hover:text-blue-800 underline"
target="_blank"
rel="noopener noreferrer"
>
{link.text || link.url}
</a>
{showPositionInfo && (
<span className="text-xs text-gray-400">
[Line {link.range.start.line + 1}]
</span>
)}
</li>
))}
</ul>
</div>
);
}, [showLinks, links, showPositionInfo]);
/**
* 渲染验证结果
*/
const renderValidation = useMemo(() => {
if (!showValidation || !validation) return null;
const { is_valid, issues } = validation;
return (
<div className={`markdown-validation mb-4 p-4 rounded ${
is_valid ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
}`}>
<h3 className="text-lg font-semibold mb-2">
{is_valid ? '✅' : '❌'}
</h3>
{issues.length > 0 && (
<ul className="space-y-2">
{issues.map((issue, index) => (
<li
key={index}
className={`flex items-start space-x-2 ${
issue.severity === ValidationSeverity.Error ? 'text-red-600' :
issue.severity === ValidationSeverity.Warning ? 'text-yellow-600' :
'text-blue-600'
}`}
>
<span className="text-sm">
{issue.severity === ValidationSeverity.Error ? '❌' :
issue.severity === ValidationSeverity.Warning ? '⚠️' : ''}
</span>
<div>
<div>{issue.message}</div>
{showPositionInfo && (
<div className="text-xs text-gray-500">
Line {issue.range.start.line + 1}
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}, [showValidation, validation, showPositionInfo]);
if (isLoading) {
return (
<div className={`markdown-parser-renderer ${className}`}>
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2">...</span>
</div>
</div>
);
}
if (error) {
return (
<div className={`markdown-parser-renderer ${className}`}>
<div className="bg-red-50 border border-red-200 rounded p-4">
<h3 className="text-red-800 font-semibold"></h3>
<p className="text-red-600 mt-2">{error}</p>
<button
onClick={parseContent}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
</button>
</div>
</div>
);
}
return (
<div className={`markdown-parser-renderer ${className}`}>
{renderOutline}
{renderLinks}
{renderValidation}
<div className="markdown-content">
{parseResult ? (
<div className="parsed-content">
{renderNode(parseResult.root)}
</div>
) : (
<div className="text-gray-500 text-center p-8">
</div>
)}
</div>
{selectedNode && (
<div className="selected-node-info mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
<h4 className="font-semibold"></h4>
<div className="mt-2 text-sm">
<div><strong>:</strong> {selectedNode.node_type}</div>
<div><strong>:</strong> {markdownService.formatRange(selectedNode.range.start, selectedNode.range.end)}</div>
<div><strong>:</strong> {selectedNode.content.length} </div>
{Object.keys(selectedNode.attributes).length > 0 && (
<div><strong>:</strong> {JSON.stringify(selectedNode.attributes, null, 2)}</div>
)}
</div>
</div>
)}
{parseResult && (
<div className="parse-statistics mt-4 p-4 bg-gray-50 rounded text-sm">
<h4 className="font-semibold mb-2"></h4>
<div className="grid grid-cols-2 gap-2">
<div>: {parseResult.statistics.total_nodes}</div>
<div>: {parseResult.statistics.error_nodes}</div>
<div>: {parseResult.statistics.parse_time_ms}ms</div>
<div>: {parseResult.statistics.max_depth}</div>
</div>
</div>
)}
</div>
);
};
export default MarkdownParserRenderer;