Sapling/Sources/SaplingRenderer/MarkdownRenderer.swift

124 lines
4.2 KiB
Swift

import Foundation
public protocol MarkdownRendering: Sendable {
func blocks(for markdown: String) -> [RenderedMarkdownBlock]
func inlineMarkdown(for source: String) -> AttributedString
}
public enum RenderedMarkdownBlock: Identifiable, Hashable, Sendable {
case heading(id: UUID, level: Int, text: String)
case paragraph(id: UUID, text: AttributedString)
case codeBlock(id: UUID, language: String?, code: String)
case task(id: UUID, checked: Bool, text: AttributedString)
case image(id: UUID, altText: String, source: String)
case blank(id: UUID)
public var id: UUID {
switch self {
case .heading(let id, _, _): id
case .paragraph(let id, _): id
case .codeBlock(let id, _, _): id
case .task(let id, _, _): id
case .image(let id, _, _): id
case .blank(let id): id
}
}
}
public struct MarkdownRenderer: MarkdownRendering {
public init() {}
public func blocks(for markdown: String) -> [RenderedMarkdownBlock] {
var blocks: [RenderedMarkdownBlock] = []
var codeLines: [String] = []
var codeLanguage: String?
var isInCodeBlock = false
for line in markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if isInCodeBlock {
blocks.append(.codeBlock(id: UUID(), language: codeLanguage, code: codeLines.joined(separator: "\n")))
codeLines.removeAll()
codeLanguage = nil
isInCodeBlock = false
} else {
isInCodeBlock = true
codeLanguage = String(line.dropFirst(3)).nilIfEmpty
}
continue
}
if isInCodeBlock {
codeLines.append(line)
continue
}
blocks.append(block(for: line))
}
if isInCodeBlock {
blocks.append(.codeBlock(id: UUID(), language: codeLanguage, code: codeLines.joined(separator: "\n")))
}
return blocks
}
public func inlineMarkdown(for source: String) -> AttributedString {
(try? AttributedString(markdown: source)) ?? AttributedString(source)
}
private func block(for line: String) -> RenderedMarkdownBlock {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else {
return .blank(id: UUID())
}
if let heading = parseHeading(trimmed) {
return .heading(id: UUID(), level: heading.level, text: heading.text)
}
if let task = parseTask(trimmed) {
return .task(id: UUID(), checked: task.checked, text: inlineMarkdown(for: task.text))
}
if let image = parseImage(trimmed) {
return .image(id: UUID(), altText: image.altText, source: image.source)
}
return .paragraph(id: UUID(), text: inlineMarkdown(for: line))
}
private func parseHeading(_ line: String) -> (level: Int, text: String)? {
let markerCount = line.prefix { $0 == "#" }.count
guard (1...6).contains(markerCount), line.dropFirst(markerCount).first == " " else {
return nil
}
return (markerCount, String(line.dropFirst(markerCount + 1)))
}
private func parseTask(_ line: String) -> (checked: Bool, text: String)? {
if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") {
return (true, String(line.dropFirst(6)))
}
if line.hasPrefix("- [ ] ") {
return (false, String(line.dropFirst(6)))
}
return nil
}
private func parseImage(_ line: String) -> (altText: String, source: String)? {
guard line.hasPrefix("!["), let closeAlt = line.firstIndex(of: "]") else { return nil }
let afterAlt = line[line.index(after: closeAlt)...]
guard afterAlt.hasPrefix("("), afterAlt.hasSuffix(")") else { return nil }
let alt = String(line[line.index(line.startIndex, offsetBy: 2)..<closeAlt])
let source = String(afterAlt.dropFirst().dropLast())
return (alt, source)
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}