追梦人物❤️包子 博主
一直走在追梦的路上。

拓展Python Markdown

2020-07-256015 阅读2 评论

通过拓展 Python Markdown 来获得类似 django 官方文档的阅读体验。

最近阅读 django 的官方文档,发现一些很细节的文档内容展现形式,能够极大地提高文档的阅读体验。阅读其他技术文档时也会经常发现类似的内容展现形式。我的博客主要也是发布一些技术类文章,于是决定实现类似的功能以增强读者阅读博客文章的体验。

确定需求后,简单地研究了一下实现方式,然后花了一个晚上的时间把功能上线了,在这里分享记录一下整个功能的实现过程。

确定需求

阅读技术类文档经常会看到这么几种内容:Code blockAdmonitionCommand tab。中文不太好翻译,来看一下实际的效果就知道了,下面是 django 中这几种内容的展现形式。

Code block

django documentation code block

代码块的上方有一个 header,左边显示代码块所在文件路径,这样示例代码应该放在哪个文件就一目了然;右边是一个按钮,点击即可复制整个代码块中的内容。

Admonition

django documentation admonition

admonition 用来展现一些提示、警告等内容,文档中经常见到的有危险(danger)、警告(warning)、注意(attention)、重要(important)、提示(hint)等内容,不同类型的内容通常会以不同的背景和字体颜色区分。

Command tab

django documentation command tab

技术类文档中少不了系统命令,很多相同效果的命令在不同操作系统中的字符内容是有一定差异的。写的不太好的文档通常只给出 Linux 下的执行命令;好点的文档则将执行命令分别列出;而 django 文档的处理就非常细节,以 tab 切换的形式给出不同系统下的命令执行方式,这样既能够列出不同系统下的执行命令,又不会重复占用文档的内容空间,提高了文档的紧凑感和阅读时的流畅性。

我的需求就是要在自己博客文章中实现以上三种内容展现效果。

方案研究

博客文章的标记语言采用的是 Markdown,具体的实现采用的是 Python-Markdown/markdown 这个开源库。这个库不仅实现了 Markdown 标准语法的解析,还提供了很多丰富的拓展语法。

例如需求中提到的 admonition 功能,通过添加 markdown.extensions.admonition 拓展就可以直接实现(具体的实现原理和使用方式下面会介绍)。

Code block 的功能也有相应的拓展来实现的,但是调研发现官方自带拓展的功能弱了一点,无法通过拓展的语法在代码块的上方添加 header,只能部分满足需求。开源的第三方拓展中也没有找到可满足需求的实现,所以这里可能需要自己拓展实现。

Command tab 功能的实现在 markdown 的第三方拓展库 facelessuser/pymdown-extensions 中找到了一个 tabbed 拓展,提供的标记语法可被解析生成一个 tab 选项卡,完美满足需求。

至此,实现方案基本就可以确定了:

  1. admonition 功能,直接使用 markdown 库的官方 admonition 拓展就可以;
  2. Code blockpymdown-extensions 中有一个更好的拓展实现,叫做 SuperFences,但是还是无法满足生成代码块 header 的需求,因此我们考虑对 SuperFences 再做进一步拓展;

  3. Command tab 使用 pymdown-extensions 的 tabbed 拓展可完美满足需求。

具体实现

Admonition

admonition 的实现最为简单,只需引入官方 markdown.extensions.admonition 拓展就可以了。它的实现原理是通过下面的语法标记 admonition 的内容:

!!! note "注意"
    请注意这段内容!

markdown 会把标记内容解析为下面的 HTML 文本:

<div class="admonition note">
<p class="admonition-title">注意</p>
<p>请注意这段内容!</p>
</div>

编写适当的 CSS 样式,就可以达到类似 django 文档中那样的展示效果了。

参考资料

markdown.extensions.admonition 拓展的使用可参考官方文档 Admonition
拓展的引入方式可参考博客项目的源码 blogproject/core/utils.py#L57
admonition 的 CSS 样式可参考博客中的源码 frontend/src/style/_admonition.scss

Code Block

code block 的实现使用 pymdown-extensions 中 SuperFences 拓展,不过遗憾的是,SuperFences 没有在代码块头部添加 header 内容的功能,这样就无法展示代码块所在的文件路径等信息了。花了不少时间读了一下 SuperFences 的源码,遗憾地发现 SuperFences 并没有暴露什么便捷的接口用于对已解析后的内容做进一步加工,如果通过继承等方式进行拓展的话可能需要覆盖重写大量方法,最后决定用一种 monkey patch 的方式进行拓展,以便使需要改动的代码量最小。

首先来看看 SuperFences 提供的代码块标记语法:

```python linenums="1"
def print_hello_world():
    print("hello world")
```

注意到高亮的第一行代码,python 指定代码块中代码属于何种编程语言,其后紧跟的 key=value 形式的键值对是拓展选项(linenums 是代码行号拓展,指定后解析的代码块中的代码将包含代码行号)。

解析后的 HTML 文档大致如下:

<pre class="highlight"><code>...</code></pre>

可惜 SuperFences 原生只提供 linenums、hl_lines 两个拓展选项,我们希望能够添加一个拓展选项 filename,用于指定代码块所属文件路径,并将其值添加到解析后的代码块头部。标记语法如下:

```python linenums="1" filename="pyproject/hello_world.py"
def print_hello_world():
    print("hello world")
```

预期的解析效果:

<div class="literal-block">
  <div class="code-block-caption">pyproject/hello_world.py</div>
  <pre class="highlight"><code>...</code></pre>
</div>

不过想基于 SuperFences 实现以上拓展并不容易,难点主要在以下两处:

  1. SuperFences 在解析内容时会校验拓展选项,默认的校验器(validator)只接受 linenums、hl_lines 两个拓展选项,任何多余的选项都无法通过校验,所以我们添加的 filename 拓展选项就无法通过校验,而 SuperFences 并未暴露任何接口可以替换掉默认的校验器。
  2. SuperFences 最终会调用 SuperFencesBlockPreprocessor.highlight 实例方法对代码块做代码高亮处理,然后返回 <pre>...</pre> 预排版内容,这是我们期望的。理想的拓展方法是对 highlight 方法返回的内容再进行包装,即在外层再包上 filename 选项的内容,但是 SuperFences 并未暴露任何接口可以替换 SuperFencesBlockPreprocessor 类,这样就无法通过继承覆盖重写 highlight 方法的方式增强 SuperFencesBlockPreprocessor

好在 Python 语言足够灵活,我们可以通过 monkey patch 的方式以最小代码 kill 掉上述两个难点。

对于难点 1,SuperFences 使用的默认校验器 highlight_validator 是定义在 pymdownx.superfences 模块中的顶层函数,因此这里采用的方式就是在 SuperFences 调用这个函数之前,将 highlight_validator 替换为我们自定义的函数,这在 Python 中实现非常简单:

import pymdownx.superfences

pymdownx.superfences.highlight_validator = _highlight_validator

_highlight_validator 是我们自定义的函数,放宽了原校验函数的校验逻辑,具体的实现代码可参考本博客的源码 blogproject/core/utils.py#L18

对于难点 2,想要对一个类方法返回的结果进一步包装,自然想到类方法装饰器。首先实现一个装饰器,对 highlight 方法返回的结果进行进一步的处理,然后再用 monkey patch 的方式将 SuperFencesBlockPreprocessor.highlight 方法替换为装饰后的方法。具体的实现代码请参考博客的源码 blogproject/core/utils.py#L26

最后编写适当的 CSS 样式,就可以达到类似 django 文档中代码块那样的展示效果了。相关的样式代码可参考博客的源码 frontend/src/style/_literal.scss

参考资料

SuperFences 拓展还提供了很多丰富的功能,具体使用方式可参考其官方文档 SuperFences

Command Tab

Command tab 借助 pymdown-extensions 的 tabbed 拓展实现,标记语法如下:

=== "Linux/macOS"
    ```bash
    $ pipenv install django
    ```

=== "Windows"
    ```shell
    ...\> pipenv install django
    ```

这段内容将被解析为一段具有 tab 选项卡结构的 HTML 代码段,编写相应的 CSS 样式就可以实现类似 django 文档中那样的命令切换选项卡效果,相关的样式代码可参考博客的源码 frontend/src/style/_tabbed.scss

效果演示

来看看最终的实现效果。

Admonition

危险

千万不要进行这样的操作:sudo rm -rf /*。

错误

如果这样做,你将造成不可修复的错误。

警告

如果执行了 sudo rm -rf /* 导致系统无法恢复,后果自负。

当心

千万当心在搜索历史命令时不经意间导致 sudo rm -rf /* 命令的执行。

注意

千万注意你的猫在键盘上乱踩时敲出 sudo rm -rf /* 命令。

重要

最好不要在系统中留下 sudo rm -rf /* 的历史记录。

备注

以上内容请切记。

提示

注意 sudo rm -rf /* 后也是可能被恢复的,所以如果你是删库跑路,一定要采取其他措施掩盖你的行径。

小贴士

物理删除不如心理删除。

Code Block

core/utils.py
def caption_fence_code_format(source, language, css_class, options, md):
    code = fence_code_format(source, language, css_class, options, md)
    caption = options.get("filename", "")
    if caption == "":
        return code
    return '<div><div class="code-caption">{}</div>{}</div>'.format(caption, code)

Command Tab

$ export ENV_VAR=test
...\> set ENV_VAR=test

致谢

感谢 Python-Markdown/markdownpymdown-extensions 开发者们的辛勤付出。

感谢 djangoproject.com 提供的参考实现。

感谢老婆大人在前端方面给予的指点。

-- EOF --

2 评论
登录后回复
evahere
2020-08-13 18:04:26

为什么博主文章里的代码 都没有高亮啊 博主不弄吗

回复
pi-dal
2020-07-25 15:56:34

本来还在考虑hexo里的一些文章怎么迁徙,现在倒好,直接发答案了😅😅😅

回复