You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
242 lines
8.7 KiB
242 lines
8.7 KiB
/**
|
|
* Markdown 渲染模块 - 基于 marked 外部脚本
|
|
*
|
|
* marked v15.0.12 以静态文件方式由 SDK 自动加载,宿主无需手动引入。
|
|
* 加载失败或未就绪时降级为纯文本渲染,不丢消息。
|
|
*
|
|
* XSS 安全策略:
|
|
* 1. 自定义 Renderer 全面接管输出,所有类名/标签由 SDK 生成
|
|
* 2. 链接/图片 URL 白名单校验(仅 http/https)
|
|
* 3. 禁止 raw HTML 渲染
|
|
* 4. 代码内容使用 escapeHtml 转义
|
|
*/
|
|
import { escapeHtml } from './utils';
|
|
|
|
/** marked 是否已加载并完成配置 */
|
|
let markedReady = false;
|
|
|
|
/** 加载 Promise,用于等待就绪 */
|
|
let markedLoadPromise: Promise<boolean> | null = null;
|
|
|
|
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */
|
|
let _markedInstance: any = null;
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
/** 从当前页面的 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/';
|
|
}
|
|
|
|
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */
|
|
function isSafeUrl(url: string): boolean {
|
|
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim());
|
|
}
|
|
|
|
// ==================== marked 配置 ====================
|
|
|
|
/**
|
|
* 配置 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 '';
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ==================== 公开 API ====================
|
|
|
|
/**
|
|
* 预加载 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 渲染 Markdown 文本为安全 HTML
|
|
*
|
|
* @param text Markdown 源文本
|
|
* @returns 安全 HTML 字符串
|
|
*
|
|
* marked 已就绪 → 完整 GFM 渲染
|
|
* 未就绪/失败 → 纯文本降级包装在 <p> 中
|
|
*/
|
|
export function renderMarkdown(text: string): string {
|
|
if (!text || typeof text !== 'string') return '';
|
|
|
|
// marked 未就绪 → 降级纯文本
|
|
if (!markedReady || !_markedInstance) {
|
|
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
|
|
}
|
|
|
|
try {
|
|
return _markedInstance.parse(text) as string;
|
|
} catch {
|
|
// 解析异常降级
|
|
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
|
|
}
|
|
}
|
|
|
|
/** 导出复位函数,供 destroy() 调用,允许重新 init 时重试加载 marked */
|
|
export function resetMarked(): void {
|
|
markedReady = false;
|
|
_markedInstance = null;
|
|
markedLoadPromise = null;
|
|
}
|