/** * 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法: * 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。 * 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。 * 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。 */ function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function safeUrl(url: string): string | null { const u = url.trim() if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u return null } /** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */ function inline(escaped: string): string { const codes: string[] = [] let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => { codes.push(`${c}`) return `\u0001${codes.length - 1}\u0001` }) s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, url: string) => { const safe = safeUrl(url) if (!safe) return `[${text}](${url})` return `${text}` }) s = s.replace(/\*\*([^*]+)\*\*/g, '$1') s = s.replace(/__([^_]+)__/g, '$1') s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2') s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1$2') s = s.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => codes[Number(i)] ?? '') return s } export function renderMarkdown(src: string): string { if (!src) return '' const text = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n') const blocks: string[] = [] const withTokens = text.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => { const cls = (lang || '').trim() const body = escapeHtml(code.replace(/\n$/, '')) const clsAttr = cls ? ` class="language-${escapeHtml(cls)}"` : '' blocks.push(`
${body}
`) return `\u0000${blocks.length - 1}\u0000` }) const lines = withTokens.split('\n') const out: string[] = [] let para: string[] = [] let i = 0 const flushPara = (): void => { if (para.length) { out.push(`

${inline(escapeHtml(para.join(' ')))}

`) para = [] } } while (i < lines.length) { const line = lines[i] const codeToken = line.match(/^\u0000(\d+)\u0000\s*$/) if (codeToken) { flushPara() out.push(blocks[Number(codeToken[1])] ?? '') i++ continue } if (/^\s*$/.test(line)) { flushPara() i++ continue } const h = line.match(/^(#{1,6})\s+(.*)$/) if (h) { flushPara() const lvl = h[1].length const tag = lvl <= 2 ? 'h3' : lvl === 3 ? 'h4' : 'h5' out.push(`<${tag} class="mmd-h">${inline(escapeHtml(h[2]))}`) i++ continue } if (/^\s*>\s?/.test(line)) { flushPara() const items: string[] = [] while (i < lines.length && /^\s*>\s?/.test(lines[i])) { items.push(lines[i].replace(/^\s*>\s?/, '')) i++ } out.push(`
${inline(escapeHtml(items.join(' ')))}
`) continue } if (/^\s*[-*+]\s+/.test(line)) { flushPara() const items: string[] = [] while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')) i++ } out.push(``) continue } if (/^\s*\d+\.\s+/.test(line)) { flushPara() const items: string[] = [] while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, '')) i++ } out.push(`
    ${items.map((it) => `
  1. ${inline(escapeHtml(it))}
  2. `).join('')}
`) continue } para.push(line) i++ } flushPara() return out.join('\n') }