Customize MkDocs-Material with Javascript
Date: | Feb 25, 2019 |
---|---|
Last Updated: | Feb 25, 2019 |
Categories: | |
Tags: |
Contents
背景
这是一篇中文教程,其目的主要是为最先进的MkDocs主题之一,Material提供一个更丰富的中文支持。部分操作需要修改模板内建的文件。截至本文写就的时候,笔者正在使用Python 3.6,MkDocs 1.0.4,以及Material 4.0.1。本文基于的模板Material,是MkDocs模板里集成了最多实用插件的模板之一,涵盖了绝大部分来自MarkDown extensions,和PyMdown Extensions的插件。已经包含了诸多妙用。该模板美观、现代,清晰易读,并且同时支持桌面和手机版,即使不用来写文档,也是一个非常优秀的博客站,欲了解更多关于Material的信息,参考以下链接:
然而,该模板本身对中文的支持十分有限。鉴于此,本教程参考了以下的一些资料:
事实上,Material已经支持了MathJax、代码高亮、Tip框、保留代码增删的注释等功能。不过在本教程里,基本不会讨论一些站点参数的设置、以及无关本文的插件的用法,读者最好应当有过使用Hugo,Hexo或者PyMarkDown等类似工具的经验。
安装
首先,对于没有安装MkDocs的用户而言,需要
pip install mkdocs
如果你的Python在你的环境变量里,或者你在使用Anaconda,那么就可以直接调用mkdocs --version
来检查安装情况了。接下来需要安装一些插件
pip install Pygments pymdown-extensions
由于我们需要修改模板本身,为了灵活方便起见,这里不建议使用安装的方式(例如pip)将Material安装到库里,反之,我们则应当直接clone项目。对于安装了Github Desktop的Windows用户,可以通过
github clone https://github.com/squidfunk/mkdocs-material.git
将该项目的最新版(目前是4.0.1)Fork到你的常用项目目录里(喜欢使用git
的也无妨)。接下来,在根目录下,删除所有无用的文件,最后只需要保留到
.
|---.github
|---docs
|---material
|---CODE_OF_CONDUCT.md
|---CONTRIBUTING.md
|---LICENSE
|---mkdocs.yml
`---README.md
事实上,根目录下除了mkdocs.yml
属于模板的基本配置文件以外,其他的都是和模板本身无关的文件。例如./src
目录保存的基本都是还没有压缩处理过的模板源文件。这些源文件亲测不能用来代替压缩后的模板./material
。在Linux下,用户可以通过Makefile完成从./src
编译到./material
的过程,但是Windows下这一操作并不方便,所以在下文中,尽管有些修改原理是基于源文件介绍的,但实际情况下我们需要直接修改被压缩后的,可读性大大变差的模板文件,不得不说是一个费时不讨好的工作。
改进
预备
新建3个空文件main.css
,extensions.css
和extensions.js
,方便后续的插件配置。
.
|---.github
|---docs
| |---stylesheets
| | |----main.css
| | |----extensions.css
| | `---...
| `---javascripts
| |----extensions.js
| `---...
|---material
`---...
提供图片放大支持
图片放大又叫Lightbox,这一类插件的功能主要是提供一个浮窗,以展示页面上缩略图的大图版。另外一类插件叫zoom-in,主要是实现鼠标悬停时出现放大镜这样的功能呢。然而,无论是在MarkDown extensions,还是在PyMdown Extensions中,都不提供Lightbox和zoom-in。因此,这一功能只能通过外挂css和js文件来完成。
下面提供了一个链接,是各种Lightbox插件的demo和下载入口。用户可以根据喜好选择心仪的插件。但是需要 特别注意 的是,有些插件,例如Fancybox3,尽管有着极为优越的性能和效果,却同时存在开源协议GPLv3和商用协议两种。换言之,如果你需要建立一个商用的MkDocs站点,显然应当避开这样的插件。
引入simpleLightbox库
我们使用一个轻量级的、功能有限的但是基于完全可以自由使用的MIT协议的插件, Simple lightbox 。注意网上有同名、同样功能的的两个不同作者编写的插件,你可以在以下链接检查我们使用的插件的Demo,也可以在这里下载它。
下载完成后,按照以下的目录组织形式放置插件脚本:
.
|---.github
|---docs
| |---stylesheets
| | |----simpleLightbox.min.css
| | `---...
| `---javascripts
| |----simpleLightbox.min.js
| `---...
|---material
`---...
然后,在配置文件./mkdocs.yml
下,进行以下设置:
extra_css:
- 'stylesheets/main.css'
- 'stylesheets/extensions.css'
- 'stylesheets/simpleLightbox.min.css'
extra_javascript:
- 'https://code.jquery.com/jquery-3.2.1.min.js'
- 'javascripts/simpleLightbox.min.js'
- 'javascripts/extensions.js'
尤其 需要注意 的是javascript的放置顺序。例如,jquery应当在最前,因为在接下来的脚本里,extension.js
需要依赖jQuery的支持。至此, Simple lightbox 库安装完毕。
图片放大的自动化处理
经过以上安装步骤后,你已经可以按照官方demo提供的方式插入一个可放大的图片了,例如
HTML:
<div class="imageGallery1"> <a href="demo/images/4big.jpg" title="Caption for gallery item 1"><img src="demo/images/4small.jpg" alt="Gallery image 1" /></a> <a href="demo/images/5big.jpg" title="Caption for gallery item 2"><img src="demo/images/5small.jpg" alt="Gallery image 2" /></a> <a href="demo/images/6big.jpg" title="Caption for gallery item 3"><img src="demo/images/6small.jpg" alt="Gallery image 3" /></a> </div>
Javascript:
$('.imageGallery1 a').simpleLightbox();
提供了插入一个图片组(Gallery)的范例。以上例子显示了如下特点:
大图通过启动函数
simpleLightbox()
实现。对同一组Gallery,同一个启动函数只需要利用Selector调用一次。被相同Selector选中的所有对象被自动归为一个Gallery下。如果一个Gallery只存在一张图,则不提供图片切换的功能。所有的可放大图片,需要有
<a></a>
的包裹。启动函数针对的对象必须是<a>
。通过
title
字段,可以指明放大后的图片下说明文字的内容。
故此,我们可以考虑,在document的渲染阶段,对所有的<img>
标签,进行以下操作:
用选择器选择所有
class
字段有.img-fluid
的标签,只有具有该class
的图片才可以放大;对每个可放大
<img>
,提取其中tag
字段的内容,将其传递给外部包裹标签<a>
的class
;对每个可放大
<img>
,提取其中title
字段的内容,将其传递给外部包裹标签<a>
的title
;对所有可放大
<img>
,给其外部包裹标签提供一个用以定制外观的字段class="boxedThumb"
。
基于以上思路,在extension.css
和extension.js
中分别添加以下内容:
extension.css
:a.boxedThumb { display:block; padding:4px; line-height:20px; border:1px solid #ddd; -webkit-border-radius:4px; -moz-border-radius:4px; border-radius:4px; -webkit-box-shadow:0 1px 3px rgba(0,0,0,0.055); -moz-box-shadow:0 1px 3px rgba(0,0,0,0.055); box-shadow:0 1px 3px rgba(0,0,0,0.055); -webkit-transition: -webkit-transform .15s ease; -moz-transition: -moz-transform .15s ease; -o-transition: -o-transform .15s ease; -ms-transition: -ms-transform .15s ease; transition: transform .15s ease; } a.boxedThumb:hover { -webkit-transform: scale(1.05); -moz-transform: scale(1.05); -o-transform: scale(1.05); -ms-transform: scale(1.05); transform: scale(1.05); z-index: 5; }
extension.js
:$(document).ready(function() { var productImageGroups = []; $('.img-fluid').each(function() { var productImageSource = $(this).attr('src'); var productImageTag = $(this).attr('tag'); var productImageTitle = $(this).attr('title'); if ( productImageTitle != undefined ){ productImageTitle = 'title="' + productImageTitle + '" ' } else { productImageTitle = '' } $(this).wrap('<a class="boxedThumb ' + productImageTag + '" ' + productImageTitle + 'href="' + productImageSource + '"></a>'); productImageGroups.push('.'+productImageTag); }); jQuery.unique( productImageGroups ); productImageGroups.forEach(productImageGroupsSet); function productImageGroupsSet(value) { $(value).simpleLightbox(); } });
接下来,还要确保一个插件的开启,以便我们能够在MarkDown中定制可放大图片。在配置文件./mkdocs.yml
下,进行以下设置:
markdown_extensions:
- markdown.extensions.attr_list
我们引入的这个插件名叫Attribute Lists。它允许我们能在MarkDown链接/图片后用括号指明任意标签的字段。完成以上所有操作后,可以测试以下例子:
设置一个单独的可放大图片
![Material for MkDocs 1](demo/img1.png){.img-fluid tag=1}
设置一个单独的、有说明文字可放大图片
![Material for MkDocs 2](demo/img2.png){.img-fluid tag=2 title="Hahaha"}
设置一个图片组
![Material for MkDocs 3-1](demo/img3-1.png){.img-fluid tag=3 title="Image 3-1"} ![Material for MkDocs 3-2](demo/img3-2.png){.img-fluid tag=3 title="Image 3-2"} ![Material for MkDocs 3-3](demo/img3-3.png){.img-fluid tag=3 title="Image 3-3"}
当然,还有这一插件尚有其他妙用,例如,用来制作弹出视频框,用来制作弹出信息框等。参照demo就可以完成。通过上述自动化相关的设置,我们已经不需要使用任何html插入可放大图片,也不需要在html后加上任何脚本。
提供中文字体支持
Material本身就已经具备了挂在在线版Google fonts的功能。默认的字体Roboto是一个西文字体。通过搜索Google fonts的可用字体列表,我们确定了中文字体包括在以下链接:
按照个人喜好,选择思源宋体。首先,在配置文件./mkdocs.yml
下,进行以下设置:
theme:
language: zh
font:
text: Noto Serif SC
注意思源宋体总共有七个可用字重,但是模板本身只启用了四个。为了引入更多字重,打开./material/base.html
,搜索并按照以下方式修改:
Index: ./material/base.html
===================================================================
--- ./material/base.html (original)
+++ ./material/base.html (revision)
@@ -74,4 +74,4 @@
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family={{
- font.text | replace(' ', '+') + ':300,400,400i,700|' +
+ font.text | replace(' ', '+') + ':300,400,400i,600,700,900|' +
font.code | replace(' ', '+')
}}">
这样一来,我们就可以使用“通常(400)”,“半粗(600)”,“粗体(700)”和“大黑(900)”四个字重。为了显示灵活字重的好处,增加如下内容:
main.css
:
.md-typeset h1 {
font-weight: 700;
font-style: normal
}
.md-typeset h2 {
font-weight: 600;
font-style: normal
}
.md-header-nav__topic {
font-weight: 900;
font-style: normal
}
上述修改改动了导航栏标题、标题1和标题2地字重。实测可以清楚地区分出不同字重的粗细。
提供中文搜索支持
接下来需要进行最复杂的步骤,强化搜索功能。截至本文写就的时候,网络上可以找到的办法已经基本过时。具体体现在
网上教程使用的库/程序 | 我们需要使用的库/程序 |
---|---|
python2 | python3 |
Jieba | Jieba3k |
内置主题 | Material 4.0.1 |
lunr 0.7.1 | lunr 2.3.5 |
其中,最大的问题莫过于集成在Material 4.0.1里的lunr 2.3.5。lunr是一个轻量级的页面内搜索工具。它的工作原理大致可以分为两步。第一步,把所有有效页面的信息提取成纯文字版,有需要加载的时候将其分成一个个分词;第二步,将输入搜索框的内容根据分隔符(包括空格、标点符号等)分成一个个分词,并和第一步的分词库比对,寻址到对应的页面信息。因此,实现中文化搜索有两大困难:
中文分词的机制和英文不同,无法使用分隔符分词;
中文分词的算法复杂,将所有页面信息临时构建成分词库的效率太低。
当前英文版的lunr,在算法上其实可以实现极其有限的中文分词效果。例如如下句子
天下风云出我辈,一入江湖岁月催。皇图霸业谈笑中,不胜人生一场醉。
英文版的lunr由于现在已经可以支持日文,对上述句子,可以得到四个分词:天下
,一入
,皇图
,不胜
。这种机制是由于,现在lunr分词是由分隔符导向的,同时对词长有一定限制,类似这种汉字过多的成句,只能保留每段分割的前两个字。
因此,已经存在的、用来修改lunr的方法大概需要完成两个工作:
修改静态分词库的生成机制,务求在生成阶段已经完成中文分词,不需要临时从库里调用js代码分词。这一步由Jieba完成;
修改搜索框内容的分词机制,使用在线的中文分词库来代替原有的分词方法。
其中,第二点经过实际测试,发现目前还无法在静态页面上实现。虽然上述的工作过去已经有人完成过,不同于他们的工作,我们需要解决的问题包括:
随着lunr的更新,很多API已经面目全非,我们需要根据最新版重新确定需要修改的代码;
jieba目前只有一个Beta版支持python3,需要通过手动方式安装;
Material内置的lunr库已经被压缩,变为可读性很差的代码,鉴于我们不愿意自己重新make该主题,实际工作得在压缩后的代码上进行。
因此,在下面介绍的方法中,和参考资料有不同的,以我们的解决方案为准。作为重要参考,我们使用的lunr 2.3.5版本可以在此查询到
利用Jieba3k构建静态分词库
首先,clone或者下载Jieba3k到任何目录下,github的地址在
下载解压后,进入repository branch的根目录,(默认python是python3的情况下)执行
python setup.py install
则Jieba3K应当已经正常安装。
接下来,进入python的安装目录,修改Lib/site-packages/mkdocs/contrib/search/search_index.py
,搜索以下代码关键字并完成修改
search_index.py
:
def generate_search_index(self):
"""python to json conversion"""
page_dicts = {
'docs': self._entries,
'config': self.config
}
for doc in page_dicts['docs']: # 调用jieba的cut接口生成分词库,过滤重复词,过滤空格
tokens = list(set([token.lower() for token in jieba.cut_for_search(doc['title'].replace('\n', ''), True)]))
if '' in tokens:
tokens.remove('')
doc['title_tokens'] = tokens
tokens = list(set([token.lower() for token in jieba.cut_for_search(doc['text'].replace('\n', ''), True)]))
if '' in tokens:
tokens.remove('')
doc['text_tokens'] = tokens
data = json.dumps(page_dicts, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
if self.config['prebuild_index']:
try:
script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prebuild-index.js')
p = subprocess.Popen(
['node', script_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
idx, err = p.communicate(data.encode('utf-8'))
if not err:
idx = idx.decode('utf-8') if hasattr(idx, 'decode') else idx
page_dicts['index'] = json.loads(idx)
data = json.dumps(page_dicts, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
log.debug('Pre-built search index created successfully.')
else:
log.warning('Failed to pre-build search index. Error: {}'.format(err))
except (OSError, IOError, ValueError) as e:
log.warning('Failed to pre-build search index. Error: {}'.format(e))
return data
最后,在lunr.js的代码中,搜索以下代码关键字并完成修改
lunr.Builder.prototype.add = function (doc, attributes) {
var docRef = doc[this._ref],
fields = Object.keys(this._fields)
this._documents[docRef] = attributes || {}
this.documentCount += 1
for (var i = 0; i < fields.length; i++) {
var fieldName = fields[i],
extractor = this._fields[fieldName].extractor,
field = extractor ? extractor(doc) : doc[fieldName],
tokens = doc[fieldName + '_tokens'],
terms = this.pipeline.run(tokens),
fieldRef = new lunr.FieldRef (docRef, fieldName),
fieldTerms = Object.create(null)
注意我们将tokens修改为我们由Jieba3K提供的形式。另外一处需要修改的地方是
lunr.trimmer = function (token) {
return token.update(function (s) {
return s.replace(/^\s+/, '').replace(/\s+$/, '')
})
}
至此,可以测试发现,对于下述句子
天下风云出我辈,一入江湖岁月催。皇图霸业谈笑中,不胜人生一场醉。
经过测试,Jieba分词能正确分出的包括:天下
,风云
,出
,我辈
,一入
,江湖
,岁月
,催
,皇图
,霸业
,谈笑
,中
,不胜
,人生
,一场
,醉
。效果十分出色。
然而,以上功能实现后,仅仅能解决词语搜索不到的问题,但是,用户在搜索框必须输入分词才能搜索到内容,例如输入
天下 风云 我辈
可以被搜索到。但是输入
天下风云出我辈
则无法搜索到任何内容。这是因为,搜索框内的中文无法被默认脚本分词,从而将“*天下风云出我辈*”这一整体当成了同一个词。
利用node-segment提供成句搜索
该部分目前无法实现在Git Pages挂载的静态页面上!
接下来需要覆盖的是lunr内置的分词机制,使用中文分词工具node-segment代替。注意由于这是一个node.js项目,它无法被加载到静态页面里。目前尚未看到有一个合适的改进。以下代码只能作为一个理论上的参考,实际情况下由于MkDocs挂载的是静态站点的问题,无法引入node-segment模块。
接下来,在lunr.js的代码中,搜索以下代码关键字并完成修改
var Segment = require('node-segment');
var segment = new Segment();
segment.useDefault();
lunr.tokenizer = function (obj, metadata) {
if (obj == null || obj == undefined) {
return []
}
if (Array.isArray(obj)) {
return obj.map(function (t) {
return new lunr.Token(
lunr.utils.asString(t).toLowerCase(),
lunr.utils.clone(metadata)
)
})
}
var str = obj.toString().trim().toLowerCase(),
len = str.length,
tokens = []
var wordList = segment.doSegment(str, {
simple: true
});
var i = wordList.length;
var sliceStart = 0;
while (i–-) {
var sliceLength = wordList[i].length;
if (sliceLength > 0) {
var tokenMetadata = lunr.utils.clone(metadata) || {}
tokenMetadata["position"] = [sliceStart, sliceLength]
tokenMetadata["index"] = tokens.length
tokens.push(
new lunr.Token (
wordList[i],
tokenMetadata
)
)
sliceStart = sliceStart + sliceLength;
}
}
return tokens
}
笔者虽然尝试过,使用browserify将上述代码重新打包,但实际情况是,browserify无法正常打包所有的子模块,因此node.js转纯javascript的方案殊不可行。目前看来唯一的解决方案是,通过将字典txt文件转成json库,来完成node-segment纯javascript化的工作。鉴于笔者并不熟悉node.js,对于这一工作的推进只能表示并不乐观,非常遗憾。