Sources/OrgSwift/Escaping.swift
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: "<")
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
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 `&` 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}