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. /// /// Not `Sendable`: it holds a ``CodeHighlighter``, and a real one wraps a thread-confined /// engine (Highlightr's JavaScriptCore context, say). Sending the renderer across isolation /// domains would call that engine off its thread. Make one where you render. public struct OrgHTMLTreeRenderer { /// 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 = OrgFootnotes(document: document) var html = metadataHeader(document) html += document.elements.map { element($0, &footnotes) }.joined() html += renderFootnoteSection(&footnotes) 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 /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``. private var resolver: OrgURLResolver { OrgURLResolver(options: options) } // MARK: - Blocks private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> 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 "
\(body)
\n" case .exampleBlock(let text): return "
\(escapeHTML(text))
\n" case .quoteBlock(let children): return "
\n" + children.map { self.element($0, ¬es) }.joined() + "
\n" case .centerBlock(let children): return #"
"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "
\n" case .verseBlock(let lines): let body = lines.map { renderInline($0, ¬es) }.joined(separator: "
\n") return #"

"# + "\n" + body + "\n

\n" case .specialBlock(let name, let children): return #"
"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "
\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 //
, matching org's exporter. guard figure.caption != nil || !figure.attributes.isEmpty else { return "

" + tag + "

\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 OrgFootnotes) -> String { if list.kind == .description { var html = "
\n" for item in list.items { if let term = item.term { html += "
" + renderInline(term, ¬es) + "
\n" } if !item.content.isEmpty { html += "
" + renderItemContent(item, ¬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 += "[-] " } } html += renderItemContent(item, ¬es) html += "
  • \n" } return html + "\n" } /// A list item's body. A lone paragraph renders bare inside the `
  • `; two or more get /// their own `

    `, matching org's exporter. Anything that is not a paragraph — a nested /// list, a source block, a table — renders as the block it is, in source order. private func renderItemContent(_ item: OrgListItem, _ notes: inout OrgFootnotes) -> String { let wrapParagraphs = item.paragraphs.count > 1 var parts: [String] = [] for child in item.content { if case .paragraph(let objects) = child { let text = renderInline(objects, ¬es) parts.append(wrapParagraphs ? "

    " + text + "

    " : text) } else { parts.append(element(child, ¬es)) } } return parts.joined(separator: "\n") } private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> 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) + "\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 = resolver.imageSource(figure.source) else { return nil } let alt = figure.alt ?? caption.map { plainText($0) } ?? "" var html = #"\#(escapeHTMLAttribute(alt))" } // MARK: - Inline func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> 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