| @@ -3,14 +3,18 @@ import Foundation |
| 3 | 3 | /// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and |
| 4 | 4 | /// `[^n]` footnotes. The title, when there is one, becomes the top heading. |
| 5 | 5 | public enum MarkdownExport { |
| 6 | | /// `path` is the file's, for `#+bibliography:`. |
| 7 | | public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String { |
| 8 | | let renderer = MarkdownRenderer(settings: settings) |
| 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) |
| 9 | 13 | var out = "" |
| 10 | 14 | renderer.inTitle = true |
| 11 | | if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" } |
| 15 | if options.title, let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" } |
| 12 | 16 | renderer.inTitle = false |
| 13 | | renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }) |
| 17 | renderer.citations = CiteProcessor(text: text, directory: directory) |
| 14 | 18 | out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text))) |
| 15 | 19 | return out.trimmingCharacters(in: .newlines) + "\n" |
| 16 | 20 | } |
| @@ -18,6 +22,9 @@ public enum MarkdownExport { |
| 18 | 22 | |
| 19 | 23 | final class MarkdownRenderer { |
| 20 | 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>? |
| 21 | 28 | private var definitions: [String: String] = [:] |
| 22 | 29 | private var numbers: [String: Int] = [:] |
| 23 | 30 | private var notes: [(Int, String)] = [] |
| @@ -27,8 +34,9 @@ final class MarkdownRenderer { |
| 27 | 34 | /// Rendering the title, where Org exports citations as nothing. |
| 28 | 35 | var inTitle = false |
| 29 | 36 | |
| 30 | | init(settings: OrgSettings) { |
| 37 | init(settings: OrgSettings, options: ExportOptions) { |
| 31 | 38 | self.settings = settings |
| 39 | self.options = options |
| 32 | 40 | } |
| 33 | 41 | |
| 34 | 42 | func document(_ text: String) -> String { |
| @@ -38,6 +46,7 @@ final class MarkdownRenderer { |
| 38 | 46 | definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) |
| 39 | 47 | } |
| 40 | 48 | } |
| 49 | selected = HTMLRenderer.selection(tree, tags: options.selectTags) |
| 41 | 50 | var out = rendering(tree, text) { blocks(tree.root) } |
| 42 | 51 | if !notes.isEmpty { |
| 43 | 52 | out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n" |
| @@ -93,14 +102,21 @@ final class MarkdownRenderer { |
| 93 | 102 | private func element(_ node: SyntaxNode, indent: String) -> String { |
| 94 | 103 | switch node.kind { |
| 95 | 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 | } |
| 96 | 112 | return blocks(node, indent: indent).trimmingCharacters(in: .newlines) |
| 97 | 113 | case .heading: |
| 98 | 114 | let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1 |
| 99 | 115 | var parts: [String] = [] |
| 100 | | if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) } |
| 101 | | if let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) } |
| 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) } |
| 102 | 118 | if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } |
| 103 | | if let tags = node.tokens.first(where: { $0.kind == .tags })?.text { |
| 119 | if options.tags, let tags = node.tokens.first(where: { $0.kind == .tags })?.text { |
| 104 | 120 | parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " ")) |
| 105 | 121 | } |
| 106 | 122 | return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ") |
| @@ -282,7 +298,9 @@ final class MarkdownRenderer { |
| 282 | 298 | return String(node.text.dropFirst(3).dropLast(3)) |
| 283 | 299 | case .superscript, .subscript: |
| 284 | 300 | let body = node.text.dropFirst() |
| 285 | | let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body) |
| 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) |
| 286 | 304 | return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" |
| 287 | 305 | case .lineBreak: return " \n" |
| 288 | 306 | case .macro: |
| @@ -319,9 +337,13 @@ final class MarkdownRenderer { |
| 319 | 337 | return "<\(target)>" |
| 320 | 338 | } |
| 321 | 339 | var href = path |
| 322 | | if path.hasPrefix("file:") { href = String(path.dropFirst(5)) } |
| 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 | } |
| 323 | 345 | if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) } |
| 324 | | if !path.contains(":"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" } |
| 346 | if !path.contains(":") || path.hasPrefix("file:"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" } |
| 325 | 347 | if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" } |
| 326 | 348 | let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? "" |
| 327 | 349 | if HTMLRenderer.imageExtensions.contains(ext) { return ")" } |