krz/org-swift

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

Sources/OrgSwift/Escaping.swift

v0.9.0
org-swift/Sources/OrgSwift/Escaping.swift history · blame · raw

85 lines · 2921 bytes

8 symbols in this file
 1import Foundation
 2
 3// MARK: - HTML Escaping
 4
 5func escapeHTML(_ text: String) -> String {
 6    text.replacingOccurrences(of: "&", with: "&")
 7        .replacingOccurrences(of: "<", with: "&lt;")
 8        .replacingOccurrences(of: ">", with: "&gt;")
 9        .replacingOccurrences(of: "\"", with: "&quot;")
10}
11
12func escapeHTMLAttribute(_ text: String) -> String {
13    escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
14}
15
16// MARK: - URL Sanitization
17
18func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
19    sanitizeReadmeURLString(
20        rawURL,
21        allowedSchemes: ["http", "https", "mailto"],
22        allowsFragmentOnly: true
23    )
24}
25
26func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
27    sanitizeReadmeURLString(
28        rawURL,
29        allowedSchemes: ["http", "https"],
30        allowsFragmentOnly: false
31    )
32}
33
34/// Returns a plain URL string, not an HTML attribute value — a caller building markup
35/// escapes at the point it interpolates. Escaping here would corrupt the URL for every
36/// other caller: `URL(string:)` on an `&amp;` gets the wrong query.
37private func sanitizeReadmeURLString(
38    _ rawURL: String,
39    allowedSchemes: Set<String>,
40    allowsFragmentOnly: Bool
41) -> String? {
42    let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
43    guard !trimmedURL.isEmpty else { return nil }
44
45    if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
46        return trimmedURL
47    }
48
49    if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() {
50        guard allowedSchemes.contains(scheme),
51              let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else {
52            return nil
53        }
54        return sanitizedURL
55    }
56
57    // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a
58    // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a
59    // resolver turn these into absolute URLs before they reach here. Reject only the
60    // protocol-relative `//host` form, which points off-origin.
61    guard !trimmedURL.hasPrefix("//") else { return nil }
62    return trimmedURL
63}
64
65// MARK: - Regex Helpers
66
67func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
68    guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
69        return nil
70    }
71    return (
72        keyword: String(match.1).lowercased(),
73        value: String(match.2).trimmingCharacters(in: .whitespaces)
74    )
75}
76
77func isOrgHorizontalRule(_ line: String) -> Bool {
78    matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
79}
80
81func matchesRegex(_ text: String, pattern: String) -> Bool {
82    guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
83    let range = NSRange(location: 0, length: (text as NSString).length)
84    return regex.firstMatch(in: text, range: range) != nil
85}