Sources/OrgSwift/Links.swift
118 lines · 4289 bytes
1import Foundation
2
3/// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same
4/// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL.
5func normalizeOrgLinkTarget(_ target: String) -> String {
6 target.hasPrefix("file:") ? String(target.dropFirst(5)) : target
7}
8
9
10private func isRenderableImageSource(_ source: String) -> Bool {
11 let lowercased = source.lowercased()
12 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
13 .contains(where: { lowercased.hasSuffix($0) })
14}
15
16/// If a whole line is just an image link with no description (`[[file:x.png]]`), return its
17/// path (with `file:` stripped). A link carrying a description is not a standalone image.
18func standaloneOrgImage(in line: String) -> String? {
19 let trimmed = line.trimmingCharacters(in: .whitespaces)
20 guard trimmed.hasPrefix("[[") && trimmed.hasSuffix("]]") else { return nil }
21 let inner = String(trimmed.dropFirst(2).dropLast(2))
22 guard !inner.contains("][") else { return nil }
23 let path = normalizeOrgLinkTarget(inner)
24 guard isRenderableImageSource(path) else { return nil }
25 return path
26}
27
28
29func resolveRepositoryLinkURL(
30 _ source: String,
31 host: String,
32 owner: String,
33 repositoryName: String,
34 ref: String,
35 readmePath: String?,
36 pathSegment: String = "blob"
37) -> String? {
38 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
39 guard !trimmedSource.isEmpty else { return nil }
40
41 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://")
42 || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") {
43 return trimmedSource
44 }
45
46 return resolveRepositoryAssetURL(
47 trimmedSource,
48 host: host,
49 owner: owner,
50 repositoryName: repositoryName,
51 ref: ref,
52 readmePath: readmePath,
53 pathSegment: pathSegment
54 )
55}
56
57func resolveRepositoryAssetURL(
58 _ source: String,
59 host: String,
60 owner: String,
61 repositoryName: String,
62 ref: String,
63 readmePath: String?,
64 pathSegment: String = "blob"
65) -> String? {
66 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
67 guard !trimmedSource.isEmpty else { return nil }
68
69 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
70 return trimmedSource
71 }
72
73 let relativePath: String
74 if trimmedSource.hasPrefix("/") {
75 relativePath = String(trimmedSource.dropFirst())
76 } else {
77 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
78 relativePath = normalizeRepositoryPath(
79 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
80 )
81 }
82
83 guard !relativePath.isEmpty else { return nil }
84 var components = URLComponents()
85 components.scheme = "https"
86 components.host = host
87 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
88 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
89 let encodedRef = ref.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? ref
90 let encodedRelativePath = relativePath
91 .split(separator: "/", omittingEmptySubsequences: false)
92 .map { segment in
93 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
94 }
95 .joined(separator: "/")
96 let encodedSegment = pathSegment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? pathSegment
97 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/\(encodedSegment)/\(encodedRef)/\(encodedRelativePath)"
98 return components.string
99}
100
101private func normalizeRepositoryPath(_ path: String) -> String {
102 var components: [String] = []
103
104 for part in path.split(separator: "/") {
105 switch part {
106 case ".":
107 continue
108 case "..":
109 if !components.isEmpty {
110 components.removeLast()
111 }
112 default:
113 components.append(String(part))
114 }
115 }
116
117 return components.joined(separator: "/")
118}