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