Skip to content
83 linesCodeBlameRaw
1import type { Root } from "hast";
2import { type Components as JsxComponents, toJsxRuntime } 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 { type Components, defaultUrlTransform } from "react-markdown";
7import remarkParse from "remark-parse";
8import remarkRehype from "remark-rehype";
9import { type PluggableList, unified } from "unified";
10import { visit } from "unist-util-visit";
11import { VFile } from "vfile";
12
13/**
14 * Markdown in two steps, the same two `react-markdown`'s `<Markdown>` takes
15 * on every render, split so the first can be kept:
16 *
17 * 1. `markdownTree`: parse, run the plugins, make URLs safe. Most of the
18 * work (nine tenths of a large README's), and it depends only on the
19 * text and the plugins, so components/markdown.tsx keeps its trees.
20 * 2. `renderMarkdownTree`: the tree as React elements, with the page's
21 * components.
22 *
23 * Together they make exactly what `<Markdown>` makes for the same options
24 * (lib/markdown-tree.test.ts renders both and compares). Options this file
25 * does not take (`allowedElements`, `skipHtml`, `urlTransform`, …) are not
26 * used by g1t.
27 */
28export type MarkdownTreeOptions = {
29 remarkPlugins?: PluggableList;
30 rehypePlugins?: PluggableList;
31};
32
33/** The hast tree `<Markdown>` would render for `source`. Not changed by rendering, so it can be kept and rendered again. */
34export function markdownTree(source: string, options: MarkdownTreeOptions = {}): Root {
35 const processor = unified()
36 .use(remarkParse)
37 .use(options.remarkPlugins ?? [])
38 .use(remarkRehype, { allowDangerousHtml: true })
39 .use(options.rehypePlugins ?? []);
40 const file = new VFile();
41 file.value = source;
42 const tree = processor.runSync(processor.parse(file), file) as Root;
43 // What react-markdown does before rendering: raw HTML left over becomes
44 // text, and every URL attribute goes through the default transform.
45 visit(tree, (node, index, parent) => {
46 if (node.type === "raw" && parent && typeof index === "number") {
47 parent.children[index] = { type: "text", value: node.value };
48 return index;
49 }
50 if (node.type === "element") {
51 for (const key in urlAttributes) {
52 if (Object.hasOwn(urlAttributes, key) && Object.hasOwn(node.properties, key)) {
53 const value = node.properties[key];
54 const test = urlAttributes[key];
55 if (test === null || test.includes(node.tagName)) {
56 node.properties[key] = defaultUrlTransform(String(value || ""));
57 }
58 }
59 }
60 }
61 return undefined;
62 });
63 return tree;
64}
65
66/** `tree` as React elements, as `<Markdown>` renders it with `components`. */
67export function renderMarkdownTree(tree: Root, components?: Components): ReactElement {
68 return toJsxRuntime(tree, {
69 Fragment,
70 // react-markdown's components, which it hands to this same function.
71 components: components as JsxComponents | undefined,
72 ignoreInvalidStyle: true,
73 jsx,
74 jsxs,
75 passKeys: true,
76 passNode: true,
77 });
78}
79
80/** How much a kept tree weighs: its text's length, a stand-in for the tree's size. */
81export function markdownWeight(source: string): number {
82 return source.length + 64;
83}