100 行代码实现 GitHub TOC 生成器

要解决的问题

  • 简要描述

GitHub 不支持将 .md 文件中的 [TOC] 标签自动解析成目录,那么使用代码解决此问题。

  • 详细描述

经常使用 GitHub 的同学都知道,README.md 是仓库的默认说明文档,当使用浏览器打开仓库的地址时,GitHub 将会自动把 README.md 渲染成网页,为用户提供良好的阅读体验。

不止是 README.md 文件,所有的 .md 文件,在从 GitHub 网站打开时都会被自动渲染。

GitHub 中的 .md 文件属于 MarkDown 文件,它使用的是 GitHub Flavored Markdown 支持的语法,也就是 GitHub 自己定义的 MarkDown 语法。

当我们使用 MarkDown 编写开源项目文档,或以 MarkDown 做笔记时, 文档中各种层级标题是必不可少的,例如项目介绍、项目实现、架构设计、流程图等,当文档内容逐渐丰富,标题逐渐出现层次,如果上来直接看文档,不可能做到对整个文档内容和结构一目了然,就像看一个 PDF 文件,没有目录,非常难以阅读。

此时,需要给 MarkDown 文档添加目录,通过了解 MarkDown 语法,发现通常在 MarkDown 顶部加上 [TOC] 标签,绝大多数 MarkDown 解析器都会将 [TOC] 标签解析为添加文档的目录,在渲染 MarkDown 文档时,自动在文档开头添加上目录,用户点击目录中的条目,即可跳转至内容区对应的标题处。

于是在了解上述内容后我立刻在我的一大坨文档上添加一个超强的 [TOC],push 到 GitHub,正准备狂喜的时候,一看。傻眼了,GitHub 原封不动的把 [TOC] 给放在那里了。

后来知道 GitHub Flavored Markdown 是不支持自动渲染 [TOC] 的。那不行,很不爽,必须得加上目录。

项目背景

通过搜索引擎寻找直接可用的 GitHub TOC 生成工具,搜索了半天,找到一个网页工具,是个外国人做的,一使用发现,竟然不支持中文,真是不行。看来还是自己搞一个吧。

然后通过看这个工具生成目录的规则,以及参考别人 GitHub 项目中的带有目录的 .md 文件发现,GitHub MarkDown 可以手动编写指向每一个标题的索引,利用 MarkDown 语法中的超链接标签 [title](url)

其中 title 放置链接的描述,url 放置超链接,那么,用户可用鼠标点击这个链接,直接跳转到对应的网页,例如:

1
[百度一下](https://www.baidu.com/)

对于 MarkDown 的标题,例如 # 前言## 项目描述,不论标题层次,它们对应的链接都是 [标题](#标题),分别如下:

1
2
3
[前言](#前言)

[项目描述](#项目描述)

那么用户点击链接时,会在当前网页的 url 后面添加 #前言,然后跳转到本页面的标题对应处。这样就可以生成目录了。

一开始写文档,标题不多,就手动制作一个目录,做了两次,感觉真的太麻烦了。

于是乎,我随手创建一个 Java 类,只用了几十行就搞定了,只要解析出 .md 文档中的 ### 等标题标签,就作为标题,然后提取标题内容,填入 [标题](#标题) 的格式,最后打印出来就可以了,然而实际用起来却像骑上一辆正方形轮子的自行车。

原因是 [标题](#标题) 格式中 # 后面的内容有一定的规则,并不是把标题内容完全搬过来就可以了,例如不能使用大写字母、不能包含一些特殊符号,一旦包含,就无法跳转至对应标题;或者当文档中的标题有重复的时候,那么相同的目录,只能跳转到第一个标题处。所以使用上面随手写的工具会出现:鼠标怎么也点不动,或者点击跳转到总是第一个重复标题的位置,所以用起来非常难受。

经过我多次编写 GitHub MarkDown 文档的搬砖经验,总结目录索引的规则,最后用了 100 多行 Java 代码写出了一个好用的工具(难度等级:猴子都能写出来),自己用的时候大呼“真香!”^_^。

其实网上看到有很多 GitHub MarkDown TOC 的项目,但是看起来都太臃肿了,废话太多,明明一个类就可以搞定,非要搞得跟坦克一样,真的不适合我。

思路

总结 GitHub MarkDown 目录规则如下:

  1. 依据 MarkDown 语法,出现在 # 后面的标题将作为目录出现;
  2. 标题 # 你好 的目录格式为 - [你好](#你好),小括号内为标题的索引,点击可跳转到标题处;
  3. 特殊符号在标题的索引中将被替换为 "";空格将被替换为 "-",大写字母将被替换为小写;
  4. 如果标题重复,那么第一次出现的标题索引保持不变,后续标题索引依次追加 -1-2 依次类推。

例如,MarkDown 文档标题如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# MarkDown TOC 项目

## 项目描述

### 背景

## 需求描述

### 背景

## 可行性分析

### 背景

生成的目录如下:

1
2
3
4
5
6
7
- [MarkDown TOC 项目](#markdown-toc-项目)
- [项目描述](#项目描述)
- [背景](#背景)
- [需求描述](#需求描述)
- [背景](#背景-1)
- [可行性分析](#可行性分析)
- [背景](#背景-2)

效果如下:

实现

根据上面总结的规则就可以编写代码了。

  1. 对于文档标题的提取,逐行扫描文档,发现有 # ## 开头的内容,认为是标题,提取后面的内容。

考虑到一种特殊情况,文档引用代码片段时,代码中可能出现以 # 开头的行,例如 Makefile 或 Shell 脚本中的注释。所以需要排除,由于代码片段是通过 ``` 符号包围的,所以,扫描到 ``` 符号,认为进入了代码,则不提取标题,再次遇到 ``` 闭合代码片段,进入正文内容后再开始提取。

  1. 对于重复标题,使用 Map 数据结构来进行保存。当遇到重复标题时,累计数量 +1,转成文本添加到目标的后面作为标号即可;

  2. 对于不能使用的特殊符号列表,采用了遍历测试 ASCII 表的方法,提取出来可出现在目录索引中的符号,其他符号一律作为特殊符号,替换为 "" 处理。

那么即可编写代码,最后生成的工具是一个 4KB 的 jar 包,使用方法如下:

指定一个后缀为 .md 的文件,目录将被打印出来,复制到 MarkDown 文档最上方即可。

1
java -jar MarkDownTocCreator.jar XXX.md

代码

已通过我个人 GitHub 上的文档测试,目前没有出现问题(其中的 IoUtils,是为了逐行读取文档)。

如需使用这个小工具,请到文末仓库地址中自取 ^_^。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/**
* Created by l0neman on 2020/07/07.
*
* @author l0neman
* @version 1.0
*/
public class MarkDownTocCreator {

// 目录标记
private static final char TOC_FLAG = '#';

// 代码段标记
private static final String CODE_FLAG = "```";

// 允许出现在目录中的字符
// 不是 [数字 + 字母 + 中文 + ASCII 表中筛选出的有效字符]
private static final String NOT_ALLOW_CHAR_REGEX =
"[^\\w\\u4e00-\\u9fa5-_ƒ^ŠŒŽšœŸªµºÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ]";

// 存放相同目录名的索引
// 按照规则,第一个目录为原始字符串,例如“哈哈”,第二个重复名字开始依次为:“哈哈-1”,“哈哈-2”,依次类推
private final Map<String, Integer> tocCount = new HashMap<>();

private String fixSpecialChar(String toc) {
return toc.toLowerCase().replace(' ', '-').replaceAll(NOT_ALLOW_CHAR_REGEX, "");
}

/*
输出标准 github toc 目录
*/
private String getToc(String tocLine) {
int index = tocLine.indexOf("# ");

// 取得目录内容
String srcToc = tocLine.substring(index + 2);
String fixToc = fixSpecialChar(srcToc);

// 处理目录重复的情况
Integer count = tocCount.get(fixToc);
if (count == null) {
tocCount.put(fixToc, 0);
} else {
++count;
tocCount.put(fixToc, count);
fixToc = fixToc + "-" + count;
}


String toc;
// 根据 # 符号数量确定目录深度,最大支持 4 层 #
switch (index) {
case 0:
toc = "-";
break;
case 1:
toc = " -";
break;
case 2:
toc = " -";
break;
case 3:
toc = " -";
break;
default:
toc = null;
break;
}

if (toc != null) {
toc += String.format(" [%s](#%s)", srcToc, fixToc);
}

return toc;
}

private boolean insideCode = false;

private void handle(String file) {
IoUtils.readToLines(new File(file), tocLine -> {

if (tocLine.startsWith(CODE_FLAG)) {
// 进入代码片段中,# 符号将不算做目录
insideCode = !insideCode;
}

if (!insideCode && tocLine.startsWith("#")) {
String convert = getToc(tocLine);

if (convert != null) {
System.out.println(convert);
}
}
});
}

public static void main(String[] args) {
if (args.length == 0 || args[0].equals("") || !args[0].endsWith(".md")) {
System.out.println("Please specify a markdown file.");
return;
}

new MarkDownTocCreator().handle(args[0]);
}
}

项目地址

作者

l0neman

发布于

2020-07-19

更新于

2020-07-19

许可协议

评论