统一流式问答入口,并用轻量 markdown 渲染助手回复。 Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
/**
|
|
* 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法:
|
|
* 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。
|
|
* 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。
|
|
* 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。
|
|
*/
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, '&')
|
|
.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(`<code class="mmd-code">${c}</code>`)
|
|
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 `<a class="mmd-link" href="${safe}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
|
})
|
|
|
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
|
|
s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1<em>$2</em>')
|
|
|
|
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(`<pre class="mmd-pre"><code${clsAttr}>${body}</code></pre>`)
|
|
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(`<p>${inline(escapeHtml(para.join(' ')))}</p>`)
|
|
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]))}</${tag}>`)
|
|
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(`<blockquote class="mmd-quote">${inline(escapeHtml(items.join(' ')))}</blockquote>`)
|
|
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(`<ul class="mmd-ul">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ul>`)
|
|
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(`<ol class="mmd-ol">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ol>`)
|
|
continue
|
|
}
|
|
|
|
para.push(line)
|
|
i++
|
|
}
|
|
flushPara()
|
|
|
|
return out.join('\n')
|
|
}
|