Dart Markdown 提供了一種便捷的方式,讓開發者能夠在應用中渲染 Markdown 文本。這一功能主要基於 CommonMark Spec 0.30 標準實現,確保了對 Markdown 語法的準確解析與轉換。核心功能集中於 markdownToHtml 方法,該方法將 Markdown 文本轉換為 HTML。

為了提供靈活性,該套件引入了 ExtensionSet 概念,允許開發者自定義解析器支持的 Markdown 語法。通過替換或擴展 ExtensionSet,開發者可以根據特定需求,輕鬆實現對不同標記式語言或擴展 Markdown 語法的支持。

本文主要關心解析 Markdown 語法的部分。主要的邏輯是依據 CommonMark Spec 0.30 實作。

mrkdownToHtml

透過 Document.parseLines 轉換成 Node , 再透過 HTMLRender 產生對應的 HTML 字串。而 Document 實際的 parse 是透過 BlockParserInlineParser 實現要轉交給哪一個 BlockSyntax 或是 InlineSyntax。因此需要支援新語法,則可以自定義 InlineSyntaxBlockSyntaxDocument 的 constructor 將 withDefaultBlockSyntaxeswithDefaultInlineSyntaxes 設定為 false , 便不會以 markdown 的語法 parse。

類圖整理如下:


classDiagram
    class NodeVisitor {
    }
    class Node {
        <<abstract>>
    }
    class Element {
    }
    class UnparsedContent {
    }
    class Text {
    }
    class BlockSyntax {
        <<g;abstract>>
    }
    class InlineSyntax {
        <<abstract>>
    }
    class Document {
    }
    class BlockParser {
    }
    class InlineParser {
    }

    NodeVisitor --|> Element
    Node <|-- Element
    Node <|-- UnparsedContent
    Node <|-- Text
    Document --> BlockParser
    Document --> InlineParser
    BlockParser --> BlockSyntax
    InlineParser --> InlineSyntax
    NodeVisitor ..> Node : accepts
    BlockSyntax ..> Node : creates
    InlineSyntax ..> Node : creates

Document

Document 是一個核心類,它代表了整個 Markdown 文檔。Document 類的主要職責是解析原始的 Markdown 文本,將其轉換成一個結構化的節點樹(Node Tree),這個節點樹反映了 Markdown 文檔的結構和內容。這種轉換過程允許 Flutter 應用以可視化的方式呈現 Markdown 文檔,並且支持 Flutter 的各種布局和風格特性。

解析過程

當一個 Document 對象被創建並接收到 Markdown 文本時,它會進行以下幾個步驟來解析文本:

  1. 預處理:對原始 Markdown 文本進行預處理,準備進行解析。這可能包括去除不必要的空白字符、處理特殊符號等。
  2. 塊級解析(Block Parsing):使用 BlockParser 解析文本中的塊級元素,如段落、標題、列表等。這一步驟將文本分割成多個塊級節點。
  3. 內聯解析(Inline Parsing):對於每個塊級節點內的文本,使用 InlineParser 進一步解析內聯元素,如鏈接、強調、圖片等。這一步驟將文本轉換成更細粒度的節點,如文本節點和元素節點。
  4. 結構化構建:根據解析結果,構建一個結構化的節點樹,這個節點樹準確地反映了 Markdown 文檔的結構。

比較需要注意的是下面這兩個 function:

  • parseInline (String text) → List<Node> Parses the given inline Markdown text to a series of AST nodes 。
  • parseLines (List<String> lines) → List<Node> Parses the given lines of Markdown to a series of AST nodes

Node

Node 是一個抽象基類,用於表示 Markdown 文檔中的各種元素。這個概念類似於許多文檔處理系統中的 DOM(Document Object Model)節點,它提供了一種結構化的方式來表示和操作文檔內容。在 Flutter 的 Markdown 實現中,所有的 Markdown 元素都是以 Node 的形式存在,並且具體可以分為以下三種實作:

1. Element

Element 代表了 Markdown 文檔中的結構化元素,比如段落、標題、列表、圖片鏈接等。這些元素通常由特定的 Markdown 語法標記定義,並且可以包含其他的 Node,如文本節點或者是其他元素節點,從而形成一個樹狀結構。Element 類型的節點是 Markdown 文檔結構的主要組成部分,它們定義了文檔的結構和內容的組織方式。

2. Text

Text 節點代表了 Markdown 文檔中的純文字內容。這些節點通常包含實際的文本字符串,並且不包含任何 Markdown 語法標記。在 Markdown 的樹狀結構中,Text 節點通常作為 Element 節點的子節點出現,用於表示元素內部的文字內容。

3. UnparsedContent

UnparsedContent 節點用於表示那些沒有被成功解析的 Markdown 內容。這可能是因為內容包含了無效的 Markdown 語法,或者是 Markdown 處理器不支持的語法。將這部分內容封裝在 UnparsedContent 節點中,可以讓 Markdown 處理器在遇到解析問題時,仍然能夠保留原始文本,而不是丟失這些資訊。

BlockParser

BlockParser 負責解析 Markdown 文本中的塊級 block-level 元素,包括段落、標題、列表、引用塊、代碼塊等,這些元素通常佔據一整行或多行,並且在視覺上與周圍內容有明顯的區隔。

流程如下:

  1. 讀取文本BlockParser 逐行讀取 Markdown 文本,根據 Markdown 語法的規則識別不同類型的塊級元素。
  2. 識別元素:對於每一行或多行文本,BlockParser 使用一系列的規則或正則表達式來匹配並識別塊級元素的類型。這些規則定義了不同塊級元素的語法,例如標題是以一個或多個 # 符號開頭,列表項目是以 -* 或數字後跟點開頭等。
  3. 生成對應結構:一旦識別出塊級元素,BlockParser 就會根據該元素的類型生成對應的數據結構或 Flutter widget。例如,對於一個列表,它可能生成一個包含多個列表項目的 widget 結構。
  4. 處理嵌套:Markdown 語法支持元素的嵌套,如列表中的引用塊、引用塊中的代碼塊等。BlockParser 需要能夠識別並正確處理這些嵌套結構,生成正確的 widget 樹。

InlineParser

InlineParser 是一個關鍵組件,負責解析 Markdown 文本中的內聯(inline)元素。內聯元素包括但不限於鏈接(links)、強調(emphasis,如粗體和斜體)、代碼(code)、圖片(images)等。InlineParser 的主要任務是識別這些元素的語法,

大部分的 Parser 都大同小異,唯一不同的是連結跟圖片用到比較特別的演算法。

_linkOrgImage()

根據 Common Markdown Spec - look for link or image 描述,當遇到 ] 時,會呼叫 _linkOrImage 來試圖 parse 成 link 或是 image, link 的文字會有強調語法, 所以會需要再做處理。在 markdown, 連結的語法是 [https://a.b.c.png],delimiter 是 [ 或是 ![ ,有 open 跟 close 兩種記號, link title 裡面會有強調語法,所以要再處理。inline_parserpushDelimiter 是被 DilimiterSyntax 呼叫。

DelimterSyntax


classDiagram

class InlineParser

class InlineSyntax {
  <<abstract>>
}

class DelimiterSyntax

class Delimiter {
  <<abstract>>
}

class DelimiterRun

class SimpleDelimiter

InlineSyntax <|-- DelimiterSyntax
DelimiterSyntax --> InlineParser
InlineParser --> Delimiter
DelimiterSyntax <--> DelimiterRun
Delimiter <|-- DelimiterRun
Delimiter <|-- SimpleDelimiter

有一個特殊算法, 建立 DelimiterRun 判斷是否有 close,維護一個 opener/closer pair list。這算法會判斷 left-flanking、 right-flanking。

_combineAdjacentText

這個方法是將相鄰的 Text 節點合併為一個 Text 節點。這是為了在跨越換行符時產生正確的輸出,其中空格有時會被壓縮。該方法遞歸檢查每個節點是否為 Text 節點或 Element 節點,如果是 Element 節點,則遞歸到其子節點進行處理。如果該節點是 Text 節點且其後面有相鄰的 Text 節點,則將它們合併為一個 Text 節點。最終,該方法會更新傳入的節點列表以反映這些更改。

_processDelimiterRun

該 fn 處理 Markdown 文件中內嵌標記的分隔符運行。分隔符運行是指包圍要強調的文字的一系列相同的分隔符字符序列(例如 "##" 表示粗體,"_" 表示斜體)。

該算法遵循了 CommonMark 規範中描述的“處理強調”程序的策略。目的是找到一對分隔符運行,將其包含的文本視為強調。

程式會遍歷分隔符運行,從底部索引開始處理。過程中,程式會維護一個映射表,用於記錄所有打開的分隔符運行的索引位置。對於每個關閉的分隔符運行,程式會在映射表中查找相應的打開分隔符運行。如果找到一對相符的分隔符運行,則根據標記類型,將它們替換為一個強調節點。如果沒有找到相符的分隔符運行,程式就會繼續遍歷。遍歷完所有的分隔符運行後,程式會刪除映射表中所有的分隔符運行,完成處理過程。

LinkParser

LinkParser 繼承 InlineParser ,專門處理 Link 的解析,詳見源碼 LinkParser 的寫法跟 org-mode 不同,這邊需要重新寫。InlineParser._linkOrImage 會用到 LinkSyntax,而 LinkSyntax 會用到 LinkParser。

ImageSyntax


classDiagram

LinkSyntax <|-- ImageSyntax

LinkSyntax

InlineParser 會先檢查是否有 ] 出現,若有便會呼叫 _linkOrImage 回頭找看看是 link 還是 image。此時會用 DelimiterSyntax 來判斷是否是一個 delimiter,InlineParser 會根據是不是 LinkSyntax 做其他處理。

close()

close() 這個方法用來找出 reference link、 reference link 使用 link label 代替 url。主要是處理下面這段 markdown 語法。

[foo][bar]

[bar]: /url "title"

這裡列出相關的程式碼。

    if (char == $lbracket) {
      parser.advanceBy(1);
      // At this point, we've matched `[...][`. Maybe a #full# reference link,
      // like `[foo][bar]` or a #collapsed# reference link, like `[foo][]`.
      if (parser.pos + 1 < parser.source.length &&
          parser.charAt(parser.pos + 1) == $rbracket) {
        // That opening `[` is not actually part of the link. Maybe a
        // #shortcut# reference link (followed by a `[`).
        parser.advanceBy(1);
        return _tryCreateReferenceLink(parser, text, getChildren: getChildren);
      }
      final label = _parseReferenceLinkLabel(parser);
      if (label != null) {
        return _tryCreateReferenceLink(parser, label, getChildren: getChildren);
      }
      return null;
    }

可以看到,] 是主要的觸發點。 close 會呼叫 _parseReferenceLinkLabel 去找出 label。