Sources/OrgSwift/Escaping.swift
82 lines · 2738 bytes
1import Foundation
2
3// MARK: - HTML Escaping
4
5func escapeHTML(_ text: String) -> String {
6 text.replacingOccurrences(of: "&", with: "&")
7 .replacingOccurrences(of: "<", with: "<")
8 .replacingOccurrences(of: ">", with: ">")
9 .replacingOccurrences(of: "\"", with: """)
10}
11
12func escapeHTMLAttribute(_ text: String) -> String {
13 escapeHTML(text).replacingOccurrences(of: "'", with: "'")
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
34private func sanitizeReadmeURLString(
35 _ rawURL: String,
36 allowedSchemes: Set<String>,
37 allowsFragmentOnly: Bool
38) -> String? {
39 let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
40 guard !trimmedURL.isEmpty else { return nil }
41
42 if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
43 return escapeHTMLAttribute(trimmedURL)
44 }
45
46 if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() {
47 guard allowedSchemes.contains(scheme),
48 let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else {
49 return nil
50 }
51 return escapeHTMLAttribute(sanitizedURL)
52 }
53
54 // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a
55 // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a
56 // resolver turn these into absolute URLs before they reach here. Reject only the
57 // protocol-relative `//host` form, which points off-origin.
58 guard !trimmedURL.hasPrefix("//") else { return nil }
59 return escapeHTMLAttribute(trimmedURL)
60}
61
62// MARK: - Regex Helpers
63
64func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
65 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
66 return nil
67 }
68 return (
69 keyword: String(match.1).lowercased(),
70 value: String(match.2).trimmingCharacters(in: .whitespaces)
71 )
72}
73
74func isOrgHorizontalRule(_ line: String) -> Bool {
75 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
76}
77
78func matchesRegex(_ text: String, pattern: String) -> Bool {
79 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
80 let range = NSRange(location: 0, length: (text as NSString).length)
81 return regex.firstMatch(in: text, range: range) != nil
82}