Skip to content

Commit

Permalink
Add markdown support to node descriptions
Browse files Browse the repository at this point in the history
Instead of escaping all html by svelte, we should pass the node
description through the markdown parser and sanitize the parsed content.
  • Loading branch information
mogorman authored and sebastinez committed Nov 28, 2024
1 parent b21b2ba commit ff71071
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 88 deletions.
66 changes: 62 additions & 4 deletions src/components/Markdown.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
<script lang="ts" context="module">
import type { Route } from "@app/lib/router";
import type { Tokens } from "marked";
import * as router from "@app/lib/router";
import { Renderer as BaseRenderer } from "marked";
import { routeToPath } from "@app/lib/router";
class Renderer extends BaseRenderer {
#route: Route;
/**
* If `baseUrl` is provided, all hrefs attributes in anchor tags, except those
* starting with `#`, are resolved with respect to `baseUrl`
*/
constructor(activeUnloadedRoute: Route) {
super();
this.#route = activeUnloadedRoute;
}
// 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.
heading({ tokens, depth }: Tokens.Heading) {
const text = this.parser.parseInline(tokens);
const escapedText = text
// By lowercasing we avoid casing mismatches, between headings and links.
.toLowerCase()
.replace(/[^\w]+/g, "-")
.replace(/^-|-$/g, "");
return `<h${depth} id="${escapedText}">${text}</h${depth}>`;
}
link({ href, title, tokens }: Tokens.Link): string {
const text = this.parser.parseInline(tokens);
if (href.startsWith("#")) {
// By lowercasing we avoid casing mismatches, between headings and links.
return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
}
if (this.#route.resource === "repo.source" && !isUrl(href)) {
href = routeToPath({
...this.#route,
path: canonicalize(href, this.#route.path || "README.md"),
route: undefined,
});
}
return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
}
}
</script>

<script lang="ts">
import type { Embed } from "@http-client";
Expand All @@ -7,19 +61,18 @@
import { toDom } from "hast-util-to-dom";
import * as modal from "@app/lib/modal";
import * as router from "@app/lib/router";
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,
scrollIntoView,
canonicalize,
isCommit,
} from "@app/lib/utils";
import { mimes } from "@app/lib/file";
import { markdown } from "@app/lib/markdown";
export let content: string;
export let path: string = "/";
Expand Down Expand Up @@ -92,7 +145,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,
Expand Down
137 changes: 57 additions & 80 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
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 { Marked, Renderer as BaseRenderer } from "marked";
import linkifyMarkedExtension from "marked-linkify-it";
import { Marked } 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";

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

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;
}

// 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>`,
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>`,
},
],
};

export class Renderer extends BaseRenderer {
#route: Route;

/**
* If `baseUrl` is provided, all hrefs attributes in anchor tags, except those
* starting with `#`, are resolved with respect to `baseUrl`
*/
constructor(activeUnloadedRoute: Route) {
super();
this.#route = activeUnloadedRoute;
}
// 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.
heading({ tokens, depth }: Tokens.Heading) {
const text = this.parser.parseInline(tokens);
const escapedText = text
// By lowercasing we avoid casing mismatches, between headings and links.
.toLowerCase()
.replace(/[^\w]+/g, "-")
.replace(/^-|-$/g, "");

return `<h${depth} id="${escapedText}">${text}</h${depth}>`;
}

link({ href, title, tokens }: Tokens.Link): string {
const text = this.parser.parseInline(tokens);
if (href.startsWith("#")) {
// By lowercasing we avoid casing mismatches, between headings and links.
return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
}
// 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">`;
},
});

if (this.#route.resource === "repo.source" && !isUrl(href)) {
href = routeToPath({
...this.#route,
path: canonicalize(href, this.#route.path || "README.md"),
route: undefined,
});
}
const footnoteExtension = footnoteMarkedExtension({ refMarkers: true });
const linkifyExtension = linkifyMarkedExtension({}, { fuzzyLink: false });
const katexExtension = katexMarkedExtension({ throwOnError: false });

return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
}
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] : []),
],
);
}

export default new Marked();

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">`;
},
}),
((): MarkedExtension => ({
extensions: [anchorMarkedExtension],
}))(),
);
12 changes: 10 additions & 2 deletions src/views/nodes/View.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -274,7 +282,7 @@
</div>
{#if node.description}
<div class="description txt-small">
{node.description}
{@html render(node.description)}
</div>
{:else}
<div
Expand Down Expand Up @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions src/views/repos/Source/RepoNameHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
);
}
Expand Down

0 comments on commit ff71071

Please sign in to comment.