做一个 MkDocs 博客插件
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(测试工程师进酒吧)。当然,造轮子虽然难,但是其中的快乐是无穷的。