// Split plain text into alternating text/link segments so a subtask can be // rendered with real tags via Svelte templating (no `{@html}`, no XSS // surface — see markdown.ts for the hardened-HTML approach notes take). export type LinkifyPart = { text: string } | { link: string }; // A run of non-whitespace starting at http(s):// — the trailing-punctuation // trim below decides how much of the run is really part of the URL. const URL_RE = /https?:\/\/[^\s<>"']+/g; // Strip trailing punctuation that's almost certainly sentence punctuation // rather than part of the URL (e.g. "check https://x.com." or "see // (https://x.com)."). A trailing ')' is only stripped when it's unbalanced // within the match — "https://x.com/foo(bar)" keeps its ')'. function trimTrailingPunct(url: string): string { let end = url.length; while (end > 0) { const ch = url[end - 1]; if (ch === ")") { const slice = url.slice(0, end); const opens = (slice.match(/\(/g) ?? []).length; const closes = (slice.match(/\)/g) ?? []).length; if (closes > opens) { end--; continue; } break; } if (".,;:!?".includes(ch)) { end--; continue; } break; } return url.slice(0, end); } /** Break `text` into segments, pulling out http(s) URLs as `{ link }` parts. */ export function linkify(text: string): LinkifyPart[] { const parts: LinkifyPart[] = []; let lastIndex = 0; for (const match of text.matchAll(URL_RE)) { const raw = match[0]; const start = match.index ?? 0; const url = trimTrailingPunct(raw); if (!url) continue; if (start > lastIndex) parts.push({ text: text.slice(lastIndex, start) }); parts.push({ link: url }); const consumedEnd = start + url.length; if (consumedEnd < start + raw.length) parts.push({ text: raw.slice(url.length) }); lastIndex = start + raw.length; } if (lastIndex < text.length) parts.push({ text: text.slice(lastIndex) }); return parts; }