krz/org-swift

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

Commit a9bc9a9685

a9bc9a9685c4a8716fdd08b81ee503469282a61d

parent: bec396ade8

Unsigned

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

Resolve URLs and number footnotes in the native renderer

OrgAttributedStringRenderer emitted `.file` targets as raw relative paths
and footnote references as their label, so a README rendered natively got
broken links and unnumbered notes where the HTML one did not. It now takes
OrgRenderOptions and shares OrgURLResolver and OrgFootnotes with the HTML
renderer, so both resolve, sanitize and number identically.

OrgRenderOptions is Sendable, which it always was in practice.

Layout: unified · split

Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +26 −25
@@ -7,11 +7,26 @@ import Foundation
7/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a 7/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a
8/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block. 8/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
9public struct OrgAttributedStringRenderer: Sendable { 9public struct OrgAttributedStringRenderer: Sendable {
10 /// Resolution options, so repository-relative links resolve exactly as they do in HTML.
11 public var options: OrgRenderOptions
10 12
11 public init() {} 13 public init(options: OrgRenderOptions = .init()) {
14 self.options = options
15 }
16
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
12 18
13 /// Render one run of inline objects, carrying intents a UI layer can style. 19 /// Render one run of inline objects, carrying intents a UI layer can style.
20 ///
21 /// Footnote numbering is document-wide, so a caller rendering a whole document threads
22 /// its own ``OrgFootnotes`` through ``inline(_:_:)``; this overload numbers within the
23 /// run it is given.
14 public func inline(_ objects: [OrgObject]) -> AttributedString { 24 public func inline(_ objects: [OrgObject]) -> AttributedString {
25 var notes = OrgFootnotes(document: OrgDocument())
26 return inline(objects, &notes)
27 }
28
29 package func inline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> AttributedString {
15 var result = AttributedString() 30 var result = AttributedString()
16 for object in objects { 31 for object in objects {
17 switch object { 32 switch object {
@@ -19,12 +34,12 @@ public struct OrgAttributedStringRenderer: Sendable {
19 result += AttributedString(text) 34 result += AttributedString(text)
20 35
21 case .bold(let children): 36 case .bold(let children):
22 var part = inline(children) 37 var part = inline(children, &notes)
23 part.inlinePresentationIntent = .stronglyEmphasized 38 part.inlinePresentationIntent = .stronglyEmphasized
24 result += part 39 result += part
25 40
26 case .italic(let children): 41 case .italic(let children):
27 var part = inline(children) 42 var part = inline(children, &notes)
28 part.inlinePresentationIntent = .emphasized 43 part.inlinePresentationIntent = .emphasized
29 result += part 44 result += part
30 45
@@ -32,12 +47,12 @@ public struct OrgAttributedStringRenderer: Sendable {
32 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they 47 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
33 // travel as roles the UI layer applies. 48 // travel as roles the UI layer applies.
34 case .underline(let children): 49 case .underline(let children):
35 var part = inline(children) 50 var part = inline(children, &notes)
36 part.orgRole = .underline 51 part.orgRole = .underline
37 result += part 52 result += part
38 53
39 case .strikeThrough(let children): 54 case .strikeThrough(let children):
40 var part = inline(children) 55 var part = inline(children, &notes)
41 part.orgRole = .strikeThrough 56 part.orgRole = .strikeThrough
42 result += part 57 result += part
43 58
@@ -48,7 +63,7 @@ public struct OrgAttributedStringRenderer: Sendable {
48 63
49 case .superscript(let children): 64 case .superscript(let children):
50 // No portable superscript attribute; mark it so a UI layer can raise it. 65 // No portable superscript attribute; mark it so a UI layer can raise it.
51 var part = inline(children) 66 var part = inline(children, &notes)
52 part.orgRole = .superscript 67 part.orgRole = .superscript
53 result += part 68 result += part
54 69
@@ -60,8 +75,8 @@ public struct OrgAttributedStringRenderer: Sendable {
60 part.orgRole = .timestamp 75 part.orgRole = .timestamp
61 result += part 76 result += part
62 77
63 case .footnoteRef(let label, _): 78 case .footnoteRef(let label, let inlineNote):
64 var part = AttributedString("[\(label)]") 79 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
65 part.orgRole = .footnoteReference 80 part.orgRole = .footnoteReference
66 result += part 81 result += part
67 82
@@ -71,8 +86,9 @@ public struct OrgAttributedStringRenderer: Sendable {
71 result += part 86 result += part
72 87
73 case .link(let link): 88 case .link(let link):
74 var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target)) 89 var part = link.description.map { inline($0, &notes) }
75 if let url = URL(string: hrefValue(link.target)) { 90 ?? AttributedString(resolver.displayValue(link.target))
91 if let href = resolver.href(for: link.target), let url = URL(string: href) {
76 part.link = url 92 part.link = url
77 } 93 }
78 result += part 94 result += part
@@ -93,21 +109,6 @@ public struct OrgAttributedStringRenderer: Sendable {
93 } 109 }
94 } 110 }
95 111
96 private func hrefValue(_ target: OrgLinkTarget) -> String {
97 switch target {
98 case .external(let url): return url
99 case .file(let path): return path
100 case .id(let identifier): return "#\(identifier)"
101 }
102 }
103
104 private func displayValue(_ target: OrgLinkTarget) -> String {
105 switch target {
106 case .external(let url): return url
107 case .file(let path): return path
108 case .id(let identifier): return identifier
109 }
110 }
111} 112}
112 113
113// MARK: - Custom attribute 114// MARK: - Custom attribute
Sources/OrgSwift/OrgRenderer.swift +1 −1
@@ -19,7 +19,7 @@ public struct PlainCodeHighlighter: CodeHighlighter {
19/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`. 19/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
20/// When either is nil, relative links are left as-is (and dropped by the 20/// When either is nil, relative links are left as-is (and dropped by the
21/// URL-scheme allowlist, which only permits absolute http/https/mailto). 21/// URL-scheme allowlist, which only permits absolute http/https/mailto).
22public struct OrgRenderOptions { 22public struct OrgRenderOptions: Sendable {
23 /// Host used to build absolute URLs for repository-relative links. 23 /// Host used to build absolute URLs for repository-relative links.
24 public var host: String 24 public var host: String
25 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution. 25 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
Tests/OrgSwiftTests/OrgTreeTests.swift +43
@@ -122,6 +122,49 @@ struct ASTRendererTests {
122 #expect(linkRun?.link?.absoluteString == "https://example.com") 122 #expect(linkRun?.link?.absoluteString == "https://example.com")
123 } 123 }
124 124
125 /// The native renderer resolves and sanitizes exactly as the HTML one does, so a README
126 /// rendered natively gets the same working links rather than raw relative paths.
127 @Test
128 func attributedStringResolvesRelativeLinksAndRejectsUnsafeOnes() {
129 let renderer = OrgAttributedStringRenderer(options: OrgRenderOptions(
130 host: "gitbay.org",
131 owner: "krz",
132 repositoryName: "org-swift",
133 ref: "HEAD",
134 readmePath: "README.org"
135 ))
136
137 let relative = renderer.inline(OrgParser.parseInline("[[./ARCHITECTURE.md][notes]]"))
138 #expect(relative.runs.first { $0.link != nil }?.link?.absoluteString
139 == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md")
140
141 // Without repository context there is nothing to resolve against, so an unsafe
142 // scheme reaches the allowlist and the link degrades to its text — as in HTML.
143 let unsafe = OrgAttributedStringRenderer().inline(OrgParser.parseInline("[[javascript:alert(1)][tap]]"))
144 #expect(String(unsafe.characters) == "tap")
145 #expect(unsafe.runs.allSatisfy { $0.link == nil })
146 #expect(!OrgRenderer.renderToHTML("[[javascript:alert(1)][tap]]").lowercased().contains("javascript:"))
147 }
148
149 /// Footnote numbering is document-wide and order-of-use, and now shared with HTML, so
150 /// both renderers agree on which note is number 1.
151 @Test
152 func attributedStringNumbersFootnotesInReferenceOrder() {
153 let doc = OrgParser.parse("""
154 First[fn:b] then second[fn:a] then first again[fn:b].
155
156 [fn:a] note a
157 [fn:b] note b
158 """)
159 guard case .paragraph(let objects) = doc.elements[0] else {
160 Issue.record("expected a paragraph"); return
161 }
162 var notes = OrgFootnotes(document: doc)
163 let text = String(OrgAttributedStringRenderer().inline(objects, &notes).characters)
164 #expect(text == "First[1] then second[2] then first again[1].")
165 #expect(notes.notes.map(\.label) == ["b", "a"])
166 }
167
125 @Test 168 @Test
126 func attributedStringCarriesRolesForNonStandardIntents() { 169 func attributedStringCarriesRolesForNonStandardIntents() {
127 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") 170 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")