import MarkdownIt from 'markdown-it' import mdAttrs from 'markdown-it-attrs' import mdDecorate from 'markdown-it-decorate' import mdEmoji from 'markdown-it-emoji' import mdTaskLists from 'markdown-it-task-lists' import mdExpandTabs from 'markdown-it-expand-tabs' import mdAbbr from 'markdown-it-abbr' import mdSup from 'markdown-it-sup' import mdSub from 'markdown-it-sub' import mdMark from 'markdown-it-mark' import mdMultiTable from 'markdown-it-multimd-table' import mdFootnote from 'markdown-it-footnote' import katex from 'katex' import mdUnderline from './modules/markdown-it-underline' import mdImsize from './modules/markdown-it-imsize' import 'katex/dist/contrib/mhchem' import twemoji from 'twemoji' import plantuml from './modules/plantuml' import kroki from './modules/kroki.mjs' import katexHelper from './modules/katex' import hljs from 'highlight.js' import { escape, findLast, times } from 'lodash-es' const quoteStyles = { chinese: '””‘’', english: '“”‘’', french: ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'], german: '„“‚‘', greek: '«»‘’', japanese: '「」「」', hungarian: '„”’’', polish: '„”‚‘', portuguese: '«»‘’', russian: '«»„“', spanish: '«»‘’', swedish: '””’’' } export class MarkdownRenderer { constructor (config = {}) { this.md = new MarkdownIt({ html: config.allowHTML, breaks: config.lineBreaks, linkify: config.linkify, typography: config.typographer, quotes: quoteStyles[config.quotes] ?? quoteStyles.english, highlight (str, lang) { if (lang === 'diagram') { return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>` } else if (['mermaid', 'plantuml'].includes(lang)) { return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>` } else { const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : { value: str } const lineCount = highlighted.value.match(/\n/g).length const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : '' return `<pre class="codeblock hljs ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>` } } }) .use(mdAttrs, { allowedAttributes: ['id', 'class', 'target'] }) .use(mdDecorate) .use(mdEmoji) .use(mdTaskLists, { label: false, labelAfter: false }) .use(mdExpandTabs, { tabWidth: config.tabWidth }) .use(mdAbbr) .use(mdSup) .use(mdSub) .use(mdMark) .use(mdFootnote) .use(mdImsize) if (config.underline) { this.md.use(mdUnderline) } if (config.mdmultiTable) { this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true }) } // -------------------------------- // PLANTUML // -------------------------------- if (config.plantuml) { plantuml.init(this.md, { server: config.plantumlServerUrl }) } // -------------------------------- // KROKI // -------------------------------- if (config.kroki) { kroki.init(this.md, { server: config.krokiServerUrl }) } // -------------------------------- // KATEX // -------------------------------- const macros = {} // TODO: Add mhchem (needs esm conversion) // Add \ce, \pu, and \tripledash to the KaTeX macros. // katex.__defineMacro('\\ce', function (context) { // return chemParse(context.consumeArgs(1)[0], 'ce') // }) // katex.__defineMacro('\\pu', function (context) { // return chemParse(context.consumeArgs(1)[0], 'pu') // }) // Needed for \bond for the ~ forms // Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not // a mathematical minus, U+2212. So we need that extra 0.56. katex.__defineMacro('\\tripledash', '{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' + '\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}') this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline) this.md.renderer.rules.katex_inline = (tokens, idx) => { try { return katex.renderToString(tokens[idx].content, { displayMode: false, macros }) } catch (err) { console.warn(err) return tokens[idx].content } } this.md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, { alt: ['paragraph', 'reference', 'blockquote', 'list'] }) this.md.renderer.rules.katex_block = (tokens, idx) => { try { return '<p>' + katex.renderToString(tokens[idx].content, { displayMode: true, macros }) + '</p>' } catch (err) { console.warn(err) return tokens[idx].content } } // -------------------------------- // TWEMOJI // -------------------------------- this.md.renderer.rules.emoji = (token, idx) => { return twemoji.parse(token[idx].content, { callback (icon, opts) { return `/_assets/svg/twemoji/${icon}.svg` } }) } // -------------------------------- // Inject line numbers for preview scroll sync // -------------------------------- this.linesMap = [] const injectLineNumbers = (tokens, idx, options, env, slf) => { let line if (tokens[idx].map && tokens[idx].level === 0) { line = tokens[idx].map[0] + 1 tokens[idx].attrJoin('class', 'line') tokens[idx].attrSet('data-line', String(line)) this.linesMap.push(line) } return slf.renderToken(tokens, idx, options, env, slf) } this.md.renderer.rules.paragraph_open = injectLineNumbers this.md.renderer.rules.heading_open = injectLineNumbers this.md.renderer.rules.blockquote_open = injectLineNumbers } render (src) { this.linesMap = [] return this.md.render(src) } getClosestPreviewLine (line) { return findLast(this.linesMap, n => n <= line) } }