krz/orgstar

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

Commit 80ccfaf8c9

80ccfaf8c917454fe4ee97304f85e512c2627b76

parent: ebd884b5da

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:55 UTC

Markdown export: #+OPTIONS, export tags, #+INCLUDE, macros, Org file links

Markdown export preprocesses the text as the HTML page does (#+INCLUDE,
macros) and follows #+OPTIONS (todo, pri, tags, ^, title), the exclude
and select tags, COMMENT trees and archived trees, through the HTML
exporter's ExportOptions and section rule. file: links to .org files
point at .md without the search option, as ox-md writes them.

Layout: unified · split

Sources/OrgCore/Export/HTMLExport.swift +19 −7
@@ -267,13 +267,11 @@ final class HTMLRenderer {
267 switch node.kind { 267 switch node.kind {
268 case .section: 268 case .section:
269 guard let heading = node.firstChild(.heading) else { fallthrough } 269 guard let heading = node.firstChild(.heading) else { fallthrough }
270 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? [] 270 switch Self.sectionExport(heading, options: options, selected: selected) {
271 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? "" 271 case .nothing: return ""
272 // `org-export--skip-p`: excluded tags, COMMENT, unselected trees. 272 case .heading: return self.heading(heading)
273 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return "" } 273 case .all: break
274 if let selected, !selected.contains(heading.range.lowerBound) { return "" } 274 }
275 // Archived trees export their heading only.
276 if tags.contains("ARCHIVE") { return self.heading(heading) }
277 var out = "" 275 var out = ""
278 for child in node.children { out += element(child) } 276 for child in node.children { out += element(child) }
279 return out 277 return out
@@ -377,6 +375,20 @@ final class HTMLRenderer {
377 375
378 /// `org-export--selected-trees`: headings with a select tag, everything below them and 376 /// `org-export--selected-trees`: headings with a select tag, everything below them and
379 /// their ancestors; nil when no heading has one. 377 /// their ancestors; nil when no heading has one.
378 enum SectionExport {
379 case all, heading, nothing
380 }
381
382 /// `org-export--skip-p` for the section under `heading`: excluded tags, COMMENT and
383 /// unselected trees export nothing, archived trees their heading only.
384 static func sectionExport(_ heading: SyntaxNode, options: ExportOptions, selected: Set<Int>?) -> SectionExport {
385 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
386 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
387 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return .nothing }
388 if let selected, !selected.contains(heading.range.lowerBound) { return .nothing }
389 return tags.contains("ARCHIVE") ? .heading : .all
390 }
391
380 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? { 392 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
381 let model = DocumentModel(tree: tree) 393 let model = DocumentModel(tree: tree)
382 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) } 394 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
Sources/OrgCore/Export/MarkdownExport.swift +34 −12
@@ -3,14 +3,18 @@ import Foundation
3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and 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. 4/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
5public enum MarkdownExport { 5public enum MarkdownExport {
6 /// `path` is the file's, for `#+bibliography:`. 6 /// As the HTML page: `#+INCLUDE`, macros, `#+OPTIONS` and the select and exclude tags.
7 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String { 7 /// `path` is the file's, for `#+INCLUDE`, `{{{input-file}}}` and `#+bibliography:`.
8 let renderer = MarkdownRenderer(settings: settings) 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 var out = "" 13 var out = ""
10 renderer.inTitle = true 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 renderer.inTitle = false 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 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text))) 18 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
15 return out.trimmingCharacters(in: .newlines) + "\n" 19 return out.trimmingCharacters(in: .newlines) + "\n"
16 } 20 }
@@ -18,6 +22,9 @@ public enum MarkdownExport {
18 22
19final class MarkdownRenderer { 23final class MarkdownRenderer {
20 let settings: OrgSettings 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 private var definitions: [String: String] = [:] 28 private var definitions: [String: String] = [:]
22 private var numbers: [String: Int] = [:] 29 private var numbers: [String: Int] = [:]
23 private var notes: [(Int, String)] = [] 30 private var notes: [(Int, String)] = []
@@ -27,8 +34,9 @@ final class MarkdownRenderer {
27 /// Rendering the title, where Org exports citations as nothing. 34 /// Rendering the title, where Org exports citations as nothing.
28 var inTitle = false 35 var inTitle = false
29 36
30 init(settings: OrgSettings) { 37 init(settings: OrgSettings, options: ExportOptions) {
31 self.settings = settings 38 self.settings = settings
39 self.options = options
32 } 40 }
33 41
34 func document(_ text: String) -> String { 42 func document(_ text: String) -> String {
@@ -38,6 +46,7 @@ final class MarkdownRenderer {
38 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) 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 var out = rendering(tree, text) { blocks(tree.root) } 50 var out = rendering(tree, text) { blocks(tree.root) }
42 if !notes.isEmpty { 51 if !notes.isEmpty {
43 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n" 52 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
@@ -93,14 +102,21 @@ final class MarkdownRenderer {
93 private func element(_ node: SyntaxNode, indent: String) -> String { 102 private func element(_ node: SyntaxNode, indent: String) -> String {
94 switch node.kind { 103 switch node.kind {
95 case .zerothSection, .section: 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 return blocks(node, indent: indent).trimmingCharacters(in: .newlines) 112 return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
97 case .heading: 113 case .heading:
98 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1 114 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
99 var parts: [String] = [] 115 var parts: [String] = []
100 if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) } 116 if options.todo, 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) } 117 if options.priority, let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
102 if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } 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 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " ")) 120 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
105 } 121 }
106 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ") 122 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
@@ -282,7 +298,9 @@ final class MarkdownRenderer {
282 return String(node.text.dropFirst(3).dropLast(3)) 298 return String(node.text.dropFirst(3).dropLast(3))
283 case .superscript, .subscript: 299 case .superscript, .subscript:
284 let body = node.text.dropFirst() 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 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" 304 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
287 case .lineBreak: return " \n" 305 case .lineBreak: return " \n"
288 case .macro: 306 case .macro:
@@ -319,9 +337,13 @@ final class MarkdownRenderer {
319 return "<\(target)>" 337 return "<\(target)>"
320 } 338 }
321 var href = path 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 if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) } 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 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" } 347 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
326 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? "" 348 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
327 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" } 349 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
Tests/OrgCoreTests/ExportTests.swift +42
@@ -168,6 +168,48 @@ struct MarkdownExportTests {
168 let ours = MarkdownExport.document(org) 168 let ours = MarkdownExport.document(org)
169 #expect(ours == expected) 169 #expect(ours == expected)
170 } 170 }
171
172 /// Words that ox-md keeps or drops for `#+OPTIONS`, the tags, COMMENT, `#+INCLUDE` and
173 /// macros, and where links to Org files point.
174 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
175 func documentLevelLikeOrgMd() throws {
176 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-md-\(UUID().uuidString)")
177 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
178 defer { try? FileManager.default.removeItem(at: folder) }
179 try "* Included\nwordincluded\n".write(to: folder.appendingPathComponent("part.org"), atomically: true, encoding: .utf8)
180 let text = """
181 #+TITLE: wordtitle
182 #+OPTIONS: todo:nil tags:nil ^:nil toc:nil num:nil
183 #+EXCLUDE_TAGS: private
184 #+MACRO: greet wordmacro $1
185 {{{greet(wordarg)}}} a_wordsub
186 * TODO wordtodo :wordtag:
187 * Kept :noexport:
188 wordnoexport
189 * Private :private:
190 wordprivate
191 * COMMENT Hidden
192 wordcomment
193 * Links
194 [[file:x.org]] [[file:x.org][X]] [[./y.org]] [[file:sub/z.ORG::*Head][Z]]
195 #+INCLUDE: "part.org"
196 """ + "\n"
197 let path = folder.appendingPathComponent("main.org").path
198 try text.write(toFile: path, atomically: true, encoding: .utf8)
199 let emacs = try EmacsOracle.evaluate(text, """
200 (with-current-buffer (find-file-noselect \(Babel.lispString(path)))
201 (require 'ox-md)
202 (list (org-export-as 'md)))
203 """)[0]
204 let ours = MarkdownExport.document(text, path: path)
205 // ox-md writes no title; this export makes it the top heading.
206 for word in text.matches(of: /\bword[a-z]+\b|\bTODO\b/).map({ String($0.0) }) where word != "wordtitle" {
207 #expect(ours.contains(word) == emacs.contains(word), "\(word)\nours:\n\(ours)\nemacs:\n\(emacs)")
208 }
209 #expect(ours.contains("<sub>") == emacs.contains("<sub>"))
210 let targets = { (s: String) in Set(s.matches(of: /[\w.\/]+\.(?:md|org|ORG)\b/).map { String($0.0) }) }
211 #expect(targets(ours) == targets(emacs), "ours:\n\(ours)\nemacs:\n\(emacs)")
212 }
171} 213}
172 214
173struct ExportCorpusTests { 215struct ExportCorpusTests {