Render org READMEs natively instead of in a web view !3

merged merged by cmc on 2026-08-31 19:11 UTC · krz/hutch:org-native-rendering into main

5 files changed, +111 −30

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +10 −2
@@ -24,6 +24,7 @@
2424 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2525 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
2626 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */; };
27 0F0F0F0F0F0F0F0F000000A5 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */; };
2728/* End PBXBuildFile section */
2829
2930/* Begin PBXContainerItemProxy section */
@@ -176,6 +177,7 @@
176177 8BF100032F9A000100000001 /* Markdown in Frameworks */,
177178 8BAA000000000000000000B0 /* Highlightr in Frameworks */,
178179 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */,
180 0F0F0F0F0F0F0F0F000000A5 /* OrgSwiftUI in Frameworks */,
179181 );
180182 runOnlyForDeploymentPostprocessing = 0;
181183 };
@@ -328,6 +330,7 @@
328330 8BF100022F9A000100000001 /* Markdown */,
329331 8BAA000000000000000000B2 /* Highlightr */,
330332 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */,
333 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */,
331334 );
332335 productName = Hutch;
333336 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -1027,8 +1030,8 @@
10271030 isa = XCRemoteSwiftPackageReference;
10281031 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
10291032 requirement = {
1030 branch = main;
1031 kind = branch;
1033 kind = upToNextMinorVersion;
1034 minimumVersion = 0.3.0;
10321035 };
10331036 };
10341037/* End XCRemoteSwiftPackageReference section */
@@ -1049,6 +1052,11 @@
10491052 package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */;
10501053 productName = OrgSwift;
10511054 };
1055 0F0F0F0F0F0F0F0F000000A4 /* OrgSwiftUI */ = {
1056 isa = XCSwiftPackageProductDependency;
1057 package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */;
1058 productName = OrgSwiftUI;
1059 };
10521060/* End XCSwiftPackageProductDependency section */
10531061 };
10541062 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
@@ -15,8 +15,8 @@
1515 "kind" : "remoteSourceControl",
1616 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
1717 "state" : {
18 "branch" : "main",
19 "revision" : "47ac630c479b79dbfa8944c91b22b7bb6974cadc"
18 "revision" : "1873fef70216e46ea03a1e72575fd83dd8b40cf2",
19 "version" : "0.3.0"
2020 }
2121 },
2222 {
Hutch/Views/Repositories/ReadmeView.swift +30 −26
@@ -1,4 +1,5 @@
11import OrgSwift
2import OrgSwiftUI
23import SwiftUI
34import WebKit
45
@@ -244,7 +245,7 @@ struct RenderedMarkupContentView: View {
244245 case .markdown(let text):
245246 return "markdown:\(theme):\(readmePath ?? ""):\(text)"
246247 case .org(let text):
247 return "org:\(theme):\(readmePath ?? ""):\(text)"
248 return "org:\(readmePath ?? ""):\(text)"
248249 case .plainText(let text):
249250 return "plain:\(readmePath ?? ""):\(text)"
250251 }
@@ -255,12 +256,26 @@ struct RenderedMarkupContentView: View {
255256 switch content {
256257 case .html(let html):
257258 HTMLWebView(html: html, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
258 case .markdown, .org:
259 case .markdown:
259260 if let renderedHTML {
260261 HTMLWebView(html: renderedHTML, colorScheme: colorScheme, onInterceptURL: onInterceptURL)
261262 } else {
262263 SRHTLoadingStateView(message: "Preparing README…")
263264 }
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 })
264279 case .plainText(let text):
265280 Text(text)
266281 .font(.system(.body, design: .monospaced))
@@ -274,7 +289,7 @@ struct RenderedMarkupContentView: View {
274289
275290 private func prepareHTMLIfNeeded() async {
276291 switch content {
277 case .html, .plainText:
292 case .html, .plainText, .org:
278293 renderedHTML = nil
279294 case .markdown(let text):
280295 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
@@ -309,31 +324,20 @@ struct RenderedMarkupContentView: View {
309324 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
310325 guard !Task.isCancelled else { return }
311326 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
335327 }
336328 }
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 }
337341}
338342
339343private final class RenderedReadmeHTMLCache: @unchecked Sendable {
Hutch/Views/Repositories/SyntaxHighlighter.swift +40
@@ -1,6 +1,7 @@
11import Foundation
22import Highlightr
33import OrgSwift
4import OrgSwiftUI
45import SwiftUI
56import UIKit
67
@@ -226,3 +227,42 @@ nonisolated private extension UIColor {
226227 return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue))
227228 }
228229}
230
231// MARK: - Native rendering
232
233extension 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 @@
1import Foundation
2import OrgSwiftUI
3import 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
9struct 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}