Commit ca8572e750

ca8572e7507c96b7a7d56bbf63992081ea19c6df

parent: f1bc173819

Unsigned ci/accessibility: success

cmc <hello@cleberg.net> · 2026-09-01 03:07 UTC

Resolve README URLs through OrgSwift for Markdown too (!9)

`ReadmeView` carried its own `resolveRepositoryLinkURL` /
`resolveRepositoryAssetURL` / `normalizeRepositoryPath` — the ancestors of the
ones OrgSwift 0.6.0 exposes as `OrgRenderOptions.resolvedLinkURL` /
`resolvedImageURL`. Both README formats now resolve through one implementation
rather than two that can drift.

Relative links in a Markdown README already worked; this removes the duplicate
and fixes two things it carried:

- The local copy hardcoded `git.sr.ht`, so a self-hosted instance was reached by
  string-replacing the host out of a finished URL. That would also rewrite a path
  segment that happened to read `git.sr.ht`. The options carry the host, and a
  test covers it.
- Markdown targets now pass the same scheme allowlist Org ones do, so a `data:`
  image in a `.md` README is dropped — consistent with the WebView allow-list
  hardening that already dropped `data:`.

-109 lines in `ReadmeView.swift`. Full suite green, 30 tests in that file.

Layout: unified · split

Hutch/Views/Repositories/ReadmeView.swift +7 −102
@@ -265,7 +265,7 @@ struct RenderedMarkupContentView: View {
265265 case .org(let text):
266266 OrgView(
267267 text,
268 options: orgOptions,
268 options: renderOptions,
269269 styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)),
270270 // Matches how tickets are colored: pending amber, resolved green.
271271 keywords: OrgKeywordStyle(todo: .orange, done: .green)
@@ -297,28 +297,13 @@ struct RenderedMarkupContentView: View {
297297 return
298298 }
299299 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
300 let options = renderOptions
300301 let html = await Task.detached(priority: .userInitiated) {
301302 markdownToHTML(
302303 text,
303304 codeTheme: theme,
304 imageURLResolver: { source in
305 resolveRepositoryAssetURL(
306 source,
307 owner: ownerCanonicalName,
308 repositoryName: repositoryName,
309 readmePath: readmePath
310 )?
311 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
312 },
313 linkURLResolver: { source in
314 resolveRepositoryLinkURL(
315 source,
316 owner: ownerCanonicalName,
317 repositoryName: repositoryName,
318 readmePath: readmePath
319 )?
320 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
321 }
305 imageURLResolver: { options.resolvedImageURL($0) },
306 linkURLResolver: { options.resolvedLinkURL($0) }
322307 )
323308 }.value
324309 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
@@ -327,9 +312,9 @@ struct RenderedMarkupContentView: View {
327312 }
328313 }
329314
330 /// Same resolution the HTML renderer used, so relative links and images
331 /// still point at this repository on this instance.
332 private var orgOptions: OrgRenderOptions {
315 /// One resolution for both formats: a relative link in a Markdown README points at the
316 /// same file page an Org one does, on whichever instance is being browsed.
317 private var renderOptions: OrgRenderOptions {
333318 OrgRenderOptions(
334319 host: repositoryHost,
335320 owner: ownerCanonicalName,
@@ -677,86 +662,6 @@ nonisolated private func replaceMatches(
677662 return result
678663}
679664
680nonisolated func resolveRepositoryLinkURL(
681 _ source: String,
682 owner: String,
683 repositoryName: String,
684 readmePath: String?
685) -> String? {
686 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
687 guard !trimmedSource.isEmpty else { return nil }
688
689 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
690 || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
691 return trimmedSource
692 }
693
694 return resolveRepositoryAssetURL(
695 trimmedSource,
696 owner: owner,
697 repositoryName: repositoryName,
698 readmePath: readmePath
699 )
700}
701
702nonisolated func resolveRepositoryAssetURL(
703 _ source: String,
704 owner: String,
705 repositoryName: String,
706 readmePath: String?
707) -> String? {
708 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
709 guard !trimmedSource.isEmpty else { return nil }
710
711 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
712 return trimmedSource
713 }
714
715 let relativePath: String
716 if trimmedSource.hasPrefix("/") {
717 relativePath = String(trimmedSource.dropFirst())
718 } else {
719 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
720 relativePath = normalizeRepositoryPath(
721 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
722 )
723 }
724
725 guard !relativePath.isEmpty else { return nil }
726 var components = URLComponents()
727 components.scheme = "https"
728 components.host = "git.sr.ht"
729 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
730 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
731 let encodedRelativePath = relativePath
732 .split(separator: "/", omittingEmptySubsequences: false)
733 .map { segment in
734 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
735 }
736 .joined(separator: "/")
737 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
738 return components.string
739}
740
741nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
742 var components: [String] = []
743
744 for part in path.split(separator: "/") {
745 switch part {
746 case ".":
747 continue
748 case "..":
749 if !components.isEmpty {
750 components.removeLast()
751 }
752 default:
753 components.append(String(part))
754 }
755 }
756
757 return components.joined(separator: "/")
758}
759
760665// MARK: - WKWebView Wrapper
761666
762667/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
HutchTests/ReadmeViewTests.swift +34 −56
@@ -1,4 +1,5 @@
11import Foundation
2import OrgSwift
23import Testing
34@testable import Hutch
45
@@ -130,14 +131,7 @@ struct MarkdownRenderingTests {
130131 func markdownFragmentLinkWithResolverPreservesFragment() {
131132 let html = markdownToHTML(
132133 "[section](#install)",
133 linkURLResolver: { source in
134 resolveRepositoryLinkURL(
135 source,
136 owner: "~ccleberg",
137 repositoryName: "Hutch",
138 readmePath: "README.md"
139 )
140 }
134 linkURLResolver: { hutchOptions.resolvedLinkURL($0) }
141135 )
142136
143137 #expect(html.contains("href=\"#install\""))
@@ -221,19 +215,24 @@ struct MarkdownRenderingTests {
221215 }
222216}
223217
218/// The repository context a README on git.sr.ht resolves against.
219private let hutchOptions = OrgRenderOptions(
220 host: "git.sr.ht",
221 owner: "~ccleberg",
222 repositoryName: "Hutch",
223 ref: "HEAD",
224 readmePath: "README.md"
225)
226
227/// Resolution lives in OrgSwift and is shared by both README formats, so a relative target
228/// in a Markdown README lands where the same target in an Org one does.
224229@MainActor
225230struct RepositoryAssetURLTests {
226231
227232 @Test
228233 func repositoryAssetURLPercentEncodesImagePaths() {
229 let url = resolveRepositoryAssetURL(
230 "images/My Logo.png",
231 owner: "~ccleberg",
232 repositoryName: "Hutch",
233 readmePath: "README.md"
234 )
235
236 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
234 #expect(hutchOptions.resolvedImageURL("images/My Logo.png")
235 == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
237236 }
238237}
239238
@@ -242,61 +241,40 @@ struct RepositoryLinkURLTests {
242241
243242 @Test
244243 func repositoryLinkURLResolvesRelativePath() {
245 let url = resolveRepositoryLinkURL(
246 "LICENSE",
247 owner: "~ccleberg",
248 repositoryName: "Hutch",
249 readmePath: "README.md"
250 )
244 #expect(hutchOptions.resolvedLinkURL("LICENSE")
245 == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
246 }
251247
252 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
248 @Test
249 func repositoryLinkURLResolvesSubdirectoryRelativePath() {
250 #expect(hutchOptions.resolvedLinkURL("docs/SECURITY.md")
251 == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
253252 }
254253
255254 @Test
256255 func repositoryLinkURLPassesThroughAbsoluteURL() {
257 let url = resolveRepositoryLinkURL(
258 "https://example.com/page",
259 owner: "~ccleberg",
260 repositoryName: "Hutch",
261 readmePath: "README.md"
262 )
263
264 #expect(url == "https://example.com/page")
256 #expect(hutchOptions.resolvedLinkURL("https://example.com/page")
257 == "https://example.com/page")
265258 }
266259
267260 @Test
268261 func repositoryLinkURLPassesThroughFragment() {
269 let url = resolveRepositoryLinkURL(
270 "#install",
271 owner: "~ccleberg",
272 repositoryName: "Hutch",
273 readmePath: "README.md"
274 )
275
276 #expect(url == "#install")
262 #expect(hutchOptions.resolvedLinkURL("#install") == "#install")
277263 }
278264
279265 @Test
280266 func repositoryLinkURLPassesThroughMailto() {
281 let url = resolveRepositoryLinkURL(
282 "mailto:hello@example.com",
283 owner: "~ccleberg",
284 repositoryName: "Hutch",
285 readmePath: "README.md"
286 )
287
288 #expect(url == "mailto:hello@example.com")
267 #expect(hutchOptions.resolvedLinkURL("mailto:hello@example.com")
268 == "mailto:hello@example.com")
289269 }
290270
271 /// The host is carried by the options rather than patched into a finished URL, so a
272 /// self-hosted instance resolves without string-replacing "git.sr.ht" out of the result.
291273 @Test
292 func repositoryLinkURLResolvesSubdirectoryRelativePath() {
293 let url = resolveRepositoryLinkURL(
294 "docs/SECURITY.md",
295 owner: "~ccleberg",
296 repositoryName: "Hutch",
297 readmePath: "README.md"
298 )
299
300 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
274 func repositoryLinkURLUsesTheConfiguredHost() {
275 var options = hutchOptions
276 options.host = "git.example.org"
277 #expect(options.resolvedLinkURL("LICENSE")
278 == "https://git.example.org/~ccleberg/Hutch/blob/HEAD/LICENSE")
301279 }
302280}