Commit dd8358b307

dd8358b307e13774817862655a6d17279d1cb057

parent: a8f630e659

Unsigned ci/accessibility: success

cmc <hello@cleberg.net> · 2026-08-31 19:05 UTC

Render org READMEs natively instead of in a web view

The org branch of RenderedMarkupContentView now uses OrgSwiftUI's OrgView.
Selectable text, Dynamic Type and VoiceOver, and no WKWebView for org
content. Markdown is unchanged and still renders through HTML.

SyntaxHighlighter gains an OrgCodeStyler conformance. Highlightr already
returns an NSAttributedString, so the native path skips the round-trip
through color-styled <span>s, and the code font scales with Dynamic Type
where the HTML was fixed at 12pt.

Links to files in this repository still open in-app: the web view's
navigation interception becomes an OpenURLAction over the same
onInterceptURL hook.

org leaves the rendered-HTML cache, and its cache key loses the theme
segment, since colors are no longer baked into a string.

org-swift moves from tracking main to the 0.3.0 tag. The revision pinned
before no longer existed — main was rewritten, which orphaned it.

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 @@
1import OrgSwift 1import OrgSwift
2import OrgSwiftUI
2import SwiftUI 3import SwiftUI
3import WebKit 4import 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
339private final class RenderedReadmeHTMLCache: @unchecked Sendable { 343private final class RenderedReadmeHTMLCache: @unchecked Sendable {
Hutch/Views/Repositories/SyntaxHighlighter.swift +40
@@ -1,6 +1,7 @@
1import Foundation 1import Foundation
2import Highlightr 2import Highlightr
3import OrgSwift 3import OrgSwift
4import OrgSwiftUI
4import SwiftUI 5import SwiftUI
5import UIKit 6import 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
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}