mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,7 +31,5 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<a {href} target="_blank" rel="noreferrer">
|
||||
<slot>{href}</slot>
|
||||
<span class="icon"><Icon name="open-external" /></span>
|
||||
</a>
|
||||
<!-- prettier-ignore -->
|
||||
<a {href} target="_blank" rel="noreferrer"><slot>{href}</slot><span class="icon"><Icon name="open-external" /></span></a>
|
||||
|
||||
@@ -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
|
||||
// `<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(
|
||||
id: string,
|
||||
baseUrl: BaseUrl,
|
||||
|
||||
@@ -290,12 +290,3 @@ export function getTagsFromRefs(
|
||||
}
|
||||
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>',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{#if commitMessageVisible}
|
||||
<div class="commit-message" style:margin="0.5rem 0">
|
||||
<pre>{@html dompurify.sanitize(
|
||||
convertUrlsToExternalLinks(escape(commit.description.trim())),
|
||||
)}</pre>
|
||||
<pre>{@html renderCommitDescription(commit.description)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="authorship global-hide-on-small-desktop-up">
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, Commit, Repo } from "@http-client";
|
||||
|
||||
import dompurify from "dompurify";
|
||||
import escape from "lodash/escape";
|
||||
import {
|
||||
baseUrlToString,
|
||||
formatObjectId,
|
||||
convertUrlsToExternalLinks,
|
||||
} from "@app/lib/utils";
|
||||
import { baseUrlToString, formatObjectId } from "@app/lib/utils";
|
||||
import { renderCommitDescription } from "@app/lib/commit";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Changeset from "@app/views/repos/Changeset.svelte";
|
||||
@@ -143,8 +138,8 @@
|
||||
</span>
|
||||
</div>
|
||||
{#if header.description}
|
||||
<pre class="description">{@html dompurify.sanitize(
|
||||
convertUrlsToExternalLinks(escape(header.description)),
|
||||
<pre class="description">{@html renderCommitDescription(
|
||||
header.description,
|
||||
)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
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 ExpandButton from "@app/components/ExpandButton.svelte";
|
||||
@@ -11,9 +12,6 @@
|
||||
import Link from "@app/components/Link.svelte";
|
||||
import Id from "@app/components/Id.svelte";
|
||||
|
||||
import dompurify from "dompurify";
|
||||
import escape from "lodash/escape";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
export let commit: CommitHeader;
|
||||
export let repoId: string;
|
||||
@@ -103,9 +101,7 @@
|
||||
</div>
|
||||
{#if commitMessageVisible}
|
||||
<div class="commit-message">
|
||||
<pre>{@html dompurify.sanitize(
|
||||
convertUrlsToExternalLinks(escape(commit.description.trim())),
|
||||
)}</pre>
|
||||
<pre>{@html renderCommitDescription(commit.description)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
<CommitAuthorship header={commit}>
|
||||
|
||||
@@ -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(
|
||||
"<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 <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="[^"]*>/);
|
||||
// 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 <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("<");
|
||||
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("<radicle-external-link");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user