使用 markdown-it 解析 markdown 代码(读 VuePress 三)
前言
在此系列文章的第一篇,我们介绍了 Vuepress 如何让 Markdown 支持 Vue 组件的,但没有提到非 Vue 组件的其他部分如何被解析。
今天,我们就来看看 Vuepress 是如何利用 markdown-it 来解析 markdown 代码的。
markdown-it 简介
markdown-it 是一个辅助解析 markdown 的库,可以完成从 # test
到 <h1>test</h1>
的转换。
它同时支持浏览器环境和 Node 环境,本质上和 babel 类似,不同之处在于,babel 解析的是 JavaScript。
说到解析,markdown-it 官方给了一个在线示例,可以让我们直观地得到 markdown 经过解析后的结果。比如还是拿 # test
举例,会得到如下结果:
[{"type": "heading_open","tag": "h1","attrs": null,"map": [0,1],"nesting": 1,"level": 0,"children": null,"content": "","markup": "#","info": "","meta": null,"block": true,"hidden": false},{"type": "inline","tag": "","attrs": null,"map": [0,1],"nesting": 0,"level": 1,"children": [{"type": "text","tag": "","attrs": null,"map": null,"nesting": 0,"level": 0,"children": null,"content": "test","markup": "","info": "","meta": null,"block": false,"hidden": false}],"content": "test","markup": "","info": "","meta": null,"block": true,"hidden": false},{"type": "heading_close","tag": "h1","attrs": null,"map": null,"nesting": -1,"level": 0,"children": null,"content": "","markup": "#","info": "","meta": null,"block": true,"hidden": false}
]
复制代码
经过 tokenizes 后,我们得到了一个 tokens:
我们也可以手动执行下面代码得到同样的结果:
const md = new MarkdownIt()
let tokens = md.parse('# test')
console.log(tokens)
复制代码
主要 API 介绍
模式
markdown-it 提供了三种模式:commonmark、default、zero。分别对应最严格、GFM、最宽松的解析模式。
解析
markdown-it 的解析规则大体上分为块(block)和内联(inline)两种。具体可体现为 MarkdownIt.block
对应的是解析块规则的 ParserBlock, MarkdownIt.inline
对应的是解析内联规则的 ParserInline,MarkdownIt.renderer.render
和 MarkdownIt.renderer.renderInline
分别对应按照块规则和内联规则生成 HTML 代码。
规则
在 MarkdownIt.renderer
中有一个特殊的属性:rules,它代表着对于 token 们的渲染规则,可以被使用者更新或扩展:
var md = require('markdown-it')();md.renderer.rules.strong_open = function () { return '<b>'; };
md.renderer.rules.strong_close = function () { return '</b>'; };var result = md.renderInline(...);
复制代码
比如这段代码就更新了渲染 strong_open 和 strong_close 这两种 token 的规则。
插件系统
markdown-it 官方说过:
We do a markdown parser. It should keep the "markdown spirit". Other things should be kept separate, in plugins, for example. We have no clear criteria, sorry. Probably, you will find CommonMark forum a useful read to understand us better.
一言以蔽之,就是 markdown-it 只做纯粹的 markdown 解析,想要更多的功能你得自己写插件。
所以,他们提供了一个 API:MarkdownIt.use
它可以将指定的插件加载到当前的解析器实例中:
var iterator = require('markdown-it-for-inline');
var md = require('markdown-it')().use(iterator, 'foo_replace', 'text', function (tokens, idx) {tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar');});
复制代码
这段示例代码就将 markdown 代码中的 foo 全部替换成了 bar。
更多信息
可以访问我国庆期间翻译的中文文档,或者官方 API 文档。
vuepress 中的应用
vuepress 借助了 markdown-it 的诸多社区插件,如高亮代码、代码块包裹、emoji 等,同时也自行编写了很多 markdown-it 插件,如识别 vue 组件、内外链区分渲染等。
相关源码
本文写自 2018 年国庆期间,对应 vuepress 代码版本为 v1.0.0-alpha.4。
入口
源码 主要做了下面五件事:
- 使用社区插件,如 emoji 识别、锚点、toc。
- 使用自定义插件,稍后详细说明。
- 使用 markdown-it-chain 支持链式调用 markdown-it,类似我在第二篇文章提到的 webpack-chain。
- 参数可以传 beforeInstantiate 和 afterInstantiate 这两个钩子,这样方便暴露 markdown-it 实例给外部。
- dataReturnable 自定义 render:
module.exports.dataReturnable = function dataReturnable (md) {// override render to allow custom plugins return dataconst render = md.rendermd.render = (...args) => {md.__data = {}const html = render.call(md, ...args)return {html,data: md.__data}}
}
复制代码
相当于让 __data 作为一个全局变量了,存储各个插件要用到的数据。
识别 vue 组件
源码
就做了一件事:替换默认的 htmlBlock 规则,这样就可以在根级别使用自定义的 vue 组件了。
module.exports = md => {md.block.ruler.at('html_block', htmlBlock)
}
复制代码
这个 htmlBlock 函数和原生的 markdown-it 的 html_block 关键区别在哪呢?
答案是在 HTML_SEQUENCES 这个正则数组里添加了两个元素:
// PascalCase Components
[/^<[A-Z]/, />/, true],
// custom elements with hyphens
[/^<\w+\-/, />/, true],
复制代码
很明显,这就是用来匹配帕斯卡写法(如 <Button/>
)和连字符(如 <button-1/>
)写法的组件的。
内容块
源码
这个组件实际上是借助了社区的 markdown-it-container 插件,在此基础上定义了 tip、warning、danger、v-pre 这四种内容块的 render 函数:
render (tokens, idx) {const token = tokens[idx]const info = token.info.trim().slice(klass.length).trim()if (token.nesting === 1) {return `<div class="${klass} custom-block"><p class="custom-block-title">${info || defaultTitle}</p>\n`} else {return `</div>\n`}
}
复制代码
这里需要说明一下的是 token 的两个属性。
info 三个反引号后面跟的那个字符串。
nesting 属性:
1
意味着标签打开。0
意味着标签是自动关闭的。-1
意味着标签正在关闭。
高亮代码
源码
- 借助了 prismjs 这个库
- 将 vue 和 html 看做是同一种语言:
if (lang === 'vue' || lang === 'html') {lang = 'markup'
}
复制代码
- 对语言缩写做了兼容,如 md、ts、py
- 使用 wrap 函数对生成的高亮代码再做一层包装:
function wrap (code, lang) {if (lang === 'text') {code = escapeHtml(code)}return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}
复制代码
高亮代码行
源码
- 在别人的代码基础上修改的。
- 重写了 md.renderer.rules.fence 方法,关键是借助一个正则判断获取要高亮的代码行们:
const RE = /{([\d,-]+)}/const lineNumbers = RE.exec(rawInfo)[1].split(',').map(v => v.split('-').map(v => parseInt(v, 10)))
复制代码
然后条件渲染:
if (inRange) {return `<div class="highlighted"> </div>`
}
return '<br>'
复制代码
最后返回高亮行代码 + 普通代码。
脚本提升
源码
重写 md.renderer.rules.html_block 规则:
const RE = /^<(script|style)(?=(\s|>|$))/imd.renderer.rules.html_block = (tokens, idx) => {const content = tokens[idx].contentconst hoistedTags = md.__data.hoistedTags || (md.__data.hoistedTags = [])if (RE.test(content.trim())) {hoistedTags.push(content)return ''} else {return content}
}
复制代码
将 style 和 script 标签保存在 __data 这个伪全局变量里。这部分数据会在 markdownLoader 中用到。
行号
源码
重写 md.renderer.rules.fence 规则,通过换行符的数量来推算代码行数,并再包裹一层:
const lines = code.split('\n')
const lineNumbersCode = [...Array(lines.length - 1)].map((line, index) => `<span class="line-number">${index + 1}</span><br>`).join('')const lineNumbersWrapperCode =`<div class="line-numbers-wrapper">${lineNumbersCode}</div>`
复制代码
最后再得到最终代码:
const finalCode = rawCode.replace('<!--beforeend-->', `${lineNumbersWrapperCode}<!--beforeend-->`).replace('extra-class', 'line-numbers-mode')return finalCode
复制代码
内外链区分
源码
一个 a 链接,可能是跳往站内的,也有可能是跳往站外的。vuepress 将这两种链接做了一个区分,最终外链会比内链多渲染出一个图标:
要实现这点,vuepress 重写了 md.renderer.rules.link_open 和 md.renderer.rules.link_close 这两个规则。
先看 md.renderer.rules.link_open :
if (isExternal) {Object.entries(externalAttrs).forEach(([key, val]) => {token.attrSet(key, val)})if (/_blank/i.test(externalAttrs['target'])) {hasOpenExternalLink = true}
} else if (isSourceLink) {hasOpenRouterLink = truetokens[idx] = toRouterLink(token, link)
}
复制代码
isExternal 便是外链的标志位,这时如果它为真,则直接设置 token 的属性即可,如果 isSourceLink 为真,则代表传入了个内链,整个 token 将会被替换成 toRouterLink(token, link)
:
function toRouterLink (token, link) {link[0] = 'to'let to = link[1]// convert link to filename and export it for existence checkconst links = md.__data.links || (md.__data.links = [])links.push(to)const indexMatch = to.match(indexRE)if (indexMatch) {const [, path, , hash] = indexMatchto = path + hash} else {to = to.replace(/\.md$/, '.html').replace(/\.md(#.*)$/, '.html$1')}// relative path usage.if (!to.startsWith('/')) {to = ensureBeginningDotSlash(to)}// markdown-it encodes the urilink[1] = decodeURI(to)// export the router links for testingconst routerLinks = md.__data.routerLinks || (md.__data.routerLinks = [])routerLinks.push(to)return Object.assign({}, token, {tag: 'router-link'})
}
复制代码
先是 href 被替换成 to,然后 to 又被替换成 .html 结尾的有效链接。
再来看 md.renderer.rules.link_close :
if (hasOpenRouterLink) {token.tag = 'router-link'hasOpenRouterLink = false
}
if (hasOpenExternalLink) {hasOpenExternalLink = false// add OutBoundLink to the beforeend of this link if it opens in _blank.return '<OutboundLink/>' + self.renderToken(tokens, idx, options)
}
return self.renderToken(tokens, idx, options)
复制代码
很明显,内链渲染 router-link 标签,外链渲染 OutboundLink 标签,也就是加了那个小图标的链接组件。
代码块包裹
源码
这个插件重写了 md.renderer.rules.fence 方法,用来对 <pre>
标签再做一次包裹:
md.renderer.rules.fence = (...args) => {const [tokens, idx] = argsconst token = tokens[idx]const rawCode = fence(...args)return `<!--beforebegin--><div class="language-${token.info.trim()} extra-class">` +`<!--afterbegin-->${rawCode}<!--beforeend--></div><!--afterend-->`
}
复制代码
将围栏代码拆成四个部分:beforebegin、afterbegin、beforeend、afterend。相当于给用户再自定义 markdown-it 插件提供了钩子。
锚点非 ascii 字符处理
源码
这段代码最初是为了解决锚点中带中文或特殊字符无法正确跳转的问题。
处理的非 acsii 字符依次是:变音符号 -> C0控制符 -> 特殊字符 -> 连续出现2次以上的短杠(-) -> 用作开头或结尾的短杆。
最后将开头的数字加上下划线,全部转为小写。
代码片段引入
源码
它在 md.block.ruler.fence 之前加入了个 snippet 规则,用作解析 <<< @/filepath
这样的代码:
const start = pos + 3
const end = state.skipSpacesBack(max, pos)
const rawPath = state.src.slice(start, end).trim().replace(/^@/, root)
const filename = rawPath.split(/[{:\s]/).shift()
const content = fs.existsSync(filename) ? fs.readFileSync(filename).toString() : 'Not found: ' + filename
复制代码
它会把其中的文件路径拿出来和 root 路径拼起来,然后读取其中文件内容。因为还可以解析 <<< @/test/markdown/fragments/snippet.js{2}
这样附带行高亮的代码片段,所以需要用 split 截取真正的文件名。
结语
markdown 作为一门解释型语言,可以帮助人们更好地描述一件事物。同时,它又作为通往 HTML 的桥梁,最终可以生成美观简约的页面。
而 markdown-it 提供的解析器、渲染器以及插件系统,更是让开发者可以根据自己的想象力赋予 markdown 更多的魅力。
使用 markdown-it 解析 markdown 代码(读 VuePress 三)相关推荐
- Vue3解析markdown解析并实现代码高亮显示
Vue实现博客前端,需要实现markdown的解析,如果有代码则需要实现代码的高亮. Vue的markdown解析库有很多,如markdown-it.vue-markdown-loader.marke ...
- 解析Markdown文件生成React组件文档
前言 最近做的项目使用了微前端框架single-spa. 对于这类微前端框架而言,通常有个utility应用,也就是公共应用,里面是各个子应用之间可以共用的一些公共组件或者方法. 对于一个团队而言,项 ...
- C#解析Markdown文档,实现替换图片链接操作
前言 又是好久没写博客了 其实也不是没写,是最近在「做一个博客」,从2月21日开始,大概一个多星期的时间,疯狂刷进度,边写代码边写了一整系列的博客开发笔记,目前为止已经写了16篇了,然后上3月之后工作 ...
- python下载图片到文件夹_python实现解析markdown文档中的图片,并且保存到本地~
背景 前阵子简书好像说是凉了,搞得我有点小慌,毕竟我的大部分博客都是放在简书上面的,虽然简书提供了打包导出功能,但是只能导出文字,图片的话还是存在简书服务器上面,再加上我一直想要重新做一个个人博客,于 ...
- php解析markdown前端渲染,Vuejs使用 vue-markdown 来渲染评论方法
如果你想使用一个编辑器或者是在评论系统中支持 markdown.这个 package 的有点还是挺多了,比如默认就支持 emoji,这个就很完美啦!laravist 的新版就使用了 vue-markd ...
- vue使用marked解析markdown文本遇到的坑及解决方法
vue使用marked解析markdown文本遇到的坑及解决方法 1. 前言 最近在搭建自己的博客网站 https://lyuanzhi.com,不免遇到了要把markdown文本转化为html代码的 ...
- MarkDown简介(深度解析MarkDown用法)
欢迎使用Markdown编辑器写博客 本Markdown编辑器使用StackEdit修改而来,用它写博客,将会带来全新的体验哦: 本MarkDown简介分成啥是MarkDown.为啥MarkDown和 ...
- Java如何解析markdown_Java类库解析markdown文本
Java类库解析markdown文本 朋也社区之前使用的方法是,在java代码里调用js,然后通过调用js里的方法来解析markdown的,但性能太渣,然后在github上找了找,发现 pegdown ...
- js解析MarkDown语法
js解析MarkDown语法 问题描述: 我们使用MarkDown编辑器之后,比如我们写的MarkDown的语法的语法 # 一级标题 ## 二级标题 ### 三级标题 这种语法我们最终要转换成HTML ...
最新文章
- 为什么MySQL数据库要用B+树存储索引?
- STE:中科院微生物所胡松年组揭示一年内医院ICU环境菌群的生物多样性
- aryson ms sql_数据治理:SQL数据清洗十八般武艺
- 2019.2.20异常
- C语言如何在可变参数函数中使用printf?
- uefi linux开发环境,开发者为 Linux 添加了一系列 RISC-V UEFI 支持补丁
- ReactNative-地图导航-iOS
- pap认证过程_PPP协议当中LCP协商过程详解和PAP认证原理介绍
- 数据科学入门的5个技巧
- 一个双线程下同一时候操作指针变量导致野指针出现的问题总结
- 今日头条、抖音推荐算法原理全文详解!
- 十进制与二进制相互转换(c++)
- 解决新版edge浏览器首页被搜狗、haoqq等垃圾搜索引擎捆绑问题,并将启动首页设为edge自带新标签页
- [附源码]java毕业设计社区生鲜电商平台
- MySQL数据库基本管理
- Java学习笔记(9)-数组
- 应对功能性失衡-农业大健康·万建民:生物营养强化精准扶贫
- 7-13 查找奥运五环色的位置 (10分)
- 部署报错$notify
- CVPR 2020 论文大盘点-医学影像处理识别篇