370 lines
12 KiB
Swift
370 lines
12 KiB
Swift
import Foundation
|
||
|
||
indirect enum MDInline: Sendable {
|
||
case text(String)
|
||
case code(String)
|
||
case strong([MDInline])
|
||
case em([MDInline])
|
||
case image(src: String, alt: String)
|
||
case link(href: String, [MDInline])
|
||
case mark(MDMark, [MDInline])
|
||
case lineBreak
|
||
}
|
||
|
||
enum MDMark: Sendable {
|
||
case ok, warn, danger, muted
|
||
}
|
||
|
||
private let marks: [String: MDMark] = [
|
||
"✓": .ok, "✔": .ok, "✔️": .ok, "✅": .ok, "☑": .ok, "☑️": .ok,
|
||
"~": .warn, "≈": .warn, "〜": .warn, "⚠": .warn, "⚠️": .warn, "🟡": .warn,
|
||
"x": .danger, "X": .danger, "×": .danger, "✗": .danger, "✘": .danger, "✕": .danger, "✖": .danger, "✖️": .danger, "❌": .danger,
|
||
"-": .muted, "–": .muted, "—": .muted, "−": .muted,
|
||
]
|
||
|
||
private let boldWrap = Re(#"^(\*\*|__)(.*)\1$"#)
|
||
private let leadToken = Re(#"^\s*(\S*)([\s\S]*)$"#)
|
||
|
||
struct MDItem: Sendable {
|
||
var choice: String?
|
||
var choiceIndex = 0
|
||
var raw = ""
|
||
var inline: [MDInline] = []
|
||
var blocks: [MDBlock] = []
|
||
}
|
||
|
||
indirect enum MDBlock: Sendable {
|
||
case paragraph([MDInline])
|
||
case heading(level: Int, [MDInline])
|
||
case rule
|
||
case quote([MDBlock])
|
||
case list(ordered: Bool, start: Int, items: [MDItem], grid: Bool)
|
||
case code(String)
|
||
case diff([String])
|
||
case details(summary: [MDInline], body: [MDBlock])
|
||
case diagram(String)
|
||
case table(head: [[MDInline]], rows: [[[MDInline]]])
|
||
}
|
||
|
||
struct Re: @unchecked Sendable {
|
||
let re: NSRegularExpression
|
||
|
||
init(_ pattern: String, caseless: Bool = false) {
|
||
re = try! NSRegularExpression(pattern: pattern, options: caseless ? [.caseInsensitive] : [])
|
||
}
|
||
|
||
func test(_ s: String) -> Bool {
|
||
re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil
|
||
}
|
||
|
||
func exec(_ s: String) -> [String?]? {
|
||
match(s)?.groups
|
||
}
|
||
|
||
func match(_ s: String) -> (range: Range<String.Index>, groups: [String?])? {
|
||
guard let m = re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) else {
|
||
return nil
|
||
}
|
||
let groups = (0..<m.numberOfRanges).map { i -> String? in
|
||
let r = m.range(at: i)
|
||
return r.location == NSNotFound ? nil : String(s[Range(r, in: s)!])
|
||
}
|
||
return (Range(m.range, in: s)!, groups)
|
||
}
|
||
}
|
||
|
||
private let choiceMarker = Re(#"^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$"#)
|
||
private let detailsOpen = Re(#"^\s*<details>\s*(?:<summary>(.*?)</summary>)?\s*$"#, caseless: true)
|
||
private let summaryLine = Re(#"^\s*<summary>(.*?)</summary>\s*$"#, caseless: true)
|
||
private let detailsClose = Re(#"^\s*</details>\s*$"#, caseless: true)
|
||
private let fence = Re(#"^\s*(```|~~~)\s*(\S*)"#)
|
||
private let heading = Re(#"^(#{1,6})\s+(.*)$"#)
|
||
private let bullet = Re(#"^(\s*)[-*+]\s+(.*)$"#)
|
||
private let ordered = Re(#"^(\s*)(\d+)[.)]\s+(.*)$"#)
|
||
private let quote = Re(#"^\s*>\s?(.*)$"#)
|
||
private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#)
|
||
private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#)
|
||
private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#)
|
||
private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![A-Za-z0-9_])_([^_\s][^_]*?)_(?![A-Za-z0-9_])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#)
|
||
|
||
func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] {
|
||
let text = src.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||
return blocks(text.components(separatedBy: "\n"), choices)
|
||
}
|
||
|
||
func parseInline(_ src: String) -> [MDInline] {
|
||
inline(src)
|
||
}
|
||
|
||
private func trimStart(_ s: String) -> Substring {
|
||
s.drop { $0 == " " || $0 == "\t" }
|
||
}
|
||
|
||
private func blocks(_ lines: [String], _ choices: Bool) -> [MDBlock] {
|
||
var out: [MDBlock] = []
|
||
var i = 0
|
||
|
||
while i < lines.count {
|
||
let line = lines[i]
|
||
|
||
if trimmed(line).isEmpty {
|
||
i += 1
|
||
continue
|
||
}
|
||
|
||
if let f = fence.exec(line) {
|
||
let marker = f[1]!
|
||
var body: [String] = []
|
||
i += 1
|
||
while i < lines.count && !trimStart(lines[i]).hasPrefix(marker) {
|
||
body.append(lines[i])
|
||
i += 1
|
||
}
|
||
i += 1
|
||
let lang = f[2]!.lowercased()
|
||
if lang == "mermaid" {
|
||
out.append(.diagram(body.joined(separator: "\n")))
|
||
continue
|
||
}
|
||
if lang == "diff" || lang == "patch" {
|
||
out.append(.diff(body))
|
||
continue
|
||
}
|
||
out.append(.code(body.joined(separator: "\n")))
|
||
continue
|
||
}
|
||
|
||
if detailsClose.test(line) {
|
||
i += 1
|
||
continue
|
||
}
|
||
|
||
if let d = detailsOpen.exec(line) {
|
||
var title = d[1]
|
||
i += 1
|
||
if title == nil && i < lines.count, let s = summaryLine.exec(lines[i]) {
|
||
title = s[1]
|
||
i += 1
|
||
}
|
||
var body: [String] = []
|
||
var depth = 1
|
||
while i < lines.count {
|
||
let l = lines[i]
|
||
i += 1
|
||
if detailsOpen.test(l) {
|
||
depth += 1
|
||
} else if detailsClose.test(l) {
|
||
depth -= 1
|
||
if depth == 0 {
|
||
break
|
||
}
|
||
}
|
||
body.append(l)
|
||
}
|
||
let summary = title.map(trimmed) ?? ""
|
||
out.append(.details(summary: inline(summary.isEmpty ? "Details" : summary), body: blocks(body, choices)))
|
||
continue
|
||
}
|
||
|
||
if let hd = heading.exec(line) {
|
||
out.append(.heading(level: min(hd[1]!.count + 2, 6), inline(hd[2]!)))
|
||
i += 1
|
||
continue
|
||
}
|
||
|
||
if rule.test(line) {
|
||
out.append(.rule)
|
||
i += 1
|
||
continue
|
||
}
|
||
|
||
if quote.test(line) {
|
||
var body: [String] = []
|
||
while i < lines.count, let q = quote.exec(lines[i]) {
|
||
body.append(q[1]!)
|
||
i += 1
|
||
}
|
||
out.append(.quote(blocks(body, false)))
|
||
continue
|
||
}
|
||
|
||
if bullet.test(line) || ordered.test(line) {
|
||
let (list, next) = parseList(lines, i, choices)
|
||
out.append(list)
|
||
i = next
|
||
continue
|
||
}
|
||
|
||
if line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1]) {
|
||
var rows: [[String]] = [cells(line)]
|
||
i += 2
|
||
while i < lines.count && lines[i].contains("|") && !trimmed(lines[i]).isEmpty {
|
||
rows.append(cells(lines[i]))
|
||
i += 1
|
||
}
|
||
out.append(.table(head: rows[0].map(cellInline), rows: rows.dropFirst().map { $0.map(cellInline) }))
|
||
continue
|
||
}
|
||
|
||
var para: [String] = []
|
||
while i < lines.count && !trimmed(lines[i]).isEmpty && !startsBlock(lines, i) {
|
||
para.append(trimmed(lines[i]))
|
||
i += 1
|
||
}
|
||
out.append(.paragraph(joinLines(para)))
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
private func startsBlock(_ lines: [String], _ i: Int) -> Bool {
|
||
let line = lines[i]
|
||
return fence.test(line) || detailsOpen.test(line) || detailsClose.test(line) || heading.test(line) || rule.test(line) || quote.test(line) || bullet.test(line) || ordered.test(line)
|
||
|| (line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1]))
|
||
}
|
||
|
||
private func joinLines(_ lines: [String]) -> [MDInline] {
|
||
var out: [MDInline] = []
|
||
for (n, l) in lines.enumerated() {
|
||
if n > 0 {
|
||
out.append(.lineBreak)
|
||
}
|
||
out += inline(l)
|
||
}
|
||
return out
|
||
}
|
||
|
||
private func indentOf(_ line: String) -> Int {
|
||
line.count - trimStart(line).count
|
||
}
|
||
|
||
private func parseList(_ lines: [String], _ start: Int, _ choices: Bool) -> (MDBlock, Int) {
|
||
let first = lines[start]
|
||
let isOrdered = !bullet.test(first)
|
||
let base = indentOf(first)
|
||
let startNumber = isOrdered ? Int(ordered.exec(first)![2]!) ?? 1 : 1
|
||
let marker = isOrdered ? ordered : bullet
|
||
|
||
var items: [MDItem] = []
|
||
var i = start
|
||
var imageChoices = !isOrdered && choices
|
||
while i < lines.count {
|
||
let line = lines[i]
|
||
guard let m = marker.exec(line), indentOf(line) == base else {
|
||
break
|
||
}
|
||
let text = isOrdered ? m[3]! : m[2]!
|
||
var body = [text]
|
||
i += 1
|
||
while i < lines.count {
|
||
let next = lines[i]
|
||
if trimmed(next).isEmpty {
|
||
if i + 1 < lines.count, indentOf(lines[i + 1]) > base, !trimmed(lines[i + 1]).isEmpty {
|
||
body.append("")
|
||
i += 1
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
if indentOf(next) <= base && (bullet.test(next) || ordered.test(next) || startsBlock(lines, i)) {
|
||
break
|
||
}
|
||
body.append(indentOf(next) > base ? String(next.dropFirst(min(indentOf(next), base + 2))) : trimmed(next))
|
||
i += 1
|
||
}
|
||
let detail = body.dropFirst().filter { !trimmed($0).isEmpty }
|
||
imageChoices = imageChoices && choiceMarker.test(text) && detail.count == 1 && imageOnly.test(detail.first!)
|
||
items.append(item(body, isOrdered ? false : choices))
|
||
while i < lines.count && trimmed(lines[i]).isEmpty && i + 1 < lines.count && indentOf(lines[i + 1]) == base && marker.test(lines[i + 1]) {
|
||
i += 1
|
||
}
|
||
}
|
||
return (.list(ordered: isOrdered, start: startNumber, items: items, grid: imageChoices), i)
|
||
}
|
||
|
||
private func item(_ body: [String], _ choices: Bool) -> MDItem {
|
||
var out = MDItem(raw: body[0])
|
||
if choices, let m = choiceMarker.exec(body[0]) {
|
||
out.choice = m[2]
|
||
}
|
||
let first = out.choice != nil ? [] : [body[0]]
|
||
let rest = Array(body.dropFirst())
|
||
if rest.indices.allSatisfy({ !startsBlock(body, $0 + 1) }) && !rest.contains("") {
|
||
out.inline = joinLines((first + rest).map(trimmed))
|
||
return out
|
||
}
|
||
var head = first
|
||
var k = 1
|
||
while k < body.count && body[k] != "" && !startsBlock(body, k) {
|
||
head.append(trimmed(body[k]))
|
||
k += 1
|
||
}
|
||
out.inline = joinLines(head)
|
||
out.blocks = blocks(Array(body[k...]), choices)
|
||
return out
|
||
}
|
||
|
||
private func cells(_ line: String) -> [String] {
|
||
var s = Substring(trimmed(line))
|
||
if s.hasPrefix("|") {
|
||
s = s.dropFirst()
|
||
}
|
||
if s.hasSuffix("|") {
|
||
s = s.dropLast()
|
||
}
|
||
return s.split(separator: "|", omittingEmptySubsequences: false).map { trimmed(String($0)) }
|
||
}
|
||
|
||
private func inline(_ src: String) -> [MDInline] {
|
||
if let mark = markOf(src) {
|
||
return [.mark(mark, spans(src))]
|
||
}
|
||
return spans(src)
|
||
}
|
||
|
||
private func markOf(_ src: String) -> MDMark? {
|
||
let t = trimmed(src)
|
||
return marks[trimmed(boldWrap.exec(t)?[2] ?? t)]
|
||
}
|
||
|
||
private func cellInline(_ src: String) -> [MDInline] {
|
||
if markOf(src) != nil {
|
||
return inline(src)
|
||
}
|
||
let g = leadToken.exec(src)!
|
||
let lead = g[1] ?? "", rest = g[2] ?? ""
|
||
guard let mark = markOf(lead) else {
|
||
return spans(src)
|
||
}
|
||
return [.mark(mark, spans(lead))] + spans(rest)
|
||
}
|
||
|
||
private func spans(_ src: String) -> [MDInline] {
|
||
var out: [MDInline] = []
|
||
var rest = src
|
||
while !rest.isEmpty {
|
||
guard let (range, g) = inlineToken.match(rest) else {
|
||
out.append(.text(rest))
|
||
break
|
||
}
|
||
if range.lowerBound > rest.startIndex {
|
||
out.append(.text(String(rest[..<range.lowerBound])))
|
||
}
|
||
if let code = g[1] {
|
||
out.append(.code(code))
|
||
} else if let strong = g[2] ?? g[3] {
|
||
out.append(.strong(spans(strong)))
|
||
} else if let em = g[4] ?? g[5] {
|
||
out.append(.em(spans(em)))
|
||
} else if let alt = g[6] {
|
||
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
|
||
} else if let text = g[9] {
|
||
out.append(.link(href: g[10]!, spans(text)))
|
||
} else if let bare = g[11] {
|
||
out.append(.link(href: bare, [.text(bare)]))
|
||
}
|
||
rest = String(rest[range.upperBound...])
|
||
}
|
||
return out
|
||
}
|