Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift
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, ¬es)
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, ¬es) + "</p>\n"
86
87 case .list(let list):
88 return renderList(list, ¬es)
89
90 case .table(let table):
91 return renderTable(table, ¬es)
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, ¬es) }.joined() + "</blockquote>\n"
103
104 case .centerBlock(let children):
105 return #"<div class="center">"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "</div>\n"
106
107 case .verseBlock(let lines):
108 let body = lines.map { renderInline($0, ¬es) }.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, ¬es) }.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, ¬es) + "</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, ¬es)
139 if let caption {
140 html += "<figcaption>" + renderInline(caption, ¬es) + "</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, ¬es) + "</dt>\n"
158 }
159 if let first = item.content.first {
160 html += "<dd>" + renderInline(first, ¬es) + "</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>[ ]</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 ?? [], ¬es)
179 } else {
180 html += item.content.map { "<p>" + renderInline($0, ¬es) + "</p>" }.joined(separator: "\n")
181 }
182 if let sublist = item.sublist {
183 html += "\n" + renderList(sublist, ¬es)
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, ¬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 = 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, ¬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 .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 += "–" + 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, ¬es) }
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)">↩</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}