《一篇带你用 VuePress + Github Pages 搭建博客》中,我们使用 VuePress 搭建了一个博客,最终的效果查看:TypeScript 中文文档
如果我们浏览过 TypeScript 官方文档,我们会发现一个很好用的功能,那就是很多代码块,在悬浮上去的时候都会出现一个 Try 按钮:
VuePress 博客优化之拓展 Markdown 语法 - 图1
点击就会跳转到对应的 Playground,比如图示的按钮跳转的就是这个链接,我们可以在这个 Playground 修改并验证代码效果。
如果我们要实现这样的功能,该怎么实现呢?

思考

我们很容易想到,写一个 VuePress 插件来实现它,这个效果看起来有点像代码复制插件,但细细一想,又非如此。
代码复制插件的实现方式,参考 《从零实现一个 VuePress 插件》,可以在页面渲染完成后,遍历每一个代码块然后插入一个复制按钮,点击复制的时候将代码写入剪切板,但是代码块跳转就不一样了,代码跳转需要我们先写入一个链接地址,然后再渲染按钮,问题是这个链接的地址写在哪里呢?要知道,我们能写的只是一个普通的 markdown 文件呀……
于是我们就想到,是否可以拓展 markdown 的语法呢?就比如正常的代码块写法是:

  1. ```typescript
  2. const message = "Hello World!";
  1. 为了实现这个效果,我们是否可以这样写:
  2. ```bash
  3. ```typescript
  4. // try-link: https://www.baidu.com
  5. const message = 'Hello World!';
  1. 但是渲染的时候,并不渲染 try-link 这行注释,而是变成这样的效果:<br />![](https://cdn.nlark.com/yuque/0/2022/png/22749908/1653622756445-c2976d56-00b3-4f26-b940-d4ce22ffda4a.png#clientId=u4397404c-af64-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u0feb8191&margin=%5Bobject%20Object%5D&originHeight=186&originWidth=1748&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=ub0dba64a-d3af-4ba7-a9ba-d09014e3976&title=)<br />当点击 Try 的时候,跳转到对应的链接。<br />当然效果更好的话,可以在鼠标悬浮在代码块上方的时候,才显示这个 Try 按钮,类似于这种效果:<br />![](https://cdn.nlark.com/yuque/0/2022/gif/22749908/1653622764380-b248d248-23cf-4d8c-8a26-8026331fc5de.gif#clientId=u4397404c-af64-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u4035a7e1&margin=%5Bobject%20Object%5D&originHeight=82&originWidth=570&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u33ed479b-ae10-48e0-9970-81770f5e03c&title=)
  2. <a name="ccHYh"></a>
  3. ## markdown-it
  4. 查阅 [VuePress 的官方文档](https://v1.vuepress.vuejs.org/zh/guide/markdown.html#%E8%BF%9B%E9%98%B6%E9%85%8D%E7%BD%AE),我们可以知道:VuePress 使用 markdown-it来渲染 Markdown,那markdown-it是什么呢?查阅 [markdown-it 的 Github 仓库](https://github.com/markdown-it/markdown-it),可以看到这样一段介绍:
  5. :::info
  6. Markdown parser done right. Fast and easy to extend.
  7. :::
  8. 简单的来说,markdown-it就是一个 markdown 渲染器,可以将 markdown 渲染成 html 等,而且 markdown-it 支持写插件拓展功能,实际上,VuePress 项目中的 markdown 文件为什么能支持写 Vue 组件,就是因为 VuePress 写了插件支持了 Vue 语法,那我们是不是也可以拓展 markdown 的语法呢?<br />还好在 [VuePress 文档](https://v1.vuepress.vuejs.org/zh/guide/markdown.html#%E8%BF%9B%E9%98%B6%E9%85%8D%E7%BD%AE)里,提供了配置,可以自定义 markdown-it 插件:
  9. :::info
  10. VuePress 使用 markdown-it (opens new window)来渲染 Markdown,上述大多数的拓展也都是通过自定义的插件实现的。想要进一步的话,你可以通过 .vuepress/config.js markdown 选项,来对当前的 markdown-it 实例做一些自定义的配置:
  11. :::
  12. ```bash
  13. module.exports = {
  14. markdown: {
  15. // markdown-it-anchor 的选项
  16. anchor: { permalink: false },
  17. // markdown-it-toc 的选项
  18. toc: { includeLevel: [1, 2] },
  19. extendMarkdown: md => {
  20. // 使用更多的 markdown-it 插件!
  21. md.use(require('markdown-it-xxx'))
  22. }
  23. }
  24. }

引入的方法知道了,但怎么写这个 markdown-it 插件呢?

markdown-it 插件

查阅 markdown-it 的Github 仓库代码文档,我们可以大致了解到 markdown-it的工作原理,其转换过程类似于 Babel,先转换成抽象语法树,然后生成对应的代码,简单的概括就是分为 Parse 和 Render 两个过程。
这点我们查看源码也可以看到:

  1. MarkdownIt.prototype.render = function (src, env) {
  2. env = env || {};
  3. return this.renderer.render(this.parse(src, env), this.options, env);
  4. };

所以这里我们解决问题的思路有两个,一个是在 Parse 过程中处理,一个在 Render 过程中处理,为了简单起见,我决定直接处理 Render 过程,查看 Render 的源码,我们可以看到 Render 里其实已经根据一些固定的类型写了默认 Rules(渲染规则),就比如关于代码块:

  1. default_rules.fence = function (tokens, idx, options, env, slf) {
  2. var token = tokens[idx],
  3. info = token.info ? unescapeAll(token.info).trim() : '',
  4. langName = '',
  5. langAttrs = '',
  6. highlighted, i, arr, tmpAttrs, tmpToken;
  7. if (info) {
  8. // ...
  9. }
  10. if (options.highlight) {
  11. highlighted = options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content);
  12. } else {
  13. highlighted = escapeHtml(token.content);
  14. }
  15. if (highlighted.indexOf('<pre') === 0) {
  16. return highlighted + '\n';
  17. }
  18. if (info) {
  19. //...
  20. }
  21. return '<pre><code' + slf.renderAttrs(token) + '>'
  22. + highlighted
  23. + '</code></pre>\n';
  24. };

我们可以覆盖这个规则,参照 markdown-it 提供的插件编写原则,我们可以这样写:

  1. # 获取 md 实例后
  2. md.renderer.rules.fence = function (tokens, idx, options, env, self) {
  3. // ...
  4. };

为了再省事一点,我准备直接获取最后渲染的 HTML 结果,它是一个字符串,然后匹配 //try-link: xxx生成的 HTML,替换成一个 链接,我们查看下 //try-link: xxx这句注释生成的 HTML:
VuePress 博客优化之拓展 Markdown 语法 - 图2
修改下 config.js文件:

  1. module.exports = {
  2. markdown: {
  3. extendMarkdown: md => {
  4. md.use(function(md) {
  5. const fence = md.renderer.rules.fence
  6. md.renderer.rules.fence = (...args) => {
  7. let rawCode = fence(...args);
  8. rawCode = rawCode.replace(/<span class="token comment">\/\/ try-link https:\/\/(.*)<\/span>\n/ig, '<a href="$1" class="try-button" target="_blank">Try</a>');
  9. return `${rawCode}`
  10. }
  11. })
  12. }
  13. }
  14. }

这里为了简洁,我没有将 链接的样式直接内联写入其中,而是加了一个类,那在哪里写这个类的样式呢?
VuePress 提供了 docs/.vuepress/styles/index.styl文件,作为将会被自动应用的全局样式文件,会生成在最终的 CSS 文件结尾,具有比默认样式更高的优先级。
所以我们在 index.styl文件下写入样式:

  1. // 默认样式
  2. .try-button {
  3. position: absolute;
  4. bottom: 1em;
  5. right: 1em;
  6. font-weight: 100;
  7. border: 1px solid #719af4;
  8. border-radius: 4px;
  9. color: #719af4;
  10. padding: 2px 8px;
  11. text-decoration: none;
  12. transition-timing-function: ease;
  13. transition: opacity .3s;
  14. opacity: 0;
  15. }
  16. // hover 样式
  17. .content__default:not(.custom) a.try-button:hover {
  18. background-color: #719af4;
  19. color: #fff;
  20. text-decoration: none;
  21. }

有的时候,自动编译可能不会生效,我们可以重新运行 yarn run docs:dev。
此时已经可以正常显示按钮了(默认样式透明度为 0,这里为了截图强行设置透明度为 1):
VuePress 博客优化之拓展 Markdown 语法 - 图3
接下来我们要实现,鼠标悬浮在代码块的时候,才显示这个按钮,这里我们可以借助
《从零实现一个 VuePress 插件》中的方法,在页面 mounted 的时候,获取所有的代码块元素,然后添加事件,我们再修改下 config.js文件:

  1. module.exports = {
  2. plugins: [
  3. (options, ctx) => {
  4. return {
  5. name: 'vuepress-plugin-code-try',
  6. clientRootMixin: path.resolve(__dirname, 'vuepress-plugin-code-try/index.js')
  7. }
  8. }
  9. ],
  10. markdown: {
  11. extendMarkdown: md => {
  12. md.use(function(md) {
  13. const fence = md.renderer.rules.fence
  14. md.renderer.rules.fence = (...args) => {
  15. let rawCode = fence(...args);
  16. rawCode = rawCode.replace(/<span class="token comment">\/\/ try-link https:\/\/(.*)<\/span>\n/ig, '<a href="$1" class="try-button" target="_blank">Try</a>');
  17. return `${rawCode}`
  18. }
  19. })
  20. }
  21. }
  22. }

然后在同级目录config.js下新建一个 vuepress-plugin-code-try目录,然后新建一个 index.js文件:

  1. export default {
  2. mounted () {
  3. setTimeout(() => {
  4. document.querySelectorAll('div[class*="language-"] pre').forEach(el => {
  5. if (el.querySelector('.try-button')) {
  6. el.addEventListener('mouseover', () => {
  7. el.querySelector('.try-button').style.opacity = '1';
  8. })
  9. el.addEventListener('mouseout', () => {
  10. el.querySelector('.try-button').style.opacity = '0';
  11. })
  12. }
  13. })
  14. }, 100)
  15. }
  16. }

此时,再运行项目,我们就实现了最初想要的效果:
VuePress 博客优化之拓展 Markdown 语法 - 图4