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 | `./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 | |
| |
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 | ### 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 `&` gets the wrong query. |
| 34 | private func sanitizeReadmeURLString( |
37 | private 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&b=2">search</a>"#)) |
| |
580 | } |
| 533 | } |
581 | } |