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 2075c32
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 47 deletions.
4 changes: 2 additions & 2 deletions http-client/lib/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions src/components/Markdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
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,
scrollIntoView,
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 = "/";
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 62 additions & 37 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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] : []),
],
);
}
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
3 changes: 3 additions & 0 deletions tests/support/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 2075c32

Please sign in to comment.