1
0

index.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * @typedef {import('hast').Node & {properties: Object<any, any>}} Node
  3. * @typedef {import('hast').Parent & {properties: Object<any, any>}} Parent
  4. * @typedef {import('hast').Root} Root
  5. * @typedef {import('unist-util-visit').Visitor<Node>} Visitor
  6. * @typedef Options options
  7. * Configuration.
  8. * @property {boolean} [showLineNumbers]
  9. * Set `showLineNumbers` to `true` to always display line number
  10. * @property {boolean} [ignoreMissing]
  11. * Set `ignoreMissing` to `true` to ignore unsupported languages and line highlighting when no language is specified
  12. */
  13. import { visit } from 'unist-util-visit'
  14. import { toString } from 'hast-util-to-string'
  15. import { refractor } from 'refractor/lib/all.js'
  16. import { toHtml } from 'hast-util-to-html'
  17. import { filter } from 'unist-util-filter'
  18. import { unified } from 'unified'
  19. import parse from 'rehype-parse'
  20. import rangeParser from 'parse-numeric-range'
  21. /**
  22. * @param {Node} node
  23. * @return {string|null}
  24. */
  25. const getLanguage = (node) => {
  26. const className = node.properties.className || []
  27. for (const classListItem of className) {
  28. if (classListItem.slice(0, 9) === 'language-') {
  29. return classListItem.slice(9).toLowerCase()
  30. }
  31. }
  32. return null
  33. }
  34. /**
  35. * Create a closure that determines if we have to highlight the given index
  36. *
  37. * @param {string} meta
  38. * @return { (index:number) => boolean }
  39. */
  40. const calculateLinesToHighlight = (meta) => {
  41. const RE = /{([\d,-]+)}/
  42. // Remove space between {} e.g. {1, 3}
  43. const parsedMeta = meta
  44. .split(',')
  45. .map((str) => str.trim())
  46. .join()
  47. if (RE.test(parsedMeta)) {
  48. const strlineNumbers = RE.exec(parsedMeta)[1]
  49. const lineNumbers = rangeParser(strlineNumbers)
  50. return (index) => lineNumbers.includes(index + 1)
  51. } else {
  52. return () => false
  53. }
  54. }
  55. /**
  56. * Split line to div node with className `code-line`
  57. *
  58. * @param {string} text
  59. * @return {Node[]}
  60. */
  61. const splitLine = (text) => {
  62. // Xdm Markdown parser every code line with \n
  63. const textArray = text.split(/\n/)
  64. // Remove last line \n which results in empty array
  65. if (textArray[textArray.length - 1].trim() === '') {
  66. textArray.pop()
  67. }
  68. // Empty array are actually line segments so we convert them back to newlines
  69. return textArray.map((line) => {
  70. return {
  71. type: 'element',
  72. tagName: 'span',
  73. properties: { className: ['code-line'] },
  74. children: [{ type: 'text', value: line }],
  75. }
  76. })
  77. }
  78. /**
  79. * Split line to div node with className `code-line`
  80. *
  81. * @param {import('refractor').RefractorRoot} ast
  82. * @return {Root}
  83. */
  84. const getNodePosition = (ast) => {
  85. // @ts-ignore
  86. let html = toHtml(ast)
  87. const hast = unified().use(parse, { emitParseErrors: true, fragment: true }).parse(html)
  88. return hast
  89. }
  90. /**
  91. * Split multiline text nodes into individual nodes with positioning
  92. *
  93. * @param {Parent['children']} ast
  94. * @return {Parent['children']}
  95. */
  96. const splitTextByLine = (ast) => {
  97. //@ts-ignore
  98. return ast.reduce((result, node) => {
  99. if (node.type === 'text') {
  100. if (node.value.indexOf('\n') === -1) {
  101. result.push(node)
  102. return result
  103. }
  104. const lines = node.value.split('\n')
  105. for (const [i, line] of lines.entries()) {
  106. result.push({
  107. type: 'text',
  108. value: i === lines.length - 1 ? line : line + '\n',
  109. position: {
  110. start: { line: node.position.start.line + i },
  111. end: { line: node.position.start.line + i },
  112. },
  113. })
  114. }
  115. return result
  116. }
  117. if (node.children) {
  118. // @ts-ignore
  119. node.children = splitTextByLine(node.children)
  120. result.push(node)
  121. return result
  122. }
  123. result.push(node)
  124. return result
  125. }, [])
  126. }
  127. /**
  128. * Rehype plugin that highlights code blocks with refractor (prismjs)
  129. *
  130. * @type {import('unified').Plugin<[Options?], Root>}
  131. */
  132. const rehypePrism = (options = {}) => {
  133. return (tree) => {
  134. // @ts-ignore
  135. visit(tree, 'element', visitor)
  136. }
  137. /**
  138. * @param {Node} node
  139. * @param {number} index
  140. * @param {Parent} parent
  141. */
  142. function visitor(node, index, parent) {
  143. if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') {
  144. return
  145. }
  146. const lang = getLanguage(node)
  147. /** @type {string} */
  148. // @ts-ignore
  149. let meta = node.data && node.data.meta ? node.data.meta : ''
  150. node.properties.className = node.properties.className || []
  151. node.properties.className.push('code-highlight')
  152. let refractorRoot
  153. let langError = false
  154. // Syntax highlight
  155. if (lang) {
  156. try {
  157. // @ts-ignore
  158. refractorRoot = refractor.highlight(toString(node), lang)
  159. } catch (err) {
  160. if (options.ignoreMissing && /Unknown language/.test(err.message)) {
  161. langError = true
  162. refractorRoot = node.children
  163. } else {
  164. throw err
  165. }
  166. }
  167. } else {
  168. refractorRoot = node.children
  169. }
  170. // @ts-ignore
  171. refractorRoot = getNodePosition(refractorRoot)
  172. refractorRoot.children = splitTextByLine(refractorRoot.children)
  173. const shouldHighlightLine = calculateLinesToHighlight(meta)
  174. // @ts-ignore
  175. const codeLineArray = splitLine(toString(node))
  176. for (const [i, line] of codeLineArray.entries()) {
  177. // Code lines
  178. if (meta.toLowerCase().includes('showLineNumbers'.toLowerCase()) || options.showLineNumbers) {
  179. line.properties.line = [(i + 1).toString()]
  180. line.properties.className.push('line-number')
  181. }
  182. // Line highlight
  183. if (shouldHighlightLine(i)) {
  184. line.properties.className.push('highlight-line')
  185. }
  186. // Syntax highlight
  187. const treeExtract = filter(
  188. refractorRoot,
  189. (node) => node.position.start.line <= i + 1 && node.position.end.line >= i + 1
  190. )
  191. line.children = treeExtract.children
  192. }
  193. node.children = codeLineArray
  194. }
  195. }
  196. export default rehypePrism