Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

95 linesCodeBlameRaw
1import 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 [
44 "apps/docs/src/content/docs/guides/deploy-to-cloudflare.md",
45 readFileSync(new URL("apps/docs/src/content/docs/guides/deploy-to-cloudflare.md", root), "utf8"),
46 ],
47 ["CONTRIBUTING.md", readFileSync(new URL("CONTRIBUTING.md", root), "utf8")],
48 [
49 "edge cases",
50 [
51 "# Title",
52 "",
53 "> [!WARNING]",
54 "> Careful with #12, acme/api#3 and @Ana, and commit 0123456789abcdef0123456789abcdef01234567.",
55 "",
56 "- [x] done",
57 "- [ ] not done",
58 "",
59 "| a | b |",
60 "| - | - |",
61 "| 1 | 2 |",
62 "",
63 "Footnote[^1] and ~~gone~~ and https://example.com.",
64 "",
65 "[^1]: The note.",
66 "",
67 '<div align="center"><img src="logo.png" alt="Logo" onerror="alert(1)"><script>alert(1)</script></div>',
68 "",
69 "[bad](javascript:alert(1)) [rel](./docs/x.md) [abs](/acme/web) ![pic](img/a.png)",
70 "",
71 "<details><summary>More</summary>",
72 "",
73 "Inside **details**.",
74 "",
75 "</details>",
76 "",
77 "```ts",
78 "const a = 1;",
79 "```",
80 "",
81 '<a href="vbscript:x" title="t">raw link</a> <iframe src="https://evil"></iframe>',
82 ].join("\n"),
83 ],
84 ["empty", ""],
85];
86
87for (const [name, source] of SAMPLES) {
88 test(`${name}: the kept tree renders exactly what react-markdown renders`, () => {
89 const expected = renderToStaticMarkup(createElement(ReactMarkdown, { ...options, components, children: source }));
90 const tree = markdownTree(source, options as never);
91 assert.equal(renderToStaticMarkup(renderMarkdownTree(tree, components)), expected);
92 // Rendering does not change the tree: rendered again, it is the same.
93 assert.equal(renderToStaticMarkup(renderMarkdownTree(tree, components)), expected);
94 });
95}