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
}