generator.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * @typedef {import('hast').Element} Element
  3. * @typedef {import('hast').Root} Root
  4. * @typedef Options options
  5. * Configuration.
  6. * @property {boolean} [showLineNumbers]
  7. * Set `showLineNumbers` to `true` to always display line number
  8. * @property {boolean} [ignoreMissing]
  9. * Set `ignoreMissing` to `true` to ignore unsupported languages and line highlighting when no language is specified
  10. */
  11. import { visit } from 'unist-util-visit'
  12. import { toString } from 'hast-util-to-string'
  13. import { filter } from 'unist-util-filter'
  14. import rangeParser from 'parse-numeric-range'
  15. /**
  16. * @param {Element} node
  17. * @return {string|null}
  18. */
  19. const getLanguage = (node) => {
  20. const className = node.properties.className
  21. //@ts-ignore
  22. for (const classListItem of className) {
  23. if (classListItem.slice(0, 9) === 'language-') {
  24. return classListItem.slice(9).toLowerCase()
  25. }
  26. }
  27. return null
  28. }
  29. /**
  30. * Create a closure that determines if we have to highlight the given index
  31. *
  32. * @param {string} meta
  33. * @return { (index:number) => boolean }
  34. */
  35. const calculateLinesToHighlight = (meta) => {
  36. const RE = /{([\d,-]+)}/
  37. // Remove space between {} e.g. {1, 3}
  38. const parsedMeta = meta
  39. .split(',')
  40. .map((str) => str.trim())
  41. .join()
  42. if (RE.test(parsedMeta)) {
  43. const strlineNumbers = RE.exec(parsedMeta)[1]
  44. const lineNumbers = rangeParser(strlineNumbers)
  45. return (index) => lineNumbers.includes(index + 1)
  46. } else {
  47. return () => false
  48. }
  49. }
  50. const recursivelyStripPositions = (node) => {
  51. delete node.position
  52. if (!node.children || node.children.length === 0) return node
  53. node.children = node.children.map((x) => recursivelyStripPositions(x))
  54. return node
  55. }
  56. /**
  57. * Check if we want to start the line numbering from a given number or 1
  58. * showLineNumbers=5, will start the numbering from 5
  59. * @param {string} meta
  60. * @returns {number}
  61. */
  62. const calculateStartingLine = (meta) => {
  63. const RE = /showLineNumbers=(?<lines>\d+)/i
  64. // pick the line number after = using a named capturing group
  65. if (RE.test(meta)) {
  66. const {
  67. groups: { lines },
  68. } = RE.exec(meta)
  69. return Number(lines)
  70. }
  71. return 1
  72. }
  73. /**
  74. * Create container AST for node lines
  75. *
  76. * @param {number} number
  77. * @return {Element[]}
  78. */
  79. const createLineNodes = (number) => {
  80. const a = new Array(number)
  81. for (let i = 0; i < number; i++) {
  82. a[i] = {
  83. type: 'element',
  84. tagName: 'span',
  85. properties: { className: [] },
  86. children: [],
  87. }
  88. }
  89. return a
  90. }
  91. /**
  92. * Split multiline text nodes into individual nodes with positioning
  93. * Add a node start and end line position information for each text node
  94. *
  95. * @return { (ast:Element['children']) => Element['children'] }
  96. *
  97. */
  98. const addNodePositionClosure = () => {
  99. let startLineNum = 1
  100. /**
  101. * @param {Element['children']} ast
  102. * @return {Element['children']}
  103. */
  104. const addNodePosition = (ast) => {
  105. return ast.reduce((result, node) => {
  106. if (node.type === 'text') {
  107. const value = /** @type {string} */ (node.value)
  108. const numLines = (value.match(/\n/g) || '').length
  109. if (numLines === 0) {
  110. node.position = {
  111. // column: 0 is to make the ts compiler happy but we do not use this field
  112. start: { line: startLineNum, column: 0 },
  113. end: { line: startLineNum, column: 0 },
  114. }
  115. result.push(node)
  116. } else {
  117. const lines = value.split('\n')
  118. for (const [i, line] of lines.entries()) {
  119. result.push({
  120. type: 'text',
  121. value: i === lines.length - 1 ? line : line + '\n',
  122. position: {
  123. start: { line: startLineNum + i },
  124. end: { line: startLineNum + i },
  125. },
  126. })
  127. }
  128. }
  129. startLineNum = startLineNum + numLines
  130. return result
  131. }
  132. if (Object.prototype.hasOwnProperty.call(node, 'children')) {
  133. const initialLineNum = startLineNum
  134. // @ts-ignore
  135. node.children = addNodePosition(node.children, startLineNum)
  136. result.push(node)
  137. node.position = {
  138. start: { line: initialLineNum, column: 0 },
  139. end: { line: startLineNum, column: 0 },
  140. }
  141. return result
  142. }
  143. result.push(node)
  144. return result
  145. }, [])
  146. }
  147. return addNodePosition
  148. }
  149. /**
  150. * Rehype prism plugin generator that highlights code blocks with refractor (prismjs)
  151. *
  152. * Pass in your own refractor object with the required languages registered:
  153. * https://github.com/wooorm/refractor#refractorregistersyntax
  154. *
  155. * @param {import('refractor/lib/core').Refractor} refractor
  156. * @return {import('unified').Plugin<[Options?], Root>}
  157. */
  158. const rehypePrismGenerator = (refractor) => {
  159. return (options = {}) => {
  160. return (tree) => {
  161. visit(tree, 'element', visitor)
  162. }
  163. /**
  164. * @param {Element} node
  165. * @param {number} index
  166. * @param {Element} parent
  167. */
  168. function visitor(node, index, parent) {
  169. if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') {
  170. return
  171. }
  172. let meta = node.data && node.data.meta ? /** @type {string} */ (node.data.meta) : ''
  173. // Coerce className to array
  174. if (node.properties.className) {
  175. if (typeof node.properties.className === 'boolean') {
  176. node.properties.className = []
  177. } else if (!Array.isArray(node.properties.className)) {
  178. node.properties.className = [node.properties.className]
  179. }
  180. } else {
  181. node.properties.className = []
  182. }
  183. node.properties.className.push('code-highlight')
  184. const lang = getLanguage(node)
  185. /** @type {Element} */
  186. let refractorRoot
  187. // Syntax highlight
  188. if (lang) {
  189. try {
  190. // @ts-ignore
  191. refractorRoot = refractor.highlight(toString(node), lang)
  192. // @ts-ignore className is already an array
  193. parent.properties.className = (parent.properties.className || []).concat(
  194. 'language-' + lang
  195. )
  196. } catch (err) {
  197. if (options.ignoreMissing && /Unknown language/.test(err.message)) {
  198. refractorRoot = node
  199. } else {
  200. throw err
  201. }
  202. }
  203. } else {
  204. refractorRoot = node
  205. }
  206. refractorRoot.children = addNodePositionClosure()(refractorRoot.children)
  207. // Add position info to root
  208. if (refractorRoot.children.length > 0) {
  209. refractorRoot.position = {
  210. start: { line: refractorRoot.children[0].position.start.line, column: 0 },
  211. end: {
  212. line: refractorRoot.children[refractorRoot.children.length - 1].position.end.line,
  213. column: 0,
  214. },
  215. }
  216. } else {
  217. refractorRoot.position = {
  218. start: { line: 0, column: 0 },
  219. end: { line: 0, column: 0 },
  220. }
  221. }
  222. const shouldHighlightLine = calculateLinesToHighlight(meta)
  223. const startingLineNumber = calculateStartingLine(meta)
  224. const codeLineArray = createLineNodes(refractorRoot.position.end.line)
  225. const falseShowLineNumbersStr = [
  226. 'showlinenumbers=false',
  227. 'showlinenumbers="false"',
  228. 'showlinenumbers={false}',
  229. ]
  230. for (const [i, line] of codeLineArray.entries()) {
  231. // Default class name for each line
  232. line.properties.className = ['code-line']
  233. // Syntax highlight
  234. const treeExtract = filter(
  235. refractorRoot,
  236. (node) => node.position.start.line <= i + 1 && node.position.end.line >= i + 1
  237. )
  238. line.children = treeExtract.children
  239. // Line number
  240. if (
  241. (meta.toLowerCase().includes('showLineNumbers'.toLowerCase()) ||
  242. options.showLineNumbers) &&
  243. !falseShowLineNumbersStr.some((str) => meta.toLowerCase().includes(str))
  244. ) {
  245. line.properties.line = [(i + startingLineNumber).toString()]
  246. line.properties.className.push('line-number')
  247. }
  248. // Line highlight
  249. if (shouldHighlightLine(i)) {
  250. line.properties.className.push('highlight-line')
  251. }
  252. // Diff classes
  253. if (lang === 'diff' && toString(line).substring(0, 1) === '-') {
  254. line.properties.className.push('deleted')
  255. } else if (lang === 'diff' && toString(line).substring(0, 1) === '+') {
  256. line.properties.className.push('inserted')
  257. }
  258. }
  259. // Remove possible trailing line when splitting by \n which results in empty array
  260. if (
  261. codeLineArray.length > 0 &&
  262. toString(codeLineArray[codeLineArray.length - 1]).trim() === ''
  263. ) {
  264. codeLineArray.pop()
  265. }
  266. node.children = codeLineArray
  267. // Removing remnant positions info as it causes some problems in @next/mdx
  268. // https://github.com/timlrx/rehype-prism-plus/issues/44
  269. recursivelyStripPositions(node)
  270. }
  271. }
  272. }
  273. export default rehypePrismGenerator