Render Org READMEs with the shared OrgSwift package !25
6 files changed, +298 −297
gitbay.xcodeproj/project.pbxproj +17
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; }; | |
| 10 | 11 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; |
| 11 | 12 | /* End PBXBuildFile section */ |
| 12 | 13 | |
| @@ -57,6 +58,7 @@ | ||
| 57 | 58 | buildActionMask = 2147483647; |
| 58 | 59 | files = ( |
| 59 | 60 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, |
| 61 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */, | |
| 60 | 62 | ); |
| 61 | 63 | runOnlyForDeploymentPostprocessing = 0; |
| 62 | 64 | }; |
| @@ -118,6 +120,7 @@ | ||
| 118 | 120 | name = gitbay; |
| 119 | 121 | packageProductDependencies = ( |
| 120 | 122 | 8B2898B1303FF3E5001BF44A /* Highlightr */, |
| 123 | 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */, | |
| 121 | 124 | ); |
| 122 | 125 | productName = gitbay; |
| 123 | 126 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; |
| @@ -203,6 +206,7 @@ | ||
| 203 | 206 | minimizedProjectReferenceProxies = 1; |
| 204 | 207 | packageReferences = ( |
| 205 | 208 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */, |
| 209 | 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */, | |
| 206 | 210 | ); |
| 207 | 211 | preferredProjectObjectVersion = 77; |
| 208 | 212 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; |
| @@ -577,6 +581,14 @@ | ||
| 577 | 581 | /* End XCConfigurationList section */ |
| 578 | 582 | |
| 579 | 583 | /* Begin XCRemoteSwiftPackageReference section */ |
| 584 | 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */ = { | |
| 585 | isa = XCRemoteSwiftPackageReference; | |
| 586 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; | |
| 587 | requirement = { | |
| 588 | branch = main; | |
| 589 | kind = branch; | |
| 590 | }; | |
| 591 | }; | |
| 580 | 592 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { |
| 581 | 593 | isa = XCRemoteSwiftPackageReference; |
| 582 | 594 | repositoryURL = "https://github.com/raspu/Highlightr"; |
| @@ -588,6 +600,11 @@ | ||
| 588 | 600 | /* End XCRemoteSwiftPackageReference section */ |
| 589 | 601 | |
| 590 | 602 | /* Begin XCSwiftPackageProductDependency section */ |
| 603 | 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */ = { | |
| 604 | isa = XCSwiftPackageProductDependency; | |
| 605 | package = 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */; | |
| 606 | productName = OrgSwift; | |
| 607 | }; | |
| 591 | 608 | 8B2898B1303FF3E5001BF44A /* Highlightr */ = { |
| 592 | 609 | isa = XCSwiftPackageProductDependency; |
| 593 | 610 | package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */; |
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "originHash" : "0a8fce6d3cd251ad12ea6d2f48f55bfdc4fa43f93e39dce01f4b2baa78e6feb8", | |
| 2 | "originHash" : "ab8cee97c943a8273a6ba915c015907ce1f3559ff83687bf97758c491bc4a149", | |
| 3 | 3 | "pins" : [ |
| 4 | 4 | { |
| 5 | 5 | "identity" : "highlightr", |
| @@ -9,6 +9,15 @@ | ||
| 9 | 9 | "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", |
| 10 | 10 | "version" : "2.3.0" |
| 11 | 11 | } |
| 12 | }, | |
| 13 | { | |
| 14 | "identity" : "org-swift", | |
| 15 | "kind" : "remoteSourceControl", | |
| 16 | "location" : "ssh://git@gitbay.org/krz/org-swift.git", | |
| 17 | "state" : { | |
| 18 | "branch" : "main", | |
| 19 | "revision" : "47ac630c479b79dbfa8944c91b22b7bb6974cadc" | |
| 20 | } | |
| 12 | 21 | } |
| 13 | 22 | ], |
| 14 | 23 | "version" : 3 |
gitbay/Views/Repos/OrgReadmeWebView.swift added +239
| @@ -0,0 +1,239 @@ | ||
| 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 (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/ReadmeView.swift +23 −196
| @@ -1,216 +1,43 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import OrgSwift | |
| 2 | 3 | import SwiftUI |
| 3 | 4 | |
| 4 | /// README rendering follows the file's format. Org is parsed as Org; | |
| 5 | /// everything else keeps the established Markdown renderer. | |
| 5 | /// README rendering follows the file's format. Org is rendered by the shared `OrgSwift` | |
| 6 | /// package (in a web view); everything else keeps the established Markdown renderer. | |
| 6 | 7 | struct ReadmeView: View { |
| 7 | 8 | let name: String |
| 8 | 9 | let content: String |
| 10 | /// Instance host and `owner/repo`, used to resolve repository-relative links and images. | |
| 11 | /// Empty leaves relative references unresolved. | |
| 12 | var host: String = "" | |
| 13 | var repoPath: String = "" | |
| 9 | 14 | |
| 10 | 15 | /// Format follows the file name, never a guess at the content. |
| 11 | 16 | var isOrg: Bool { name.lowercased().hasSuffix(".org") } |
| 12 | 17 | |
| 13 | 18 | var body: some View { |
| 14 | 19 | if isOrg { |
| 15 | OrgDocumentView(document: OrgDocument.parse(content)) | |
| 20 | OrgReadmeWebView(source: content, options: orgOptions) | |
| 16 | 21 | } else { |
| 17 | 22 | MarkdownView(markdown: content) |
| 18 | 23 | } |
| 19 | 24 | } |
| 20 | } | |
| 21 | ||
| 22 | nonisolated struct OrgDocument: Sendable, Hashable { | |
| 23 | let blocks: [OrgBlock] | |
| 24 | ||
| 25 | static func parse(_ source: String) -> OrgDocument { | |
| 26 | var result: [OrgBlock] = [] | |
| 27 | var lines = source.components(separatedBy: "\n")[...] | |
| 28 | ||
| 29 | while let raw = lines.first { | |
| 30 | let line = raw.trimmingCharacters(in: .whitespaces) | |
| 31 | if line.isEmpty { | |
| 32 | lines = lines.dropFirst() | |
| 33 | continue | |
| 34 | } | |
| 35 | let lower = line.lowercased() | |
| 36 | if lower.hasPrefix("#+title:") { | |
| 37 | result.append(.heading(level: 1, text: String(line.dropFirst(8)).trimmingCharacters(in: .whitespaces))) | |
| 38 | lines = lines.dropFirst() | |
| 39 | continue | |
| 40 | } | |
| 41 | if lower.hasPrefix("#+begin_src") || lower.hasPrefix("#+begin_example") { | |
| 42 | let language = lower.hasPrefix("#+begin_src") | |
| 43 | ? String(line.dropFirst(11)).trimmingCharacters(in: .whitespaces) | |
| 44 | : "" | |
| 45 | lines = lines.dropFirst() | |
| 46 | var body: [String] = [] | |
| 47 | while let next = lines.first, | |
| 48 | !next.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("#+end_") { | |
| 49 | body.append(next) | |
| 50 | lines = lines.dropFirst() | |
| 51 | } | |
| 52 | if !lines.isEmpty { lines = lines.dropFirst() } | |
| 53 | result.append(.code(language: language, text: body.joined(separator: "\n"))) | |
| 54 | continue | |
| 55 | } | |
| 56 | if lower == "#+begin_quote" { | |
| 57 | lines = lines.dropFirst() | |
| 58 | var body: [String] = [] | |
| 59 | while let next = lines.first, | |
| 60 | next.trimmingCharacters(in: .whitespaces).lowercased() != "#+end_quote" { | |
| 61 | body.append(next.trimmingCharacters(in: .whitespaces)) | |
| 62 | lines = lines.dropFirst() | |
| 63 | } | |
| 64 | if !lines.isEmpty { lines = lines.dropFirst() } | |
| 65 | result.append(.quote(body.joined(separator: " "))) | |
| 66 | continue | |
| 67 | } | |
| 68 | if line.first == "*" { | |
| 69 | let stars = line.prefix(while: { $0 == "*" }).count | |
| 70 | if line.dropFirst(stars).first == " " { | |
| 71 | result.append(.heading( | |
| 72 | level: min(stars, 4), | |
| 73 | text: String(line.dropFirst(stars)).trimmingCharacters(in: .whitespaces) | |
| 74 | )) | |
| 75 | lines = lines.dropFirst() | |
| 76 | continue | |
| 77 | } | |
| 78 | } | |
| 79 | if line.hasPrefix("- ") || line.hasPrefix("+ ") { | |
| 80 | var items: [String] = [] | |
| 81 | while let next = lines.first?.trimmingCharacters(in: .whitespaces), | |
| 82 | next.hasPrefix("- ") || next.hasPrefix("+ ") { | |
| 83 | items.append(String(next.dropFirst(2))) | |
| 84 | lines = lines.dropFirst() | |
| 85 | } | |
| 86 | result.append(.bullet(items)) | |
| 87 | continue | |
| 88 | } | |
| 89 | if line.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil { | |
| 90 | var items: [String] = [] | |
| 91 | while let next = lines.first?.trimmingCharacters(in: .whitespaces), | |
| 92 | let range = next.range(of: #"^\d+[.)] "#, options: .regularExpression) { | |
| 93 | items.append(String(next[range.upperBound...])) | |
| 94 | lines = lines.dropFirst() | |
| 95 | } | |
| 96 | result.append(.ordered(items)) | |
| 97 | continue | |
| 98 | } | |
| 99 | if lower.hasPrefix("#+") { | |
| 100 | lines = lines.dropFirst() // document metadata, not prose | |
| 101 | continue | |
| 102 | } | |
| 103 | ||
| 104 | var paragraph: [String] = [] | |
| 105 | while let next = lines.first { | |
| 106 | let trimmed = next.trimmingCharacters(in: .whitespaces) | |
| 107 | let nextLower = trimmed.lowercased() | |
| 108 | if trimmed.isEmpty || trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ") | |
| 109 | || trimmed.hasPrefix("+ ") || nextLower.hasPrefix("#+") { | |
| 110 | break | |
| 111 | } | |
| 112 | paragraph.append(trimmed) | |
| 113 | lines = lines.dropFirst() | |
| 114 | } | |
| 115 | result.append(.paragraph(paragraph.joined(separator: " "))) | |
| 116 | } | |
| 117 | return OrgDocument(blocks: result) | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | nonisolated enum OrgBlock: Sendable, Hashable { | |
| 122 | case heading(level: Int, text: String) | |
| 123 | case code(language: String, text: String) | |
| 124 | case quote(String) | |
| 125 | case bullet([String]) | |
| 126 | case ordered([String]) | |
| 127 | case paragraph(String) | |
| 128 | } | |
| 129 | ||
| 130 | private struct OrgDocumentView: View { | |
| 131 | let document: OrgDocument | |
| 132 | ||
| 133 | var body: some View { | |
| 134 | VStack(alignment: .leading, spacing: 12) { | |
| 135 | ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in | |
| 136 | blockView(block) | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | @ViewBuilder | |
| 142 | private func blockView(_ block: OrgBlock) -> some View { | |
| 143 | switch block { | |
| 144 | case .heading(let level, let text): | |
| 145 | inline(text) | |
| 146 | .font(headingFont(level)) | |
| 147 | .padding(.top, level <= 2 ? 8 : 4) | |
| 148 | case .code(let language, let text): | |
| 149 | VStack(alignment: .leading, spacing: 4) { | |
| 150 | if !language.isEmpty { | |
| 151 | Text(language) | |
| 152 | .font(.gbMono(.caption2)) | |
| 153 | .foregroundStyle(.secondary) | |
| 154 | } | |
| 155 | ScrollView(.horizontal) { | |
| 156 | Text(text) | |
| 157 | .font(.gbMono(.caption)) | |
| 158 | .padding(10) | |
| 159 | } | |
| 160 | } | |
| 161 | .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) | |
| 162 | case .quote(let text): | |
| 163 | HStack(spacing: 10) { | |
| 164 | RoundedRectangle(cornerRadius: 2).fill(.tertiary).frame(width: 3) | |
| 165 | inline(text).foregroundStyle(.secondary) | |
| 166 | } | |
| 167 | .fixedSize(horizontal: false, vertical: true) | |
| 168 | case .bullet(let items): | |
| 169 | list(items, ordered: false) | |
| 170 | case .ordered(let items): | |
| 171 | list(items, ordered: true) | |
| 172 | case .paragraph(let text): | |
| 173 | inline(text) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | private func list(_ items: [String], ordered: Bool) -> some View { | |
| 178 | VStack(alignment: .leading, spacing: 4) { | |
| 179 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | |
| 180 | HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| 181 | Text(ordered ? "\(index + 1)." : "•") | |
| 182 | inline(item) | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | private func inline(_ source: String) -> Text { | |
| 189 | var markdown = source | |
| 190 | markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, template: "[$2]($1)") | |
| 191 | markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\]"#, template: "<$1>") | |
| 192 | markdown = replacing(markdown, pattern: #"=([^=]+)="#, template: "`$1`") | |
| 193 | if let attributed = try? AttributedString( | |
| 194 | markdown: markdown, | |
| 195 | options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) | |
| 196 | ) { | |
| 197 | return Text(attributed) | |
| 198 | } | |
| 199 | return Text(source) | |
| 200 | } | |
| 201 | ||
| 202 | private func replacing(_ source: String, pattern: String, template: String) -> String { | |
| 203 | guard let expression = try? NSRegularExpression(pattern: pattern) else { return source } | |
| 204 | let range = NSRange(source.startIndex..., in: source) | |
| 205 | return expression.stringByReplacingMatches(in: source, range: range, withTemplate: template) | |
| 206 | } | |
| 207 | 25 | |
| 208 | private func headingFont(_ level: Int) -> Font { | |
| 209 | switch level { | |
| 210 | case 1: .gbSans(.title2).bold() | |
| 211 | case 2: .gbSans(.title3).bold() | |
| 212 | case 3: .gbSans(.headline) | |
| 213 | default: .gbSans(.subheadline).bold() | |
| 26 | /// gitbay serves raw bytes at `/{owner}/{repo}/raw/{ref}/…` and the file page at | |
| 27 | /// `/blob/…`, so relative images resolve against `raw` and links against `blob`. | |
| 28 | private var orgOptions: OrgRenderOptions { | |
| 29 | let parts = repoPath.split(separator: "/", maxSplits: 1).map(String.init) | |
| 30 | guard parts.count == 2, !host.isEmpty else { | |
| 31 | return OrgRenderOptions() | |
| 214 | 32 | } |
| 33 | return OrgRenderOptions( | |
| 34 | host: host, | |
| 35 | owner: parts[0], | |
| 36 | repositoryName: parts[1], | |
| 37 | ref: "HEAD", | |
| 38 | readmePath: name, | |
| 39 | imagePathSegment: "raw", | |
| 40 | linkPathSegment: "blob" | |
| 41 | ) | |
| 215 | 42 | } |
| 216 | 43 | } |
gitbay/Views/Repos/RepoView.swift +9 −2
| @@ -4,9 +4,11 @@ struct RepoView: View { | ||
| 4 | 4 | |
| 5 | 5 | @State private var model: RepoDetailViewModel |
| 6 | 6 | private let path: String |
| 7 | private let host: String | |
| 7 | 8 | |
| 8 | 9 | init(client: GitbayClient, path: String) { |
| 9 | 10 | self.path = path |
| 11 | self.host = client.instance.baseURL.host() ?? "" | |
| 10 | 12 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) |
| 11 | 13 | } |
| 12 | 14 | |
| @@ -63,8 +65,13 @@ struct RepoView: View { | ||
| 63 | 65 | |
| 64 | 66 | if let readme = model.readme { |
| 65 | 67 | Section("README") { |
| 66 | ReadmeView(name: model.readmeName ?? "README.md", content: readme) | |
| 67 | .padding(.vertical, 4) | |
| 68 | ReadmeView( | |
| 69 | name: model.readmeName ?? "README.md", | |
| 70 | content: readme, | |
| 71 | host: host, | |
| 72 | repoPath: path | |
| 73 | ) | |
| 74 | .padding(.vertical, 4) | |
| 68 | 75 | } |
| 69 | 76 | } |
| 70 | 77 | } |
gitbayTests/ReadmeOrgTests.swift deleted −98
| @@ -1,98 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// README.org rendered as Org, not Markdown (krz/gitbay-ios#4). The | |
| 6 | /// markdown block parser turned `#+title:` into a heading and left | |
| 7 | /// `[[link]]` raw. | |
| 8 | struct OrgDocumentTests { | |
| 9 | ||
| 10 | @Test func titleAndStarHeadingsBecomeHeadings() { | |
| 11 | let doc = OrgDocument.parse(""" | |
| 12 | #+title: orgo | |
| 13 | #+author: krz | |
| 14 | ||
| 15 | * Install | |
| 16 | ** From source | |
| 17 | """) | |
| 18 | ||
| 19 | #expect(doc.blocks == [ | |
| 20 | .heading(level: 1, text: "orgo"), | |
| 21 | .heading(level: 1, text: "Install"), | |
| 22 | .heading(level: 2, text: "From source"), | |
| 23 | ]) | |
| 24 | } | |
| 25 | ||
| 26 | @Test func metadataOtherThanTitleIsDropped() { | |
| 27 | let doc = OrgDocument.parse("#+options: toc:nil\n#+startup: showall\n\nProse.") | |
| 28 | #expect(doc.blocks == [.paragraph("Prose.")]) | |
| 29 | } | |
| 30 | ||
| 31 | @Test func sourceBlocksKeepTheirLanguageAndBody() { | |
| 32 | let doc = OrgDocument.parse(""" | |
| 33 | #+begin_src sh | |
| 34 | gitbay repo list | |
| 35 | gitbay mr create | |
| 36 | #+end_src | |
| 37 | """) | |
| 38 | #expect(doc.blocks == [ | |
| 39 | .code(language: "sh", text: "gitbay repo list\ngitbay mr create"), | |
| 40 | ]) | |
| 41 | } | |
| 42 | ||
| 43 | @Test func examplesAndQuotesAreTheirOwnBlocks() { | |
| 44 | let doc = OrgDocument.parse(""" | |
| 45 | #+begin_example | |
| 46 | $ gitbay whoami | |
| 47 | #+end_example | |
| 48 | ||
| 49 | #+begin_quote | |
| 50 | SSH is the API. | |
| 51 | #+end_quote | |
| 52 | """) | |
| 53 | #expect(doc.blocks == [ | |
| 54 | .code(language: "", text: "$ gitbay whoami"), | |
| 55 | .quote("SSH is the API."), | |
| 56 | ]) | |
| 57 | } | |
| 58 | ||
| 59 | @Test func bothListFormsParse() { | |
| 60 | let doc = OrgDocument.parse(""" | |
| 61 | - one | |
| 62 | + two | |
| 63 | ||
| 64 | 1. first | |
| 65 | 2) second | |
| 66 | """) | |
| 67 | #expect(doc.blocks == [ | |
| 68 | .bullet(["one", "two"]), | |
| 69 | .ordered(["first", "second"]), | |
| 70 | ]) | |
| 71 | } | |
| 72 | ||
| 73 | @Test func aBoldStarLineIsNotAHeading() { | |
| 74 | // "*bold*" has no space after the star, so it is prose. | |
| 75 | let doc = OrgDocument.parse("*bold* opening line") | |
| 76 | #expect(doc.blocks == [.paragraph("*bold* opening line")]) | |
| 77 | } | |
| 78 | ||
| 79 | @Test func paragraphsRunUntilABlankLineOrBlockStart() { | |
| 80 | let doc = OrgDocument.parse(""" | |
| 81 | One sentence | |
| 82 | continued here. | |
| 83 | * Heading | |
| 84 | """) | |
| 85 | #expect(doc.blocks == [ | |
| 86 | .paragraph("One sentence continued here."), | |
| 87 | .heading(level: 1, text: "Heading"), | |
| 88 | ]) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func readmeViewPicksTheRendererByExtension() { | |
| 92 | // Only the name decides; content is not sniffed. | |
| 93 | #expect(ReadmeView(name: "README.org", content: "* x").isOrg) | |
| 94 | #expect(ReadmeView(name: "readme.ORG", content: "* x").isOrg) | |
| 95 | #expect(!ReadmeView(name: "README.md", content: "# x").isOrg) | |
| 96 | #expect(!ReadmeView(name: "README", content: "x").isOrg) | |
| 97 | } | |
| 98 | } | |