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 @@
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}