Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Use linkify-it for commit description URLs
Rūdolfs Ošiņš committed 14 days ago
commit 3f798744bbd1a7327a23aab32b39d27ef4885736
parent 20be88e5dafa43d1bbd0203f34b6e99aed607b66
9 files changed +135 -36
modified 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",
modified 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",
modified src/components/ExternalLink.svelte
@@ -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>
modified 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
+
// `<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,
modified src/lib/utils.ts
@@ -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>',
-
  );
-
}
modified 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 @@
    </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">
modified src/views/repos/Commit.svelte
@@ -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>
modified src/views/repos/Commit/CommitTeaser.svelte
@@ -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}>
added tests/unit/commit.test.ts
@@ -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");
+
  });
+
});