Files
byre/mac/Sources/Byre/Markdown.swift
T

352 lines
12 KiB
Swift
Raw Normal View History

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
}