krz/orgstar

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

Sources/OrgCore/Export/MarkdownExport.swift

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

367 lines · 18975 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    /// As the HTML page: `#+INCLUDE`, macros, `#+OPTIONS` and the select and exclude tags.
  7    /// `path` is the file's, for `#+INCLUDE`, `{{{input-file}}}` and `#+bibliography:`.
  8    public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
  9        let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
 10        let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords)
 11        let options = ExportOptions.parse(settings.setupKeywords + text)
 12        let renderer = MarkdownRenderer(settings: settings, options: options)
 13        var out = ""
 14        renderer.inTitle = true
 15        if options.title, let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
 16        renderer.inTitle = false
 17        renderer.citations = CiteProcessor(text: text, directory: directory)
 18        out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
 19        return out.trimmingCharacters(in: .newlines) + "\n"
 20    }
 21}
 22
 23final class MarkdownRenderer {
 24    let settings: OrgSettings
 25    let options: ExportOptions
 26    /// With select tags in the document: the sections to export, by heading start.
 27    private var selected: Set<Int>?
 28    private var definitions: [String: String] = [:]
 29    private var numbers: [String: Int] = [:]
 30    private var notes: [(Int, String)] = []
 31    private var anonymous = 0
 32    /// Citations and the bibliography, as `oc-basic` exports them.
 33    var citations: CiteProcessor?
 34    /// Rendering the title, where Org exports citations as nothing.
 35    var inTitle = false
 36
 37    init(settings: OrgSettings, options: ExportOptions) {
 38        self.settings = settings
 39        self.options = options
 40    }
 41
 42    func document(_ text: String) -> String {
 43        let tree = OrgParser.parse(text, defaults: settings)
 44        for node in tree.root.descendants() where node.kind == .footnoteDefinition {
 45            if let m = node.text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
 46                definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
 47            }
 48        }
 49        selected = HTMLRenderer.selection(tree, tags: options.selectTags)
 50        var out = rendering(tree, text) { blocks(tree.root) }
 51        if !notes.isEmpty {
 52            out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
 53        }
 54        return out
 55    }
 56
 57    func inlineString(_ text: String) -> String {
 58        let tree = OrgParser.parse(text, defaults: settings)
 59        guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text }
 60        return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
 61    }
 62
 63    private var source: ExportSource?
 64
 65    /// `body` with `tree` as the source of `:exports`.
 66    private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
 67        let saved = source
 68        source = ExportSource(tree: tree, text: text)
 69        defer { source = saved }
 70        return body()
 71    }
 72
 73    private var lastExports = "code"
 74    private var results = false
 75
 76    private func blocks(_ node: SyntaxNode, indent: String = "") -> String {
 77        let parts = node.children.map { child -> String in
 78            // Results show for `:exports results` and `both`, as in the HTML export.
 79            if child.kind == .affiliatedKeyword {
 80                if child.text.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+RESULTS") { results = true }
 81                return ""
 82            }
 83            defer { results = false }
 84            if results {
 85                defer { lastExports = "code" }
 86                if lastExports != "results", lastExports != "both" { return "" }
 87            }
 88            if child.kind == .block || child.kind == .keyword, let exports = source?.exports(at: child.range.lowerBound) {
 89                lastExports = exports
 90                if exports == "results" || exports == "none" { return "" }
 91            }
 92            return element(child, indent: indent)
 93        }.filter { !$0.isEmpty }
 94        return parts.isEmpty ? "" : parts.joined(separator: "\n\n") + "\n\n"
 95    }
 96
 97    private func blocks(text: String) -> String {
 98        let tree = OrgParser.parse(text, defaults: settings)
 99        return rendering(tree, text) { blocks(tree.root) }
100    }
101
102    private func element(_ node: SyntaxNode, indent: String) -> String {
103        switch node.kind {
104        case .zerothSection, .section:
105            if node.kind == .section, let heading = node.firstChild(.heading) {
106                switch HTMLRenderer.sectionExport(heading, options: options, selected: selected) {
107                case .nothing: return ""
108                case .heading: return element(heading, indent: indent)
109                case .all: break
110                }
111            }
112            return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
113        case .heading:
114            let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
115            var parts: [String] = []
116            if options.todo, let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) }
117            if options.priority, let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
118            if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
119            if options.tags, let tags = node.tokens.first(where: { $0.kind == .tags })?.text {
120                parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
121            }
122            return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
123        case .keyword where citations != nil && node.text.range(of: "^[ \\t]*#\\+print_bibliography:", options: [.regularExpression, .caseInsensitive]) != nil:
124            return citations!.bibliography.map { indent + render($0) }.joined(separator: "\n\n")
125        case .paragraph:
126            return indent + inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
127                .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + indent)
128        case .plainList:
129            return list(node, indent: indent)
130        case .table:
131            return table(node, indent: indent)
132        case .block:
133            return block(node, indent: indent)
134        case .horizontalRule:
135            return indent + "---"
136        case .tableEl:
137            let lines = node.text.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.lowercased().contains("#+tblfm") }
138            return fence(lines, language: "", indent: indent)
139        case .fixedWidth:
140            let lines = node.text.split(separator: "\n").map { line -> String in
141                let trimmed = line.drop { $0 == " " || $0 == "\t" }
142                return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
143            }
144            return fence(lines, language: "", indent: indent)
145        default:
146            return ""
147        }
148    }
149
150    private func fence(_ lines: [String], language: String, indent: String) -> String {
151        let longest = lines.map { $0.prefix { $0 == "`" }.count }.max() ?? 0
152        let ticks = String(repeating: "`", count: max(3, longest + 1))
153        return ([indent + ticks + language] + lines.map { indent + $0 } + [indent + ticks]).joined(separator: "\n")
154    }
155
156    private func list(_ node: SyntaxNode, indent: String) -> String {
157        var out: [String] = []
158        var number = 0
159        for item in node.children where item.kind == .item {
160            let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
161            let ordered = bullet.first.map { $0.isNumber || $0.isLetter } ?? false
162            number += 1
163            var marker = ordered ? "\(number)." : "-"
164            if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
165                marker += box == "[X]" || box == "[x]" ? " [x]" : " [ ]"
166            }
167            let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count)
168            var parts: [String] = []
169            let term = item.firstChild(.itemTag).map { "**" + inline($0).trimmingCharacters(in: .whitespaces) + "**: " }
170            for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
171                if index == 0, child.kind == .paragraph {
172                    let text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
173                        .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent)
174                    parts.append((term ?? "") + text)
175                } else {
176                    parts.append(element(child, indent: childIndent))
177                }
178            }
179            if let term, parts.first.map({ !$0.hasPrefix(term) }) ?? true { parts.insert(term.trimmingCharacters(in: .whitespaces), at: 0) }
180            let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined()
181            out.append(indent + marker + " " + body)
182        }
183        return out.joined(separator: "\n")
184    }
185
186    private func table(_ node: SyntaxNode, indent: String) -> String {
187        var rows: [[String]] = []
188        var ruleAfter: Int?
189        for row in node.children where row.kind == .tableRow {
190            let cells = row.children.filter { $0.kind == .tableCell }
191            if cells.isEmpty {
192                if ruleAfter == nil, !rows.isEmpty { ruleAfter = rows.count }
193                continue
194            }
195            rows.append(cells.map { inline($0).trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "|", with: "\\|") })
196        }
197        guard !rows.isEmpty else { return "" }
198        let columns = rows.map(\.count).max() ?? 0
199        rows = rows.map { $0 + Array(repeating: "", count: columns - $0.count) }
200        var header: [String]
201        var body: [[String]]
202        if ruleAfter == 1 {
203            header = rows[0]
204            body = Array(rows.dropFirst())
205        } else {
206            header = Array(repeating: "", count: columns)
207            body = rows
208        }
209        func line(_ cells: [String]) -> String { indent + "| " + cells.joined(separator: " | ") + " |" }
210        return ([line(header), indent + "|" + Array(repeating: "---", count: columns).joined(separator: "|") + "|"] + body.map(line)).joined(separator: "\n")
211    }
212
213    private func block(_ node: SyntaxNode, indent: String) -> String {
214        var lines = node.text.components(separatedBy: "\n")
215        if lines.last == "" { lines.removeLast() }
216        guard let first = lines.first, let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
217        let type = m.1.lowercased()
218        let parameters = String(m.2)
219        var body = Array(lines.dropFirst().dropLast()).map {
220            $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression)
221        }
222        let common = body.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }.min() ?? 0
223        body = body.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
224        switch type {
225        case "src":
226            return fence(body, language: parameters.split(separator: " ").first.map(String.init) ?? "", indent: indent)
227        case "example":
228            return fence(body, language: "", indent: indent)
229        case "quote":
230            return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines)
231                .components(separatedBy: "\n").map { indent + ($0.isEmpty ? ">" : "> " + $0) }.joined(separator: "\n")
232        case "verse":
233            return body.map { indent + inlineString($0) + "  " }.joined(separator: "\n")
234        case "export":
235            let backend = parameters.lowercased()
236            return backend.hasPrefix("markdown") || backend.hasPrefix("md") || backend.hasPrefix("html") ? body.joined(separator: "\n") : ""
237        case "comment":
238            return ""
239        default:
240            return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines)
241        }
242    }
243
244    func inline(_ node: SyntaxNode) -> String {
245        var out = ""
246        for element in node.elements {
247            switch element {
248            case .token(let token):
249                switch token.kind {
250                case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority: continue
251                default: out += token.text
252                }
253            case .node(let child):
254                out += object(child)
255            }
256        }
257        return out
258    }
259
260    /// A citation's or a bibliography entry's pieces, prefixes and suffixes as Org markup.
261    private func render(_ pieces: [CiteProcessor.Piece]) -> String {
262        pieces.map { piece in
263            switch piece {
264            case .text(let s): s
265            case .italic(let s): "*" + s + "*"
266            case .org(let s) where s.allSatisfy(\.isWhitespace): s
267            case .org(let s):
268                String(s.prefix { $0.isWhitespace }) + inlineString(s) + (s.allSatisfy(\.isWhitespace) ? "" : String(s.reversed().prefix { $0.isWhitespace }))
269            }
270        }.joined()
271    }
272
273    private func code(_ text: String) -> String {
274        let ticks = text.contains("`") ? "``" : "`"
275        return ticks + (ticks == "``" ? " " + text + " " : text) + ticks
276    }
277
278    private func object(_ node: SyntaxNode) -> String {
279        switch node.kind {
280        case .bold: return "**\(inline(node))**"
281        case .italic: return "*\(inline(node))*"
282        case .underline: return "<u>\(inline(node))</u>"
283        case .strikeThrough: return "~~\(inline(node))~~"
284        case .verbatim, .code: return code(inline(node))
285        case .link: return link(node)
286        case .linkDescription: return inline(node)
287        case .timestamp:
288            guard let stamp = Timestamp.parse(node.text) else { return node.text }
289            func show(_ p: Timestamp.Point) -> String {
290                String(format: "%04d-%02d-%02d", p.year, p.month, p.day) + (p.hour.map { String(format: " %02d:%02d", $0, p.minute ?? 0) } ?? "")
291            }
292            return show(stamp.start) + (stamp.end.map { "–" + show($0) } ?? "")
293        case .footnoteReference: return footnote(node.text)
294        case .entity:
295            let name = node.text.dropFirst().replacingOccurrences(of: "{}", with: "")
296            return OrgEntities.display[name] ?? node.text
297        case .radioTarget:
298            return String(node.text.dropFirst(3).dropLast(3))
299        case .superscript, .subscript:
300            let body = node.text.dropFirst()
301            let braced = body.hasPrefix("{") && body.hasSuffix("}")
302            if options.scripts == .off || (options.scripts == .braces && !braced) { return node.text }
303            let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
304            return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
305        case .lineBreak: return "  \n"
306        case .macro:
307            guard case .some(let value) = source?.resultsMacro(node) else { return "" }
308            return value.map(inlineString) ?? ""
309        case .target: return ""
310        case .inlineSourceBlock:
311            let exports = source?.exports(at: node.range.lowerBound) ?? "results"
312            source?.inlineResults = exports == "results" || exports == "both"
313            guard exports == "code" || exports == "both" else { return "" }
314            if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return code(String(m.1)) }
315            return node.text
316        // ox-md uses ox-html's translator: only HTML snippets.
317        case .exportSnippet:
318            guard node.tokens.first?.text == "@@html:" else { return "" }
319            return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
320        case .citation:
321            if inTitle { return "" }
322            guard let pieces = citations?.next(node.text) else { return node.text }
323            return render(pieces)
324        case .inlineBabelCall:
325            source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
326            return ""
327        default: return inline(node)
328        }
329    }
330
331    private func link(_ node: SyntaxNode) -> String {
332        guard let path = node.tokens.first(where: { $0.kind == .linkPath })?.text else {
333            // A radio link is its text.
334            if !node.text.hasPrefix("<"), !node.text.contains(":") { return node.text }
335            var target = node.text
336            if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
337            return "<\(target)>"
338        }
339        var href = path
340        if path.hasPrefix("file:") {
341            // ox-md drops the search option and, as `org-md-link-org-files-as-md`, writes .org as .md.
342            href = String(path.dropFirst(5))
343            if let search = href.range(of: "::") { href = String(href[..<search.lowerBound]) }
344        }
345        if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) }
346        if !path.contains(":") || path.hasPrefix("file:"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" }
347        if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
348        let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
349        if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
350        return path.contains("://") ? "<\(href)>" : "[\(href)](\(href))"
351    }
352
353    private func footnote(_ text: String) -> String {
354        guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return text }
355        var label = String(m.1)
356        if label.isEmpty {
357            anonymous += 1
358            label = "anonymous-\(anonymous)"
359        }
360        if let n = numbers[label] { return "[^\(n)]" }
361        guard let content = m.2.map(String.init) ?? definitions[label] else { return text }
362        let n = notes.count + 1
363        numbers[label] = n
364        notes.append((n, inlineString(content.replacingOccurrences(of: "\n", with: " "))))
365        return "[^\(n)]"
366    }
367}