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);
}