admin管理员组

文章数量:1027621

基于.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 的代码

  1. 核心数据结构 : src\Markdig\Syntax\HeadingBlock.cs
  • 定义了表示标题的数据结构
  • 包含标题的关键属性:
    • Level: 标题级别(1-6)
    • HeaderChar: 标题字符(通常是#)
    • IsSetext: 是否是 Setext 风格的标题(使用 === 或---)
  1. 解析器 : src\Markdig\Parsers\HeadingBlockParser.cs
  • 负责解析 Markdown 中的标题语法
  • 支持两种标题格式:
    • ATX 风格 (#)
    • Setext 风格 (=== 或 ---)
  • 主要解析逻辑在 TryOpen 方法中
  1. 渲染器 : src\Markdig\Renderers\Html\HeadingRenderer.cs
  • 负责将 HeadingBlock 渲染为 HTML
  • 将不同级别的标题转换为对应的 h1-h6 标签

处理流程:

  1. 当遇到以 # 开头的行或者下一行包含 === / --- 的文本行时, HeadingBlockParser 会被触发
  2. 解析器会创建一个 HeadingBlock 实例,并设置相应的属性(级别、类型等)
  3. 最后通过 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 的生成是在处理内联元素结束时进行的:

代码语言:javascript代码运行次数:0运行复制
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。

这种设计是为了确保:

  1. ID 的唯一性(通过在渲染时收集所有标题)
  2. 正确处理标题中的内联元素(如链接、强调等)
  3. 按照文档顺序正确处理重复标题的情况 所以在使用 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驱动的文章一键发布工具,已开发完成,接下来马上会发布
  • 博客自动备份工具,正在开发中
  • 更多的访问日志分析功能,正在开发中
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。原始发表:2025-05-01,如有侵权请联系 cloudcommunity@tencent 删除优化源码渲染工具开发

基于.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 的代码

  1. 核心数据结构 : src\Markdig\Syntax\HeadingBlock.cs
  • 定义了表示标题的数据结构
  • 包含标题的关键属性:
    • Level: 标题级别(1-6)
    • HeaderChar: 标题字符(通常是#)
    • IsSetext: 是否是 Setext 风格的标题(使用 === 或---)
  1. 解析器 : src\Markdig\Parsers\HeadingBlockParser.cs
  • 负责解析 Markdown 中的标题语法
  • 支持两种标题格式:
    • ATX 风格 (#)
    • Setext 风格 (=== 或 ---)
  • 主要解析逻辑在 TryOpen 方法中
  1. 渲染器 : src\Markdig\Renderers\Html\HeadingRenderer.cs
  • 负责将 HeadingBlock 渲染为 HTML
  • 将不同级别的标题转换为对应的 h1-h6 标签

处理流程:

  1. 当遇到以 # 开头的行或者下一行包含 === / --- 的文本行时, HeadingBlockParser 会被触发
  2. 解析器会创建一个 HeadingBlock 实例,并设置相应的属性(级别、类型等)
  3. 最后通过 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 的生成是在处理内联元素结束时进行的:

代码语言:javascript代码运行次数:0运行复制
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。

这种设计是为了确保:

  1. ID 的唯一性(通过在渲染时收集所有标题)
  2. 正确处理标题中的内联元素(如链接、强调等)
  3. 按照文档顺序正确处理重复标题的情况 所以在使用 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驱动的文章一键发布工具,已开发完成,接下来马上会发布
  • 博客自动备份工具,正在开发中
  • 更多的访问日志分析功能,正在开发中
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。原始发表:2025-05-01,如有侵权请联系 cloudcommunity@tencent 删除优化源码渲染工具开发

本文标签: 基于NetCore开发 StarBlog 番外篇 (2) 深入解析Markdig源码,优化ToC标题提取和文章目录树生成逻辑