|
| 1 | +import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'; |
| 2 | +import { toText } from 'hast-util-to-text'; |
| 3 | +import katex, { type KatexOptions } from 'katex'; |
| 4 | +import { SKIP, visitParents } from 'unist-util-visit-parents'; |
| 5 | +import type { VFile } from 'vfile'; |
| 6 | + |
| 7 | +type Options = Omit<KatexOptions, 'displayMode' | 'throwOnError'>; |
| 8 | +type HastNode = Parameters<typeof toText>[0]; |
| 9 | +type Root = Extract<HastNode, { type: 'root' }>; |
| 10 | +type ElementContent = Extract<HastNode, { type: 'element' }>['children'][number]; |
| 11 | + |
| 12 | +function parseKaTeXHtml(html: string): ElementContent[] { |
| 13 | + return fromHtmlIsomorphic(html, { fragment: true }).children.filter( |
| 14 | + (child: HastNode): child is ElementContent => child.type !== 'doctype', |
| 15 | + ); |
| 16 | +} |
| 17 | + |
| 18 | +const EMPTY_OPTIONS: Readonly<Options> = {}; |
| 19 | +const EMPTY_CLASSES: readonly unknown[] = []; |
| 20 | + |
| 21 | +/** |
| 22 | + * Render the math nodes produced by remark-math with the app's direct KaTeX version. |
| 23 | + * rehype-katex 7 is intentionally not used because it pins KaTeX 0.16 and emits |
| 24 | + * layout classes that do not match the KaTeX 0.18 stylesheet. |
| 25 | + */ |
| 26 | +export function rehypeKatex(options?: Readonly<Options> | null) { |
| 27 | + const settings = options ?? EMPTY_OPTIONS; |
| 28 | + |
| 29 | + return (tree: Root, file: VFile) => { |
| 30 | + visitParents(tree, 'element', (element, parents) => { |
| 31 | + const classes = Array.isArray(element.properties.className) |
| 32 | + ? element.properties.className |
| 33 | + : EMPTY_CLASSES; |
| 34 | + const languageMath = classes.includes('language-math'); |
| 35 | + const mathDisplay = classes.includes('math-display'); |
| 36 | + const mathInline = classes.includes('math-inline'); |
| 37 | + let displayMode = mathDisplay; |
| 38 | + |
| 39 | + if (!languageMath && !mathDisplay && !mathInline) return; |
| 40 | + |
| 41 | + let parent = parents.at(-1); |
| 42 | + let scope = element; |
| 43 | + |
| 44 | + if ( |
| 45 | + element.tagName === 'code' && |
| 46 | + languageMath && |
| 47 | + parent?.type === 'element' && |
| 48 | + parent.tagName === 'pre' |
| 49 | + ) { |
| 50 | + scope = parent; |
| 51 | + parent = parents.at(-2); |
| 52 | + displayMode = true; |
| 53 | + } |
| 54 | + |
| 55 | + if (!parent) return; |
| 56 | + |
| 57 | + const value = toText(scope, { whitespace: 'pre' }); |
| 58 | + let result: ElementContent[]; |
| 59 | + |
| 60 | + try { |
| 61 | + result = parseKaTeXHtml( |
| 62 | + katex.renderToString(value, { |
| 63 | + ...settings, |
| 64 | + displayMode, |
| 65 | + throwOnError: true, |
| 66 | + }), |
| 67 | + ); |
| 68 | + } catch (error) { |
| 69 | + const cause = error as Error; |
| 70 | + file.message('Could not render math with KaTeX', { |
| 71 | + ancestors: [...parents, element], |
| 72 | + cause, |
| 73 | + place: element.position, |
| 74 | + ruleId: cause.name.toLowerCase(), |
| 75 | + source: 'rehype-katex', |
| 76 | + }); |
| 77 | + |
| 78 | + try { |
| 79 | + result = parseKaTeXHtml( |
| 80 | + katex.renderToString(value, { |
| 81 | + ...settings, |
| 82 | + displayMode, |
| 83 | + strict: 'ignore', |
| 84 | + throwOnError: false, |
| 85 | + }), |
| 86 | + ); |
| 87 | + } catch { |
| 88 | + result = [ |
| 89 | + { |
| 90 | + type: 'element', |
| 91 | + tagName: 'span', |
| 92 | + properties: { |
| 93 | + className: ['katex-error'], |
| 94 | + style: `color:${settings.errorColor ?? '#cc0000'}`, |
| 95 | + title: String(error), |
| 96 | + }, |
| 97 | + children: [{ type: 'text', value }], |
| 98 | + }, |
| 99 | + ]; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + const index = parent.children.indexOf(scope); |
| 104 | + parent.children.splice(index, 1, ...result); |
| 105 | + return SKIP; |
| 106 | + }); |
| 107 | + }; |
| 108 | +} |
0 commit comments