feat(web): implement robust inline message formatting parser
All checks were successful
CI / test (push) Successful in 25s

This commit is contained in:
2026-03-08 13:00:11 +03:00
parent 58208787e7
commit 65d8a9379b

View File

@@ -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 `<a href="${safeHref}" target="_blank" rel="noreferrer" class="underline text-sky-300">${label}</a>`;
});
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
html = html.replace(/__([^_]+)__/g, "<u>$1</u>");
html = html.replace(/~~([^~]+)~~/g, "<s>$1</s>");
html = html.replace(/`([^`]+)`/g, "<code class=\"rounded bg-slate-700/60 px-1 py-0.5 text-[12px]\">$1</code>");
html = html.replace(/\|\|([^|]+)\|\|/g, "<span class=\"rounded bg-slate-700/80 px-1 text-transparent hover:text-inherit\">$1</span>");
html = html.replace(/\n/g, "<br/>");
return html;
function renderLink(labelHtml: string, href: string): string {
const safeHref = sanitizeHref(href);
return `<a href="${safeHref}" target="_blank" rel="noreferrer" class="underline text-sky-300">${labelHtml}</a>`;
}
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 += "<br/>";
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 += `<code class="rounded bg-slate-700/60 px-1 py-0.5 text-[12px]">${value}</code>`;
i = end + 1;
continue;
}
}
const tokenDefs: Array<{ marker: string; open: string; close: string }> = [
{ marker: "||", open: '<span class="rounded bg-slate-700/80 px-1 text-transparent hover:text-inherit">', close: "</span>" },
{ marker: "**", open: "<strong>", close: "</strong>" },
{ marker: "__", open: "<u>", close: "</u>" },
{ marker: "~~", open: "<s>", close: "</s>" },
{ marker: "*", open: "<em>", close: "</em>" },
];
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);
}