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
77/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a
88/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
99public struct OrgAttributedStringRenderer: Sendable {
10 /// Resolution options, so repository-relative links resolve exactly as they do in HTML.
11 public var options: OrgRenderOptions
1012
11 public init() {}
13 public init(options: OrgRenderOptions = .init()) {
14 self.options = options
15 }
16
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
1218
1319 /// 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.
1424 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 {
1530 var result = AttributedString()
1631 for object in objects {
1732 switch object {
@@ -19,12 +34,12 @@ public struct OrgAttributedStringRenderer: Sendable {
1934 result += AttributedString(text)
2035
2136 case .bold(let children):
22 var part = inline(children)
37 var part = inline(children, &notes)
2338 part.inlinePresentationIntent = .stronglyEmphasized
2439 result += part
2540
2641 case .italic(let children):
27 var part = inline(children)
42 var part = inline(children, &notes)
2843 part.inlinePresentationIntent = .emphasized
2944 result += part
3045
@@ -32,12 +47,12 @@ public struct OrgAttributedStringRenderer: Sendable {
3247 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
3348 // travel as roles the UI layer applies.
3449 case .underline(let children):
35 var part = inline(children)
50 var part = inline(children, &notes)
3651 part.orgRole = .underline
3752 result += part
3853
3954 case .strikeThrough(let children):
40 var part = inline(children)
55 var part = inline(children, &notes)
4156 part.orgRole = .strikeThrough
4257 result += part
4358
@@ -48,7 +63,7 @@ public struct OrgAttributedStringRenderer: Sendable {
4863
4964 case .superscript(let children):
5065 // No portable superscript attribute; mark it so a UI layer can raise it.
51 var part = inline(children)
66 var part = inline(children, &notes)
5267 part.orgRole = .superscript
5368 result += part
5469
@@ -60,8 +75,8 @@ public struct OrgAttributedStringRenderer: Sendable {
6075 part.orgRole = .timestamp
6176 result += part
6277
63 case .footnoteRef(let label, _):
64 var part = AttributedString("[\(label)]")
78 case .footnoteRef(let label, let inlineNote):
79 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
6580 part.orgRole = .footnoteReference
6681 result += part
6782
@@ -71,8 +86,9 @@ public struct OrgAttributedStringRenderer: Sendable {
7186 result += part
7287
7388 case .link(let link):
74 var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target))
75 if let url = URL(string: hrefValue(link.target)) {
89 var part = link.description.map { inline($0, &notes) }
90 ?? AttributedString(resolver.displayValue(link.target))
91 if let href = resolver.href(for: link.target), let url = URL(string: href) {
7692 part.link = url
7793 }
7894 result += part
@@ -93,21 +109,6 @@ public struct OrgAttributedStringRenderer: Sendable {
93109 }
94110 }
95111
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 }
111112}
112113
113114// MARK: - Custom attribute
Sources/OrgSwift/OrgRenderer.swift +1 −1
@@ -19,7 +19,7 @@ public struct PlainCodeHighlighter: CodeHighlighter {
1919/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
2020/// When either is nil, relative links are left as-is (and dropped by the
2121/// URL-scheme allowlist, which only permits absolute http/https/mailto).
22public struct OrgRenderOptions {
22public struct OrgRenderOptions: Sendable {
2323 /// Host used to build absolute URLs for repository-relative links.
2424 public var host: String
2525 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
Tests/OrgSwiftTests/OrgTreeTests.swift +43
@@ -122,6 +122,49 @@ struct ASTRendererTests {
122122 #expect(linkRun?.link?.absoluteString == "https://example.com")
123123 }
124124
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
125168 @Test
126169 func attributedStringCarriesRolesForNonStandardIntents() {
127170 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")