Sources/OrgCore/Export/MarkdownExport.swift
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 ")" }
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}