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 | 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 | 153 | [[bind]] |
| 154 | 154 | keys = "C-c a" |
| 155 | 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 | 178 | # Org |
| 158 | 179 | [[bind]] |
Sources/OrgCore/Syntax/SyntaxNode.swift +19
| @@ -78,6 +78,25 @@ public final class SyntaxNode: Sendable { | ||
| 78 | 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 | 100 | /// This node and every node below it, in document order. |
| 82 | 101 | public func descendants() -> [SyntaxNode] { |
| 83 | 102 | [self] + children.flatMap { $0.descendants() } |
Sources/OrgEditorAppKit/EditorCommands.swift +7
| @@ -43,6 +43,13 @@ public enum EditorCommands { | ||
| 43 | 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), |
| 44 | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | 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 | 54 | movement("forward-char", "Forward Character", "moveForward:"), |
| 48 | 55 | movement("backward-char", "Backward Character", "moveBackward:"), |
Sources/Orgstar/ContentView.swift +48 −1
| @@ -1,3 +1,4 @@ | ||
| 1 | import AppKit | |
| 1 | 2 | import OrgApp |
| 2 | 3 | import OrgCore |
| 3 | 4 | import OrgEditorAppKit |
| @@ -38,7 +39,7 @@ struct ContentView: View { | ||
| 38 | 39 | @FocusState private var searchFocused: Bool |
| 39 | 40 | @Environment(\.openWindow) private var openWindow |
| 40 | 41 | |
| 41 | var body: some View { | |
| 42 | private var main: some View { | |
| 42 | 43 | NavigationSplitView { |
| 43 | 44 | Sidebar(workspace: workspace, selection: $selectedFile) |
| 44 | 45 | .navigationSplitViewColumnWidth(min: 200, ideal: 260) |
| @@ -109,6 +110,13 @@ struct ContentView: View { | ||
| 109 | 110 | .onChange(of: workspace.changedFile?.version) { |
| 110 | 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 | 120 | .onChange(of: commands.jumpRequest?.id) { |
| 113 | 121 | guard let request = commands.jumpRequest else { return } |
| 114 | 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 | 214 | /// Work a command hands to the platform: tables recalculated in Emacs. |
| 170 | 215 | private func runExternal(_ request: ExternalRequest) { |
| 171 | 216 | switch request { |
| @@ -264,6 +309,8 @@ struct ContentView: View { | ||
| 264 | 309 | if let message = session.archive() { commands.show(message) } |
| 265 | 310 | case "app.agenda": |
| 266 | 311 | openWindow(id: "agenda") |
| 312 | case let id where id.hasPrefix("app.export."): | |
| 313 | export(String(id.dropFirst("app.export.".count))) | |
| 267 | 314 | default: |
| 268 | 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 | 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 | 52 | func jump(path: String, offset: Int) { |
| 44 | 53 | jumpID += 1 |
| 45 | 54 | jumpRequest = (path, offset, jumpID) |
| @@ -161,6 +170,14 @@ struct OrgstarApp: App { | ||
| 161 | 170 | Button("Command Palette…") { commands.showingPalette = true } |
| 162 | 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 | 181 | CommandGroup(replacing: .saveItem) { |
| 165 | 182 | Button("Save") { |
| 166 | 183 | if !session.save(), session.conflict != nil { commands.showingConflict = true } |
Tests/OrgAppTests/AppTests.swift +14
| @@ -459,3 +459,17 @@ struct BabelSessionTests { | ||
| 459 | 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". | |