| 1 | import type { Root } from "hast"; |
| 2 | import { type Components as JsxComponents, toJsxRuntime } from "hast-util-to-jsx-runtime"; |
| 3 | import { urlAttributes } from "html-url-attributes"; |
| 4 | import type { ReactElement } from "react"; |
| 5 | import { Fragment, jsx, jsxs } from "react/jsx-runtime"; |
| 6 | import { type Components, defaultUrlTransform } from "react-markdown"; |
| 7 | import remarkParse from "remark-parse"; |
| 8 | import remarkRehype from "remark-rehype"; |
| 9 | import { type PluggableList, unified } from "unified"; |
| 10 | import { visit } from "unist-util-visit"; |
| 11 | import { 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 | */ |
| 28 | export 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. */ |
| 34 | export 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`. */ |
| 67 | export 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. */ |
| 81 | export function markdownWeight(source: string): number { |
| 82 | return source.length + 64; |
| 83 | } |