i18n+filtering fork - fluent-templates v2
at main 7.5 kB view raw
1use crate::{ 2 atproto::lexicon::{ 3 community::lexicon::calendar::event::NSID, 4 events::smokesignal::calendar::event::NSID as LegacyNSID, 5 }, 6 http::errors::UrlError, 7}; 8use regex::Regex; 9use std::sync::LazyLock; 10 11pub type QueryParam<'a> = (&'a str, &'a str); 12pub type QueryParams<'a> = Vec<QueryParam<'a>>; 13 14pub fn stringify(query: QueryParams) -> String { 15 query.iter().fold(String::new(), |acc, &tuple| { 16 acc + tuple.0 + "=" + tuple.1 + "&" 17 }) 18} 19 20pub struct URLBuilder { 21 host: String, 22 path: String, 23 params: Vec<(String, String)>, 24} 25 26pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String { 27 let mut url_builder = URLBuilder::new(host); 28 url_builder.path(path); 29 30 for (key, value) in params.iter().filter_map(|x| *x) { 31 url_builder.param(key, value); 32 } 33 34 url_builder.build() 35} 36 37impl URLBuilder { 38 pub fn new(host: &str) -> URLBuilder { 39 let host = if host.starts_with("https://") { 40 host.to_string() 41 } else { 42 format!("https://{}", host) 43 }; 44 45 let host = if let Some(trimmed) = host.strip_suffix('/') { 46 trimmed.to_string() 47 } else { 48 host 49 }; 50 51 URLBuilder { 52 host: host.to_string(), 53 params: vec![], 54 path: "/".to_string(), 55 } 56 } 57 58 pub fn param(&mut self, key: &str, value: &str) -> &mut Self { 59 self.params 60 .push((key.to_owned(), urlencoding::encode(value).to_string())); 61 self 62 } 63 64 pub fn path(&mut self, path: &str) -> &mut Self { 65 path.clone_into(&mut self.path); 66 self 67 } 68 69 pub fn build(self) -> String { 70 let mut url_params = String::new(); 71 72 if !self.params.is_empty() { 73 url_params.push('?'); 74 75 let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect(); 76 url_params.push_str(stringify(qs_args).as_str()); 77 } 78 79 format!("{}{}{}", self.host, self.path, url_params) 80 } 81} 82 83pub fn url_from_aturi(external_base: &str, aturi: &str) -> Result<String, UrlError> { 84 let aturi = aturi.strip_prefix("at://").unwrap_or(aturi); 85 let parts = aturi.split("/").collect::<Vec<_>>(); 86 if parts.len() == 3 && parts[1] == NSID { 87 let path = format!("/{}/{}", parts[0], parts[2]); 88 return Ok(build_url(external_base, &path, vec![])); 89 } 90 if parts.len() == 3 && parts[1] == LegacyNSID { 91 let path = format!("/{}/{}", parts[0], parts[2]); 92 return Ok(build_url(external_base, &path, vec![])); 93 } 94 Err(UrlError::UnsupportedCollection) 95} 96 97fn find_char_bytes_len(ch: &char) -> i32 { 98 let mut b = [0; 4]; 99 ch.encode_utf8(&mut b); 100 let mut clen = 0; 101 for a in b.iter() { 102 clen += match a { 103 0 => 0, 104 _ => 1, 105 } 106 } 107 clen 108} 109 110pub fn truncate_text(text: &str, tlen: usize, suffix: Option<String>) -> String { 111 if text.len() <= tlen { 112 return text.to_string(); 113 } 114 115 let c = text.chars().nth(tlen); 116 let ret = match c { 117 Some(s) => match char::is_whitespace(s) { 118 true => text.split_at(tlen).0, 119 false => { 120 let chars: Vec<_> = text.chars().collect(); 121 let truncated = chars.split_at(tlen); 122 let mut first_len = 0; 123 for ch in truncated.0.iter() { 124 first_len += find_char_bytes_len(ch); 125 } 126 127 let mut prev_ws = first_len - 1; 128 for ch in truncated.0.iter().rev() { 129 if char::is_whitespace(*ch) { 130 break; 131 } 132 prev_ws -= find_char_bytes_len(ch); 133 } 134 135 let mut next_ws = first_len + 1; 136 for ch in truncated.1.iter() { 137 let mut b = [0; 4]; 138 ch.encode_utf8(&mut b); 139 if char::is_whitespace(*ch) { 140 break; 141 } 142 next_ws += find_char_bytes_len(ch); 143 } 144 145 match next_ws > prev_ws && prev_ws > 0 { 146 true => text.split_at(prev_ws as usize).0, 147 false => text.split_at(next_ws as usize).1, 148 } 149 } 150 }, 151 None => text, 152 }; 153 154 if ret.len() < text.len() { 155 if let Some(suffix) = suffix { 156 return format!("{} {}", ret, suffix.clone()); 157 } 158 } 159 ret.to_string() 160} 161 162/// Convert a handle to a URL-safe slug format 163/// 164/// This function takes a handle (which may be in various formats like `example.com`, 165/// `@example.com`, `did:web:example.com`, or `did:plc:abc123`) and converts it to 166/// a URL-safe slug that can be used in URL paths. 167/// 168/// # Arguments 169/// * `handle` - The handle to convert to a slug 170/// 171/// # Returns 172/// * A URL-safe slug string 173pub fn slug_from_handle(handle: &str) -> String { 174 // Strip common prefixes to get the core handle 175 let trimmed = if let Some(value) = handle.strip_prefix("at://") { 176 value 177 } else if let Some(value) = handle.strip_prefix('@') { 178 value 179 } else { 180 handle 181 }; 182 183 // For DID formats, we need to handle them specially 184 if let Some(web_handle) = trimmed.strip_prefix("did:web:") { 185 // For did:web: format, use the domain part 186 web_handle.to_string() 187 } else if trimmed.starts_with("did:plc:") { 188 // For did:plc: format, use the full DID as the slug 189 trimmed.to_string() 190 } else { 191 // For regular handles, use as-is (they're already domain-like) 192 trimmed.to_string() 193 } 194} 195 196/// Regular expression for matching URLs 197static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| { 198 Regex::new(r"https?://[^\s<>\[\]{}|\\^`]+").expect("Failed to compile URL regex") 199}); 200 201/// Convert URLs in text to clickable HTML links while escaping other HTML content 202/// 203/// This function finds URLs in the input text and converts them to clickable anchor tags 204/// while properly escaping any other HTML content to prevent XSS attacks. 205/// 206/// # Arguments 207/// * `text` - The input text that may contain URLs 208/// 209/// # Returns 210/// * A string with URLs converted to HTML anchor tags and other content HTML-escaped 211pub fn convert_urls_to_links(text: &str) -> String { 212 tracing::debug!("convert_urls_to_links called with text: {}", text); 213 214 let mut result = String::new(); 215 let mut last_end = 0; 216 217 for url_match in URL_REGEX.find_iter(text) { 218 // Add the text before this URL (HTML escaped) 219 let before_url = &text[last_end..url_match.start()]; 220 result.push_str(&html_escape::encode_text(before_url)); 221 222 // Add the URL as a clickable link 223 let url = url_match.as_str(); 224 let href = if url.starts_with("http://") || url.starts_with("https://") { 225 url.to_string() 226 } else { 227 format!("https://{}", url) 228 }; 229 230 result.push_str(&format!( 231 r#"<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>"#, 232 html_escape::encode_quoted_attribute(&href), 233 html_escape::encode_text(url) 234 )); 235 236 last_end = url_match.end(); 237 } 238 239 // Add any remaining text after the last URL (HTML escaped) 240 let remaining = &text[last_end..]; 241 result.push_str(&html_escape::encode_text(remaining)); 242 243 tracing::debug!("convert_urls_to_links result: {}", result); 244 result 245}