Commit d3a69034f3
Verified · cmc
Layout: unified · split
gitbay/Views/Issues/IssueView.swift +3 −2
| @@ -31,7 +31,7 @@ struct IssueView: View { | |||
| 31 | 31 | ||
| 32 | if let body = issue.body, !body.isEmpty { | 32 | if let body = issue.body, !body.isEmpty { |
| 33 | Section { | 33 | Section { |
| 34 | FormattedBodyView(text: body, format: issue.bodyFormat) | 34 | FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath) |
| 35 | .padding(.vertical, 4) | 35 | .padding(.vertical, 4) |
| 36 | } | 36 | } |
| 37 | } | 37 | } |
| @@ -41,6 +41,7 @@ struct IssueView: View { | |||
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 43 | .overlay { LoadStateOverlay(state: model.state) } | 43 | .overlay { LoadStateOverlay(state: model.state) } |
| 44 | .referenceNavigation() | ||
| 44 | .scrollDismissesKeyboard(.interactively) | 45 | .scrollDismissesKeyboard(.interactively) |
| 45 | .navigationTitle("#\(model.number)") | 46 | .navigationTitle("#\(model.number)") |
| 46 | .navigationBarTitleDisplayMode(.inline) | 47 | .navigationBarTitleDisplayMode(.inline) |
| @@ -166,7 +167,7 @@ struct IssueView: View { | |||
| 166 | .font(.gbSans(.caption)) | 167 | .font(.gbSans(.caption)) |
| 167 | .foregroundStyle(.tertiary) | 168 | .foregroundStyle(.tertiary) |
| 168 | } | 169 | } |
| 169 | FormattedBodyView(text: comment.body, format: comment.bodyFormat) | 170 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 170 | .font(.gbSans(.subheadline)) | 171 | .font(.gbSans(.subheadline)) |
| 171 | } | 172 | } |
| 172 | .padding(.vertical, 2) | 173 | .padding(.vertical, 2) |
gitbay/Views/MRs/MRView.swift +3 −2
| @@ -37,7 +37,7 @@ struct MRView: View { | |||
| 37 | 37 | ||
| 38 | if let body = mr.body, !body.isEmpty { | 38 | if let body = mr.body, !body.isEmpty { |
| 39 | Section { | 39 | Section { |
| 40 | FormattedBodyView(text: body, format: mr.bodyFormat) | 40 | FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) |
| 41 | .padding(.vertical, 4) | 41 | .padding(.vertical, 4) |
| 42 | } | 42 | } |
| 43 | } | 43 | } |
| @@ -72,6 +72,7 @@ struct MRView: View { | |||
| 72 | } | 72 | } |
| 73 | } | 73 | } |
| 74 | .overlay { LoadStateOverlay(state: model.state) } | 74 | .overlay { LoadStateOverlay(state: model.state) } |
| 75 | .referenceNavigation() | ||
| 75 | .scrollDismissesKeyboard(.interactively) | 76 | .scrollDismissesKeyboard(.interactively) |
| 76 | .navigationTitle("!\(model.number)") | 77 | .navigationTitle("!\(model.number)") |
| 77 | .navigationBarTitleDisplayMode(.inline) | 78 | .navigationBarTitleDisplayMode(.inline) |
| @@ -532,7 +533,7 @@ struct MRView: View { | |||
| 532 | .font(.gbSans(.caption)) | 533 | .font(.gbSans(.caption)) |
| 533 | .foregroundStyle(.tertiary) | 534 | .foregroundStyle(.tertiary) |
| 534 | } | 535 | } |
| 535 | FormattedBodyView(text: comment.body, format: comment.bodyFormat) | 536 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 536 | .font(.gbSans(.subheadline)) | 537 | .font(.gbSans(.subheadline)) |
| 537 | } | 538 | } |
| 538 | .padding(.vertical, 2) | 539 | .padding(.vertical, 2) |
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
| @@ -8,6 +8,9 @@ import SwiftUI | |||
| 8 | struct FormattedBodyView: View { | 8 | struct FormattedBodyView: View { |
| 9 | let text: String | 9 | let text: String |
| 10 | var format: String? | 10 | var format: String? |
| 11 | /// The repository `#N` and `!N` refer to; nil links no references. | ||
| 12 | /// The links open through `referenceNavigation()`. | ||
| 13 | var repo: String? | ||
| 11 | 14 | ||
| 12 | @Environment(\.colorScheme) private var colorScheme | 15 | @Environment(\.colorScheme) private var colorScheme |
| 13 | 16 | ||
| @@ -22,7 +25,7 @@ struct FormattedBodyView: View { | |||
| 22 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) | 25 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) |
| 23 | ) | 26 | ) |
| 24 | } else { | 27 | } else { |
| 25 | MarkdownView(markdown: text) | 28 | MarkdownView(markdown: text, referenceRepo: repo) |
| 26 | } | 29 | } |
| 27 | } | 30 | } |
| 28 | } | 31 | } |
gitbay/Views/Repos/MarkdownView.swift +36 −10
| @@ -10,6 +10,8 @@ struct MarkdownView: View { | |||
| 10 | /// Repository context for resolving relative link targets. Nil leaves them as authored, | 10 | /// Repository context for resolving relative link targets. Nil leaves them as authored, |
| 11 | /// which is right for a body with no file to be relative to. | 11 | /// which is right for a body with no file to be relative to. |
| 12 | var linkOptions: OrgRenderOptions? | 12 | var linkOptions: OrgRenderOptions? |
| 13 | /// The repository `#N` and `!N` refer to. Nil links no references. | ||
| 14 | var referenceRepo: String? | ||
| 13 | 15 | ||
| 14 | var body: some View { | 16 | var body: some View { |
| 15 | VStack(alignment: .leading, spacing: 12) { | 17 | VStack(alignment: .leading, spacing: 12) { |
| @@ -223,7 +225,8 @@ struct MarkdownView: View { | |||
| 223 | private func blockView(_ block: Block) -> some View { | 225 | private func blockView(_ block: Block) -> some View { |
| 224 | switch block { | 226 | switch block { |
| 225 | case .heading(let level, let text): | 227 | case .heading(let level, let text): |
| 226 | Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions) | 228 | Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions, |
| 229 | referenceRepo: referenceRepo) | ||
| 227 | .font(headingFont(level)) | 230 | .font(headingFont(level)) |
| 228 | .padding(.top, level <= 2 ? 8 : 4) | 231 | .padding(.top, level <= 2 ? 8 : 4) |
| 229 | case .code(let code): | 232 | case .code(let code): |
| @@ -238,7 +241,7 @@ struct MarkdownView: View { | |||
| 238 | RoundedRectangle(cornerRadius: 2) | 241 | RoundedRectangle(cornerRadius: 2) |
| 239 | .fill(.tertiary) | 242 | .fill(.tertiary) |
| 240 | .frame(width: 3) | 243 | .frame(width: 3) |
| 241 | Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary) | 244 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary) |
| 242 | } | 245 | } |
| 243 | .fixedSize(horizontal: false, vertical: true) | 246 | .fixedSize(horizontal: false, vertical: true) |
| 244 | case .bullet(let items): | 247 | case .bullet(let items): |
| @@ -246,7 +249,7 @@ struct MarkdownView: View { | |||
| 246 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in | 249 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 247 | HStack(alignment: .firstTextBaseline, spacing: 8) { | 250 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 248 | Text("•") | 251 | Text("•") |
| 249 | Self.inline(item, linkOptions: linkOptions) | 252 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) |
| 250 | .fixedSize(horizontal: false, vertical: true) | 253 | .fixedSize(horizontal: false, vertical: true) |
| 251 | } | 254 | } |
| 252 | } | 255 | } |
| @@ -256,17 +259,17 @@ struct MarkdownView: View { | |||
| 256 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | 259 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 257 | HStack(alignment: .firstTextBaseline, spacing: 8) { | 260 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 258 | Text("\(index + 1).").monospacedDigit() | 261 | Text("\(index + 1).").monospacedDigit() |
| 259 | Self.inline(item, linkOptions: linkOptions) | 262 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) |
| 260 | .fixedSize(horizontal: false, vertical: true) | 263 | .fixedSize(horizontal: false, vertical: true) |
| 261 | } | 264 | } |
| 262 | } | 265 | } |
| 263 | } | 266 | } |
| 264 | case .table(let table): | 267 | case .table(let table): |
| 265 | MarkdownTableView(table: table, linkOptions: linkOptions) | 268 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo) |
| 266 | case .rule: | 269 | case .rule: |
| 267 | Divider() | 270 | Divider() |
| 268 | case .paragraph(let text): | 271 | case .paragraph(let text): |
| 269 | Self.inline(text, linkOptions: linkOptions) | 272 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) |
| 270 | } | 273 | } |
| 271 | } | 274 | } |
| 272 | 275 | ||
| @@ -277,15 +280,18 @@ struct MarkdownView: View { | |||
| 277 | static func inline( | 280 | static func inline( |
| 278 | _ text: String, | 281 | _ text: String, |
| 279 | codeStyle: Font.TextStyle = .subheadline, | 282 | codeStyle: Font.TextStyle = .subheadline, |
| 280 | linkOptions: OrgRenderOptions? = nil | 283 | linkOptions: OrgRenderOptions? = nil, |
| 284 | referenceRepo: String? = nil | ||
| 281 | ) -> Text { | 285 | ) -> Text { |
| 282 | Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions)) | 286 | Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, |
| 287 | referenceRepo: referenceRepo)) | ||
| 283 | } | 288 | } |
| 284 | 289 | ||
| 285 | static func attributed( | 290 | static func attributed( |
| 286 | _ text: String, | 291 | _ text: String, |
| 287 | codeStyle: Font.TextStyle = .subheadline, | 292 | codeStyle: Font.TextStyle = .subheadline, |
| 288 | linkOptions: OrgRenderOptions? = nil | 293 | linkOptions: OrgRenderOptions? = nil, |
| 294 | referenceRepo: String? = nil | ||
| 289 | ) -> AttributedString { | 295 | ) -> AttributedString { |
| 290 | guard var attributed = try? AttributedString( | 296 | guard var attributed = try? AttributedString( |
| 291 | markdown: text, | 297 | markdown: text, |
| @@ -306,9 +312,28 @@ struct MarkdownView: View { | |||
| 306 | attributed[range].link = url | 312 | attributed[range].link = url |
| 307 | } | 313 | } |
| 308 | } | 314 | } |
| 315 | if let referenceRepo { | ||
| 316 | linkReferences(in: &attributed, repo: referenceRepo) | ||
| 317 | } | ||
| 309 | return attributed | 318 | return attributed |
| 310 | } | 319 | } |
| 311 | 320 | ||
| 321 | /// Links `#N`, `!N` and `owner/name#N` outside code spans and | ||
| 322 | /// existing links. Fenced code never reaches here. | ||
| 323 | private static func linkReferences(in attributed: inout AttributedString, repo: String) { | ||
| 324 | for reference in bodyReferences(in: String(attributed.characters), repo: repo) { | ||
| 325 | let lower = attributed.characters.index(attributed.startIndex, | ||
| 326 | offsetBy: reference.range.lowerBound) | ||
| 327 | let upper = attributed.characters.index(lower, offsetBy: reference.range.count) | ||
| 328 | let skip = attributed[lower..<upper].runs.contains { | ||
| 329 | $0.link != nil || $0.inlinePresentationIntent?.contains(.code) == true | ||
| 330 | } | ||
| 331 | if !skip, let url = referenceURL(reference.destination) { | ||
| 332 | attributed[lower..<upper].link = url | ||
| 333 | } | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 312 | private func headingFont(_ level: Int) -> Font { | 337 | private func headingFont(_ level: Int) -> Font { |
| 313 | switch level { | 338 | switch level { |
| 314 | case 1: .title2.bold() | 339 | case 1: .title2.bold() |
| @@ -338,6 +363,7 @@ struct MarkdownView: View { | |||
| 338 | private struct MarkdownTableView: View { | 363 | private struct MarkdownTableView: View { |
| 339 | let table: MarkdownView.Table | 364 | let table: MarkdownView.Table |
| 340 | var linkOptions: OrgRenderOptions? | 365 | var linkOptions: OrgRenderOptions? |
| 366 | var referenceRepo: String? | ||
| 341 | 367 | ||
| 342 | private static let spacing: CGFloat = 14 | 368 | private static let spacing: CGFloat = 14 |
| 343 | 369 | ||
| @@ -372,7 +398,7 @@ private struct MarkdownTableView: View { | |||
| 372 | 398 | ||
| 373 | private func cell(_ text: String, _ column: Int) -> some View { | 399 | private func cell(_ text: String, _ column: Int) -> some View { |
| 374 | let alignment = table.alignments[column] | 400 | let alignment = table.alignments[column] |
| 375 | return MarkdownView.inline(text, linkOptions: linkOptions) | 401 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) |
| 376 | .multilineTextAlignment(Self.textAlignment(alignment)) | 402 | .multilineTextAlignment(Self.textAlignment(alignment)) |
| 377 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) | 403 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) |
| 378 | .fixedSize(horizontal: cellWidth == nil, vertical: true) | 404 | .fixedSize(horizontal: cellWidth == nil, vertical: true) |
gitbay/Views/Shared/BodyReferences.swift added +96
| @@ -0,0 +1,96 @@ | |||
| 1 | import Foundation | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// A `#N`, `!N`, `owner/name#N` or `owner/name!N` in a body, as the web's | ||
| 5 | /// autolink package reads them: `#` is an issue, `!` a merge request. | ||
| 6 | /// `range` counts characters. | ||
| 7 | nonisolated struct BodyReference: Equatable, Sendable { | ||
| 8 | let range: Range<Int> | ||
| 9 | let destination: NotificationDestination | ||
| 10 | } | ||
| 11 | |||
| 12 | /// Every reference in `text`, bare ones resolved against `repo`. The | ||
| 13 | /// patterns are the web's: a bare reference needs the start of the text, | ||
| 14 | /// whitespace or an opening bracket before it, and a cross-repository one | ||
| 15 | /// wins where the two overlap. Unlike the web, the target is not checked | ||
| 16 | /// to exist. | ||
| 17 | nonisolated func bodyReferences(in text: String, repo: String) -> [BodyReference] { | ||
| 18 | let cross = /([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)/ | ||
| 19 | let bare = /(^|[\s(\[{])([#!])([0-9]+)(?![0-9A-Za-z_])/ | ||
| 20 | |||
| 21 | func offsets(_ range: Range<String.Index>) -> Range<Int> { | ||
| 22 | let lower = text.distance(from: text.startIndex, to: range.lowerBound) | ||
| 23 | return lower..<(lower + text.distance(from: range.lowerBound, to: range.upperBound)) | ||
| 24 | } | ||
| 25 | func destination(_ repo: String, _ kind: Substring, _ number: Substring) -> NotificationDestination? { | ||
| 26 | guard let n = Int64(number) else { return nil } | ||
| 27 | return kind == "#" ? .issue(repo: repo, number: n) : .mr(repo: repo, number: n) | ||
| 28 | } | ||
| 29 | |||
| 30 | var found: [BodyReference] = [] | ||
| 31 | for match in text.matches(of: cross) { | ||
| 32 | if let d = destination("\(match.1)/\(match.2)", match.3, match.4) { | ||
| 33 | found.append(BodyReference(range: offsets(match.range), destination: d)) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | for match in text.matches(of: bare) { | ||
| 37 | let range = offsets(match.2.startIndex..<match.3.endIndex) | ||
| 38 | guard !found.contains(where: { $0.range.overlaps(range) }), | ||
| 39 | let d = destination(repo, match.2, match.3) else { continue } | ||
| 40 | found.append(BodyReference(range: range, destination: d)) | ||
| 41 | } | ||
| 42 | return found.sorted { $0.range.lowerBound < $1.range.lowerBound } | ||
| 43 | } | ||
| 44 | |||
| 45 | nonisolated private let referenceScheme = "gitbay-ref" | ||
| 46 | |||
| 47 | /// The link a reference carries: the web path under an app-only scheme, | ||
| 48 | /// so only `referenceNavigation()` acts on it. | ||
| 49 | nonisolated func referenceURL(_ destination: NotificationDestination) -> URL? { | ||
| 50 | let path = switch destination { | ||
| 51 | case .issue(let repo, let number): "\(repo)/issues/\(number)" | ||
| 52 | case .mr(let repo, let number): "\(repo)/mrs/\(number)" | ||
| 53 | case .build(let repo, let number): "\(repo)/builds/\(number)" | ||
| 54 | } | ||
| 55 | return URL(string: "\(referenceScheme):\(path)") | ||
| 56 | } | ||
| 57 | |||
| 58 | nonisolated func referenceDestination(_ url: URL) -> NotificationDestination? { | ||
| 59 | guard url.scheme == referenceScheme else { return nil } | ||
| 60 | return notificationDestination(forPath: String(url.absoluteString.dropFirst(referenceScheme.count + 1))) | ||
| 61 | } | ||
| 62 | |||
| 63 | extension View { | ||
| 64 | /// Opens reference links from a body in-app. Attach outside the | ||
| 65 | /// `List`: a navigation destination inside a lazy container is ignored. | ||
| 66 | func referenceNavigation() -> some View { | ||
| 67 | modifier(ReferenceNavigation()) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | private struct ReferenceNavigation: ViewModifier { | ||
| 72 | |||
| 73 | @Environment(SessionStore.self) private var session | ||
| 74 | @State private var target: NotificationDestination? | ||
| 75 | |||
| 76 | func body(content: Content) -> some View { | ||
| 77 | content | ||
| 78 | .environment(\.openURL, OpenURLAction { url in | ||
| 79 | guard let destination = referenceDestination(url) else { return .systemAction } | ||
| 80 | target = destination | ||
| 81 | return .handled | ||
| 82 | }) | ||
| 83 | .navigationDestination(item: $target) { destination in | ||
| 84 | if let client = session.client { | ||
| 85 | switch destination { | ||
| 86 | case .issue(let repo, let number): | ||
| 87 | IssueView(client: client, repo: repo, number: number) | ||
| 88 | case .mr(let repo, let number): | ||
| 89 | MRView(client: client, repo: repo, number: number) | ||
| 90 | case .build(let repo, let number): | ||
| 91 | BuildDetailView(client: client, repo: repo, number: number) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | } | ||
gitbayTests/BodyReferenceTests.swift added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | /// `#N`, `!N`, `owner/name#N` and `owner/name!N`, found the way the web's | ||
| 6 | /// autolink package finds them: `#` is an issue, `!` a merge request. | ||
| 7 | struct BodyReferenceTests { | ||
| 8 | |||
| 9 | private func refs(_ text: String, repo: String = "krz/gitbay") -> [BodyReference] { | ||
| 10 | bodyReferences(in: text, repo: repo) | ||
| 11 | } | ||
| 12 | |||
| 13 | @Test func bareHashIsAnIssueInThisRepo() { | ||
| 14 | #expect(refs("see #12 please") == [ | ||
| 15 | BodyReference(range: 4..<7, destination: .issue(repo: "krz/gitbay", number: 12)), | ||
| 16 | ]) | ||
| 17 | } | ||
| 18 | |||
| 19 | @Test func bangIsAMergeRequest() { | ||
| 20 | #expect(refs("!3") == [ | ||
| 21 | BodyReference(range: 0..<2, destination: .mr(repo: "krz/gitbay", number: 3)), | ||
| 22 | ]) | ||
| 23 | } | ||
| 24 | |||
| 25 | @Test func crossRepoReferenceNamesItsRepo() { | ||
| 26 | #expect(refs("fixed in krz/solar#7 and krz/solar!8.") == [ | ||
| 27 | BodyReference(range: 9..<20, destination: .issue(repo: "krz/solar", number: 7)), | ||
| 28 | BodyReference(range: 25..<36, destination: .mr(repo: "krz/solar", number: 8)), | ||
| 29 | ]) | ||
| 30 | } | ||
| 31 | |||
| 32 | @Test func aBareReferenceNeedsABoundaryBefore() { | ||
| 33 | #expect(refs("a1b2#3").isEmpty) | ||
| 34 | #expect(refs("(#4)").map(\.range) == [1..<3]) | ||
| 35 | #expect(refs("[#5]").map(\.range) == [1..<3]) | ||
| 36 | #expect(refs("line\n#6").map(\.range) == [5..<7]) | ||
| 37 | } | ||
| 38 | |||
| 39 | @Test func digitsMustEndAtAWordBoundary() { | ||
| 40 | #expect(refs("#12abc").isEmpty) | ||
| 41 | #expect(refs("#12, #13").count == 2) | ||
| 42 | } | ||
| 43 | |||
| 44 | @Test func aCrossReferenceIsNotAlsoReadAsABareOne() { | ||
| 45 | #expect(refs("krz/solar#7").count == 1) | ||
| 46 | } | ||
| 47 | |||
| 48 | @Test func offsetsCountCharactersNotBytes() { | ||
| 49 | #expect(refs("é #1").map(\.range) == [2..<4]) | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test func theURLRoundTrips() throws { | ||
| 53 | for destination in [NotificationDestination.issue(repo: "krz/gitbay", number: 12), | ||
| 54 | .mr(repo: "krz/solar", number: 3)] { | ||
| 55 | let url = try #require(referenceURL(destination)) | ||
| 56 | #expect(referenceDestination(url) == destination) | ||
| 57 | } | ||
| 58 | #expect(referenceDestination(URL(string: "https://gitbay.org/krz/gitbay/issues/1")!) == nil) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | @MainActor | ||
| 63 | struct BodyReferenceMarkdownTests { | ||
| 64 | |||
| 65 | private func links(_ markdown: String) -> [String: URL] { | ||
| 66 | let attributed = MarkdownView.attributed(markdown, referenceRepo: "krz/gitbay") | ||
| 67 | var found: [String: URL] = [:] | ||
| 68 | for run in attributed.runs { | ||
| 69 | if let link = run.link { | ||
| 70 | found[String(attributed[run.range].characters)] = link | ||
| 71 | } | ||
| 72 | } | ||
| 73 | return found | ||
| 74 | } | ||
| 75 | |||
| 76 | @Test func referencesBecomeLinks() { | ||
| 77 | let found = links("closes #12, see krz/solar!3") | ||
| 78 | #expect(found["#12"] == referenceURL(.issue(repo: "krz/gitbay", number: 12))) | ||
| 79 | #expect(found["krz/solar!3"] == referenceURL(.mr(repo: "krz/solar", number: 3))) | ||
| 80 | } | ||
| 81 | |||
| 82 | @Test func codeSpansAndExistingLinksAreLeftAlone() { | ||
| 83 | let found = links("`#12` and [#13](https://example.com)") | ||
| 84 | #expect(found.count == 1) | ||
| 85 | #expect(found["#13"] == URL(string: "https://example.com")) | ||
| 86 | } | ||
| 87 | |||
| 88 | @Test func noRepoMeansNoReferenceLinks() { | ||
| 89 | #expect(MarkdownView.attributed("#12").runs.allSatisfy { $0.link == nil }) | ||
| 90 | } | ||
| 91 | } | ||