import Foundation
/// Renders an ``OrgDocument`` to HTML by walking the tree.
///
/// The point of the prototype: this produces the same shape of output as the shipped
/// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second
/// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it.
public struct OrgHTMLTreeRenderer: Sendable {
/// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
public var options: OrgRenderOptions
public var highlighter: CodeHighlighter
public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) {
self.options = options
self.highlighter = highlighter
}
public func render(_ document: OrgDocument) -> String {
var footnotes = FootnoteNumbering(document: document)
var html = metadataHeader(document)
html += document.elements.map { element($0, &footnotes) }.joined()
html += footnotes.renderSection(self)
return html
}
/// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats
/// these as document metadata carried by a page template, so the corpus renders with this
/// off; an app showing a README title turns it on.
private func metadataHeader(_ document: OrgDocument) -> String {
guard options.metadataHeader else { return "" }
let title = document.keyword("title")
let author = document.keyword("author")
let date = document.keyword("date")
guard title != nil || author != nil || date != nil else { return "" }
var html = "
\n"
if let title { html += "
" + escapeHTML(title) + "
\n" }
if let author { html += "
" + escapeHTML(author) + "
\n" }
if let date { html += "
" + escapeHTML(date) + "
\n" }
return html + "
\n"
}
// MARK: - URL resolution
/// Resolve a link target to a safe `href`, or nil when it cannot be made safe.
///
/// Because the tree keeps targets typed, resolution is a renderer concern applied to the
/// `.file` case only — no resolver closure has to be threaded through the parse.
private func href(for target: OrgLinkTarget) -> String? {
switch target {
case .id(let identifier):
return sanitizedReadmeLinkURLString("#\(identifier)")
case .external(let url):
return sanitizedReadmeLinkURLString(url)
case .file(let path):
return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
}
}
/// Resolve an image source to a safe `src`, or nil when it cannot be made safe.
private func imageSource(_ source: String) -> String? {
sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
}
// MARK: - Blocks
private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
switch element {
case .heading(let heading):
let level = min(6, max(1, heading.level + options.headingLevelOffset))
var inner = ""
if let todo = heading.todo {
inner += #"\#(todo) "#
}
if let priority = heading.priority {
inner += #"[#\#(priority)] "#
}
inner += renderInline(heading.title, ¬es)
for tag in heading.tags {
inner += #" \#(escapeHTML(tag))"#
}
return "\(inner)\n"
case .paragraph(let objects):
return "
" + renderInline(objects, ¬es) + "
\n"
case .list(let list):
return renderList(list, ¬es)
case .table(let table):
return renderTable(table, ¬es)
case .srcBlock(let language, let code):
let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? ""
let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code)
return "
\n"
case .exportBlock(let backend, let raw):
return backend == "html" ? raw + "\n" : ""
case .figure(let figure):
guard let tag = imageTag(figure, caption: figure.caption) else {
return "
" + escapeHTML(figure.alt ?? figure.source) + "
\n"
}
// A bare image is a paragraph; a caption or explicit attributes promote it to a
//
\n"
}
var html = "" + tag
if let caption = figure.caption {
notes.figureNumber += 1
html += #"Figure \#(notes.figureNumber): "#
+ renderInline(caption, ¬es) + ""
}
return html + "\n"
case .captioned(let name, let caption, let content):
let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
var html = #""# + "\n"
html += self.element(content, ¬es)
if let caption {
html += "" + renderInline(caption, ¬es) + "\n"
}
return html + "\n"
case .horizontalRule:
return "\n"
case .footnoteDefinition:
return "" // collected and emitted in the notes section
}
}
private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String {
if list.kind == .description {
var html = "
\n"
for item in list.items {
if let term = item.term {
html += "
" + renderInline(term, ¬es) + "
\n"
}
if let first = item.content.first {
html += "
" + renderInline(first, ¬es) + "
\n"
}
}
return html + "
\n"
}
let tag = list.kind == .ordered ? "ol" : "ul"
var html = "<\(tag)>\n"
for item in list.items {
html += "
"
if let checkbox = item.checkbox {
switch checkbox {
case .off: html += "[ ] "
case .on: html += "[X] "
case .partial: html += "[-] "
}
}
if item.content.count <= 1 {
html += renderInline(item.content.first ?? [], ¬es)
} else {
html += item.content.map { "
" + renderInline($0, ¬es) + "
" }.joined(separator: "\n")
}
if let sublist = item.sublist {
html += "\n" + renderList(sublist, ¬es)
}
html += "
\n"
}
return html + "\(tag)>\n"
}
private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String {
var html = "
\n"
var wroteHeader = false
var inBody = false
let headerCount = table.headerRowCount
for (index, row) in table.rows.enumerated() {
switch row {
case .rule:
if wroteHeader, !inBody { html += "\n\n"; inBody = true }
case .cells(let cells):
let isHeader = headerCount > 0 && index < headerCount
if isHeader, !wroteHeader { html += "\n"; wroteHeader = true }
if !isHeader, !inBody { html += "\n"; inBody = true }
html += "
\n"
for (column, cell) in cells.enumerated() {
let tag = isHeader ? "th" : "td"
let alignment = column < table.alignments.count ? table.alignments[column] : nil
let style = alignment.map { #" style="text-align: \#($0.rawValue);""# } ?? ""
html += "<\(tag)\(style)>" + renderInline(cell, ¬es) + "\(tag)>\n"
}
html += "
\n"
}
}
if inBody { html += "\n" }
return html + "
\n"
}
/// The `` for a figure, or nil when the source cannot be resolved to a safe URL —
/// in which case the caller falls back to text rather than pointing at something unsafe.
private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
guard let source = imageSource(figure.source) else { return nil }
let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
var html = #""
}
// MARK: - Inline
func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String {
var html = ""
for object in objects {
switch object {
case .text(let text): html += escapeHTML(text)
case .bold(let children): html += "" + renderInline(children, ¬es) + ""
case .italic(let children): html += "" + renderInline(children, ¬es) + ""
case .underline(let children): html += "" + renderInline(children, ¬es) + ""
case .strikeThrough(let children): html += "" + renderInline(children, ¬es) + ""
case .verbatim(let text), .code(let text): html += "" + escapeHTML(text) + ""
case .superscript(let children): html += "" + renderInline(children, ¬es) + ""
case .lineBreak: html += " "
case .image(let figure):
html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source)
case .timestamp(let stamp):
let cssClass = stamp.active ? "timestamp" : "timestamp inactive"
func time(_ machine: String, _ display: String) -> String {
#""#
}
html += time(stamp.machineValue, stamp.displayValue)
// A range is two