krz/org-swift

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

Commit 6ea947eeca

6ea947eeca85512c4ed4f35a1b0ee84b5e11b165

parent: 6c1f09c138

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-01 00:28 UTC

Expose repository-relative URL resolution

A README is not always org. gitbay-ios renders a Markdown README through its own
view, which had no way to resolve a relative link, so `[x](ARCHITECTURE.md)`
rendered styled but inert. Ref krz/gitbay-ios#11.

OrgRenderOptions.resolvedLinkURL/resolvedImageURL make the existing resolution
public rather than have a second implementation grow in the app.

The sanitizer no longer HTML-escapes what it returns: it returns a URL, and a
URL(string:) caller on an `&amp;` gets the wrong query. The HTML renderer now
escapes at the two points it writes an attribute, which also fixes `&` in a
native org link.

Layout: unified · split

README.md +10
@@ -62,6 +62,16 @@ let html = OrgRenderer.renderToHTML(
62`./images/badge.svg` then resolves to 62`./images/badge.svg` then resolves to
63`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. 63`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`.
64 64
65The same resolution is available on its own, for a caller whose README is not org:
66
67```swift
68options.resolvedLinkURL("ARCHITECTURE.md") // .../blob/HEAD/ARCHITECTURE.md
69options.resolvedImageURL("docs/logo.png") // .../raw/HEAD/docs/logo.png
70```
71
72Both return a plain URL string, not an HTML attribute value — a caller building
73markup escapes where it interpolates.
74
65### Native SwiftUI 75### Native SwiftUI
66 76
67`OrgSwiftUI` renders the same parse as views rather than HTML — selectable text, 77`OrgSwiftUI` renders the same parse as views rather than HTML — selectable text,
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +2 −2
@@ -217,7 +217,7 @@ public struct OrgHTMLTreeRenderer {
217 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { 217 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
218 guard let source = resolver.imageSource(figure.source) else { return nil } 218 guard let source = resolver.imageSource(figure.source) else { return nil }
219 let alt = figure.alt ?? caption.map { plainText($0) } ?? "" 219 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
220 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# 220 var html = #"<img src="\#(escapeHTMLAttribute(source))" alt="\#(escapeHTMLAttribute(alt))""#
221 for (key, value) in figure.attributes where key != "alt" { 221 for (key, value) in figure.attributes where key != "alt" {
222 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" 222 html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\""
223 } 223 }
@@ -258,7 +258,7 @@ public struct OrgHTMLTreeRenderer {
258 ?? escapeHTML(resolver.displayValue(link.target)) 258 ?? escapeHTML(resolver.displayValue(link.target))
259 // An unsafe or unresolvable target degrades to its text, never a bad anchor. 259 // An unsafe or unresolvable target degrades to its text, never a bad anchor.
260 if let href = resolver.href(for: link.target) { 260 if let href = resolver.href(for: link.target) {
261 html += #"<a href="\#(href)">\#(text)</a>"# 261 html += #"<a href="\#(escapeHTMLAttribute(href))">\#(text)</a>"#
262 } else { 262 } else {
263 html += text 263 html += text
264 } 264 }
Sources/OrgSwift/Escaping.swift +6 −3
@@ -31,6 +31,9 @@ func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
31 ) 31 )
32} 32}
33 33
34/// Returns a plain URL string, not an HTML attribute value — a caller building markup
35/// escapes at the point it interpolates. Escaping here would corrupt the URL for every
36/// other caller: `URL(string:)` on an `&amp;` gets the wrong query.
34private func sanitizeReadmeURLString( 37private func sanitizeReadmeURLString(
35 _ rawURL: String, 38 _ rawURL: String,
36 allowedSchemes: Set<String>, 39 allowedSchemes: Set<String>,
@@ -40,7 +43,7 @@ private func sanitizeReadmeURLString(
40 guard !trimmedURL.isEmpty else { return nil } 43 guard !trimmedURL.isEmpty else { return nil }
41 44
42 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { 45 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
43 return escapeHTMLAttribute(trimmedURL) 46 return trimmedURL
44 } 47 }
45 48
46 if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() { 49 if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() {
@@ -48,7 +51,7 @@ private func sanitizeReadmeURLString(
48 let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else { 51 let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else {
49 return nil 52 return nil
50 } 53 }
51 return escapeHTMLAttribute(sanitizedURL) 54 return sanitizedURL
52 } 55 }
53 56
54 // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a 57 // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a
@@ -56,7 +59,7 @@ private func sanitizeReadmeURLString(
56 // resolver turn these into absolute URLs before they reach here. Reject only the 59 // resolver turn these into absolute URLs before they reach here. Reject only the
57 // protocol-relative `//host` form, which points off-origin. 60 // protocol-relative `//host` form, which points off-origin.
58 guard !trimmedURL.hasPrefix("//") else { return nil } 61 guard !trimmedURL.hasPrefix("//") else { return nil }
59 return escapeHTMLAttribute(trimmedURL) 62 return trimmedURL
60} 63}
61 64
62// MARK: - Regex Helpers 65// MARK: - Regex Helpers
Sources/OrgSwift/OrgRenderer.swift +17
@@ -69,6 +69,23 @@ public struct OrgRenderOptions: Sendable {
69 self.linkPathSegment = linkPathSegment 69 self.linkPathSegment = linkPathSegment
70 } 70 }
71 71
72 /// Resolve a README *link* target to a safe absolute URL string, or nil when it cannot
73 /// be made safe. Absolute `http(s)`/`mailto:` targets and `#fragment`s pass through; a
74 /// repository-relative path resolves against ``readmePath`` under ``linkPathSegment``.
75 ///
76 /// Public because a README is not always org. An app that renders the Markdown README of
77 /// the same repository has the same relative targets to resolve, and resolving them in a
78 /// second place is how two surfaces of one repository drift apart.
79 public func resolvedLinkURL(_ target: String) -> String? {
80 OrgURLResolver(options: self).href(for: .file(normalizeOrgLinkTarget(target)))
81 }
82
83 /// The image counterpart of ``resolvedLinkURL(_:)``, resolving under ``imagePathSegment``.
84 /// Stricter: only `http(s)` sources survive.
85 public func resolvedImageURL(_ source: String) -> String? {
86 OrgURLResolver(options: self).imageSource(normalizeOrgLinkTarget(source))
87 }
88
72 func imageURLResolver() -> ((String) -> String?)? { 89 func imageURLResolver() -> ((String) -> String?)? {
73 guard let owner, let repositoryName else { return nil } 90 guard let owner, let repositoryName else { return nil }
74 let host = host 91 let host = host
Tests/OrgSwiftTests/OrgRendererTests.swift +48
@@ -530,4 +530,52 @@ struct OrgRendererTests {
530 #expect(html.contains(#"<img src="https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png""#)) 530 #expect(html.contains(#"<img src="https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png""#))
531 #expect(html.contains(#"<a href="https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org">the design</a>"#)) 531 #expect(html.contains(#"<a href="https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org">the design</a>"#))
532 } 532 }
533
534 /// The resolver is public so a Markdown README of the same repository resolves its
535 /// relative targets the same way, rather than growing a second implementation.
536 @Test
537 func resolvedURLsAreAvailableToNonOrgCallers() {
538 let options = OrgRenderOptions(
539 host: "gitbay.org",
540 owner: "krz",
541 repositoryName: "org-swift",
542 ref: "HEAD",
543 readmePath: "README.md",
544 imagePathSegment: "raw",
545 linkPathSegment: "blob"
546 )
547 #expect(options.resolvedLinkURL("ARCHITECTURE.md")
548 == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md")
549 #expect(options.resolvedLinkURL("./GAPS.md")
550 == "https://gitbay.org/krz/org-swift/blob/HEAD/GAPS.md")
551 #expect(options.resolvedLinkURL("../org-conformance")
552 == "https://gitbay.org/krz/org-swift/blob/HEAD/org-conformance")
553 #expect(options.resolvedLinkURL("/docs/x.md")
554 == "https://gitbay.org/krz/org-swift/blob/HEAD/docs/x.md")
555 #expect(options.resolvedImageURL("docs/logo.png")
556 == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
557
558 // Absolute and fragment targets keep the behavior the org path has.
559 #expect(options.resolvedLinkURL("https://example.com/a") == "https://example.com/a")
560 #expect(options.resolvedLinkURL("#anchor") == "#anchor")
561
562 // A dangerous scheme is not passed through: with a repository configured it is not
563 // an absolute URL, so it is percent-encoded into a repository path and defused.
564 #expect(options.resolvedLinkURL("javascript:alert(1)")
565 == "https://gitbay.org/krz/org-swift/blob/HEAD/javascript%3Aalert(1)")
566 // Without one there is nothing to resolve against, and the scheme allowlist rejects it.
567 #expect(OrgRenderOptions().resolvedLinkURL("javascript:alert(1)") == nil)
568 #expect(OrgRenderOptions().resolvedImageURL("javascript:alert(1)") == nil)
569 }
570
571 /// A resolved URL is a URL, not an HTML attribute: `&` survives for a `URL(string:)`
572 /// caller, and the HTML renderer escapes it where it writes the attribute.
573 @Test
574 func querySeparatorsSurviveResolutionAndAreEscapedInHTML() {
575 let options = OrgRenderOptions(host: "gitbay.org", owner: "krz", repositoryName: "gitbay")
576 #expect(options.resolvedLinkURL("https://example.com/s?a=1&b=2")
577 == "https://example.com/s?a=1&b=2")
578 let html = render("[[https://example.com/s?a=1&b=2][search]]", options: options)
579 #expect(html.contains(#"<a href="https://example.com/s?a=1&amp;b=2">search</a>"#))
580 }
533} 581}