import AppKit import Foundation /// A deliberately small Markdown renderer/parser. Enough for the formats the /// app offers (headings, emphasis, code, lists, quotes, links, rules, images) /// without pulling in a dependency. enum Markdown { // MARK: - Markdown → HTML static func html(from markdown: String, title: String = "Document") -> String { let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") var body: [String] = [] var paragraph: [String] = [] var listType: String? var inCode = false var codeLines: [String] = [] func flushParagraph() { guard !paragraph.isEmpty else { return } body.append("
" + inline(paragraph.joined(separator: " ")) + "
") paragraph = [] } func closeList() { if let type = listType { body.append("\(type)>") listType = nil } } for rawLine in lines { let line = rawLine let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") { if inCode { body.append("" + escape(codeLines.joined(separator: "\n")) + "")
codeLines = []
inCode = false
} else {
flushParagraph()
closeList()
inCode = true
}
continue
}
if inCode {
codeLines.append(line)
continue
}
if trimmed.isEmpty {
flushParagraph()
closeList()
continue
}
if let heading = headingLevel(trimmed) {
flushParagraph()
closeList()
let text = trimmed.dropFirst(heading).trimmingCharacters(in: .whitespaces)
body.append("") continue } if let item = listItem(trimmed) { flushParagraph() if listType != item.type { closeList() listType = item.type body.append("<\(item.type)>") } body.append("" + inline(text) + "
" + escape(codeLines.joined(separator: "\n")) + "")
}
flushParagraph()
closeList()
return """
\($0[1])" }
text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "\($0[1])" }
text = replace(#"__([^_]+)__"#, in: text) { "\($0[1])" }
text = replace(#"(?\($0[1])" }
text = replace(#"(?\($0[1])" }
text = replace(#"~~([^~]+)~~"#, in: text) { "]*>(.*?)"#, in: text) { match in "\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n" } text = replace(#"(?is)
]*>(.*?)"#, in: text) { match in "\n\n" + stripTags(match[1]).trimmed .split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n" } text = replace(#"(?is)
]*>"#, in: text) { _ in "\n\n" }
text = replace(#"(?is)"#, in: text) { _ in "\n" }
// Inline.
text = replace(#"(?is)<(strong|b)[^>]*>(.*?)\1>"#, in: text) { "**\(stripTags($0[2]).trimmed)**" }
text = replace(#"(?is)<(em|i)[^>]*>(.*?)\1>"#, in: text) { "*\(stripTags($0[2]).trimmed)*" }
text = replace(#"(?is)]*>(.*?)"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" }
text = replace(#"(?is)]*>(.*?)"#, in: text) { "`\(stripTags($0[1]))`" }
text = replace(#"(?is)]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) {
"![\($0[1])](\($0[2]))"
}
text = replace(#"(?is)
]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in ")" }
text = replace(#"(?is)]*href=["']([^"']+)["'][^>]*>(.*?)"#, in: text) {
"[\(stripTags($0[2]).trimmed)](\($0[1]))"
}
text = stripTags(text)
text = decodeEntities(text)
// Collapse the whitespace the block replacements left behind.
text = replace(#"[ \t]+\n"#, in: text) { _ in "\n" }
text = replace(#"\n{3,}"#, in: text) { _ in "\n\n" }
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
}
// MARK: - Helpers
static func escape(_ string: String) -> String {
string
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
}
static func stripTags(_ html: String) -> String {
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
}
static func decodeEntities(_ text: String) -> String {
var result = text
let map = [
" ": " ", "&": "&", "<": "<", ">": ">", """: "\"",
"'": "'", "'": "'", "—": "—", "–": "–", "…": "…",
"’": "’", "‘": "‘", "“": "“", "”": "”"
]
for (entity, value) in map {
result = result.replacingOccurrences(of: entity, with: value)
}
// Numeric entities.
result = replace(#"(\d+);"#, in: result) { match in
guard let code = UInt32(match[1]), let scalar = Unicode.Scalar(code) else { return match[0] }
return String(Character(scalar))
}
return result
}
/// Regex replacement where the closure receives the full match plus groups.
static func replace(_ pattern: String, in string: String, _ transform: ([String]) -> String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return string }
let range = NSRange(string.startIndex..., in: string)
let matches = regex.matches(in: string, range: range)
guard !matches.isEmpty else { return string }
var output = string
for match in matches.reversed() {
guard let matchRange = Range(match.range, in: output) else { continue }
var groups: [String] = []
for index in 0..