| 1 | import type { DiffsHighlighter } from "@pierre/diffs"; |
| 2 | import { toHtml } from "hast-util-to-html"; |
| 3 | import { toJsxRuntime } from "hast-util-to-jsx-runtime"; |
| 4 | import { cloneElement, isValidElement, memo, type DOMAttributes } from "react"; |
| 5 | import { Fragment, jsx, jsxs } from "react/jsx-runtime"; |
| 6 | |
| 7 | type HighlightedRoot = ReturnType<DiffsHighlighter["codeToHast"]>; |
| 8 | type HighlightedNode = HighlightedRoot["children"][number]; |
| 9 | const runtime = { Fragment, jsx, jsxs }; |
| 10 | |
| 11 | function elementShell(node: Extract<HighlightedNode, { type: "element" }>) { |
| 12 | const element = toJsxRuntime({ ...node, children: [] }, runtime); |
| 13 | if (!isValidElement<DOMAttributes<HTMLElement>>(element)) { |
| 14 | throw new Error("Expected a highlighted code element"); |
| 15 | } |
| 16 | return element; |
| 17 | } |
| 18 | |
| 19 | const HighlightedLine = memo(function HighlightedLine({ node }: { node: HighlightedNode }) { |
| 20 | if (node.type !== "element") return toJsxRuntime(node, runtime); |
| 21 | return cloneElement(elementShell(node), { |
| 22 | dangerouslySetInnerHTML: { __html: toHtml({ type: "root", children: node.children }) }, |
| 23 | }); |
| 24 | }); |
| 25 | |
| 26 | /** Completed line nodes retain their identity in the incremental highlighter. |
| 27 | * Keep their DOM mounted too: replacing the entire pre makes the browser parse |
| 28 | * and resolve styles for thousands of unchanged token spans on each update. |
| 29 | */ |
| 30 | export function HighlightedCodeLines({ root }: { root: HighlightedRoot }) { |
| 31 | const pre = root.children[0]; |
| 32 | if (pre?.type !== "element" || pre.tagName !== "pre") return toJsxRuntime(root, runtime); |
| 33 | const code = pre.children[0]; |
| 34 | if (code?.type !== "element" || code.tagName !== "code") return toJsxRuntime(root, runtime); |
| 35 | return cloneElement( |
| 36 | elementShell(pre), |
| 37 | undefined, |
| 38 | cloneElement( |
| 39 | elementShell(code), |
| 40 | undefined, |
| 41 | code.children.map((node, index) => ( |
| 42 | // A line's position is stable as tokens and new lines are appended. |
| 43 | // oxlint-disable-next-line react/no-array-index-key |
| 44 | <HighlightedLine key={index} node={node} /> |
| 45 | )), |
| 46 | ), |
| 47 | ); |
| 48 | } |