generator.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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. * @property {string} [defaultLanguage]
  11. * Uses the specified language as the default if none is specified. Takes precedence over `ignoreMissing`.
  12. * Note: The language must be registered with refractor.
  13. */
  14. import { visit } from "unist-util-visit"
  15. import { toString } from "hast-util-to-string"
  16. import { filter } from "unist-util-filter"
  17. import rangeParser from "parse-numeric-range"
  18. const getLanguage = (node) => {
  19. const className = node.properties.className
  20. //@ts-ignore
  21. for (const classListItem of className) {
  22. if (classListItem.slice(0, 9) === "language-") {
  23. return classListItem.slice(9).toLowerCase()
  24. }
  25. }
  26. return null
  27. }
  28. /**
  29. * @param {import("refractor/lib/core").Refractor} refractor
  30. * @param {string} defaultLanguage
  31. * @return {void}
  32. */
  33. const checkIfLanguageIsRegistered = (refractor, defaultLanguage) => {
  34. if (defaultLanguage && !refractor.registered(defaultLanguage)) {
  35. throw new Error(`The default language "${defaultLanguage}" is not registered with refractor.`)
  36. }
  37. }
  38. /**
  39. * Create a closure that determines if we have to highlight the given index
  40. *
  41. * @param {string} meta
  42. * @return { (index:number) => boolean }
  43. */
  44. const calculateLinesToHighlight = (meta) => {
  45. const RE = /{([\d,-]+)}/
  46. // Remove space between {} e.g. {1, 3}
  47. const parsedMeta = meta
  48. .split(",")
  49. .map((str) => str.trim())
  50. .join()
  51. if (RE.test(parsedMeta)) {
  52. const strlineNumbers = RE.exec(parsedMeta)[1]
  53. const lineNumbers = rangeParser(strlineNumbers)
  54. return (index) => lineNumbers.includes(index + 1)
  55. } else {
  56. return () => false
  57. }
  58. }
  59. /**
  60. * Check if we want to start the line numbering from a given number or 1
  61. * showLineNumbers=5, will start the numbering from 5
  62. * @param {string} meta
  63. * @returns {number}
  64. */
  65. const calculateStartingLine = (meta) => {
  66. const RE = /showLineNumbers=(?<lines>\d+)/i
  67. // pick the line number after = using a named capturing group
  68. if (RE.test(meta)) {
  69. const {
  70. groups: { lines },
  71. } = RE.exec(meta)
  72. return Number(lines)
  73. }
  74. return 1
  75. }
  76. /**
  77. * Create container AST for node lines
  78. *
  79. * @param {number} number
  80. * @return {Element[]}
  81. */
  82. const createLineNodes = (number) => {
  83. const a = new Array(number)
  84. for (let i = 0; i < number; i++) {
  85. a[i] = {
  86. type: "element",
  87. tagName: "span",
  88. properties: { className: [] },
  89. children: [],
  90. }
  91. }
  92. return a
  93. }
  94. /**
  95. * Split multiline text nodes into individual nodes with positioning
  96. * Add a node start and end line position information for each text node
  97. *
  98. * @return { (ast:Element["children"]) => Element["children"] }
  99. *
  100. */
  101. const addNodePositionClosure = () => {
  102. let startLineNum = 1
  103. /**
  104. * @param {Element["children"]} ast
  105. * @return {Element["children"]}
  106. */
  107. const addNodePosition = (ast) => {
  108. return ast.reduce((result, node) => {
  109. if (node.type === "text") {
  110. const value = /** @type {string} */ (node.value)
  111. const numLines = (value.match(/\n/g) || "").length
  112. if (numLines === 0) {
  113. node.position = {
  114. // column: 1 is needed to avoid error with @next/mdx
  115. // https://github.com/timlrx/rehype-prism-plus/issues/44
  116. start: { line: startLineNum, column: 1 },
  117. end: { line: startLineNum, column: 1 },
  118. }
  119. result.push(node)
  120. } else {
  121. const lines = 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: startLineNum + i, column: 1 },
  128. end: { line: startLineNum + i, column: 1 },
  129. },
  130. })
  131. }
  132. }
  133. startLineNum = startLineNum + numLines
  134. return result
  135. }
  136. if (Object.prototype.hasOwnProperty.call(node, "children")) {
  137. const initialLineNum = startLineNum
  138. // @ts-ignore
  139. node.children = addNodePosition(node.children, startLineNum)
  140. result.push(node)
  141. node.position = {
  142. start: { line: initialLineNum, column: 1 },
  143. end: { line: startLineNum, column: 1 },
  144. }
  145. return result
  146. }
  147. result.push(node)
  148. return result
  149. }, [])
  150. }
  151. return addNodePosition
  152. }
  153. /**
  154. * @param {Element} parent
  155. * @param {string} meta
  156. */
  157. const createToolbarElement = (parent, meta) => {
  158. const toolbarShowRx = /toolbar-.*/
  159. if (!toolbarShowRx.test(meta)) {
  160. return
  161. }
  162. const toolbarTitleRx = /toolbar-title="(.*)"/
  163. const toolbarTitle = toolbarTitleRx.exec(meta)[1]
  164. parent.children.push({
  165. type: "element",
  166. tagName: "div",
  167. properties: { className: ["toolbar-container"] },
  168. children: [
  169. {
  170. type: "element",
  171. tagName: "div",
  172. properties: { className: ["toolbar"] },
  173. children: [
  174. {
  175. type: "element",
  176. tagName: "div",
  177. properties: { className: ["toolbar-title"] },
  178. children: [
  179. {
  180. type: "text",
  181. value: toolbarTitle,
  182. }
  183. ]
  184. },
  185. {
  186. type: "element",
  187. tagName: "div",
  188. properties: {
  189. className: ["toolbar-buttons"],
  190. },
  191. children: [
  192. {
  193. type: "element",
  194. tagName: "button",
  195. properties: {
  196. className: ["toolbar-download-code-button"],
  197. },
  198. children: [
  199. {
  200. type: "text",
  201. value: "DOWNLOAD"
  202. },
  203. ]
  204. },
  205. {
  206. type: "element",
  207. tagName: "button",
  208. properties: {
  209. className: ["toolbar-copy-code-button"],
  210. },
  211. children: [
  212. {
  213. type: "text",
  214. value: "COPY"
  215. },
  216. ]
  217. }
  218. ]
  219. }
  220. ],
  221. }
  222. ],
  223. })
  224. }
  225. /**
  226. * Rehype prism plugin generator that highlights code blocks with refractor (prismjs)
  227. *
  228. * Pass in your own refractor object with the required languages registered:
  229. * https://github.com/wooorm/refractor#refractorregistersyntax
  230. *
  231. * @param {import("refractor/lib/core").Refractor} refractor
  232. * @return {import("unified").Plugin<[Options?], Root>}
  233. */
  234. const rehypePrismGenerator = (refractor) => {
  235. return (options = {}) => {
  236. checkIfLanguageIsRegistered(refractor, options.defaultLanguage)
  237. return (tree) => {
  238. visit(tree, "element", visitor)
  239. }
  240. /**
  241. * @param {Element} node
  242. * @param {number} index
  243. * @param {Element} parent
  244. */
  245. function visitor(node, index, parent) {
  246. if (!parent || parent.tagName !== "pre" || node.tagName !== "code") {
  247. return
  248. }
  249. // @ts-ignore meta is a custom code block property
  250. let meta = /** @type {string} */ (node?.data?.meta || node?.properties?.metastring || "")
  251. // Coerce className to array
  252. if (node.properties.className) {
  253. if (typeof node.properties.className === "boolean") {
  254. node.properties.className = []
  255. } else if (!Array.isArray(node.properties.className)) {
  256. node.properties.className = [node.properties.className]
  257. }
  258. } else {
  259. node.properties.className = []
  260. }
  261. let lang = getLanguage(node)
  262. if (!lang && options.defaultLanguage) {
  263. lang = options.defaultLanguage
  264. node.properties.className.push(`language-${lang}`)
  265. }
  266. node.properties.className.push("code-highlight")
  267. /** @type {Element} */
  268. let refractorRoot
  269. // Syntax highlight
  270. if (lang) {
  271. try {
  272. let rootLang
  273. if (lang?.includes("diff-")) {
  274. rootLang = lang.split("-")[1]
  275. } else {
  276. rootLang = lang
  277. }
  278. // @ts-ignore
  279. refractorRoot = refractor.highlight(toString(node), rootLang)
  280. // @ts-ignore className is already an array
  281. parent.properties.className = (parent.properties.className || []).concat(
  282. "language-" + rootLang
  283. )
  284. } catch (err) {
  285. if (options.ignoreMissing && /Unknown language/.test(err.message)) {
  286. refractorRoot = node
  287. } else {
  288. throw err
  289. }
  290. }
  291. } else {
  292. refractorRoot = node
  293. }
  294. refractorRoot.children = addNodePositionClosure()(refractorRoot.children)
  295. // Add position info to root
  296. if (refractorRoot.children.length > 0) {
  297. refractorRoot.position = {
  298. start: { line: refractorRoot.children[0].position.start.line, column: 0 },
  299. end: {
  300. line: refractorRoot.children[refractorRoot.children.length - 1].position.end.line,
  301. column: 0,
  302. },
  303. }
  304. } else {
  305. refractorRoot.position = {
  306. start: { line: 0, column: 0 },
  307. end: { line: 0, column: 0 },
  308. }
  309. }
  310. const shouldHighlightLine = calculateLinesToHighlight(meta)
  311. const startingLineNumber = calculateStartingLine(meta)
  312. const codeLineArray = createLineNodes(refractorRoot.position.end.line)
  313. const falseShowLineNumbersStr = [
  314. "showlinenumbers=false",
  315. "showlinenumbers=\"false\"",
  316. "showlinenumbers={false}",
  317. ]
  318. const shouldShowLineNumbers = (meta.toLowerCase().includes("showLineNumbers".toLowerCase())
  319. || options.showLineNumbers) && !falseShowLineNumbersStr.some((str) => meta.toLowerCase().includes(str))
  320. createToolbarElement(parent, meta)
  321. for (const [i, line] of codeLineArray.entries()) {
  322. // Default class name for each line
  323. line.properties.className = ["code-line"]
  324. // Syntax highlight
  325. const treeExtract = filter(
  326. refractorRoot,
  327. (node) => node.position.start.line <= i + 1 && node.position.end.line >= i + 1
  328. )
  329. line.children = treeExtract.children
  330. if (shouldShowLineNumbers) {
  331. line.properties.line = [(i + startingLineNumber).toString()]
  332. line.properties.className.push("line-number")
  333. }
  334. if (shouldHighlightLine(i)) {
  335. line.properties.className.push("highlight-line")
  336. }
  337. // Diff classes
  338. if (
  339. (lang === "diff" || lang?.includes("diff-")) &&
  340. toString(line).substring(0, 1) === "-"
  341. ) {
  342. line.properties.className.push("deleted")
  343. } else if (
  344. (lang === "diff" || lang?.includes("diff-")) &&
  345. toString(line).substring(0, 1) === "+"
  346. ) {
  347. line.properties.className.push("inserted")
  348. }
  349. }
  350. // Remove possible trailing line when splitting by \n which results in empty array
  351. if (
  352. codeLineArray.length > 0 &&
  353. toString(codeLineArray[codeLineArray.length - 1]).trim() === ""
  354. ) {
  355. codeLineArray.pop()
  356. }
  357. node.children = codeLineArray
  358. }
  359. }
  360. }
  361. export default rehypePrismGenerator