krz/orgstar

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

Sources/OrgCore/Export/HTMLExport.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgCore/Export/HTMLExport.swift history · blame · raw

902 lines · 42577 bytes

47 symbols in this file
  1import Foundation
  2
  3/// Document metadata from `#+TITLE`, `#+AUTHOR` and `#+DATE`.
  4public struct ExportMetadata: Sendable, Equatable {
  5    public var title: String?
  6    public var author: String?
  7    public var date: String?
  8}
  9
 10/// HTML export. The body follows the shape orgo produces, which the org-conformance corpus
 11/// holds as the reference: headings one level down (`*` is `<h2>`), timestamps as `<time>`,
 12/// footnotes collected at the end, captioned images as figures.
 13public enum HTMLExport {
 14    public static func body(_ text: String, settings: OrgSettings = .default) -> String {
 15        let renderer = HTMLRenderer(settings: settings, options: .orgo)
 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        // Setup files' keywords come first, as Org reads them where `#+SETUPFILE` is.
 23        let setup = settings.setupKeywords.split(separator: "\n").map(String.init)
 24        let own = tree.root.children.filter { $0.kind == .zerothSection }.flatMap { $0.children.filter { $0.kind == .keyword } }.map(\.text)
 25        for text in setup + own {
 26            let line = text.trimmingCharacters(in: .whitespacesAndNewlines)
 27            guard let colon = line.firstIndex(of: ":") else { continue }
 28            let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
 29            let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
 30            switch key {
 31            case "TITLE": meta.title = value
 32            case "AUTHOR": meta.author = value
 33            case "DATE": meta.date = value
 34            default: break
 35            }
 36        }
 37        return meta
 38    }
 39
 40    /// A standalone page: the body under the document's title, with a small stylesheet, as
 41    /// `#+OPTIONS` and the export keywords ask. `path` is the file's, for `#+INCLUDE` and
 42    /// `{{{input-file}}}`.
 43    public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
 44        let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
 45        let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords)
 46        let options = ExportOptions.parse(settings.setupKeywords + text)
 47        let meta = metadata(text, settings: settings)
 48        let title = meta.title ?? fallbackTitle
 49        let renderer = HTMLRenderer(settings: settings, options: options)
 50        // The title isn't part of the document Org processes citations in: they drop out.
 51        renderer.inTitle = true
 52        let titleHTML = renderer.inlineString(title)
 53        renderer.inTitle = false
 54        renderer.citations = CiteProcessor(text: text, directory: directory)
 55        var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
 56        let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
 57        if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
 58        let body = renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
 59        header += renderer.tableOfContents()
 60        var head = options.htmlHead.map { $0 + "\n" }.joined()
 61        if renderer.hasMath {
 62            head += """
 63            <script>window.MathJax = { tex: { inlineMath: [['\\\\(', '\\\\)']], displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']] } };</script>
 64            <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
 65
 66            """
 67        }
 68        return """
 69        <!DOCTYPE html>
 70        <html lang="en">
 71        <head>
 72        <meta charset="utf-8">
 73        <meta name="viewport" content="width=device-width, initial-scale=1">
 74        <title>\(HTMLRenderer.escape(title))</title>
 75        <style>
 76        body { font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; max-width: 46em; margin: 2em auto; padding: 0 1em; color: #222; }
 77        @media (prefers-color-scheme: dark) { body { background: #1d1d1f; color: #ddd; } a { color: #6cb6ff; } }
 78        pre { background: rgba(127, 127, 127, 0.12); padding: 0.75em; overflow-x: auto; }
 79        code { font: 0.9em ui-monospace, Menlo, monospace; }
 80        table { border-collapse: collapse; } th, td { border: 1px solid rgba(127, 127, 127, 0.4); padding: 0.25em 0.6em; }
 81        blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid rgba(127, 127, 127, 0.4); }
 82        .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); }
 83        .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; }
 84        figure { margin: 1em 0; } img { max-width: 100%; }
 85        #table-of-contents ul { list-style: none; padding-left: 1.2em; } #table-of-contents > div > ul { padding-left: 0; }
 86        </style>
 87        \(head)</head>
 88        <body>
 89        \(header)\(body)</body>
 90        </html>
 91
 92        """
 93    }
 94}
 95
 96/// The tree being exported, for what its src blocks, calls and inline blocks export.
 97final class ExportSource {
 98    let tree: OrgTree
 99    let text: NSString
100    private lazy var model = DocumentModel(tree: tree)
101    /// Whether the last inline src block or call lets the `{{{results(…)}}}` after it show;
102    /// nil when nothing asks.
103    var inlineResults: Bool?
104
105    init(tree: OrgTree, text: String) {
106        self.tree = tree
107        self.text = text as NSString
108    }
109
110    func exports(at offset: Int) -> String? {
111        Babel.exports(at: offset, tree: tree, model: model, text: text)
112    }
113
114    /// `{{{results(…)}}}`, Org's `$1` macro, as the inline block or call before it allows:
115    /// nil for other macros, the text to show (or nil) for this one.
116    func resultsMacro(_ node: SyntaxNode) -> String?? {
117        guard let m = node.text.firstMatch(of: /(?i)^\{\{\{results\((.*)\)\}\}\}$/) else { return .none }
118        defer { inlineResults = nil }
119        return inlineResults ?? true ? ExportPreprocess.arguments(String(m.1)).first : .some(nil)
120    }
121}
122
123final class HTMLRenderer {
124    let settings: OrgSettings
125    let options: ExportOptions
126    /// With select tags in the document: the sections to export, by heading start.
127    private var selected: Set<Int>?
128    private var numbers: [Int] = []
129    private var tocEntries: [(level: Int, id: String, html: String)] = []
130    /// A LaTeX fragment was exported, for MathJax.
131    private(set) var hasMath = false
132    private var footnoteNumbers: [String: Int] = [:]
133    private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
134    private var definitions: [String: String] = [:]
135    private var anonymous = 0
136    private var figures = 0
137    private var ids: Set<String> = []
138    /// Citations and the bibliography, as `oc-basic` exports them.
139    var citations: CiteProcessor?
140    /// Rendering the title, where Org exports citations as nothing.
141    var inTitle = false
142
143    enum FootnoteContent {
144        case definition(String)
145        case inline(String)
146    }
147
148    init(settings: OrgSettings, options: ExportOptions = .orgo) {
149        self.settings = settings
150        self.options = options
151    }
152
153    static func escape(_ s: String) -> String {
154        var out = ""
155        for c in s {
156            switch c {
157            case "&": out += "&amp;"
158            case "<": out += "&lt;"
159            case ">": out += "&gt;"
160            case "\"": out += "&quot;"
161            default: out.append(c)
162            }
163        }
164        return out
165    }
166
167    // MARK: - Document
168
169    func document(_ text: String) -> String {
170        let tree = OrgParser.parse(text, defaults: settings)
171        collectDefinitions(tree.root)
172        selected = Self.selection(tree, tags: options.selectTags)
173        var out = ""
174        rendering(tree, text) {
175            for child in tree.root.children { out += element(child) }
176        }
177        out += footnoteSection()
178        return out
179    }
180
181    private var source: ExportSource?
182
183    /// `body` with `tree` as the source of `:exports`.
184    private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
185        let saved = source
186        source = ExportSource(tree: tree, text: text)
187        defer { source = saved }
188        return body()
189    }
190
191    private func collectDefinitions(_ node: SyntaxNode) {
192        for child in node.children {
193            if child.kind == .footnoteDefinition {
194                let text = child.text
195                if let m = text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
196                    definitions[String(m.1)] = String(text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
197                }
198            } else {
199                collectDefinitions(child)
200            }
201        }
202    }
203
204    private func footnoteSection() -> String {
205        guard !footnoteOrder.isEmpty else { return "" }
206        var out = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
207        for (index, note) in footnoteOrder.enumerated() {
208            let n = index + 1
209            let content: String
210            switch note.content {
211            case .definition(let text): content = blocks(text).trimmingCharacters(in: .newlines)
212            case .inline(let text): content = inlineString(text)
213            }
214            out += "<li id=\"fn-\(n)\">\(content)\n <a class=\"footnote-back\" href=\"#fnr-\(n)\" aria-label=\"Back to reference \(n)\">&#8617;</a></li>\n"
215        }
216        return out + "</ol>\n</section>\n"
217    }
218
219    /// Org text rendered as elements, for block and footnote contents.
220    func blocks(_ text: String) -> String {
221        let tree = OrgParser.parse(text, defaults: settings)
222        var out = ""
223        rendering(tree, text) {
224            for child in tree.root.children { out += element(child) }
225        }
226        return out
227    }
228
229    /// One line of org text rendered inline.
230    func inlineString(_ text: String) -> String {
231        let tree = OrgParser.parse(text, defaults: settings)
232        guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
233        return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
234    }
235
236    /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as
237    /// Org markup with their blanks kept.
238    private func render(_ pieces: [CiteProcessor.Piece]) -> String {
239        pieces.map { piece in
240            switch piece {
241            case .text(let s): Self.escape(s)
242            case .italic(let s): "<i>" + Self.escape(s) + "</i>"
243            case .org(let s) where s.allSatisfy(\.isWhitespace): s
244            case .org(let s):
245                String(s.prefix { $0.isWhitespace }) + inlineString(s) + (s.allSatisfy(\.isWhitespace) ? "" : String(s.reversed().prefix { $0.isWhitespace }))
246            }
247        }.joined()
248    }
249
250    // MARK: - Elements
251
252    private var affiliated: [String: String] = [:]
253    /// `:exports` of the last src block or call: its results are exported only for `results`
254    /// and `both`; nothing runs.
255    private var lastExports = "code"
256
257    func element(_ node: SyntaxNode) -> String {
258        defer { if node.kind != .affiliatedKeyword { affiliated = [:] } }
259        if node.kind != .affiliatedKeyword, affiliated["RESULTS"] != nil {
260            defer { lastExports = "code" }
261            if lastExports != "results", lastExports != "both" { return "" }
262        }
263        if node.kind == .block || node.kind == .keyword, let exports = source?.exports(at: node.range.lowerBound) {
264            lastExports = exports
265            if exports == "results" || exports == "none" { return "" }
266        }
267        switch node.kind {
268        case .section:
269            guard let heading = node.firstChild(.heading) else { fallthrough }
270            switch Self.sectionExport(heading, options: options, selected: selected) {
271            case .nothing: return ""
272            case .heading: return self.heading(heading)
273            case .all: break
274            }
275            var out = ""
276            for child in node.children { out += element(child) }
277            return out
278        case .zerothSection, .document:
279            var out = ""
280            for child in node.children { out += element(child) }
281            return out
282        case .heading:
283            return heading(node)
284        case .affiliatedKeyword:
285            let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
286            if let m = line.firstMatch(of: /^#\+([A-Za-z_]+):[ \t]*(.*)$/) {
287                affiliated[m.1.uppercased()] = String(m.2)
288            }
289            return ""
290        case .paragraph:
291            return paragraph(node)
292        case .keyword where citations != nil && node.text.range(of: "^[ \\t]*#\\+print_bibliography:", options: [.regularExpression, .caseInsensitive]) != nil:
293            return citations!.bibliography.map { "<p>" + render($0) + "</p>\n" }.joined()
294        case .plainList:
295            return list(node)
296        case .table:
297            return table(node)
298        case .block:
299            return block(node)
300        case .horizontalRule:
301            return "<hr>\n"
302        case .inlineTask:
303            // As ox-html: the title in bold, then the contents.
304            var out = "<div class=\"inlinetask\">\n"
305            for child in node.children {
306                if child.kind == .heading {
307                    guard child.range.lowerBound == node.range.lowerBound else { continue }
308                    let title = child.firstChild(.title).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? ""
309                    let todo = options.todo ? child.tokens.first { $0.kind == .todoKeyword }.map { "<span class=\"todo \(Self.escape($0.text))\">\(Self.escape($0.text))</span> " } ?? "" : ""
310                    out += "<b>\(todo)\(title)</b><br>\n"
311                } else {
312                    out += element(child)
313                }
314            }
315            return out + "</div>\n"
316        case .latexEnvironment:
317            // For MathJax, as ox-html leaves it with `tex:t`.
318            guard options.tex else { return "" }
319            hasMath = true
320            return Self.escape(node.text.trimmingCharacters(in: .newlines)) + "\n"
321        case .fixedWidth:
322            let lines = node.text.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
323                let trimmed = line.drop { $0 == " " || $0 == "\t" }
324                return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
325            }
326            return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
327        case .tableEl:
328            let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") }
329            // As `org-html-table--table.el-table`: cells with their spans, lines kept apart.
330            if let cells = TableEl.cells(lines.joined(separator: "\n")) {
331                var out = "<table class=\"table-el\">\n"
332                for row in Dictionary(grouping: cells, by: \.row).keys.sorted() {
333                    out += "<tr>"
334                    for cell in cells where cell.row == row {
335                        let spans = (cell.colspan > 1 ? " colspan=\"\(cell.colspan)\"" : "") + (cell.rowspan > 1 ? " rowspan=\"\(cell.rowspan)\"" : "")
336                        let content = cell.lines.reversed().drop { $0.isEmpty }.reversed().map(Self.escape).joined(separator: "<br>")
337                        out += "<td\(spans)>\(content)</td>"
338                    }
339                    out += "</tr>\n"
340                }
341                return out + "</table>\n"
342            }
343            return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
344        case .dynamicBlock:
345            let lines = node.text.components(separatedBy: "\n")
346            return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
347        default:
348            // Keywords, comments, drawers, planning, clocks, footnote definitions, formulas.
349            return ""
350        }
351    }
352
353    private func slug(_ text: String) -> String {
354        var slug = ""
355        var dash = false
356        for c in text.lowercased() {
357            if c.isLetter || c.isNumber {
358                if dash, !slug.isEmpty { slug.append("-") }
359                slug.append(c)
360                dash = false
361            } else {
362                dash = true
363            }
364        }
365        if slug.isEmpty { slug = "section" }
366        var candidate = slug
367        var n = 1
368        while ids.contains(candidate) {
369            candidate = "\(slug)-\(n)"
370            n += 1
371        }
372        ids.insert(candidate)
373        return candidate
374    }
375
376    /// `org-export--selected-trees`: headings with a select tag, everything below them and
377    /// their ancestors; nil when no heading has one.
378    enum SectionExport {
379        case all, heading, nothing
380    }
381
382    /// `org-export--skip-p` for the section under `heading`: excluded tags, COMMENT and
383    /// unselected trees export nothing, archived trees their heading only.
384    static func sectionExport(_ heading: SyntaxNode, options: ExportOptions, selected: Set<Int>?) -> SectionExport {
385        let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
386        let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
387        if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return .nothing }
388        if let selected, !selected.contains(heading.range.lowerBound) { return .nothing }
389        return tags.contains("ARCHIVE") ? .heading : .all
390    }
391
392    static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
393        let model = DocumentModel(tree: tree)
394        let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
395        guard !picked.isEmpty else { return nil }
396        var set: Set<Int> = []
397        for index in picked {
398            set.insert(model.headings[index].headingRange.lowerBound)
399            for a in model.ancestors(of: index) { set.insert(model.headings[a].headingRange.lowerBound) }
400            let level = model.headings[index].level
401            for d in model.headings[(index + 1)...].prefix(while: { $0.level > level }) { set.insert(d.headingRange.lowerBound) }
402        }
403        return set
404    }
405
406    /// The table of contents, as ox-html writes it, or nothing.
407    func tableOfContents() -> String {
408        guard options.toc != nil, !tocEntries.isEmpty else { return "" }
409        var out = "<div id=\"table-of-contents\" role=\"doc-toc\">\n<h2>Table of Contents</h2>\n<div id=\"text-table-of-contents\" role=\"doc-toc\">\n"
410        var depth = 0
411        var open: [Bool] = []
412        let base = tocEntries.map(\.level).min() ?? 1
413        for entry in tocEntries {
414            let level = entry.level - base + 1
415            if level > depth {
416                while depth < level {
417                    out += "\n<ul>\n"
418                    depth += 1
419                    open.append(false)
420                }
421            } else {
422                out += "</li>\n"
423                while depth > level {
424                    out += "</ul>\n</li>\n"
425                    depth -= 1
426                    open.removeLast()
427                }
428            }
429            out += "<li><a href=\"#\(Self.escape(entry.id))\">\(entry.html)</a>"
430        }
431        out += "</li>\n"
432        while depth > 1 {
433            out += "</ul>\n</li>\n"
434            depth -= 1
435        }
436        return out + "</ul>\n</div>\n</div>\n"
437    }
438
439    private func heading(_ node: SyntaxNode) -> String {
440        let tokens = node.tokens
441        let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
442        let title = node.firstChild(.title)
443        let titleText = title?.text.trimmingCharacters(in: .whitespaces) ?? ""
444        // `CUSTOM_ID` from the property drawer that follows.
445        var customID: String?
446        if let section = node.parent, let drawer = section.firstChild(.propertyDrawer) {
447            for property in drawer.children where property.kind == .nodeProperty {
448                if let m = property.text.firstMatch(of: /^[ \t]*:CUSTOM_ID:[ \t]*(\S+)/) { customID = String(m.1) }
449            }
450        }
451        let id = customID ?? slug(titleText)
452        if customID != nil { ids.insert(id) }
453        var parts: [String] = []
454        let h = min(level + 1, 6)
455        if let num = options.num, level <= num {
456            if numbers.count < level { numbers += Array(repeating: 0, count: level - numbers.count) }
457            numbers = Array(numbers.prefix(level))
458            numbers[level - 1] += 1
459            parts.append("<span class=\"section-number-\(h)\">\(numbers.map(String.init).joined(separator: ".")).</span>")
460        }
461        if options.todo, let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
462            parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>")
463        }
464        if options.priority, let priority = tokens.first(where: { $0.kind == .priority })?.text {
465            parts.append("<span class=\"priority\">\(Self.escape(priority))</span>")
466        }
467        if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
468        var tagParts: [String] = []
469        if options.tags, let tags = tokens.first(where: { $0.kind == .tags })?.text {
470            tagParts = tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
471        }
472        if let toc = options.toc, level <= toc {
473            tocEntries.append((level, id, (parts + (options.tagsInTOC ? tagParts : [])).joined(separator: " ")))
474        }
475        parts += tagParts
476        return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n"
477    }
478
479    private func paragraph(_ node: SyntaxNode) -> String {
480        let body = inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
481        if let image = soleImage(node), affiliated["CAPTION"] != nil || affiliated["ATTR_HTML"] != nil {
482            var out = "<figure>\n" + image + "\n"
483            if let caption = affiliated["CAPTION"] {
484                figures += 1
485                out += "<figcaption>Figure \(figures): \(inlineString(caption))</figcaption>\n"
486            }
487            return out + "</figure>\n"
488        }
489        return "<p>\(body)</p>\n"
490    }
491
492    /// The `<img>` of a paragraph that is only an image link.
493    private func soleImage(_ node: SyntaxNode) -> String? {
494        let links = node.children.filter { $0.kind == .link }
495        guard links.count == 1, node.children.count == 1,
496              node.tokens.allSatisfy({ $0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil }
497        let html = link(links[0])
498        return html.hasPrefix("<img") ? html : nil
499    }
500
501    // MARK: - Lists
502
503    private func isOrdered(_ item: SyntaxNode) -> Bool {
504        let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
505        return bullet.first.map { $0.isNumber || $0.isLetter } ?? false
506    }
507
508    /// As orgo does, a change between ordered and unordered bullets starts another list.
509    private func list(_ node: SyntaxNode) -> String {
510        var runs: [[SyntaxNode]] = []
511        for item in node.children where item.kind == .item {
512            if let last = runs.last?.last, isOrdered(last) == isOrdered(item) {
513                runs[runs.count - 1].append(item)
514            } else {
515                runs.append([item])
516            }
517        }
518        return runs.map(list).joined()
519    }
520
521    private func list(_ items: [SyntaxNode]) -> String {
522        guard let first = items.first else { return "" }
523        let ordered = isOrdered(first)
524        // `org-list-get-list-type`: descriptive when the first item has a tag.
525        let description = !ordered && first.firstChild(.itemTag) != nil
526        if description {
527            var out = "<dl>\n"
528            for item in items {
529                let (term, rest) = descriptionParts(item)
530                out += "<dt>\(term)</dt>\n<dd>\(rest)</dd>\n"
531            }
532            return out + "</dl>\n"
533        }
534        let tag = ordered ? "ol" : "ul"
535        var out = "<\(tag)>\n"
536        for item in items { out += listItem(item) }
537        return out + "</\(tag)>\n"
538    }
539
540    /// A description item's term, `(no term)` without one, and the rest of the item.
541    private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
542        var term = item.firstChild(.itemTag).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? "(no term)"
543        if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
544            term = "<code>\(box == "[ ]" ? "[&nbsp;]" : box.uppercased())</code> " + term
545        }
546        var rest = ""
547        for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
548            rest += index == 0 && child.kind == .paragraph ? inline(child).trimmingCharacters(in: .whitespacesAndNewlines) : element(child)
549        }
550        return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
551    }
552
553    private func listItem(_ item: SyntaxNode) -> String {
554        // ox-html gives an ordered item's counter as its value.
555        var open = "<li>"
556        if isOrdered(item), let counter = item.tokens.first(where: { $0.kind == .counter })?.text.firstMatch(of: /([0-9]+|[A-Za-z])\]/) {
557            open = "<li value=\"\(counter.1)\">"
558        }
559        var prefix = ""
560        if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
561            switch box {
562            case "[X]", "[x]":
563                open = "<li class=\"on\">"
564                prefix = "<code>[X]</code> "
565            case "[-]":
566                open = "<li class=\"trans\">"
567                prefix = "<code>[-]</code> "
568            default:
569                open = "<li class=\"off\">"
570                prefix = "<code>[&nbsp;]</code> "
571            }
572        }
573        let paragraphs = item.children.filter { $0.kind == .paragraph }.count
574        var body = ""
575        for child in item.children {
576            if child.kind == .paragraph {
577                let content = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
578                body += paragraphs >= 2 ? "<p>\(content)</p>\n" : content
579            } else {
580                body += element(child)
581            }
582        }
583        if paragraphs >= 2 {
584            body = prefix + body
585        } else {
586            body = prefix + body
587        }
588        return open + body + (body.hasSuffix("\n") ? "" : "") + "</li>\n"
589    }
590
591    // MARK: - Tables
592
593    private func table(_ node: SyntaxNode) -> String {
594        let rows = node.children.filter { $0.kind == .tableRow }
595        var groups: [[SyntaxNode]] = [[]]
596        for row in rows {
597            if row.children.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") {
598                if !groups[groups.count - 1].isEmpty { groups.append([]) }
599            } else {
600                groups[groups.count - 1].append(row)
601            }
602        }
603        groups.removeAll { $0.isEmpty }
604        var out = "<table>\n"
605        if let caption = affiliated["CAPTION"] { out += "<caption>\(inlineString(caption))</caption>\n" }
606        let hasHeader = groups.count > 1
607        for (index, group) in groups.enumerated() {
608            let header = hasHeader && index == 0
609            if header { out += "<thead>\n" } else if index == (hasHeader ? 1 : 0) { out += "<tbody>\n" }
610            for row in group {
611                out += "<tr>" + row.children.filter { $0.kind == .tableCell }.map { cell in
612                    let content = inline(cell).trimmingCharacters(in: .whitespaces)
613                    return header ? "<th>\(content)</th>" : "<td>\(content)</td>"
614                }.joined() + "</tr>\n"
615            }
616            if header { out += "</thead>\n" }
617        }
618        if !groups.isEmpty, !(hasHeader && groups.count == 1) { out += "</tbody>\n" }
619        return out + "</table>\n"
620    }
621
622    // MARK: - Blocks
623
624    private func block(_ node: SyntaxNode) -> String {
625        var lines = node.text.components(separatedBy: "\n")
626        if lines.last == "" { lines.removeLast() }
627        guard let first = lines.first,
628              let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
629        let type = m.1.lowercased()
630        let parameters = String(m.2)
631        let body = Array(lines.dropFirst().dropLast())
632        // Commas protect `*` and `#+` lines inside blocks.
633        let unescaped = body.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
634        let text = unescaped.joined(separator: "\n")
635        switch type {
636        case "src":
637            let language = parameters.split(separator: " ").first.map(String.init)
638            let code = Self.escape(dedent(unescaped).joined(separator: "\n"))
639            let cls = language.map { " class=\"language-\(Self.escape($0))\"" } ?? ""
640            return "<pre><code\(cls)>\(code)</code></pre>\n"
641        case "example":
642            return "<pre>\(Self.escape(dedent(unescaped).joined(separator: "\n")))</pre>\n"
643        case "quote":
644            return "<blockquote>\n\(blocks(text))</blockquote>\n"
645        case "center":
646            return "<div class=\"center\">\n\(blocks(text))</div>\n"
647        case "verse":
648            let rendered = unescaped.map { line -> String in
649                let indent = line.prefix { $0 == " " }.count
650                return String(repeating: "&nbsp;", count: indent) + inlineString(String(line.dropFirst(indent)))
651            }
652            return "<p class=\"verse\">\n" + rendered.joined(separator: "<br>\n") + "\n</p>\n"
653        case "export":
654            return parameters.lowercased().hasPrefix("html") ? text + "\n" : ""
655        case "comment":
656            return ""
657        default:
658            return "<div class=\"\(Self.escape(type))\">\n\(blocks(text))</div>\n"
659        }
660    }
661
662    private func dedent(_ lines: [String]) -> [String] {
663        let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }
664        let common = indents.min() ?? 0
665        return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
666    }
667
668    // MARK: - Inline
669
670    func inline(_ node: SyntaxNode) -> String {
671        var out = ""
672        for element in node.elements {
673            switch element {
674            case .token(let token):
675                switch token.kind {
676                case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority:
677                    continue
678                default:
679                    out += entities(token.text)
680                }
681            case .node(let child):
682                out += object(child)
683            }
684        }
685        return out
686    }
687
688    /// Text: LaTeX fragments left for MathJax, `\alpha` and friends as characters, `_` and `^`
689    /// scripts as `<sub>` and `<sup>`, as `#+OPTIONS` says.
690    static let textObjects = try! NSRegularExpression(pattern: [
691        #"\\\((.+?)\\\)"#, #"\\\[(.+?)\\\]"#, #"\$\$(.+?)\$\$"#,
692        #"(?<![$\w])\$([^\s$.,;](?:[^$]*?[^\s$.,\\])?)\$(?=[\s.,;:!?'")\]-]|$)"#,
693        #"\\([a-zA-Z]+)(\{\})?"#,
694        #"(?<=\S)([_^])(\{[^{}]*\}|[+-]?[\p{L}\p{N}.,\\]*[\p{L}\p{N}])"#,
695    ].joined(separator: "|"))
696
697    private func entities(_ text: String) -> String {
698        guard text.contains(where: { $0 == "\\" || $0 == "$" || $0 == "_" || $0 == "^" }) else { return Self.escape(text) }
699        let ns = text as NSString
700        var out = ""
701        var at = 0
702        for m in Self.textObjects.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
703            out += Self.escape(ns.substring(with: NSRange(location: at, length: m.range.location - at)))
704            at = NSMaxRange(m.range)
705            let whole = ns.substring(with: m.range)
706            func group(_ i: Int) -> String? { m.range(at: i).location == NSNotFound ? nil : ns.substring(with: m.range(at: i)) }
707            if group(1) != nil || group(2) != nil || group(3) != nil || group(4) != nil {
708                guard options.tex else { continue }
709                hasMath = true
710                out += Self.escape(whole)
711            } else if let name = group(5) {
712                out += Self.escape(OrgEntities.display[name] ?? whole)
713            } else if let marker = group(7), let body = group(8) {
714                let braced = body.hasPrefix("{")
715                if options.scripts == .off || (options.scripts == .braces && !braced) {
716                    out += Self.escape(whole)
717                    continue
718                }
719                let inner = braced ? String(body.dropFirst().dropLast()) : body
720                out += marker == "_" ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
721            } else {
722                out += Self.escape(whole)
723            }
724        }
725        return out + Self.escape(ns.substring(from: at))
726    }
727
728    private func object(_ node: SyntaxNode) -> String {
729        switch node.kind {
730        case .bold: return "<strong>\(inline(node))</strong>"
731        case .italic: return "<em>\(inline(node))</em>"
732        case .underline: return "<u>\(inline(node))</u>"
733        case .strikeThrough: return "<del>\(inline(node))</del>"
734        case .verbatim: return "<code class=\"verbatim\">\(inline(node))</code>"
735        case .code: return "<code>\(inline(node))</code>"
736        case .link: return link(node)
737        case .linkDescription: return inline(node)
738        case .timestamp: return timestamp(node.text)
739        case .footnoteReference: return footnote(node.text)
740        case .entity:
741            let name = node.text.dropFirst().replacingOccurrences(of: "{}", with: "")
742            return Self.escape(OrgEntities.display[name] ?? node.text)
743        case .radioTarget:
744            let inner = String(node.text.dropFirst(3).dropLast(3))
745            return "<a id=\"\(Self.escape(slugOnly(inner)))\">\(Self.escape(inner))</a>"
746        case .superscript, .subscript:
747            let text = node.text
748            let body = text.dropFirst()
749            let braced = body.hasPrefix("{") && body.hasSuffix("}")
750            if options.scripts == .off || (options.scripts == .braces && !braced) { return Self.escape(text) }
751            let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
752            return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
753        case .lineBreak: return "<br>\n"
754        case .macro:
755            guard case .some(let value) = source?.resultsMacro(node) else { return "" }
756            return value.map(inlineString) ?? ""
757        case .target: return ""
758        case .inlineSourceBlock:
759            let exports = source?.exports(at: node.range.lowerBound) ?? "results"
760            source?.inlineResults = exports == "results" || exports == "both"
761            guard exports == "code" || exports == "both" else { return "" }
762            if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
763            return Self.escape(node.text)
764        case .latexFragment:
765            // For MathJax, as ox-html leaves them with `tex:t`; `tex:nil` drops them.
766            guard options.tex else { return "" }
767            hasMath = true
768            return Self.escape(node.text)
769        case .statisticsCookie: return Self.escape(node.text)
770        // `org-html-export-snippet`: only HTML snippets.
771        case .exportSnippet:
772            guard node.tokens.first?.text == "@@html:" else { return "" }
773            return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
774        case .citation:
775            if inTitle { return "" }
776            guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
777            return render(pieces)
778        case .inlineBabelCall:
779            source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
780            return ""
781        default: return inline(node)
782        }
783    }
784
785    static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tif", "tiff", "avif"]
786
787    private func link(_ node: SyntaxNode) -> String {
788        let path = node.tokens.first { $0.kind == .linkPath }?.text
789        let description = node.firstChild(.linkDescription)
790        guard let path else {
791            // A radio link goes to its target.
792            if !node.text.hasPrefix("<"), !node.text.contains(":") {
793                return "<a href=\"#\(Self.escape(slugOnly(node.text)))\">\(Self.escape(node.text))</a>"
794            }
795            // A plain or angle link.
796            var target = node.text
797            if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
798            return "<a href=\"\(Self.escape(target))\">\(Self.escape(target))</a>"
799        }
800        var href = path
801        var label = path
802        if path.hasPrefix("file:") {
803            href = String(path.dropFirst(5))
804            label = href
805        } else if path.hasPrefix("id:") {
806            href = "#" + path.dropFirst(3)
807            label = String(path.dropFirst(3))
808        } else if path.hasPrefix("*") {
809            href = "#" + slugOnly(String(path.dropFirst()))
810        } else if !path.hasPrefix("#"), !path.contains(":"), !path.contains("/"), !path.contains(".") {
811            href = "#" + slugOnly(path)
812        }
813        let isFile = path.hasPrefix("file:") || (!path.contains(":") && (path.contains("/") || path.contains(".")))
814        if isFile, href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".html" }
815        if isFile, let range = href.range(of: ".org::") { href = String(href[..<range.lowerBound]) + ".html" }
816        let ext = (href.split(separator: "?").first.map(String.init) ?? href).split(separator: ".").last.map { $0.lowercased() } ?? ""
817        if description == nil, Self.imageExtensions.contains(ext) {
818            var attributes = ""
819            if let attrs = affiliated["ATTR_HTML"] {
820                for (key, value) in parseAttributes(attrs) where key != "src" {
821                    attributes += " \(key)=\"\(Self.escape(value))\""
822                }
823            }
824            if !attributes.contains(" alt=") { attributes = " alt=\"\(Self.escape((href as NSString).lastPathComponent))\"" + attributes }
825            return "<img src=\"\(Self.escape(href))\"\(attributes)>"
826        }
827        let text = description.map(inline) ?? Self.escape(label)
828        return "<a href=\"\(Self.escape(href))\">\(text)</a>"
829    }
830
831    private func slugOnly(_ text: String) -> String {
832        var slug = ""
833        var dash = false
834        for c in text.lowercased() {
835            if c.isLetter || c.isNumber {
836                if dash, !slug.isEmpty { slug.append("-") }
837                slug.append(c)
838                dash = false
839            } else {
840                dash = true
841            }
842        }
843        return slug
844    }
845
846    /// `:key value :other "quoted value"`.
847    private func parseAttributes(_ text: String) -> [(String, String)] {
848        var pairs: [(String, String)] = []
849        let regex = try! NSRegularExpression(pattern: ":([A-Za-z_-]+)[ \\t]+(\"[^\"]*\"|[^:\\s][^\\s]*)")
850        let ns = text as NSString
851        for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
852            var value = ns.substring(with: m.range(at: 2))
853            if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value = String(value.dropFirst().dropLast()) }
854            pairs.append((ns.substring(with: m.range(at: 1)), value))
855        }
856        return pairs
857    }
858
859    private func timestamp(_ text: String) -> String {
860        guard let stamp = Timestamp.parse(text) else { return Self.escape(text) }
861        func date(_ p: Timestamp.Point) -> String { String(format: "%04d-%02d-%02d", p.year, p.month, p.day) }
862        func time(_ p: Timestamp.Point) -> String? {
863            guard let h = p.hour, let m = p.minute else { return nil }
864            return String(format: "%02d:%02d", h, m)
865        }
866        func element(_ p: Timestamp.Point, full: Bool) -> String {
867            let datetime = date(p) + (time(p).map { "T" + $0 } ?? "")
868            let shown = full ? date(p) + (time(p).map { " " + $0 } ?? "") : (time(p) ?? date(p))
869            return "<time class=\"timestamp\" datetime=\"\(datetime)\">\(shown)</time>"
870        }
871        guard let end = stamp.end else { return element(stamp.start, full: true) }
872        let sameDay = end.year == stamp.start.year && end.month == stamp.start.month && end.day == stamp.start.day
873        return element(stamp.start, full: true) + "–" + element(end, full: !sameDay)
874    }
875
876    private func footnote(_ text: String) -> String {
877        guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return Self.escape(text) }
878        var label = String(m.1)
879        let inlineText = m.2.map(String.init)
880        if label.isEmpty {
881            anonymous += 1
882            label = "anonymous-\(anonymous)"
883        }
884        let number: Int
885        if let existing = footnoteNumbers[label] {
886            number = existing
887        } else {
888            let content: FootnoteContent
889            if let inlineText {
890                content = .inline(inlineText)
891            } else if let definition = definitions[label] {
892                content = .definition(definition)
893            } else {
894                return Self.escape(text)
895            }
896            footnoteOrder.append((label, content))
897            number = footnoteOrder.count
898            footnoteNumbers[label] = number
899        }
900        return "<sup class=\"footnote-ref\"><a id=\"fnr-\(number)\" href=\"#fn-\(number)\">\(number)</a></sup>"
901    }
902}