feat(web-ai): render markdown answers safely
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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>`;
|
||||
};
|
||||
Reference in New Issue
Block a user