Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Rewrite markdown link navigation
Rūdolfs Ošiņš committed 2 years ago
commit 74fa05cf26a9515cf439f887f1a9229c49b16adc
parent cb52e9654242d8501f5bd3081ab6799197f6781f
14 files changed +145 -121
modified src/components/Comment.svelte
@@ -1,6 +1,5 @@
<script lang="ts" strictEvents>
  import type { AuthorAliasColor } from "@app/components/Authorship.svelte";
-
  import type { BaseUrl } from "@httpd-client";

  import Authorship from "@app/components/Authorship.svelte";
  import Button from "@app/components/Button.svelte";
@@ -9,8 +8,6 @@
  import Textarea from "@app/components/Textarea.svelte";
  import { createEventDispatcher } from "svelte";

-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
  export let id: string | undefined = undefined;
  export let authorId: string;
  export let authorAlias: string | undefined = undefined;
@@ -85,7 +82,7 @@
    {:else if body.trim() === ""}
      <span class="txt-missing">No description.</span>
    {:else}
-
      <Markdown {projectId} {baseUrl} {rawPath} content={body} />
+
      <Markdown {rawPath} content={body} />
    {/if}
  </div>
</div>
modified src/components/InlineMarkdown.svelte
@@ -2,19 +2,11 @@
  import dompurify from "dompurify";
  import { marked } from "marked";

-
  import { renderer } from "@app/lib/markdown";
  import { twemoji } from "@app/lib/utils";

  export let content: string;
  export let fontSize: "tiny" | "small" | "medium" = "small";

-
  marked.use({
-
    renderer,
-
    // TODO: Disables deprecated options, remove once removed from marked
-
    mangle: false,
-
    headerIds: false,
-
  });
-

  const render = (content: string): string =>
    dompurify.sanitize(marked.parseInline(content));
</script>
modified src/components/Markdown.svelte
@@ -1,58 +1,64 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
-

  import dompurify from "dompurify";
  import matter from "@radicle/gray-matter";
  import { afterUpdate } from "svelte";
  import { marked } from "marked";
  import { toDom } from "hast-util-to-dom";

-
  import * as utils from "@app/lib/utils";
  import * as router from "@app/lib/router";
  import { highlight } from "@app/lib/syntax";
  import { isUrl, twemoji, scrollIntoView, canonicalize } from "@app/lib/utils";
-
  import {
-
    markdownExtensions as extensions,
-
    renderer,
-
  } from "@app/lib/markdown";
+
  import { Renderer } from "@app/lib/markdown";

-
  export let baseUrl: BaseUrl;
  export let content: string;
  export let hash: string | undefined = undefined;
+
  // If present, resolve all relative links with respect to this URL
+
  export let linkBaseUrl: string | undefined = undefined;
  export let path: string = "/";
-
  export let projectId: string;
  export let rawPath: string | undefined = undefined;

  $: doc = matter(content);
  $: frontMatter = Object.entries(doc.data).filter(
    ([, val]) => typeof val === "string" || typeof val === "number",
  );
-
  marked.use({
-
    extensions,
-
    renderer,
-
    // TODO: Disables deprecated options, remove once removed from marked
-
    mangle: false,
-
    headerIds: false,
-
  });
-

  let container: HTMLElement;

-
  const render = (content: string): string =>
-
    dompurify.sanitize(marked.parse(content));
-

-
  function navigateToMarkdownLink(event: any) {
-
    if (event.target.matches(".file-link")) {
-
      event.preventDefault();
-
      void router.push({
-
        resource: "projects",
-
        params: {
-
          id: projectId,
-
          baseUrl,
-
          view: { resource: "tree" },
-
          path: utils.canonicalize(event.target.getAttribute("href"), path),
-
        },
-
      });
+
  /**
+
   * Do internal navigation on for clicks on anchor elements if possible
+
   */
+
  function navigateInternalOnAnchor(event: MouseEvent) {
+
    if (router.useDefaultNavigation(event)) {
+
      return;
+
    }
+

+
    let url: URL;
+
    if (!(event.target instanceof HTMLAnchorElement)) {
+
      return;
+
    }
+
    const href = event.target?.getAttribute("href");
+
    if (href === null || href.startsWith("#")) {
+
      return;
+
    }
+

+
    try {
+
      url = new URL(href, window.location.href);
+
    } catch {
+
      return;
    }
+

+
    event.preventDefault();
+
    void router.navigateToUrl("push", url);
+
  }
+

+
  function render(content: string): string {
+
    return dompurify.sanitize(
+
      marked.parse(content, {
+
        renderer: new Renderer(linkBaseUrl),
+
        // TODO: Disables deprecated options, remove once removed from marked
+
        mangle: false,
+
        headerIds: false,
+
      }),
+
    );
  }

  afterUpdate(async () => {
@@ -82,11 +88,6 @@
      }
    }

-
    const fileAnchorTags = document.querySelectorAll(".file-link");
-
    fileAnchorTags.forEach(anchorTag => {
-
      anchorTag.addEventListener("click", navigateToMarkdownLink);
-
    });
-

    // Replaces code blocks in the background with highlighted code.
    const prefix = "language-";
    const nodes = Array.from(document.body.querySelectorAll("pre code"));
@@ -344,6 +345,13 @@
  </div>
{/if}

-
<div class="markdown" bind:this={container} use:twemoji={{ exclude: ["21a9"] }}>
+
<!-- The click handler only handles bubbling events from anchor tags -->
+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y-no-static-element-interactions -->
+
<div
+
  class="markdown"
+
  bind:this={container}
+
  use:twemoji={{ exclude: ["21a9"] }}
+
  on:click={navigateInternalOnAnchor}>
  {@html render(doc.content)}
</div>
modified src/components/Thread.svelte
@@ -1,5 +1,4 @@
<script lang="ts" strictEvents>
-
  import type { BaseUrl } from "@httpd-client";
  import type { Comment } from "@httpd-client";

  import Button from "@app/components/Button.svelte";
@@ -9,8 +8,6 @@
  import { scrollIntoView } from "@app/lib/utils";
  import { httpdStore } from "@app/lib/httpd";

-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
  export let showReplyTextarea = false;
@@ -76,8 +73,6 @@
<div class="comments">
  <div class="comment">
    <CommentComponent
-
      {projectId}
-
      {baseUrl}
      {rawPath}
      id={root.id}
      authorId={root.author.id}
@@ -90,8 +85,6 @@
  {#each replies as reply}
    <div class="comment reply">
      <CommentComponent
-
        {projectId}
-
        {baseUrl}
        {rawPath}
        id={reply.id}
        authorId={reply.author.id}
modified src/lib/markdown.ts
@@ -1,8 +1,7 @@
import dompurify from "dompurify";
import emojis from "@app/lib/emojis";
import katex from "katex";
-
import { marked } from "marked";
-
import { isUrl } from "@app/lib/utils";
+
import { marked, Renderer as BaseRenderer } from "marked";

dompurify.setConfig({
  // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -11,9 +10,6 @@ dompurify.setConfig({
  FORBID_TAGS: ["textarea", "style"],
});

-
// TODO: Disables deprecated options, remove once removed from marked
-
marked.use({ mangle: false, headerIds: false });
-

const emojisMarkedExtension = {
  name: "emoji",
  level: "inline",
@@ -126,7 +122,30 @@ const anchorMarkedExtension = {
  },
};

-
export const renderer = {
+
// TODO: Disables deprecated options, remove once removed from marked
+
marked.use({
+
  extensions: [
+
    anchorMarkedExtension,
+
    emojisMarkedExtension,
+
    footnoteMarkedExtension,
+
    footnoteReferenceMarkedExtension,
+
    katexMarkedExtension,
+
  ],
+
  mangle: false,
+
  headerIds: false,
+
});
+

+
export class Renderer extends BaseRenderer {
+
  #baseUrl: string | 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) {
+
    super();
+
    this.#baseUrl = baseUrl;
+
  }
  // Overwrites the rendering of heading tokens.
  // Since there are possible non ASCII characters in headings,
  // we escape them by replacing them with dashes and,
@@ -139,24 +158,19 @@ export const renderer = {
      .replace(/^-|-$/g, "");

    return `<h${level} id="${escapedText}">${text}</h${level}>`;
-
  },
-
  link(href: string, _title: string, text: string) {
-
    // Adding the file-link class to relative file names,
-
    // so we're able to navigate to the file in the editor.
-
    if (!isUrl(href) && !href.startsWith("#")) {
-
      return `<a href="${href}" class="file-link">${text}</a>`;
-
    } else if (href.startsWith("#")) {
+
  }
+

+
  link(href: string, _title: string, text: string): string {
+
    if (href.startsWith("#")) {
      // By lowercasing we avoid casing mismatches, between headings and links.
      return `<a href="${href.toLowerCase()}">${text}</a>`;
+
    } else {
+
      try {
+
        href = new URL(href, this.#baseUrl).href;
+
      } catch {
+
        // Use original href value
+
      }
+
      return `<a href="${href}">${text}</a>`;
    }
-
    return `<a href="${href}">${text}</a>`;
-
  },
-
};
-

-
export const markdownExtensions = [
-
  anchorMarkedExtension,
-
  emojisMarkedExtension,
-
  footnoteMarkedExtension,
-
  footnoteReferenceMarkedExtension,
-
  katexMarkedExtension,
-
];
+
  }
+
}
modified src/lib/router.ts
@@ -38,7 +38,18 @@ export function useDefaultNavigation(event: MouseEvent) {
export const base = import.meta.env.VITE_HASH_ROUTING ? "./" : "/";

export async function loadFromLocation(): Promise<void> {
-
  let { pathname, hash } = window.location;
+
  await navigateToUrl("replace", new URL(window.location.href));
+
}
+

+
export async function navigateToUrl(
+
  action: "push" | "replace",
+
  url: URL,
+
): Promise<void> {
+
  let { pathname, hash } = url;
+

+
  if (url.origin !== window.origin) {
+
    throw new Error("Cannot navigate to other origin");
+
  }

  if (import.meta.env.VITE_HASH_ROUTING) {
    if (pathname === "/" && hash && !hash.startsWith("#/")) {
@@ -52,20 +63,23 @@ export async function loadFromLocation(): Promise<void> {
    if (
      currentUrl &&
      currentUrl.pathname === pathname &&
-
      currentUrl.search === window.location.search
+
      currentUrl.search === url.search
    ) {
      return;
    }
  }

-
  const relativeUrl = pathname + window.location.search + (hash || "");
-
  const url = new URL(relativeUrl, window.origin);
+
  const relativeUrl = pathname + url.search + (hash || "");
+
  url = new URL(relativeUrl, window.origin);
  const route = pathToRoute(url);

  if (route) {
    await replace(route);
  } else {
-
    await replace({ resource: "notFound", params: { url: relativeUrl } });
+
    await navigate(action, {
+
      resource: "notFound",
+
      params: { url: relativeUrl },
+
    });
  }
}

modified src/views/projects/Blob.svelte
@@ -13,6 +13,8 @@

  export let baseUrl: BaseUrl;
  export let projectId: string;
+
  export let peer: string | undefined;
+
  export let revision: string | undefined;
  export let path: string;
  export let hash: string | undefined = undefined;
  export let blob: Blob;
@@ -261,6 +263,8 @@
      <Readme
        {baseUrl}
        {projectId}
+
        {peer}
+
        {revision}
        content={blob.content}
        {rawPath}
        {path}
modified src/views/projects/Browser.svelte
@@ -176,8 +176,10 @@
      <div class="column-right">
        {#if blobResult.ok}
          <BlobComponent
-
            projectId={project.id}
            {baseUrl}
+
            projectId={project.id}
+
            {peer}
+
            {revision}
            {path}
            {hash}
            blob={blobResult.blob}
modified src/views/projects/Cob/Revision.svelte
@@ -282,8 +282,6 @@
        {#if revisionDescription && !first}
          <div class="revision-description txt-small">
            <Markdown
-
              {baseUrl}
-
              {projectId}
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
              content={revisionDescription} />
          </div>
@@ -347,8 +345,6 @@
        <div style:margin-left="1.5rem">
          {#if element.type === "thread"}
            <Thread
-
              {baseUrl}
-
              {projectId}
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
              thread={element.inner}
              on:reply />
@@ -383,8 +379,6 @@
                class:positive-review={review.verdict === "accept"}
                class:negative-review={review.verdict === "reject"}>
                <CommentComponent
-
                  {baseUrl}
-
                  {projectId}
                  caption={formatVerdict(review.verdict)}
                  authorId={author}
                  authorAlias={review.author.alias}
modified src/views/projects/Issue.svelte
@@ -332,8 +332,6 @@
      </svelte:fragment>
      <div slot="description">
        <Markdown
-
          {baseUrl}
-
          {projectId}
          content={issue.discussion[0].body}
          rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
      </div>
@@ -346,12 +344,7 @@
    </CobHeader>
    {#each threads as thread (thread.root.id)}
      <div class="thread">
-
        <ThreadComponent
-
          {baseUrl}
-
          {projectId}
-
          {thread}
-
          {rawPath}
-
          on:reply={createReply} />
+
        <ThreadComponent {thread} {rawPath} on:reply={createReply} />
      </div>
    {/each}
    {#if $httpdStore.state === "authenticated"}
modified src/views/projects/Issue/New.svelte
@@ -154,8 +154,6 @@
              <p class="txt-missing">No description</p>
            {:else}
              <Markdown
-
                {baseUrl}
-
                {projectId}
                content={issueText}
                rawPath={utils.getRawBasePath(
                  projectId,
modified src/views/projects/Patch.svelte
@@ -303,8 +303,6 @@
      <svelte:fragment slot="description">
        {#if patch.revisions[0].description}
          <Markdown
-
            {projectId}
-
            {baseUrl}
            content={patch.revisions[0].description}
            rawPath={utils.getRawBasePath(
              projectId,
modified src/views/projects/Readme.svelte
@@ -2,13 +2,44 @@
  import type { BaseUrl } from "@httpd-client";

  import Markdown from "@app/components/Markdown.svelte";
+
  import { routeToPath } from "@app/lib/router";

+
  export let projectId: string;
+
  export let peer: string | undefined;
  export let baseUrl: BaseUrl;
+
  export let revision: string | undefined;
  export let content: string;
  export let hash: string | undefined = undefined;
  export let path: string;
-
  export let projectId: string;
  export let rawPath: string;
+

+
  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: "projects",
+
          params: {
+
            id: projectId,
+
            baseUrl,
+
            view: { resource: "tree" },
+
            peer,
+
            revision,
+
            path: "README.md",
+
          },
+
        }),
+
        window.origin,
+
      ).href;
+
    } else {
+
      linkBaseUrl = undefined;
+
    }
+
  }
</script>

<style>
@@ -22,5 +53,5 @@
</style>

<article>
-
  <Markdown {baseUrl} {projectId} {content} {hash} {rawPath} {path} />
+
  <Markdown {linkBaseUrl} {content} {hash} {rawPath} {path} />
</article>
modified src/views/projects/router.ts
@@ -460,20 +460,6 @@ function sanitizeQueryString(queryString: string): string {
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
}

-
function createProjectRoute(
-
  activeRoute: ProjectRoute,
-
  projectRouteParams: Partial<ProjectsParams>,
-
): ProjectRoute {
-
  return {
-
    resource: "projects",
-
    params: {
-
      ...activeRoute.params,
-
      hash: undefined,
-
      ...projectRouteParams,
-
    },
-
  };
-
}
-

export function resolveProjectRoute(
  url: URL,
  baseUrl: BaseUrl,