前端markdown-it实现自定义内联或块级元素实战

3,348 阅读9分钟

通过markdown-it, 渲染自定义内联或块级规则的markdown

有道小P app中,有大量的地方需要做流式渲染,并需要支持markdown,且要根据需求自定义规则,这篇文章介绍一下内部关于markdown处理有关的细节

目录

  1. 什么是markdown
  2. 什么是markdown-it
  3. 如何通过markdown-it实现下列功能:
    • 自定义内联规则
      • 场景:流式渲染latex公式
    • 自定义块级规则
      • 场景:自定义块级元素,渲染思维导图
      • markdown-it-container介绍
      • 自定义实现

什么是markdown

Markdown是一种轻量级的标记语言,它允许人们使用易读易写的纯文本格式编写文档,然后转换成结构化的HTML(或者其他格式)。它的目标是实现“易读易写”,并且具有一定的可读性,即使在没有格式转换的情况下也能被阅读。

什么是markdown-it

markdown-it是前端常用的markdown文本转换工具,对比于另一个工具marked.js,markdownIt的功能更全面,更易于扩展,能更好的应对复杂的markdown渲染需求。缺点是体积更大
如果有复杂的渲染需求,markdown-it更合适;如果是简单的展示markdown,marked.js更合适

通过markdown-it实现自定义功能

一、自定义内联规则(inline元素)

应用场景:

流式回答时,需要通过依赖Mathjax渲染latex公式。latex公式的渲染是异步,即需要将页面内容(latex公式)append到元素中,再调用Mathjax去渲染。 因此,在流式渲染markdown时,会重复下面过程

  1. LaTex公式输出,此时还未没Mathjax处理
  2. LatexMathJax处理了
  3. 流式输出时,添加了新的文本,整个markdown回答区域会被不断覆盖,此时LaTex公式又变成了未被处理的状态

从而导致LaTex公式一直在闪(见下图)

LaTex简介

通俗的讲,就是根据给定的格式编写公式,提高复杂公式的可读性。
以一元二次求根公式为例:

  • 非LaTex公式:x = (-b ± √(b² - 4ac)) / 2a
  • LaTex公式
    • LaTex原式:$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
    • LaTex渲染结果:x=b±b24ac2a.x = {-b \pm \sqrt{b^2-4ac} \over 2a}.

可以看到,LaTex公式渲染后可读性更高,外观更接近纸上的手写公式

优化前效果:

output4.gif

解决思路

干预在markdown-itmarkdown文本转换为markdownHTML的过程,步骤如下:

  1. markdown-it处理markdown的过程中,匹配出所有LaTex公式
  2. 在可视区域外异步渲染匹配到的LaTex公式,并将LaTex公式对应的渲染结果LaTexHtml记录下来
    • 为避免保存的Html内容过多,可以配合LRU机制处理
  3. markdown内容更新后,会再次在markdown-it的处理过程中触发匹配LaTex。针对匹配到的LaTex公式,如果有对应的渲染结果,那么就用渲染结果LaTexHtml直接替换掉LaTex公式,从而解决公式闪动的问题(因为此时输出的不再是LaTex原公式,而是对应的LaTexHtml
markdown-it中匹配内联文本

解决思路比较好理解,主要是如何在markdown-it中匹配到LaTex公式。如果在其他场景下,要匹配其他的内联文本,其实是一样的做法。

初始化markdown-it实例
const md = markdownIt()

markdown-it中提供了md.inline.ruler来接受解析规则。我们基于这个类来做处理

自定义unshift方法

我们需要添加自定义内联规则,来匹配LaTex公式并处理。markdown-itRuler类提供了push(尾部插入),before(某个规则前插入),after(某个规则后插入)三个方法插入规则。
但是实际开发中,发现push插入的规则由于在尾部,得到的文本已经被默认的规则处理,导致难以匹配到LaTex,并不合适。
最终选择参考源码Ruler.push的实现,新增md.inline.ruler.unshift方法,用于将自定义规则插入到第一个

// unshift代码的实现参考https://github.com/markdown-it/markdown-it/blob/master/lib/ruler.mjs的push函数
md.inline.ruler.unshift = function (ruleName, fn, options) {
  const opt = options || {}
  this.__rules__.unshift({
    name: ruleName,
    enabled: true,
    fn,
    alt: opt.alt || []
  })

  this.__cache__ = null
}
LaTex的格式处理

LaTex公式一般是\(xxx \)$ xxxx $\[ xxx \]等多种前后缀,如$ a+b $,因此markdown在进入markdown-it处理前,会做个匹配,使得公式变成如:

  1. 块级公式(占据整行): latexStartBlock xxxx latexEndBlock
  2. 内联公式(跟在文本前后): latexStartInline xxxxx latexEndInline

改成这两种格式后,可以通过下面两个正则来匹配公式:

const regexArr = [
    { reg: () => /latexStartBlock(.*?)latexEndBlock/s, display: 'Block' },
    { reg: () => /latexStartInline(.*?)latexEndInline/s, display: 'Inline'}
]
ruler自定义规则中获取当前匹配的文本,并匹配

state.pos:记录上次处理后的位置
state.src:总的文本
state.src.slice(pos):剩余的文本

md.inline.ruler.unshift('latex', function (state) {
  let pos = state.pos;
  const str = state.src.slice(pos)
  const { saveLatexRender } = state.env
  // 这里使用正则表达式匹配 LaTeX 公式。latexStart和latexEnd是自定义的标记,用于标记公式的开始和结束
  // 上面的情况可能正常,因为inline被截取后会被md.renderInline,最后还是会被渲染出来
  const regexArr = [
    { reg: () => /latexStartBlock(.*?)latexEndBlock/s, display: 'Block' },
    { reg: () => /latexStartInline(.*?)latexEndInline/s, display: 'Inline' }]
  for (let i = 0; i < regexArr.length; i++) {
    const regex = regexArr[i].reg()
    const display = regexArr[i].display

    let pos = state.pos;
    const str = state.src.slice(pos)
    const match = str.match(regex);

    // 是否匹配到latex公式
    if (match) {
        // ...其他逻辑
    }
  }
  // 没有匹配到公式,用默认的处理。返回false
  return false;
});

注意,上面的自定义规则中,匹配到后,并不能保证str的开头就是公式的开头,以及str的结尾就是公式的结尾。
针对这种情况,需要先将LaTex前的其他文本用默认的规则处理:md.renderInline(inlineText),并通过new State.Token记录处理的结果
此外,还需要更新当前的位置pos的位置

// 如果公式前面有其他内容,先渲染其他内容
if (!str.startsWith(`latexStart${display}`)) {
    const startIndex = str.indexOf(`latexStart${display}`)
    // 移动指针,保证移动后latex公式在最前面
    state.pos += startIndex;
    const token = new state.Token('html_inline', '', 0);
    state.tokens.push(token);
    // 渲染前面的内容,然后再处理latex公式
    const inlineText = str.slice(0, startIndex)
    token.content = `${md.renderInline(inlineText)}`;
    return true
}

剩下的步骤,便是检查匹配到的LaTex有无对应的LaTexHtml,无则忽略,有则替换,完整代码如下:

// 自定义渲染规则来处理 LaTeX 公式
md.inline.ruler.unshift('latex', function (state) {
  const { saveLatexRender } = state.env
  // 这里使用正则表达式匹配 LaTeX 公式。latexStart和latexEnd是自定义的标记,用于标记公式的开始和结束
  // 上面的情况可能正常,因为inline被截取后会被md.renderInline,最后还是会被渲染出来
  const regexArr = [
    { reg: () => /latexStartBlock(.*?)latexEndBlock/s, display: 'Block' },
    { reg: () => /latexStartInline(.*?)latexEndInline/s, display: 'Inline' }]
  for (let i = 0; i < regexArr.length; i++) {
    const regex = regexArr[i].reg()
    const display = regexArr[i].display

    let pos = state.pos;
    const str = state.src.slice(pos)
    const match = str.match(regex);

    // 是否匹配到latex公式
    if (match) {
      const latex = match[1]?.trim() || '';
      if (!latex) {
        continue
      }

      // 如果公式前面有其他内容,先渲染其他内容
      if (!str.startsWith(`latexStart${display}`)) {
        const startIndex = str.indexOf(`latexStart${display}`)
        state.pos += startIndex;// 移动指针,保证移动后latex公式在最前面
        const token = new state.Token('html_inline', '', 0);
        state.tokens.push(token);
        // 渲染前面的内容,然后再处理latex公式
        const inlineText = str.slice(0, startIndex)
        token.content = `${md.renderInline(inlineText)}`;
        return true
      }
      // latex有对应的HTML
      if (preUtils.hasLatex(latex)) {
        // 包裹 LaTeX 公式以便 MathJax 可以处理
        const token = new state.Token('html_inline', '', 0);
        token.content = preUtils.getLatex(latex);
        state.tokens.push(token);
        state.pos += match[0].length;
        return true
      } else {
        if (saveLatexRender) {
          const isBlock = display === 'Block'
          preUtils.preRenderLatex(latex, isBlock)
        }
        // 匹配到,但是没有实际的html,直接返回公式
        const token = new state.Token('html_inline', '', 0);
        token.content = match[0]
        state.tokens.push(token);
        state.pos += match[0].length;
        return true
      }
    }
  }
  // 没有匹配到公式,用默认的处理。返回false
  return false;
});

优化后效果

output5.gif

二、自定义块级规则

应用场景

AI作文中,需要在流式中渲染思维导图。而markdown-it本身是不支持思维导图的渲染,且需求里要渲染的思维导图是输出ui给定的样式

处理过程
  1. markdown-it中匹配出要渲染成思维导图的块级内容
  2. 将匹配到的块级内容转换成思维导图的HTML
  3. 将步骤2转换得到的思维导图替换对应内容
markdown-it-container插件

最初考虑通过官方的插件markdown-it-container来匹配自定义的块级内容
但是跑demo的时候发现,这个插件只能在外层添加内容,内部的渲染无法被修改,仅适合在外层添加container的场景。翻了issue发现设计便是如此

image.png

自定义一个块级内容匹配规则

md.inliner.rule.unshift类似,先自定义一个md.block.ruler.unshift函数。后续的匹配逻辑添加到md.block.ruler

// unshift代码的实现参考https://github.com/markdown-it/markdown-it/blob/master/lib/ruler.mjs的push函数
md.block.ruler.unshift = function (ruleName, fn, options) {
  const opt = options || {}
  this.__rules__.unshift({
    name: ruleName,
    enabled: true,
    fn,
    alt: opt.alt || []
  })

  this.__cache__ = null
}
自定义思维导图格式

:mps开头,:mpe结尾的块级元素,在通过内部的有序/无序标签生成思维导图

:mps // mindmap start
xxxx 思维导图的具体内容
# 标题1
## 副标题2
:mpe // mindmap end

具体匹配实现:

参考了markdown-it中的block规则

// 自定义规则处理markdown-mindmap标签
md.block.ruler.unshift('markdown_mindmap', markdownToMindMapRule, {
  alt: ['paragraph','reference', 'blockquote']
})

function markdownToMindMapRule(state, startLine, endLine, silent) {
  ...
}

STATE的参数介绍

  1. state.bMarks=[]:记录某一行的开始的位置,在整个markdown中对应的pos
  2. state.eMarks=[]:记录某行行结束的位置对应的pos
  3. state.tShift=[]:记录某一行的前面的空格或制表符\t数量(制表符记为1)
  4. state.sCount=[]:记录某一行的前面的空格或制表符数量(制表符记为4)
  5. state.src:记录输入的整个markdown文本

用下面这段markdown举例:


let markdown = ` \t   # 这很好呀
  ## 没办法咯`

一共有两行,可以得到:

  • 第一行 \t # 这很好呀
    • bMarks[0] = 0:因为是第一行,从0开始
    • eMarks[0] = 11:第一行文本结束的位置即'呀'的位置11
    • tShift[0] = 5:第一行首个有意义的内容是#,他前面有1个空格+4个制表符数量
    • sCount[0] = 7:第一行有意义的内容是#,他前面有4个空格+1个制表符,所以是3+1*4
  • 第二行 ## 没办法咯
    • bMarks[0] = 0:第二行,在整个markdown中,开始位置是12
    • eMarks[0] = 11:第一行结束的位置即的位置11,加上后面的一个换行符=12
    • tShift[0] = 2:第二行首个有意义的内容是#,他前面有2个空格
    • sCount=[0] = 2:第二行首个有意义的内容是#,他前面有2个空格。因为没有制表符\t,所以和tShift一样

根据上面的四个参数,可以得到当前行的文本内容

// 整个markdown中,lineNum开始的pos
let pos = state.bMarks[lineNum] + state.tShift[lineNum]
// 整个markdown中,结束的pos
let max = state.eMarks[lineNum]
const lineText = state.src.slice(pos, max)

基于此,我们可以这样去匹配:

  1. 逐行匹配,若遇到需要的:mps,则进入下一步,否则忽略
  2. 匹配到:mps后,记录当前行startLine,并继续往下匹配,直到某一行遇到结束标志:mpe,记录endLine
  3. 根据startLineendLine,获取中间的内容,并转换成思维导图的HTML,替换进去

详细的自定义md.block.ruler代码如下

/* eslint-disable */
// 核心代码来自:https://github.com/markdown-it/markdown-it/blob/master/lib/rules_block/html_block.mjs
// HTML block
import markdownToMindMap from "./markdownToMindMapHtml"

export default function html_block(state, startLine, endLine, silent) {
  /**
   * 得到
   * ```
   * :mps
   * xxxx 思维导图具体内容
   * mpe
   * ```
   * 中的内容
   * 由于是在流式中渲染的,因此开头符号要尽可能断 mps=mindmapstart
   */
  const startReg = /^:mps\s*/is
  const endReg = /^mpe\s*/is
  let pos = state.bMarks[startLine] + state.tShift[startLine]
  let max = state.eMarks[startLine]

  // if it's indented more than 3 spaces, it should be a code block
  if (state.sCount[startLine] - state.blkIndent >= 4) { return false }

  if (!state.md.options.html) { return false }


  let lineText = state.src.slice(pos, max)
  
  if (!startReg.test(lineText)) return false


  let nextLine = startLine + 1

  // If we are here - we detected HTML block.
  // Let's roll down till block end.

  // 开头匹配:::mindMap
  // 然后下面这里在匹配到结束的标志mindMapEnd之前,将中间行的文本拼接起来
  if (!endReg.test(lineText)) {
    for (; nextLine < endLine; nextLine++) {
      if (state.sCount[nextLine] < state.blkIndent) { break }

      pos = state.bMarks[nextLine] + state.tShift[nextLine]
      max = state.eMarks[nextLine]
      // 此时lineText是下一行的文本了
      lineText = state.src.slice(pos, max)

      if (endReg.test(lineText)) {
        if (lineText.length !== 0) { nextLine++ }
        break
      }
    }
  }

  state.line = nextLine

  const token = state.push('html_block', '', 0)
  token.map = [startLine, nextLine]
  
  token.content = markdownToMindMap(state.getLines(startLine, nextLine, state.blkIndent, true))

  return true
}

思维导图最终效果

output.gif

总结

详细介绍了下markdown-it中的自定义内联与块级元素并替换为期望的内容

demo仓库

github