Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift
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, ¬es)
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, ¬es) + "</p>\n"
73
74 case .list(let list):
75 return renderList(list, ¬es)
76
77 case .table(let table):
78 return renderTable(table, ¬es)
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, ¬es) }.joined() + "</blockquote>\n"
90
91 case .centerBlock(let children):
92 return #"<div class="center">"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "</div>\n"
93
94 case .verseBlock(let lines):
95 let body = lines.map { renderInline($0, ¬es) }.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, ¬es) }.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, ¬es) + "</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, ¬es)
126 if let caption {
127 html += "<figcaption>" + renderInline(caption, ¬es) + "</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, ¬es) + "</dt>\n"
148 }
149 if !item.content.isEmpty {
150 html += "<dd>" + renderItemContent(item, ¬es) + "</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>[ ]</code> "
163 case .on: html += "<code>[X]</code> "
164 case .partial: html += "<code>[-]</code> "
165 }
166 }
167 html += renderItemContent(item, ¬es)
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, ¬es)
182 parts.append(wrapParagraphs ? "<p>" + text + "</p>" : text)
183 } else {
184 parts.append(element(child, ¬es))
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, ¬es) + "</\(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, ¬es) + "</strong>"
238 case .italic(let children): html += "<em>" + renderInline(children, ¬es) + "</em>"
239 case .underline(let children): html += "<u>" + renderInline(children, ¬es) + "</u>"
240 case .strikeThrough(let children): html += "<del>" + renderInline(children, ¬es) + "</del>"
241 case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>"
242 case .superscript(let children): html += "<sup>" + renderInline(children, ¬es) + "</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 += "–" + 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, ¬es) }
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)">↩</a>"##
303 let body = renderInline(note.content, ¬es)
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}