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("") 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("" + inline(text) + "") continue } if trimmed == "---" || trimmed == "***" || trimmed == "___" { flushParagraph() closeList() body.append("
") continue } if trimmed.hasPrefix(">") { flushParagraph() closeList() let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) body.append("

" + inline(text) + "

") continue } if let item = listItem(trimmed) { flushParagraph() if listType != item.type { closeList() listType = item.type body.append("<\(item.type)>") } body.append("
  • " + inline(item.text) + "
  • ") continue } if trimmed.hasPrefix("|") && trimmed.hasSuffix("|") { flushParagraph() closeList() let cells = trimmed.dropFirst().dropLast().split(separator: "|").map { inline($0.trimmingCharacters(in: .whitespaces)) } if !cells.allSatisfy({ $0.allSatisfy { $0 == "-" || $0 == ":" } }) { body.append("" + cells.map { "\($0)" }.joined() + "") } continue } paragraph.append(trimmed) } if inCode { body.append("
    " + escape(codeLines.joined(separator: "\n")) + "
    ") } flushParagraph() closeList() return """ \(escape(title)) \(body.joined(separator: "\n")) """ } private static func headingLevel(_ line: String) -> Int? { let hashes = line.prefix { $0 == "#" } guard (1...6).contains(hashes.count) else { return nil } let rest = line.dropFirst(hashes.count) guard rest.isEmpty || rest.first == " " else { return nil } return hashes.count } private static func listItem(_ line: String) -> (type: String, text: String)? { for marker in ["- ", "* ", "+ "] where line.hasPrefix(marker) { return ("ul", String(line.dropFirst(2))) } let digits = line.prefix { $0.isNumber } if !digits.isEmpty, line.dropFirst(digits.count).hasPrefix(". ") { return ("ol", String(line.dropFirst(digits.count + 1))) } return nil } /// Inline spans: bold, italic, strikethrough, code, links, images. static func inline(_ raw: String) -> String { var text = escape(raw) text = replace(#"!\[([^\]]*)\]\(([^)]+)\)"#, in: text) { match in "\"\(match[1])\"/" } text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in "\(match[1])" } text = replace(#"`([^`]+)`"#, in: text) { "\($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) { "\($0[1])" } return text } // MARK: - HTML → Markdown static func markdown(fromHTML html: String) -> String { var text = html // Drop anything that cannot be represented as text. text = replace(#"(?is)<(head|script|style|noscript)[^>]*>.*?"#, in: text) { _ in "" } text = replace(#"(?is)"#, in: text) { _ in "" } // Block level first. for level in (1...6).reversed() { text = replace(#"(?is)]*>(.*?)"#, in: text) { match in "\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n" } } text = replace(#"(?is)]*>(.*?)"#, 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) { match in "\n- " + stripTags(match[1]).trimmed } text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" } text = replace(#"(?is)"#, in: text) { _ in "\n" } text = replace(#"(?is)]*/?>"#, in: text) { _ in "\n\n---\n\n" } text = replace(#"(?is)]*>(.*?)"#, in: text) { match in " " + stripTags(match[1]).trimmed + " |" } text = replace(#"(?is)]*>"#, in: text) { _ in "\n|" } text = replace(#"(?is)]*/?>"#, in: text) { _ in " \n" } text = replace(#"(?is)

    "#, in: text) { _ in "\n\n" } text = replace(#"(?is)]*>"#, in: text) { _ in "\n\n" } text = replace(#"(?is)"#, in: text) { _ in "\n" } // Inline. text = replace(#"(?is)<(strong|b)[^>]*>(.*?)"#, in: text) { "**\(stripTags($0[2]).trimmed)**" } text = replace(#"(?is)<(em|i)[^>]*>(.*?)"#, 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 "![](\(match[1]))" } 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..