Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add footnotes support to markdown
Sebastian Martinez committed 3 years ago
commit c8b8c18064b5caad71ad1535263f238d66fc9352
parent 993c9f87081f47a1170f7c4b8b462bb3b0e4bfb1
4 files changed +154 -116
modified src/components/Markdown.svelte
@@ -8,15 +8,12 @@
  import { toDom } from "hast-util-to-dom";

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

  export let content: string;
  export let doc = matter(content);
@@ -183,6 +180,12 @@
    font-weight: var(--font-weight-medium);
  }

+
  .markdown :global(.footnote-ref > a),
+
  .markdown :global(a.ref-arrow) {
+
    border-bottom: none;
+
    color: unset;
+
  }
+

  .markdown :global(img) {
    border-style: none;
    max-width: 100%;
@@ -309,6 +312,6 @@
  </div>
{/if}

-
<div class="markdown" bind:this={container} use:twemoji>
+
<div class="markdown" bind:this={container} use:twemoji={{ exclude: ["21a9"] }}>
  {@html render(doc.content)}
</div>
added src/lib/markdown.ts
@@ -0,0 +1,136 @@
+
import emojis from "@app/lib/emojis";
+
import katex from "katex";
+
import { marked } from "marked";
+

+
const emojisMarkedExtension = {
+
  name: "emoji",
+
  level: "inline",
+
  start: (src: string) => src.indexOf(":"),
+
  tokenizer(src: string) {
+
    const match = src.match(/^:([\w+-]+):/);
+
    if (match) {
+
      return {
+
        type: "emoji",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) => {
+
    return `<span>${
+
      token.text in emojis ? emojis[token.text] : token.text
+
    }</span>`;
+
  },
+
};
+

+
const katexMarkedExtension = {
+
  name: "katex",
+
  level: "inline",
+
  start: (src: string) => src.indexOf("$"),
+
  tokenizer(src: string) {
+
    const match = src.match(/^\$+([^$\n]+?)\$+/);
+
    if (match) {
+
      return {
+
        type: "katex",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) =>
+
    katex.renderToString(token.text, {
+
      throwOnError: false,
+
    }),
+
};
+
const footnotePrefix = "marked-fn";
+
const referencePrefix = "marked-fnref";
+
const referenceMatch = /^\[\^([^\]]+)\](?!\()/;
+

+
const footnoteReferenceMarkedExtension = {
+
  name: "footnote-ref",
+
  level: "inline",
+
  start: (src: string) => referenceMatch.test(src),
+
  tokenizer(src: string) {
+
    const match = src.match(referenceMatch);
+
    if (match) {
+
      return {
+
        type: "footnote-ref",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) => {
+
    return `<sup class="footnote-ref" id="${referencePrefix}:${token.text}"><a href="#${footnotePrefix}:${token.text}">[${token.text}]</a></sup>`;
+
  },
+
};
+
const footnoteMatch = /^\[\^([^\]]+)\]:\s([\S]*)/;
+
const footnoteMarkedExtension = {
+
  name: "footnote",
+
  level: "block",
+
  start: (src: string) => footnoteMatch.test(src),
+
  tokenizer(src: string) {
+
    const match = src.match(footnoteMatch);
+
    if (match) {
+
      return {
+
        type: "footnote",
+
        raw: match[0],
+
        reference: match[1].trim(),
+
        text: match[2].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: marked.Tokens.Generic) => {
+
    return `<p class="txt-small" id="${footnotePrefix}:${token.reference}">${
+
      token.reference
+
    }. ${marked.parseInline(
+
      token.text,
+
    )} <a class="txt-tiny ref-arrow" href="#${referencePrefix}:${
+
      token.reference
+
    }">↩</a></p>`;
+
  },
+
};
+

+
// 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: marked.Tokens.Generic) => {
+
    return `<a name="${token.text}"></a>`;
+
  },
+
};
+

+
// Overwrites the rendering of heading tokens.
+
// Since there are possible non ASCII characters in headings,
+
// we escape them by replacing them with dashes and,
+
// trim eventual dashes on each side of the string.
+
export const renderer = {
+
  heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
+
    const escapedText = text
+
      .toLowerCase()
+
      .replace(/[^\w]+/g, "-")
+
      .replace(/^-|-$/g, "");
+

+
    return `<h${level} id="${escapedText}">${text}</h${level}>`;
+
  },
+
};
+

+
export const markdownExtensions = [
+
  anchorMarkedExtension,
+
  emojisMarkedExtension,
+
  footnoteMarkedExtension,
+
  footnoteReferenceMarkedExtension,
+
  katexMarkedExtension,
+
];
modified src/lib/utils.ts
@@ -1,10 +1,6 @@
-
import type { marked } from "marked";
-

-
import katex from "katex";
import md5 from "md5";
import twemojiModule from "twemoji";

-
import emojis from "@app/lib/emojis";
import { assert } from "@app/lib/error";
import { base } from "@app/lib/router";
import { config } from "@app/lib/config";
@@ -208,14 +204,6 @@ export function getDaysPassed(from: Date, to: Date): number {
  return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
}

-
export function parseEmoji(input: string): string {
-
  if (input in emojis) {
-
    return emojis[input];
-
  }
-

-
  return input;
-
}
-

export function scrollIntoView(id: string) {
  const lineElement = document.getElementById(id);
  if (lineElement) lineElement.scrollIntoView();
@@ -264,91 +252,19 @@ export const unreachable = (value: never): never => {
  throw new Error(`Unreachable code: ${value}`);
};

-
const emojisMarkedExtension = {
-
  name: "emoji",
-
  level: "inline",
-
  start: (src: string) => src.indexOf(":"),
-
  tokenizer(src: string) {
-
    const match = src.match(/^:([\w+-]+):/);
-
    if (match) {
-
      return {
-
        type: "emoji",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: marked.Tokens.Generic) =>
-
    `<span>${parseEmoji(token.text)}</span>`,
-
};
-

-
const katexMarkedExtension = {
-
  name: "katex",
-
  level: "inline",
-
  start: (src: string) => src.indexOf("$"),
-
  tokenizer(src: string) {
-
    const match = src.match(/^\$+([^$\n]+?)\$+/);
-
    if (match) {
-
      return {
-
        type: "katex",
-
        raw: match[0],
-
        text: match[1].trim(),
-
      };
-
    }
-
  },
-
  renderer: (token: marked.Tokens.Generic) =>
-
    katex.renderToString(token.text, {
-
      throwOnError: false,
-
    }),
-
};
-

-
// 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: marked.Tokens.Generic) => {
-
    return `<a name="${token.text}"></a>`;
-
  },
-
};
-

-
// Overwrites the rendering of heading tokens.
-
// Since there are possible non ASCII characters in headings,
-
// we escape them by replacing them with dashes and,
-
// trim eventual dashes on each side of the string.
-
export const renderer = {
-
  heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
-
    const escapedText = text
-
      .toLowerCase()
-
      .replace(/[^\w]+/g, "-")
-
      .replace(/^-|-$/g, "");
-

-
    return `<h${level} id="${escapedText}">${text}</h${level}>`;
-
  },
-
};
-

-
export function twemoji(node: HTMLElement) {
+
export function twemoji(
+
  node: HTMLElement,
+
  { exclude }: { exclude: string[] } = { exclude: [] },
+
) {
  twemojiModule.parse(node, {
+
    callback: (icon, options: Record<string, any>) => {
+
      return exclude.includes(icon)
+
        ? false
+
        : "".concat(options.base, options.size, "/", icon, options.ext);
+
    },
    base,
    folder: "twemoji",
    ext: ".svg",
    className: `txt-emoji`,
  });
}
-

-
export const markdownExtensions = [
-
  emojisMarkedExtension,
-
  katexMarkedExtension,
-
  anchorMarkedExtension,
-
];
modified tests/unit/utils.test.ts
@@ -37,23 +37,6 @@ describe("Format functions", () => {
  });

  test.each([
-
    {
-
      input: "seedling",
-
      expected: "🌱",
-
    },
-
    {
-
      input: "+1",
-
      expected: "👍",
-
    },
-
    {
-
      input: "radicle",
-
      expected: "radicle",
-
    },
-
  ])("parseEmoji $input => $expected", ({ input, expected }) => {
-
    expect(utils.parseEmoji(input)).toEqual(expected);
-
  });
-

-
  test.each([
    { commit: "a8a6a979a6261a2ec1ea85fc9a65a4a30aa22cc8", expected: "a8a6a97" },
    { commit: "a8a6a97", expected: "a8a6a97" },
  ])("formatCommit $commit => $expected", ({ commit, expected }) => {