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 {
267267 switch node.kind {
268268 case .section:
269269 guard let heading = node.firstChild(.heading) else { fallthrough }
270 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
271 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
272 // `org-export--skip-p`: excluded tags, COMMENT, unselected trees.
273 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return "" }
274 if let selected, !selected.contains(heading.range.lowerBound) { return "" }
275 // Archived trees export their heading only.
276 if tags.contains("ARCHIVE") { return self.heading(heading) }
270 switch Self.sectionExport(heading, options: options, selected: selected) {
271 case .nothing: return ""
272 case .heading: return self.heading(heading)
273 case .all: break
274 }
277275 var out = ""
278276 for child in node.children { out += element(child) }
279277 return out
@@ -377,6 +375,20 @@ final class HTMLRenderer {
377375
378376 /// `org-export--selected-trees`: headings with a select tag, everything below them and
379377 /// 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
380392 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
381393 let model = DocumentModel(tree: tree)
382394 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
33/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and
44/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
55public 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)
913 var out = ""
1014 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" }
1216 renderer.inTitle = false
13 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
17 renderer.citations = CiteProcessor(text: text, directory: directory)
1418 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
1519 return out.trimmingCharacters(in: .newlines) + "\n"
1620 }
@@ -18,6 +22,9 @@ public enum MarkdownExport {
1822
1923final class MarkdownRenderer {
2024 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>?
2128 private var definitions: [String: String] = [:]
2229 private var numbers: [String: Int] = [:]
2330 private var notes: [(Int, String)] = []
@@ -27,8 +34,9 @@ final class MarkdownRenderer {
2734 /// Rendering the title, where Org exports citations as nothing.
2835 var inTitle = false
2936
30 init(settings: OrgSettings) {
37 init(settings: OrgSettings, options: ExportOptions) {
3138 self.settings = settings
39 self.options = options
3240 }
3341
3442 func document(_ text: String) -> String {
@@ -38,6 +46,7 @@ final class MarkdownRenderer {
3846 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
3947 }
4048 }
49 selected = HTMLRenderer.selection(tree, tags: options.selectTags)
4150 var out = rendering(tree, text) { blocks(tree.root) }
4251 if !notes.isEmpty {
4352 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
@@ -93,14 +102,21 @@ final class MarkdownRenderer {
93102 private func element(_ node: SyntaxNode, indent: String) -> String {
94103 switch node.kind {
95104 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 }
96112 return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
97113 case .heading:
98114 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
99115 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) }
102118 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 {
104120 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
105121 }
106122 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
@@ -282,7 +298,9 @@ final class MarkdownRenderer {
282298 return String(node.text.dropFirst(3).dropLast(3))
283299 case .superscript, .subscript:
284300 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)
286304 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
287305 case .lineBreak: return " \n"
288306 case .macro:
@@ -319,9 +337,13 @@ final class MarkdownRenderer {
319337 return "<\(target)>"
320338 }
321339 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 }
323345 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" }
325347 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
326348 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
327349 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
Tests/OrgCoreTests/ExportTests.swift +42
@@ -168,6 +168,48 @@ struct MarkdownExportTests {
168168 let ours = MarkdownExport.document(org)
169169 #expect(ours == expected)
170170 }
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 }
171213}
172214
173215struct ExportCorpusTests {