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