Export !26

merged merged by cmc on 2026-10-05 07:13 UTC · krz/orgstar:phase5-export into main

36 files changed, +2383 −1

Layout: unified · split

Sources/OrgApp/EmacsRunner.swift +51
@@ -79,4 +79,55 @@ public enum EmacsRunner {
79 } 79 }
80 return String(decoding: data, as: UTF8.self) 80 return String(decoding: data, as: UTF8.self)
81 } 81 }
82
83 /// Exports `text`, the buffer of the file at `path`, with an ox function such as
84 /// `org-latex-export-to-pdf`. The file is written beside `path`, as Emacs does; source
85 /// blocks are not run. Returns the path Emacs wrote.
86 public static func export(text: String, path: String, function: String, timeout: Duration = .seconds(120)) async throws -> String {
87 guard let emacs = executable() else { throw Failure(description: "Emacs isn't installed, so this format can't be exported.") }
88 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-export-\(UUID().uuidString)")
89 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
90 defer { try? FileManager.default.removeItem(at: folder) }
91 let snapshot = folder.appendingPathComponent("snapshot.org")
92 let output = folder.appendingPathComponent("output")
93 let script = folder.appendingPathComponent("export.el")
94 try text.write(to: snapshot, atomically: true, encoding: .utf8)
95 func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
96 try """
97 ;;; -*- lexical-binding: t -*-
98 (require 'org)
99 (require 'ox)
100 (setq enable-local-variables :safe
101 org-export-use-babel nil)
102 (with-current-buffer (find-file-noselect \(lisp(path)))
103 (let ((inhibit-read-only t)
104 (coding-system-for-read 'utf-8-unix))
105 (erase-buffer)
106 (insert-file-contents \(lisp(snapshot.path)))
107 (set-buffer-modified-p nil))
108 (let ((file (\(function))))
109 (with-temp-file \(lisp(output.path)) (insert (expand-file-name file)))))
110 """.write(to: script, atomically: true, encoding: .utf8)
111 let process = Process()
112 process.executableURL = emacs
113 process.arguments = ["-Q", "--batch", "-l", script.path]
114 process.currentDirectoryURL = URL(fileURLWithPath: path).deletingLastPathComponent()
115 let errors = Pipe()
116 process.standardError = errors
117 process.standardOutput = FileHandle.nullDevice
118 try process.run()
119 let deadline = ContinuousClock.now + timeout
120 while process.isRunning {
121 if ContinuousClock.now > deadline {
122 process.terminate()
123 throw Failure(description: "Emacs took too long to export.")
124 }
125 try await Task.sleep(for: .milliseconds(100))
126 }
127 guard let written = try? String(contentsOf: output, encoding: .utf8) else {
128 let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
129 throw Failure(description: log.split(separator: "\n").last.map(String.init) ?? "Emacs couldn't export the file.")
130 }
131 return written
132 }
82} 133}
Sources/OrgCore/Export/HTMLExport.swift added +648
@@ -0,0 +1,648 @@
1import Foundation
2
3/// Document metadata from `#+TITLE`, `#+AUTHOR` and `#+DATE`.
4public struct ExportMetadata: Sendable, Equatable {
5 public var title: String?
6 public var author: String?
7 public var date: String?
8}
9
10/// HTML export. The body follows the shape orgo produces, which the org-conformance corpus
11/// holds as the reference: headings one level down (`*` is `<h2>`), timestamps as `<time>`,
12/// footnotes collected at the end, captioned images as figures.
13public enum HTMLExport {
14 public static func body(_ text: String, settings: OrgSettings = .default) -> String {
15 let renderer = HTMLRenderer(settings: settings)
16 return renderer.document(text)
17 }
18
19 public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata {
20 let tree = OrgParser.parse(text, defaults: settings)
21 var meta = ExportMetadata()
22 for node in tree.root.children where node.kind == .zerothSection {
23 for child in node.children where child.kind == .keyword {
24 let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
25 guard let colon = line.firstIndex(of: ":") else { continue }
26 let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
27 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
28 switch key {
29 case "TITLE": meta.title = value
30 case "AUTHOR": meta.author = value
31 case "DATE": meta.date = value
32 default: break
33 }
34 }
35 }
36 return meta
37 }
38
39 /// A standalone page: the body under the document's title, with a small stylesheet.
40 public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default) -> String {
41 let meta = metadata(text, settings: settings)
42 let title = meta.title ?? fallbackTitle
43 let renderer = HTMLRenderer(settings: settings)
44 let titleHTML = renderer.inlineString(title)
45 var header = "<h1 class=\"title\">\(titleHTML)</h1>\n"
46 let byline = [meta.author, meta.date].compactMap { $0 }.filter { !$0.isEmpty }
47 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
48 return """
49 <!DOCTYPE html>
50 <html lang="en">
51 <head>
52 <meta charset="utf-8">
53 <meta name="viewport" content="width=device-width, initial-scale=1">
54 <title>\(HTMLRenderer.escape(title))</title>
55 <style>
56 body { font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; max-width: 46em; margin: 2em auto; padding: 0 1em; color: #222; }
57 @media (prefers-color-scheme: dark) { body { background: #1d1d1f; color: #ddd; } a { color: #6cb6ff; } }
58 pre { background: rgba(127, 127, 127, 0.12); padding: 0.75em; overflow-x: auto; }
59 code { font: 0.9em ui-monospace, Menlo, monospace; }
60 table { border-collapse: collapse; } th, td { border: 1px solid rgba(127, 127, 127, 0.4); padding: 0.25em 0.6em; }
61 blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid rgba(127, 127, 127, 0.4); }
62 .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); }
63 .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; }
64 figure { margin: 1em 0; } img { max-width: 100%; }
65 </style>
66 </head>
67 <body>
68 \(header)\(renderer.document(text))</body>
69 </html>
70
71 """
72 }
73}
74
75final class HTMLRenderer {
76 let settings: OrgSettings
77 private var footnoteNumbers: [String: Int] = [:]
78 private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
79 private var definitions: [String: String] = [:]
80 private var anonymous = 0
81 private var figures = 0
82 private var ids: Set<String> = []
83
84 enum FootnoteContent {
85 case definition(String)
86 case inline(String)
87 }
88
89 init(settings: OrgSettings) {
90 self.settings = settings
91 }
92
93 static func escape(_ s: String) -> String {
94 var out = ""
95 for c in s {
96 switch c {
97 case "&": out += "&amp;"
98 case "<": out += "&lt;"
99 case ">": out += "&gt;"
100 case "\"": out += "&quot;"
101 default: out.append(c)
102 }
103 }
104 return out
105 }
106
107 // MARK: - Document
108
109 func document(_ text: String) -> String {
110 let tree = OrgParser.parse(text, defaults: settings)
111 collectDefinitions(tree.root)
112 var out = ""
113 for child in tree.root.children { out += element(child) }
114 out += footnoteSection()
115 return out
116 }
117
118 private func collectDefinitions(_ node: SyntaxNode) {
119 for child in node.children {
120 if child.kind == .footnoteDefinition {
121 let text = child.text
122 if let m = text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
123 definitions[String(m.1)] = String(text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
124 }
125 } else {
126 collectDefinitions(child)
127 }
128 }
129 }
130
131 private func footnoteSection() -> String {
132 guard !footnoteOrder.isEmpty else { return "" }
133 var out = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
134 for (index, note) in footnoteOrder.enumerated() {
135 let n = index + 1
136 let content: String
137 switch note.content {
138 case .definition(let text): content = blocks(text).trimmingCharacters(in: .newlines)
139 case .inline(let text): content = inlineString(text)
140 }
141 out += "<li id=\"fn-\(n)\">\(content)\n <a class=\"footnote-back\" href=\"#fnr-\(n)\" aria-label=\"Back to reference \(n)\">&#8617;</a></li>\n"
142 }
143 return out + "</ol>\n</section>\n"
144 }
145
146 /// Org text rendered as elements, for block and footnote contents.
147 func blocks(_ text: String) -> String {
148 let tree = OrgParser.parse(text, defaults: settings)
149 var out = ""
150 for child in tree.root.children { out += element(child) }
151 return out
152 }
153
154 /// One line of org text rendered inline.
155 func inlineString(_ text: String) -> String {
156 let tree = OrgParser.parse(text, defaults: settings)
157 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
158 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
159 }
160
161 // MARK: - Elements
162
163 private var affiliated: [String: String] = [:]
164 /// `:exports` of the last src block: its results are exported only for `results` and
165 /// `both`; nothing runs.
166 private var lastExports = "code"
167
168 func element(_ node: SyntaxNode) -> String {
169 defer { if node.kind != .affiliatedKeyword { affiliated = [:] } }
170 if node.kind != .affiliatedKeyword, affiliated["RESULTS"] != nil {
171 defer { lastExports = "code" }
172 if lastExports != "results", lastExports != "both" { return "" }
173 }
174 if node.kind == .block, let exports = Self.srcExports(node) {
175 lastExports = exports
176 if exports == "results" || exports == "none" { return "" }
177 }
178 switch node.kind {
179 case .zerothSection, .section, .document:
180 var out = ""
181 for child in node.children { out += element(child) }
182 return out
183 case .heading:
184 return heading(node)
185 case .affiliatedKeyword:
186 let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
187 if let m = line.firstMatch(of: /^#\+([A-Za-z_]+):[ \t]*(.*)$/) {
188 affiliated[m.1.uppercased()] = String(m.2)
189 }
190 return ""
191 case .paragraph:
192 return paragraph(node)
193 case .plainList:
194 return list(node)
195 case .table:
196 return table(node)
197 case .block:
198 return block(node)
199 case .horizontalRule:
200 return "<hr>\n"
201 case .fixedWidth:
202 let lines = node.text.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
203 let trimmed = line.drop { $0 == " " || $0 == "\t" }
204 return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
205 }
206 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
207 case .dynamicBlock:
208 let lines = node.text.components(separatedBy: "\n")
209 return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
210 default:
211 // Keywords, comments, drawers, planning, clocks, footnote definitions, formulas.
212 return ""
213 }
214 }
215
216 /// The `:exports` value of a src block, or nil for other blocks.
217 static func srcExports(_ node: SyntaxNode) -> String? {
218 guard DocumentModel.isSrcBlock(node) else { return nil }
219 let first = node.text.components(separatedBy: "\n").first ?? ""
220 if let m = first.firstMatch(of: /:exports[ \t]+(\S+)/) { return m.1.lowercased() }
221 return "code"
222 }
223
224 private func slug(_ text: String) -> String {
225 var slug = ""
226 var dash = false
227 for c in text.lowercased() {
228 if c.isLetter || c.isNumber {
229 if dash, !slug.isEmpty { slug.append("-") }
230 slug.append(c)
231 dash = false
232 } else {
233 dash = true
234 }
235 }
236 if slug.isEmpty { slug = "section" }
237 var candidate = slug
238 var n = 1
239 while ids.contains(candidate) {
240 candidate = "\(slug)-\(n)"
241 n += 1
242 }
243 ids.insert(candidate)
244 return candidate
245 }
246
247 private func heading(_ node: SyntaxNode) -> String {
248 let tokens = node.tokens
249 let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
250 let title = node.firstChild(.title)
251 let titleText = title?.text.trimmingCharacters(in: .whitespaces) ?? ""
252 // `CUSTOM_ID` from the property drawer that follows.
253 var customID: String?
254 if let section = node.parent, let drawer = section.firstChild(.propertyDrawer) {
255 for property in drawer.children where property.kind == .nodeProperty {
256 if let m = property.text.firstMatch(of: /^[ \t]*:CUSTOM_ID:[ \t]*(\S+)/) { customID = String(m.1) }
257 }
258 }
259 let id = customID ?? slug(titleText)
260 if customID != nil { ids.insert(id) }
261 var parts: [String] = []
262 if let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
263 parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>")
264 }
265 if let priority = tokens.first(where: { $0.kind == .priority })?.text {
266 parts.append("<span class=\"priority\">\(Self.escape(priority))</span>")
267 }
268 if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
269 if let tags = tokens.first(where: { $0.kind == .tags })?.text {
270 parts += tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
271 }
272 let h = min(level + 1, 6)
273 return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n"
274 }
275
276 private func paragraph(_ node: SyntaxNode) -> String {
277 let body = inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
278 if let image = soleImage(node), affiliated["CAPTION"] != nil || affiliated["ATTR_HTML"] != nil {
279 var out = "<figure>\n" + image + "\n"
280 if let caption = affiliated["CAPTION"] {
281 figures += 1
282 out += "<figcaption>Figure \(figures): \(inlineString(caption))</figcaption>\n"
283 }
284 return out + "</figure>\n"
285 }
286 return "<p>\(body)</p>\n"
287 }
288
289 /// The `<img>` of a paragraph that is only an image link.
290 private func soleImage(_ node: SyntaxNode) -> String? {
291 let links = node.children.filter { $0.kind == .link }
292 guard links.count == 1, node.children.count == 1,
293 node.tokens.allSatisfy({ $0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil }
294 let html = link(links[0])
295 return html.hasPrefix("<img") ? html : nil
296 }
297
298 // MARK: - Lists
299
300 private func isOrdered(_ item: SyntaxNode) -> Bool {
301 let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
302 return bullet.first.map { $0.isNumber || $0.isLetter } ?? false
303 }
304
305 /// As orgo does, a change between ordered and unordered bullets starts another list.
306 private func list(_ node: SyntaxNode) -> String {
307 var runs: [[SyntaxNode]] = []
308 for item in node.children where item.kind == .item {
309 if let last = runs.last?.last, isOrdered(last) == isOrdered(item) {
310 runs[runs.count - 1].append(item)
311 } else {
312 runs.append([item])
313 }
314 }
315 return runs.map(list).joined()
316 }
317
318 private func list(_ items: [SyntaxNode]) -> String {
319 guard let first = items.first else { return "" }
320 let ordered = isOrdered(first)
321 let description = !ordered && firstLine(first).contains(" :: ")
322 if description {
323 var out = "<dl>\n"
324 for item in items {
325 let (term, rest) = descriptionParts(item)
326 out += "<dt>\(term)</dt>\n<dd>\(rest)</dd>\n"
327 }
328 return out + "</dl>\n"
329 }
330 let tag = ordered ? "ol" : "ul"
331 var out = "<\(tag)>\n"
332 for item in items { out += listItem(item) }
333 return out + "</\(tag)>\n"
334 }
335
336 private func firstLine(_ item: SyntaxNode) -> String {
337 item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? ""
338 }
339
340 private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
341 guard let paragraph = item.firstChild(.paragraph) else { return ("", "") }
342 let text = paragraph.text
343 guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) }
344 let term = inlineString(String(text[..<separator.lowerBound]))
345 var rest = inlineParagraphText(String(text[separator.upperBound...]))
346 for child in item.children where child.kind != .paragraph || child.range != paragraph.range {
347 rest += element(child)
348 }
349 return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
350 }
351
352 /// Paragraph text, possibly spanning lines, rendered inline.
353 private func inlineParagraphText(_ text: String) -> String {
354 let tree = OrgParser.parse(text, defaults: settings)
355 let paragraphs = tree.root.descendants().filter { $0.kind == .paragraph }
356 guard !paragraphs.isEmpty else { return Self.escape(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
357 return paragraphs.map { inline($0).trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\n")
358 }
359
360 private func listItem(_ item: SyntaxNode) -> String {
361 var open = "<li>"
362 var prefix = ""
363 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
364 switch box {
365 case "[X]", "[x]":
366 open = "<li class=\"on\">"
367 prefix = "<code>[X]</code> "
368 case "[-]":
369 open = "<li class=\"trans\">"
370 prefix = "<code>[-]</code> "
371 default:
372 open = "<li class=\"off\">"
373 prefix = "<code>[&nbsp;]</code> "
374 }
375 }
376 let paragraphs = item.children.filter { $0.kind == .paragraph }.count
377 var body = ""
378 for child in item.children {
379 if child.kind == .paragraph {
380 let content = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
381 body += paragraphs >= 2 ? "<p>\(content)</p>\n" : content
382 } else {
383 body += element(child)
384 }
385 }
386 if paragraphs >= 2 {
387 body = prefix + body
388 } else {
389 body = prefix + body
390 }
391 return open + body + (body.hasSuffix("\n") ? "" : "") + "</li>\n"
392 }
393
394 // MARK: - Tables
395
396 private func table(_ node: SyntaxNode) -> String {
397 let rows = node.children.filter { $0.kind == .tableRow }
398 var groups: [[SyntaxNode]] = [[]]
399 for row in rows {
400 if row.children.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") {
401 if !groups[groups.count - 1].isEmpty { groups.append([]) }
402 } else {
403 groups[groups.count - 1].append(row)
404 }
405 }
406 groups.removeAll { $0.isEmpty }
407 var out = "<table>\n"
408 if let caption = affiliated["CAPTION"] { out += "<caption>\(inlineString(caption))</caption>\n" }
409 let hasHeader = groups.count > 1
410 for (index, group) in groups.enumerated() {
411 let header = hasHeader && index == 0
412 if header { out += "<thead>\n" } else if index == (hasHeader ? 1 : 0) { out += "<tbody>\n" }
413 for row in group {
414 out += "<tr>" + row.children.filter { $0.kind == .tableCell }.map { cell in
415 let content = inline(cell).trimmingCharacters(in: .whitespaces)
416 return header ? "<th>\(content)</th>" : "<td>\(content)</td>"
417 }.joined() + "</tr>\n"
418 }
419 if header { out += "</thead>\n" }
420 }
421 if !groups.isEmpty, !(hasHeader && groups.count == 1) { out += "</tbody>\n" }
422 return out + "</table>\n"
423 }
424
425 // MARK: - Blocks
426
427 private func block(_ node: SyntaxNode) -> String {
428 var lines = node.text.components(separatedBy: "\n")
429 if lines.last == "" { lines.removeLast() }
430 guard let first = lines.first,
431 let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
432 let type = m.1.lowercased()
433 let parameters = String(m.2)
434 let body = Array(lines.dropFirst().dropLast())
435 // Commas protect `*` and `#+` lines inside blocks.
436 let unescaped = body.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
437 let text = unescaped.joined(separator: "\n")
438 switch type {
439 case "src":
440 let language = parameters.split(separator: " ").first.map(String.init)
441 let code = Self.escape(dedent(unescaped).joined(separator: "\n"))
442 let cls = language.map { " class=\"language-\(Self.escape($0))\"" } ?? ""
443 return "<pre><code\(cls)>\(code)</code></pre>\n"
444 case "example":
445 return "<pre>\(Self.escape(dedent(unescaped).joined(separator: "\n")))</pre>\n"
446 case "quote":
447 return "<blockquote>\n\(blocks(text))</blockquote>\n"
448 case "center":
449 return "<div class=\"center\">\n\(blocks(text))</div>\n"
450 case "verse":
451 let rendered = unescaped.map { line -> String in
452 let indent = line.prefix { $0 == " " }.count
453 return String(repeating: "&nbsp;", count: indent) + inlineString(String(line.dropFirst(indent)))
454 }
455 return "<p class=\"verse\">\n" + rendered.joined(separator: "<br>\n") + "\n</p>\n"
456 case "export":
457 return parameters.lowercased().hasPrefix("html") ? text + "\n" : ""
458 case "comment":
459 return ""
460 default:
461 return "<div class=\"\(Self.escape(type))\">\n\(blocks(text))</div>\n"
462 }
463 }
464
465 private func dedent(_ lines: [String]) -> [String] {
466 let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }
467 let common = indents.min() ?? 0
468 return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
469 }
470
471 // MARK: - Inline
472
473 func inline(_ node: SyntaxNode) -> String {
474 var out = ""
475 for element in node.elements {
476 switch element {
477 case .token(let token):
478 switch token.kind {
479 case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority:
480 continue
481 default:
482 out += entities(token.text)
483 }
484 case .node(let child):
485 out += object(child)
486 }
487 }
488 return out
489 }
490
491 /// `\alpha` and friends as characters.
492 private func entities(_ text: String) -> String {
493 guard text.contains("\\") else { return Self.escape(text) }
494 var out = ""
495 var rest = Substring(text)
496 while let m = rest.firstMatch(of: /\\([a-zA-Z]+)(\{\})?/) {
497 out += Self.escape(String(rest[..<m.range.lowerBound]))
498 if let display = OrgEntities.display[String(m.1)] {
499 out += Self.escape(display)
500 } else {
501 out += Self.escape(String(rest[m.range]))
502 }
503 rest = rest[m.range.upperBound...]
504 }
505 return out + Self.escape(String(rest))
506 }
507
508 private func object(_ node: SyntaxNode) -> String {
509 switch node.kind {
510 case .bold: return "<strong>\(inline(node))</strong>"
511 case .italic: return "<em>\(inline(node))</em>"
512 case .underline: return "<u>\(inline(node))</u>"
513 case .strikeThrough: return "<del>\(inline(node))</del>"
514 case .verbatim: return "<code class=\"verbatim\">\(inline(node))</code>"
515 case .code: return "<code>\(inline(node))</code>"
516 case .link: return link(node)
517 case .linkDescription: return inline(node)
518 case .timestamp: return timestamp(node.text)
519 case .footnoteReference: return footnote(node.text)
520 case .superscript:
521 let text = node.text
522 let body = text.dropFirst()
523 let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
524 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
525 case .lineBreak: return "<br>\n"
526 case .target, .macro: return ""
527 case .inlineSourceBlock:
528 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
529 return Self.escape(node.text)
530 case .statisticsCookie, .latexFragment: return Self.escape(node.text)
531 default: return inline(node)
532 }
533 }
534
535 static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tif", "tiff", "avif"]
536
537 private func link(_ node: SyntaxNode) -> String {
538 let path = node.tokens.first { $0.kind == .linkPath }?.text
539 let description = node.firstChild(.linkDescription)
540 guard let path else {
541 // A plain or angle link.
542 var target = node.text
543 if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
544 return "<a href=\"\(Self.escape(target))\">\(Self.escape(target))</a>"
545 }
546 var href = path
547 var label = path
548 if path.hasPrefix("file:") {
549 href = String(path.dropFirst(5))
550 label = href
551 } else if path.hasPrefix("id:") {
552 href = "#" + path.dropFirst(3)
553 label = String(path.dropFirst(3))
554 } else if path.hasPrefix("*") {
555 href = "#" + slugOnly(String(path.dropFirst()))
556 } else if !path.hasPrefix("#"), !path.contains(":"), !path.contains("/"), !path.contains(".") {
557 href = "#" + slugOnly(path)
558 }
559 let isFile = path.hasPrefix("file:") || (!path.contains(":") && (path.contains("/") || path.contains(".")))
560 if isFile, href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".html" }
561 if isFile, let range = href.range(of: ".org::") { href = String(href[..<range.lowerBound]) + ".html" }
562 let ext = (href.split(separator: "?").first.map(String.init) ?? href).split(separator: ".").last.map { $0.lowercased() } ?? ""
563 if description == nil, Self.imageExtensions.contains(ext) {
564 var attributes = ""
565 if let attrs = affiliated["ATTR_HTML"] {
566 for (key, value) in parseAttributes(attrs) where key != "src" {
567 attributes += " \(key)=\"\(Self.escape(value))\""
568 }
569 }
570 if !attributes.contains(" alt=") { attributes = " alt=\"\(Self.escape((href as NSString).lastPathComponent))\"" + attributes }
571 return "<img src=\"\(Self.escape(href))\"\(attributes)>"
572 }
573 let text = description.map(inline) ?? Self.escape(label)
574 return "<a href=\"\(Self.escape(href))\">\(text)</a>"
575 }
576
577 private func slugOnly(_ text: String) -> String {
578 var slug = ""
579 var dash = false
580 for c in text.lowercased() {
581 if c.isLetter || c.isNumber {
582 if dash, !slug.isEmpty { slug.append("-") }
583 slug.append(c)
584 dash = false
585 } else {
586 dash = true
587 }
588 }
589 return slug
590 }
591
592 /// `:key value :other "quoted value"`.
593 private func parseAttributes(_ text: String) -> [(String, String)] {
594 var pairs: [(String, String)] = []
595 let regex = try! NSRegularExpression(pattern: ":([A-Za-z_-]+)[ \\t]+(\"[^\"]*\"|[^:\\s][^\\s]*)")
596 let ns = text as NSString
597 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
598 var value = ns.substring(with: m.range(at: 2))
599 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value = String(value.dropFirst().dropLast()) }
600 pairs.append((ns.substring(with: m.range(at: 1)), value))
601 }
602 return pairs
603 }
604
605 private func timestamp(_ text: String) -> String {
606 guard let stamp = Timestamp.parse(text) else { return Self.escape(text) }
607 func date(_ p: Timestamp.Point) -> String { String(format: "%04d-%02d-%02d", p.year, p.month, p.day) }
608 func time(_ p: Timestamp.Point) -> String? {
609 guard let h = p.hour, let m = p.minute else { return nil }
610 return String(format: "%02d:%02d", h, m)
611 }
612 func element(_ p: Timestamp.Point, full: Bool) -> String {
613 let datetime = date(p) + (time(p).map { "T" + $0 } ?? "")
614 let shown = full ? date(p) + (time(p).map { " " + $0 } ?? "") : (time(p) ?? date(p))
615 return "<time class=\"timestamp\" datetime=\"\(datetime)\">\(shown)</time>"
616 }
617 guard let end = stamp.end else { return element(stamp.start, full: true) }
618 let sameDay = end.year == stamp.start.year && end.month == stamp.start.month && end.day == stamp.start.day
619 return element(stamp.start, full: true) + "–" + element(end, full: !sameDay)
620 }
621
622 private func footnote(_ text: String) -> String {
623 guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return Self.escape(text) }
624 var label = String(m.1)
625 let inlineText = m.2.map(String.init)
626 if label.isEmpty {
627 anonymous += 1
628 label = "anonymous-\(anonymous)"
629 }
630 let number: Int
631 if let existing = footnoteNumbers[label] {
632 number = existing
633 } else {
634 let content: FootnoteContent
635 if let inlineText {
636 content = .inline(inlineText)
637 } else if let definition = definitions[label] {
638 content = .definition(definition)
639 } else {
640 return Self.escape(text)
641 }
642 footnoteOrder.append((label, content))
643 number = footnoteOrder.count
644 footnoteNumbers[label] = number
645 }
646 return "<sup class=\"footnote-ref\"><a id=\"fnr-\(number)\" href=\"#fn-\(number)\">\(number)</a></sup>"
647 }
648}
Sources/OrgCore/Export/MarkdownExport.swift added +282
@@ -0,0 +1,282 @@
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 "![](\(href))" }
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}
Sources/OrgCore/Keymap/Presets.swift +21
@@ -153,6 +153,27 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
153 [[bind]] 153 [[bind]]
154 keys = "C-c a" 154 keys = "C-c a"
155 command = "app.agenda" 155 command = "app.agenda"
156 [[bind]]
157 keys = "C-c C-e h h"
158 command = "app.export.html"
159 [[bind]]
160 keys = "C-c C-e h o"
161 command = "app.export.html-open"
162 [[bind]]
163 keys = "C-c C-e m m"
164 command = "app.export.markdown"
165 [[bind]]
166 keys = "C-c C-e l p"
167 command = "app.export.pdf"
168 [[bind]]
169 keys = "C-c C-e l l"
170 command = "app.export.latex"
171 [[bind]]
172 keys = "C-c C-e o o"
173 command = "app.export.odt"
174 [[bind]]
175 keys = "C-c C-e t u"
176 command = "app.export.text"
156 177
157 # Org 178 # Org
158 [[bind]] 179 [[bind]]
Sources/OrgCore/Syntax/SyntaxNode.swift +19
@@ -78,6 +78,25 @@ public final class SyntaxNode: Sendable {
78 return nil 78 return nil
79 } 79 }
80 80
81 public enum Element {
82 case node(SyntaxNode)
83 case token(SyntaxToken)
84 }
85
86 /// Child nodes and tokens in document order.
87 public var elements: [Element] {
88 var result: [Element] = []
89 var at = offset
90 for child in green.children {
91 switch child {
92 case .node(let node): result.append(.node(SyntaxNode(green: node, offset: at, parent: self)))
93 case .token(let token): result.append(.token(SyntaxToken(kind: token.kind, text: token.text, range: at..<(at + child.length))))
94 }
95 at += child.length
96 }
97 return result
98 }
99
81 /// This node and every node below it, in document order. 100 /// This node and every node below it, in document order.
82 public func descendants() -> [SyntaxNode] { 101 public func descendants() -> [SyntaxNode] {
83 [self] + children.flatMap { $0.descendants() } 102 [self] + children.flatMap { $0.descendants() }
Sources/OrgEditorAppKit/EditorCommands.swift +7
@@ -43,6 +43,13 @@ public enum EditorCommands {
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app), 43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), 44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app), 45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
46 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
47 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
48 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app),
49 EditorCommand(id: "app.export.pdf", title: "Export to PDF with Emacs", action: .app),
50 EditorCommand(id: "app.export.odt", title: "Export to ODT with Emacs", action: .app),
51 EditorCommand(id: "app.export.latex", title: "Export to LaTeX with Emacs", action: .app),
52 EditorCommand(id: "app.export.text", title: "Export to Plain Text with Emacs", action: .app),
46 53
47 movement("forward-char", "Forward Character", "moveForward:"), 54 movement("forward-char", "Forward Character", "moveForward:"),
48 movement("backward-char", "Backward Character", "moveBackward:"), 55 movement("backward-char", "Backward Character", "moveBackward:"),
Sources/Orgstar/ContentView.swift +48 −1
@@ -1,3 +1,4 @@
1import AppKit
1import OrgApp 2import OrgApp
2import OrgCore 3import OrgCore
3import OrgEditorAppKit 4import OrgEditorAppKit
@@ -38,7 +39,7 @@ struct ContentView: View {
38 @FocusState private var searchFocused: Bool 39 @FocusState private var searchFocused: Bool
39 @Environment(\.openWindow) private var openWindow 40 @Environment(\.openWindow) private var openWindow
40 41
41 var body: some View { 42 private var main: some View {
42 NavigationSplitView { 43 NavigationSplitView {
43 Sidebar(workspace: workspace, selection: $selectedFile) 44 Sidebar(workspace: workspace, selection: $selectedFile)
44 .navigationSplitViewColumnWidth(min: 200, ideal: 260) 45 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
@@ -109,6 +110,13 @@ struct ContentView: View {
109 .onChange(of: workspace.changedFile?.version) { 110 .onChange(of: workspace.changedFile?.version) {
110 if let path = workspace.changedFile?.path { session.fileChanged(path) } 111 if let path = workspace.changedFile?.path { session.fileChanged(path) }
111 } 112 }
113 }
114
115 var body: some View {
116 main
117 .onChange(of: commands.runRequest?.serial) {
118 if let id = commands.runRequest?.id { perform(id) }
119 }
112 .onChange(of: commands.jumpRequest?.id) { 120 .onChange(of: commands.jumpRequest?.id) {
113 guard let request = commands.jumpRequest else { return } 121 guard let request = commands.jumpRequest else { return }
114 if request.path != session.path { 122 if request.path != session.path {
@@ -166,6 +174,43 @@ struct ContentView: View {
166 } 174 }
167 } 175 }
168 176
177 /// Writes the export beside the file, as Emacs's export dispatcher does.
178 private func export(_ format: String) {
179 guard let path = session.path, let text = session.document?.text else {
180 commands.show("No file is open")
181 return
182 }
183 let url = URL(fileURLWithPath: path)
184 let base = url.deletingPathExtension()
185 let defaults = Preferences.orgSettings
186 switch format {
187 case "html", "html-open", "markdown":
188 let target = base.appendingPathExtension(format == "markdown" ? "md" : "html")
189 let content = format == "markdown"
190 ? MarkdownExport.document(text, settings: defaults)
191 : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults)
192 do {
193 try content.write(to: target, atomically: true, encoding: .utf8)
194 commands.show("Exported to \(target.lastPathComponent)")
195 if format == "html-open" { NSWorkspace.shared.open(target) }
196 } catch {
197 commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)")
198 }
199 default:
200 let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"]
201 guard let function = functions[format] else { return }
202 commands.show("Exporting with Emacs…")
203 Task {
204 do {
205 let written = try await EmacsRunner.export(text: text, path: path, function: function)
206 commands.show("Exported to \((written as NSString).lastPathComponent)")
207 } catch {
208 commands.show("\(error)")
209 }
210 }
211 }
212 }
213
169 /// Work a command hands to the platform: tables recalculated in Emacs. 214 /// Work a command hands to the platform: tables recalculated in Emacs.
170 private func runExternal(_ request: ExternalRequest) { 215 private func runExternal(_ request: ExternalRequest) {
171 switch request { 216 switch request {
@@ -264,6 +309,8 @@ struct ContentView: View {
264 if let message = session.archive() { commands.show(message) } 309 if let message = session.archive() { commands.show(message) }
265 case "app.agenda": 310 case "app.agenda":
266 openWindow(id: "agenda") 311 openWindow(id: "agenda")
312 case let id where id.hasPrefix("app.export."):
313 export(String(id.dropFirst("app.export.".count)))
267 default: 314 default:
268 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } 315 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
269 } 316 }
Sources/Orgstar/OrgstarApp.swift +17
@@ -40,6 +40,15 @@ final class AppCommands {
40 request.reply(text) 40 request.reply(text)
41 } 41 }
42 42
43 /// An app command from a menu, run by the main window.
44 private(set) var runRequest: (id: String, serial: Int)?
45 private var runSerial = 0
46
47 func run(_ id: String) {
48 runSerial += 1
49 runRequest = (id, runSerial)
50 }
51
43 func jump(path: String, offset: Int) { 52 func jump(path: String, offset: Int) {
44 jumpID += 1 53 jumpID += 1
45 jumpRequest = (path, offset, jumpID) 54 jumpRequest = (path, offset, jumpID)
@@ -161,6 +170,14 @@ struct OrgstarApp: App {
161 Button("Command Palette…") { commands.showingPalette = true } 170 Button("Command Palette…") { commands.showingPalette = true }
162 .keyboardShortcut("p", modifiers: [.command, .shift]) 171 .keyboardShortcut("p", modifiers: [.command, .shift])
163 } 172 }
173 CommandGroup(after: .saveItem) {
174 Menu("Export") {
175 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.export.") }, id: \.id) { command in
176 Button(command.title) { commands.run(command.id) }
177 }
178 }
179 .disabled(session.path == nil)
180 }
164 CommandGroup(replacing: .saveItem) { 181 CommandGroup(replacing: .saveItem) {
165 Button("Save") { 182 Button("Save") {
166 if !session.save(), session.conflict != nil { commands.showingConflict = true } 183 if !session.save(), session.conflict != nil { commands.showingConflict = true }
Tests/OrgAppTests/AppTests.swift +14
@@ -459,3 +459,17 @@ struct BabelSessionTests {
459 #expect(session.document?.text == "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n\n#+RESULTS:\n| answer |\n| 42 |\n") 459 #expect(session.document?.text == "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n\n#+RESULTS:\n| answer |\n| 42 |\n")
460 } 460 }
461} 461}
462
463struct EmacsExportTests {
464 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
465 func exportsBesideTheFile() async throws {
466 let notes = try Folder()
467 let file = try notes.write("doc.org", "#+TITLE: On disk\n")
468 // The buffer's text is exported, not the file's.
469 let written = try await EmacsRunner.export(text: "#+TITLE: In the buffer\n\n* Heading\nBody.\n", path: file.path, function: "org-ascii-export-to-ascii")
470 #expect(URL(fileURLWithPath: written).lastPathComponent == "doc.txt")
471 let text = try String(contentsOfFile: written, encoding: .utf8)
472 #expect(text.uppercased().contains("IN THE BUFFER") && text.contains("Body."), "\(text)")
473 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n")
474 }
475}
Tests/OrgCoreTests/Conformance/README.md added +1
@@ -0,0 +1 @@
1Cases from krz/org-conformance (382ce0a), 0BSD: the in-scope `.org` inputs and their golden `.skeleton` files.
Tests/OrgCoreTests/Conformance/blocks.org added +66
@@ -0,0 +1,66 @@
1#+TITLE: Blocks
2
3* Quote
4
5#+BEGIN_QUOTE
6A quoted paragraph with /markup/.
7
8And a second paragraph.
9#+END_QUOTE
10
11* Center
12
13#+BEGIN_CENTER
14Centred text.
15#+END_CENTER
16
17* Example
18
19#+BEGIN_EXAMPLE
20Verbatim *not bold* text.
21 Indentation preserved.
22#+END_EXAMPLE
23
24* Export
25
26#+BEGIN_EXPORT html
27<aside class="raw">Raw HTML passes through.</aside>
28#+END_EXPORT
29
30#+BEGIN_EXPORT latex
31\emph{A non-HTML backend is dropped.}
32#+END_EXPORT
33
34* Source
35
36#+BEGIN_SRC python
37def greet(name):
38 return f"hello {name}"
39#+END_SRC
40
41#+BEGIN_SRC
42plain block, no language
43#+END_SRC
44
45* Nested
46
47#+BEGIN_QUOTE
48A quote containing a source block:
49
50#+BEGIN_SRC sh
51echo hi
52#+END_SRC
53#+END_QUOTE
54
55* Verse
56
57#+BEGIN_VERSE
58Line breaks are the point
59 and indentation survives.
60#+END_VERSE
61
62* A named special block
63
64#+BEGIN_NOTE
65Contents are *org*, not literal text.
66#+END_NOTE
Tests/OrgCoreTests/Conformance/blocks.skeleton added +77
@@ -0,0 +1,77 @@
1<h2>
2"Quote"
3</h2>
4<blockquote>
5<p>
6"A quoted paragraph with"
7<em>
8"markup"
9</em>
10"."
11</p>
12<p>
13"And a second paragraph."
14</p>
15</blockquote>
16<h2>
17"Center"
18</h2>
19<p>
20"Centred text."
21</p>
22<h2>
23"Example"
24</h2>
25<pre>
26"Verbatim *not bold* text. Indentation preserved."
27</pre>
28<h2>
29"Export"
30</h2>
31<aside>
32"Raw HTML passes through."
33</aside>
34<h2>
35"Source"
36</h2>
37<pre>
38<code>
39"def greet(name): return f\"hello {name}\""
40</code>
41</pre>
42<pre>
43<code>
44"plain block, no language"
45</code>
46</pre>
47<h2>
48"Nested"
49</h2>
50<blockquote>
51<p>
52"A quote containing a source block:"
53</p>
54<pre>
55<code>
56"echo hi"
57</code>
58</pre>
59</blockquote>
60<h2>
61"Verse"
62</h2>
63<p>
64"Line breaks are the point"
65<br>
66"and indentation survives."
67</p>
68<h2>
69"A named special block"
70</h2>
71<p>
72"Contents are"
73<strong>
74"org"
75</strong>
76", not literal text."
77</p>
Tests/OrgCoreTests/Conformance/core.org added +21
@@ -0,0 +1,21 @@
1#+TITLE: Core Constructs
2
3Intro paragraph with a bare URL https://example.com and some ~inline code~.
4
5* Ordered and checked
6
71. first item
82. second item with /emphasis/
9
10- [ ] todo item
11- [X] done item
12
13* Links and code
14
15An external [[https://example.org][site]] and a bare [[https://bare.example]].
16
17#+BEGIN_SRC rust
18fn main() {
19 println!("hello");
20}
21#+END_SRC
Tests/OrgCoreTests/Conformance/core.skeleton added +58
@@ -0,0 +1,58 @@
1<p>
2"Intro paragraph with a bare URL"
3<a href="https://example.com">
4"https://example.com"
5</a>
6"and some"
7<code>
8"inline code"
9</code>
10"."
11</p>
12<h2>
13"Ordered and checked"
14</h2>
15<ol>
16<li>
17"first item"
18</li>
19<li>
20"second item with"
21<em>
22"emphasis"
23</em>
24</li>
25</ol>
26<ul>
27<li>
28<code>
29"[ ]"
30</code>
31"todo item"
32</li>
33<li>
34<code>
35"[X]"
36</code>
37"done item"
38</li>
39</ul>
40<h2>
41"Links and code"
42</h2>
43<p>
44"An external"
45<a href="https://example.org">
46"site"
47</a>
48"and a bare"
49<a href="https://bare.example">
50"https://bare.example"
51</a>
52"."
53</p>
54<pre>
55<code>
56"fn main() { println!(\"hello\"); }"
57</code>
58</pre>
Tests/OrgCoreTests/Conformance/elements.org added +31
@@ -0,0 +1,31 @@
1#+TITLE: Element Sampler
2#+FILETAGS: :sample:
3
4* Code and tables
5
6#+BEGIN_SRC rust
7fn main() {
8 println!("hello");
9}
10#+END_SRC
11
12| Name | Score |
13|-------+-------|
14| alpha | 10 |
15| beta | 20 |
16
17* Links and footnotes
18
19An external link: [[https://example.com][Example]] and an id link [[id:abc-123]].
20
21Text with a footnote reference.[fn:1]
22
23[fn:1] The footnote definition.
24
25* Blocks
26
27#+BEGIN_QUOTE
28A quoted paragraph.
29#+END_QUOTE
30
31-----
Tests/OrgCoreTests/Conformance/elements.skeleton added +82
@@ -0,0 +1,82 @@
1<h2>
2"Code and tables"
3</h2>
4<pre>
5<code>
6"fn main() { println!(\"hello\"); }"
7</code>
8</pre>
9<table>
10<thead>
11<tr>
12<th>
13"Name"
14</th>
15<th>
16"Score"
17</th>
18</tr>
19</thead>
20<tbody>
21<tr>
22<td>
23"alpha"
24</td>
25<td>
26"10"
27</td>
28</tr>
29<tr>
30<td>
31"beta"
32</td>
33<td>
34"20"
35</td>
36</tr>
37</tbody>
38</table>
39<h2>
40"Links and footnotes"
41</h2>
42<p>
43"An external link:"
44<a href="https://example.com">
45"Example"
46</a>
47"and an id link"
48<a href="#abc-123">
49"abc-123"
50</a>
51"."
52</p>
53<p>
54"Text with a footnote reference."
55<sup>
56<a href="#fn-1">
57"1"
58</a>
59</sup>
60</p>
61<h2>
62"Blocks"
63</h2>
64<blockquote>
65<p>
66"A quoted paragraph."
67</p>
68</blockquote>
69<hr>
70<section>
71<hr>
72<ol>
73<li>
74<p>
75"The footnote definition."
76</p>
77<a href="#fnr-1">
78"↩"
79</a>
80</li>
81</ol>
82</section>
Tests/OrgCoreTests/Conformance/footnote.org added +8
@@ -0,0 +1,8 @@
1#+TITLE: Footnote Sample
2
3Text with a reference.[fn:1] And a second one.[fn:2]
4
5An inline footnote.[fn:3:defined right here]
6
7[fn:1] The first definition.
8[fn:2] The second definition, with /emphasis/.
Tests/OrgCoreTests/Conformance/footnote.skeleton added +53
@@ -0,0 +1,53 @@
1<p>
2"Text with a reference."
3<sup>
4<a href="#fn-1">
5"1"
6</a>
7</sup>
8"And a second one."
9<sup>
10<a href="#fn-2">
11"2"
12</a>
13</sup>
14</p>
15<p>
16"An inline footnote."
17<sup>
18<a href="#fn-3">
19"3"
20</a>
21</sup>
22</p>
23<section>
24<hr>
25<ol>
26<li>
27<p>
28"The first definition."
29</p>
30<a href="#fnr-1">
31"↩"
32</a>
33</li>
34<li>
35<p>
36"The second definition, with"
37<em>
38"emphasis"
39</em>
40"."
41</p>
42<a href="#fnr-2">
43"↩"
44</a>
45</li>
46<li>
47"defined right here"
48<a href="#fnr-3">
49"↩"
50</a>
51</li>
52</ol>
53</section>
Tests/OrgCoreTests/Conformance/headings.org added +20
@@ -0,0 +1,20 @@
1#+TITLE: Heading Metadata
2
3* TODO [#A] Write the parser :work:rust:
4:PROPERTIES:
5:CUSTOM_ID: write-parser
6:OWNER: nobody
7:END:
8A heading carrying a keyword, a priority, tags and a property drawer.
9
10** DONE Nested and finished
11Sub-headings nest by star count.
12
13** [#C] Priority without a keyword
14A priority cookie can stand alone.
15
16* TODOs are not a keyword
17The word boundary matters: this heading has no TODO keyword.
18
19* DONE
20A keyword with no title at all.
Tests/OrgCoreTests/Conformance/headings.skeleton added +30
@@ -0,0 +1,30 @@
1<h2>
2"TODO [#A] Write the parser work rust"
3</h2>
4<p>
5"A heading carrying a keyword, a priority, tags and a property drawer."
6</p>
7<h3>
8"DONE Nested and finished"
9</h3>
10<p>
11"Sub-headings nest by star count."
12</p>
13<h3>
14"[#C] Priority without a keyword"
15</h3>
16<p>
17"A priority cookie can stand alone."
18</p>
19<h2>
20"TODOs are not a keyword"
21</h2>
22<p>
23"The word boundary matters: this heading has no TODO keyword."
24</p>
25<h2>
26"DONE"
27</h2>
28<p>
29"A keyword with no title at all."
30</p>
Tests/OrgCoreTests/Conformance/images.org added +25
@@ -0,0 +1,25 @@
1#+TITLE: Images
2
3* Bare image
4
5[[file:diagram.png]]
6
7* Captioned figure
8
9#+CAPTION: The pipeline, end to end
10#+ATTR_HTML: :width 640 :class diagram
11[[file:pipeline.svg]]
12
13* Caption with markup
14
15#+CAPTION: A /stylised/ chart
16[[file:chart.png]]
17
18* Quoted attribute values
19
20#+ATTR_HTML: :alt "a cat, sitting" :loading lazy
21[[file:cat.jpg]]
22
23* Image with a description is a link
24
25[[file:diagram.png][the diagram]]
Tests/OrgCoreTests/Conformance/images.skeleton added +42
@@ -0,0 +1,42 @@
1<h2>
2"Bare image"
3</h2>
4<p>
5<img src="diagram.png">
6</p>
7<h2>
8"Captioned figure"
9</h2>
10<figure>
11<img src="pipeline.svg">
12<figcaption>
13"Figure 1: The pipeline, end to end"
14</figcaption>
15</figure>
16<h2>
17"Caption with markup"
18</h2>
19<figure>
20<img src="chart.png">
21<figcaption>
22"Figure 2: A"
23<em>
24"stylised"
25</em>
26"chart"
27</figcaption>
28</figure>
29<h2>
30"Quoted attribute values"
31</h2>
32<figure>
33<img src="cat.jpg">
34</figure>
35<h2>
36"Image with a description is a link"
37</h2>
38<p>
39<a href="diagram.png">
40"the diagram"
41</a>
42</p>
Tests/OrgCoreTests/Conformance/listblocks.org added +46
@@ -0,0 +1,46 @@
1#+TITLE: Blocks inside list items
2
3* Source blocks
4
51. Fork the repository
62. Create a new branch:
7 #+begin_src bash
8 git checkout -b my-feature
9 #+end_src
103. Open a pull request
11
12* Other blocks
13
14- an item with an example block
15
16 #+begin_example
17 plain example text
18 #+end_example
19
20- an item with a quote
21
22 #+begin_quote
23 quoted line
24 #+end_quote
25
26* Blocks alongside other content
27
28- an item with a sublist and a block
29 - inner item
30 #+begin_src sh
31 echo hi
32 #+end_src
33
34- an item with two paragraphs and a block
35
36 the second paragraph
37
38 #+begin_src sh
39 echo again
40 #+end_src
41
42- an item with a table
43
44 | a | b |
45 |---+---|
46 | 1 | 2 |
Tests/OrgCoreTests/Conformance/listblocks.skeleton added +94
@@ -0,0 +1,94 @@
1<h2>
2"Source blocks"
3</h2>
4<ol>
5<li>
6"Fork the repository"
7</li>
8<li>
9"Create a new branch:"
10<pre>
11<code>
12"git checkout -b my-feature"
13</code>
14</pre>
15</li>
16<li>
17"Open a pull request"
18</li>
19</ol>
20<h2>
21"Other blocks"
22</h2>
23<ul>
24<li>
25"an item with an example block"
26<pre>
27"plain example text"
28</pre>
29</li>
30<li>
31"an item with a quote"
32<blockquote>
33<p>
34"quoted line"
35</p>
36</blockquote>
37</li>
38</ul>
39<h2>
40"Blocks alongside other content"
41</h2>
42<ul>
43<li>
44"an item with a sublist and a block"
45<ul>
46<li>
47"inner item"
48</li>
49</ul>
50<pre>
51<code>
52"echo hi"
53</code>
54</pre>
55</li>
56<li>
57<p>
58"an item with two paragraphs and a block"
59</p>
60<p>
61"the second paragraph"
62</p>
63<pre>
64<code>
65"echo again"
66</code>
67</pre>
68</li>
69<li>
70"an item with a table"
71<table>
72<thead>
73<tr>
74<th>
75"a"
76</th>
77<th>
78"b"
79</th>
80</tr>
81</thead>
82<tbody>
83<tr>
84<td>
85"1"
86</td>
87<td>
88"2"
89</td>
90</tr>
91</tbody>
92</table>
93</li>
94</ul>
Tests/OrgCoreTests/Conformance/lists.org added +38
@@ -0,0 +1,38 @@
1#+TITLE: Lists
2
3* Nesting
4
5- outer item
6 - inner item
7 - deepest item
8 - second inner
9- second outer
10
11* Ordered
12
131. first
142. second
15 1. second point one
16 2. second point two
173. third
18
19* Checkboxes
20
21- [ ] not done
22- [X] done
23- [-] partially done
24
25* Description
26
27- term one :: the first definition
28- term two :: the second definition, which is
29 soft-wrapped across two lines
30- /marked up/ term :: definitions hold inline markup
31
32* Multi-paragraph items
33
34- an item whose body has two paragraphs
35
36 the second paragraph, indented under the bullet
37
38- a plain sibling
Tests/OrgCoreTests/Conformance/lists.skeleton added +111
@@ -0,0 +1,111 @@
1<h2>
2"Nesting"
3</h2>
4<ul>
5<li>
6"outer item"
7<ul>
8<li>
9"inner item"
10<ul>
11<li>
12"deepest item"
13</li>
14</ul>
15</li>
16<li>
17"second inner"
18</li>
19</ul>
20</li>
21<li>
22"second outer"
23</li>
24</ul>
25<h2>
26"Ordered"
27</h2>
28<ol>
29<li>
30"first"
31</li>
32<li>
33"second"
34<ol>
35<li>
36"second point one"
37</li>
38<li>
39"second point two"
40</li>
41</ol>
42</li>
43<li>
44"third"
45</li>
46</ol>
47<h2>
48"Checkboxes"
49</h2>
50<ul>
51<li>
52<code>
53"[ ]"
54</code>
55"not done"
56</li>
57<li>
58<code>
59"[X]"
60</code>
61"done"
62</li>
63<li>
64<code>
65"[-]"
66</code>
67"partially done"
68</li>
69</ul>
70<h2>
71"Description"
72</h2>
73<dl>
74<dt>
75"term one"
76</dt>
77<dd>
78"the first definition"
79</dd>
80<dt>
81"term two"
82</dt>
83<dd>
84"the second definition, which is soft-wrapped across two lines"
85</dd>
86<dt>
87<em>
88"marked up"
89</em>
90"term"
91</dt>
92<dd>
93"definitions hold inline markup"
94</dd>
95</dl>
96<h2>
97"Multi-paragraph items"
98</h2>
99<ul>
100<li>
101<p>
102"an item whose body has two paragraphs"
103</p>
104<p>
105"the second paragraph, indented under the bullet"
106</p>
107</li>
108<li>
109"a plain sibling"
110</li>
111</ul>
Tests/OrgCoreTests/Conformance/minimal.org added +20
@@ -0,0 +1,20 @@
1#+TITLE: Minimal Fixture
2#+DATE: 2026-08-08
3#+AUTHOR: Owner
4
5A single paragraph of preamble text before any heading.
6
7* First Heading
8 :PROPERTIES:
9 :CUSTOM_ID: first
10 :END:
11
12Some body text with *bold*, /italic/, and =verbatim=.
13
14** A Subheading :tag1:tag2:
15
16- an unordered item
17- another with a checkbox [ ]
18
19* Second Heading
20 See [[#first][the first heading]].
Tests/OrgCoreTests/Conformance/minimal.skeleton added +42
@@ -0,0 +1,42 @@
1<p>
2"A single paragraph of preamble text before any heading."
3</p>
4<h2>
5"First Heading"
6</h2>
7<p>
8"Some body text with"
9<strong>
10"bold"
11</strong>
12","
13<em>
14"italic"
15</em>
16", and"
17<code>
18"verbatim"
19</code>
20"."
21</p>
22<h3>
23"A Subheading tag1 tag2"
24</h3>
25<ul>
26<li>
27"an unordered item"
28</li>
29<li>
30"another with a checkbox [ ]"
31</li>
32</ul>
33<h2>
34"Second Heading"
35</h2>
36<p>
37"See"
38<a href="#first">
39"the first heading"
40</a>
41"."
42</p>
Tests/OrgCoreTests/Conformance/table.org added +6
@@ -0,0 +1,6 @@
1#+TITLE: Table Sample
2
3| Name | Score |
4|-------+-------|
5| alpha | 10 |
6| beta | 20 |
Tests/OrgCoreTests/Conformance/table.skeleton added +30
@@ -0,0 +1,30 @@
1<table>
2<thead>
3<tr>
4<th>
5"Name"
6</th>
7<th>
8"Score"
9</th>
10</tr>
11</thead>
12<tbody>
13<tr>
14<td>
15"alpha"
16</td>
17<td>
18"10"
19</td>
20</tr>
21<tr>
22<td>
23"beta"
24</td>
25<td>
26"20"
27</td>
28</tr>
29</tbody>
30</table>
Tests/OrgCoreTests/Conformance/tblfm.org added +10
@@ -0,0 +1,10 @@
1#+TITLE: Table Formula
2
3The cells deliberately contradict the formula: org's HTML exporter does not
4recalculate on export, so both exporters must emit the values as written.
5
6| N | N^2 |
7|---+-----|
8| 2 | 999 |
9| 3 | 888 |
10#+TBLFM: $2=$1^2
Tests/OrgCoreTests/Conformance/tblfm.skeleton added +36
@@ -0,0 +1,36 @@
1<p>
2"The cells deliberately contradict the formula: org's HTML exporter does not recalculate on export, so both exporters must emit the values as written."
3</p>
4<table>
5<thead>
6<tr>
7<th>
8"N"
9</th>
10<th>
11"N"
12<sup>
13"2"
14</sup>
15</th>
16</tr>
17</thead>
18<tbody>
19<tr>
20<td>
21"2"
22</td>
23<td>
24"999"
25</td>
26</tr>
27<tr>
28<td>
29"3"
30</td>
31<td>
32"888"
33</td>
34</tr>
35</tbody>
36</table>
Tests/OrgCoreTests/Conformance/timestamps.org added +21
@@ -0,0 +1,21 @@
1#+TITLE: Timestamps
2
3* Single
4
5An active date <2024-01-15 Mon> and an inactive one [2024-01-15 Mon].
6
7With a time: <2024-01-15 Mon 10:30>.
8
9* Ranges
10
11A same-day time range <2024-01-15 Mon 10:00-11:45>.
12
13A multi-day range <2024-01-15 Mon>--<2024-01-20 Sat>.
14
15* Ignored decorations
16
17A repeater is dropped: <2024-01-15 Mon +1w>.
18
19* Not timestamps
20
21Comparisons like 3 < 4 and [not a stamp] stay literal text.
Tests/OrgCoreTests/Conformance/timestamps.skeleton added +62
@@ -0,0 +1,62 @@
1<h2>
2"Single"
3</h2>
4<p>
5"An active date"
6<time>
7"2024-01-15"
8</time>
9"and an inactive one"
10<time>
11"2024-01-15"
12</time>
13"."
14</p>
15<p>
16"With a time:"
17<time>
18"2024-01-15 10:30"
19</time>
20"."
21</p>
22<h2>
23"Ranges"
24</h2>
25<p>
26"A same-day time range"
27<time>
28"2024-01-15 10:00"
29</time>
30"–"
31<time>
32"11:45"
33</time>
34"."
35</p>
36<p>
37"A multi-day range"
38<time>
39"2024-01-15"
40</time>
41"–"
42<time>
43"2024-01-20"
44</time>
45"."
46</p>
47<h2>
48"Ignored decorations"
49</h2>
50<p>
51"A repeater is dropped:"
52<time>
53"2024-01-15"
54</time>
55"."
56</p>
57<h2>
58"Not timestamps"
59</h2>
60<p>
61"Comparisons like 3 < 4 and [not a stamp] stay literal text."
62</p>
Tests/OrgCoreTests/ExportTests.swift added +223
@@ -0,0 +1,223 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// The org-conformance skeleton reduction (SKELETON.md): element opens and closes and text
6/// runs, without `div`/`span` and attributes other than `href` and `src`.
7enum Skeleton {
8 static let ignored: Set<String> = ["div", "span"]
9 static let void: Set<String> = ["br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr"]
10
11 static func decode(_ s: String) -> String {
12 var out = ""
13 var i = s.startIndex
14 while i < s.endIndex {
15 if s[i] == "&", let semicolon = s[i...].prefix(13).firstIndex(of: ";") {
16 let name = String(s[s.index(after: i)..<semicolon])
17 var replacement: String?
18 switch name {
19 case "amp": replacement = "&"
20 case "lt": replacement = "<"
21 case "gt": replacement = ">"
22 case "quot": replacement = "\""
23 case "apos": replacement = "'"
24 case "nbsp": replacement = " "
25 default:
26 if name.hasPrefix("#x") || name.hasPrefix("#X"), let v = UInt32(name.dropFirst(2), radix: 16), let u = Unicode.Scalar(v) {
27 replacement = String(Character(u))
28 } else if name.hasPrefix("#"), let v = UInt32(name.dropFirst()), let u = Unicode.Scalar(v) {
29 replacement = String(Character(u))
30 }
31 }
32 if let replacement {
33 out += replacement == "\u{a0}" ? " " : replacement
34 i = s.index(after: semicolon)
35 continue
36 }
37 }
38 out.append(s[i] == "\u{a0}" ? " " : s[i])
39 i = s.index(after: i)
40 }
41 return out
42 }
43
44 static func reduce(_ html: String) -> String {
45 var lines: [String] = []
46 var text = ""
47 func flush() {
48 let collapsed = decode(text).split(whereSeparator: \.isWhitespace).joined(separator: " ")
49 if !collapsed.isEmpty {
50 lines.append("\"" + collapsed.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"")
51 }
52 text = ""
53 }
54 var i = html.startIndex
55 while i < html.endIndex {
56 if html[i] == "<" {
57 if html[i...].hasPrefix("<!--") {
58 let end = html[i...].range(of: "-->")?.upperBound ?? html.endIndex
59 i = end
60 continue
61 }
62 guard let close = html[i...].firstIndex(of: ">") else { text.append(html[i]); i = html.index(after: i); continue }
63 let tag = String(html[html.index(after: i)..<close])
64 i = html.index(after: close)
65 if tag.hasPrefix("!") { continue }
66 let closing = tag.hasPrefix("/")
67 var body = closing ? String(tag.dropFirst()) : tag
68 if body.hasSuffix("/") { body.removeLast() }
69 let name = String(body.prefix { !$0.isWhitespace }).lowercased()
70 if ignored.contains(name) { continue }
71 if closing {
72 if void.contains(name) { continue }
73 flush()
74 lines.append("</\(name)>")
75 continue
76 }
77 flush()
78 var attributes = ""
79 for attribute in ["href", "src"] {
80 if let m = body.firstMatch(of: try! Regex("\\s\(attribute)=\"([^\"]*)\"")) {
81 attributes += " \(attribute)=\"\(decode(String(m.output[1].substring!)))\""
82 }
83 }
84 lines.append("<\(name)\(attributes)>")
85 } else {
86 text.append(html[i])
87 i = html.index(after: i)
88 }
89 }
90 flush()
91 return lines.joined(separator: "\n") + "\n"
92 }
93}
94
95struct ExportTests {
96 static let folder = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Conformance")
97
98 @Test(arguments: ["minimal", "core", "elements", "headings", "listblocks", "lists", "table", "blocks", "timestamps", "footnote", "images", "tblfm"])
99 func conformance(_ name: String) throws {
100 let org = try String(contentsOf: Self.folder.appendingPathComponent("\(name).org"), encoding: .utf8)
101 let expected = try String(contentsOf: Self.folder.appendingPathComponent("\(name).skeleton"), encoding: .utf8)
102 let html = HTMLExport.body(org)
103 let ours = Skeleton.reduce(html)
104 #expect(ours == expected, "\(name)\n--- ours\n\(ours)\n--- html\n\(html)")
105 }
106
107 @Test func page() {
108 let page = HTMLExport.page("#+TITLE: Notes & Things\n#+AUTHOR: Me\n\nHello.\n", fallbackTitle: "x")
109 #expect(page.contains("<title>Notes &amp; Things</title>"))
110 #expect(page.contains("<h1 class=\"title\">Notes &amp; Things</h1>"))
111 #expect(page.contains("<p class=\"byline\">Me</p>"))
112 #expect(page.contains("<p>Hello.</p>"))
113 }
114}
115
116struct MarkdownExportTests {
117 @Test func document() {
118 let org = """
119 #+TITLE: Notes
120
121 Intro with *bold*, /italic/, =code=, a [[https://example.com][link]] and a note.[fn:1]
122
123 * TODO Plans :work:
124 - [ ] first
125 - [X] second
126 1. nested
127 - term :: definition
128
129 | a | b |
130 |---+---|
131 | 1 | 2 |
132
133 #+begin_src swift
134 let x = 1
135 #+end_src
136
137 #+begin_quote
138 Quoted.
139 #+end_quote
140
141 [fn:1] The note.
142 """
143 let expected = """
144 # Notes
145
146 Intro with **bold**, *italic*, `code`, a [link](https://example.com) and a note.[^1]
147
148 ## TODO Plans `work`
149
150 - [ ] first
151 - [x] second
152 1. nested
153 - **term**: definition
154
155 | a | b |
156 |---|---|
157 | 1 | 2 |
158
159 ```swift
160 let x = 1
161 ```
162
163 > Quoted.
164
165 [^1]: The note.
166
167 """
168 let ours = MarkdownExport.document(org)
169 #expect(ours == expected)
170 }
171}
172
173struct ExportCorpusTests {
174 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: every file exports to HTML and Markdown.
175 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
176 func exportsEveryFile() throws {
177 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
178 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
179 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }
180 var slowest = (0.0, "")
181 for file in files {
182 guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
183 let start = Date()
184 let html = HTMLExport.page(text, fallbackTitle: file.lastPathComponent)
185 let markdown = MarkdownExport.document(text)
186 let elapsed = Date().timeIntervalSince(start)
187 if elapsed > slowest.0 { slowest = (elapsed, file.lastPathComponent) }
188 #expect(html.contains("</html>") && !markdown.isEmpty, "\(file.path)")
189 }
190 print("exported \(files.count) files; slowest \(slowest.1) in \(String(format: "%.2f", slowest.0)) s")
191 }
192}
193
194struct ExportResultsTests {
195 @Test func resultsFollowExports() {
196 let org = """
197 #+begin_src sh
198 echo a
199 #+end_src
200
201 #+RESULTS:
202 : a
203
204 #+begin_src sh :exports both
205 echo b
206 #+end_src
207
208 #+RESULTS:
209 : b
210
211 #+begin_src sh :exports results
212 echo c
213 #+end_src
214
215 #+RESULTS:
216 : c
217 """
218 let html = HTMLExport.body(org)
219 #expect(html.contains("echo a") && !html.contains(">a</pre>"))
220 #expect(html.contains("echo b") && html.contains(">b</pre>"))
221 #expect(!html.contains("echo c") && html.contains(">c</pre>"))
222 }
223}
docs/plans/2026-10-05-export.md added +23
@@ -0,0 +1,23 @@
1# Export Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Native HTML and Markdown export from OrgCore's tree, and every other format through Emacs.
6
7**Architecture:** `OrgCore/Export/HTMLExport.swift` renders the tree in the shape orgo gives, since the org-conformance corpus (orgo's output, checked against `ox-html`) is the reference the design names: `*` is `<h2>` with a `CUSTOM_ID` or slug id, TODO/priority/tag spans, `<strong>`/`<em>`/`<code>`, timestamps as `<time>` with ranges joined by `–`, footnotes numbered by first reference and listed in a closing `<section>`, captioned or `ATTR_HTML` images as `<figure>`, lists split where bullets change between ordered and unordered, quote/center/special block contents rendered as org, export blocks for html only. `page` wraps the body with the title, author and date and a small stylesheet. `MarkdownExport.swift` writes GitHub-flavored Markdown. Both follow `:exports` for src blocks and their results and run nothing. The app writes `file.html`/`file.md` beside the file as Emacs's dispatcher does (`C-c C-e h h`, `h o`, `m m`, File ▸ Export); PDF, LaTeX, ODT and plain text run `emacs --batch` on the buffer's text with `org-export-use-babel` off.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing.
10
11**Spec:** `docs/design.md`, "Phase 5", "Testing" (conformance).
12
13## Global Constraints
14
15- The 12 in-scope org-conformance cases (vendored in `Tests/OrgCoreTests/Conformance`) match their skeletons.
16- Markdown is GitHub-flavored, not `ox-md` (which leaves tables as HTML and code as indented blocks).
17
18---
19
20- [x] HTML body, page, skeleton reduction, conformance; `:exports`.
21- [x] Markdown.
22- [x] App: export commands, menu, keys; `EmacsRunner.export`; corpus smoke test.
23- [x] Commit "Export".