Browse Source

chatsdk 对markdown解析器进行重构

TDesign-Vue-Next-1.20.6
wanghanlin 2 weeks ago
parent
commit
0953e9481b
  1. 69
      client/assets/marked.min.js
  2. 5700
      client/dist/chatbot-sdk.js
  3. 1
      client/dist/chatbot-sdk.min.js
  4. 1
      client/rollup.config.js
  5. 5
      client/src/index.ts
  6. 478
      client/src/markdown.ts
  7. 6
      client/src/styles.ts
  8. 200
      client/tests/markdown.test.ts
  9. 5700
      src/main/resources/static/sdk/chatbot-sdk.js
  10. 1
      src/main/resources/static/sdk/chatbot-sdk.min.js

69
client/assets/marked.min.js
File diff suppressed because it is too large
View File

5700
client/dist/chatbot-sdk.js
File diff suppressed because it is too large
View File

1
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

1
client/rollup.config.js

@ -9,6 +9,7 @@ const BACKEND_DIR = resolve(ROOT, '../src/main/resources/static/sdk');
const ASSETS = [ const ASSETS = [
{ src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' }, { src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' },
{ src: resolve(ROOT, 'assets/marked.min.js'), name: 'marked.min.js' },
]; ];
const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js']; const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js'];

5
client/src/index.ts

@ -12,6 +12,7 @@ import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger'; import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api'; import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api';
import { injectStyles, removeStyles } from './styles'; import { injectStyles, removeStyles } from './styles';
import { preloadMarked, resetMarked } from './markdown';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom'; import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat'; import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage'; import { clearMessages } from './storage';
@ -93,6 +94,9 @@ function init(rawConfig: SDKConfig): void {
// 5. 注入样式 // 5. 注入样式
injectStyles(config); injectStyles(config);
// 5.1 预加载 marked(外部 Markdown 渲染库,异步不阻塞 UI)
preloadMarked();
// 6. 创建悬浮按钮 // 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle); launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl); document.body.appendChild(launcherEl);
@ -417,6 +421,7 @@ function destroy(): void {
audioCtx = null; audioCtx = null;
disclaimerEl = null; disclaimerEl = null;
clearApiConfig(); clearApiConfig();
resetMarked(); // 重置 marked 加载状态,允许下次 init 重试
logger.lifecycleDestroy(oldIntegrateId || ''); logger.lifecycleDestroy(oldIntegrateId || '');
} }

478
client/src/markdown.ts

@ -1,290 +1,240 @@
/** /**
* Markdown - XSS
* Markdown - marked
* *
*
* HTML Markdown HTML
* marked v18 SDK 宿
*
*
* XSS
* 1. Renderer / SDK
* 2. / URL http/https
* 3. raw HTML
* 4. 使 escapeHtml
*/ */
import { escapeHtml } from './utils'; import { escapeHtml } from './utils';
/** 代码块占位符前缀 */
const CODE_BLOCK_PREFIX = '\x00CODEBLOCK_';
/** marked 是否已加载并完成配置 */
let markedReady = false;
/** 行内代码占位符前缀 */
const INLINE_CODE_PREFIX = '\x00INLINECODE_';
/** 加载 Promise,用于等待就绪 */
let markedLoadPromise: Promise<boolean> | null = null;
/**
* Markdown HTML
* @param text Markdown
* @returns HTML
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */
let _markedInstance: any = null;
// 1. 提取代码块(防止内部 Markdown 被处理)
const codeBlocks: string[] = [];
let processed = text;
// 提取围栏代码块 ```
processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length;
const escapedCode = escapeHtml(code.trimEnd());
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
codeBlocks.push(`<pre class="csk-md-code-block"><code${langClass}>${escapedCode}</code></pre>`);
return `${CODE_BLOCK_PREFIX}${idx}\x00`;
});
// 2. 提取行内代码
const inlineCodes: string[] = [];
processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
const idx = inlineCodes.length;
inlineCodes.push(`<code class="csk-md-inline-code">${escapeHtml(code)}</code>`);
return `${INLINE_CODE_PREFIX}${idx}\x00`;
});
// 3. 转义剩余 HTML(代码块和行内代码已安全处理)
processed = escapeHtml(processed);
// 4. 还原代码块和行内代码占位符(它们已经是安全 HTML)
processed = restorePlaceholders(processed, CODE_BLOCK_PREFIX, codeBlocks);
processed = restorePlaceholders(processed, INLINE_CODE_PREFIX, inlineCodes);
// 5. 逐行处理 Markdown 语法
const lines = processed.split('\n');
const result: string[] = [];
let inList = false;
let listType = ''; // 'ul' 或 'ol'
let inBlockquote = false;
let paragraphBuffer: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 代码块已在占位符还原阶段处理,直接输出
if (line.includes(CODE_BLOCK_PREFIX) || line.includes('<pre class="csk-md-code-block">')) {
flushParagraph();
closeList();
closeBlockquote();
result.push(line);
continue;
}
// 标题
// Accept standard headings and common no-space forms before CJK text or digits.
const headingMatch = line.match(/^ {0,3}(#{1,6})(?:\s+|(?=[\u3400-\u9FFF\d]))(.+)/);
if (headingMatch) {
flushParagraph();
closeList();
closeBlockquote();
const level = headingMatch[1].length;
result.push(`<h${level} class="csk-md-h${level}">${headingMatch[2]}</h${level}>`);
continue;
}
// 引用
// GFM table: require a valid separator row to avoid matching ordinary pipe text.
if (i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const headers = splitTableRow(line);
const separators = splitTableRow(lines[i + 1]);
if (headers.length > 0 && headers.length === separators.length) {
flushParagraph();
closeList();
closeBlockquote();
const alignments = separators.map(getTableAlignment);
result.push('<div class="csk-md-table-wrap"><table class="csk-md-table">');
result.push('<thead><tr>');
headers.forEach((cell, index) => {
result.push(`<th${alignmentAttr(alignments[index])}>${inlineFormat(cell)}</th>`);
});
result.push('</tr></thead><tbody>');
i += 2;
while (i < lines.length && isTableDataRow(lines[i])) {
const cells = splitTableRow(lines[i]);
result.push('<tr>');
headers.forEach((_header, index) => {
result.push(`<td${alignmentAttr(alignments[index])}>${inlineFormat(cells[index] || '')}</td>`);
});
result.push('</tr>');
i++;
}
// ==================== 工具函数 ====================
result.push('</tbody></table></div>');
i--;
continue;
}
/** 从当前页面的 chatbot-sdk 脚本 src 推断 SDK 基础路径 */
function getSdkBasePath(): string {
if (typeof document === 'undefined') return '/sdk/';
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts[i].src;
if (src && /\/chatbot-sdk(\.min)?\.js([?#].*)?$/.test(src)) {
return src.replace(/chatbot-sdk(\.min)?\.js.*$/, '');
} }
}
return '/sdk/';
}
const quoteMatch = line.match(/^ {0,3}&gt;\s?(.*)/);
if (quoteMatch) {
flushParagraph();
closeList();
if (!inBlockquote) {
inBlockquote = true;
result.push('<blockquote class="csk-md-blockquote">');
}
result.push(`<p>${inlineFormat(quoteMatch[1])}</p>`);
continue;
} else if (inBlockquote) {
closeBlockquote();
}
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */
function isSafeUrl(url: string): boolean {
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim());
}
// 无序列表
const ulMatch = line.match(/^ {0,3}[\-\*]\s+(.+)/);
if (ulMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ul') {
closeList();
inList = true;
listType = 'ul';
result.push('<ul class="csk-md-ul">');
}
result.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// ==================== marked 配置 ====================
// 有序列表
const olMatch = line.match(/^ {0,3}\d+\.\s+(.+)/);
if (olMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ol') {
closeList();
inList = true;
listType = 'ol';
result.push('<ol class="csk-md-ol">');
}
result.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
/**
* marked
* - 使 csk-md-* styles.ts
* - / http/https
* - raw HTML
*/
function setupMarked(m: any): void {
// 保存到闭包变量,后续 renderMarkdown 只使用此引用,防止 window.marked 被篡改
_markedInstance = m;
// 全局选项:不自动转换换行为 <br>,启用 GFM
m.setOptions({ breaks: false, gfm: true });
m.use({
renderer: {
/** 标题 → <hN class="csk-md-hN"> */
heading(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<h${token.depth} class="csk-md-h${token.depth}">${text}</h${token.depth}>\n`;
},
/** 段落 → <p class="csk-md-p"> */
paragraph(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<p class="csk-md-p">${text}</p>\n`;
},
/** 代码块 → <pre class="csk-md-code-block"><code> */
code(token: any): string {
const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
return `<pre class="csk-md-code-block"><code${langClass}>${escapeHtml(token.text)}</code></pre>\n`;
},
/** 行内代码 → <code class="csk-md-inline-code"> */
codespan(token: any): string {
return `<code class="csk-md-inline-code">${escapeHtml(token.text)}</code>`;
},
/** 斜体(补充 csk-md-* 类名体系) */
em(this: any, token: any): string {
return `<em class="csk-md-em">${this.parser.parseInline(token.tokens)}</em>`;
},
/** 粗体(补充 csk-md-* 类名体系) */
strong(this: any, token: any): string {
return `<strong class="csk-md-strong">${this.parser.parseInline(token.tokens)}</strong>`;
},
/** 删除线(补充 csk-md-* 类名体系) */
del(this: any, token: any): string {
return `<del class="csk-md-del">${this.parser.parseInline(token.tokens)}</del>`;
},
/** 引用块 → <blockquote class="csk-md-blockquote"> */
blockquote(this: any, token: any): string {
const body = this.parser.parse(token.tokens);
return `<blockquote class="csk-md-blockquote">\n${body}</blockquote>\n`;
},
/** 列表 → <ul class="csk-md-ul"> / <ol class="csk-md-ol"> */
list(this: any, token: any): string {
const tag = token.ordered ? 'ol' : 'ul';
const cls = token.ordered ? 'csk-md-ol' : 'csk-md-ul';
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
const body = token.items.map((item: any) => this.parser.parse([item])).join('');
return `<${tag} class="${cls}"${startAttr}>\n${body}</${tag}>\n`;
},
/** 列表项(支持任务列表 checkbox) */
listitem(this: any, token: any): string {
let checkbox = '';
if (token.task) {
checkbox = token.checked
? '<input type="checkbox" checked disabled> '
: '<input type="checkbox" disabled> ';
}
const body = this.parser.parse(token.tokens);
return `<li>${checkbox}${body}</li>\n`;
},
/** 表格(包裹在可滚动容器中) */
table(this: any, token: any): string {
const headerCells = token.header.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<th${align}>${this.parser.parseInline(cell.tokens)}</th>`;
}).join('');
const bodyRows = token.rows.map((row: any) => {
const cells = row.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<td${align}>${this.parser.parseInline(cell.tokens)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<div class="csk-md-table-wrap"><table class="csk-md-table">\n<thead><tr>${headerCells}</tr></thead>\n<tbody>${bodyRows}</tbody>\n</table></div>\n`;
},
/** 链接 → <a class="csk-md-link">(仅允许 http/https) */
link(this: any, token: any): string {
const safeHref = isSafeUrl(token.href) ? token.href : '#';
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
const text = this.parser.parseInline(token.tokens);
return `<a class="csk-md-link" href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
/** 图片 → <img class="csk-md-img">(仅允许 http/https,非法降级为 alt 文本) */
image(token: any): string {
if (!isSafeUrl(token.href)) return escapeHtml(token.text);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
return `<img class="csk-md-img" src="${token.href}" alt="${escapeHtml(token.text)}"${titleAttr}>`;
},
/** 水平线 → <hr class="csk-md-hr"> */
hr(): string {
return '<hr class="csk-md-hr">\n';
},
/** 禁止 raw HTML(关键 XSS 防线) */
html(): string {
return '';
},
},
});
}
// 空行 → 段落分隔
if (line.trim() === '') {
flushParagraph();
closeList();
continue;
}
// ==================== 公开 API ====================
// 水平线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) {
flushParagraph();
closeList();
closeBlockquote();
result.push('<hr class="csk-md-hr">');
continue;
/**
* marked
* SDK init
*/
export function preloadMarked(): void {
if (markedLoadPromise) return;
markedLoadPromise = new Promise<boolean>((resolve) => {
// 1. 检查是否已存在(宿主手动引入或相同域名已加载)
const existing = (window as any).marked;
if (existing && typeof existing.parse === 'function') {
setupMarked(existing);
markedReady = true;
resolve(true);
return;
} }
// 普通文本 → 收集到段落缓冲
closeList();
closeBlockquote();
paragraphBuffer.push(inlineFormat(line));
}
flushParagraph();
closeList();
closeBlockquote();
return result.join('\n');
// === 辅助函数 ===
/** 行内格式化:粗体、斜体、链接 */
function inlineFormat(text: string): string {
// 粗体 **text** 或 __text__
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体 *text* 或 _text_
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>');
// 删除线 ~~text~~
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => {
// 只允许 http/https 链接,防止 javascript: 协议
const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
return `<a class="csk-md-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
return text;
}
/** 将段落缓冲输出为 <p> */
function isTableSeparator(line: string): boolean {
const cells = splitTableRow(line);
return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
}
function isTableDataRow(line: string): boolean {
return line.trim() !== '' && line.includes('|');
}
function splitTableRow(line: string): string[] {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
if (!trimmed.includes('|')) return [];
const cells: string[] = [];
let cell = '';
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '\\' && trimmed[i + 1] === '|') {
cell += '|';
i++;
} else if (char === '|') {
cells.push(cell.trim());
cell = '';
} else {
cell += char;
// 2. 动态创建 <script> 加载 marked
const basePath = getSdkBasePath();
const url = basePath + 'marked.min.js';
const el = document.createElement('script');
el.src = url;
el.onload = () => {
const m = (window as any).marked;
if (m && typeof m.parse === 'function') {
setupMarked(m);
markedReady = true;
} }
}
cells.push(cell.trim());
return cells;
}
function getTableAlignment(separator: string): '' | 'left' | 'center' | 'right' {
const value = separator.trim();
if (value.startsWith(':') && value.endsWith(':')) return 'center';
if (value.endsWith(':')) return 'right';
if (value.startsWith(':')) return 'left';
return '';
}
function alignmentAttr(alignment: '' | 'left' | 'center' | 'right'): string {
return alignment ? ` class="csk-md-align-${alignment}"` : '';
}
resolve(markedReady);
};
el.onerror = () => {
resolve(false);
};
document.head.appendChild(el);
});
}
function flushParagraph(): void {
if (paragraphBuffer.length > 0) {
result.push(`<p class="csk-md-p">${paragraphBuffer.join('<br>')}</p>`);
paragraphBuffer = [];
}
}
/**
* Markdown HTML
*
* @param text Markdown
* @returns HTML
*
* marked GFM
* / <p>
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
/** 关闭列表 */
function closeList(): void {
if (inList) {
result.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
// marked 未就绪 → 降级纯文本
if (!markedReady || !_markedInstance) {
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
} }
/** 关闭引用块 */
function closeBlockquote(): void {
if (inBlockquote) {
result.push('</blockquote>');
inBlockquote = false;
}
try {
return _markedInstance.parse(text) as string;
} catch {
// 解析异常降级
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
} }
} }
/** 还原占位符为安全 HTML */
function restorePlaceholders(text: string, prefix: string, replacements: string[]): string {
return text.replace(new RegExp(escapeRegex(prefix) + '(\\d+)\x00', 'g'), (_m, idx) => {
return replacements[parseInt(idx)] || '';
});
}
/** 转义正则特殊字符 */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** 导出复位函数,供 destroy() 调用,允许重新 init 时重试加载 marked */
export function resetMarked(): void {
markedReady = false;
_markedInstance = null;
markedLoadPromise = null;
} }

6
client/src/styles.ts

@ -841,6 +841,11 @@ function getStyles(config: ResolvedConfig): string {
.csk-md-table .csk-md-align-center { text-align: center; } .csk-md-table .csk-md-align-center { text-align: center; }
.csk-md-table .csk-md-align-right { text-align: right; } .csk-md-table .csk-md-align-right { text-align: right; }
/* marked 新增元素 */
.csk-md-img { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 8px; }
.csk-msg--ai .csk-msg__bubble .csk-md-ul input[type="checkbox"],
.csk-msg--ai .csk-msg__bubble .csk-md-ol input[type="checkbox"] { margin-right: 6px; vertical-align: middle; }
/* ========== P2: 会话管理面板 ========== */ /* ========== P2: 会话管理面板 ========== */
.csk-history-panel { .csk-history-panel {
position: absolute; position: absolute;
@ -1282,6 +1287,7 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-md-link { color: var(--csk-primary-light); } .csk-dark .csk-md-link { color: var(--csk-primary-light); }
.csk-dark .csk-md-table th { background: rgba(var(--csk-primary-rgb), 0.14); color: #E2E8F0; } .csk-dark .csk-md-table th { background: rgba(var(--csk-primary-rgb), 0.14); color: #E2E8F0; }
.csk-dark .csk-md-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, 0.05); } .csk-dark .csk-md-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, 0.05); }
.csk-dark .csk-md-img { opacity: 0.9; }
.csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); } .csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); }
.csk-dark .csk-welcome__title { color: #E2E8F0; } .csk-dark .csk-welcome__title { color: #E2E8F0; }
.csk-dark .csk-welcome__desc { color: #6B7280; } .csk-dark .csk-welcome__desc { color: #6B7280; }

200
client/tests/markdown.test.ts

@ -1,200 +0,0 @@
/**
* markdown.ts Markdown
*
*
* 1.
* 2. XSS http/https
*/
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../src/markdown';
describe('renderMarkdown - 基础语法', () => {
it('空字符串返回空', () => {
expect(renderMarkdown('')).toBe('');
});
it('null/undefined 返回空', () => {
expect(renderMarkdown(null as never)).toBe('');
expect(renderMarkdown(undefined as never)).toBe('');
});
it('普通文本渲染为段落', () => {
expect(renderMarkdown('你好世界')).toContain('<p class="csk-md-p">你好世界</p>');
});
it('标题渲染 h1-h6', () => {
expect(renderMarkdown('# 标题一')).toContain('<h1 class="csk-md-h1">');
expect(renderMarkdown('## 标题二')).toContain('<h2 class="csk-md-h2">');
expect(renderMarkdown('### 标题三')).toContain('<h3 class="csk-md-h3">');
});
it('粗体渲染', () => {
expect(renderMarkdown('**粗体文本**')).toContain('<strong>粗体文本</strong>');
});
it('斜体渲染', () => {
expect(renderMarkdown('*斜体文本*')).toContain('<em>斜体文本</em>');
});
it('删除线渲染', () => {
expect(renderMarkdown('~~删除文本~~')).toContain('<del>删除文本</del>');
});
it('无序列表渲染', () => {
const result = renderMarkdown('- 项目一\n- 项目二');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<li>');
expect(result).toContain('项目一');
expect(result).toContain('项目二');
});
it('有序列表渲染', () => {
const result = renderMarkdown('1. 第一步\n2. 第二步');
expect(result).toContain('<ol class="csk-md-ol">');
expect(result).toContain('<li>');
});
it('引用渲染', () => {
const result = renderMarkdown('> 这是引用');
expect(result).toContain('<blockquote class="csk-md-blockquote">');
expect(result).toContain('这是引用');
});
it('链接渲染(http 协议)', () => {
const result = renderMarkdown('[百度](https://www.baidu.com)');
expect(result).toContain('href="https://www.baidu.com"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('水平线渲染', () => {
expect(renderMarkdown('---')).toContain('<hr class="csk-md-hr">');
});
});
describe('renderMarkdown - 代码', () => {
it('行内代码渲染', () => {
const result = renderMarkdown('使用 `console.log` 调试');
expect(result).toContain('<code class="csk-md-inline-code">console.log</code>');
});
it('代码块渲染', () => {
const result = renderMarkdown('```js\nconsole.log("hello");\n```');
expect(result).toContain('<pre class="csk-md-code-block">');
expect(result).toContain('class="language-js"');
expect(result).toContain('console.log');
});
it('代码块内容不被 Markdown 处理', () => {
const result = renderMarkdown('```\n**粗体** *斜体*\n```');
// 代码块内的 Markdown 语法不应被转换
expect(result).toContain('**粗体**');
expect(result).toContain('*斜体*');
expect(result).not.toContain('<strong>');
});
});
describe('renderMarkdown - XSS 防护', () => {
it('转义 HTML 标签', () => {
const result = renderMarkdown('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('转义 img 标签的 onerror', () => {
const result = renderMarkdown('<img src=x onerror=alert(1)>');
// < 被转义为 &lt;,标签不会执行,onerror 变为纯文本
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('链接只允许 http/https 协议', () => {
const result = renderMarkdown('[恶意](javascript:alert(1))');
expect(result).toContain('href="#"');
expect(result).not.toContain('javascript:');
});
it('允许 http 链接', () => {
const result = renderMarkdown('[安全](http://example.com)');
expect(result).toContain('href="http://example.com"');
});
it('代码块内容同样被转义', () => {
const result = renderMarkdown('```\n<script>alert(1)</script>\n```');
expect(result).not.toContain('<script>alert');
expect(result).toContain('&lt;script&gt;');
});
it('行内代码内容同样被转义', () => {
const result = renderMarkdown('`<img src=x>`');
expect(result).toContain('&lt;img');
expect(result).not.toContain('<img');
});
});
describe('renderMarkdown - 复合内容', () => {
it('混合标题和段落', () => {
const result = renderMarkdown('# 标题\n\n这是正文');
expect(result).toContain('<h1');
expect(result).toContain('<p class="csk-md-p">这是正文</p>');
});
it('多段落正确分隔', () => {
const result = renderMarkdown('第一段\n\n第二段');
expect(result).toContain('第一段');
expect(result).toContain('第二段');
// 应有两个 <p> 标签
const pCount = (result.match(/<p class="csk-md-p">/g) || []).length;
expect(pCount).toBe(2);
});
});
describe('renderMarkdown - GFM tables and tolerant headings', () => {
it('accepts a no-space heading before CJK text', () => {
expect(renderMarkdown('##\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757')).toContain(
'<h2 class="csk-md-h2">\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757</h2>',
);
});
it('accepts a no-space heading before a digit', () => {
expect(renderMarkdown('##1 purchase warehouse')).toContain(
'<h2 class="csk-md-h2">1 purchase warehouse</h2>',
);
});
it('accepts up to three leading spaces for block syntax', () => {
const result = renderMarkdown(
' ## 1\uFE0F\u20E3 purchase warehouse\n\n - **field**: warehouseid',
);
expect(result).toContain('<h2 class="csk-md-h2">1\uFE0F\u20E3 purchase warehouse</h2>');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<strong>field</strong>');
});
it('does not treat an English hashtag as a heading', () => {
expect(renderMarkdown('#topic')).toContain('<p class="csk-md-p">#topic</p>');
});
it('renders a GFM table with alignment and inline formatting', () => {
const result = renderMarkdown(
'|\u5b57\u6bb5|\u8bf4\u660e|\u6821\u9a8c\u89c4\u5219|\n|---|:---:|---:|\n|**warehouseId**|ID|required|',
);
expect(result).toContain('<table class="csk-md-table">');
expect(result).toContain('<th>\u5b57\u6bb5</th>');
expect(result).toContain('<th class="csk-md-align-center">\u8bf4\u660e</th>');
expect(result).toContain('<td class="csk-md-align-right">required</td>');
expect(result).toContain('<strong>warehouseId</strong>');
});
it('keeps table cell HTML escaped', () => {
const result = renderMarkdown('|field|note|\n|---|---|\n|name|<img src=x onerror=alert(1)>|');
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not treat ordinary pipe text as a table', () => {
const result = renderMarkdown('A | B\nnot a separator');
expect(result).not.toContain('<table');
});
});

5700
src/main/resources/static/sdk/chatbot-sdk.js
File diff suppressed because it is too large
View File

1
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

Loading…
Cancel
Save