import Foundation // MARK: - HTML Escaping func escapeHTML(_ text: String) -> String { text.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } // MARK: - URL Sanitization func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https", "mailto"], allowsFragmentOnly: true ) } func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { sanitizeReadmeURLString( rawURL, allowedSchemes: ["http", "https"], allowsFragmentOnly: false ) } private func sanitizeReadmeURLString( _ rawURL: String, allowedSchemes: Set, allowsFragmentOnly: Bool ) -> String? { let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedURL.isEmpty else { return nil } if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { return escapeHTMLAttribute(trimmedURL) } if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() { guard allowedSchemes.contains(scheme), let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else { return nil } return escapeHTMLAttribute(sanitizedURL) } // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a // resolver turn these into absolute URLs before they reach here. Reject only the // protocol-relative `//host` form, which points off-origin. guard !trimmedURL.hasPrefix("//") else { return nil } return escapeHTMLAttribute(trimmedURL) } // MARK: - Regex Helpers func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? { guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else { return nil } return ( keyword: String(match.1).lowercased(), value: String(match.2).trimmingCharacters(in: .whitespaces) ) } func isOrgHorizontalRule(_ line: String) -> Bool { matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) } func matchesRegex(_ text: String, pattern: String) -> Bool { guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } let range = NSRange(location: 0, length: (text as NSString).length) return regex.firstMatch(in: text, range: range) != nil }