diff --git a/web/src/utils/formatMessage.tsx b/web/src/utils/formatMessage.tsx index e91d656..89ec1b7 100644 --- a/web/src/utils/formatMessage.tsx +++ b/web/src/utils/formatMessage.tsx @@ -9,25 +9,104 @@ function escapeHtml(input: string): string { function sanitizeHref(input: string): string { const normalized = input.trim(); - if (/^https?:\/\//i.test(normalized)) { + if (/^(https?:\/\/|mailto:)/i.test(normalized)) { return normalized; } return "#"; } -export function formatMessageHtml(text: string): string { - let html = escapeHtml(text); - - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, href: string) => { - const safeHref = sanitizeHref(href); - return `${label}`; - }); - html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); - html = html.replace(/\*([^*]+)\*/g, "$1"); - html = html.replace(/__([^_]+)__/g, "$1"); - html = html.replace(/~~([^~]+)~~/g, "$1"); - html = html.replace(/`([^`]+)`/g, "$1"); - html = html.replace(/\|\|([^|]+)\|\|/g, "$1"); - html = html.replace(/\n/g, "
"); - return html; +function renderLink(labelHtml: string, href: string): string { + const safeHref = sanitizeHref(href); + return `${labelHtml}`; +} + +function findClosing(text: string, marker: string, start: number): number { + let index = text.indexOf(marker, start); + while (index !== -1) { + if (index > start) { + return index; + } + index = text.indexOf(marker, index + marker.length); + } + return -1; +} + +function parseInline(text: string): string { + let i = 0; + let out = ""; + + while (i < text.length) { + if (text[i] === "\n") { + out += "
"; + i += 1; + continue; + } + + const mdLink = text.slice(i).match(/^\[([^\]]+)\]\(([^)]+)\)/); + if (mdLink) { + const [, label, href] = mdLink; + out += renderLink(parseInline(label), href); + i += mdLink[0].length; + continue; + } + + const bareUrl = text.slice(i).match(/^https?:\/\/[^\s<>()]+/i); + if (bareUrl) { + const raw = bareUrl[0]; + const trimmed = raw.replace(/[),.;!?]+$/g, ""); + const trailing = raw.slice(trimmed.length); + out += renderLink(escapeHtml(trimmed), trimmed); + out += escapeHtml(trailing); + i += raw.length; + continue; + } + + if (text.startsWith("`", i)) { + const end = findClosing(text, "`", i + 1); + if (end !== -1) { + const value = escapeHtml(text.slice(i + 1, end)); + out += `${value}`; + i = end + 1; + continue; + } + } + + const tokenDefs: Array<{ marker: string; open: string; close: string }> = [ + { marker: "||", open: '', close: "" }, + { marker: "**", open: "", close: "" }, + { marker: "__", open: "", close: "" }, + { marker: "~~", open: "", close: "" }, + { marker: "*", open: "", close: "" }, + ]; + let matched = false; + for (const def of tokenDefs) { + if (!text.startsWith(def.marker, i)) { + continue; + } + const end = findClosing(text, def.marker, i + def.marker.length); + if (end === -1) { + continue; + } + const value = text.slice(i + def.marker.length, end); + if (!value.trim()) { + continue; + } + out += `${def.open}${parseInline(value)}${def.close}`; + i = end + def.marker.length; + matched = true; + break; + } + if (matched) { + continue; + } + + out += escapeHtml(text[i]); + i += 1; + } + + return out; +} + +export function formatMessageHtml(text: string): string { + return parseInline(text); }