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.
This commit is contained in:
Rūdolfs Ošiņš
2026-05-06 09:54:04 +02:00
parent 20be88e5da
commit 3f798744bb
9 changed files with 135 additions and 36 deletions
+2
View File
@@ -20,6 +20,7 @@
"fuzzysort": "^3.1.0", "fuzzysort": "^3.1.0",
"hast-util-to-dom": "^4.0.1", "hast-util-to-dom": "^4.0.1",
"hast-util-to-html": "^9.0.5", "hast-util-to-html": "^9.0.5",
"linkify-it": "^5.0.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"lru-cache": "^11.3.5", "lru-cache": "^11.3.5",
"marked": "^18.0.2", "marked": "^18.0.2",
@@ -38,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/linkify-it": "^5.0.0",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6", "@types/md5": "^2.3.6",
"@types/node": "^24", "@types/node": "^24",
+2
View File
@@ -30,6 +30,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/linkify-it": "^5.0.0",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6", "@types/md5": "^2.3.6",
"@types/node": "^24", "@types/node": "^24",
@@ -67,6 +68,7 @@
"fuzzysort": "^3.1.0", "fuzzysort": "^3.1.0",
"hast-util-to-dom": "^4.0.1", "hast-util-to-dom": "^4.0.1",
"hast-util-to-html": "^9.0.5", "hast-util-to-html": "^9.0.5",
"linkify-it": "^5.0.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"lru-cache": "^11.3.5", "lru-cache": "^11.3.5",
"marked": "^18.0.2", "marked": "^18.0.2",
+2 -4
View File
@@ -31,7 +31,5 @@
} }
</style> </style>
<a {href} target="_blank" rel="noreferrer"> <!-- prettier-ignore -->
<slot>{href}</slot> <a {href} target="_blank" rel="noreferrer"><slot>{href}</slot><span class="icon"><Icon name="open-external" /></span></a>
<span class="icon"><Icon name="open-external" /></span>
</a>
+38
View File
@@ -1,8 +1,14 @@
import type { BaseUrl, CommitHeader } from "@http-client"; 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 { getDaysPassed } from "@app/lib/utils";
import { HttpdClient } from "@http-client"; import { HttpdClient } from "@http-client";
const linkify = new LinkifyIt({}, { fuzzyLink: false });
// A set of commits grouped by time. // A set of commits grouped by time.
interface CommitGroup { interface CommitGroup {
date: string; date: string;
@@ -110,6 +116,38 @@ function groupCommitsByWeek(commits: number[]): WeeklyActivity[] {
return groupedCommits; return groupedCommits;
} }
// Renders a commit description as safe HTML, with bare URLs converted to
// `<radicle-external-link>` 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: `<https://example.com>`. 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 += `<radicle-external-link href="${href}">${display}</radicle-external-link>`;
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( export async function loadRepoActivity(
id: string, id: string,
baseUrl: BaseUrl, baseUrl: BaseUrl,
-9
View File
@@ -290,12 +290,3 @@ export function getTagsFromRefs(
} }
return tags; return tags;
} }
// Converts plain URLs into <radicle-external-link> components
export function convertUrlsToExternalLinks(text: string): string {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.replace(
urlRegex,
'<radicle-external-link href="$1">$1</radicle-external-link>',
);
}
+3 -7
View File
@@ -2,7 +2,8 @@
import type { BaseUrl, CommitHeader } from "@http-client"; import type { BaseUrl, CommitHeader } from "@http-client";
import type { Snippet } from "svelte"; 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 CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
import ExpandButton from "@app/components/ExpandButton.svelte"; import ExpandButton from "@app/components/ExpandButton.svelte";
@@ -12,9 +13,6 @@
import InlineTitle from "@app/views/repos/components/InlineTitle.svelte"; import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
import Link from "@app/components/Link.svelte"; import Link from "@app/components/Link.svelte";
import dompurify from "dompurify";
import escape from "lodash/escape";
export let baseUrl: BaseUrl; export let baseUrl: BaseUrl;
export let commit: CommitHeader; export let commit: CommitHeader;
export let repoId: string; export let repoId: string;
@@ -93,9 +91,7 @@
</div> </div>
{#if commitMessageVisible} {#if commitMessageVisible}
<div class="commit-message" style:margin="0.5rem 0"> <div class="commit-message" style:margin="0.5rem 0">
<pre>{@html dompurify.sanitize( <pre>{@html renderCommitDescription(commit.description)}</pre>
convertUrlsToExternalLinks(escape(commit.description.trim())),
)}</pre>
</div> </div>
{/if} {/if}
<div class="authorship global-hide-on-small-desktop-up"> <div class="authorship global-hide-on-small-desktop-up">
+4 -9
View File
@@ -1,13 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { BaseUrl, Commit, Repo } from "@http-client"; import type { BaseUrl, Commit, Repo } from "@http-client";
import dompurify from "dompurify"; import { baseUrlToString, formatObjectId } from "@app/lib/utils";
import escape from "lodash/escape"; import { renderCommitDescription } from "@app/lib/commit";
import {
baseUrlToString,
formatObjectId,
convertUrlsToExternalLinks,
} from "@app/lib/utils";
import Button from "@app/components/Button.svelte"; import Button from "@app/components/Button.svelte";
import Changeset from "@app/views/repos/Changeset.svelte"; import Changeset from "@app/views/repos/Changeset.svelte";
@@ -143,8 +138,8 @@
</span> </span>
</div> </div>
{#if header.description} {#if header.description}
<pre class="description">{@html dompurify.sanitize( <pre class="description">{@html renderCommitDescription(
convertUrlsToExternalLinks(escape(header.description)), header.description,
)}</pre> )}</pre>
{/if} {/if}
</div> </div>
+3 -7
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { BaseUrl, CommitHeader } from "@http-client"; import type { BaseUrl, CommitHeader } from "@http-client";
import { convertUrlsToExternalLinks, twemoji } from "@app/lib/utils"; import { twemoji } from "@app/lib/utils";
import { renderCommitDescription } from "@app/lib/commit";
import CommitAuthorship from "./CommitAuthorship.svelte"; import CommitAuthorship from "./CommitAuthorship.svelte";
import ExpandButton from "@app/components/ExpandButton.svelte"; import ExpandButton from "@app/components/ExpandButton.svelte";
@@ -11,9 +12,6 @@
import Link from "@app/components/Link.svelte"; import Link from "@app/components/Link.svelte";
import Id from "@app/components/Id.svelte"; import Id from "@app/components/Id.svelte";
import dompurify from "dompurify";
import escape from "lodash/escape";
export let baseUrl: BaseUrl; export let baseUrl: BaseUrl;
export let commit: CommitHeader; export let commit: CommitHeader;
export let repoId: string; export let repoId: string;
@@ -103,9 +101,7 @@
</div> </div>
{#if commitMessageVisible} {#if commitMessageVisible}
<div class="commit-message"> <div class="commit-message">
<pre>{@html dompurify.sanitize( <pre>{@html renderCommitDescription(commit.description)}</pre>
convertUrlsToExternalLinks(escape(commit.description.trim())),
)}</pre>
</div> </div>
{/if} {/if}
<CommitAuthorship header={commit}> <CommitAuthorship header={commit}>
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vitest";
import * as commit from "@app/lib/commit";
describe("renderCommitDescription", () => {
test("escapes HTML in commit text", () => {
expect(commit.renderCommitDescription("<script>alert(1)</script>")).toEqual(
"&lt;script&gt;alert(1)&lt;/script&gt;",
);
});
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 <radicle-external-link href="https://example.com">https://example.com</radicle-external-link>',
);
});
test("strips surrounding angle brackets (CommonMark autolink)", () => {
const out = commit.renderCommitDescription(
"see <https://github.com/radicle-dev/heartwood> for code",
);
expect(out).toContain('href="https://github.com/radicle-dev/heartwood"');
expect(out).not.toMatch(/href="[^"]*&gt;/);
// Autolink syntax should not produce visible angle brackets.
expect(out).not.toContain("&lt;");
expect(out).not.toContain("&gt;");
});
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 <https://doc.rust-lang.org/edition-guide/rust-2024/>.",
);
expect(out).toContain(
'href="https://doc.rust-lang.org/edition-guide/rust-2024/"',
);
expect(out).not.toContain("&lt;");
expect(out).not.toContain("&gt;");
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&amp;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("<radicle-external-link");
});
});