
最近是有点迷茫的,毕竟现在已经是 AI 时代,我之前关注的 CPython 源码解析大佬也宣布不再发表技术文章了,让我一度对卷代码卷技术的意义产生了怀疑…
不过习惯的力量还是很强的,再怎么说也做了这么多年的技术,突然要放弃坚持了好久的东西也不是那么容易的……
OK,短暂的迷茫之后回归正题,之前在 StarBlog 开发笔记系列的第 19 篇:Markdown 渲染方案探索 有介绍我自己造轮子实现了 Markdown 的 ToC 提取。
尽管当时我已经花了不少时间去设计这个功能,不过由于技术和精力有限,这个功能也不完善,一些场景下经常出现提取后的 id 和 Markdig 生成的 id 不一致的问题。
最近在开发 StarBlog 博客发布工具,又遇到了这个问题,我决定花时间把这个问题彻底搞定!
Markdig 这个库好用是好用,就是没啥文档,为了实现一些定制性的功能,只能去翻源码。
本次的工作重构了 Markdown 的目录生成逻辑,使用 Markdig 的 AutoIdentifiers 扩展自动生成标题 ID,并优化了父子关系的建立和树状结构的生成。移除了手动生成 slug 的逻辑,提高了代码的可维护性和准确性。
当前是我自己手搓的一个比较简陋的 ToC 提取功能,具体的思路和实现在第 19 篇开发笔记里有介绍,遇到的问题是一些不该替换的字符被替换了。
这个实现的代码在: StarBlog.Share/Extensions/Markdown/ToC.cs
举个例子:
以下这个 heading2
## No.2 cookiecutter-django Github星数: 5735
使用 Markdig 生成的 id 是: no.2-cookiecutter-django-github5735
而我手搓的实现是: no2-cookiecutterdjango-github5735
这就造成了点击左侧标题无法跳转的问题。
一开始我想着优化我的 ToC 提取方法实现
不过尝试了几次之后发现总有漏网之鱼的 case
最后还是只能转向最开始就放弃的方案:直接用 Markdig 同款的 ToC 提取方法。
那么一开始为啥不用呢?
原因其实我一开始也说了,Markdig 的文档很不详细,我又懒得去翻 Markdig 的源码。
这下不得不深入源码了
以下解析仅适用于本文撰写时最新的 0.40.0 版本: https://github.com/xoofx/markdig/tree/0.40.0
先来看看 Markdig 用于处理 markdown heading 的代码
src\Markdig\Syntax\HeadingBlock.cssrc\Markdig\Parsers\HeadingBlockParser.cssrc\Markdig\Renderers\Html\HeadingRenderer.cs处理流程:
现在把 heading 处理部分理清了
也学到了不错的思路,现在自己手搓一个轮子来处理 markdown heading 都绰绰有余了
不过这次要解决的问题是 heading 的 id
还得继续翻代码
根据代码分析,Markdig 中 heading 的 ID 生成主要通过 AutoIdentifier 扩展来实现,具体实现在 src\Markdig\Extensions\AutoIdentifiers\AutoIdentifierExtension.cs 中
ID生成的主要流程如下:
// 获取heading的原始文本
stripRenderer.Render(headingBlock.Inline);
ReadOnlySpan<char> rawHeadingText = ((FastStringWriter)stripRenderer.Writer).AsSpan();
// 将文本转换为URL友好的格式
string headingText = (_options & AutoIdentifierOptions.GitHub) != 0
? LinkHelper.UrilizeAsGfm(rawHeadingText)
: LinkHelper.Urilize(rawHeadingText, (_options & AutoIdentifierOptions.AllowOnlyAscii) != 0);
// 处理空heading的情况
var baseHeadingId = string.IsNullOrEmpty(headingText) ? "section" : headingText;
// 处理ID冲突
var headingId = baseHeadingId;
if (!identifiers.Add(headingId))
{
var headingBuffer = new ValueStringBuilder(stackallocchar[ValueStringBuilder.StackallocThreshold]);
headingBuffer.Append(baseHeadingId);
headingBuffer.Append('-');
uint index = 0;
do
{
index++;
headingBuffer.Append(index);
headingId = headingBuffer.AsSpan().ToString();
headingBuffer.Length = baseHeadingId.Length + 1;
}
while (!identifiers.Add(headingId));
}
从测试用例可以看出一些特殊情况的处理:
# 1.0 This is a heading
会生成ID为"this-is-a-heading",即会去掉开头的数字。
这种ID生成机制确保了
现在已经了解了 Markdig 中的 heading 部分的具体实现
那么如何在使用 Markdig 库的时候拿到生成 heading ID 呢?
通过仔细分析代码,我发现 Markdig 中获取标题 ID 的正确方式是通过 GetAttributes() 方法,但需要在渲染完成后才能获取。这是因为 ID 的生成是在 HeadingBlock_ProcessInlinesEnd 阶段完成的。
正确获取方式是:
var pipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers()
.Build();
// 首先需要解析文档
var document = Markdown.Parse(markdownText, pipeline);
// 重要:需要先渲染文档,因为 ID 是在渲染过程中生成的
Markdown.ToHtml(document, pipeline);
// 然后才能获取标题的 ID
foreach (var heading in document.Descendants<HeadingBlock>())
{
string? headingId = heading.GetAttributes().Id;
Console.WriteLine($"Heading: {heading.Level}, ID: {headingId}");
}
因为在 AutoIdentifierExtension.cs 中,ID 的生成是在处理内联元素结束时进行的:
private void HeadingBlockParser_Closed(BlockProcessor processor, Block block)
{
// ...
// Then we register after inline have been processed to actually generate the proper #id
headingBlock.ProcessInlinesEnd += HeadingBlock_ProcessInlinesEnd;
}
所以如果不先调用 ToHtml() 进行渲染, ProcessInlinesEnd 事件就不会被触发,ID 就不会被生成。这就是为什么需要先进行渲染,然后才能获取到正确的 ID。
这种设计是为了确保:
代码在: StarBlog.Share/Extensions/Markdown/ToC.cs
这里主要是重构了 Markdown 目录生成逻辑,使用 Markdig 的 AutoIdentifiers 扩展自动生成标题 ID,并优化了父子关系的建立和树状结构的生成。移除了手动生成 slug 的逻辑,提高了代码的可维护性和准确性。
private static string GetHeadingText(HeadingBlock heading) {
if (heading.Inline == null) returnstring.Empty;
var stringBuilder = new StringBuilder();
foreach (var inline in heading.Inline.Descendants<LiteralInline>()) {
stringBuilder.Append(inline.Content);
}
return stringBuilder.ToString();
}
publicstatic List<TocNode>? ExtractToc(this Post post) {
if (post.Content == null) returnnull;
var pipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers()
.Build();
var document = Markdig.Markdown.Parse(post.Content, pipeline);
// Markdig 中获取标题 ID 的正确方式是通过 GetAttributes() 方法,但需要在渲染完成后才能获取。
// 因为 ID 的生成是在 HeadingBlock_ProcessInlinesEnd 阶段完成的 (参考源码: src\Markdig\Extensions\AutoIdentifiers\AutoIdentifierExtension.cs)
_ = document.ToHtml(pipeline);
// 1. 先将所有标题转换为扁平结构
var headings = document.Descendants<HeadingBlock>()
.Select((heading, index) => new Heading {
Id = index,
Text = GetHeadingText(heading),
Slug = heading.GetAttributes().Id,
Level = heading.Level
})
.ToList();
// 2. 建立父子关系
for (var i = 0; i < headings.Count; i++) {
var current = headings[i];
// 向前查找第一个级别小于当前标题的标题作为父标题
for (int j = i - 1; j >= 0; j--) {
if (headings[j].Level < current.Level) {
current.Pid = headings[j].Id;
break;
}
}
}
// 3. 转换为树状结构
var tocNodes = new List<TocNode>();
var nodeMap = new Dictionary<int, TocNode>();
foreach (var heading in headings) {
var node = new TocNode {
Text = heading.Text,
Href = $"#{heading.Slug}"
};
nodeMap[heading.Id] = node;
if (heading.Pid == -1) {
// 根节点
tocNodes.Add(node);
}
else {
// 子节点
var parentNode = nodeMap[heading.Pid];
if (parentNode.Nodes == null) {
parentNode.Nodes = new List<TocNode>();
}
parentNode.Nodes.Add(node);
}
}
return tocNodes;
}
这样提取的 ToC 就与 Markdig 保持完全一致了。
简简单单的功能,但却也是个不小的坑,开发博客的过程中,有无数个这样的坑需要花时间去解决,累还是有点累的,不过既然项目已经上线跑这么久了,总得修修补补。
接下来我还会发布几个与 StarBlog 有关的新玩意: