gitbay/Views/Repos/OrgReadmeWebView.swift
239 lines · 9585 bytes
1import Highlightr
2import OrgSwift
3import SwiftUI
4import UIKit
5import 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.
10struct 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.
38private 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
91private 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
102private 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
186private 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}