index.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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. * Check if we want to start the line numbering from a given number or 1
  57. * showLineNumbers=5, will start the numbering from 5
  58. * @param {string} meta
  59. * @returns {number}
  60. */
  61. const calculateStartingLine = (meta) => {
  62. const RE = /showLineNumbers=(?<lines>\d+)/i
  63. // pick the line number after = using a named capturing group
  64. if (RE.test(meta)) {
  65. const {
  66. groups: { lines },
  67. } = RE.exec(meta)
  68. return Number(lines)
  69. }
  70. return 1
  71. }
  72. /**
  73. * Split line to div node with className `code-line`
  74. *
  75. * @param {string} text
  76. * @return {Node[]}
  77. */
  78. const splitLine = (text) => {
  79. // Xdm Markdown parser every code line with \n
  80. const textArray = text.split(/\n/)
  81. // Remove last line \n which results in empty array
  82. if (textArray[textArray.length - 1].trim() === '') {
  83. textArray.pop()
  84. }
  85. // Empty array are actually line segments so we convert them back to newlines
  86. return textArray.map((line) => {
  87. return {
  88. type: 'element',
  89. tagName: 'span',
  90. properties: { className: ['code-line'] },
  91. children: [{ type: 'text', value: line }],
  92. }
  93. })
  94. }
  95. /**
  96. * Split line to div node with className `code-line`
  97. *
  98. * @param {import('refractor').RefractorRoot} ast
  99. * @return {Root}
  100. */
  101. const getNodePosition = (ast) => {
  102. // @ts-ignore
  103. let html = toHtml(ast)
  104. const hast = unified().use(parse, { emitParseErrors: true, fragment: true }).parse(html)
  105. return hast
  106. }
  107. /**
  108. * Split multiline text nodes into individual nodes with positioning
  109. *
  110. * @param {Parent['children']} ast
  111. * @return {Parent['children']}
  112. */
  113. const splitTextByLine = (ast) => {
  114. //@ts-ignore
  115. return ast.reduce((result, node) => {
  116. if (node.type === 'text') {
  117. if (node.value.indexOf('\n') === -1) {
  118. result.push(node)
  119. return result
  120. }
  121. const lines = node.value.split('\n')
  122. for (const [i, line] of lines.entries()) {
  123. result.push({
  124. type: 'text',
  125. value: i === lines.length - 1 ? line : line + '\n',
  126. position: {
  127. start: { line: node.position.start.line + i },
  128. end: { line: node.position.start.line + i },
  129. },
  130. })
  131. }
  132. return result
  133. }
  134. if (node.children) {
  135. // @ts-ignore
  136. node.children = splitTextByLine(node.children)
  137. result.push(node)
  138. return result
  139. }
  140. result.push(node)
  141. return result
  142. }, [])
  143. }
  144. /**
  145. * Rehype plugin that highlights code blocks with refractor (prismjs)
  146. *
  147. * @type {import('unified').Plugin<[Options?], Root>}
  148. */
  149. const rehypePrism = (options = {}) => {
  150. return (tree) => {
  151. // @ts-ignore
  152. visit(tree, 'element', visitor)
  153. }
  154. /**
  155. * @param {Node} node
  156. * @param {number} index
  157. * @param {Parent} parent
  158. */
  159. function visitor(node, index, parent) {
  160. if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') {
  161. return
  162. }
  163. const lang = getLanguage(node)
  164. /** @type {string} */
  165. // @ts-ignore
  166. let meta = node.data && node.data.meta ? node.data.meta : ''
  167. node.properties.className = node.properties.className || []
  168. node.properties.className.push('code-highlight')
  169. let refractorRoot
  170. let langError = false
  171. // Syntax highlight
  172. if (lang) {
  173. try {
  174. // @ts-ignore
  175. refractorRoot = refractor.highlight(toString(node), lang)
  176. parent.properties.className = (parent.properties.className || []).concat('language-' + lang)
  177. } catch (err) {
  178. if (options.ignoreMissing && /Unknown language/.test(err.message)) {
  179. langError = true
  180. refractorRoot = node.children
  181. } else {
  182. throw err
  183. }
  184. }
  185. } else {
  186. refractorRoot = node.children
  187. }
  188. // @ts-ignore
  189. refractorRoot = getNodePosition(refractorRoot)
  190. refractorRoot.children = splitTextByLine(refractorRoot.children)
  191. const shouldHighlightLine = calculateLinesToHighlight(meta)
  192. const startingLineNumber = calculateStartingLine(meta)
  193. // @ts-ignore
  194. const codeLineArray = splitLine(toString(node))
  195. for (const [i, line] of codeLineArray.entries()) {
  196. // Code lines
  197. if (meta.toLowerCase().includes('showLineNumbers'.toLowerCase()) || options.showLineNumbers) {
  198. line.properties.line = [(i + startingLineNumber).toString()]
  199. line.properties.className.push('line-number')
  200. }
  201. // Line highlight
  202. if (shouldHighlightLine(i)) {
  203. line.properties.className.push('highlight-line')
  204. }
  205. // Syntax highlight
  206. const treeExtract = filter(
  207. refractorRoot,
  208. (node) => node.position.start.line <= i + 1 && node.position.end.line >= i + 1
  209. )
  210. line.children = treeExtract.children
  211. }
  212. node.children = codeLineArray
  213. }
  214. }
  215. export default rehypePrism