From 76747298049054358a6793bbbd2bcb6d14d39f1f Mon Sep 17 00:00:00 2001
From: QihuAI Design
Date: Mon, 20 Jul 2026 20:32:36 +0800
Subject: [PATCH] test(frontend): extract Markdown renderer to
utils/markdown.ts + add vitest regression tests
---
frontend/src/pages/front/ChatPage.tsx | 193 ++----------------------
frontend/src/utils/markdown.test.ts | 80 ++++++++++
frontend/src/utils/markdown.ts | 205 ++++++++++++++++++++++++++
3 files changed, 294 insertions(+), 184 deletions(-)
create mode 100644 frontend/src/utils/markdown.test.ts
create mode 100644 frontend/src/utils/markdown.ts
diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx
index edc3b06..bacbd3b 100644
--- a/frontend/src/pages/front/ChatPage.tsx
+++ b/frontend/src/pages/front/ChatPage.tsx
@@ -6,9 +6,7 @@ import type { Conversation, Message } from '@/types';
import './ChatPage.css';
// ===== 增强渲染依赖 =====
-import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
-import katex from 'katex';
import 'katex/dist/katex.min.css';
import mermaid from 'mermaid';
@@ -16,7 +14,7 @@ mermaid.initialize({ startOnLoad: false, theme: 'default' });
// ===== Enhanced Markdown Renderer =====
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
-interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
+import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown';
// 全屏灯箱预览
const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
@@ -154,182 +152,17 @@ const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
+ // 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts,
+ // 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。
+ const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]);
+
const nodes = useMemo(() => {
- let text = content;
-
- // 1. Extract code blocks first (protect from other replacements)
- const codeBlocks: string[] = [];
- const mermaidBlocks: string[] = [];
- text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
- if (_lang === 'mermaid') {
- const idx = mermaidBlocks.length;
- mermaidBlocks.push('');
- return '\x00MERMAID' + idx + '\x00';
- }
- const idx = codeBlocks.length;
- codeBlocks.push('' + escapeHtml(code.trim()) + '
');
- return '\x00CODEBLOCK' + idx + '\x00';
- });
-
- // 2. Extract inline code
- const inlineCodes: string[] = [];
- text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
- const idx = inlineCodes.length;
- inlineCodes.push('' + escapeHtml(code) + '');
- return '\x00INLINE' + idx + '\x00';
- });
-
- // 2.5 Extract media blocks (
/
');
- text = text.replace(/\n/g, ' ');
-
- // 10. Restore code blocks and inline codes
- text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MERMAID(\d+)\x00/g, (_m: string, idx: string) => mermaidBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
- text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
-
- // 10.5 Apply syntax highlighting to code blocks + add copy button
- text = text.replace(
- /
([\s\S]*?)<\/code><\/pre>/g,
- (_m: string, code: string) => {
- const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
- let highlighted: string;
- try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
- return ``;
- }
- );
-
- // 11. Wrap in paragraph and clean up
- text = '' + text + '
';
- text = text.replace(/<\/p>/g, '');
- text = text.replace(/
()<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
-
- // 12. 拆分为文本片段与媒体组件
- const parts = text.split(/(\x00MEDIA\d+\x00)/g);
+ // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA\x00 占位符保留在 html 中)
+ const parts = html.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) {
- const info = mediaBlocks[parseInt(m[1])];
+ const info = media[parseInt(m[1])];
if (info && info.src) return ;
return null;
}
@@ -339,7 +172,7 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
if (!s.trim()) return null;
return ;
});
- }, [content]);
+ }, [html, media]);
const containerRef = useRef(null);
useEffect(() => {
@@ -397,14 +230,6 @@ const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose
};
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
// ===== Time Formatting =====
function formatTime(iso: string): string {
return formatChatTime(iso);
diff --git a/frontend/src/utils/markdown.test.ts b/frontend/src/utils/markdown.test.ts
new file mode 100644
index 0000000..1c9d42e
--- /dev/null
+++ b/frontend/src/utils/markdown.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest';
+import { renderMarkdownToHtml } from './markdown';
+
+// 前台聊天 Markdown 渲染曾两次被 bug 击穿:
+// 1) 段落内单个 \n 被转成
,导致 AI 回复被强制大量换行、内容撑成多行;
+// 2) 表格在加粗/链接转换之前被提取,单元格内 **xxx** 原样显示、且
未压缩。
+// 本测试固化「渲染管线」契约,防止回归。管线代码见 ./markdown.ts(从 ChatPage 抽出)。
+describe('renderMarkdownToHtml', () => {
+ it('段落内单个 \\n 转为空格(而非
)', () => {
+ const { html } = renderMarkdownToHtml('第一行\n第二行');
+ expect(html).not.toContain('
{
+ const { html } = renderMarkdownToHtml('段一\n\n段二');
+ expect(html).toContain('段一段二');
+ });
+
+ it('3+ 连续空行压缩为 2 个(与 2 个空行结果一致)', () => {
+ const many = renderMarkdownToHtml('a\n\n\n\n\nb').html;
+ const few = renderMarkdownToHtml('a\n\nb').html;
+ expect(many).toBe(few);
+ expect(many).toContain('a
b');
+ });
+
+ it('表格在加粗之后提取,单元格内的 **xxx** 被渲染为 ', () => {
+ const input = '| 能力 | 说明 |\n| --- | --- |\n| **渠道排序** | 按优先级 |\n';
+ const { html } = renderMarkdownToHtml(input);
+ expect(html).toContain('');
+ expect(html).toContain('渠道排序');
+ });
+
+ it('单元格内连续 被压缩为单个', () => {
+ const input = '| 列 |\n| --- |\n| a
b |\n';
+ const { html } = renderMarkdownToHtml(input);
+ expect(html).toContain('a b');
+ expect(html).not.toContain('
');
+ });
+
+ it('无序列表渲染为 - ', () => {
+ const { html } = renderMarkdownToHtml('- a\n- b');
+ expect(html).toContain('
');
+ expect(html).toContain('- a
');
+ expect(html).toContain('- b
');
+ });
+
+ it('一级标题渲染为 h2', () => {
+ const { html } = renderMarkdownToHtml('# Title');
+ expect(html).toContain('Title');
+ });
+
+ it('代码块内容不被行内加粗转换', () => {
+ const { html } = renderMarkdownToHtml('```\n**not bold**\n```');
+ expect(html).toContain(' ');
+ expect(html).not.toContain('not bold');
+ });
+
+ it('行内代码被抽取保护', () => {
+ const { html } = renderMarkdownToHtml('use `npm run` here');
+ expect(html).toContain('npm run');
+ });
+
+ it('[文本](url) 链接渲染', () => {
+ const { html } = renderMarkdownToHtml('[Google](https://g.com)');
+ expect(html).toContain(')以占位符保留并返回结构化 media 列表', () => {
+ const { html, media } = renderMarkdownToHtml('看这张图 ');
+ expect(media.length).toBe(1);
+ expect(media[0].type).toBe('img');
+ expect(media[0].src).toBe('https://x.com/a.png');
+ expect(media[0].alt).toBe('图');
+ expect(html).toContain('\x00MEDIA0\x00');
+ });
+});
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
new file mode 100644
index 0000000..c7105cd
--- /dev/null
+++ b/frontend/src/utils/markdown.ts
@@ -0,0 +1,205 @@
+import katex from 'katex';
+import hljs from 'highlight.js';
+
+export interface MediaInfo {
+ type: 'img' | 'video';
+ src: string;
+ alt?: string;
+}
+
+export interface RenderResult {
+ html: string;
+ media: MediaInfo[];
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+/**
+ * 将 Markdown 文本转为带占位符的 HTML 字符串。
+ *
+ * 设计契约(曾被线上 bug 击穿、用单测固化,见 markdown.test.ts):
+ * - 段落内【单个 \n 转为空格】而非 ,避免 AI 回复被强制大量换行;
+ * - 【3+ 连续空行压缩为 2 个】(/\n{3,}/ -> \n\n),避免多余空白把内容撑成多行;
+ * - 【表格在加粗/链接转换之后提取】,单元格内的 **xxx** / [文本](url) 才能被渲染;
+ * 且单元格内连续 被压缩为单个(normalizeCell)。
+ *
+ * 媒体( /
|