krz/org-swift

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

Commit 72d3e10034

72d3e10034006cf0c5ff11ee8bb255c64992ab96

parent: 753f773650

Unsigned

cmc <hello@cleberg.net> · 2026-08-28 18:57 UTC

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.

Layout: unified · split

Sources/OrgSwift/Links.swift +8 −4
@@ -242,7 +242,8 @@ func resolveRepositoryLinkURL(
242 owner: String, 242 owner: String,
243 repositoryName: String, 243 repositoryName: String,
244 ref: String, 244 ref: String,
245 readmePath: String? 245 readmePath: String?,
246 pathSegment: String = "blob"
246) -> String? { 247) -> String? {
247 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) 248 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
248 guard !trimmedSource.isEmpty else { return nil } 249 guard !trimmedSource.isEmpty else { return nil }
@@ -258,7 +259,8 @@ func resolveRepositoryLinkURL(
258 owner: owner, 259 owner: owner,
259 repositoryName: repositoryName, 260 repositoryName: repositoryName,
260 ref: ref, 261 ref: ref,
261 readmePath: readmePath 262 readmePath: readmePath,
263 pathSegment: pathSegment
262 ) 264 )
263} 265}
264 266
@@ -268,7 +270,8 @@ func resolveRepositoryAssetURL(
268 owner: String, 270 owner: String,
269 repositoryName: String, 271 repositoryName: String,
270 ref: String, 272 ref: String,
271 readmePath: String? 273 readmePath: String?,
274 pathSegment: String = "blob"
272) -> String? { 275) -> String? {
273 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) 276 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
274 guard !trimmedSource.isEmpty else { return nil } 277 guard !trimmedSource.isEmpty else { return nil }
@@ -300,7 +303,8 @@ func resolveRepositoryAssetURL(
300 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment) 303 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
301 } 304 }
302 .joined(separator: "/") 305 .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)"
304 return components.string 308 return components.string
305} 309}
306 310
Sources/OrgSwift/OrgRenderer.swift +19 −4
@@ -26,10 +26,17 @@ public struct OrgRenderOptions {
26 public var owner: String? 26 public var owner: String?
27 /// Repository name. Nil disables relative-link resolution. 27 /// Repository name. Nil disables relative-link resolution.
28 public var repositoryName: String? 28 public var repositoryName: String?
29 /// Git ref used in the `blob/<ref>` path segment. 29 /// Git ref used in the `<segment>/<ref>` path segment.
30 public var ref: String 30 public var ref: String
31 /// Path of the README being rendered, used to resolve paths relative to it. 31 /// Path of the README being rendered, used to resolve paths relative to it.
32 public var readmePath: String? 32 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
33 /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`. 40 /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`.
34 /// Apps that show a README title want this; orgo treats those keywords as document 41 /// Apps that show a README title want this; orgo treats those keywords as document
35 /// metadata carried by the page template, not body content, so the conformance corpus 42 /// metadata carried by the page template, not body content, so the conformance corpus
@@ -47,7 +54,9 @@ public struct OrgRenderOptions {
47 ref: String = "HEAD", 54 ref: String = "HEAD",
48 readmePath: String? = nil, 55 readmePath: String? = nil,
49 metadataHeader: Bool = true, 56 metadataHeader: Bool = true,
50 headingLevelOffset: Int = 0 57 headingLevelOffset: Int = 0,
58 imagePathSegment: String = "blob",
59 linkPathSegment: String = "blob"
51 ) { 60 ) {
52 self.host = host 61 self.host = host
53 self.owner = owner 62 self.owner = owner
@@ -56,6 +65,8 @@ public struct OrgRenderOptions {
56 self.readmePath = readmePath 65 self.readmePath = readmePath
57 self.metadataHeader = metadataHeader 66 self.metadataHeader = metadataHeader
58 self.headingLevelOffset = headingLevelOffset 67 self.headingLevelOffset = headingLevelOffset
68 self.imagePathSegment = imagePathSegment
69 self.linkPathSegment = linkPathSegment
59 } 70 }
60 71
61 func imageURLResolver() -> ((String) -> String?)? { 72 func imageURLResolver() -> ((String) -> String?)? {
@@ -63,6 +74,7 @@ public struct OrgRenderOptions {
63 let host = host 74 let host = host
64 let ref = ref 75 let ref = ref
65 let readmePath = readmePath 76 let readmePath = readmePath
77 let segment = imagePathSegment
66 return { source in 78 return { source in
67 resolveRepositoryAssetURL( 79 resolveRepositoryAssetURL(
68 source, 80 source,
@@ -70,7 +82,8 @@ public struct OrgRenderOptions {
70 owner: owner, 82 owner: owner,
71 repositoryName: repositoryName, 83 repositoryName: repositoryName,
72 ref: ref, 84 ref: ref,
73 readmePath: readmePath 85 readmePath: readmePath,
86 pathSegment: segment
74 ) 87 )
75 } 88 }
76 } 89 }
@@ -80,6 +93,7 @@ public struct OrgRenderOptions {
80 let host = host 93 let host = host
81 let ref = ref 94 let ref = ref
82 let readmePath = readmePath 95 let readmePath = readmePath
96 let segment = linkPathSegment
83 return { source in 97 return { source in
84 resolveRepositoryLinkURL( 98 resolveRepositoryLinkURL(
85 source, 99 source,
@@ -87,7 +101,8 @@ public struct OrgRenderOptions {
87 owner: owner, 101 owner: owner,
88 repositoryName: repositoryName, 102 repositoryName: repositoryName,
89 ref: ref, 103 ref: ref,
90 readmePath: readmePath 104 readmePath: readmePath,
105 pathSegment: segment
91 ) 106 )
92 } 107 }
93 } 108 }
Tests/OrgSwiftTests/OrgRendererTests.swift +22
@@ -477,4 +477,26 @@ struct OrgRendererTests {
477 #expect(!html.contains("sample")) 477 #expect(!html.contains("sample"))
478 #expect(html.contains("<p>Body.</p>")) 478 #expect(html.contains("<p>Body.</p>"))
479 } 479 }
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 }
480} 502}