krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift

799d4ba18ece2bd259bd4760cca46faeae0fd1e1
org-swift/Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift history · blame · raw

312 lines · 14594 bytes

12 symbols in this file
  1import Foundation
  2
  3/// Renders an ``OrgDocument`` to HTML by walking the tree.
  4///
  5/// The point of the prototype: this produces the same shape of output as the shipped
  6/// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second
  7/// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it.
  8///
  9/// Not `Sendable`: it holds a ``CodeHighlighter``, and a real one wraps a thread-confined
 10/// engine (Highlightr's JavaScriptCore context, say). Sending the renderer across isolation
 11/// domains would call that engine off its thread. Make one where you render.
 12public struct OrgHTMLTreeRenderer {
 13    /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
 14    public var options: OrgRenderOptions
 15    public var highlighter: CodeHighlighter
 16
 17    public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) {
 18        self.options = options
 19        self.highlighter = highlighter
 20    }
 21
 22    public func render(_ document: OrgDocument) -> String {
 23        var footnotes = OrgFootnotes(document: document)
 24        var html = metadataHeader(document)
 25        html += document.elements.map { element($0, &footnotes) }.joined()
 26        html += renderFootnoteSection(&footnotes)
 27        return html
 28    }
 29
 30    /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats
 31    /// these as document metadata carried by a page template, so the corpus renders with this
 32    /// off; an app showing a README title turns it on.
 33    private func metadataHeader(_ document: OrgDocument) -> String {
 34        guard options.metadataHeader else { return "" }
 35        let title = document.keyword("title")
 36        let author = document.keyword("author")
 37        let date = document.keyword("date")
 38        guard title != nil || author != nil || date != nil else { return "" }
 39
 40        var html = "<div class=\"org-metadata\">\n"
 41        if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" }
 42        if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" }
 43        if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" }
 44        return html + "</div>\n"
 45    }
 46
 47    // MARK: - URL resolution
 48
 49    /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``.
 50    private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
 51
 52    // MARK: - Blocks
 53
 54    private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String {
 55        switch element {
 56        case .heading(let heading):
 57            let level = min(6, max(1, heading.level + options.headingLevelOffset))
 58            var inner = ""
 59            if let todo = heading.todo {
 60                inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "#
 61            }
 62            if let priority = heading.priority {
 63                inner += #"<span class="priority">[#\#(priority)]</span> "#
 64            }
 65            inner += renderInline(heading.title, &notes)
 66            for tag in heading.tags {
 67                inner += #" <span class="tag">\#(escapeHTML(tag))</span>"#
 68            }
 69            return "<h\(level)>\(inner)</h\(level)>\n"
 70
 71        case .paragraph(let objects):
 72            return "<p>" + renderInline(objects, &notes) + "</p>\n"
 73
 74        case .list(let list):
 75            return renderList(list, &notes)
 76
 77        case .table(let table):
 78            return renderTable(table, &notes)
 79
 80        case .srcBlock(let language, let code):
 81            let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? ""
 82            let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code)
 83            return "<pre><code\(classAttribute)>\(body)</code></pre>\n"
 84
 85        case .exampleBlock(let text):
 86            return "<pre>\(escapeHTML(text))</pre>\n"
 87
 88        case .quoteBlock(let children):
 89            return "<blockquote>\n" + children.map { self.element($0, &notes) }.joined() + "</blockquote>\n"
 90
 91        case .centerBlock(let children):
 92            return #"<div class="center">"# + "\n" + children.map { self.element($0, &notes) }.joined() + "</div>\n"
 93
 94        case .verseBlock(let lines):
 95            let body = lines.map { renderInline($0, &notes) }.joined(separator: "<br>\n")
 96            return #"<p class="verse">"# + "\n" + body + "\n</p>\n"
 97
 98        case .specialBlock(let name, let children):
 99            return #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n"
100                + children.map { self.element($0, &notes) }.joined() + "</div>\n"
101
102        case .exportBlock(let backend, let raw):
103            return backend == "html" ? raw + "\n" : ""
104
105        case .figure(let figure):
106            guard let tag = imageTag(figure, caption: figure.caption) else {
107                return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n"
108            }
109            // A bare image is a paragraph; a caption or explicit attributes promote it to a
110            // <figure>, matching org's exporter.
111            guard figure.caption != nil || !figure.attributes.isEmpty else {
112                return "<p>" + tag + "</p>\n"
113            }
114            var html = "<figure>" + tag
115            if let caption = figure.caption {
116                notes.figureNumber += 1
117                html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"#
118                    + renderInline(caption, &notes) + "</figcaption>"
119            }
120            return html + "</figure>\n"
121
122        case .captioned(let name, let caption, let content):
123            let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
124            var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n"
125            html += self.element(content, &notes)
126            if let caption {
127                html += "<figcaption>" + renderInline(caption, &notes) + "</figcaption>\n"
128            }
129            return html + "</figure>\n"
130
131        case .horizontalRule:
132            return "<hr>\n"
133
134        case .latexBlock(let raw):
135            return "<pre>\(escapeHTML(raw))</pre>\n"
136
137        case .footnoteDefinition:
138            return ""  // collected and emitted in the notes section
139        }
140    }
141
142    private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String {
143        if list.kind == .description {
144            var html = "<dl>\n"
145            for item in list.items {
146                if let term = item.term {
147                    html += "<dt>" + renderInline(term, &notes) + "</dt>\n"
148                }
149                if !item.content.isEmpty {
150                    html += "<dd>" + renderItemContent(item, &notes) + "</dd>\n"
151                }
152            }
153            return html + "</dl>\n"
154        }
155
156        let tag = list.kind == .ordered ? "ol" : "ul"
157        var html = "<\(tag)>\n"
158        for item in list.items {
159            html += "<li>"
160            if let checkbox = item.checkbox {
161                switch checkbox {
162                case .off: html += "<code>[&nbsp;]</code> "
163                case .on: html += "<code>[X]</code> "
164                case .partial: html += "<code>[-]</code> "
165                }
166            }
167            html += renderItemContent(item, &notes)
168            html += "</li>\n"
169        }
170        return html + "</\(tag)>\n"
171    }
172
173    /// A list item's body. A lone paragraph renders bare inside the `<li>`; two or more get
174    /// their own `<p>`, matching org's exporter. Anything that is not a paragraph — a nested
175    /// list, a source block, a table — renders as the block it is, in source order.
176    private func renderItemContent(_ item: OrgListItem, _ notes: inout OrgFootnotes) -> String {
177        let wrapParagraphs = item.paragraphs.count > 1
178        var parts: [String] = []
179        for child in item.content {
180            if case .paragraph(let objects) = child {
181                let text = renderInline(objects, &notes)
182                parts.append(wrapParagraphs ? "<p>" + text + "</p>" : text)
183            } else {
184                parts.append(element(child, &notes))
185            }
186        }
187        return parts.joined(separator: "\n")
188    }
189
190    private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
191        var html = "<table>\n"
192        var wroteHeader = false
193        var inBody = false
194        let headerCount = table.headerRowCount
195
196        for (index, row) in table.rows.enumerated() {
197            switch row {
198            case .rule:
199                if wroteHeader, !inBody { html += "</thead>\n<tbody>\n"; inBody = true }
200            case .cells(let cells):
201                let isHeader = headerCount > 0 && index < headerCount
202                if isHeader, !wroteHeader { html += "<thead>\n"; wroteHeader = true }
203                if !isHeader, !inBody { html += "<tbody>\n"; inBody = true }
204                html += "<tr>\n"
205                for (column, cell) in cells.enumerated() {
206                    let tag = isHeader ? "th" : "td"
207                    let alignment = column < table.alignments.count ? table.alignments[column] : nil
208                    let style = alignment.map { #" style="text-align: \#($0.rawValue);""# } ?? ""
209                    html += "<\(tag)\(style)>" + renderInline(cell, &notes) + "</\(tag)>\n"
210                }
211                html += "</tr>\n"
212            }
213        }
214        if inBody { html += "</tbody>\n" }
215        return html + "</table>\n"
216    }
217
218    /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL —
219    /// in which case the caller falls back to text rather than pointing at something unsafe.
220    private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
221        guard let source = resolver.imageSource(figure.source) else { return nil }
222        let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223        var html = #"<img src="\#(escapeHTMLAttribute(source))" alt="\#(escapeHTMLAttribute(alt))""#
224        for (key, value) in figure.attributes where key != "alt" {
225            html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
226        }
227        return html + ">"
228    }
229
230    // MARK: - Inline
231
232    func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String {
233        var html = ""
234        for object in objects {
235            switch object {
236            case .text(let text): html += escapeHTML(text)
237            case .bold(let children): html += "<strong>" + renderInline(children, &notes) + "</strong>"
238            case .italic(let children): html += "<em>" + renderInline(children, &notes) + "</em>"
239            case .underline(let children): html += "<u>" + renderInline(children, &notes) + "</u>"
240            case .strikeThrough(let children): html += "<del>" + renderInline(children, &notes) + "</del>"
241            case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>"
242            case .superscript(let children): html += "<sup>" + renderInline(children, &notes) + "</sup>"
243            case .lineBreak: html += "<br>"
244            case .math(let math): html += escapeHTML(math.source)
245            case .image(let figure):
246                html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source)
247            case .timestamp(let stamp):
248                let cssClass = stamp.active ? "timestamp" : "timestamp inactive"
249                func time(_ machine: String, _ display: String) -> String {
250                    #"<time class="\#(cssClass)" datetime="\#(machine)">\#(display)</time>"#
251                }
252                html += time(stamp.machineValue, stamp.displayValue)
253                // A range is two <time> elements joined by an en-dash, as org exports it.
254                if let end = stamp.end {
255                    html += "&#8211;" + time(end.machineValue, end.displayValue)
256                }
257            case .footnoteRef(let label, let inline):
258                let number = notes.number(for: label, inline: inline)
259                html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
260            case .link(let link):
261                let text = link.description.map { renderInline($0, &notes) }
262                    ?? escapeHTML(resolver.displayValue(link.target))
263                // An unsafe or unresolvable target degrades to its text, never a bad anchor.
264                if let href = resolver.href(for: link.target) {
265                    html += #"<a href="\#(escapeHTMLAttribute(href))">\#(text)</a>"#
266                } else {
267                    html += text
268                }
269            }
270        }
271        return html
272    }
273
274    /// Inline objects reduced to plain text, for an `alt` attribute.
275    func plainText(_ objects: [OrgObject]) -> String {
276        objects.map { object in
277            switch object {
278            case .text(let text): return text
279            case .verbatim(let text), .code(let text): return text
280            case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
281                return plainText(c)
282            case .link(let link): return link.description.map { plainText($0) } ?? resolver.displayValue(link.target)
283            case .timestamp(let stamp): return stamp.displayValue
284            case .image(let figure): return figure.alt ?? ""
285            case .math(let math): return math.source
286            case .footnoteRef, .lineBreak: return ""
287            }
288        }.joined()
289    }
290}
291
292// MARK: - Footnote section
293
294private extension OrgHTMLTreeRenderer {
295    /// The `<section class="footnotes">` org's exporter puts at the end of a document.
296    func renderFootnoteSection(_ notes: inout OrgFootnotes) -> String {
297        let collected = notes.notes
298        guard !collected.isEmpty else { return "" }
299        var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
300        for note in collected {
301            let n = note.number
302            let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"##
303            let body = renderInline(note.content, &notes)
304            if note.isInline {
305                html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n"
306            } else {
307                html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
308            }
309        }
310        return html + "</ol>\n</section>\n"
311    }
312}