通过markdown-it, 渲染自定义内联或块级规则的markdown
在有道小P app中,有大量的地方需要做流式渲染,并需要支持markdown
,且要根据需求自定义规则,这篇文章介绍一下内部关于markdown
处理有关的细节
目录
- 什么是markdown
- 什么是markdown-it
- 如何通过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
时,会重复下面过程
LaTex
公式输出,此时还未没Mathjax
处理Latex
被MathJax
处理了- 流式输出时,添加了新的文本,整个
markdown
回答区域会被不断覆盖,此时LaTex
公式又变成了未被处理的状态
从而导致LaTex公式一直在闪(见下图)
LaTex简介
通俗的讲,就是根据给定的格式编写公式,提高复杂公式的可读性。
以一元二次求根公式为例:
- 非LaTex公式:x = (-b ± √(b² - 4ac)) / 2a
- LaTex公式
- LaTex原式:
$$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
- LaTex渲染结果:
- LaTex原式:
可以看到,LaTex公式渲染后可读性更高,外观更接近纸上的手写公式
优化前效果:
解决思路
干预在markdown-it
将markdown
文本转换为markdownHTML
的过程,步骤如下:
- 在
markdown-it
处理markdown
的过程中,匹配出所有LaTex公式 - 在可视区域外异步渲染匹配到的
LaTex
公式,并将LaTex
公式对应的渲染结果LaTexHtml
记录下来- 为避免保存的
Html
内容过多,可以配合LRU机制处理
- 为避免保存的
- 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-it
的Ruler
类提供了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
处理前,会做个匹配,使得公式变成如:
- 块级公式(占据整行):
latexStartBlock xxxx latexEndBlock
- 内联公式(跟在文本前后):
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;
});
优化后效果
二、自定义块级规则
应用场景
AI作文中,需要在流式中渲染思维导图。而markdown-it
本身是不支持思维导图的渲染,且需求里要渲染的思维导图是输出ui给定的样式
处理过程
- 在
markdown-it中
匹配出要渲染成思维导图的块级内容 - 将匹配到的块级内容转换成思维导图的
HTML
- 将步骤2转换得到的思维导图替换对应内容
markdown-it-container插件
最初考虑通过官方的插件markdown-it-container
来匹配自定义的块级内容
但是跑demo的时候发现,这个插件只能在外层添加内容,内部的渲染无法被修改,仅适合在外层添加container
的场景。翻了issue发现设计便是如此
自定义一个块级内容匹配规则
与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
的参数介绍
state.bMarks=[]
:记录某一行的开始的位置,在整个markdown中对应的posstate.eMarks=[]
:记录某行行结束的位置对应的posstate.tShift=[]
:记录某一行的前面的空格或制表符\t
数量(制表符记为1)state.sCount=[]
:记录某一行的前面的空格或制表符数量(制表符记为4)state.src
:记录输入的整个markdown
文本
用下面这段markdown
举例:
let markdown = ` \t # 这很好呀
## 没办法咯`
一共有两行,可以得到:
- 第一行
\t # 这很好呀
bMarks[0] = 0
:因为是第一行,从0开始eMarks[0] = 11
:第一行文本结束的位置即'呀'的位置11tShift[0] = 5
:第一行首个有意义的内容是#
,他前面有1个空格+4个制表符数量sCount[0] = 7
:第一行有意义的内容是#
,他前面有4个空格+1个制表符,所以是3+1*4
- 第二行
## 没办法咯
bMarks[0] = 0
:第二行,在整个markdown中,开始位置是12eMarks[0] = 11
:第一行结束的位置即呀
的位置11,加上呀
后面的一个换行符=12tShift[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)
基于此,我们可以这样去匹配:
- 逐行匹配,若遇到需要的
:mps
,则进入下一步,否则忽略 - 匹配到
:mps
后,记录当前行startLine
,并继续往下匹配,直到某一行遇到结束标志:mpe
,记录endLine
- 根据
startLine
与endLine
,获取中间的内容,并转换成思维导图的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
}
思维导图最终效果
总结
详细介绍了下markdown-it
中的自定义内联与块级元素并替换为期望的内容