项目

Markdig 解析概述

Markdig 提供了一种高效的、不依赖正则表达式的解析方式,直接将 Markdown 文档转换成抽象语法树(AST)。AST 是 Markdown 文档语义结构的表示,可以被程序化地操作和探索。

  • 这篇文章概述了解析系统的组件及其使用方法。
  • 抽象语法树 文档讨论了 Markdig 如何表示解析结果。
  • 扩展/解析器 文档深入探讨了如何在扩展 Markdig 解析能力的上下文中使用扩展和块/内联解析器。

引言

Markdig 的解析机制主要由两个表面组件构成:Markdown.Parse(...) 方法和 MarkdownPipeline 类。解析后的文档以MarkdownDocument 对象的形式表示,它是基于 MarkdownObject 派生的对象,包括块元素和内联元素。

Markdown 静态类是 Markdig API 的主要入口点,其中包含 Parse(...) 方法,这是主解析算法。Parse(...) 方法本身使用一个密封的内部类 MarkdownPipeline,它维护了一些配置信息以及解析器和扩展的集合。MarkdownPipeline 决定了解析器的行为和功能。可以通过内置的或自定义的扩展来修改 MarkdownPipeline

相关类型简述

以下是一些与解析相关的类型,相关文档中有详细说明。完整列表请参考 API 文档(即将发布)。

类型 描述
Markdown 静态类,通过 Parse(...) 方法提供解析算法的入口
MarkdownPipeline 配置解析器的类,包含块和内联解析器的集合,以及注册的扩展
MarkdownPipelineBuilder 负责构建 MarkdownPipeline,客户端代码用于配置管道选项和行为
IMarkdownExtension 接口,扩展 通过这个接口改变解析器的行为,这是 Markdig 扩展的标准机制
BlockParser 识别 Markdown 源文件中块元素的单个解析组件的基类
InlineParser 识别块内内联元素的单个解析组件的基类
Block AST 中的节点,表示 Markdown 块元素,可以是 ContainerBlockLeafBlock
Inline AST 中的节点,表示 Markdown 内联元素
MarkdownDocument 由解析器产生的抽象语法树的根节点,继承自 ContainerBlock
MarkdownObject 所有 BlockInline 派生对象(以及 HtmlAttributes )的基类

简单示例

以下是一些简单的解析示例,帮助您入门。后续章节会详细介绍 Markdig 解析机制的不同部分。

默认情况下,Markdown.Parse(...) 方法使用一个默认的解析器,该解析器符合 CommonMark 规范,但没有其他特性。

var markdownText = File.ReadAllText("sample.md");

// 如果未提供解析器,将使用默认解析器
var document = Markdown.Parse(markdownText);

您可以手动创建并配置解析器:

var markdownText = File.ReadAllText("sample.md");

// Markdig的"UseAdvancedExtensions"选项包含了CommonMark之外的许多常见扩展,如引用、图片、脚注、网格表格、数学公式、任务列表、图表等。
var pipeline = new MarkdownPipelineBuilder()
    .UseAdvancedExtensions()
    .Build();

var document = Markdown.Parse(markdownText, pipeline);

也可以单独添加扩展:

var markdownText = File.ReadAllText("sample.md");

var pipeline = new MarkdownPipelineBuilder()
    .UseCitations()
    .UseFootnotes()
    .UseMyCustomExtension()
    .Build();

var document = Markdown.Parse(markdownText, pipeline);

Markdown.ParseMarkdownPipeline

引言 所述,Markdig 的解析机制涉及两个表面组件:Markdown.Parse(...) 方法和 MarkdownPipeline 类。Markdown.Parse(...) 方法包含主要的解析算法,而不是单个的块解析器和内联解析器组件。MarkdownPipeline 负责配置解析器的行为。

下文详细介绍了这两个组件。

MarkdownPipeline

MarkdownPipeline 是一个密封的内部类,它决定了解析器的功能。必须使用MarkdownPipelineBuilder 来创建解析器,如上面的示例所示。

MarkdownPipeline 包含配置信息和扩展和解析器的集合。解析器分为两类:

  • 块解析器(BlockParser
  • 内联解析器(InlineParser

扩展是实现 IMarkdownExtension 接口的类,它们允许添加到解析器集合中,或者修改现有的解析器和/或渲染器。当 MarkdownPipelineBuilder.Build() 方法在构建管道的最后阶段被调用时,这些扩展会被调用,对管道进行修改。

此外,MarkdownPipeline 还包含一些额外元素:

  • 一个设置,决定是否跟踪非贡献元素(如空格、额外的标题字符、未转义的字符串等)。
  • 一个设置,决定 AST 中的节点是否引用原始源的位置。
  • 一个可选委托,在文档处理完成后被调用。
  • 一个可选的TextWriter,用于接收解析器的日志输出。

Markdown.Parse 方法

Markdown.Parse 是一个静态方法,包含整体的解析算法,但不包含实际的解析组件,这些组件存储在MarkdownPipeline 中。

Markdown.Parse(...) 方法接受包含原始 Markdown 文本的字符串,并返回一个MarkdownDocument,它是抽象语法树的根节点。如果未提供MarkdownPipelineParse(...)方法将使用一个具有基本功能的默认解析器。

解析过程中,会执行以下操作:

  1. 使用管道中的块解析器处理原始 Markdown 文本,生成初始的块元素树。
  2. 如果配置了跟踪 Markdown trivia(非贡献元素),则块会扩展以吸收邻近的 trivia。
  3. 现在使用管道中的内联解析器处理块,填充抽象语法树的内联元素。
  4. 如果已配置在文档处理完成后调用的委托,则现在调用该委托。
  5. 返回抽象语法树(MarkdownDocument对象)。

MarkdownPipelineBuilder 和扩展

MarkdownPipeline 决定了解析器的行为和能力,而通过MarkdownPipelineBuilder 添加的扩展决定了管道的配置。

本节更详细地讨论了MarkdownPipelineBuilder和扩展的概念。

扩展(IMarkdownExtension

注意:此部分讨论的是如何通过添加扩展到管道来消费扩展。有关如何实现扩展的讨论,请参阅 扩展/解析器 文档。

扩展是主要的机制,用于修改管道中的解析器。

任何实现了IMarkdownExtension接口(位于 IMarkdownExtension.cs )的类都可以被视为扩展。该接口仅包含两个重载的 Setup(...) 方法,第一个参数都是MarkdownPipelineBuilder

MarkdownPipelineBuilder.Build() 方法作为构建管道的最终阶段被调用时,构建器会按顺序遍历注册的扩展,并调用它们的Setup(...)方法。然后,扩展可以完全访问解析器集合本身(通过添加新的解析器)或找到并修改现有解析器。

由于这个原因,某些扩展可能需要按照特定顺序与其他扩展一起使用,例如如果它们修改了由其他扩展添加的解析器。OrderedList<T>类提供了查找和插入特定类型的其他扩展的方法,从而方便排序。

MarkdownPipelineBuilder

由于MarkdownPipeline是一个密封的内部类,不能(也不应该尝试)直接创建。相反,MarkdownPipelineBuilder负责在客户端代码提供配置后管理解析器的必要构造。

如上所述,MarkdownPipeline主要由块解析器和内联解析器的集合组成,这些集合被传递给 Markdown.Parse(...) 方法,从而决定了其特性和行为。这些集合以及其中的一些解析器是可变的,通过 IMarkdownExtension 接口的Setup(...)方法进行修改。这在 扩展 部分有更详细的讨论。

流式接口

MarkdownExtensions.cs 文件中的一个扩展方法集合提供了一个方便的流式 API 来配置 MarkdownPipelineBuilder。这应被视为标准的配置方式。

配置选项

有一些扩展方法可以应用到构建器上,改变管道之外的设置,而无需使用典型的扩展。

方法 描述
.ConfigureNewLine(...) 指定用于解析的新行分隔符
.DisableHeadings() 禁用 ATX 和 Setex 标题的解析
.DisableHtml() 禁用 HTML 元素的解析
.EnableTrackTrivia() 启用跟踪 trivia(非贡献元素)
.UsePreciseSourceLocation() 将语法对象映射到原始源的确切位置,例如用于语法高亮
var builder = new MarkdownPipelineBuilder()
    .ConfigureNewLine("\r\n")
    .DisableHeadings()
    .DisableHtml()
    .EnableTrackTrivia()
    .UsePreciseSourceLocation();

var pipeline = builder.Build();
添加扩展

Markdig 随带的所有扩展可以通过专用的流式方法添加,而实现 IMarkdownExtension 接口的自定义用户代码则可以使用 Use() 方法之一或客户端代码中实现的自定义扩展方法来添加。

查看 MarkdownExtensions.cs 以获取完整的扩展方法列表:

var builder = new MarkdownPipelineBuilder()
    .UseFootnotes()
    .UseFigures();

对于自定义或用户提供的扩展,Use<TExtension>(...)方法允许直接添加类型或将已构造的实例放入扩展容器。它们会在内部防止将两个相同类型的扩展添加到容器中。

public class MyExtension : IMarkdownExtension
{
    // ...
}

// 只有当MyExtension有一个空构造函数(即new())时才有效
var builder = new MarkdownPipelineBuilder()
    .Use<MyExtension>();

或者:

public class MyExtension : IMarkdownExtension
{
    public MyExtension(object someConfigurationObject) { /* ... */ }
    // ...
}

var instance = new MyExtension(configData);

var builder = new MarkdownPipelineBuilder()
    .Use(instance);
使用 Configure 方法添加扩展

MarkdownPipelineBuilder 还有一个名为 Configure(...) 的方法用于配置扩展,它接受一个以 + 分隔的字符串参数,用于动态指定应配置哪些扩展。这是在运行时只知道扩展的情况下配置管道的便利方法。

查看 MarkdownExtensions.cs 的Configure(...) 以获取完整列表的扩展。

var builder = new MarkdownPipelineBuilder()
    .Configure("common+footnotes+figures");

var pipeline = builder.Build();

手动配置

在内部,流式接口包装了对三个主要集合的手动操作:

  • MarkdownPipelineBuilder.BlockParsers - 这是一个 OrderedList<BlockParser>,包含块解析器
  • MarkdownPipelineBuilder.InlineParsers - 这是一个 OrderedList<InlineParser>,包含内联元素解析器
  • MarkdownPipelineBuilder.Extensions - 这是一个 OrderedList<IMarkdownExtension>,包含扩展

这三个集合都是 OrderedList<T>,这是一种 Markdig 特有的集合类型,包含查找和插入衍生类型的特殊方法。创建构建器后,可以通过访问这些集合及其元素并根据需要进行修改来进行手动配置。

警告:在配置管道时,不建议直接修改 BlockParsersInlineParsers 集合。相反,应该尽可能通过扩展的 Setup(...) 方法修改它们,这将在实际构建管道时进行,并允许考虑依赖其他操作的顺序。

块和内联解析器

让我们深入了解一下解析系统。配置好的管道使用 Markdown.Parse 方法会经过两次概念性的处理,以生成抽象语法树。

  1. 首先,对文件的每一行调用 BlockProcessor.ProcessLine,尝试从源文本中识别块元素。
  2. 然后,为每个块创建或借用一个 InlineProcessor,并在其中识别内联元素。

这两个概念性的操作决定了 Markdig 的两种解析器类型,它们都从 ParserBase<TProcessor> 派生。

块解析器,继承自 BlockParser,从源文本的行中识别块元素并将它们推送到抽象语法树。内联解析器,继承自 InlineParser,从 LeafBlock 元素中识别内联元素,并将它们推入关联的容器:ContainerInline? LeafBlock.Inline 属性。

两者都不使用正则表达式,而是基于找到开始字符然后快速读取源文本的部分进行工作。

块解析器

(这部分我不太确定,这是我阅读代码后的理解,但可能需要一些指导)

(CanInterrupt特指中断段落块吗?)

为了添加到解析管道,所有块解析器必须从 BlockParser 派生。

内部,主要的解析算法会遍历源文本,使用 BlockParser 集合的 HasOpeningCharacter(char c) 方法预先识别出在给定文本位置可能打开块的解析器,基于当前字符。因此,任何派生实现都需要设置 char[]? OpeningCharacter 属性,以指定可能开始块的初始字符。

如果解析器可能在源文本的某个位置打开块,它应该期望 TryOpen(BlockProcessor processor) 方法被调用。这是一个虚拟方法,所有派生类都必须实现。BlockProcessor 参数是保存当前解析状态和源位置的对象引用。

(关于 TryOpenBlockState 返回类型有什么规则?我看到的例子返回 NoneContinueBreakDiscardContinueDiscard。返回值如何改变算法行为?)

(新块是否始终应在 TryOpen 方法中推送到 processor.NewBlocks ?)

随着主解析算法的前进,它会调用 TryContinue(...) 方法处理在 TryOpen(...) 中打开的块。

(这是关闭块的地方吗?除了 block.UpdateSpanEnd 和返回 BlockState.Break 之外,还需要做些什么?)

内联解析器

内联解析器从源中提取内联 Markdown 元素,但它们的起点是块解析过程产生的每个 LeafBlock 的文本。要了解每个内联解析器的作用,首先需要理解整个内联解析过程。

内联解析过程

在块解析完成后,文档的抽象语法树只填充了块元素,从根 MarkdownDocument 节点开始,到单独的 LeafBlock 衍生块元素,其中大多数是 ParagraphBlocks,但也包括 CodeBlocksHeadingBlocksFigureCaptions 等。

此时,解析器会遍历每个 LeafBlock,一次一个,为其 LeafBlock.Inline 属性分配一个空的 ContainerInline,然后对 LeafBlock 的文本进行扫描,运行内联解析器。这是通过以下过程完成的:

从文本的第一个字符开始,它会依次运行所有具有该字符作为提取相关内联可能的开始字符的 InlineParser 对象。解析器按顺序运行(因为这是解决解析器之间冲突的唯一方式,对整体解析系统的行为很重要),然后依次调用每个候选解析器的 Match(...) 方法,直到其中一个返回 true

Match(...) 方法将接收到一个文本片段,从特定处理的字符开始,直到 LeafBlock 完整文本的末尾。如果解析器能创建一个 Inline 元素,它就会这样做并返回 true,否则返回 false。解析器将创建的 Inline 对象存储在处理器的 InlineProcessor.Inline 属性中,该属性作为参数传递给 Match(...) 方法。解析器还会将工作 StringSlice 的起始位置向前推进匹配消耗的字符数。

  • 如果解析器创建了一个内联元素并返回 true,该元素将被推送到最深的开放 ContainerInline
  • 如果返回 false,将运行默认的 LiteralInlineParser
    • 如果 InlineProcessor.Inline 属性中已经有了现有的 LiteralInline,这些字符将添加到现有 LiteralInline 中,从而扩展它
    • 如果 InlineProcessor.Inline 属性中没有 LiteralInline,将创建一个新的包含消耗字符的 LiteralInline 并推送到最深的开放 ContainerInline

之后,LeafBlock 的工作文本从概念上通过工作 StringSlice 的起始位置的移动而缩短,将起始字符向前移动。如果仍有文本剩余,该过程将重复,直到所有文本都被消耗。

在这个阶段,当 LeafBlock 的所有源文本都被消耗完后,会进行后处理步骤。实现了 IPostInlineProcessor 的内联解析器管道中的对象会作用于 LeafBlock 的根 ContainerInline。例如,这就是 EmphasisInlineParser 的无结构输出然后被重构为干净嵌套的 EmphasisInlineLiteralInline 元素的机制。

内联解析器的职责

与块解析器类似,内联解析器需要通过 char[]? OpeningCharacter 属性提供一个打开字符数组。

然而,内联解析器只需要提供一个额外的方法:Match(InlineProcessor processor, ref StringSlice slice)。这个方法应该判断相关内联是否在 slice 参数的起始字符处存在匹配。

Match方法中,解析器应执行以下操作:

  1. 确定匹配是否从 slice 参数的起始字符开始。
  2. 如果没有匹配,方法应返回 false,并且不更新 slice 参数的 Start 属性。
  3. 如果存在匹配,则执行以下操作:
    • 创建适当的 Inline 派生类实例,并将其赋值给处理器参数 processor.Inline = myInlineObject
    • 使用 StringSlice 类的 NextChar()SkipChar() 或其他辅助方法,将 slice 参数的 Start 属性向前推进匹配字符的数量。
    • 返回 true

在解析过程中,InlineProcessor 会包含一些可用于访问当前解析状态的属性。例如,processor.Inline 属性是返回新内联元素的机制,但在赋值之前,它包含的是上一个创建的内联,可以通过其获取其父级。

对于可能包含其他内联的内联,一种策略是在检测到打开分隔符时注入一个 DelimiterInline 派生的内联,然后在找到关闭分隔符时替换为最终所需的元素。例如,LinkInlineParser 就采用了这种方法。在这种情况下,可以使用下一节中描述的工具,如 ReplaceBy 方法。需要注意的是,如果使用了此方法,应在 InlineProcessor 上调用后处理方法来完成任何强调元素的最终化。例如,来自 LinkInlineParser 的代码改编如下:

var parent = processor.Inline?.FirstParentOfType<MyDelimiterInline>();
if (parent is null) return;

var myInline = new MySpecialInline { /* 设置span和其他参数 */ };

// 将分隔符内联替换为最终内联类型,并将所有子元素移动到新的内联中
parent.ReplaceBy(myInline);

// 当地创建内联时通知处理器
processor.Inline = myInline;

// 处理强调分隔符
processor.PostProcessInlines(0, myInline, null, false);

内联后处理

内联后处理的主要目的是在初始解析完成后,重新组织内联元素,以便在解析过程中无法访问到的整个内联元素结构现在可用。通常包括移除、替换和重新排列 Inline 元素。

为此,Inline 抽象基类包含了一些辅助方法,用于在后处理阶段操作内联元素。

方法 目的
InsertAfter(...) 接受一个新的内联作为参数,并将其插入到同一父容器中,位置在本实例之后
InsertBefore(...) 接受一个新的内联作为参数,并将其插入到同一父容器中,位置在本实例之前
Remove() 从其父容器中移除此内联
ReplaceBy(...) 移除当前实例并用传入参数指定的新内联替换。可选地将原始内联的所有子元素移动到新内联中

此外,还可以使用 PreviousSiblingNextSibling 属性来确定内联元素在其父容器中的兄弟元素。FirstParentOfType<T>() 方法可用于搜索父元素,这对于查找实现为容器的 DelimiterInline 派生元素时非常有用。

在本文档中