markdown のリストアイテムデリミタ
Markdown List Item Delimiters
このダイジェストは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
}
}
listItemDelimiter は presentationIntent と組み合わせて使う属性です。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 文字になることが保証されており、かつ String や AttributedString.CharacterView と組み合わせて扱う際に最も自然だからです。
既存の PresentationIntent.Kind を拡張しなかった理由
ordered list / unordered list のデリミタは概念的には PresentationIntent.Kind の unorderedList / orderedList ケースに乗せたい情報ですが、既存の enum ケースに associated value を追加するのはソース互換性を壊すため、そのアプローチは採用されていません。新しいケースを別に用意する方法も、既存ケースとの使い分けが分かりにくくなり、markdown のパース結果として両方を同時に返すこともできない、といった理由で見送られています。
そのため、デリミタの情報は PresentationIntent 側ではなく独立した AttributedString 属性として表現する形になっています。