eleven / messenger Public

web: detect scheme-less bare domains (rte.ie, guardian.co.uk) as links

Link detection required http(s):// or www.; now a bare host.tld is linkified
(and unfurled) too. Validated against a TLD allowlist so file names, versions,
abbreviations and email domains (node.js, index.html, main.go, e.g., v1.2.3,
paul@rte.ie) are NOT turned into links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 9424f87d4eb853778633b940372d4ef53642112e parent 9cf1cf7
2 files changed, +56 −6
  • test/format.test.mjs +22 −0
  • web/static/format.js +34 −6
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 74ae4db..c66b09d 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -70,6 +70,28 @@ await test("newlines and text are preserved around links", () => {
assert.equal(roundtrip(input), input);
});
+await test("bare domain (no scheme) becomes a link", () => {
+ const link = tokenizeText("see rte.ie for news").find((t) => t.href);
+ assert.equal(link.text, "rte.ie");
+ assert.equal(link.href, "https://rte.ie");
+});
+
+await test("bare multi-part domain links, and a bare domain with a path", () => {
+ assert.equal(tokenizeText("on guardian.co.uk today").find((t) => t.href).href, "https://guardian.co.uk");
+ assert.equal(tokenizeText("rte.ie/news here").find((t) => t.href).href, "https://rte.ie/news");
+});
+
+await test("file names / abbreviations / versions are NOT linkified", () => {
+ for (const s of ["node.js", "index.html", "main.go", "see e.g. this", "v1.2.3"]) {
+ assert.ok(!tokenizeText(s).some((t) => t.href), s);
+ assert.equal(roundtrip(s), s);
+ }
+});
+
+await test("an email's domain is not linkified", () => {
+ assert.ok(!tokenizeText("ping paul@rte.ie now").some((t) => t.href));
+});
+
await test("formatBytes is human readable", () => {
assert.equal(formatBytes(500), "500 B");
assert.equal(formatBytes(1536), "1.5 KB");
diff --git a/web/static/format.js b/web/static/format.js
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;