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

html library org-mode swift

Commit 47ac630c47

47ac630c479b79dbfa8944c91b22b7bb6974cadc

parent: 1620a0f1e7

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T18:57:51Z

OrgSwift: configurable path segments for relative URL resolution

Add imagePathSegment/linkPathSegment to OrgRenderOptions (default blob). A forge
that serves raw bytes on a separate route can now resolve relative images against
one segment and links against another — gitbay uses raw for images, blob for links.
Sources/OrgSwift/Links.swift +8 −4
@@ -242,7 +242,8 @@ func resolveRepositoryLinkURL(
242242 owner: String,
243243 repositoryName: String,
244244 ref: String,
245 readmePath: String?
245 readmePath: String?,
246 pathSegment: String = "blob"
246247 ) -> String? {
247248 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
248249 guard !trimmedSource.isEmpty else { return nil }
@@ -258,7 +259,8 @@ func resolveRepositoryLinkURL(
258259 owner: owner,
259260 repositoryName: repositoryName,
260261 ref: ref,
261 readmePath: readmePath
262 readmePath: readmePath,
263 pathSegment: pathSegment
262264 )
263265 }
264266
@@ -268,7 +270,8 @@ func resolveRepositoryAssetURL(
268270 owner: String,
269271 repositoryName: String,
270272 ref: String,
271 readmePath: String?
273 readmePath: String?,
274 pathSegment: String = "blob"
272275 ) -> String? {
273276 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
274277 guard !trimmedSource.isEmpty else { return nil }
@@ -300,7 +303,8 @@ func resolveRepositoryAssetURL(
300303 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
301304 }
302305 .joined(separator: "/")
303 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/\(encodedRef)/\(encodedRelativePath)"
306 let encodedSegment = pathSegment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? pathSegment
307 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/\(encodedSegment)/\(encodedRef)/\(encodedRelativePath)"
304308 return components.string
305309 }
306310
Sources/OrgSwift/OrgRenderer.swift +19 −4
@@ -26,10 +26,17 @@ public struct OrgRenderOptions {
2626 public var owner: String?
2727 /// Repository name. Nil disables relative-link resolution.
2828 public var repositoryName: String?
29 /// Git ref used in the `blob/<ref>` path segment.
29 /// Git ref used in the `<segment>/<ref>` path segment.
3030 public var ref: String
3131 /// Path of the README being rendered, used to resolve paths relative to it.
3232 public var readmePath: String?
33 /// Path segment for a resolved relative *image* URL: `<host>/<owner>/<repo>/<segment>/`.
34 /// Defaults to `blob`; forges that serve raw bytes on a different route (gitbay uses
35 /// `raw`) set this so an `<img src>` points at the file, not its HTML viewer page.
36 public var imagePathSegment: String
37 /// Path segment for a resolved relative *link* URL. Defaults to `blob` a link should
38 /// open the file's page, not its raw bytes.
39 public var linkPathSegment: String
3340 /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`.
3441 /// Apps that show a README title want this; orgo treats those keywords as document
3542 /// metadata carried by the page template, not body content, so the conformance corpus
@@ -47,7 +54,9 @@ public struct OrgRenderOptions {
4754 ref: String = "HEAD",
4855 readmePath: String? = nil,
4956 metadataHeader: Bool = true,
50 headingLevelOffset: Int = 0
57 headingLevelOffset: Int = 0,
58 imagePathSegment: String = "blob",
59 linkPathSegment: String = "blob"
5160 ) {
5261 self.host = host
5362 self.owner = owner
@@ -56,6 +65,8 @@ public struct OrgRenderOptions {
5665 self.readmePath = readmePath
5766 self.metadataHeader = metadataHeader
5867 self.headingLevelOffset = headingLevelOffset
68 self.imagePathSegment = imagePathSegment
69 self.linkPathSegment = linkPathSegment
5970 }
6071
6172 func imageURLResolver() -> ((String) -> String?)? {
@@ -63,6 +74,7 @@ public struct OrgRenderOptions {
6374 let host = host
6475 let ref = ref
6576 let readmePath = readmePath
77 let segment = imagePathSegment
6678 return { source in
6779 resolveRepositoryAssetURL(
6880 source,
@@ -70,7 +82,8 @@ public struct OrgRenderOptions {
7082 owner: owner,
7183 repositoryName: repositoryName,
7284 ref: ref,
73 readmePath: readmePath
85 readmePath: readmePath,
86 pathSegment: segment
7487 )
7588 }
7689 }
@@ -80,6 +93,7 @@ public struct OrgRenderOptions {
8093 let host = host
8194 let ref = ref
8295 let readmePath = readmePath
96 let segment = linkPathSegment
8397 return { source in
8498 resolveRepositoryLinkURL(
8599 source,
@@ -87,7 +101,8 @@ public struct OrgRenderOptions {
87101 owner: owner,
88102 repositoryName: repositoryName,
89103 ref: ref,
90 readmePath: readmePath
104 readmePath: readmePath,
105 pathSegment: segment
91106 )
92107 }
93108 }
Tests/OrgSwiftTests/OrgRendererTests.swift +22
@@ -477,4 +477,26 @@ struct OrgRendererTests {
477477 #expect(!html.contains("sample"))
478478 #expect(html.contains("<p>Body.</p>"))
479479 }
480
481 @Test
482 func relativeURLsResolveWithConfigurablePathSegments() {
483 // gitbay serves raw bytes at /raw/<ref>/ and the file page at /blob/<ref>/, so an
484 // image resolves against a different segment than a link.
485 let options = OrgRenderOptions(
486 host: "gitbay.org",
487 owner: "krz",
488 repositoryName: "gitbay",
489 ref: "HEAD",
490 readmePath: "README.org",
491 imagePathSegment: "raw",
492 linkPathSegment: "blob"
493 )
494 let html = render("""
495 [[file:docs/logo.png]]
496
497 See [[docs/DESIGN.org][the design]].
498 """, options: options)
499 #expect(html.contains(#"<img src="https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png""#))
500 #expect(html.contains(#"<a href="https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org">the design</a>"#))
501 }
480502 }