markdown.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import MarkdownIt from 'markdown-it'
  2. import mdAttrs from 'markdown-it-attrs'
  3. import mdDecorate from 'markdown-it-decorate'
  4. import mdEmoji from 'markdown-it-emoji'
  5. import mdTaskLists from 'markdown-it-task-lists'
  6. import mdExpandTabs from 'markdown-it-expand-tabs'
  7. import mdAbbr from 'markdown-it-abbr'
  8. import mdSup from 'markdown-it-sup'
  9. import mdSub from 'markdown-it-sub'
  10. import mdMark from 'markdown-it-mark'
  11. import mdMultiTable from 'markdown-it-multimd-table'
  12. import mdFootnote from 'markdown-it-footnote'
  13. import mdMdc from 'markdown-it-mdc'
  14. import katex from 'katex'
  15. import mdUnderline from './modules/markdown-it-underline'
  16. import mdImsize from './modules/markdown-it-imsize'
  17. import 'katex/dist/contrib/mhchem'
  18. import twemoji from 'twemoji'
  19. import plantuml from './modules/plantuml'
  20. import kroki from './modules/kroki.mjs'
  21. import katexHelper from './modules/katex'
  22. import hljs from 'highlight.js'
  23. import { escape, findLast, times } from 'lodash-es'
  24. const quoteStyles = {
  25. chinese: '””‘’',
  26. english: '“”‘’',
  27. french: ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'],
  28. german: '„“‚‘',
  29. greek: '«»‘’',
  30. japanese: '「」「」',
  31. hungarian: '„”’’',
  32. polish: '„”‚‘',
  33. portuguese: '«»‘’',
  34. russian: '«»„“',
  35. spanish: '«»‘’',
  36. swedish: '””’’'
  37. }
  38. export class MarkdownRenderer {
  39. constructor (config = {}) {
  40. this.md = new MarkdownIt({
  41. html: config.allowHTML,
  42. breaks: config.lineBreaks,
  43. linkify: config.linkify,
  44. typography: config.typographer,
  45. quotes: quoteStyles[config.quotes] ?? quoteStyles.english,
  46. highlight (str, lang) {
  47. if (lang === 'diagram') {
  48. return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
  49. } else if (['mermaid', 'plantuml'].includes(lang)) {
  50. return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
  51. } else {
  52. const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : { value: str }
  53. const lineCount = highlighted.value.match(/\n/g).length
  54. const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
  55. return `<pre class="codeblock hljs ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
  56. }
  57. }
  58. })
  59. .use(mdAttrs, {
  60. allowedAttributes: ['id', 'class', 'target']
  61. })
  62. .use(mdDecorate)
  63. .use(mdEmoji)
  64. .use(mdTaskLists, { label: false, labelAfter: false })
  65. .use(mdExpandTabs, { tabWidth: config.tabWidth })
  66. .use(mdAbbr)
  67. .use(mdSup)
  68. .use(mdSub)
  69. .use(mdMark)
  70. .use(mdFootnote)
  71. .use(mdImsize)
  72. .use(mdMdc)
  73. if (config.underline) {
  74. this.md.use(mdUnderline)
  75. }
  76. if (config.mdmultiTable) {
  77. this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
  78. }
  79. // --------------------------------
  80. // PLANTUML
  81. // --------------------------------
  82. if (config.plantuml) {
  83. plantuml.init(this.md, { server: config.plantumlServerUrl })
  84. }
  85. // --------------------------------
  86. // KROKI
  87. // --------------------------------
  88. if (config.kroki) {
  89. kroki.init(this.md, { server: config.krokiServerUrl })
  90. }
  91. // --------------------------------
  92. // KATEX
  93. // --------------------------------
  94. const macros = {}
  95. // TODO: Add mhchem (needs esm conversion)
  96. // Add \ce, \pu, and \tripledash to the KaTeX macros.
  97. // katex.__defineMacro('\\ce', function (context) {
  98. // return chemParse(context.consumeArgs(1)[0], 'ce')
  99. // })
  100. // katex.__defineMacro('\\pu', function (context) {
  101. // return chemParse(context.consumeArgs(1)[0], 'pu')
  102. // })
  103. // Needed for \bond for the ~ forms
  104. // Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not
  105. // a mathematical minus, U+2212. So we need that extra 0.56.
  106. katex.__defineMacro('\\tripledash', '{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' + '\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}')
  107. this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
  108. this.md.renderer.rules.katex_inline = (tokens, idx) => {
  109. try {
  110. return katex.renderToString(tokens[idx].content, {
  111. displayMode: false, macros
  112. })
  113. } catch (err) {
  114. console.warn(err)
  115. return tokens[idx].content
  116. }
  117. }
  118. this.md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
  119. alt: ['paragraph', 'reference', 'blockquote', 'list']
  120. })
  121. this.md.renderer.rules.katex_block = (tokens, idx) => {
  122. try {
  123. return '<p>' + katex.renderToString(tokens[idx].content, {
  124. displayMode: true, macros
  125. }) + '</p>'
  126. } catch (err) {
  127. console.warn(err)
  128. return tokens[idx].content
  129. }
  130. }
  131. // --------------------------------
  132. // TWEMOJI
  133. // --------------------------------
  134. this.md.renderer.rules.emoji = (token, idx) => {
  135. return twemoji.parse(token[idx].content, {
  136. callback (icon, opts) {
  137. return `/_assets/svg/twemoji/${icon}.svg`
  138. }
  139. })
  140. }
  141. // --------------------------------
  142. // Inject line numbers for preview scroll sync
  143. // --------------------------------
  144. this.linesMap = []
  145. const injectLineNumbers = (tokens, idx, options, env, slf) => {
  146. let line
  147. if (tokens[idx].map && tokens[idx].level === 0) {
  148. line = tokens[idx].map[0] + 1
  149. tokens[idx].attrJoin('class', 'line')
  150. tokens[idx].attrSet('data-line', String(line))
  151. this.linesMap.push(line)
  152. }
  153. return slf.renderToken(tokens, idx, options, env, slf)
  154. }
  155. this.md.renderer.rules.paragraph_open = injectLineNumbers
  156. this.md.renderer.rules.heading_open = injectLineNumbers
  157. this.md.renderer.rules.blockquote_open = injectLineNumbers
  158. }
  159. render (src) {
  160. this.linesMap = []
  161. return this.md.render(src)
  162. }
  163. getClosestPreviewLine (line) {
  164. return findLast(this.linesMap, n => n <= line)
  165. }
  166. }