/** * 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 | 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: { /** 标题 → */ heading(this: any, token: any): string { const text = this.parser.parseInline(token.tokens); return `${text}\n`; }, /** 段落 →

*/ paragraph(this: any, token: any): string { const text = this.parser.parseInline(token.tokens); return `

${text}

\n`; }, /** 代码块 →
 */
      code(token: any): string {
        const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
        return `
${escapeHtml(token.text)}
\n`; }, /** 行内代码 → */ codespan(token: any): string { return `${escapeHtml(token.text)}`; }, /** 斜体(补充 csk-md-* 类名体系) */ em(this: any, token: any): string { return `${this.parser.parseInline(token.tokens)}`; }, /** 粗体(补充 csk-md-* 类名体系) */ strong(this: any, token: any): string { return `${this.parser.parseInline(token.tokens)}`; }, /** 删除线(补充 csk-md-* 类名体系) */ del(this: any, token: any): string { return `${this.parser.parseInline(token.tokens)}`; }, /** 引用块 →
*/ blockquote(this: any, token: any): string { const body = this.parser.parse(token.tokens); return `
\n${body}
\n`; }, /** 列表 →
    /
      */ 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}\n`; }, /** 列表项(支持任务列表 checkbox) */ listitem(this: any, token: any): string { let checkbox = ''; if (token.task) { checkbox = token.checked ? ' ' : ' '; } const body = this.parser.parse(token.tokens); return `
    1. ${checkbox}${body}
    2. \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 `${this.parser.parseInline(cell.tokens)}`; }).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 `${this.parser.parseInline(cell.tokens)}`; }).join(''); return `${cells}`; }).join(''); return `
      \n${headerCells}\n${bodyRows}\n
      \n`; }, /** 链接 → (仅允许 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 `${text}`; }, /** 图片 → (仅允许 http/https,非法降级为 alt 文本) */ image(token: any): string { if (!isSafeUrl(token.href)) return escapeHtml(token.text); const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : ''; return `${escapeHtml(token.text)}`; }, /** 水平线 →
      */ hr(): string { return '
      \n'; }, /** 禁止 raw HTML(关键 XSS 防线) */ html(): string { return ''; }, }, }); } // ==================== 公开 API ==================== /** * 预加载 marked 外部脚本 * 在 SDK init 时调用,尽早开始加载,减少首次渲染时的等待 */ export function preloadMarked(): void { if (markedLoadPromise) return; markedLoadPromise = new Promise((resolve) => { // 1. 检查是否已存在(宿主手动引入或相同域名已加载) const existing = (window as any).marked; if (existing && typeof existing.parse === 'function') { setupMarked(existing); markedReady = true; resolve(true); return; } // 2. 动态创建