Skip to content
92 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Markdown is parsed once per text: the tree react-markdown makes on every render is kept, and only rendered with the page's components1import assert from "node:assert/strict";
2import { readFileSync } from "node:fs";
3import { test } from "node:test";
4
5import { createElement } from "react";
6import { renderToStaticMarkup } from "react-dom/server";
7import ReactMarkdown, { type Components } from "react-markdown";
8import rehypeRaw from "rehype-raw";
9import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
10import remarkGfm from "remark-gfm";
11
12import { rehypeAlerts, rehypeReferences } from "./markdown-plugins.ts";
13import { markdownTree, renderMarkdownTree } from "./markdown-tree.ts";
14
15// components/markdown.tsx's options: GitHub flavour, sanitized raw HTML,
16// alerts and references.
17const SCHEMA = {
18 ...defaultSchema,
19 attributes: {
20 ...defaultSchema.attributes,
21 code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]],
22 },
23};
24const repo = { namespace: "acme", name: "web" };
25const options = {
26 remarkPlugins: [remarkGfm],
27 rehypePlugins: [rehypeRaw, [rehypeSanitize, SCHEMA], rehypeAlerts, [rehypeReferences, { repo }]],
28} as const;
29
30// Components that read the node they are given, as the real ones do.
31const components: Components = {
32 a: ({ href, children, node }) =>
33 createElement("a", { href, "data-ref": String((node?.properties as { dataRef?: string } | undefined)?.dataRef ?? "") }, children),
34 blockquote: ({ children, node }) =>
35 createElement("blockquote", { "data-alert": String((node?.properties as { dataAlert?: string } | undefined)?.dataAlert ?? "") }, children),
36 h2: ({ children }) => createElement("h2", { className: "heading" }, children),
37 img: ({ src, alt }) => createElement("img", { src: typeof src === "string" ? `/raw/${src}` : undefined, alt: alt ?? "" }),
38};
39
40const root = new URL("../../../../", import.meta.url);
41const SAMPLES: [string, string][] = [
42 ["README.md", readFileSync(new URL("README.md", root), "utf8")],
43 ["docs/PERFORMANCE.md", readFileSync(new URL("docs/PERFORMANCE.md", root), "utf8")],
44 ["CONTRIBUTING.md", readFileSync(new URL("CONTRIBUTING.md", root), "utf8")],
45 [
46 "edge cases",
47 [
48 "# Title",
49 "",
50 "> [!WARNING]",
51 "> Careful with #12, acme/api#3 and @Ana, and commit 0123456789abcdef0123456789abcdef01234567.",
52 "",
53 "- [x] done",
54 "- [ ] not done",
55 "",
56 "| a | b |",
57 "| - | - |",
58 "| 1 | 2 |",
59 "",
60 "Footnote[^1] and ~~gone~~ and https://example.com.",
61 "",
62 "[^1]: The note.",
63 "",
64 '<div align="center"><img src="logo.png" alt="Logo" onerror="alert(1)"><script>alert(1)</script></div>',
65 "",
66 "[bad](javascript:alert(1)) [rel](./docs/x.md) [abs](/acme/web) ![pic](img/a.png)",
67 "",
68 "<details><summary>More</summary>",
69 "",
70 "Inside **details**.",
71 "",
72 "</details>",
73 "",
74 "```ts",
75 "const a = 1;",
76 "```",
77 "",
78 '<a href="vbscript:x" title="t">raw link</a> <iframe src="https://evil"></iframe>',
79 ].join("\n"),
80 ],
81 ["empty", ""],
82];
83
84for (const [name, source] of SAMPLES) {
85 test(`${name}: the kept tree renders exactly what react-markdown renders`, () => {
86 const expected = renderToStaticMarkup(createElement(ReactMarkdown, { ...options, components, children: source }));
87 const tree = markdownTree(source, options as never);
88 assert.equal(renderToStaticMarkup(renderMarkdownTree(tree, components)), expected);
89 // Rendering does not change the tree: rendered again, it is the same.
90 assert.equal(renderToStaticMarkup(renderMarkdownTree(tree, components)), expected);
91 });
92}