a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/Repos/OrgReadmeWebView.swift

main
gitbay-ios/gitbay/Views/Repos/OrgReadmeWebView.swift history · blame · raw

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: "&amp;")
 79                .replacingOccurrences(of: "<", with: "&lt;")
 80                .replacingOccurrences(of: ">", with: "&gt;")
 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}