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 `&` 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 | 62 | `./images/badge.svg` then resolves to |
| 63 | 63 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. |
| 64 | 64 | |
| 65 | The same resolution is available on its own, for a caller whose README is not org: |
| 66 | |
| 67 | ```swift |
| 68 | options.resolvedLinkURL("ARCHITECTURE.md") // .../blob/HEAD/ARCHITECTURE.md |
| 69 | options.resolvedImageURL("docs/logo.png") // .../raw/HEAD/docs/logo.png |
| 70 | ``` |
| 71 | |
| 72 | Both return a plain URL string, not an HTML attribute value — a caller building |
| 73 | markup escapes where it interpolates. |
| 74 | |
| 65 | 75 | ### Native SwiftUI |
| 66 | 76 | |
| 67 | 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 | 217 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { |
| 218 | 218 | guard let source = resolver.imageSource(figure.source) else { return nil } |
| 219 | 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 | 221 | for (key, value) in figure.attributes where key != "alt" { |
| 222 | 222 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" |
| 223 | 223 | } |
| @@ -258,7 +258,7 @@ public struct OrgHTMLTreeRenderer { |
| 258 | 258 | ?? escapeHTML(resolver.displayValue(link.target)) |
| 259 | 259 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. |
| 260 | 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 | 262 | } else { |
| 263 | 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 `&` gets the wrong query. |
| 34 | 37 | private func sanitizeReadmeURLString( |
| 35 | 38 | _ rawURL: String, |
| 36 | 39 | allowedSchemes: Set<String>, |
| @@ -40,7 +43,7 @@ private func sanitizeReadmeURLString( |
| 40 | 43 | guard !trimmedURL.isEmpty else { return nil } |
| 41 | 44 | |
| 42 | 45 | if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { |
| 43 | | return escapeHTMLAttribute(trimmedURL) |
| 46 | return trimmedURL |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() { |
| @@ -48,7 +51,7 @@ private func sanitizeReadmeURLString( |
| 48 | 51 | let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else { |
| 49 | 52 | return nil |
| 50 | 53 | } |
| 51 | | return escapeHTMLAttribute(sanitizedURL) |
| 54 | return sanitizedURL |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a |
| @@ -56,7 +59,7 @@ private func sanitizeReadmeURLString( |
| 56 | 59 | // resolver turn these into absolute URLs before they reach here. Reject only the |
| 57 | 60 | // protocol-relative `//host` form, which points off-origin. |
| 58 | 61 | guard !trimmedURL.hasPrefix("//") else { return nil } |
| 59 | | return escapeHTMLAttribute(trimmedURL) |
| 62 | return trimmedURL |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | 65 | // MARK: - Regex Helpers |
Sources/OrgSwift/OrgRenderer.swift
+17
| @@ -69,6 +69,23 @@ public struct OrgRenderOptions: Sendable { |
| 69 | 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 | 89 | func imageURLResolver() -> ((String) -> String?)? { |
| 73 | 90 | guard let owner, let repositoryName else { return nil } |
| 74 | 91 | let host = host |
Tests/OrgSwiftTests/OrgRendererTests.swift
+48
| @@ -530,4 +530,52 @@ struct OrgRendererTests { |
| 530 | 530 | #expect(html.contains(#"<img src="https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png""#)) |
| 531 | 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&b=2">search</a>"#)) |
| 580 | } |
| 533 | 581 | } |