index.js 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. /**
  2. * @import {ElementContent, Root} from 'hast'
  3. * @import {KatexOptions} from 'katex'
  4. * @import {VFile} from 'vfile'
  5. */
  6. /**
  7. * @typedef {Omit<KatexOptions, 'displayMode' | 'throwOnError'>} Options
  8. */
  9. import {fromHtmlIsomorphic} from 'hast-util-from-html-isomorphic'
  10. import {toText} from 'hast-util-to-text'
  11. import katex from 'katex'
  12. import {SKIP, visitParents} from 'unist-util-visit-parents'
  13. /** @type {Readonly<Options>} */
  14. const emptyOptions = {}
  15. /** @type {ReadonlyArray<unknown>} */
  16. const emptyClasses = []
  17. /**
  18. * Render elements with a `language-math` (or `math-display`, `math-inline`)
  19. * class with KaTeX.
  20. *
  21. * @param {Readonly<Options> | null | undefined} [options]
  22. * Configuration (optional).
  23. * @returns
  24. * Transform.
  25. */
  26. export default function rehypeKatex(options) {
  27. const settings = options || emptyOptions
  28. /**
  29. * Transform.
  30. *
  31. * @param {Root} tree
  32. * Tree.
  33. * @param {VFile} file
  34. * File.
  35. * @returns {undefined}
  36. * Nothing.
  37. */
  38. return function (tree, file) {
  39. visitParents(tree, 'element', function (element, parents) {
  40. const classes = Array.isArray(element.properties.className)
  41. ? element.properties.className
  42. : emptyClasses
  43. // This class can be generated from markdown with ` ```math `.
  44. const languageMath = classes.includes('language-math')
  45. // This class is used by `remark-math` for flow math (block, `$$\nmath\n$$`).
  46. const mathDisplay = classes.includes('math-display')
  47. // This class is used by `remark-math` for text math (inline, `$math$`).
  48. const mathInline = classes.includes('math-inline')
  49. let displayMode = mathDisplay
  50. // Any class is fine.
  51. if (!languageMath && !mathDisplay && !mathInline) {
  52. return
  53. }
  54. let parent = parents[parents.length - 1]
  55. let scope = element
  56. // If this was generated with ` ```math `, replace the `<pre>` and use
  57. // display.
  58. if (
  59. element.tagName === 'code' &&
  60. languageMath &&
  61. parent &&
  62. parent.type === 'element' &&
  63. parent.tagName === 'pre'
  64. ) {
  65. scope = parent
  66. parent = parents[parents.length - 2]
  67. displayMode = true
  68. }
  69. /* c8 ignore next -- verbose to test. */
  70. if (!parent) return
  71. const value = toText(scope, {whitespace: 'pre'})
  72. /** @type {Array<ElementContent> | string | undefined} */
  73. let result
  74. try {
  75. result = katex.renderToString(value, {
  76. ...settings,
  77. displayMode,
  78. throwOnError: true
  79. })
  80. } catch (error) {
  81. const cause = /** @type {Error} */ (error)
  82. const ruleId = cause.name.toLowerCase()
  83. file.message('Could not render math with KaTeX', {
  84. ancestors: [...parents, element],
  85. cause,
  86. place: element.position,
  87. ruleId,
  88. source: 'rehype-katex'
  89. })
  90. // KaTeX *should* handle `ParseError` itself, but not others.
  91. // it doesn’t always:
  92. // <https://github.com/remarkjs/react-markdown/issues/853>
  93. try {
  94. result = katex.renderToString(value, {
  95. ...settings,
  96. displayMode,
  97. strict: 'ignore',
  98. throwOnError: false
  99. })
  100. } catch {
  101. // Generate similar markup if this is an other error.
  102. // See: <https://github.com/KaTeX/KaTeX/blob/5dc7af0/docs/error.md>.
  103. result = [
  104. {
  105. type: 'element',
  106. tagName: 'span',
  107. properties: {
  108. className: ['katex-error'],
  109. style: 'color:' + (settings.errorColor || '#cc0000'),
  110. title: String(error)
  111. },
  112. children: [{type: 'text', value}]
  113. }
  114. ]
  115. }
  116. }
  117. if (typeof result === 'string') {
  118. const root = fromHtmlIsomorphic(result, {fragment: true})
  119. // Cast as we don’t expect `doctypes` in KaTeX result.
  120. result = /** @type {Array<ElementContent>} */ (root.children)
  121. }
  122. const index = parent.children.indexOf(scope)
  123. parent.children.splice(index, 1, ...result)
  124. return SKIP
  125. })
  126. }
  127. }