| @@ -16,10 +16,10 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | public func render(_ document: OrgDocument) -> String { |
| 19 | | var footnotes = FootnoteNumbering(document: document) |
| 19 | var footnotes = OrgFootnotes(document: document) |
| 20 | 20 | var html = metadataHeader(document) |
| 21 | 21 | html += document.elements.map { element($0, &footnotes) }.joined() |
| 22 | | html += footnotes.renderSection(self) |
| 22 | html += renderFootnoteSection(&footnotes) |
| 23 | 23 | return html |
| 24 | 24 | } |
| 25 | 25 | |
| @@ -42,29 +42,12 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 42 | 42 | |
| 43 | 43 | // MARK: - URL resolution |
| 44 | 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 | | } |
| 45 | /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``. |
| 46 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| 64 | 47 | |
| 65 | 48 | // MARK: - Blocks |
| 66 | 49 | |
| 67 | | private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { |
| 50 | private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String { |
| 68 | 51 | switch element { |
| 69 | 52 | case .heading(let heading): |
| 70 | 53 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) |
| @@ -149,7 +132,7 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 149 | 132 | } |
| 150 | 133 | } |
| 151 | 134 | |
| 152 | | private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String { |
| 135 | private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String { |
| 153 | 136 | if list.kind == .description { |
| 154 | 137 | var html = "<dl>\n" |
| 155 | 138 | for item in list.items { |
| @@ -187,7 +170,7 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 187 | 170 | return html + "</\(tag)>\n" |
| 188 | 171 | } |
| 189 | 172 | |
| 190 | | private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String { |
| 173 | private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { |
| 191 | 174 | var html = "<table>\n" |
| 192 | 175 | var wroteHeader = false |
| 193 | 176 | var inBody = false |
| @@ -218,7 +201,7 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 218 | 201 | /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — |
| 219 | 202 | /// in which case the caller falls back to text rather than pointing at something unsafe. |
| 220 | 203 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { |
| 221 | | guard let source = imageSource(figure.source) else { return nil } |
| 204 | guard let source = resolver.imageSource(figure.source) else { return nil } |
| 222 | 205 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" |
| 223 | 206 | var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# |
| 224 | 207 | for (key, value) in figure.attributes where key != "alt" { |
| @@ -229,7 +212,7 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 229 | 212 | |
| 230 | 213 | // MARK: - Inline |
| 231 | 214 | |
| 232 | | func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { |
| 215 | func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String { |
| 233 | 216 | var html = "" |
| 234 | 217 | for object in objects { |
| 235 | 218 | switch object { |
| @@ -258,9 +241,9 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 258 | 241 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## |
| 259 | 242 | case .link(let link): |
| 260 | 243 | let text = link.description.map { renderInline($0, ¬es) } |
| 261 | | ?? escapeHTML(displayValue(link.target)) |
| 244 | ?? escapeHTML(resolver.displayValue(link.target)) |
| 262 | 245 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. |
| 263 | | if let href = href(for: link.target) { |
| 246 | if let href = resolver.href(for: link.target) { |
| 264 | 247 | html += #"<a href="\#(href)">\#(text)</a>"# |
| 265 | 248 | } else { |
| 266 | 249 | html += text |
| @@ -270,14 +253,6 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 270 | 253 | return html |
| 271 | 254 | } |
| 272 | 255 | |
| 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 | 256 | /// Inline objects reduced to plain text, for an `alt` attribute. |
| 282 | 257 | func plainText(_ objects: [OrgObject]) -> String { |
| 283 | 258 | objects.map { object in |
| @@ -286,7 +261,7 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 286 | 261 | case .verbatim(let text), .code(let text): return text |
| 287 | 262 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): |
| 288 | 263 | return plainText(c) |
| 289 | | case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target) |
| 264 | case .link(let link): return link.description.map { plainText($0) } ?? resolver.displayValue(link.target) |
| 290 | 265 | case .timestamp(let stamp): return stamp.displayValue |
| 291 | 266 | case .image(let figure): return figure.alt ?? "" |
| 292 | 267 | case .footnoteRef, .lineBreak: return "" |
| @@ -295,47 +270,21 @@ public struct OrgHTMLTreeRenderer: Sendable { |
| 295 | 270 | } |
| 296 | 271 | } |
| 297 | 272 | |
| 298 | | // MARK: - Footnote numbering |
| 299 | | |
| 300 | | /// Assigns footnote numbers in first-reference order and renders the notes section. |
| 301 | | struct 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 | | } |
| 273 | // MARK: - Footnote section |
| 326 | 274 | |
| 327 | | mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String { |
| 328 | | guard !order.isEmpty else { return "" } |
| 275 | private extension OrgHTMLTreeRenderer { |
| 276 | /// The `<section class="footnotes">` org's exporter puts at the end of a document. |
| 277 | func renderFootnoteSection(_ notes: inout OrgFootnotes) -> String { |
| 278 | let collected = notes.notes |
| 279 | guard !collected.isEmpty else { return "" } |
| 329 | 280 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" |
| 330 | | for label in order { |
| 331 | | let n = numbers[label] ?? 0 |
| 281 | for note in collected { |
| 282 | let n = note.number |
| 332 | 283 | 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" |
| 284 | let body = renderInline(note.content, ¬es) |
| 285 | if note.isInline { |
| 286 | html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n" |
| 337 | 287 | } else { |
| 338 | | let body = definitions[label].map { renderer.renderInline($0, &self) } ?? "" |
| 339 | 288 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" |
| 340 | 289 | } |
| 341 | 290 | } |