feat(web-ai): render markdown answers safely

This commit is contained in:
2026-07-19 22:02:49 +08:00
parent 7bd2c540fa
commit c17d83fc59
5 changed files with 225 additions and 2 deletions
+120
View File
@@ -0,0 +1,120 @@
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char] || char));
const safeHref = (value: string) => {
try {
const url = new URL(value.trim());
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : '';
} catch {
return '';
}
};
const inline = (source: string): string => {
const tokens: string[] = [];
const token = (html: string) => `\uE000${tokens.push(html) - 1}\uE001`;
let value: string = source
.replace(/`([^`\n]+)`/g, (_, code: string) => token(`<code>${escapeHtml(code)}</code>`))
.replace(/\[([^\]\n]+)]\(([^)\n]+)\)/g, (_, label: string, rawHref: string) => {
const href = safeHref(rawHref);
return href ? token(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inline(label)}</a>`) : _;
});
value = escapeHtml(value)
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
return value.replace(/\uE000(\d+)\uE001/g, (_: string, index: string) => tokens[Number(index)] || '');
};
/** Converts the small Markdown subset returned by Web AI into safe rich-text HTML. */
export const renderMarkdown = (value: string): string => {
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listType: 'ol' | 'ul' | null = null;
let listItems: string[] = [];
let code: string[] | null = null;
const flushParagraph = () => {
if (paragraph.length) output.push(`<p>${paragraph.map(inline).join('<br>')}</p>`);
paragraph = [];
};
const flushQuote = () => {
if (quote.length) output.push(`<blockquote>${quote.join('<br>')}</blockquote>`);
quote = [];
};
const flushList = () => {
if (listType) output.push(`<${listType}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${listType}>`);
listType = null;
listItems = [];
};
const flushBlocks = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const line of lines) {
if (/^```/.test(line)) {
flushBlocks();
if (code) {
output.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
code = null;
} else {
code = [];
}
continue;
}
if (code) {
code.push(line);
continue;
}
if (!line.trim()) {
flushBlocks();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushBlocks();
output.push(`<h${heading[1].length}>${inline(heading[2])}</h${heading[1].length}>`);
continue;
}
const quoted = line.match(/^>\s?(.*)$/);
if (quoted) {
flushParagraph();
flushList();
quote.push(inline(quoted[1]));
continue;
}
const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
if (unordered || ordered) {
flushParagraph();
flushQuote();
const type = ordered ? 'ol' : 'ul';
if (listType !== type) {
flushList();
listType = type;
}
listItems.push(inline((ordered || unordered)![1]));
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
if (code) output.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
flushBlocks();
return output.join('') || `<p>${escapeHtml(String(value || ''))}</p>`;
};