a native ios client for gitbay

client ios swift

https://gitbay.org

Render Org READMEs with the shared OrgSwift package !25

merged cmc wants to merge krz/gitbay-ios:org-swift-readme into main

6 files changed, +298 −297

gitbay.xcodeproj/project.pbxproj +17
@@ -7,6 +7,7 @@
77 objects = {
88
99/* Begin PBXBuildFile section */
10 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; };
1011 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; };
1112/* End PBXBuildFile section */
1213
@@ -57,6 +58,7 @@
5758 buildActionMask = 2147483647;
5859 files = (
5960 8B289850303FF914001BF44A /* Highlightr in Frameworks */,
61 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */,
6062 );
6163 runOnlyForDeploymentPostprocessing = 0;
6264 };
@@ -118,6 +120,7 @@
118120 name = gitbay;
119121 packageProductDependencies = (
120122 8B2898B1303FF3E5001BF44A /* Highlightr */,
123 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */,
121124 );
122125 productName = gitbay;
123126 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
@@ -203,6 +206,7 @@
203206 minimizedProjectReferenceProxies = 1;
204207 packageReferences = (
205208 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */,
209 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */,
206210 );
207211 preferredProjectObjectVersion = 77;
208212 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
@@ -577,6 +581,14 @@
577581/* End XCConfigurationList section */
578582
579583/* 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 };
580592 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
581593 isa = XCRemoteSwiftPackageReference;
582594 repositoryURL = "https://github.com/raspu/Highlightr";
@@ -588,6 +600,11 @@
588600/* End XCRemoteSwiftPackageReference section */
589601
590602/* Begin XCSwiftPackageProductDependency section */
603 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */ = {
604 isa = XCSwiftPackageProductDependency;
605 package = 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */;
606 productName = OrgSwift;
607 };
591608 8B2898B1303FF3E5001BF44A /* Highlightr */ = {
592609 isa = XCSwiftPackageProductDependency;
593610 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */;
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,5 +1,5 @@
11{
2 "originHash" : "0a8fce6d3cd251ad12ea6d2f48f55bfdc4fa43f93e39dce01f4b2baa78e6feb8",
2 "originHash" : "ab8cee97c943a8273a6ba915c015907ce1f3559ff83687bf97758c491bc4a149",
33 "pins" : [
44 {
55 "identity" : "highlightr",
@@ -9,6 +9,15 @@
99 "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
1010 "version" : "2.3.0"
1111 }
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 }
1221 }
1322 ],
1423 "version" : 3
gitbay/Views/Repos/OrgReadmeWebView.swift added +239
@@ -0,0 +1,239 @@
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 (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 @@
11 import Foundation
2import OrgSwift
23 import SwiftUI
34
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.
67 struct ReadmeView: View {
78 let name: String
89 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 = ""
914
1015 /// Format follows the file name, never a guess at the content.
1116 var isOrg: Bool { name.lowercased().hasSuffix(".org") }
1217
1318 var body: some View {
1419 if isOrg {
15 OrgDocumentView(document: OrgDocument.parse(content))
20 OrgReadmeWebView(source: content, options: orgOptions)
1621 } else {
1722 MarkdownView(markdown: content)
1823 }
1924 }
20}
21
22nonisolated 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
121nonisolated 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
130private 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 }
20725
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()
21432 }
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 )
21542 }
21643 }
gitbay/Views/Repos/RepoView.swift +9 −2
@@ -4,9 +4,11 @@ struct RepoView: View {
44
55 @State private var model: RepoDetailViewModel
66 private let path: String
7 private let host: String
78
89 init(client: GitbayClient, path: String) {
910 self.path = path
11 self.host = client.instance.baseURL.host() ?? ""
1012 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
1113 }
1214
@@ -63,8 +65,13 @@ struct RepoView: View {
6365
6466 if let readme = model.readme {
6567 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)
6875 }
6976 }
7077 }
gitbayTests/ReadmeOrgTests.swift deleted −98
@@ -1,98 +0,0 @@
1import Foundation
2import 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.
8struct 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}