admin管理员组文章数量:1437153
基于.NetCore开发 StarBlog 番外篇 (2) 深入解析Markdig源码,优化ToC标题提取和文章目录树生成逻辑
前言
最近是有点迷茫的,毕竟现在已经是 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
代码语言:javascript代码运行次数:0运行复制## No.2 cookiecutter-django Github星数: 5735
使用 Markdig 生成的 id 是: no.2-cookiecutter-django-github5735
而我手搓的实现是: no2-cookiecutterdjango-github5735
这就造成了点击左侧标题无法跳转的问题。
解决思路
一开始我想着优化我的 ToC 提取方法实现
不过尝试了几次之后发现总有漏网之鱼的 case
最后还是只能转向最开始就放弃的方案:直接用 Markdig 同款的 ToC 提取方法。
那么一开始为啥不用呢?
原因其实我一开始也说了,Markdig 的文档很不详细,我又懒得去翻 Markdig 的源码。
深入 Markdig 源码
这下不得不深入源码了
以下解析仅适用于本文撰写时最新的 0.40.0 版本: .40.0
heading 处理部分
先来看看 Markdig 用于处理 markdown heading 的代码
- 核心数据结构 :
src\Markdig\Syntax\HeadingBlock.cs
- 定义了表示标题的数据结构
- 包含标题的关键属性:
- Level: 标题级别(1-6)
- HeaderChar: 标题字符(通常是#)
- IsSetext: 是否是 Setext 风格的标题(使用 === 或---)
- 解析器 :
src\Markdig\Parsers\HeadingBlockParser.cs
- 负责解析 Markdown 中的标题语法
- 支持两种标题格式:
- ATX 风格 (#)
- Setext 风格 (=== 或 ---)
- 主要解析逻辑在 TryOpen 方法中
- 渲染器 :
src\Markdig\Renderers\Html\HeadingRenderer.cs
- 负责将 HeadingBlock 渲染为 HTML
- 将不同级别的标题转换为对应的 h1-h6 标签
处理流程:
- 当遇到以 # 开头的行或者下一行包含 === / --- 的文本行时, HeadingBlockParser 会被触发
- 解析器会创建一个 HeadingBlock 实例,并设置相应的属性(级别、类型等)
- 最后通过 HeadingRenderer 将 HeadingBlock 渲染为对应的 HTML 标签
heading id 生成
现在把 heading 处理部分理清了
也学到了不错的思路,现在自己手搓一个轮子来处理 markdown heading 都绰绰有余了
不过这次要解决的问题是 heading 的 id
还得继续翻代码
根据代码分析,Markdig 中 heading 的 ID 生成主要通过 AutoIdentifier 扩展来实现,具体实现在 src\Markdig\Extensions\AutoIdentifiers\AutoIdentifierExtension.cs
中
ID生成的主要流程如下:
基本生成规则
- 如果heading已经手动设置了ID属性,则保持不变
- 如果heading内容为空,使用"section"作为ID
- 否则,将heading的文本内容转换为URL友好的格式
ID冲突处理
- 当生成的ID与已存在的ID冲突时,会自动添加数字后缀
- 例如:如果"my-heading"已存在,则新的ID会变成"my-heading-1","my-heading-2"等
具体处理步骤
代码语言:javascript代码运行次数:0运行复制// 获取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));
}
特殊处理规则
从测试用例可以看出一些特殊情况的处理:
代码语言:javascript代码运行次数:0运行复制# 1.0 This is a heading
会生成ID为"this-is-a-heading",即会去掉开头的数字。
小结
这种ID生成机制确保了
- ID的唯一性
- URL友好(没有特殊字符)
- 保持可读性
- 自动处理重复情况
如何获取 id ?
现在已经了解了 Markdig 中的 heading 部分的具体实现
那么如何在使用 Markdig 库的时候拿到生成 heading ID 呢?
通过仔细分析代码,我发现 Markdig 中获取标题 ID 的正确方式是通过 GetAttributes() 方法,但需要在渲染完成后才能获取。这是因为 ID 的生成是在 HeadingBlock_ProcessInlinesEnd
阶段完成的。
正确获取方式是:
代码语言:javascript代码运行次数:0运行复制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。
这种设计是为了确保:
- ID 的唯一性(通过在渲染时收集所有标题)
- 正确处理标题中的内联元素(如链接、强调等)
- 按照文档顺序正确处理重复标题的情况 所以在使用 Markdig 时,如果需要获取标题 ID,一定要先进行渲染,否则获取到的 ID 将会是 null。
最终实现
代码在: StarBlog.Share/Extensions/Markdown/ToC.cs
这里主要是重构了 Markdown 目录生成逻辑,使用 Markdig 的 AutoIdentifiers 扩展自动生成标题 ID,并优化了父子关系的建立和树状结构的生成。移除了手动生成 slug 的逻辑,提高了代码的可维护性和准确性。
代码语言:javascript代码运行次数:0运行复制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 有关的新玩意:
- StarBlogPublisher - AI驱动的文章一键发布工具,已开发完成,接下来马上会发布
- 博客自动备份工具,正在开发中
- 更多的访问日志分析功能,正在开发中
本文标签: 基于NetCore开发 StarBlog 番外篇 (2) 深入解析Markdig源码,优化ToC标题提取和文章目录树生成逻辑
版权声明:本文标题:基于.NetCore开发 StarBlog 番外篇 (2) 深入解析Markdig源码,优化ToC标题提取和文章目录树生成逻辑 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.betaflare.com/biancheng/1747426587a2696199.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论