Swift Markdownを利用してMarkdownを解析・編集する

feather for Mastodonは、特定の文字数以上の投稿を自動的に折りたたむ機能を追加しました。この際にSwift Markdownという apple 製のライブラリを使ったので紹介したいと思います。

github.com

featherではMastodonのHTML形式の投稿をMarkdownに変換してから表示しています。

今回追加した特定の文字数以上の投稿を折りたたむ機能では、リンクのdestinationやMarkdown記法を考慮して文字数をカウントする必要があります。

例えば、text [link](https://example.com) のような投稿があった場合、リンクの[]や()の中身の文字数は無視する必要があります。この処理を自前で作るのは非常に大変なため、Swift Markdownを利用しました。

Swift Markdownの基本的な使い方

基本的な使い方は、Snippetsのページを参照するとわかりやすいでしょう。

Markdownの解析や何らかの集計には MarkupWalker、内容の書き換えには MarkupRewriter を使います。

今回featherでは、任意の文字数以降の要素を非表示にしたかったので MarkupRewriter を使って指定した文字数以上のコンテンツを全て削除するような処理を作りました。

具体的なコード

MarkupWalkerMarkupRewriter を使った具体的なコードを示したいと思います。

以下は記号などを削除してmarkdownのレンダリング結果の文字だけを作りたいときの例です。この場合は要素の書き換えは必要ないので MarkupWalker を使います。

descendInto を呼ばないと子要素の解析が行われないので注意が必要です。逆に子要素の解析が必要ないときは呼ばないことでスキップさせることができます。

struct Visitor: MarkupWalker {
    var string: String = ""
    mutating func visitText(_ text: Text) {
        string += text.string
    }

    mutating func visitLink(_ link: Link) {
        string += link.plainText
    }

    mutating func visitSoftBreak(_: SoftBreak) {
        string += "\n"
    }

    mutating func visitParagraph(_ paragraph: Paragraph) {
        if paragraph.indexInParent > 0 {
            string += "\n\n"
        }
        // Paragraphの中身を解析
        descendInto(paragraph)
    }
}

以下は limit 文字数以降の要素を削除する処理です。要素を編集するので MarkupRewriter を使います。

"visitLink" の中ではLinkの中のTextの操作はせず "defaultVisit" を呼んであげることで "visitText" に処理を任せることができます。


struct Trimmer: MarkupRewriter {
    let limit: Int
    var count: Int = 0

    init(limit: Int) {
        self.limit = limit
    }

    mutating func visitText(_ text: Text) -> Markup? {
        guard count <= limit else { return nil }
        defer { count += text.string.count }
        let remainCount = limit - count
        guard remainCount < text.string.count else { return text }
        // 表示可能な残り文字数だけ表示してあげる
        var text = text
        text.string = String(text.string.prefix(remainCount))
        return text
    }

    mutating func visitLink(_ link: Link) -> Markup? {
        // リミット超えている場合はリンクごと消す
        guard count < limit else { return nil }
        return defaultVisit(link)
    }

    mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Markup? {
        defer {
            count += 1
        }
        return count < limit ? softBreak : nil
    }

    mutating func visitParagraph(_ paragraph: Paragraph) -> Markup? {
        if paragraph.indexInParent > 0 {
            count += 2 // \n\n
        }
        return count < limit ? defaultVisit(paragraph) : nil
    }
}

※ Mastodonの仕様に合わせて全てのMarkdownの要素について解析しているわけではなく、リンクなど一部の要素のみを考慮しています。

解析した済みの Document を文字列に戻す

以下は limit の文字数にトリムしたものを文字列として返す String の extensionです。format() を呼ぶことで文字列に変換できます。

そのさいオプションを渡すことができるので、 例えばリストの先頭の記号の "-" OR "*" を選んだりできます。

extension String {
    func markdownPrefix(_ limit: Int) -> String {
        let document = Document(parsing: self)
        var trimmer = Trimmer(limit: limit)
        // 失敗したらそのまま返す
        guard let newDocument = trimmer.visit(document) as? Document else { return self }
        return newDocument.format()
    }
}

まとめ

Swift Markdownを利用することで、簡単にMarkdownを操作することができました。

Markdown を解析したり内容をいじったりする必要があるときはぜひご利用ください。