MkDocs 是个好东西,非常适合用来写一些 tech 文章;而 MkDocs 的 Material 主题更是 geek 风格十足。第一次看到 Material for MkDocs,就一见钟情,马上搭了个博客网站。

但是,仔细一看 Material for MkDocs 的介绍,感觉不太对劲:

Technical documentation that just works

Create a branded static site from a set of Markdown files to host the documentation of your Open Source or commercial project…

MkDocs 本来就只是为了搭建项目文档网站设计的,很多功能和设计(如底部导航栏、侧边栏等)更适合文档,而非博客。这导致,MkDocs 并没有将所有文章列在一个地方的功能。没有博客界面,怎么能叫做一个博客呢……

但是,实在舍不得这么好看的界面,就琢磨着自己魔改一番。正好小学期学了 Python,可以用来练练手。

最终成果开源于 mkdocs-blogging-plugin

MkDocs 的插件是怎么工作的

仔细读了读开发者文档,MkDocs 插件的工作是在 MkDocs 处理文档的整个生命周期的不同阶段拦截当前结果,并返回处理后的结果。比如,对生成的 html 进行修改,是在插件类的 on_page_content 函数中实现的:

on_page_content

The page_content event is called after the Markdown text is rendered to HTML (but before being passed to a template) and can be used to alter the HTML body of the page.

Parameters:

  • html: HTML rendered from Markdown source as string
  • page: mkdocs.nav.Page instance
  • config: global configuration object
  • files: global files collection

Returns: HTML rendered from Markdown source as string

我们从函数参数中读取当前处理中的 html 字符串,在对其进行处理后,返回最终的 html 字符串,就完成了插件对页面内容的修改。

实现这些函数,我们就可以读取需要的信息,并更改某些页面内容。

设计与实现

首先,考虑把博客内容加入什么地方。考虑到用户可能需要在博客列表的前后加上其他的信息,直接生成一个页面不太可行。参考 mkdocs-git-revision-date-localized-plugin 的做法,让用户在需要添加博客内容的地方加上一个指定的占位符 {{ blog_content }},然后在处理时用正则表达式替换即可。

然后,考虑博客的排序。我们选择使用 git log 来获取博文的创建和更新时间,然后向用户提供配置的选项(从新到旧 + 根据创建时间 / 从旧到新 + 根据修改时间 等)。这部分代码可以直接抄 mkdocs-git-revision-date-localized-plugin 的实现,工作量–。

插件类与用户配置

根据文档,插件类需要继承 BasePlugin。在 BasePlugin 中,config_scheme 为插件的可配置选项。比如,我们需要用户设置一个名叫 foo 的参数,其类型为 str

class BloggingPlugin(BasePlugin):
    config_scheme = (
        ("foo", config_options.Type(str, default=None)),
    )

这样,用户在 mkdocs.yml 中,可以这样配置 foo(假设我们的插件名为 blogging):

plugins:
  - blogging:
      foo: bar

获取博客文件

我们需要给予用户自行设置哪些文件夹、哪些文件的途径。在 config_scheme 中,加上一项 dirs

config_scheme = (
    ("dirs", config_options.Type(list, default=None)),
)

然后,在 on_page_content 函数中,如果 page 是在用户设置的 dirs 里面,就将其储存下来:

def on_post_page(self, output, page, config):
    for dir in self.docs_dirs:
        if page.file.src_path[:len(dir)] == dir:
            self.blog_pages.append(page)

另外,有些文章在 dirs 中,但可能并不希望被包括进博客中(如一些 index page)。对于这些文件,我们要求用户在 markdown 的 meta 中加入一项 exclude_from_blog

---
exclude_from_blog: true
---

然后,修改一下上面的判断条件:

if page.file.src_path[:len(dir)] == dir \
    and (not "exclude_from_blog" in page.meta \
        or not page.meta["exclude_from_blog"]):

就可以实现页面的筛选。

页面生成

我们需要生成博客的列表界面,最简单的办法就是用 html 模版引擎来为我们动态地生成静态页面,这里使用 MkDocs 使用的 jinja2 来处理。

生成博客列表不是难事,写一个 for 循环就可以了:

{% for page in pages %}
    <div>...</div>
{% endfor %}

比较棘手的是分页的处理。根据我们的设计,我们最终在一个页面中插入博客内容,但是分页一般是多个页面

最终采取了将所有博客信息塞入页面、使用 css 现实当前页面博客信息 / 隐藏其他页面的方法:

首先,在 pagination 中,将地址设置为 #page{{当前页面}}

<a href="#page{{ page_idx + 1 }}">{{ page_idx + 1 }}</a>

在 css 中,id 与 # 后面字符相同的可以用 :target 选择器选中。比如,当前页面是 /blog/#page1,那么 :target 选中的就是 id = "page1" 的元素。我们只需要将 :target 设为显示,其他设为隐藏就可以了。

在 html 中:

<div class="pages">
    {% for page_idx in range(0, page_num) %}
        <div class="page" id="page{{ page_idx + 1 }}">
            <!-- 第 page_index + 1 页的内容 -->
        </div>
    {% endfor %}
</div>

在 css 中:

/* 将所有子元素隐藏 */
.pages > * {
    display: none;
}

/* 显示 :target */
.pages > :target {
    display: block;
}

由于 css 处于后面的声明会覆盖前面的声明,:target 可以显示,而其他页面的元素将被隐藏。这样就实现了分页。

另外,我们还需要 JavaScript 来处理 pagination 的高亮,在这里就不赘述了。

这种解决方法其实有个问题:当我们点击链接跳转到 #pageX 的时候,浏览器会自动跳转到 id = pageX 的元素处,非常影响体验。如今已经抛弃这种方法,改为在 JavaScript 中利用点击事件获取页面、更改 page 的 class 来实现页面的显示和隐藏。

页面插入

在插件的 on_post_page 函数中,将原来文件中的 {{ placeholder }} 替换为用 jinja 生成的 html 字符串:

def on_post_page(self, output, page, config):
    additional_html = self.template.render(pages=self.blog_pages, ...)

    output = re.sub(
        r"\{\{\s*blog_content\s*\}\}",
        additional_html,
        output,
        flags=re.IGNORECASE,
    )

    return output

就基本上完成了所有的工作。

总结

上面所述的仅仅是插件开发中一些枝干的问题,实际上,做一个工程,而不仅是自己用的小软件,要考虑的问题远不止于怎么实现功能,不仅要尽可能满足用户的需求,还要处理各种 corner cases(测试工程师进酒吧)。当然,造轮子虽然难,但是其中的快乐是无穷的。