Add markdown support to node descriptions

Instead of escaping all html by svelte, we should pass the node
description through the markdown parser and sanitize the parsed content.
This commit is contained in:
Matthew O'Gorman
2024-11-28 16:43:11 +01:00
committed by Sebastian Martinez
parent b21b2bae5e
commit 2075c320f3
6 changed files with 88 additions and 47 deletions
+2 -2
View File
@@ -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,
+9 -4
View File
@@ -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,
+57 -32
View File
@@ -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({
// 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>`,
},
],
};
// 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">`;
},
}),
((): MarkedExtension => ({
extensions: [anchorMarkedExtension],
}))(),
});
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] : []),
],
);
}
+10 -2
View File
@@ -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>
+2 -2
View File
@@ -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,
);
}
+3
View File
@@ -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,