以前主要写后端,这种改了多次都不满意,有没有大佬指点一下。
// src/utils/markdown.ts
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
// 配置 marked (使用现代 API)
marked.setOptions({
highlight: function(code: string, lang: string) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
try {
return hljs.highlight(code, { language }).value;
} catch (e) {
return code;
}
},
langPrefix: 'hljs language-',
breaks: false, // 使用标准 Markdown 换行规则(双换行符 = 段落)
gfm: true // GitHub 风格 Markdown
} as any);
/**
* 智能处理段落分隔和标题格式
* 1. 确保标题前有空行(Markdown 标准要求)
* 2. 将中文标点后的单换行符转换为双换行符
*/
function normalizeParagraphBreaks(text: string): string {
if (!text || text.length < 20) {
return text;
}
let result = text;
// 关键修复1:处理分割线后紧跟标题的情况(如 `---###附:`)
// 将 `---` 或 `---\n###` 转换为 `---\n\n###`,确保标题前有空行
result = result.replace(/(---)\s*\n?\s*(#{1,6}\s+)/g, '$1\n\n$2');
result = result.replace(/(---)(#{1,6}\s+)/g, '$1\n\n$2');
// 关键修复2:确保标题前有空行
// 匹配行首的标题(### 或 ## 或 #),如果前面不是空行,则添加空行
result = result.replace(/([^\n])\n(#{1,6}\s+)/g, '$1\n\n$2');
// 关键修复3:处理行内标题(如文本后直接跟标题,没有换行)
// 将 `文本###标题` 转换为 `文本\n\n###标题`
result = result.replace(/([^\n#])(#{1,6}\s+)/g, '$1\n\n$2');
// 策略1:将中文标点(。!?)后的单换行符转换为双换行符
result = result.replace(/([。!?])\n(?!\n)/g, '$1\n\n');
// 策略2:如果文本没有双换行符,但在中文标点后有其他字符,添加双换行符
if (!result.includes('\n\n')) {
// 在中文标点后添加双换行符(但避免在连续标点后添加)
result = result.replace(/([。!?])([^\n。!?\s])/g, (match, punc, next) => {
// 如果下一个字符是标点或空格,不添加换行
if (/[。!?\s]/.test(next)) {
return match;
}
return punc + '\n\n' + next;
});
}
// 清理多余的换行符(超过2个的)
result = result.replace(/\n{3,}/g, '\n\n');
// 清理首尾空白(但保留开头的标题)
return result.trim();
}
/**
* 渲染 Markdown 为 HTML
*/
export const renderMarkdown = (text: string): string => {
if (!text) return '';
try {
// 预处理:规范化段落分隔和标题格式
let processedText = normalizeParagraphBreaks(text);
// 调试:检查标题格式(开发环境)
if (import.meta.env.DEV) {
const headingMatches = processedText.match(/^#{1,6}\s+.+$/gm);
if (headingMatches && headingMatches.length > 0) {
console.log('检测到标题:', headingMatches.slice(0, 5));
}
}
// 使用 marked 同步解析
const html = marked.parse(processedText) as string;
// 调试:检查渲染结果(开发环境)
if (import.meta.env.DEV && html) {
const hasH3 = html.includes('<h3>');
const hasH2 = html.includes('<h2>');
const hasH1 = html.includes('<h1>');
if (hasH1 || hasH2 || hasH3) {
console.log('Markdown 渲染包含标题:', { hasH1, hasH2, hasH3 });
}
}
return html;
} catch (error: unknown) {
console.error('Markdown 渲染错误:', error);
// 降级方案:基本 HTML 转义和换行处理
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n\n+/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^(.*)$/, '<p>$1</p>');
}
};
/**
* 渲染 Markdown(流式友好版):增强预处理,确保流式输出时 Markdown 能正确显示
* 策略:规范化标题、列表、表格格式,确保即使是不完整的片段也能被正确识别
*/
export const renderMarkdownStreaming = (text: string): string => {
if (!text) return '';
try {
let processedText = text;
// 1. 规范化标题格式:确保标题前有空行,标题后可以有空格或直接跟文字
// 处理行首的标题(### 或 ## 或 #),如果前面不是空行,则添加空行
processedText = processedText.replace(/([^\n])\n(#{1,6}\s*)/g, '$1\n\n$2');
// 处理分割线后紧跟标题的情况(如 `---####1.`)
processedText = processedText.replace(/(---)\s*\n?\s*(#{1,6}\s*)/g, '$1\n\n$2');
// 处理行内标题(如文本后直接跟标题,没有换行)
processedText = processedText.replace(/([^\n#\s])(#{1,6}\s*)/g, '$1\n\n$2');
// 关键:处理标题后没有空格的情况(如 `###用第一性原理`)
// 在标题标记和文字之间添加空格,确保 Markdown 能识别
// 只处理标题后直接跟非空白字符的情况,避免重复处理
processedText = processedText.replace(/(#{1,6})([^\s\n#])/g, '$1 $2');
// 2. 规范化列表格式:确保列表项前有空行(如果前面不是列表)
// 处理无序列表:`-` 或 `*` 开头,后面没有空格时添加空格
// 先处理行首的列表项(前面可能有空行)
processedText = processedText.replace(/(\n|^)([-*])([^\s\n-*])/g, '$1$2 $3');
// 再确保列表项前有空行(如果前面不是列表)
processedText = processedText.replace(/([^\n])\n([-*]\s+)/g, '$1\n\n$2');
// 处理有序列表:数字开头,后面没有空格时添加空格
processedText = processedText.replace(/(\n|^)(\d+\.)([^\s\n\d])/g, '$1$2 $3');
processedText = processedText.replace(/([^\n])\n(\d+\.\s+)/g, '$1\n\n$2');
// 3. 规范化表格格式:确保表格前有空行
processedText = processedText.replace(/([^\n])\n(\|.*\|)/g, (match, before, tableLine) => {
// 如果下一行是表格行(包含 |),在前面添加空行
if (tableLine.includes('|')) {
return before + '\n\n' + tableLine;
}
return match;
});
// 4. 规范化引用块格式:确保引用块前有空行
processedText = processedText.replace(/([^\n])\n(>\s*)/g, '$1\n\n$2');
// 5. 规范化代码块格式:确保代码块前有空行
processedText = processedText.replace(/([^\n])\n(```)/g, '$1\n\n$2');
// 使用 marked 解析,临时启用 breaks 让单换行也能显示(流式友好)
// 注意:marked 的配置是全局的,所以这里需要临时修改然后恢复
const originalBreaks = (marked as any).defaults?.breaks ?? false;
marked.setOptions({ breaks: true, gfm: true } as any);
const html = marked.parse(processedText) as string;
marked.setOptions({ breaks: originalBreaks, gfm: true } as any);
return html;
} catch (error: unknown) {
console.error('Markdown 流式渲染错误:', error);
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n\n+/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^(.*)$/, '<p>$1</p>');
}
};





