125 lines
4.2 KiB
Swift
125 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
|
||
|
|
}
|
||
|
|
}
|