Customize MkDocs-Material with Javascript

Date: Feb 25, 2019
Last Updated: Feb 25, 2019
Categories:
Playground Technique Web
Tags:
mkdocs blog markdown

Contents


背景

这是一篇中文教程,其目的主要是为最先进的MkDocs主题之一,Material提供一个更丰富的中文支持。部分操作需要修改模板内建的文件。截至本文写就的时候,笔者正在使用Python 3.6,MkDocs 1.0.4,以及Material 4.0.1。本文基于的模板Material,是MkDocs模板里集成了最多实用插件的模板之一,涵盖了绝大部分来自MarkDown extensions,和PyMdown Extensions的插件。已经包含了诸多妙用。该模板美观、现代,清晰易读,并且同时支持桌面和手机版,即使不用来写文档,也是一个非常优秀的博客站,欲了解更多关于Material的信息,参考以下链接:

Material

然而,该模板本身对中文的支持十分有限。鉴于此,本教程参考了以下的一些资料:

mkdocs如何支持中文搜索

为 lunr.js 添加中文支持

事实上,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.cssextensions.cssextensions.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站点,显然应当避开这样的插件。

Lightbox plugins

引入simpleLightbox库

我们使用一个轻量级的、功能有限的但是基于完全可以自由使用的MIT协议的插件, Simple lightbox 。注意网上有同名、同样功能的的两个不同作者编写的插件,你可以在以下链接检查我们使用的插件的Demo,也可以在这里下载它。

Simple lightbox

下载完成后,按照以下的目录组织形式放置插件脚本:

.
|---.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>标签,进行以下操作:

  1. 用选择器选择所有class字段有.img-fluid的标签,只有具有该class的图片才可以放大;

  2. 对每个可放大<img>,提取其中tag字段的内容,将其传递给外部包裹标签<a>class

  3. 对每个可放大<img>,提取其中title字段的内容,将其传递给外部包裹标签<a>title

  4. 对所有可放大<img>,给其外部包裹标签提供一个用以定制外观的字段class="boxedThumb"

基于以上思路,在extension.cssextension.js中分别添加以下内容:

  1. 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;
    }
    
  2. 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的可用字体列表,我们确定了中文字体包括在以下链接:

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地字重。实测可以清楚地区分出不同字重的粗细。

提供中文搜索支持

接下来需要进行最复杂的步骤,强化搜索功能。截至本文写就的时候,网络上可以找到的办法已经基本过时。具体体现在

网上教程使用的库/程序我们需要使用的库/程序
python2python3
JiebaJieba3k
内置主题Material 4.0.1
lunr 0.7.1lunr 2.3.5

其中,最大的问题莫过于集成在Material 4.0.1里的lunr 2.3.5。lunr是一个轻量级的页面内搜索工具。它的工作原理大致可以分为两步。第一步,把所有有效页面的信息提取成纯文字版,有需要加载的时候将其分成一个个分词;第二步,将输入搜索框的内容根据分隔符(包括空格、标点符号等)分成一个个分词,并和第一步的分词库比对,寻址到对应的页面信息。因此,实现中文化搜索有两大困难:

  1. 中文分词的机制和英文不同,无法使用分隔符分词;

  2. 中文分词的算法复杂,将所有页面信息临时构建成分词库的效率太低。

当前英文版的lunr,在算法上其实可以实现极其有限的中文分词效果。例如如下句子

天下风云出我辈,一入江湖岁月催。皇图霸业谈笑中,不胜人生一场醉。

英文版的lunr由于现在已经可以支持日文,对上述句子,可以得到四个分词:天下一入皇图不胜。这种机制是由于,现在lunr分词是由分隔符导向的,同时对词长有一定限制,类似这种汉字过多的成句,只能保留每段分割的前两个字。

因此,已经存在的、用来修改lunr的方法大概需要完成两个工作:

  1. 修改静态分词库的生成机制,务求在生成阶段已经完成中文分词,不需要临时从库里调用js代码分词。这一步由Jieba完成;

  2. 修改搜索框内容的分词机制,使用在线的中文分词库来代替原有的分词方法。

其中,第二点经过实际测试,发现目前还无法在静态页面上实现。虽然上述的工作过去已经有人完成过,不同于他们的工作,我们需要解决的问题包括:

  1. 随着lunr的更新,很多API已经面目全非,我们需要根据最新版重新确定需要修改的代码;

  2. jieba目前只有一个Beta版支持python3,需要通过手动方式安装;

  3. Material内置的lunr库已经被压缩,变为可读性很差的代码,鉴于我们不愿意自己重新make该主题,实际工作得在压缩后的代码上进行。

因此,在下面介绍的方法中,和参考资料有不同的,以我们的解决方案为准。作为重要参考,我们使用的lunr 2.3.5版本可以在此查询到

lunr.js 2.3.5

利用Jieba3k构建静态分词库

首先,clone或者下载Jieba3k到任何目录下,github的地址在

Jieba3K

下载解压后,进入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模块。

中文分词模块 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,对于这一工作的推进只能表示并不乐观,非常遗憾。