2026-10-16 23:30:57 -07:00
|
|
|
|
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])
|
2026-10-17 08:53:21 -07:00
|
|
|
|
case mark(MDMark, [MDInline])
|
2026-10-16 23:30:57 -07:00
|
|
|
|
case lineBreak
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 08:53:21 -07:00
|
|
|
|
enum MDMark: Sendable {
|
|
|
|
|
|
case ok, warn, danger
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
private let boldWrap = Re(#"^(\*\*|__)(.*)\1$"#)
|
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
|
struct MDItem: Sendable {
|
|
|
|
|
|
var choice: String?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
var choiceIndex = 0
|
|
|
|
|
|
var raw = ""
|
2026-10-16 23:30:57 -07:00
|
|
|
|
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(inline), rows: rows.dropFirst().map { $0.map(inline) }))
|
|
|
|
|
|
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 {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
var out = MDItem(raw: body[0])
|
2026-10-16 23:30:57 -07:00
|
|
|
|
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] {
|
2026-10-17 08:53:21 -07:00
|
|
|
|
let t = trimmed(src)
|
|
|
|
|
|
if let mark = marks[trimmed(boldWrap.exec(t)?[2] ?? t)] {
|
|
|
|
|
|
return [.mark(mark, spans(src))]
|
|
|
|
|
|
}
|
|
|
|
|
|
return spans(src)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func spans(_ src: String) -> [MDInline] {
|
2026-10-16 23:30:57 -07:00
|
|
|
|
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] {
|
2026-10-17 08:53:21 -07:00
|
|
|
|
out.append(.strong(spans(strong)))
|
2026-10-16 23:30:57 -07:00
|
|
|
|
} else if let em = g[4] ?? g[5] {
|
2026-10-17 08:53:21 -07:00
|
|
|
|
out.append(.em(spans(em)))
|
2026-10-16 23:30:57 -07:00
|
|
|
|
} else if let alt = g[6] {
|
|
|
|
|
|
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
|
|
|
|
|
|
} else if let text = g[9] {
|
2026-10-17 08:53:21 -07:00
|
|
|
|
out.append(.link(href: g[10]!, spans(text)))
|
2026-10-16 23:30:57 -07:00
|
|
|
|
} else if let bare = g[11] {
|
|
|
|
|
|
out.append(.link(href: bare, [.text(bare)]))
|
|
|
|
|
|
}
|
|
|
|
|
|
rest = String(rest[range.upperBound...])
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|