| index 280af11..d767cb5 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -1,13 +1,37 @@ |
| // Text formatting helpers. Pure functions so they can be unit-tested in Node; |
| // the DOM rendering that consumes these lives in chat.js. |
| |
| -// Matches http(s):// URLs and bare www. hosts. Deliberately narrow: only these |
| -// become links, so things like "javascript:" or "mailto:" are never linkified. |
| -const URL_RE = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi; |
| +// Matches http(s):// URLs, bare www. hosts, and scheme-less "host.tld" domains. |
| +// The bare case is validated against a TLD allowlist (see linkHref) so file names |
| +// and abbreviations (node.js, index.html, e.g., main.go) aren't turned into links. |
| +const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/gi; |
| |
| // Trailing punctuation that's almost certainly sentence punctuation, not URL. |
| const TRAILING = /[.,!?;:'")\]}]+$/; |
| |
| +const SCHEME_RE = /^https?:\/\//i; |
| +const WWW_RE = /^www\./i; |
| +// TLDs we treat a scheme-less "host.tld" as a link for: common gTLDs + ccTLDs. |
| +// File extensions (js, html, css, py, go, md, sh, so, txt, json…) are deliberately |
| +// excluded so "node.js" / "index.html" don't become links. |
| +const BARE_TLDS = new Set( |
| + ("com org net edu gov int io ie co dev app ai me tv fm gg news info biz xyz " + |
| + "eu uk us ca au nz de fr es it nl se no fi dk pl pt be ch at gr jp kr cn in " + |
| + "br mx za ru").split(" ") |
| +); |
| + |
| +// The http(s) href for a matched token, or null if it isn't really a link. |
| +function linkHref(token) { |
| + if (SCHEME_RE.test(token)) return token; |
| + if (WWW_RE.test(token)) return "https://" + token; |
| + const host = token.split(/[/?#]/)[0]; |
| + const labels = host.split("."); |
| + if (labels.length >= 2 && BARE_TLDS.has(labels[labels.length - 1].toLowerCase())) { |
| + return "https://" + token; |
| + } |
| + return null; |
| +} |
| + |
| // tokenizeText splits a message into an ordered list of tokens: |
| // { text } → plain text (render as a text node) |
| // { text, href } → a link (render as an <a>, href is always http(s)://) |
| @@ -20,8 +44,6 @@ export function tokenizeText(text) { |
| for (const m of text.matchAll(URL_RE)) { |
| const match = m[0]; |
| const offset = m.index; |
| - if (offset > last) tokens.push({ text: text.slice(last, offset) }); |
| - |
| let url = match; |
| let trail = ""; |
| const tm = url.match(TRAILING); |
| @@ -29,7 +51,13 @@ export function tokenizeText(text) { |
| trail = tm[0]; |
| url = url.slice(0, url.length - trail.length); |
| } |
| - const href = /^https?:\/\//i.test(url) ? url : "https://" + url; |
| + const bare = !SCHEME_RE.test(url) && !WWW_RE.test(url); |
| + // A scheme-less candidate glued to a preceding word char or '@' is mid-word or |
| + // an email's domain — leave it as plain text. |
| + if (bare && offset > 0 && /[\w@.]/.test(text[offset - 1])) continue; |
| + const href = linkHref(url); |
| + if (!href) continue; // e.g. "node.js" / "index.html" — not a link |
| + if (offset > last) tokens.push({ text: text.slice(last, offset) }); |
| tokens.push({ text: url, href }); |
| if (trail) tokens.push({ text: trail }); |
| last = offset + match.length; |