Expose repository-relative URL resolution !8
5 files changed, +83 −5
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 | } |