From 3f798744bbd1a7327a23aab32b39d27ef4885736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 6 May 2026 09:35:26 +0200 Subject: [PATCH] Use linkify-it for commit description URLs Replace the hand-rolled URL regex with `linkify-it` (already a transitive dep via `marked-linkify-it`). Add unit tests. --- package-lock.json | 2 + package.json | 2 + src/components/ExternalLink.svelte | 6 +- src/lib/commit.ts | 38 ++++++++++ src/lib/utils.ts | 9 --- src/views/repos/Cob/CobCommitTeaser.svelte | 10 +-- src/views/repos/Commit.svelte | 13 ++-- src/views/repos/Commit/CommitTeaser.svelte | 10 +-- tests/unit/commit.test.ts | 81 ++++++++++++++++++++++ 9 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 tests/unit/commit.test.ts diff --git a/package-lock.json b/package-lock.json index 5efe873af..d1760025b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "fuzzysort": "^3.1.0", "hast-util-to-dom": "^4.0.1", "hast-util-to-html": "^9.0.5", + "linkify-it": "^5.0.0", "lodash": "^4.18.1", "lru-cache": "^11.3.5", "marked": "^18.0.2", @@ -38,6 +39,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tsconfig/svelte": "^5.0.8", "@types/katex": "^0.16.8", + "@types/linkify-it": "^5.0.0", "@types/lodash": "^4.17.24", "@types/md5": "^2.3.6", "@types/node": "^24", diff --git a/package.json b/package.json index b502361f3..55f26f6e6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tsconfig/svelte": "^5.0.8", "@types/katex": "^0.16.8", + "@types/linkify-it": "^5.0.0", "@types/lodash": "^4.17.24", "@types/md5": "^2.3.6", "@types/node": "^24", @@ -67,6 +68,7 @@ "fuzzysort": "^3.1.0", "hast-util-to-dom": "^4.0.1", "hast-util-to-html": "^9.0.5", + "linkify-it": "^5.0.0", "lodash": "^4.18.1", "lru-cache": "^11.3.5", "marked": "^18.0.2", diff --git a/src/components/ExternalLink.svelte b/src/components/ExternalLink.svelte index efd5c84ce..ce1388017 100644 --- a/src/components/ExternalLink.svelte +++ b/src/components/ExternalLink.svelte @@ -31,7 +31,5 @@ } - - {href} - - + +{href} diff --git a/src/lib/commit.ts b/src/lib/commit.ts index 88f996d89..8a23f3e1c 100644 --- a/src/lib/commit.ts +++ b/src/lib/commit.ts @@ -1,8 +1,14 @@ import type { BaseUrl, CommitHeader } from "@http-client"; +import LinkifyIt from "linkify-it"; +import dompurify from "dompurify"; +import escape from "lodash/escape"; + import { getDaysPassed } from "@app/lib/utils"; import { HttpdClient } from "@http-client"; +const linkify = new LinkifyIt({}, { fuzzyLink: false }); + // A set of commits grouped by time. interface CommitGroup { date: string; @@ -110,6 +116,38 @@ function groupCommitsByWeek(commits: number[]): WeeklyActivity[] { return groupedCommits; } +// Renders a commit description as safe HTML, with bare URLs converted to +// `` components. +export function renderCommitDescription(text: string): string { + const trimmed = text.trim(); + // Match http(s) only; avoids turning bare emails into `mailto:` links inside + // commit messages. + const matches = (linkify.match(trimmed) ?? []).filter( + m => m.schema === "http:" || m.schema === "https:", + ); + let out = ""; + let cursor = 0; + for (const m of matches) { + // CommonMark autolink: ``. Drop the surrounding + // brackets from the visible output rather than rendering them as text. + const isAutolink = + trimmed[m.index - 1] === "<" && trimmed[m.lastIndex] === ">"; + const segmentEnd = isAutolink ? m.index - 1 : m.index; + out += escape(trimmed.slice(cursor, segmentEnd)); + const href = escape(m.url); + const display = escape(m.text); + out += `${display}`; + cursor = isAutolink ? m.lastIndex + 1 : m.lastIndex; + } + out += escape(trimmed.slice(cursor)); + return dompurify.sanitize(out, { + /* eslint-disable @typescript-eslint/naming-convention */ + ADD_TAGS: ["radicle-external-link"], + ADD_ATTR: ["href"], + /* eslint-enable @typescript-eslint/naming-convention */ + }); +} + export async function loadRepoActivity( id: string, baseUrl: BaseUrl, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7f973a05e..983677aa7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -290,12 +290,3 @@ export function getTagsFromRefs( } return tags; } - -// Converts plain URLs into components -export function convertUrlsToExternalLinks(text: string): string { - const urlRegex = /(https?:\/\/[^\s]+)/g; - return text.replace( - urlRegex, - '$1', - ); -} diff --git a/src/views/repos/Cob/CobCommitTeaser.svelte b/src/views/repos/Cob/CobCommitTeaser.svelte index 09dc9b00e..ddc8bf702 100644 --- a/src/views/repos/Cob/CobCommitTeaser.svelte +++ b/src/views/repos/Cob/CobCommitTeaser.svelte @@ -2,7 +2,8 @@ import type { BaseUrl, CommitHeader } from "@http-client"; import type { Snippet } from "svelte"; - import { twemoji, convertUrlsToExternalLinks } from "@app/lib/utils"; + import { twemoji } from "@app/lib/utils"; + import { renderCommitDescription } from "@app/lib/commit"; import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte"; import ExpandButton from "@app/components/ExpandButton.svelte"; @@ -12,9 +13,6 @@ import InlineTitle from "@app/views/repos/components/InlineTitle.svelte"; import Link from "@app/components/Link.svelte"; - import dompurify from "dompurify"; - import escape from "lodash/escape"; - export let baseUrl: BaseUrl; export let commit: CommitHeader; export let repoId: string; @@ -93,9 +91,7 @@ {#if commitMessageVisible}
-
{@html dompurify.sanitize(
-            convertUrlsToExternalLinks(escape(commit.description.trim())),
-          )}
+
{@html renderCommitDescription(commit.description)}
{/if}
diff --git a/src/views/repos/Commit.svelte b/src/views/repos/Commit.svelte index 1f41ad46d..4f1adbe87 100644 --- a/src/views/repos/Commit.svelte +++ b/src/views/repos/Commit.svelte @@ -1,13 +1,8 @@ ")).toEqual( + "<script>alert(1)</script>", + ); + }); + + test("trims leading and trailing whitespace", () => { + expect(commit.renderCommitDescription(" hello ")).toEqual("hello"); + }); + + test("converts a bare URL into a radicle-external-link", () => { + expect(commit.renderCommitDescription("see https://example.com")).toEqual( + 'see https://example.com', + ); + }); + + test("strips surrounding angle brackets (CommonMark autolink)", () => { + const out = commit.renderCommitDescription( + "see for code", + ); + expect(out).toContain('href="https://github.com/radicle-dev/heartwood"'); + expect(out).not.toMatch(/href="[^"]*>/); + // Autolink syntax should not produce visible angle brackets. + expect(out).not.toContain("<"); + expect(out).not.toContain(">"); + }); + + test("does not include trailing period in the link href", () => { + const out = commit.renderCommitDescription("see https://example.com."); + expect(out).toContain('href="https://example.com"'); + expect(out).not.toContain('href="https://example.com."'); + expect(out.endsWith(".")).toBe(true); + }); + + test("autolink followed by period: brackets stripped, period preserved", () => { + const out = commit.renderCommitDescription( + "See .", + ); + expect(out).toContain( + 'href="https://doc.rust-lang.org/edition-guide/rust-2024/"', + ); + expect(out).not.toContain("<"); + expect(out).not.toContain(">"); + expect(out.endsWith(".")).toBe(true); + }); + + test("does not include enclosing parentheses in the link href", () => { + const out = commit.renderCommitDescription("see (https://example.com)"); + expect(out).toContain('href="https://example.com"'); + expect(out).not.toContain('href="https://example.com)"'); + }); + + test("preserves ampersands inside query strings", () => { + const out = commit.renderCommitDescription("https://example.com/?a=1&b=2"); + expect(out).toContain('href="https://example.com/?a=1&b=2"'); + }); + + test("links multiple URLs in one description", () => { + const out = commit.renderCommitDescription( + "see https://a.example and https://b.example", + ); + expect(out).toContain('href="https://a.example"'); + expect(out).toContain('href="https://b.example"'); + }); + + test("leaves text without URLs untouched", () => { + expect(commit.renderCommitDescription("just some plain text")).toEqual( + "just some plain text", + ); + }); + + test("does not linkify text without a scheme (fuzzyLink off)", () => { + const out = commit.renderCommitDescription("contact foo@example.com"); + expect(out).not.toContain("