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

html library org-mode swift

Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift

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

344 lines · 15747 bytes

  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.
  8public struct OrgHTMLTreeRenderer: Sendable {
  9    /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
 10    public var options: OrgRenderOptions
 11    public var highlighter: CodeHighlighter
 12
 13    public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) {
 14        self.options = options
 15        self.highlighter = highlighter
 16    }
 17
 18    public func render(_ document: OrgDocument) -> String {
 19        var footnotes = FootnoteNumbering(document: document)
 20        var html = metadataHeader(document)
 21        html += document.elements.map { element($0, &footnotes) }.joined()
 22        html += footnotes.renderSection(self)
 23        return html
 24    }
 25
 26    /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats
 27    /// these as document metadata carried by a page template, so the corpus renders with this
 28    /// off; an app showing a README title turns it on.
 29    private func metadataHeader(_ document: OrgDocument) -> String {
 30        guard options.metadataHeader else { return "" }
 31        let title = document.keyword("title")
 32        let author = document.keyword("author")
 33        let date = document.keyword("date")
 34        guard title != nil || author != nil || date != nil else { return "" }
 35
 36        var html = "<div class=\"org-metadata\">\n"
 37        if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" }
 38        if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" }
 39        if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" }
 40        return html + "</div>\n"
 41    }
 42
 43    // MARK: - URL resolution
 44
 45    /// Resolve a link target to a safe `href`, or nil when it cannot be made safe.
 46    ///
 47    /// Because the tree keeps targets typed, resolution is a renderer concern applied to the
 48    /// `.file` case only  no resolver closure has to be threaded through the parse.
 49    private func href(for target: OrgLinkTarget) -> String? {
 50        switch target {
 51        case .id(let identifier):
 52            return sanitizedReadmeLinkURLString("#\(identifier)")
 53        case .external(let url):
 54            return sanitizedReadmeLinkURLString(url)
 55        case .file(let path):
 56            return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
 57        }
 58    }
 59
 60    /// Resolve an image source to a safe `src`, or nil when it cannot be made safe.
 61    private func imageSource(_ source: String) -> String? {
 62        sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
 63    }
 64
 65    // MARK: - Blocks
 66
 67    private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
 68        switch element {
 69        case .heading(let heading):
 70            let level = min(6, max(1, heading.level + options.headingLevelOffset))
 71            var inner = ""
 72            if let todo = heading.todo {
 73                inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "#
 74            }
 75            if let priority = heading.priority {
 76                inner += #"<span class="priority">[#\#(priority)]</span> "#
 77            }
 78            inner += renderInline(heading.title, &notes)
 79            for tag in heading.tags {
 80                inner += #" <span class="tag">\#(escapeHTML(tag))</span>"#
 81            }
 82            return "<h\(level)>\(inner)</h\(level)>\n"
 83
 84        case .paragraph(let objects):
 85            return "<p>" + renderInline(objects, &notes) + "</p>\n"
 86
 87        case .list(let list):
 88            return renderList(list, &notes)
 89
 90        case .table(let table):
 91            return renderTable(table, &notes)
 92
 93        case .srcBlock(let language, let code):
 94            let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? ""
 95            let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code)
 96            return "<pre><code\(classAttribute)>\(body)</code></pre>\n"
 97
 98        case .exampleBlock(let text):
 99            return "<pre>\(escapeHTML(text))</pre>\n"
100
101        case .quoteBlock(let children):
102            return "<blockquote>\n" + children.map { self.element($0, &notes) }.joined() + "</blockquote>\n"
103
104        case .centerBlock(let children):
105            return #"<div class="center">"# + "\n" + children.map { self.element($0, &notes) }.joined() + "</div>\n"
106
107        case .verseBlock(let lines):
108            let body = lines.map { renderInline($0, &notes) }.joined(separator: "<br>\n")
109            return #"<p class="verse">"# + "\n" + body + "\n</p>\n"
110
111        case .specialBlock(let name, let children):
112            return #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n"
113                + children.map { self.element($0, &notes) }.joined() + "</div>\n"
114
115        case .exportBlock(let backend, let raw):
116            return backend == "html" ? raw + "\n" : ""
117
118        case .figure(let figure):
119            guard let tag = imageTag(figure, caption: figure.caption) else {
120                return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n"
121            }
122            // A bare image is a paragraph; a caption or explicit attributes promote it to a
123            // <figure>, matching org's exporter.
124            guard figure.caption != nil || !figure.attributes.isEmpty else {
125                return "<p>" + tag + "</p>\n"
126            }
127            var html = "<figure>" + tag
128            if let caption = figure.caption {
129                notes.figureNumber += 1
130                html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"#
131                    + renderInline(caption, &notes) + "</figcaption>"
132            }
133            return html + "</figure>\n"
134
135        case .captioned(let name, let caption, let content):
136            let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
137            var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n"
138            html += self.element(content, &notes)
139            if let caption {
140                html += "<figcaption>" + renderInline(caption, &notes) + "</figcaption>\n"
141            }
142            return html + "</figure>\n"
143
144        case .horizontalRule:
145            return "<hr>\n"
146
147        case .footnoteDefinition:
148            return ""  // collected and emitted in the notes section
149        }
150    }
151
152    private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String {
153        if list.kind == .description {
154            var html = "<dl>\n"
155            for item in list.items {
156                if let term = item.term {
157                    html += "<dt>" + renderInline(term, &notes) + "</dt>\n"
158                }
159                if let first = item.content.first {
160                    html += "<dd>" + renderInline(first, &notes) + "</dd>\n"
161                }
162            }
163            return html + "</dl>\n"
164        }
165
166        let tag = list.kind == .ordered ? "ol" : "ul"
167        var html = "<\(tag)>\n"
168        for item in list.items {
169            html += "<li>"
170            if let checkbox = item.checkbox {
171                switch checkbox {
172                case .off: html += "<code>[&nbsp;]</code> "
173                case .on: html += "<code>[X]</code> "
174                case .partial: html += "<code>[-]</code> "
175                }
176            }
177            if item.content.count <= 1 {
178                html += renderInline(item.content.first ?? [], &notes)
179            } else {
180                html += item.content.map { "<p>" + renderInline($0, &notes) + "</p>" }.joined(separator: "\n")
181            }
182            if let sublist = item.sublist {
183                html += "\n" + renderList(sublist, &notes)
184            }
185            html += "</li>\n"
186        }
187        return html + "</\(tag)>\n"
188    }
189
190    private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> 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 = imageSource(figure.source) else { return nil }
222        let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223        var html = #"<img src="\#(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 FootnoteNumbering) -> 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 .image(let figure):
245                html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source)
246            case .timestamp(let stamp):
247                let cssClass = stamp.active ? "timestamp" : "timestamp inactive"
248                func time(_ machine: String, _ display: String) -> String {
249                    #"<time class="\#(cssClass)" datetime="\#(machine)">\#(display)</time>"#
250                }
251                html += time(stamp.machineValue, stamp.displayValue)
252                // A range is two <time> elements joined by an en-dash, as org exports it.
253                if let end = stamp.end {
254                    html += "&#8211;" + time(end.machineValue, end.displayValue)
255                }
256            case .footnoteRef(let label, let inline):
257                let number = notes.number(for: label, inline: inline)
258                html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
259            case .link(let link):
260                let text = link.description.map { renderInline($0, &notes) }
261                    ?? escapeHTML(displayValue(link.target))
262                // An unsafe or unresolvable target degrades to its text, never a bad anchor.
263                if let href = href(for: link.target) {
264                    html += #"<a href="\#(href)">\#(text)</a>"#
265                } else {
266                    html += text
267                }
268            }
269        }
270        return html
271    }
272
273    private func displayValue(_ target: OrgLinkTarget) -> String {
274        switch target {
275        case .external(let url): return url
276        case .file(let path): return path
277        case .id(let identifier): return identifier
278        }
279    }
280
281    /// Inline objects reduced to plain text, for an `alt` attribute.
282    func plainText(_ objects: [OrgObject]) -> String {
283        objects.map { object in
284            switch object {
285            case .text(let text): return text
286            case .verbatim(let text), .code(let text): return text
287            case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
288                return plainText(c)
289            case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target)
290            case .timestamp(let stamp): return stamp.displayValue
291            case .image(let figure): return figure.alt ?? ""
292            case .footnoteRef, .lineBreak: return ""
293            }
294        }.joined()
295    }
296}
297
298// MARK: - Footnote numbering
299
300/// Assigns footnote numbers in first-reference order and renders the notes section.
301struct FootnoteNumbering {
302    private var numbers: [String: Int] = [:]
303    private var order: [String] = []
304    /// Reference-style definitions, gathered from the document's `[fn:x] ` lines.
305    private var definitions: [String: [OrgObject]] = [:]
306    /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered.
307    private var inlineDefinitions: [String: [OrgObject]] = [:]
308    var figureNumber = 0
309
310    init(document: OrgDocument) {
311        for element in document.elements {
312            if case .footnoteDefinition(let label, let content) = element {
313                definitions[label] = content
314            }
315        }
316    }
317
318    mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int {
319        if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline }
320        if let existing = numbers[label] { return existing }
321        let next = order.count + 1
322        numbers[label] = next
323        order.append(label)
324        return next
325    }
326
327    mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String {
328        guard !order.isEmpty else { return "" }
329        var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
330        for label in order {
331            let n = numbers[label] ?? 0
332            let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"##
333            // An inline footnote's text sits directly in the item; a reference-style
334            // definition is a paragraph, matching org's exporter.
335            if let inline = inlineDefinitions[label] {
336                html += "<li id=\"fn-\(n)\">\(renderer.renderInline(inline, &self)) \(back)</li>\n"
337            } else {
338                let body = definitions[label].map { renderer.renderInline($0, &self) } ?? ""
339                html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
340            }
341        }
342        return html + "</ol>\n</section>\n"
343    }
344}