gitbook源码解析-词汇解析与匹配

4,193 阅读7分钟

gitbook已经很长时间没有更新了,开发gitbook的人更少了,现在才去了解源码似乎意义不大了,但因为没有更好的替代,也因为自身的缺陷,还是希望能在源码层面解决一下这些问题。仅就解决问题而言,也许不需要特别深入代码,但还是希望能在整个应用的结构与模块关系形成一个整体认识,所以需要把握一点深入的程度。而且很多非开发的人员用gitbook管理自己的文档,有必要了解一些实现原理。首先要找到一个问题的根因:为什么带)的GLOSSARY词汇不能被匹配。

应用起始

了解应用结构当然最好从应用初始化开始,以gitbook serve为例,因为这个命令不仅需要解析各md文件,还需要支行一个本地服务器以察看书写效果。用$ROOT来表示当前gitbook的安装目录,它应该是$NODE/lib/node_modules/gitbook-cli$NODE

很容易找到在$ROOT/bin/gitbook.js中,program对象运行的是.command('*')分支,如此看来,serve,build等命令是统一处理的:

program
    .command('*')
    .description('run a command with a specific gitbook version')
    .action(function(commandName){
        var args = parsedArgv._.slice(1);
        var kwargs = _.omit(parsedArgv, '$0', '_');

        runPromise(
            manager.ensureAndLoad(bookRoot, program.gitbook)
            .then(function(gitbook) {
                return commands.exec(gitbook.commands, commandName, args, kwargs);
            })
        );
    });

比较容易看出manager.ensureAndLoad方法通过指定目录生成一个叫gitbook的对象,commands模块运行了gitbook.commands,在commands模块($ROOT/lib/commands.js)中:

function exec(commands, command, args, kwargs) {
    var cmd = _.find(commands, function(_cmd) {
        return _.first(_cmd.name.split(" ")) == command;
    });
    ...
    return cmd.exec(args, kwargs);
}

通过在commands中找到指定的command来执行相应的命令,这样的写法是为了让命令的参数有默认值。

manager.ensureAndLoad方法运行了local模块的load方法,这个方法实际运行loadVersion方法($ROOT/lib/local.js):

function loadVersion(version) {
    gitbook = require(resolved.path);
}

这里从指定路径加载了gitbook对象,resolved.path的值是~/.gitbook/versions/3.2.3,原来真正的源码和模块是放在这个目录下的,怪不得$ROOT目录下代码这么少,这个路径姑且定为$LIB

词汇文件解析

因为要看的是GLOSSARY文件是如何被解析的,所以我们先略过一些细节,直接看$LIB/parse/parseGlossary.js,看看能有什么发现。看源码的时候不可能了解一切细节,应当有针对性的选取可能的对象,加快我们理解的进程。好的源码自然是名称和内容相符的,最头痛的就是名实不符,不管是文件名,方法名还是变量名,一旦名称与实际作用不一致就会给人带来很多理解上的困扰,开发的人应当都了解。

parseGlossary.js文件本身很简单,只有一个方法,通过parseStructureFile把原始文件读取成结构化对象,并且打散成一个叫做entries的列表,这自然就是原始文件中一个个的词汇名称了,最后通过Glossary.createFromEntries方法构建glossary对象,并设置给了book对象。

function parseGlossary(book) {
    var logger = book.getLogger();

    return parseStructureFile(book, 'glossary')
    .spread(function(file, entries) {
        if (!file) {
            return book;
        }

        console.log('glossary index file found at', file.getPath(), entries);

        var glossary = Glossary.createFromEntries(file, entries);
        return book.set('glossary', glossary);
    });
}

看来只是将词汇解析成数据,并被book对象持有,需要了解glossary在哪里用到。但一搜索glossary发现引用的地方非常多,而且不同的对象似乎都有glossary字段,它们是不是同一个对象也不能确定。现在就有2个问题:GLOSSARY文件需要解析成网页,这里有一个解析过程;从GLOSSARY文件获取的词汇列表需要用来解析正文内容,这里也有一个解析过程。显然正文中被匹配的单词可以跳转到GLOSSARY文件指定位置,也就是说这里存在一个关联。

查看解析好了的正文网页源码,可以发现匹配的词汇表实际就是包裹了一个<a>标签,并且class="glossary-term",于是很轻松得在$LIB/lib/output/modifiers/annotateText.js中找到了glossary-term的引用。

function annotateText(entries, glossaryFilePath, $) {
    entries.forEach(function(entry) {
        var entryId     = entry.getID();
        var name        = entry.getName();
        var description = entry.getDescription();

        var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' );

        $('*').each(function() {
            var $this = $(this);

            if (
                $this.is(ANNOTATION_IGNORE) ||
                $this.parents(ANNOTATION_IGNORE).length > 0
            ) return;

            replaceText($, this, searchRegex, function(match) {
                return '<a href="/' + glossaryFilePath + '#' + entryId + '" '
                    + 'class="glossary-term" title="' + escape(description) + '">'
                    + match
                    + '</a>';
            });
        });

    });
}

再清楚不过了,对于每一个词汇entry,它本身的名称为name,我们需要知道带)的词汇这里返回的内容是什么,会不会把某些特殊符号过滤掉;它的展示内容description,这里应当是纯文本;它用来跳转到词汇表页面的一个key,我们也需要明确这个key有没有作特殊的处理。

词汇匹配失败问题

显然这里最关键的就是searchRegex这个正则表达式,如果不能确定,可以轻松的验证一下,新建一个gitbook工程:

README.md
SUMMARY.md
GLOSSARY.md

README.md:

# Introduction

That is good!

不幸的亨利(七世)(Heinrich (VII))的儿子, 康拉德四世(Konrad IV)

SUMMARY.md:

# Summary

* [Introduction](README.md)

GLOSSARY.md:

## Good

## Heinrich (VII)

不幸的[亨利](https://juejin.cn/user/1028798615655886)

## Konrad IV

罗马人民的国王[康拉德四世](https://zh.wikipedia.org/wiki/%E5%BA%B7%E6%8B%89%E5%BE%B7%E5%9B%9B%E4%B8%96_%28%E5%BE%B7%E6%84%8F%E5%BF%97%E5%9B%BD%E7%8E%8B%29),也是耶路撒冷国王。

运行一下可以确定针对词汇Heinrich (VII)replaceText的回调实际没有执行。这里又有1个问题:name中包含的)已经被去掉了;这无论如何当然是匹配不了的,不过先解决正则的问题。有很多在线的正则表达式,很容易验证即使名称中包含的),也是无法匹配的:

image.png

这里的正则用到了边界符\b,但是看含义似乎应该是可以匹配上的(VII)后跟的是另一个),而)显然是一个单词边界,按照描述\b匹配一个单词边界,即字与空格间的位置。所以中文的一些表述很成问题,再看英文Matches at the position between a word character (anything matched by \w) and a non-word character (anything matched by [^\w] or \W) as well as at the start and/or end of the string if the first and/or last characters in the string are word characters.

tmp.png

它匹配的是一个word character和一个non-word character的边界,而我们现在期望的是两个)中间的位置,是两个non-word character,这时候要如何匹配呢?这种情况正是边界符\B应用的场景,也就是说适合\w\w\W\W两种情况,两个))当然属于\W\W,验证一下,果然如此:

image.png

需要深刻理解边界符\b\B的含义和用法,刚开始被搞懵了,这种写法也适用于非英文字母结尾的单词如Brancaleone von Andalò

然而不可以这么写,因为这样会影响不带)的词汇,比如示例中的Konrad IV,这样也让问题变得有点棘手,因为用\b\B的规则是冲突的,一般这种情况下开发者容易进入一个误区,企图用一个正则表达式去覆盖所有情况,但这样是很难的;我们可以分情况用不同的正则,问题就迎刃而解:

let q = pregQuote(name.toLowerCase())

var searchRegex = /\w/.test(q.slice(-1)) ? new RegExp( '\\b(' + q + ')\\b' , 'gi' ) : new RegExp( '\\b(' + q + ')\\B' , 'gi' );

判断当前词汇的最后一个字符是否为一个word character,是就用通常的式子,否就用\B结尾的式子,这样无论混合字符还是单一字符都可以正确处理。或者干脆就不用\B,一旦以非英文字符结尾,直接用new RegExp( '\\b(' + q + ')' , 'gi' ),后一种写法对中文非常方便,对其它拉丁字母字符不是那么准确(例如Andalò会匹配到Andalòa)。更严格的,则需要再把词汇的开头也考虑进去,如果这样实现了那中文作为词汇也可以进行匹配了! 如果有人有更好的实现方式欢迎交流。

还有一个需要注意的点,是关于开发语言的。如果包含'(',')'的js字符串作为js正则表达式的话,()会被直接作为正则中的含义,也就是说()没有被当成字符数据而是正则表达式的匹配括号!所以上文中的pregQuote方法就是在这些字符前加一个\\

词汇跳转失败问题

匹配完成后生成的链接不能成功跳转,明显我们是用entryId作为url的fragment,因此需要看一下它的实现,entry的数据是$LIB/lib/models/glossaryEntry.js中的GlossaryEntry

GlossaryEntry.prototype.getID = function() {
    return GlossaryEntry.nameToID(this.getName());
};

GlossaryEntry.nameToID = function nameToID(name) {
    return slug(name);
};

slug来自github-slugid($LIB/node_modules/github-slugid/index.js):

var slug = require('github-slugid');

原来是用了一个库,虽然简单,但一般这样的库都是无数人在使用的,经过千锤百炼,可信度相当高,如果不是特别了解尽量不要改动:

var SYMBOLS = [
    '[', ']', '!', '"', '\'', '#',
    '$', '%', '&', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=',
    '>', '?', '@', '', '', '^', '_', '`', '{', '|', '}', '~',
    '©', '∑', '®', '†', '“', '”', '‘', '’', '∂', 'ƒ', '™', '℠', '…',
    'œ', 'Œ','˚', 'º', 'ª', '•', '∆', '∞', '♥', '&', '|'
];


function slug(content, separator) {
    separator = separator || '-';

    var re = new RegExp('[\\'+SYMBOLS.join('\\')+']+', 'g');
    var s = content
        .replace(re, '')
        .replace(/ /g, separator)
        .toLowerCase();

    if (s[0] == separator) s = s.slice(1);

    return s;
}

也就是说原词汇中的()被移除了,我们现在这个情况是不应该当移除的,因为在我们的词汇表中Heinrich (VII)Heinrich VII是不同的,如果移除就无法区分了,所以()必须被保留,但是这里出现一个问题,一些特殊字符在fragment中是不允许的,我们可以用encode的方式解决:

function slug(content, separator) {
    separator = separator || '-';

    var s = content
        .replace(/\(/g, '%28').replace(/\)/g, '%29')
        .replace(/ /g, separator)
        .toLowerCase();

    if (s[0] == separator) s = s.slice(1);

    return s;
}

将方法放入新建文件中$LIB/lib/utils/slugid.js,再更改引入var slug = require('../utils/slugid');

可以在页面看到生成的id为heinrich-%28vii%29

然而实际效果还是无法跳转,找了半天原因,发现是因为点击glossary-term链接的时候运行了theme.js,目前还不知道脚本做了什么操作导致跳转失败,不过在浏览器内直接输入$url/GLOSSARY.html#heinrich-%28vii%29是可以成功展示指定的条目的。theme.js是gitbook自带的主题插件gitbook-plugin-theme-default生成的文件,要彻底解决还得改这个插件,那得是另外一个故事了。