krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Export/MarkdownExport.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/OrgCore/Export/MarkdownExport.swift history · blame · raw

282 lines · 13734 bytes

  1import Foundation
  2
  3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and
  4/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
  5public enum MarkdownExport {
  6    public static func document(_ text: String, settings: OrgSettings = .default) -> String {
  7        let renderer = MarkdownRenderer(settings: settings)
  8        var out = ""
  9        if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
 10        out += renderer.document(text)
 11        return out.trimmingCharacters(in: .newlines) + "\n"
 12    }
 13}
 14
 15final class MarkdownRenderer {
 16    let settings: OrgSettings
 17    private var definitions: [String: String] = [:]
 18    private var numbers: [String: Int] = [:]
 19    private var notes: [(Int, String)] = []
 20    private var anonymous = 0
 21
 22    init(settings: OrgSettings) {
 23        self.settings = settings
 24    }
 25
 26    func document(_ text: String) -> String {
 27        let tree = OrgParser.parse(text, defaults: settings)
 28        for node in tree.root.descendants() where node.kind == .footnoteDefinition {
 29            if let m = node.text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
 30                definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
 31            }
 32        }
 33        var out = blocks(tree.root)
 34        if !notes.isEmpty {
 35            out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
 36        }
 37        return out
 38    }
 39
 40    func inlineString(_ text: String) -> String {
 41        let tree = OrgParser.parse(text, defaults: settings)
 42        guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text }
 43        return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
 44    }
 45
 46    private var lastExports = "code"
 47    private var results = false
 48
 49    private func blocks(_ node: SyntaxNode, indent: String = "") -> String {
 50        let parts = node.children.map { child -> String in
 51            // Results show for `:exports results` and `both`, as in the HTML export.
 52            if child.kind == .affiliatedKeyword {
 53                if child.text.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+RESULTS") { results = true }
 54                return ""
 55            }
 56            defer { results = false }
 57            if results {
 58                defer { lastExports = "code" }
 59                if lastExports != "results", lastExports != "both" { return "" }
 60            }
 61            if child.kind == .block, let exports = HTMLRenderer.srcExports(child) {
 62                lastExports = exports
 63                if exports == "results" || exports == "none" { return "" }
 64            }
 65            return element(child, indent: indent)
 66        }.filter { !$0.isEmpty }
 67        return parts.isEmpty ? "" : parts.joined(separator: "\n\n") + "\n\n"
 68    }
 69
 70    private func blocks(text: String) -> String {
 71        blocks(OrgParser.parse(text, defaults: settings).root)
 72    }
 73
 74    private func element(_ node: SyntaxNode, indent: String) -> String {
 75        switch node.kind {
 76        case .zerothSection, .section:
 77            return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
 78        case .heading:
 79            let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
 80            var parts: [String] = []
 81            if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) }
 82            if let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
 83            if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
 84            if let tags = node.tokens.first(where: { $0.kind == .tags })?.text {
 85                parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
 86            }
 87            return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
 88        case .paragraph:
 89            return indent + inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
 90                .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + indent)
 91        case .plainList:
 92            return list(node, indent: indent)
 93        case .table:
 94            return table(node, indent: indent)
 95        case .block:
 96            return block(node, indent: indent)
 97        case .horizontalRule:
 98            return indent + "---"
 99        case .fixedWidth:
100            let lines = node.text.split(separator: "\n").map { line -> String in
101                let trimmed = line.drop { $0 == " " || $0 == "\t" }
102                return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
103            }
104            return fence(lines, language: "", indent: indent)
105        default:
106            return ""
107        }
108    }
109
110    private func fence(_ lines: [String], language: String, indent: String) -> String {
111        let longest = lines.map { $0.prefix { $0 == "`" }.count }.max() ?? 0
112        let ticks = String(repeating: "`", count: max(3, longest + 1))
113        return ([indent + ticks + language] + lines.map { indent + $0 } + [indent + ticks]).joined(separator: "\n")
114    }
115
116    private func list(_ node: SyntaxNode, indent: String) -> String {
117        var out: [String] = []
118        var number = 0
119        for item in node.children where item.kind == .item {
120            let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
121            let ordered = bullet.first.map { $0.isNumber || $0.isLetter } ?? false
122            number += 1
123            var marker = ordered ? "\(number)." : "-"
124            if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
125                marker += box == "[X]" || box == "[x]" ? " [x]" : " [ ]"
126            }
127            let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count)
128            var parts: [String] = []
129            for (index, child) in item.children.enumerated() {
130                if index == 0, child.kind == .paragraph {
131                    var text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
132                        .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent)
133                    if let separator = text.range(of: " :: ") {
134                        text = "**" + text[..<separator.lowerBound] + "**: " + text[separator.upperBound...]
135                    }
136                    parts.append(text)
137                } else {
138                    parts.append(element(child, indent: childIndent))
139                }
140            }
141            let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined()
142            out.append(indent + marker + " " + body)
143        }
144        return out.joined(separator: "\n")
145    }
146
147    private func table(_ node: SyntaxNode, indent: String) -> String {
148        var rows: [[String]] = []
149        var ruleAfter: Int?
150        for row in node.children where row.kind == .tableRow {
151            let cells = row.children.filter { $0.kind == .tableCell }
152            if cells.isEmpty {
153                if ruleAfter == nil, !rows.isEmpty { ruleAfter = rows.count }
154                continue
155            }
156            rows.append(cells.map { inline($0).trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "|", with: "\\|") })
157        }
158        guard !rows.isEmpty else { return "" }
159        let columns = rows.map(\.count).max() ?? 0
160        rows = rows.map { $0 + Array(repeating: "", count: columns - $0.count) }
161        var header: [String]
162        var body: [[String]]
163        if ruleAfter == 1 {
164            header = rows[0]
165            body = Array(rows.dropFirst())
166        } else {
167            header = Array(repeating: "", count: columns)
168            body = rows
169        }
170        func line(_ cells: [String]) -> String { indent + "| " + cells.joined(separator: " | ") + " |" }
171        return ([line(header), indent + "|" + Array(repeating: "---", count: columns).joined(separator: "|") + "|"] + body.map(line)).joined(separator: "\n")
172    }
173
174    private func block(_ node: SyntaxNode, indent: String) -> String {
175        var lines = node.text.components(separatedBy: "\n")
176        if lines.last == "" { lines.removeLast() }
177        guard let first = lines.first, let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
178        let type = m.1.lowercased()
179        let parameters = String(m.2)
180        var body = Array(lines.dropFirst().dropLast()).map {
181            $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression)
182        }
183        let common = body.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }.min() ?? 0
184        body = body.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
185        switch type {
186        case "src":
187            return fence(body, language: parameters.split(separator: " ").first.map(String.init) ?? "", indent: indent)
188        case "example":
189            return fence(body, language: "", indent: indent)
190        case "quote":
191            return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines)
192                .components(separatedBy: "\n").map { indent + ($0.isEmpty ? ">" : "> " + $0) }.joined(separator: "\n")
193        case "verse":
194            return body.map { indent + inlineString($0) + "  " }.joined(separator: "\n")
195        case "export":
196            let backend = parameters.lowercased()
197            return backend.hasPrefix("markdown") || backend.hasPrefix("md") || backend.hasPrefix("html") ? body.joined(separator: "\n") : ""
198        case "comment":
199            return ""
200        default:
201            return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines)
202        }
203    }
204
205    func inline(_ node: SyntaxNode) -> String {
206        var out = ""
207        for element in node.elements {
208            switch element {
209            case .token(let token):
210                switch token.kind {
211                case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority: continue
212                default: out += token.text
213                }
214            case .node(let child):
215                out += object(child)
216            }
217        }
218        return out
219    }
220
221    private func code(_ text: String) -> String {
222        let ticks = text.contains("`") ? "``" : "`"
223        return ticks + (ticks == "``" ? " " + text + " " : text) + ticks
224    }
225
226    private func object(_ node: SyntaxNode) -> String {
227        switch node.kind {
228        case .bold: return "**\(inline(node))**"
229        case .italic: return "*\(inline(node))*"
230        case .underline: return "<u>\(inline(node))</u>"
231        case .strikeThrough: return "~~\(inline(node))~~"
232        case .verbatim, .code: return code(inline(node))
233        case .link: return link(node)
234        case .linkDescription: return inline(node)
235        case .timestamp:
236            guard let stamp = Timestamp.parse(node.text) else { return node.text }
237            func show(_ p: Timestamp.Point) -> String {
238                String(format: "%04d-%02d-%02d", p.year, p.month, p.day) + (p.hour.map { String(format: " %02d:%02d", $0, p.minute ?? 0) } ?? "")
239            }
240            return show(stamp.start) + (stamp.end.map { "–" + show($0) } ?? "")
241        case .footnoteReference: return footnote(node.text)
242        case .superscript:
243            let body = node.text.dropFirst()
244            let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
245            return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
246        case .lineBreak: return "  \n"
247        case .target, .macro: return ""
248        default: return inline(node)
249        }
250    }
251
252    private func link(_ node: SyntaxNode) -> String {
253        guard let path = node.tokens.first(where: { $0.kind == .linkPath })?.text else {
254            var target = node.text
255            if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
256            return "<\(target)>"
257        }
258        var href = path
259        if path.hasPrefix("file:") { href = String(path.dropFirst(5)) }
260        if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) }
261        if !path.contains(":"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" }
262        if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
263        let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
264        if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
265        return path.contains("://") ? "<\(href)>" : "[\(href)](\(href))"
266    }
267
268    private func footnote(_ text: String) -> String {
269        guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return text }
270        var label = String(m.1)
271        if label.isEmpty {
272            anonymous += 1
273            label = "anonymous-\(anonymous)"
274        }
275        if let n = numbers[label] { return "[^\(n)]" }
276        guard let content = m.2.map(String.init) ?? definitions[label] else { return text }
277        let n = notes.count + 1
278        numbers[label] = n
279        notes.append((n, inlineString(content.replacingOccurrences(of: "\n", with: " "))))
280        return "[^\(n)]"
281    }
282}