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中包含的)已经被去掉了;这无论如何当然是匹配不了的,不过先解决正则的问题。有很多在线的正则表达式,很容易验证即使名称中包含的),也是无法匹配的:
这里的正则用到了边界符\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.
它匹配的是一个word character和一个non-word character的边界,而我们现在期望的是两个)中间的位置,是两个non-word character,这时候要如何匹配呢?这种情况正是边界符\B应用的场景,也就是说适合\w\w和\W\W两种情况,两个))当然属于\W\W,验证一下,果然如此:
需要深刻理解边界符\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生成的文件,要彻底解决还得改这个插件,那得是另外一个故事了。