Render org READMEs natively instead of in a web view !3
5 files changed, +111 −30
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +10 −2
| @@ -24,6 +24,7 @@ | |||
| 24 | 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | 24 | 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; |
| 25 | 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; | 25 | 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; |
| 26 | 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */; }; | 26 | 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */; }; |
| 27 | 0F0F0F0F0F0F0F0F000000A5 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */; }; | ||
| 27 | /* End PBXBuildFile section */ | 28 | /* End PBXBuildFile section */ |
| 28 | 29 | ||
| 29 | /* Begin PBXContainerItemProxy section */ | 30 | /* Begin PBXContainerItemProxy section */ |
| @@ -176,6 +177,7 @@ | |||
| 176 | 8BF100032F9A000100000001 /* Markdown in Frameworks */, | 177 | 8BF100032F9A000100000001 /* Markdown in Frameworks */, |
| 177 | 8BAA000000000000000000B0 /* Highlightr in Frameworks */, | 178 | 8BAA000000000000000000B0 /* Highlightr in Frameworks */, |
| 178 | 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */, | 179 | 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */, |
| 180 | 0F0F0F0F0F0F0F0F000000A5 /* OrgSwiftUI in Frameworks */, | ||
| 179 | ); | 181 | ); |
| 180 | runOnlyForDeploymentPostprocessing = 0; | 182 | runOnlyForDeploymentPostprocessing = 0; |
| 181 | }; | 183 | }; |
| @@ -328,6 +330,7 @@ | |||
| 328 | 8BF100022F9A000100000001 /* Markdown */, | 330 | 8BF100022F9A000100000001 /* Markdown */, |
| 329 | 8BAA000000000000000000B2 /* Highlightr */, | 331 | 8BAA000000000000000000B2 /* Highlightr */, |
| 330 | 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */, | 332 | 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */, |
| 333 | 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */, | ||
| 331 | ); | 334 | ); |
| 332 | productName = Hutch; | 335 | productName = Hutch; |
| 333 | productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; | 336 | productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; |
| @@ -1027,8 +1030,8 @@ | |||
| 1027 | isa = XCRemoteSwiftPackageReference; | 1030 | isa = XCRemoteSwiftPackageReference; |
| 1028 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; | 1031 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; |
| 1029 | requirement = { | 1032 | requirement = { |
| 1030 | branch = main; | 1033 | kind = upToNextMinorVersion; |
| 1031 | kind = branch; | 1034 | minimumVersion = 0.3.0; |
| 1032 | }; | 1035 | }; |
| 1033 | }; | 1036 | }; |
| 1034 | /* End XCRemoteSwiftPackageReference section */ | 1037 | /* End XCRemoteSwiftPackageReference section */ |
| @@ -1049,6 +1052,11 @@ | |||
| 1049 | package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */; | 1052 | package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */; |
| 1050 | productName = OrgSwift; | 1053 | productName = OrgSwift; |
| 1051 | }; | 1054 | }; |
| 1055 | 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */ = { | ||
| 1056 | isa = XCSwiftPackageProductDependency; | ||
| 1057 | package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */; | ||
| 1058 | productName = OrgSwiftUI; | ||
| 1059 | }; | ||
| 1052 | /* End XCSwiftPackageProductDependency section */ | 1060 | /* End XCSwiftPackageProductDependency section */ |
| 1053 | }; | 1061 | }; |
| 1054 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; | 1062 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; |
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
| @@ -15,8 +15,8 @@ | |||
| 15 | "kind" : "remoteSourceControl", | 15 | "kind" : "remoteSourceControl", |
| 16 | "location" : "ssh://git@gitbay.org/krz/org-swift.git", | 16 | "location" : "ssh://git@gitbay.org/krz/org-swift.git", |
| 17 | "state" : { | 17 | "state" : { |
| 18 | "branch" : "main", | 18 | "revision" : "1873fef70216e46ea03a1e72575fd83dd8b40cf2", |
| 19 | "revision" : "47ac630c479b79dbfa8944c91b22b7bb6974cadc" | 19 | "version" : "0.3.0" |
| 20 | } | 20 | } |
| 21 | }, | 21 | }, |
| 22 | { | 22 | { |
Hutch/Views/Repositories/ReadmeView.swift +30 −26
| @@ -1,4 +1,5 @@ | |||
| 1 | import OrgSwift | 1 | import OrgSwift |
| 2 | import OrgSwiftUI | ||
| 2 | import SwiftUI | 3 | import SwiftUI |
| 3 | import WebKit | 4 | import WebKit |
| 4 | 5 | ||
| @@ -244,7 +245,7 @@ struct RenderedMarkupContentView: View { | |||
| 244 | case .markdown(let text): | 245 | case .markdown(let text): |
| 245 | return "markdown:\(theme):\(readmePath ?? ""):\(text)" | 246 | return "markdown:\(theme):\(readmePath ?? ""):\(text)" |
| 246 | case .org(let text): | 247 | case .org(let text): |
| 247 | return "org:\(theme):\(readmePath ?? ""):\(text)" | 248 | return "org:\(readmePath ?? ""):\(text)" |
| 248 | case .plainText(let text): | 249 | case .plainText(let text): |
| 249 | return "plain:\(readmePath ?? ""):\(text)" | 250 | return "plain:\(readmePath ?? ""):\(text)" |
| 250 | } | 251 | } |
| @@ -255,12 +256,26 @@ struct RenderedMarkupContentView: View { | |||
| 255 | switch content { | 256 | switch content { |
| 256 | case .html(let html): | 257 | case .html(let html): |
| 257 | HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL) | 258 | HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL) |
| 258 | case .markdown, .org: | 259 | case .markdown: |
| 259 | if let renderedHTML { | 260 | if let renderedHTML { |
| 260 | HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL) | 261 | HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL) |
| 261 | } else { | 262 | } else { |
| 262 | SRHTLoadingStateView(message: "Preparing README…") | 263 | SRHTLoadingStateView(message: "Preparing README…") |
| 263 | } | 264 | } |
| 265 | case .org(let text): | ||
| 266 | OrgView( | ||
| 267 | text, | ||
| 268 | options: orgOptions, | ||
| 269 | styler: SyntaxHighlighter(theme: SyntaxHighlightTheme(colorScheme: colorScheme)), | ||
| 270 | // Matches how tickets are colored: pending amber, resolved green. | ||
| 271 | keywords: OrgKeywordStyle(todo: .orange, done: .green) | ||
| 272 | ) | ||
| 273 | .environment(\.openURL, OpenURLAction { url in | ||
| 274 | // Links to files in this repository open in-app, as they did | ||
| 275 | // when the web view intercepted its own navigation. | ||
| 276 | if onInterceptURL?(url) == true { return .handled } | ||
| 277 | return .systemAction | ||
| 278 | }) | ||
| 264 | case .plainText(let text): | 279 | case .plainText(let text): |
| 265 | Text(text) | 280 | Text(text) |
| 266 | .font(.system(.body, design: .monospaced)) | 281 | .font(.system(.body, design: .monospaced)) |
| @@ -274,7 +289,7 @@ struct RenderedMarkupContentView: View { | |||
| 274 | 289 | ||
| 275 | private func prepareHTMLIfNeeded() async { | 290 | private func prepareHTMLIfNeeded() async { |
| 276 | switch content { | 291 | switch content { |
| 277 | case .html, .plainText: | 292 | case .html, .plainText, .org: |
| 278 | renderedHTML = nil | 293 | renderedHTML = nil |
| 279 | case .markdown(let text): | 294 | case .markdown(let text): |
| 280 | if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { | 295 | if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { |
| @@ -309,31 +324,20 @@ struct RenderedMarkupContentView: View { | |||
| 309 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) | 324 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) |
| 310 | guard !Task.isCancelled else { return } | 325 | guard !Task.isCancelled else { return } |
| 311 | renderedHTML = html | 326 | renderedHTML = html |
| 312 | case .org(let text): | ||
| 313 | if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { | ||
| 314 | renderedHTML = cached | ||
| 315 | return | ||
| 316 | } | ||
| 317 | let theme = SyntaxHighlightTheme(colorScheme: colorScheme) | ||
| 318 | let host = repositoryHost | ||
| 319 | let owner = ownerCanonicalName | ||
| 320 | let repo = repositoryName | ||
| 321 | let path = readmePath | ||
| 322 | let html = await Task.detached(priority: .userInitiated) { | ||
| 323 | let options = OrgRenderOptions( | ||
| 324 | host: host, | ||
| 325 | owner: owner, | ||
| 326 | repositoryName: repo, | ||
| 327 | ref: "HEAD", | ||
| 328 | readmePath: path | ||
| 329 | ) | ||
| 330 | return OrgRenderer.renderToHTML(text, options: options, highlighter: SyntaxHighlighter(theme: theme)) | ||
| 331 | }.value | ||
| 332 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) | ||
| 333 | guard !Task.isCancelled else { return } | ||
| 334 | renderedHTML = html | ||
| 335 | } | 327 | } |
| 336 | } | 328 | } |
| 329 | |||
| 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 { | ||
| 333 | OrgRenderOptions( | ||
| 334 | host: repositoryHost, | ||
| 335 | owner: ownerCanonicalName, | ||
| 336 | repositoryName: repositoryName, | ||
| 337 | ref: "HEAD", | ||
| 338 | readmePath: readmePath | ||
| 339 | ) | ||
| 340 | } | ||
| 337 | } | 341 | } |
| 338 | 342 | ||
| 339 | private final class RenderedReadmeHTMLCache: @unchecked Sendable { | 343 | private final class RenderedReadmeHTMLCache: @unchecked Sendable { |
Hutch/Views/Repositories/SyntaxHighlighter.swift +40
| @@ -1,6 +1,7 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Highlightr | 2 | import Highlightr |
| 3 | import OrgSwift | 3 | import OrgSwift |
| 4 | import OrgSwiftUI | ||
| 4 | import SwiftUI | 5 | import SwiftUI |
| 5 | import UIKit | 6 | import UIKit |
| 6 | 7 | ||
| @@ -226,3 +227,42 @@ nonisolated private extension UIColor { | |||
| 226 | return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue)) | 227 | return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue)) |
| 227 | } | 228 | } |
| 228 | } | 229 | } |
| 230 | |||
| 231 | // MARK: - Native rendering | ||
| 232 | |||
| 233 | extension SyntaxHighlighter: OrgCodeStyler { | ||
| 234 | /// `OrgSwiftUI.OrgCodeStyler` conformance: the native counterpart to | ||
| 235 | /// `highlightedHTML(code:language:)`. Highlightr already produces an | ||
| 236 | /// `NSAttributedString`, so the native path skips the round-trip through | ||
| 237 | /// color-styled `<span>`s entirely. | ||
| 238 | nonisolated func highlighted(code: String, language: String?) -> AttributedString? { | ||
| 239 | guard let attributed = attributedText( | ||
| 240 | for: code, language: language, font: Self.nativeMeasurementFont | ||
| 241 | ) else { | ||
| 242 | return nil | ||
| 243 | } | ||
| 244 | return Self.attributedString(from: attributed) | ||
| 245 | } | ||
| 246 | |||
| 247 | nonisolated private static var nativeMeasurementFont: UIFont { | ||
| 248 | .monospacedSystemFont(ofSize: 13, weight: .regular) | ||
| 249 | } | ||
| 250 | |||
| 251 | /// Carry Highlightr's colors across, in a monospaced font that scales with | ||
| 252 | /// Dynamic Type — which the fixed-size HTML never did. | ||
| 253 | nonisolated private static func attributedString(from attributed: NSAttributedString) -> AttributedString { | ||
| 254 | var result = AttributedString() | ||
| 255 | let range = NSRange(location: 0, length: attributed.length) | ||
| 256 | attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in | ||
| 257 | var fragment = AttributedString( | ||
| 258 | (attributed.string as NSString).substring(with: subrange) | ||
| 259 | ) | ||
| 260 | fragment.font = .system(.footnote, design: .monospaced) | ||
| 261 | if let color = value as? UIColor { | ||
| 262 | fragment.foregroundColor = Color(uiColor: color) | ||
| 263 | } | ||
| 264 | result += fragment | ||
| 265 | } | ||
| 266 | return result | ||
| 267 | } | ||
| 268 | } | ||
HutchTests/OrgNativeRenderingTests.swift added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgSwiftUI | ||
| 3 | import Testing | ||
| 4 | @testable import Hutch | ||
| 5 | |||
| 6 | /// `SyntaxHighlighter` as an `OrgCodeStyler` — the native counterpart to the HTML path. | ||
| 7 | /// The renderer it feeds is covered by org-swift's own tests. | ||
| 8 | @MainActor | ||
| 9 | struct OrgNativeRenderingTests { | ||
| 10 | |||
| 11 | @Test | ||
| 12 | func syntaxHighlighterStylesCodeNatively() throws { | ||
| 13 | let highlighter = SyntaxHighlighter(theme: .dark) | ||
| 14 | let styled = try #require( | ||
| 15 | highlighter.highlighted(code: "let x = 1\nprint(x)", language: "swift") | ||
| 16 | ) | ||
| 17 | |||
| 18 | #expect(String(styled.characters) == "let x = 1\nprint(x)") | ||
| 19 | |||
| 20 | // Highlighting means more than one color across the run, in a monospaced font that | ||
| 21 | // scales with Dynamic Type — which the fixed-size HTML never did. | ||
| 22 | let colors = Set(styled.runs.compactMap { $0.foregroundColor }) | ||
| 23 | #expect(colors.count > 1) | ||
| 24 | #expect(styled.runs.allSatisfy { $0.font != nil }) | ||
| 25 | |||
| 26 | // An unknown language falls back to plain rather than mis-highlighting. | ||
| 27 | #expect(highlighter.highlighted(code: "+++", language: "notalanguage") == nil) | ||
| 28 | } | ||
| 29 | } | ||