Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add markdown support to node descriptions
Matthew O'Gorman committed 1 year ago
commit 2075c320f38e64d3abad473dffe51301ed9d584c
parent b21b2ba
6 files changed +88 -47
modified http-client/lib/shared.ts
@@ -87,8 +87,8 @@ export const configSchema = object({
    pinned: object({
      repositories: array(string()),
    }),
-
    imageUrl: string().optional(),
-
    name: string().optional(),
+
    bannerUrl: string().optional(),
+
    avatarUrl: string().optional(),
    description: string().optional(),
  }),
  node: nodeConfigSchema,
modified src/components/Markdown.svelte
@@ -6,12 +6,12 @@
  import { afterUpdate } from "svelte";
  import { toDom } from "hast-util-to-dom";

-
  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
+
  import * as modal from "@app/lib/modal";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import { Renderer, markdownWithExtensions } from "@app/lib/markdown";
  import { activeUnloadedRouteStore } from "@app/lib/router";
  import { highlight } from "@app/lib/syntax";
+
  import { mimes } from "@app/lib/file";
  import {
    isUrl,
    twemoji,
@@ -19,7 +19,7 @@
    canonicalize,
    isCommit,
  } from "@app/lib/utils";
-
  import { mimes } from "@app/lib/file";
+
  import { Renderer, markdown } from "@app/lib/markdown";

  export let content: string;
  export let path: string = "/";
@@ -92,7 +92,12 @@

  function render(content: string): string {
    return dompurify.sanitize(
-
      markdownWithExtensions.parse(content, {
+
      markdown({
+
        katex: true,
+
        emojis: true,
+
        footnotes: true,
+
        linkify: true,
+
      }).parse(content, {
        renderer: new Renderer($activeUnloadedRouteStore),
        breaks,
      }) as string,
modified src/lib/markdown.ts
@@ -2,15 +2,15 @@ import type { MarkedExtension, Tokens } from "marked";
import type { Route } from "@app/lib/router";

import dompurify from "dompurify";
+
import footnoteMarkedExtension from "marked-footnote";
import katexMarkedExtension from "marked-katex-extension";
-
import markedFootnote from "marked-footnote";
-
import markedLinkifyIt from "marked-linkify-it";
+
import linkifyMarkedExtension from "marked-linkify-it";
import { Marked, Renderer as BaseRenderer } from "marked";
import { markedEmoji } from "marked-emoji";

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

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

-
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
-
// e.g. <a name="test"/> -> <a name="test"></a>
-
const anchorMarkedExtension = {
-
  name: "sanitizedAnchor",
-
  level: "block",
-
  start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
-
  tokenizer(src: string) {
-
    const match = src.match(/^<a name="([\w]+)"\/>/);
-
    if (match) {
-
      return {
-
        type: "sanitizedAnchor",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: Tokens.Generic): string => `<a name="${token.text}"></a>`,
-
};
-

export class Renderer extends BaseRenderer {
  #route: Route;

@@ -83,20 +64,64 @@ export class Renderer extends BaseRenderer {
  }
}

-
export default new Marked();
+
interface MarkedOptions {
+
  /** Converts double colon separated strings like `:emoji:` into img tags. */
+
  emojis?: boolean;
+
  /** Enable footnotes support. */
+
  footnotes?: boolean;
+
  /** Detect links and convert them into anchor tags. */
+
  linkify?: boolean;
+
  /** Enable katex support. */
+
  katex?: boolean;
+
}

-
export const markdownWithExtensions = new Marked(
-
  katexMarkedExtension({ throwOnError: false }),
-
  markedLinkifyIt({}, { fuzzyLink: false }),
-
  markedFootnote({ refMarkers: true }),
-
  markedEmoji({
-
    emojis,
-
    renderer: (token: { name: string; emoji: string }) => {
-
      const src = token.emoji.codePointAt(0)?.toString(16);
-
      return `<img alt="${token.name}" src="/twemoji/${src}.svg" class="txt-emoji">`;
+
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
+
// e.g. <a name="test"/> -> <a name="test"></a>
+
const anchorExtension: MarkedExtension = {
+
  extensions: [
+
    {
+
      name: "sanitizedAnchor",
+
      level: "block",
+
      start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
+
      tokenizer(src: string) {
+
        const match = src.match(/^<a name="([\w]+)"\/>/);
+
        if (match) {
+
          return {
+
            type: "sanitizedAnchor",
+
            raw: match[0],
+
            text: match[1].trim(),
+
          };
+
        }
+
      },
+
      renderer: (token: Tokens.Generic): string =>
+
        `<a name="${token.text}"></a>`,
    },
-
  }),
-
  ((): MarkedExtension => ({
-
    extensions: [anchorMarkedExtension],
-
  }))(),
-
);
+
  ],
+
};
+

+
// Converts double colon separated strings like `:emoji:` into img tags.
+
const emojiExtension = markedEmoji({
+
  emojis,
+
  renderer: (token: { name: string; emoji: string }) => {
+
    const src = token.emoji.codePointAt(0)?.toString(16);
+
    return `<img alt="${token.name}" src="/twemoji/${src}.svg" class="txt-emoji">`;
+
  },
+
});
+

+
const footnoteExtension = footnoteMarkedExtension({ refMarkers: true });
+
const linkifyExtension = linkifyMarkedExtension({}, { fuzzyLink: false });
+
const katexExtension = katexMarkedExtension({ throwOnError: false });
+

+
export function markdown(options: MarkedOptions): Marked {
+
  return new Marked(
+
    // Default extensions to always include.
+
    ...[anchorExtension],
+
    // Optional extensions to include according to use case.
+
    ...[
+
      ...(options.emojis ? [emojiExtension] : []),
+
      ...(options.footnotes ? [footnoteExtension] : []),
+
      ...(options.katex ? [katexExtension] : []),
+
      ...(options.linkify ? [linkifyExtension] : []),
+
    ],
+
  );
+
}
modified src/views/nodes/View.svelte
@@ -2,6 +2,8 @@
  import type { BaseUrl, Node, NodeStats } from "@http-client";

  import * as router from "@app/lib/router";
+
  import dompurify from "dompurify";
+
  import { markdown } from "@app/lib/markdown";
  import { baseUrlToString } from "@app/lib/utils";
  import { fetchRepoInfos } from "@app/components/RepoCard";
  import { handleError } from "@app/views/nodes/error";
@@ -42,6 +44,12 @@
  $: background = node.bannerUrl
    ? `url("${node.bannerUrl}")`
    : `url("/images/default-seed-header.png")`;
+

+
  function render(content: string): string {
+
    return dompurify.sanitize(
+
      markdown({ linkify: true, emojis: true }).parse(content) as string,
+
    );
+
  }
</script>

<style>
@@ -274,7 +282,7 @@
        </div>
        {#if node.description}
          <div class="description txt-small">
-
            {node.description}
+
            {@html render(node.description)}
          </div>
        {:else}
          <div
@@ -398,7 +406,7 @@
              style:gap="0.25rem">
              {#if node.description}
                <div class="description txt-small">
-
                  {node.description}
+
                  {@html render(node.description)}
                </div>
              {/if}
            </div>
modified src/views/repos/Source/RepoNameHeader.svelte
@@ -2,7 +2,7 @@
  import type { BaseUrl, Repo } from "@http-client";

  import dompurify from "dompurify";
-
  import { markdownWithExtensions } from "@app/lib/markdown";
+
  import { markdown } from "@app/lib/markdown";
  import { twemoji } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
@@ -18,7 +18,7 @@

  function render(content: string): string {
    return dompurify.sanitize(
-
      markdownWithExtensions.parseInline(content) as string,
+
      markdown({ linkify: true, emojis: true }).parseInline(content) as string,
    );
  }

modified tests/support/globalSetup.ts
@@ -77,6 +77,9 @@ export default async function globalSetup(): Promise<() => void> {
        pinned: {
          repositories: ["rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir"],
        },
+
        description: `:seedling: Radicle is an open source, peer-to-peer code collaboration stack built on Git.
+

+
:construction: [radicle.xyz](https://radicle.xyz)`,
      },
      node: {
        ...defaultConfig.node,