Open History to see the last commit
1import type { Element, Nodes, Root } from "hast";
2import { toJsxRuntime, type Components } from "hast-util-to-jsx-runtime";
3import { urlAttributes } from "html-url-attributes";
4import type { ReactElement } from "react";
5import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6import remarkParse from "remark-parse";
7import remarkRehype from "remark-rehype";
8import { type PluggableList, unified } from "unified";
9import { VFile } from "vfile";
10
11import { remarkParseWithoutUnusedSyntax } from "./markdownSyntaxGates";
12
13export type MarkdownUrlTransform = (url: string, key: string, node: Element) => string;
14
15interface MarkdownRendererOptions {
16 readonly remarkPlugins: PluggableList;
17 readonly rehypePlugins: PluggableList;
18 readonly components: Partial<Components>;
19 readonly urlTransform: MarkdownUrlTransform;
20}
21
22// Elements are immutable, so a remount (a thread switch, a row scrolled back into view)
23// reuses one instead of parsing again. Parsing is most of the cost of rendering markdown.
24// An element and the hast it keeps hold about 20 bytes per source character, so ~10 MB.
25const MAX_CACHED_CHARS = 500_000;
26const rendered = new Map<string, ReactElement>();
27let cachedChars = 0;
28let nextRendererId = 0;
29
30// A short block still holds a whole element tree.
31const ENTRY_OVERHEAD_CHARS = 200;
32const entrySize = (key: string) => key.length + ENTRY_OVERHEAD_CHARS;
33
34function remember(key: string, element: ReactElement) {
35 // One huge message would otherwise evict everything else.
36 if (entrySize(key) > MAX_CACHED_CHARS / 4) return;
37 rendered.set(key, element);
38 cachedChars += entrySize(key);
39 for (const oldest of rendered.keys()) {
40 if (cachedChars <= MAX_CACHED_CHARS) break;
41 rendered.delete(oldest);
42 cachedChars -= entrySize(oldest);
43 }
44}
45
46/** Renders markdown as react-markdown does, with one processor per plugin set. */
47export function createMarkdownRenderer({
48 remarkPlugins,
49 rehypePlugins,
50 components,
51 urlTransform,
52}: MarkdownRendererOptions) {
53 const processor = unified()
54 .use(remarkParse)
55 .use(remarkParseWithoutUnusedSyntax)
56 .use(remarkPlugins)
57 .use(remarkRehype, { allowDangerousHtml: true })
58 .use(rehypePlugins)
59 .freeze();
60 const keyPrefix = `${nextRendererId++}\u0000`;
61
62 // Literal raw HTML that survived the rehype plugins, and URL attributes, as react-markdown treats them.
63 const prepare = (node: Nodes) => {
64 if (!("children" in node)) return;
65 const { children } = node;
66 for (let index = 0; index < children.length; index++) {
67 const child = children[index]!;
68 if (child.type === "raw") {
69 children[index] = { type: "text", value: child.value };
70 continue;
71 }
72 if (child.type !== "element") continue;
73 const { properties } = child;
74 for (const key in properties) {
75 if (!Object.hasOwn(urlAttributes, key)) continue;
76 const tags = urlAttributes[key];
77 if (tags === null || tags?.includes(child.tagName)) {
78 properties[key] = urlTransform(String(properties[key] || ""), key, child);
79 }
80 }
81 prepare(child);
82 }
83 };
84
85 return (text: string, cache: boolean): ReactElement => {
86 const key = keyPrefix + text;
87 const cached = rendered.get(key);
88 if (cached !== undefined) {
89 // Refresh its place in the eviction order.
90 rendered.delete(key);
91 rendered.set(key, cached);
92 return cached;
93 }
94 const file = new VFile(text);
95 const tree = processor.runSync(processor.parse(file), file) as Root;
96 prepare(tree);
97 const element = toJsxRuntime(tree, {
98 Fragment,
99 components,
100 ignoreInvalidStyle: true,
101 jsx,
102 jsxs,
103 passKeys: true,
104 passNode: true,
105 });
106 if (cache) remember(key, element);
107 return element;
108 };
109}