A dependency-free Swift library that renders org-mode to sanitized HTML.

html library org-mode swift

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}