krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit bec396ade8

bec396ade8d304ad3623576fdc055f9cb4ce1938

parent: 40c9508fc7

Unsigned

cmc <hello@cleberg.net> · 2026-08-31 17:26 UTC

Share footnote numbering and URL resolution across renderers

Both were private to the HTML renderer, so a second renderer in another
module could not reach them. Footnote numbering is output-format agnostic
and moves to OrgFootnotes; the section markup stays HTML-side. Link and
image resolution moves to OrgURLResolver. Both are `package`, so the
public API is unchanged.

Layout: unified · split

Sources/OrgSwift/AST/OrgFootnotes.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2
3/// Assigns footnote numbers in first-reference order and holds the notes they resolve to.
4///
5/// Numbering is document-wide and order-of-use, so it belongs to the render pass rather than
6/// the parse: a renderer walks the tree, calls ``number(for:inline:)`` as it meets each
7/// reference, then reads ``notes`` to emit the section. It is output-format agnostic —
8/// the HTML renderer and the SwiftUI one share it.
9package struct OrgFootnotes {
10 private var numbers: [String: Int] = [:]
11 private var order: [String] = []
12 /// Reference-style definitions, gathered from the document's `[fn:x] …` lines.
13 private var definitions: [String: [OrgObject]] = [:]
14 /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered.
15 private var inlineDefinitions: [String: [OrgObject]] = [:]
16 package var figureNumber = 0
17
18 package init(document: OrgDocument) {
19 for element in document.elements {
20 if case .footnoteDefinition(let label, let content) = element {
21 definitions[label] = content
22 }
23 }
24 }
25
26 package mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int {
27 if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline }
28 if let existing = numbers[label] { return existing }
29 let next = order.count + 1
30 numbers[label] = next
31 order.append(label)
32 return next
33 }
34
35 /// One numbered note, resolved to its content.
36 package struct Note {
37 package var number: Int
38 package var label: String
39 package var content: [OrgObject]
40 /// An inline footnote's text sits directly in the item; a reference-style definition
41 /// is a paragraph, matching org's exporter.
42 package var isInline: Bool
43 }
44
45 /// The referenced notes, in numbering order. Empty when the document has none, which is
46 /// the renderer's cue to omit the section entirely.
47 package var notes: [Note] {
48 order.map { label in
49 Note(
50 number: numbers[label] ?? 0,
51 label: label,
52 content: inlineDefinitions[label] ?? definitions[label] ?? [],
53 isInline: inlineDefinitions[label] != nil
54 )
55 }
56 }
57}
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +23 −74
@@ -16,10 +16,10 @@ public struct OrgHTMLTreeRenderer: Sendable {
1616 }
1717
1818 public func render(_ document: OrgDocument) -> String {
19 var footnotes = FootnoteNumbering(document: document)
19 var footnotes = OrgFootnotes(document: document)
2020 var html = metadataHeader(document)
2121 html += document.elements.map { element($0, &footnotes) }.joined()
22 html += footnotes.renderSection(self)
22 html += renderFootnoteSection(&footnotes)
2323 return html
2424 }
2525
@@ -42,29 +42,12 @@ public struct OrgHTMLTreeRenderer: Sendable {
4242
4343 // MARK: - URL resolution
4444
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) }
6447
6548 // MARK: - Blocks
6649
67 private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
50 private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String {
6851 switch element {
6952 case .heading(let heading):
7053 let level = min(6, max(1, heading.level + options.headingLevelOffset))
@@ -149,7 +132,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
149132 }
150133 }
151134
152 private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String {
135 private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String {
153136 if list.kind == .description {
154137 var html = "<dl>\n"
155138 for item in list.items {
@@ -187,7 +170,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
187170 return html + "</\(tag)>\n"
188171 }
189172
190 private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String {
173 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
191174 var html = "<table>\n"
192175 var wroteHeader = false
193176 var inBody = false
@@ -218,7 +201,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
218201 /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL —
219202 /// in which case the caller falls back to text rather than pointing at something unsafe.
220203 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 }
222205 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223206 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""#
224207 for (key, value) in figure.attributes where key != "alt" {
@@ -229,7 +212,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
229212
230213 // MARK: - Inline
231214
232 func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String {
215 func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String {
233216 var html = ""
234217 for object in objects {
235218 switch object {
@@ -258,9 +241,9 @@ public struct OrgHTMLTreeRenderer: Sendable {
258241 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
259242 case .link(let link):
260243 let text = link.description.map { renderInline($0, &notes) }
261 ?? escapeHTML(displayValue(link.target))
244 ?? escapeHTML(resolver.displayValue(link.target))
262245 // 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) {
264247 html += #"<a href="\#(href)">\#(text)</a>"#
265248 } else {
266249 html += text
@@ -270,14 +253,6 @@ public struct OrgHTMLTreeRenderer: Sendable {
270253 return html
271254 }
272255
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
281256 /// Inline objects reduced to plain text, for an `alt` attribute.
282257 func plainText(_ objects: [OrgObject]) -> String {
283258 objects.map { object in
@@ -286,7 +261,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
286261 case .verbatim(let text), .code(let text): return text
287262 case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
288263 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)
290265 case .timestamp(let stamp): return stamp.displayValue
291266 case .image(let figure): return figure.alt ?? ""
292267 case .footnoteRef, .lineBreak: return ""
@@ -295,47 +270,21 @@ public struct OrgHTMLTreeRenderer: Sendable {
295270 }
296271}
297272
298// MARK: - Footnote numbering
299
300/// Assigns footnote numbers in first-reference order and renders the notes section.
301struct 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
326274
327 mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String {
328 guard !order.isEmpty else { return "" }
275private 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 "" }
329280 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
332283 let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</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, &notes)
285 if note.isInline {
286 html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n"
337287 } else {
338 let body = definitions[label].map { renderer.renderInline($0, &self) } ?? ""
339288 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
340289 }
341290 }
Sources/OrgSwift/AST/OrgURLResolver.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2
3/// Resolves a link target or image source to a safe absolute URL string, or nil when it
4/// cannot be made safe.
5///
6/// Because the tree keeps targets typed, resolution is a renderer concern applied to the
7/// `.file` case only — no resolver closure has to be threaded through the parse. Both
8/// renderers resolve identically, so a link that degrades to text in HTML degrades to text
9/// natively too.
10package struct OrgURLResolver: Sendable {
11 package var options: OrgRenderOptions
12
13 package init(options: OrgRenderOptions) {
14 self.options = options
15 }
16
17 package func href(for target: OrgLinkTarget) -> String? {
18 switch target {
19 case .id(let identifier):
20 return sanitizedReadmeLinkURLString("#\(identifier)")
21 case .external(let url):
22 return sanitizedReadmeLinkURLString(url)
23 case .file(let path):
24 return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
25 }
26 }
27
28 package func imageSource(_ source: String) -> String? {
29 sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
30 }
31
32 /// The text a target shows when it carries no description.
33 package func displayValue(_ target: OrgLinkTarget) -> String {
34 switch target {
35 case .external(let url): return url
36 case .file(let path): return path
37 case .id(let identifier): return identifier
38 }
39 }
40}