4 Commits
1dc9cc9fe6
...
c22d044e5c
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
c22d044e5c |
fix(sdk): 修复 marked 列表渲染异常导致回复挤成一行
marked v15 的 Parser.parse 按 token.type 做 switch 分发、无 list_item 分支,list(token) 用 this.parser.parse([item]) 会抛异常,被 renderMarkdown 降级为纯文本,导致换行折叠成一行、Markdown 全部失效。改为官方写法 this.listitem(item)。 - 修正 marked 版本注释 v18 → v15.0.12(与部署产物实际版本一致) - 同步更新 client/CLAUDE.md 与 README.md 的 Markdown 模块说明与部署依赖 - .gitignore 忽略 sdk 构建产物目录 |
2 weeks ago |
|
|
0205af7b72 |
fix(app): SSE 流式输出保留行尾空白避免 Markdown 标题损坏
新增 preserveTrailingWhitespace 方法,缓冲以空白结尾的 chunk 与下一 chunk 合并后发出,规避前端 SSE 逐行 trim 导致的行尾空格丢失(如 "## " 标题标记被拼成 "##标题")。 - 捕获内部订阅并注册 onCancel/onDispose,下游取消时同步取消上游,避免资源泄漏 - 尾部空白缓冲设 256 上限,防止纯空白输出导致 buffer 无界增长 - onError 先 flush 已缓冲内容再传播,避免已生成文本丢失 - 空白判定改用 Character.isWhitespace,对齐前端 trim 语义 |
2 weeks ago |
|
|
0953e9481b |
chatsdk 对markdown解析器进行重构
|
2 weeks ago |
|
|
7ce8028d73 |
重构客服角色管理页面排版
|
2 weeks ago |
-
1.gitignore
-
4client/CLAUDE.md
-
6client/README.md
-
BINclient/assets/launcher-logo.png
-
69client/assets/marked.min.js
-
5700client/dist/chatbot-sdk.js
-
1client/dist/chatbot-sdk.min.js
-
1client/rollup.config.js
-
5client/src/index.ts
-
482client/src/markdown.ts
-
12client/src/styles.ts
-
200client/tests/markdown.test.ts
-
556frontend/src/views/RoleManager.vue
-
66src/main/java/com/wok/supportbot/app/AssistantApp.java
-
5src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
30src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
-
5700src/main/resources/static/sdk/chatbot-sdk.js
-
1src/main/resources/static/sdk/chatbot-sdk.min.js
-
BINsrc/main/resources/static/sdk/launcher-logo.png
|
Before Width: 1024 | Height: 1024 | Size: 87 KiB After Width: 1254 | Height: 1254 | Size: 178 KiB |
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,290 +1,242 @@ |
|||
/** |
|||
* 轻量级 Markdown 渲染器 - 无外部依赖,XSS 安全 |
|||
* Markdown 渲染模块 - 基于 marked 外部脚本 |
|||
* |
|||
* 支持:代码块、行内代码、标题、粗体、斜体、列表、链接、引用、段落 |
|||
* 策略:先转义 HTML,再转 Markdown 为安全 HTML 标签 |
|||
* marked v15.0.12 以静态文件方式由 SDK 自动加载,宿主无需手动引入。 |
|||
* 加载失败或未就绪时降级为纯文本渲染,不丢消息。 |
|||
* |
|||
* XSS 安全策略: |
|||
* 1. 自定义 Renderer 全面接管输出,所有类名/标签由 SDK 生成 |
|||
* 2. 链接/图片 URL 白名单校验(仅 http/https) |
|||
* 3. 禁止 raw HTML 渲染 |
|||
* 4. 代码内容使用 escapeHtml 转义 |
|||
*/ |
|||
import { escapeHtml } from './utils'; |
|||
|
|||
/** 代码块占位符前缀 */ |
|||
const CODE_BLOCK_PREFIX = '\x00CODEBLOCK_'; |
|||
|
|||
/** 行内代码占位符前缀 */ |
|||
const INLINE_CODE_PREFIX = '\x00INLINECODE_'; |
|||
|
|||
/** |
|||
* 渲染 Markdown 文本为安全 HTML |
|||
* @param text Markdown 源文本 |
|||
* @returns 安全 HTML 字符串 |
|||
*/ |
|||
export function renderMarkdown(text: string): string { |
|||
if (!text || typeof text !== 'string') return ''; |
|||
|
|||
// 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`; |
|||
}); |
|||
/** marked 是否已加载并完成配置 */ |
|||
let markedReady = false; |
|||
|
|||
// 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`; |
|||
}); |
|||
/** 加载 Promise,用于等待就绪 */ |
|||
let markedLoadPromise: Promise<boolean> | null = null; |
|||
|
|||
// 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; |
|||
} |
|||
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */ |
|||
let _markedInstance: any = null; |
|||
|
|||
// 标题
|
|||
// 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++; |
|||
/** 从当前页面的 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.*$/, ''); |
|||
} |
|||
|
|||
result.push('</tbody></table></div>'); |
|||
i--; |
|||
continue; |
|||
} |
|||
} |
|||
|
|||
const quoteMatch = line.match(/^ {0,3}>\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(); |
|||
} |
|||
|
|||
// 无序列表
|
|||
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; |
|||
} |
|||
|
|||
// 有序列表
|
|||
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; |
|||
} |
|||
|
|||
// 空行 → 段落分隔
|
|||
if (line.trim() === '') { |
|||
flushParagraph(); |
|||
closeList(); |
|||
continue; |
|||
} |
|||
return '/sdk/'; |
|||
} |
|||
|
|||
// 水平线
|
|||
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) { |
|||
flushParagraph(); |
|||
closeList(); |
|||
closeBlockquote(); |
|||
result.push('<hr class="csk-md-hr">'); |
|||
continue; |
|||
} |
|||
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */ |
|||
function isSafeUrl(url: string): boolean { |
|||
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim()); |
|||
} |
|||
|
|||
// 普通文本 → 收集到段落缓冲
|
|||
closeList(); |
|||
closeBlockquote(); |
|||
paragraphBuffer.push(inlineFormat(line)); |
|||
} |
|||
// ==================== marked 配置 ====================
|
|||
|
|||
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>`; |
|||
/** |
|||
* 配置 marked 实例 |
|||
* - 输出统一使用 csk-md-* 类名前缀(兼容现有 styles.ts) |
|||
* - 链接/图片仅允许 http/https |
|||
* - 禁止 raw HTML |
|||
*/ |
|||
function setupMarked(m: any): void { |
|||
// 保存到闭包变量,后续 renderMarkdown 只使用此引用,防止 window.marked 被篡改
|
|||
_markedInstance = m; |
|||
|
|||
// 全局选项:聊天场景保留换行,启用 GFM
|
|||
m.setOptions({ breaks: true, 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}"` : ''; |
|||
// 列表项须用 this.listitem(item) 渲染,不可 this.parser.parse([item]):
|
|||
// marked v15 的 Parser.parse 按 token.type 做 switch 分发,无 "list_item" 分支,直接 parse 会抛异常
|
|||
const body = token.items.map((item: any) => this.listitem(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 ''; |
|||
}, |
|||
}, |
|||
}); |
|||
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; |
|||
} |
|||
} |
|||
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 ''; |
|||
} |
|||
// ==================== 公开 API ====================
|
|||
|
|||
function alignmentAttr(alignment: '' | 'left' | 'center' | 'right'): string { |
|||
return alignment ? ` class="csk-md-align-${alignment}"` : ''; |
|||
} |
|||
/** |
|||
* 预加载 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; |
|||
} |
|||
|
|||
// 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; |
|||
} |
|||
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; |
|||
} |
|||
@ -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('<script>'); |
|||
}); |
|||
|
|||
it('转义 img 标签的 onerror', () => { |
|||
const result = renderMarkdown('<img src=x onerror=alert(1)>'); |
|||
// < 被转义为 <,标签不会执行,onerror 变为纯文本
|
|||
expect(result).not.toContain('<img'); |
|||
expect(result).toContain('<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('<script>'); |
|||
}); |
|||
|
|||
it('行内代码内容同样被转义', () => { |
|||
const result = renderMarkdown('`<img src=x>`'); |
|||
expect(result).toContain('<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('<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
|
Before Width: 1024 | Height: 1024 | Size: 87 KiB After Width: 1254 | Height: 1254 | Size: 178 KiB |