Commit dd00288f15
Unsigned
Layout: unified · split
gitbay.xcodeproj/project.pbxproj +12 −4
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | ||
| 9 | /* Begin PBXBuildFile section */ | 9 | /* Begin PBXBuildFile section */ |
| 10 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; }; | 10 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; }; |
| 11 | 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */; }; | ||
| 11 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; | 12 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; |
| 12 | /* End PBXBuildFile section */ | 13 | /* End PBXBuildFile section */ |
| 13 | 14 | ||
| @@ -59,6 +60,7 @@ | |||
| 59 | files = ( | 60 | files = ( |
| 60 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, | 61 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, |
| 61 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */, | 62 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */, |
| 63 | 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */, | ||
| 62 | ); | 64 | ); |
| 63 | runOnlyForDeploymentPostprocessing = 0; | 65 | runOnlyForDeploymentPostprocessing = 0; |
| 64 | }; | 66 | }; |
| @@ -121,6 +123,7 @@ | |||
| 121 | packageProductDependencies = ( | 123 | packageProductDependencies = ( |
| 122 | 8B2898B1303FF3E5001BF44A /* Highlightr */, | 124 | 8B2898B1303FF3E5001BF44A /* Highlightr */, |
| 123 | 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */, | 125 | 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */, |
| 126 | 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */, | ||
| 124 | ); | 127 | ); |
| 125 | productName = gitbay; | 128 | productName = gitbay; |
| 126 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; | 129 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; |
| @@ -413,9 +416,9 @@ | |||
| 413 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 416 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 414 | ENABLE_PREVIEWS = YES; | 417 | ENABLE_PREVIEWS = YES; |
| 415 | GENERATE_INFOPLIST_FILE = YES; | 418 | GENERATE_INFOPLIST_FILE = YES; |
| 419 | INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; | ||
| 416 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | 420 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| 417 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | 421 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; |
| 418 | INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; | ||
| 419 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | 422 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 420 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 423 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 421 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 424 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| @@ -446,9 +449,9 @@ | |||
| 446 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 449 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 447 | ENABLE_PREVIEWS = YES; | 450 | ENABLE_PREVIEWS = YES; |
| 448 | GENERATE_INFOPLIST_FILE = YES; | 451 | GENERATE_INFOPLIST_FILE = YES; |
| 452 | INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; | ||
| 449 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | 453 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| 450 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | 454 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; |
| 451 | INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; | ||
| 452 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | 455 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 453 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 456 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 454 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 457 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| @@ -587,8 +590,8 @@ | |||
| 587 | isa = XCRemoteSwiftPackageReference; | 590 | isa = XCRemoteSwiftPackageReference; |
| 588 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; | 591 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; |
| 589 | requirement = { | 592 | requirement = { |
| 590 | branch = main; | 593 | kind = upToNextMinorVersion; |
| 591 | kind = branch; | 594 | minimumVersion = 0.2.0; |
| 592 | }; | 595 | }; |
| 593 | }; | 596 | }; |
| 594 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { | 597 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { |
| @@ -607,6 +610,11 @@ | |||
| 607 | package = 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */; | 610 | package = 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */; |
| 608 | productName = OrgSwift; | 611 | productName = OrgSwift; |
| 609 | }; | 612 | }; |
| 613 | 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */ = { | ||
| 614 | isa = XCSwiftPackageProductDependency; | ||
| 615 | package = 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */; | ||
| 616 | productName = OrgSwiftUI; | ||
| 617 | }; | ||
| 610 | 8B2898B1303FF3E5001BF44A /* Highlightr */ = { | 618 | 8B2898B1303FF3E5001BF44A /* Highlightr */ = { |
| 611 | isa = XCSwiftPackageProductDependency; | 619 | isa = XCSwiftPackageProductDependency; |
| 612 | package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */; | 620 | package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */; |
gitbay.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" : "ca2a0de2077b96dc2405d59def877f53e1933063", |
| 19 | "revision" : "47ac630c479b79dbfa8944c91b22b7bb6974cadc" | 19 | "version" : "0.2.0" |
| 20 | } | 20 | } |
| 21 | } | 21 | } |
| 22 | ], | 22 | ], |
gitbay/Views/Repos/OrgReadmeWebView.swift deleted −239
| @@ -1,239 +0,0 @@ | |||
| 1 | import Highlightr | ||
| 2 | import OrgSwift | ||
| 3 | import SwiftUI | ||
| 4 | import UIKit | ||
| 5 | import WebKit | ||
| 6 | |||
| 7 | /// Renders an Org README with the shared `OrgSwift` package and shows the resulting HTML in | ||
| 8 | /// a self-sizing, non-scrolling `WKWebView` embedded in the surrounding list. Code blocks are | ||
| 9 | /// highlighted through Highlightr; links open in the system browser. | ||
| 10 | struct OrgReadmeWebView: View { | ||
| 11 | let source: String | ||
| 12 | var options: OrgRenderOptions = .init() | ||
| 13 | |||
| 14 | @Environment(\.colorScheme) private var colorScheme | ||
| 15 | @State private var html = "" | ||
| 16 | @State private var height: CGFloat = 1 | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | OrgWebRepresentable(html: html, height: $height) | ||
| 20 | .frame(height: max(height, 1)) | ||
| 21 | .task(id: colorScheme) { await render(for: colorScheme) } | ||
| 22 | } | ||
| 23 | |||
| 24 | private func render(for scheme: ColorScheme) async { | ||
| 25 | // READMEs are small, and Highlightr (JavaScriptCore) is thread-confined; rendering | ||
| 26 | // on the main actor keeps it simple and matches how the app highlights elsewhere. | ||
| 27 | let highlighter = OrgCodeHighlighter(colorScheme: scheme) | ||
| 28 | let body = OrgRenderer.renderToHTML(source, options: options, highlighter: highlighter) | ||
| 29 | html = OrgReadmeDocument.wrap(body, colorScheme: scheme) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | // MARK: - Highlightr → OrgSwift.CodeHighlighter | ||
| 34 | |||
| 35 | /// Bridges Highlightr to `OrgSwift.CodeHighlighter`: the highlighted attributed string is | ||
| 36 | /// flattened to color-styled `<span>`s, the inner HTML of a `<code>` block. Colors are | ||
| 37 | /// inlined, so a new instance is made per theme. | ||
| 38 | private final class OrgCodeHighlighter: CodeHighlighter { | ||
| 39 | private let highlightr: Highlightr? | ||
| 40 | private let supported: Set<String> | ||
| 41 | |||
| 42 | init(colorScheme: ColorScheme) { | ||
| 43 | let engine = Highlightr() | ||
| 44 | engine?.setTheme(to: colorScheme == .dark ? "atom-one-dark" : "xcode") | ||
| 45 | highlightr = engine | ||
| 46 | supported = Set(engine?.supportedLanguages() ?? []) | ||
| 47 | } | ||
| 48 | |||
| 49 | func highlightedHTML(code: String, language: String?) -> String? { | ||
| 50 | guard let highlightr, let language = resolvedLanguage(language) else { return nil } | ||
| 51 | highlightr.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) | ||
| 52 | guard let attributed = highlightr.highlight(code, as: language, fastRender: true) else { | ||
| 53 | return nil | ||
| 54 | } | ||
| 55 | return Self.html(from: attributed) | ||
| 56 | } | ||
| 57 | |||
| 58 | private func resolvedLanguage(_ raw: String?) -> String? { | ||
| 59 | guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), | ||
| 60 | !trimmed.isEmpty else { return nil } | ||
| 61 | let mapped = Self.aliases[trimmed] ?? trimmed | ||
| 62 | return supported.contains(mapped) ? mapped : nil | ||
| 63 | } | ||
| 64 | |||
| 65 | private static let aliases: [String: String] = [ | ||
| 66 | "js": "javascript", "jsx": "javascript", "ts": "typescript", "tsx": "typescript", | ||
| 67 | "py": "python", "rb": "ruby", "rs": "rust", "sh": "bash", "shell": "bash", "zsh": "bash", | ||
| 68 | "yml": "yaml", "c++": "cpp", "cc": "cpp", "kt": "kotlin", "cs": "csharp", | ||
| 69 | "objc": "objectivec", "objective-c": "objectivec", "html": "xml", "htm": "xml", | ||
| 70 | ] | ||
| 71 | |||
| 72 | private static func html(from attributed: NSAttributedString) -> String { | ||
| 73 | var html = "" | ||
| 74 | let range = NSRange(location: 0, length: attributed.length) | ||
| 75 | attributed.enumerateAttribute(.foregroundColor, in: range) { value, subrange, _ in | ||
| 76 | let fragment = (attributed.string as NSString).substring(with: subrange) | ||
| 77 | let escaped = fragment | ||
| 78 | .replacingOccurrences(of: "&", with: "&") | ||
| 79 | .replacingOccurrences(of: "<", with: "<") | ||
| 80 | .replacingOccurrences(of: ">", with: ">") | ||
| 81 | if let color = value as? UIColor, let hex = color.hexRGBString { | ||
| 82 | html += "<span style=\"color:\(hex)\">\(escaped)</span>" | ||
| 83 | } else { | ||
| 84 | html += escaped | ||
| 85 | } | ||
| 86 | } | ||
| 87 | return html | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | private extension UIColor { | ||
| 92 | var hexRGBString: String? { | ||
| 93 | var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 | ||
| 94 | guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil } | ||
| 95 | return String(format: "#%02x%02x%02x", | ||
| 96 | Int(round(red * 255)), Int(round(green * 255)), Int(round(blue * 255))) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | // MARK: - HTML document | ||
| 101 | |||
| 102 | private enum OrgReadmeDocument { | ||
| 103 | /// Wrap OrgSwift's body HTML in a themed document. The palette follows the app's | ||
| 104 | /// light/dark scheme; layout is deliberately close to the native README styling. | ||
| 105 | static func wrap(_ body: String, colorScheme: ColorScheme) -> String { | ||
| 106 | let dark = colorScheme == .dark | ||
| 107 | let text = dark ? "#e6e6e6" : "#1a1a1a" | ||
| 108 | let muted = dark ? "#9aa0a6" : "#606060" | ||
| 109 | let link = dark ? "#6cb6ff" : "#0a58ca" | ||
| 110 | let rule = dark ? "#333" : "#e0e0e0" | ||
| 111 | let codeBg = dark ? "#1c1c1e" : "#f4f4f5" | ||
| 112 | let quoteBar = dark ? "#3a3a3c" : "#d0d0d0" | ||
| 113 | |||
| 114 | return """ | ||
| 115 | <!DOCTYPE html> | ||
| 116 | <html> | ||
| 117 | <head> | ||
| 118 | <meta charset="utf-8"> | ||
| 119 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||
| 120 | <style> | ||
| 121 | :root { color-scheme: \(dark ? "dark" : "light"); } | ||
| 122 | html, body { margin: 0; padding: 0; background: transparent; } | ||
| 123 | body { | ||
| 124 | font: 16px/1.6 -apple-system, system-ui, sans-serif; | ||
| 125 | color: \(text); | ||
| 126 | -webkit-text-size-adjust: 100%; | ||
| 127 | word-wrap: break-word; | ||
| 128 | } | ||
| 129 | a { color: \(link); text-decoration: none; } | ||
| 130 | h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.2em 0 0.5em; font-weight: 600; } | ||
| 131 | h1 { font-size: 1.5em; } h2 { font-size: 1.3em; } h3 { font-size: 1.15em; } | ||
| 132 | h4, h5, h6 { font-size: 1em; } | ||
| 133 | p, ul, ol, dl, blockquote, pre, table, figure { margin: 0 0 0.85em; } | ||
| 134 | :first-child { margin-top: 0; } | ||
| 135 | ul, ol { padding-left: 1.4em; } | ||
| 136 | li { margin: 0.2em 0; } | ||
| 137 | dt { font-weight: 600; margin-top: 0.5em; } | ||
| 138 | dd { margin: 0 0 0.4em 1.2em; } | ||
| 139 | code { | ||
| 140 | font-family: ui-monospace, "SF Mono", Menlo, monospace; | ||
| 141 | font-size: 0.88em; | ||
| 142 | background: \(codeBg); | ||
| 143 | padding: 0.1em 0.35em; | ||
| 144 | border-radius: 3px; | ||
| 145 | } | ||
| 146 | pre { | ||
| 147 | background: \(codeBg); | ||
| 148 | padding: 12px; | ||
| 149 | border-radius: 4px; | ||
| 150 | overflow-x: auto; | ||
| 151 | } | ||
| 152 | pre code { background: none; padding: 0; font-size: 0.82em; line-height: 1.45; } | ||
| 153 | blockquote { | ||
| 154 | margin-left: 0; | ||
| 155 | padding-left: 1em; | ||
| 156 | border-left: 3px solid \(quoteBar); | ||
| 157 | color: \(muted); | ||
| 158 | } | ||
| 159 | hr { border: none; border-top: 1px solid \(rule); margin: 1.4em 0; } | ||
| 160 | table { border-collapse: collapse; display: block; overflow-x: auto; } | ||
| 161 | th, td { border: 1px solid \(rule); padding: 6px 10px; text-align: left; } | ||
| 162 | th { font-weight: 600; } | ||
| 163 | img { max-width: 100%; height: auto; } | ||
| 164 | figure { margin: 0 0 0.85em; } | ||
| 165 | figcaption { color: \(muted); font-size: 0.9em; margin-top: 0.3em; } | ||
| 166 | .tag { | ||
| 167 | font-size: 0.75em; color: \(muted); | ||
| 168 | border: 1px solid \(rule); border-radius: 3px; | ||
| 169 | padding: 0 0.35em; margin-left: 0.3em; | ||
| 170 | } | ||
| 171 | sup a { text-decoration: none; } | ||
| 172 | .footnotes { font-size: 0.9em; color: \(muted); } | ||
| 173 | time { color: inherit; } | ||
| 174 | </style> | ||
| 175 | </head> | ||
| 176 | <body> | ||
| 177 | \(body) | ||
| 178 | </body> | ||
| 179 | </html> | ||
| 180 | """ | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | // MARK: - WKWebView representable | ||
| 185 | |||
| 186 | private struct OrgWebRepresentable: UIViewRepresentable { | ||
| 187 | let html: String | ||
| 188 | @Binding var height: CGFloat | ||
| 189 | @Environment(\.openURL) private var openURL | ||
| 190 | |||
| 191 | func makeCoordinator() -> Coordinator { Coordinator(self) } | ||
| 192 | |||
| 193 | func makeUIView(context: Context) -> WKWebView { | ||
| 194 | let config = WKWebViewConfiguration() | ||
| 195 | config.defaultWebpagePreferences.allowsContentJavaScript = false | ||
| 196 | let webView = WKWebView(frame: .zero, configuration: config) | ||
| 197 | webView.isOpaque = false | ||
| 198 | webView.backgroundColor = .clear | ||
| 199 | webView.scrollView.isScrollEnabled = false | ||
| 200 | webView.scrollView.contentInsetAdjustmentBehavior = .never | ||
| 201 | webView.navigationDelegate = context.coordinator | ||
| 202 | return webView | ||
| 203 | } | ||
| 204 | |||
| 205 | func updateUIView(_ webView: WKWebView, context: Context) { | ||
| 206 | context.coordinator.parent = self | ||
| 207 | guard !html.isEmpty, context.coordinator.loadedHTML != html else { return } | ||
| 208 | context.coordinator.loadedHTML = html | ||
| 209 | webView.loadHTMLString(html, baseURL: nil) | ||
| 210 | } | ||
| 211 | |||
| 212 | @MainActor | ||
| 213 | final class Coordinator: NSObject, WKNavigationDelegate { | ||
| 214 | var parent: OrgWebRepresentable | ||
| 215 | var loadedHTML: String? | ||
| 216 | |||
| 217 | init(_ parent: OrgWebRepresentable) { self.parent = parent } | ||
| 218 | |||
| 219 | func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { | ||
| 220 | webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in | ||
| 221 | guard let self, let value = result as? NSNumber else { return } | ||
| 222 | self.parent.height = CGFloat(value.doubleValue) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | func webView( | ||
| 227 | _ webView: WKWebView, | ||
| 228 | decidePolicyFor action: WKNavigationAction, | ||
| 229 | decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void | ||
| 230 | ) { | ||
| 231 | if action.navigationType == .linkActivated, let url = action.request.url { | ||
| 232 | parent.openURL(url) | ||
| 233 | decisionHandler(.cancel) | ||
| 234 | return | ||
| 235 | } | ||
| 236 | decisionHandler(.allow) | ||
| 237 | } | ||
| 238 | } | ||
| 239 | } | ||
gitbay/Views/Repos/OrgSyntaxStyler.swift added +59
| @@ -0,0 +1,59 @@ | |||
| 1 | import Highlightr | ||
| 2 | import OrgSwiftUI | ||
| 3 | import SwiftUI | ||
| 4 | import UIKit | ||
| 5 | |||
| 6 | /// Highlights `#+begin_src` blocks in org content, bridging Highlightr to `OrgCodeStyler`. | ||
| 7 | /// | ||
| 8 | /// Highlightr already returns an `NSAttributedString`, so this walks its color runs straight | ||
| 9 | /// into an `AttributedString`. Colors are baked in, so a new instance is made per theme. | ||
| 10 | @MainActor | ||
| 11 | final class OrgSyntaxStyler: OrgCodeStyler { | ||
| 12 | private let highlightr: Highlightr? | ||
| 13 | private let supported: Set<String> | ||
| 14 | |||
| 15 | init(colorScheme: ColorScheme) { | ||
| 16 | let engine = Highlightr() | ||
| 17 | engine?.setTheme(to: colorScheme == .dark ? "atom-one-dark" : "xcode") | ||
| 18 | highlightr = engine | ||
| 19 | supported = Set(engine?.supportedLanguages() ?? []) | ||
| 20 | } | ||
| 21 | |||
| 22 | func highlighted(code: String, language: String?) -> AttributedString? { | ||
| 23 | guard let highlightr, let language = resolvedLanguage(language) else { return nil } | ||
| 24 | highlightr.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) | ||
| 25 | guard let attributed = highlightr.highlight(code, as: language, fastRender: true) else { | ||
| 26 | return nil | ||
| 27 | } | ||
| 28 | return Self.attributedString(from: attributed) | ||
| 29 | } | ||
| 30 | |||
| 31 | private func resolvedLanguage(_ raw: String?) -> String? { | ||
| 32 | guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), | ||
| 33 | !trimmed.isEmpty else { return nil } | ||
| 34 | let mapped = Self.aliases[trimmed] ?? trimmed | ||
| 35 | return supported.contains(mapped) ? mapped : nil | ||
| 36 | } | ||
| 37 | |||
| 38 | private static let aliases: [String: String] = [ | ||
| 39 | "js": "javascript", "jsx": "javascript", "ts": "typescript", "tsx": "typescript", | ||
| 40 | "py": "python", "rb": "ruby", "rs": "rust", "sh": "bash", "shell": "bash", "zsh": "bash", | ||
| 41 | "yml": "yaml", "c++": "cpp", "cc": "cpp", "kt": "kotlin", "cs": "csharp", | ||
| 42 | "objc": "objectivec", "objective-c": "objectivec", "html": "xml", "htm": "xml", | ||
| 43 | ] | ||
| 44 | |||
| 45 | /// Carry Highlightr's colors across, in a monospaced font at the block's own size. | ||
| 46 | private static func attributedString(from attributed: NSAttributedString) -> AttributedString { | ||
| 47 | var result = AttributedString() | ||
| 48 | let range = NSRange(location: 0, length: attributed.length) | ||
| 49 | attributed.enumerateAttribute(.foregroundColor, in: range) { value, subrange, _ in | ||
| 50 | var fragment = AttributedString((attributed.string as NSString).substring(with: subrange)) | ||
| 51 | fragment.font = .system(.footnote, design: .monospaced) | ||
| 52 | if let color = value as? UIColor { | ||
| 53 | fragment.foregroundColor = Color(uiColor: color) | ||
| 54 | } | ||
| 55 | result += fragment | ||
| 56 | } | ||
| 57 | return result | ||
| 58 | } | ||
| 59 | } | ||
gitbay/Views/Repos/ReadmeView.swift +9 −3
| @@ -1,9 +1,10 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgSwift | 2 | import OrgSwift |
| 3 | import OrgSwiftUI | ||
| 3 | import SwiftUI | 4 | import SwiftUI |
| 4 | 5 | ||
| 5 | /// README rendering follows the file's format. Org is rendered by the shared `OrgSwift` | 6 | /// README rendering follows the file's format. Org is rendered natively by the shared |
| 6 | /// package (in a web view); everything else keeps the established Markdown renderer. | 7 | /// `OrgSwiftUI` package; everything else keeps the established Markdown renderer. |
| 7 | struct ReadmeView: View { | 8 | struct ReadmeView: View { |
| 8 | let name: String | 9 | let name: String |
| 9 | let content: String | 10 | let content: String |
| @@ -12,12 +13,17 @@ struct ReadmeView: View { | |||
| 12 | var host: String = "" | 13 | var host: String = "" |
| 13 | var repoPath: String = "" | 14 | var repoPath: String = "" |
| 14 | 15 | ||
| 16 | @Environment(\.colorScheme) private var colorScheme | ||
| 17 | |||
| 15 | /// Format follows the file name, never a guess at the content. | 18 | /// Format follows the file name, never a guess at the content. |
| 16 | var isOrg: Bool { name.lowercased().hasSuffix(".org") } | 19 | var isOrg: Bool { name.lowercased().hasSuffix(".org") } |
| 17 | 20 | ||
| 18 | var body: some View { | 21 | var body: some View { |
| 19 | if isOrg { | 22 | if isOrg { |
| 20 | OrgReadmeWebView(source: content, options: orgOptions) | 23 | // Qualified: `OrgView` here is org-mode, not the organization view. |
| 24 | OrgSwiftUI.OrgView( | ||
| 25 | content, options: orgOptions, styler: OrgSyntaxStyler(colorScheme: colorScheme) | ||
| 26 | ) | ||
| 21 | } else { | 27 | } else { |
| 22 | MarkdownView(markdown: content) | 28 | MarkdownView(markdown: content) |
| 23 | } | 29 | } |