Swift Digest

markdown のリストアイテムデリミタ

Markdown List Item Delimiters

Proposal
SF-0025
Authors
Jeremy Schonfeld
Status
Pitch

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Markdown には bullet list(unordered list)と ordered list の 2 種類があり、それぞれの項目はリストアイテムマーカーで始まります。bullet list のマーカーは -+* のいずれかで、ordered list のマーカーは数字に続く . または ) です。同じリストの中ではすべての項目が同じマーカーで揃っている必要があります。

Foundation で markdown から AttributedString を作ると、リスト項目の情報は PresentationIntent として表現されます。ordered list の各項目では、PresentationIntent.Kind.listItem(ordinal:) を通して何番目の項目かを取得できますが、実際にどのデリミタ(.)、あるいは - / + / *)が使われていたか という情報は取り出せませんでした。

let attrStr = try AttributedString(markdown: /* ... */)

for (intent, range) in attrStr.runs[\.presentationIntent] {
    guard let component = intent.components.last else { continue }

    switch component.kind {
    case .listItem(let ordinal):
        // ordinal は取れるが、`.` か `)` か、`-` か `+` か `*` かは分からない
        break
    default:
        break
    }
}

そのため、markdown を解析して AttributedString 化したものをレンダリングするビューが、元のソーステキストと同じ(あるいは近い)マーカーを使ってリストを描画したい、という要件を満たせませんでした。

02 どのように解決されるのか

AttributedString に、リスト項目のデリミタ文字を保持する新しい属性 listItemDelimiter が追加されます。値は Character で、ordered list なら . または )、unordered list なら - / + / * のいずれかになります。

let attrStr = try AttributedString(markdown: /* ... some markdown string ... */)

for (intent, range) in attrStr.runs[\.presentationIntent] {
    guard let component = intent.components.last else { continue }

    switch component.kind {
    case .listItem(let ordinal):
        let isOrdered = intent.components[intent.components.count - 2] == .orderedList
        let listItemDelimiter = attrStr[range].listItemDelimiter
        // isOrdered, ordinal, listItemDelimiter を使ってリスト項目を描画する
        _ = (isOrdered, ordinal, listItemDelimiter)
    default:
        break
    }
}

listItemDelimiterpresentationIntent と組み合わせて使う属性です。presentationIntent から「ordered か unordered か」「何番目の項目か」を読み取り、listItemDelimiter から「どの記号が使われていたか」を読み取る、という役割分担になっています。

属性の定義

属性は AttributeScopes.FoundationAttributes に追加され、FoundationPreview 6.2 から利用できます。

extension AttributeScopes.FoundationAttributes {
    @available(FoundationPreview 6.2, *)
    public let listItemDelimiter: ListItemDelimiterAttribute

    @frozen
    @available(FoundationPreview 6.2, *)
    public enum ListItemDelimiterAttribute: AttributedStringKey, CodableAttributedStringKey, ObjectiveCConvertibleAttributedStringKey {
        public typealias Value = Character
        public static let name = "NSListItemDelimiter"
    }
}

@available(*, unavailable)
extension AttributeScopes.FoundationAttributes.ListItemDelimiterAttribute: Sendable {}

値の型に Character を使っているのは、cmark の仕様上このデリミタが必ず単一の ASCII 文字になることが保証されており、かつ StringAttributedString.CharacterView と組み合わせて扱う際に最も自然だからです。

既存の PresentationIntent.Kind を拡張しなかった理由

ordered list / unordered list のデリミタは概念的には PresentationIntent.KindunorderedList / orderedList ケースに乗せたい情報ですが、既存の enum ケースに associated value を追加するのはソース互換性を壊すため、そのアプローチは採用されていません。新しいケースを別に用意する方法も、既存ケースとの使い分けが分かりにくくなり、markdown のパース結果として両方を同時に返すこともできない、といった理由で見送られています。

そのため、デリミタの情報は PresentationIntent 側ではなく独立した AttributedString 属性として表現する形になっています。