Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Handle absolute markdown links with leading slash
Merged did:key:z6MkkfM3...sVz5 opened 2 years ago
9 files changed +70 -54 d6f3d178 2efd0ad5
modified src/components/InlineMarkdown.svelte
@@ -3,6 +3,7 @@

  import markdown from "@app/lib/markdown";
  import { twemoji } from "@app/lib/utils";
+
  import { activeUnloadedRouteStore } from "@app/lib/router";
  import { Renderer } from "@app/lib/markdown";

  export let content: string;
@@ -13,7 +14,9 @@
  const render = (content: string): string =>
    dompurify.sanitize(
      markdown.parseInline(content, {
-
        renderer: new Renderer(undefined, stripEmphasizedStyling),
+
        renderer: new Renderer($activeUnloadedRouteStore, {
+
          stripEmphasizedStyling,
+
        }),
      }) as string,
    );
</script>
modified src/components/Markdown.svelte
@@ -11,6 +11,7 @@
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import markdown from "@app/lib/markdown";
  import { Renderer } from "@app/lib/markdown";
+
  import { activeUnloadedRouteStore } from "@app/lib/router";
  import { highlight } from "@app/lib/syntax";
  import {
    isUrl,
@@ -22,8 +23,6 @@
  import { mimes } from "@app/lib/file";

  export let content: string;
-
  // If present, resolve all relative links with respect to this URL
-
  export let linkBaseUrl: string | undefined = undefined;
  export let path: string = "/";
  export let rawPath: string;
  // If present, means we are in a preview context,
@@ -63,7 +62,7 @@
  }

  /**
-
   * Do internal navigation on for clicks on anchor elements if possible
+
   * Do internal navigation for clicks on anchor elements if possible
   */
  function navigateInternalOnAnchor(event: MouseEvent) {
    if (router.useDefaultNavigation(event)) {
@@ -94,7 +93,9 @@
  function render(content: string): string {
    return dompurify.sanitize(
      markdown.parse(content, {
-
        renderer: new Renderer(linkBaseUrl, false),
+
        renderer: new Renderer($activeUnloadedRouteStore, {
+
          stripEmphasizedStyling: false,
+
        }),
        breaks,
      }) as string,
    );
modified src/lib/markdown.ts
@@ -1,4 +1,5 @@
import type { Tokens } from "marked";
+
import type { Route } from "@app/lib/router";

import dompurify from "dompurify";
import katexMarkedExtension from "marked-katex-extension";
@@ -6,6 +7,8 @@ import markedLinkifyIt from "marked-linkify-it";
import { Marked, Renderer as BaseRenderer } from "marked";

import emojis from "@app/lib/emojis";
+
import { routeToPath } from "@app/lib/router";
+
import { canonicalize, isUrl } from "@app/lib/utils";

dompurify.setConfig({
  // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -101,16 +104,19 @@ const anchorMarkedExtension = {
};

export class Renderer extends BaseRenderer {
-
  #baseUrl: string | undefined;
+
  #activeUnloadedRoute: Route;
  #stripEmphasizedStyling: boolean | undefined;

  /**
   * If `baseUrl` is provided, all hrefs attributes in anchor tags, except those
   * starting with `#`, are resolved with respect to `baseUrl`
   */
-
  constructor(baseUrl: string | undefined, stripEmphasizedStyling: boolean) {
+
  constructor(
+
    activeUnloadedRoute: Route,
+
    { stripEmphasizedStyling }: { stripEmphasizedStyling: boolean },
+
  ) {
    super();
-
    this.#baseUrl = baseUrl;
+
    this.#activeUnloadedRoute = activeUnloadedRoute;
    this.#stripEmphasizedStyling = stripEmphasizedStyling;
  }
  // Overwrites the rendering of heading tokens.
@@ -139,15 +145,21 @@ export class Renderer extends BaseRenderer {
    if (href.startsWith("#")) {
      // By lowercasing we avoid casing mismatches, between headings and links.
      return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
-
    } else {
-
      try {
-
        href = new URL(href, this.#baseUrl).href;
-
      } catch {
-
        // Use original href value
-
      }
-

-
      return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
    }
+

+
    if (
+
      "path" in this.#activeUnloadedRoute &&
+
      this.#activeUnloadedRoute.path &&
+
      !isUrl(href)
+
    ) {
+
      href = routeToPath({
+
        ...this.#activeUnloadedRoute,
+
        path: canonicalize(href, this.#activeUnloadedRoute.path),
+
        route: undefined,
+
      });
+
    }
+

+
    return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
  }
}

modified src/lib/utils.ts
@@ -122,12 +122,16 @@ export function canonicalize(
  base: string,
  origin = document.location.origin,
): string {
-
  path = path.replace(/^\//, ""); // Remove leading slash
-
  const finalPath = base
-
    .split("/")
-
    .slice(0, -1) // Remove file name.
-
    .concat([path]) // Add image file path.
-
    .join("/");
+
  let finalPath: string | undefined;
+
  if (path.startsWith("/")) {
+
    finalPath = new URL(path, origin).pathname;
+
  } else {
+
    finalPath = base
+
      .split("/")
+
      .slice(0, -1) // Remove file name.
+
      .concat([path]) // Add image file path.
+
      .join("/");
+
  }

  // URL is used to resolve relative paths, eg. `../../assets/image.png`.
  const url = new URL(finalPath, origin);
modified src/views/projects/Source.svelte
@@ -179,11 +179,9 @@
      <div class="column-right">
        {#if blobResult.ok}
          <BlobComponent
+
            {path}
            {baseUrl}
            projectId={project.id}
-
            {peer}
-
            {revision}
-
            {path}
            blob={blobResult.blob}
            highlighted={blobResult.highlighted}
            rawPath={rawPath(tree.lastCommit.id)} />
modified src/views/projects/Source/Blob.svelte
@@ -7,7 +7,6 @@
  import * as Syntax from "@app/lib/syntax";
  import { isImagePath, isMarkdownPath, isSvgPath } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
-
  import { routeToPath } from "@app/lib/router";

  import Button from "@app/components/Button.svelte";
  import CommitButton from "@app/views/projects/components/CommitButton.svelte";
@@ -20,8 +19,6 @@

  export let baseUrl: BaseUrl;
  export let projectId: string;
-
  export let peer: string | undefined;
-
  export let revision: string | undefined;
  export let path: string;
  export let blob: Blob;
  export let highlighted: Syntax.Root | undefined;
@@ -53,31 +50,6 @@
  $: enablePreview = isMarkdown || isSvg;
  $: preview = enablePreview && selectedLineId === undefined;

-
  let linkBaseUrl: string | undefined;
-

-
  $: {
-
    if (!path || path === "/") {
-
      // For the default root path, the `tree/<revision>` portion is omitted
-
      // from the URL. This means that links cannot be resolved with respect
-
      // to the current location. To work around this we provide path that
-
      // results a fully expanded URL with which we can resolve all links in the
-
      // Markdown.
-
      linkBaseUrl = new URL(
-
        routeToPath({
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          peer,
-
          revision,
-
          path: "README.md",
-
        }),
-
        window.origin,
-
      ).href;
-
    } else {
-
      linkBaseUrl = undefined;
-
    }
-
  }
-

  afterUpdate(() => {
    for (const item of document.getElementsByClassName("highlight")) {
      item.classList.remove("highlight");
@@ -217,7 +189,7 @@
  {:else if preview && blob.content}
    {#if isMarkdown}
      <div style:padding="2rem">
-
        <Markdown {rawPath} {path} {linkBaseUrl} content={blob.content} />
+
        <Markdown content={blob.content} {rawPath} {path} />
      </div>
    {:else if isSvg}
      <div style:margin="1rem 0" style:text-align="center">
modified tests/e2e/project.spec.ts
@@ -468,6 +468,26 @@ test("external markdown link", async ({ context, page }) => {
  await expect(newPage).toHaveURL("https://example.com");
});

+
test("absolute markdown link", async ({ page }) => {
+
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
+
  await page.getByRole("link", { name: "Absolute Link" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/main/relative-files/linked-file.md`,
+
  );
+
  await page.getByRole("link", { name: "nested file", exact: true }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/main/relative-files/nested-file.md`,
+
  );
+
  await page.goBack();
+
  await page.getByRole("link", { name: "nested file with" }).click();
+
  await expect(page).toHaveURL(
+
    `${markdownUrl}/tree/main/relative-files/nested-file.md`,
+
  );
+
  await page.goBack();
+
  await page.getByRole("link", { name: "Back to link-files with" }).click();
+
  await expect(page).toHaveURL(`${markdownUrl}/tree/main/link-files.md`);
+
});
+

test("internal file markdown link", async ({ page }) => {
  await page.goto(`${markdownUrl}/tree/main/link-files.md`);
  await page.getByRole("link", { name: "Markdown Cheatsheet" }).click();
modified tests/fixtures/repos/markdown.tar.bz2
modified tests/unit/utils.test.ts
@@ -167,6 +167,12 @@ describe("Path Manipulation", () => {
      expected: "assets/images/tux.png",
    },
    {
+
      imagePath: "/tux.md",
+
      base: "/components/assets/README.md",
+
      origin: "http://localhost:3000",
+
      expected: "tux.md",
+
    },
+
    {
      imagePath: "assets/images/tux.png",
      base: "/",
      origin: "https://app.radicle.xyz",