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