重构 Markdown 代码块文档结构以支持行号显示

前言

Markdown 是一种以纯文本编写文档的方式,其自身是一系列标记的合集,内容呈现一般是由支持 Markdown 的解析器程序转换成 HTML 或者直接展示渲染结果。MD 在程序员圈子知名度很高,Github 项目默认即采用 Markdown 作为 ReadMe(自述文件)的编写格式。
同时本博客的文章内容也是使用 Markdown 编写的,并且 Markdown 文本会被储存进数据库。在响应用户请求之前翻译为 HTML 文本,最后再呈现在浏览器上。

但是 Markdown 对代码块的支持很薄弱,翻译为 HTML 以后仅仅使用一个 pre 加一个 code 标签包裹原始内容。这造成了很多限制,例如对代码行号的显示、关键字高亮等,这些对于阅读代码而言都是不可或缺的。

原始代码块结构

在 Markdown 中,代码可以用作为外围的上下两条 ```` 包裹起来,最后会翻译为 pre > code > code text 的文档结构。

原始 Markdown 文本(反斜杠是为了防转义,请忽略):

\````
\I'm code!
\````

翻译为 HTML:

\<pre>
\    <code>I'm code!</code>
\</pre>

其中 code 在 W3C 规范中是用于展示计算机程序代码的标签,在 code 中的文本内容将会以等宽字体展示出来。而被 pre 所包裹的文本在展示时则会以原本的样子(完全保留空格和换行等字符内容),并且严格限制为等宽字体。所以实际上 pre 包裹 code 标签再填充代码文本,这个做法是没有违反任何规范的。

但是仅仅单纯以某种限制的字体展示代码原始的格式,仍然是不够的,就像上面提过的,我们至少还需要显示行号。不过遗憾的是无论是 CSS 还是 HTML 标签属性都无法完成对这样的两个标签进一步定制到实现此功能的目的。
于是,我们只能重构这个简单的文档结构内部,来做到对代码块的展示增强。不过,这个需要动的小手脚可以不在 Markdown 解析器上做,如果定制了解析器那么等同于修改了规范,输出了“错误”的文档结构,所以我甚至建议不要动解析器。我推荐的做法是在前端进行这一步,在正确的文档结构已经输出的基础上。

实现过程

我们先创建一个 HTML 文件,其中包含一段代码块:

<!DOCTYPE html>
<html lang='zh-Hans'>

<head>
    <meta content='text/html; charset=UTF-8' http-equiv='Content-Type'>
    <meta charset='UTF-8'>
    <title>Markdown 代码块扩展</title>
</head>

<body>
    <div id='root'>
        <span>下面是一个 Reducer 函数代码:</span>
        <!-- 以下为 Markdown 代码块翻译完成的 HTML 文档结构: -->
        <pre>
            <code>
/**
* reducer 函数:发表评论
*/
const defaultPostCommentState = {
    isPadding: false,
    result: {message: ''}
};
function postComment(state = defaultPostCommentState, action) {
    switch (action.type) {
        case POST_COMMENT_REQUEST:
            return Object.assign({}, state, {isPadding: true});
        case POST_COMMENT_RESPONSE:
            return Object.assign({}, state, {isPadding: false, result: action.result})
    }
    return state;
}
            </code>
        </pre>
    </div>
</body>

</html>

打开这个网页,浏览器会在网页上将代码文本完全原封不动的原样输出到网页上,顺便附带了一些默认样式(例如 font-family: monospace 等):

图1

对 Github 的参考和我本人的都觉得 table(表格) 是显示行号 + 代码的最佳结构,整个代码块是一个大 table,每一行则是 table 中的 tr(行),每一个行号是前一个 td(列) 每一行代码内容就是后一个 td,所以代码块实际上就是一个多行两列结构的 HTML 表格。
对代码块结构的重构过程其实也很简单:首先选取出每一个代码块的代码文本,然后逐行拆分并拼装成一个两列的行,前一列表示行号后一列即该行代码文本,最后将所有行追加在一起并插入固定的表格标签内部:

const rebuild = (codeDom) => {
    let lines = new Set();
    codeDom.innerHTML.split('\n').slice(1, -1).forEach((lineText, i) => {
        const tr =
            `<tr><td class="line-num">${i + 1}</td><td class="line-content">${lineText}</td></tr>`;
        lines.add(tr);
    });
    let rows = '';
    lines.forEach(line => rows += line);
    codeDom.innerHTML = `<code><table><tbody>${rows}</tbody></table></code>`;
};
const dom = document;
dom.querySelectorAll('pre > code').forEach(codeDom => rebuild(codeDom));

将上面的 Javascript 插入页面底部,刷新网页会发现目的已经达到了,行号显示出来了:

图2

当然,仅此而已仍然是不够的,即使我们不在意外观这个代码块仍然是有问题的,为什么呢?因为行号此时已经成了代码的一部分,在用户进行复制的时候会被选中,如下图:

图3

要解决这个问题并不难,一个 CSS 属性即可:

.line-num {
    user-select: none;
}

这个名为 line-num 的 class 是在遍历每一行代码文本的时候固定插入行号 td 中的,作用也在与此。有人要说了,虽然无法选中行号了,但是行号实际上仍然是在 code 标签中不是吗?是的,但是请不要对规范如此的严格,因为原本在 code 中插入 table 就已经不规范了。对于用户而言,能选中的才是代码,不能选中的行号自然不是代码了:

图4

等等,还没完。如果我们仔细观察网页上的行号和一般编辑器的行号,会发现有一个差异,这个差异对于整个代码块展示而言是“微小”的,但是对于行号而言是“巨大”的。几乎所有的编辑器的行号都是右对齐的,而我们当前的行号是左对齐的,这样就显得有些格格不入了!所以还需要给 line-num 这个 class 加一个 CSS 属性:

.line-num {
    /* other */
    text-align: right;
}

效果:

图5

此时再刷新网页会发现顺眼多了!如果再进行一些样式装饰,便可以如同编辑器界面一般好看起来:)

结束语

其实我的旧前端对代码块的显示比新前端要强(2018/3-5),可以根据代码块传递的类型渲染不同的样式。例如代码块中的内容是 bash 命令行的话则不会显示行号,样式也是 Linux 终端那种极客风格,代码块内容是程序错误输出的话,不会显示行号,并且以错误常见的红色的作为前景和背景色展示内容(类似的如果是警告消息则是黄色)。
在之后的博客代码更新中,会逐步实现上面描述的功能,并且增加关键字高亮(虽然这个不是那么容易就能完美实现的)。