Resolve README URLs through OrgSwift for Markdown too !9
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 | ||
| 680 | nonisolated 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 | |||
| 702 | nonisolated 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 | |||
| 741 | nonisolated 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgSwift | ||
| 2 | import Testing | 3 | import 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. | ||
| 219 | private 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 |
| 225 | struct RepositoryAssetURLTests { | 230 | struct 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 | } |