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 "
\(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 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 + "\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) + "\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 = #"\#(escapeHTMLAttribute(alt))" } // 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