Commit d73e513144
Verified · cmc ci/accessibility: success
Layout: unified · split
Hutch/Views/Repositories/ReadmeView.swift +7 −102
| @@ -265,7 +265,7 @@ struct RenderedMarkupContentView: View { | ||
| 265 | 265 | case .org(let text): |
| 266 | 266 | OrgView( |
| 267 | 267 | text, |
| 268 | options: orgOptions, | |
| 268 | options: renderOptions, | |
| 269 | 269 | styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)), |
| 270 | 270 | // Matches how tickets are colored: pending amber, resolved green. |
| 271 | 271 | keywords: OrgKeywordStyle(todo: .orange, done: .green) |
| @@ -297,28 +297,13 @@ struct RenderedMarkupContentView: View { | ||
| 297 | 297 | return |
| 298 | 298 | } |
| 299 | 299 | let theme = SyntaxHighlightTheme(colorScheme: colorScheme) |
| 300 | let options = renderOptions | |
| 300 | 301 | let html = await Task.detached(priority: .userInitiated) { |
| 301 | 302 | markdownToHTML( |
| 302 | 303 | text, |
| 303 | 304 | 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) } | |
| 322 | 307 | ) |
| 323 | 308 | }.value |
| 324 | 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 | |
| 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 { | |
| 333 | 318 | OrgRenderOptions( |
| 334 | 319 | host: repositoryHost, |
| 335 | 320 | owner: ownerCanonicalName, |
| @@ -677,86 +662,6 @@ nonisolated private func replaceMatches( | ||
| 677 | 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 | 665 | // MARK: - WKWebView Wrapper |
| 761 | 666 | |
| 762 | 667 | /// A WKWebView wrapper that renders HTML inline and grows to fit its content. |
HutchTests/ReadmeViewTests.swift +34 −56
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import OrgSwift | |
| 2 | 3 | import Testing |
| 3 | 4 | @testable import Hutch |
| 4 | 5 | |
| @@ -130,14 +131,7 @@ struct MarkdownRenderingTests { | ||
| 130 | 131 | func markdownFragmentLinkWithResolverPreservesFragment() { |
| 131 | 132 | let html = markdownToHTML( |
| 132 | 133 | "[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) } | |
| 141 | 135 | ) |
| 142 | 136 | |
| 143 | 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 | 229 | @MainActor |
| 225 | 230 | struct RepositoryAssetURLTests { |
| 226 | 231 | |
| 227 | 232 | @Test |
| 228 | 233 | 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") | |
| 237 | 236 | } |
| 238 | 237 | } |
| 239 | 238 | |
| @@ -242,61 +241,40 @@ struct RepositoryLinkURLTests { | ||
| 242 | 241 | |
| 243 | 242 | @Test |
| 244 | 243 | 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 | } | |
| 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 | 254 | @Test |
| 256 | 255 | 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") | |
| 265 | 258 | } |
| 266 | 259 | |
| 267 | 260 | @Test |
| 268 | 261 | 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") | |
| 277 | 263 | } |
| 278 | 264 | |
| 279 | 265 | @Test |
| 280 | 266 | 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") | |
| 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 | 273 | @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") | |
| 301 | 279 | } |
| 302 | 280 | } |