Export !26
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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Document metadata from `#+TITLE`, `#+AUTHOR` and `#+DATE`. | ||
| 4 | public 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. | ||
| 13 | public 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 | |||
| 75 | final 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 += "&" | ||
| 98 | case "<": out += "<" | ||
| 99 | case ">": out += ">" | ||
| 100 | case "\"": out += """ | ||
| 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)\">↩</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>[ ]</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: " ", 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 @@ | |||
| 1 | import 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. | ||
| 5 | public 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 | |||
| 15 | final class MarkdownRenderer { | ||
| 16 | let settings: OrgSettings | ||
| 17 | private var definitions: [String: String] = [:] | ||
| 18 | private var numbers: [String: Int] = [:] | ||
| 19 | private var notes: [(Int, String)] = [] | ||
| 20 | private var anonymous = 0 | ||
| 21 | |||
| 22 | init(settings: OrgSettings) { | ||
| 23 | self.settings = settings | ||
| 24 | } | ||
| 25 | |||
| 26 | func document(_ text: String) -> String { | ||
| 27 | let tree = OrgParser.parse(text, defaults: settings) | ||
| 28 | for node in tree.root.descendants() where node.kind == .footnoteDefinition { | ||
| 29 | if let m = node.text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) { | ||
| 30 | definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | var out = blocks(tree.root) | ||
| 34 | if !notes.isEmpty { | ||
| 35 | out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n" | ||
| 36 | } | ||
| 37 | return out | ||
| 38 | } | ||
| 39 | |||
| 40 | func inlineString(_ text: String) -> String { | ||
| 41 | let tree = OrgParser.parse(text, defaults: settings) | ||
| 42 | guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text } | ||
| 43 | return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 44 | } | ||
| 45 | |||
| 46 | private var lastExports = "code" | ||
| 47 | private var results = false | ||
| 48 | |||
| 49 | private func blocks(_ node: SyntaxNode, indent: String = "") -> String { | ||
| 50 | let parts = node.children.map { child -> String in | ||
| 51 | // Results show for `:exports results` and `both`, as in the HTML export. | ||
| 52 | if child.kind == .affiliatedKeyword { | ||
| 53 | if child.text.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+RESULTS") { results = true } | ||
| 54 | return "" | ||
| 55 | } | ||
| 56 | defer { results = false } | ||
| 57 | if results { | ||
| 58 | defer { lastExports = "code" } | ||
| 59 | if lastExports != "results", lastExports != "both" { return "" } | ||
| 60 | } | ||
| 61 | if child.kind == .block, let exports = HTMLRenderer.srcExports(child) { | ||
| 62 | lastExports = exports | ||
| 63 | if exports == "results" || exports == "none" { return "" } | ||
| 64 | } | ||
| 65 | return element(child, indent: indent) | ||
| 66 | }.filter { !$0.isEmpty } | ||
| 67 | return parts.isEmpty ? "" : parts.joined(separator: "\n\n") + "\n\n" | ||
| 68 | } | ||
| 69 | |||
| 70 | private func blocks(text: String) -> String { | ||
| 71 | blocks(OrgParser.parse(text, defaults: settings).root) | ||
| 72 | } | ||
| 73 | |||
| 74 | private func element(_ node: SyntaxNode, indent: String) -> String { | ||
| 75 | switch node.kind { | ||
| 76 | case .zerothSection, .section: | ||
| 77 | return blocks(node, indent: indent).trimmingCharacters(in: .newlines) | ||
| 78 | case .heading: | ||
| 79 | let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1 | ||
| 80 | var parts: [String] = [] | ||
| 81 | if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) } | ||
| 82 | if let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) } | ||
| 83 | if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } | ||
| 84 | if let tags = node.tokens.first(where: { $0.kind == .tags })?.text { | ||
| 85 | parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " ")) | ||
| 86 | } | ||
| 87 | return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ") | ||
| 88 | case .paragraph: | ||
| 89 | return indent + inline(node).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 90 | .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + indent) | ||
| 91 | case .plainList: | ||
| 92 | return list(node, indent: indent) | ||
| 93 | case .table: | ||
| 94 | return table(node, indent: indent) | ||
| 95 | case .block: | ||
| 96 | return block(node, indent: indent) | ||
| 97 | case .horizontalRule: | ||
| 98 | return indent + "---" | ||
| 99 | case .fixedWidth: | ||
| 100 | let lines = node.text.split(separator: "\n").map { line -> String in | ||
| 101 | let trimmed = line.drop { $0 == " " || $0 == "\t" } | ||
| 102 | return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0)) | ||
| 103 | } | ||
| 104 | return fence(lines, language: "", indent: indent) | ||
| 105 | default: | ||
| 106 | return "" | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | private func fence(_ lines: [String], language: String, indent: String) -> String { | ||
| 111 | let longest = lines.map { $0.prefix { $0 == "`" }.count }.max() ?? 0 | ||
| 112 | let ticks = String(repeating: "`", count: max(3, longest + 1)) | ||
| 113 | return ([indent + ticks + language] + lines.map { indent + $0 } + [indent + ticks]).joined(separator: "\n") | ||
| 114 | } | ||
| 115 | |||
| 116 | private func list(_ node: SyntaxNode, indent: String) -> String { | ||
| 117 | var out: [String] = [] | ||
| 118 | var number = 0 | ||
| 119 | for item in node.children where item.kind == .item { | ||
| 120 | let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-" | ||
| 121 | let ordered = bullet.first.map { $0.isNumber || $0.isLetter } ?? false | ||
| 122 | number += 1 | ||
| 123 | var marker = ordered ? "\(number)." : "-" | ||
| 124 | if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text { | ||
| 125 | marker += box == "[X]" || box == "[x]" ? " [x]" : " [ ]" | ||
| 126 | } | ||
| 127 | let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count) | ||
| 128 | var parts: [String] = [] | ||
| 129 | for (index, child) in item.children.enumerated() { | ||
| 130 | if index == 0, child.kind == .paragraph { | ||
| 131 | var text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 132 | .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent) | ||
| 133 | if let separator = text.range(of: " :: ") { | ||
| 134 | text = "**" + text[..<separator.lowerBound] + "**: " + text[separator.upperBound...] | ||
| 135 | } | ||
| 136 | parts.append(text) | ||
| 137 | } else { | ||
| 138 | parts.append(element(child, indent: childIndent)) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined() | ||
| 142 | out.append(indent + marker + " " + body) | ||
| 143 | } | ||
| 144 | return out.joined(separator: "\n") | ||
| 145 | } | ||
| 146 | |||
| 147 | private func table(_ node: SyntaxNode, indent: String) -> String { | ||
| 148 | var rows: [[String]] = [] | ||
| 149 | var ruleAfter: Int? | ||
| 150 | for row in node.children where row.kind == .tableRow { | ||
| 151 | let cells = row.children.filter { $0.kind == .tableCell } | ||
| 152 | if cells.isEmpty { | ||
| 153 | if ruleAfter == nil, !rows.isEmpty { ruleAfter = rows.count } | ||
| 154 | continue | ||
| 155 | } | ||
| 156 | rows.append(cells.map { inline($0).trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "|", with: "\\|") }) | ||
| 157 | } | ||
| 158 | guard !rows.isEmpty else { return "" } | ||
| 159 | let columns = rows.map(\.count).max() ?? 0 | ||
| 160 | rows = rows.map { $0 + Array(repeating: "", count: columns - $0.count) } | ||
| 161 | var header: [String] | ||
| 162 | var body: [[String]] | ||
| 163 | if ruleAfter == 1 { | ||
| 164 | header = rows[0] | ||
| 165 | body = Array(rows.dropFirst()) | ||
| 166 | } else { | ||
| 167 | header = Array(repeating: "", count: columns) | ||
| 168 | body = rows | ||
| 169 | } | ||
| 170 | func line(_ cells: [String]) -> String { indent + "| " + cells.joined(separator: " | ") + " |" } | ||
| 171 | return ([line(header), indent + "|" + Array(repeating: "---", count: columns).joined(separator: "|") + "|"] + body.map(line)).joined(separator: "\n") | ||
| 172 | } | ||
| 173 | |||
| 174 | private func block(_ node: SyntaxNode, indent: String) -> String { | ||
| 175 | var lines = node.text.components(separatedBy: "\n") | ||
| 176 | if lines.last == "" { lines.removeLast() } | ||
| 177 | guard let first = lines.first, let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" } | ||
| 178 | let type = m.1.lowercased() | ||
| 179 | let parameters = String(m.2) | ||
| 180 | var body = Array(lines.dropFirst().dropLast()).map { | ||
| 181 | $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) | ||
| 182 | } | ||
| 183 | let common = body.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }.min() ?? 0 | ||
| 184 | body = body.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) } | ||
| 185 | switch type { | ||
| 186 | case "src": | ||
| 187 | return fence(body, language: parameters.split(separator: " ").first.map(String.init) ?? "", indent: indent) | ||
| 188 | case "example": | ||
| 189 | return fence(body, language: "", indent: indent) | ||
| 190 | case "quote": | ||
| 191 | return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines) | ||
| 192 | .components(separatedBy: "\n").map { indent + ($0.isEmpty ? ">" : "> " + $0) }.joined(separator: "\n") | ||
| 193 | case "verse": | ||
| 194 | return body.map { indent + inlineString($0) + " " }.joined(separator: "\n") | ||
| 195 | case "export": | ||
| 196 | let backend = parameters.lowercased() | ||
| 197 | return backend.hasPrefix("markdown") || backend.hasPrefix("md") || backend.hasPrefix("html") ? body.joined(separator: "\n") : "" | ||
| 198 | case "comment": | ||
| 199 | return "" | ||
| 200 | default: | ||
| 201 | return blocks(text: body.joined(separator: "\n")).trimmingCharacters(in: .newlines) | ||
| 202 | } | ||
| 203 | } | ||
| 204 | |||
| 205 | func inline(_ node: SyntaxNode) -> String { | ||
| 206 | var out = "" | ||
| 207 | for element in node.elements { | ||
| 208 | switch element { | ||
| 209 | case .token(let token): | ||
| 210 | switch token.kind { | ||
| 211 | case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority: continue | ||
| 212 | default: out += token.text | ||
| 213 | } | ||
| 214 | case .node(let child): | ||
| 215 | out += object(child) | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return out | ||
| 219 | } | ||
| 220 | |||
| 221 | private func code(_ text: String) -> String { | ||
| 222 | let ticks = text.contains("`") ? "``" : "`" | ||
| 223 | return ticks + (ticks == "``" ? " " + text + " " : text) + ticks | ||
| 224 | } | ||
| 225 | |||
| 226 | private func object(_ node: SyntaxNode) -> String { | ||
| 227 | switch node.kind { | ||
| 228 | case .bold: return "**\(inline(node))**" | ||
| 229 | case .italic: return "*\(inline(node))*" | ||
| 230 | case .underline: return "<u>\(inline(node))</u>" | ||
| 231 | case .strikeThrough: return "~~\(inline(node))~~" | ||
| 232 | case .verbatim, .code: return code(inline(node)) | ||
| 233 | case .link: return link(node) | ||
| 234 | case .linkDescription: return inline(node) | ||
| 235 | case .timestamp: | ||
| 236 | guard let stamp = Timestamp.parse(node.text) else { return node.text } | ||
| 237 | func show(_ p: Timestamp.Point) -> String { | ||
| 238 | String(format: "%04d-%02d-%02d", p.year, p.month, p.day) + (p.hour.map { String(format: " %02d:%02d", $0, p.minute ?? 0) } ?? "") | ||
| 239 | } | ||
| 240 | return show(stamp.start) + (stamp.end.map { "–" + show($0) } ?? "") | ||
| 241 | case .footnoteReference: return footnote(node.text) | ||
| 242 | case .superscript: | ||
| 243 | let body = node.text.dropFirst() | ||
| 244 | let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body) | ||
| 245 | return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" | ||
| 246 | case .lineBreak: return " \n" | ||
| 247 | case .target, .macro: return "" | ||
| 248 | default: return inline(node) | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | private func link(_ node: SyntaxNode) -> String { | ||
| 253 | guard let path = node.tokens.first(where: { $0.kind == .linkPath })?.text else { | ||
| 254 | var target = node.text | ||
| 255 | if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) } | ||
| 256 | return "<\(target)>" | ||
| 257 | } | ||
| 258 | var href = path | ||
| 259 | if path.hasPrefix("file:") { href = String(path.dropFirst(5)) } | ||
| 260 | if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) } | ||
| 261 | if !path.contains(":"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" } | ||
| 262 | if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" } | ||
| 263 | let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? "" | ||
| 264 | if HTMLRenderer.imageExtensions.contains(ext) { return ")" } | ||
| 265 | return path.contains("://") ? "<\(href)>" : "[\(href)](\(href))" | ||
| 266 | } | ||
| 267 | |||
| 268 | private func footnote(_ text: String) -> String { | ||
| 269 | guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return text } | ||
| 270 | var label = String(m.1) | ||
| 271 | if label.isEmpty { | ||
| 272 | anonymous += 1 | ||
| 273 | label = "anonymous-\(anonymous)" | ||
| 274 | } | ||
| 275 | if let n = numbers[label] { return "[^\(n)]" } | ||
| 276 | guard let content = m.2.map(String.init) ?? definitions[label] else { return text } | ||
| 277 | let n = notes.count + 1 | ||
| 278 | numbers[label] = n | ||
| 279 | notes.append((n, inlineString(content.replacingOccurrences(of: "\n", with: " ")))) | ||
| 280 | return "[^\(n)]" | ||
| 281 | } | ||
| 282 | } | ||
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 @@ | |||
| 1 | import AppKit | ||
| 1 | import OrgApp | 2 | import OrgApp |
| 2 | import OrgCore | 3 | import OrgCore |
| 3 | import OrgEditorAppKit | 4 | import 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 | |||
| 463 | struct 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 @@ | |||
| 1 | Cases 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 | ||
| 6 | A quoted paragraph with /markup/. | ||
| 7 | |||
| 8 | And a second paragraph. | ||
| 9 | #+END_QUOTE | ||
| 10 | |||
| 11 | * Center | ||
| 12 | |||
| 13 | #+BEGIN_CENTER | ||
| 14 | Centred text. | ||
| 15 | #+END_CENTER | ||
| 16 | |||
| 17 | * Example | ||
| 18 | |||
| 19 | #+BEGIN_EXAMPLE | ||
| 20 | Verbatim *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 | ||
| 37 | def greet(name): | ||
| 38 | return f"hello {name}" | ||
| 39 | #+END_SRC | ||
| 40 | |||
| 41 | #+BEGIN_SRC | ||
| 42 | plain block, no language | ||
| 43 | #+END_SRC | ||
| 44 | |||
| 45 | * Nested | ||
| 46 | |||
| 47 | #+BEGIN_QUOTE | ||
| 48 | A quote containing a source block: | ||
| 49 | |||
| 50 | #+BEGIN_SRC sh | ||
| 51 | echo hi | ||
| 52 | #+END_SRC | ||
| 53 | #+END_QUOTE | ||
| 54 | |||
| 55 | * Verse | ||
| 56 | |||
| 57 | #+BEGIN_VERSE | ||
| 58 | Line breaks are the point | ||
| 59 | and indentation survives. | ||
| 60 | #+END_VERSE | ||
| 61 | |||
| 62 | * A named special block | ||
| 63 | |||
| 64 | #+BEGIN_NOTE | ||
| 65 | Contents 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 | |||
| 3 | Intro paragraph with a bare URL https://example.com and some ~inline code~. | ||
| 4 | |||
| 5 | * Ordered and checked | ||
| 6 | |||
| 7 | 1. first item | ||
| 8 | 2. second item with /emphasis/ | ||
| 9 | |||
| 10 | - [ ] todo item | ||
| 11 | - [X] done item | ||
| 12 | |||
| 13 | * Links and code | ||
| 14 | |||
| 15 | An external [[https://example.org][site]] and a bare [[https://bare.example]]. | ||
| 16 | |||
| 17 | #+BEGIN_SRC rust | ||
| 18 | fn 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 | ||
| 7 | fn 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 | |||
| 19 | An external link: [[https://example.com][Example]] and an id link [[id:abc-123]]. | ||
| 20 | |||
| 21 | Text with a footnote reference.[fn:1] | ||
| 22 | |||
| 23 | [fn:1] The footnote definition. | ||
| 24 | |||
| 25 | * Blocks | ||
| 26 | |||
| 27 | #+BEGIN_QUOTE | ||
| 28 | A 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 | |||
| 3 | Text with a reference.[fn:1] And a second one.[fn:2] | ||
| 4 | |||
| 5 | An 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: | ||
| 8 | A heading carrying a keyword, a priority, tags and a property drawer. | ||
| 9 | |||
| 10 | ** DONE Nested and finished | ||
| 11 | Sub-headings nest by star count. | ||
| 12 | |||
| 13 | ** [#C] Priority without a keyword | ||
| 14 | A priority cookie can stand alone. | ||
| 15 | |||
| 16 | * TODOs are not a keyword | ||
| 17 | The word boundary matters: this heading has no TODO keyword. | ||
| 18 | |||
| 19 | * DONE | ||
| 20 | A 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 | |||
| 5 | 1. Fork the repository | ||
| 6 | 2. Create a new branch: | ||
| 7 | #+begin_src bash | ||
| 8 | git checkout -b my-feature | ||
| 9 | #+end_src | ||
| 10 | 3. 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 | |||
| 13 | 1. first | ||
| 14 | 2. second | ||
| 15 | 1. second point one | ||
| 16 | 2. second point two | ||
| 17 | 3. 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 | |||
| 5 | A single paragraph of preamble text before any heading. | ||
| 6 | |||
| 7 | * First Heading | ||
| 8 | :PROPERTIES: | ||
| 9 | :CUSTOM_ID: first | ||
| 10 | :END: | ||
| 11 | |||
| 12 | Some 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 | |||
| 3 | The cells deliberately contradict the formula: org's HTML exporter does not | ||
| 4 | recalculate 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 | |||
| 5 | An active date <2024-01-15 Mon> and an inactive one [2024-01-15 Mon]. | ||
| 6 | |||
| 7 | With a time: <2024-01-15 Mon 10:30>. | ||
| 8 | |||
| 9 | * Ranges | ||
| 10 | |||
| 11 | A same-day time range <2024-01-15 Mon 10:00-11:45>. | ||
| 12 | |||
| 13 | A multi-day range <2024-01-15 Mon>--<2024-01-20 Sat>. | ||
| 14 | |||
| 15 | * Ignored decorations | ||
| 16 | |||
| 17 | A repeater is dropped: <2024-01-15 Mon +1w>. | ||
| 18 | |||
| 19 | * Not timestamps | ||
| 20 | |||
| 21 | Comparisons 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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`. | ||
| 7 | enum 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 | |||
| 95 | struct 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 & Things</title>")) | ||
| 110 | #expect(page.contains("<h1 class=\"title\">Notes & Things</h1>")) | ||
| 111 | #expect(page.contains("<p class=\"byline\">Me</p>")) | ||
| 112 | #expect(page.contains("<p>Hello.</p>")) | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | struct 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 | |||
| 173 | struct 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 | |||
| 194 | struct 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". | ||