Resolve README URLs through OrgSwift for Markdown too !9

merged merged by cmc on 2026-09-01 03:07 UTC · krz/hutch:markdown-shared-resolver into main

2 files changed, +41 −158

Layout: unified · split

Hutch/Views/Repositories/ReadmeView.swift +7 −102
@@ -265,7 +265,7 @@ struct RenderedMarkupContentView: View {
265 case .org(let text): 265 case .org(let text):
266 OrgView( 266 OrgView(
267 text, 267 text,
268 options: orgOptions, 268 options: renderOptions,
269 styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)), 269 styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)),
270 // Matches how tickets are colored: pending amber, resolved green. 270 // Matches how tickets are colored: pending amber, resolved green.
271 keywords: OrgKeywordStyle(todo: .orange, done: .green) 271 keywords: OrgKeywordStyle(todo: .orange, done: .green)
@@ -297,28 +297,13 @@ struct RenderedMarkupContentView: View {
297 return 297 return
298 } 298 }
299 let theme = SyntaxHighlightTheme(colorScheme: colorScheme) 299 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
300 let options = renderOptions
300 let html = await Task.detached(priority: .userInitiated) { 301 let html = await Task.detached(priority: .userInitiated) {
301 markdownToHTML( 302 markdownToHTML(
302 text, 303 text,
303 codeTheme: theme, 304 codeTheme: theme,
304 imageURLResolver: { source in 305 imageURLResolver: { options.resolvedImageURL($0) },
305 resolveRepositoryAssetURL( 306 linkURLResolver: { options.resolvedLinkURL($0) }
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 }
322 ) 307 )
323 }.value 308 }.value
324 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) 309 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
@@ -327,9 +312,9 @@ struct RenderedMarkupContentView: View {
327 } 312 }
328 } 313 }
329 314
330 /// Same resolution the HTML renderer used, so relative links and images 315 /// One resolution for both formats: a relative link in a Markdown README points at the
331 /// still point at this repository on this instance. 316 /// same file page an Org one does, on whichever instance is being browsed.
332 private var orgOptions: OrgRenderOptions { 317 private var renderOptions: OrgRenderOptions {
333 OrgRenderOptions( 318 OrgRenderOptions(
334 host: repositoryHost, 319 host: repositoryHost,
335 owner: ownerCanonicalName, 320 owner: ownerCanonicalName,
@@ -677,86 +662,6 @@ nonisolated private func replaceMatches(
677 return result 662 return result
678} 663}
679 664
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
760// MARK: - WKWebView Wrapper 665// MARK: - WKWebView Wrapper
761 666
762/// A WKWebView wrapper that renders HTML inline and grows to fit its content. 667/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
HutchTests/ReadmeViewTests.swift +34 −56
@@ -1,4 +1,5 @@
1import Foundation 1import Foundation
2import OrgSwift
2import Testing 3import Testing
3@testable import Hutch 4@testable import Hutch
4 5
@@ -130,14 +131,7 @@ struct MarkdownRenderingTests {
130 func markdownFragmentLinkWithResolverPreservesFragment() { 131 func markdownFragmentLinkWithResolverPreservesFragment() {
131 let html = markdownToHTML( 132 let html = markdownToHTML(
132 "[section](#install)", 133 "[section](#install)",
133 linkURLResolver: { source in 134 linkURLResolver: { hutchOptions.resolvedLinkURL($0) }
134 resolveRepositoryLinkURL(
135 source,
136 owner: "~ccleberg",
137 repositoryName: "Hutch",
138 readmePath: "README.md"
139 )
140 }
141 ) 135 )
142 136
143 #expect(html.contains("href=\"#install\"")) 137 #expect(html.contains("href=\"#install\""))
@@ -221,19 +215,24 @@ struct MarkdownRenderingTests {
221 } 215 }
222} 216}
223 217
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.
224@MainActor 229@MainActor
225struct RepositoryAssetURLTests { 230struct RepositoryAssetURLTests {
226 231
227 @Test 232 @Test
228 func repositoryAssetURLPercentEncodesImagePaths() { 233 func repositoryAssetURLPercentEncodesImagePaths() {
229 let url = resolveRepositoryAssetURL( 234 #expect(hutchOptions.resolvedImageURL("images/My Logo.png")
230 "images/My Logo.png", 235 == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.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")
237 } 236 }
238} 237}
239 238
@@ -242,61 +241,40 @@ struct RepositoryLinkURLTests {
242 241
243 @Test 242 @Test
244 func repositoryLinkURLResolvesRelativePath() { 243 func repositoryLinkURLResolvesRelativePath() {
245 let url = resolveRepositoryLinkURL( 244 #expect(hutchOptions.resolvedLinkURL("LICENSE")
246 "LICENSE", 245 == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
247 owner: "~ccleberg", 246 }
248 repositoryName: "Hutch",
249 readmePath: "README.md"
250 )
251 247
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")
253 } 252 }
254 253
255 @Test 254 @Test
256 func repositoryLinkURLPassesThroughAbsoluteURL() { 255 func repositoryLinkURLPassesThroughAbsoluteURL() {
257 let url = resolveRepositoryLinkURL( 256 #expect(hutchOptions.resolvedLinkURL("https://example.com/page")
258 "https://example.com/page", 257 == "https://example.com/page")
259 owner: "~ccleberg",
260 repositoryName: "Hutch",
261 readmePath: "README.md"
262 )
263
264 #expect(url == "https://example.com/page")
265 } 258 }
266 259
267 @Test 260 @Test
268 func repositoryLinkURLPassesThroughFragment() { 261 func repositoryLinkURLPassesThroughFragment() {
269 let url = resolveRepositoryLinkURL( 262 #expect(hutchOptions.resolvedLinkURL("#install") == "#install")
270 "#install",
271 owner: "~ccleberg",
272 repositoryName: "Hutch",
273 readmePath: "README.md"
274 )
275
276 #expect(url == "#install")
277 } 263 }
278 264
279 @Test 265 @Test
280 func repositoryLinkURLPassesThroughMailto() { 266 func repositoryLinkURLPassesThroughMailto() {
281 let url = resolveRepositoryLinkURL( 267 #expect(hutchOptions.resolvedLinkURL("mailto:hello@example.com")
282 "mailto:hello@example.com", 268 == "mailto:hello@example.com")
283 owner: "~ccleberg",
284 repositoryName: "Hutch",
285 readmePath: "README.md"
286 )
287
288 #expect(url == "mailto:hello@example.com")
289 } 269 }
290 270
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.
291 @Test 273 @Test
292 func repositoryLinkURLResolvesSubdirectoryRelativePath() { 274 func repositoryLinkURLUsesTheConfiguredHost() {
293 let url = resolveRepositoryLinkURL( 275 var options = hutchOptions
294 "docs/SECURITY.md", 276 options.host = "git.example.org"
295 owner: "~ccleberg", 277 #expect(options.resolvedLinkURL("LICENSE")
296 repositoryName: "Hutch", 278 == "https://git.example.org/~ccleberg/Hutch/blob/HEAD/LICENSE")
297 readmePath: "README.md"
298 )
299
300 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
301 } 279 }
302} 280}