util.rs
1 use chrono::{DateTime, Utc}; 2 use sauron::prelude::*; 3 use sauron::vdom::element; 4 5 /// Decode HTML entities in text content 6 fn decode_entities(text: &str) -> String { 7 text.replace("&", "&") 8 .replace("<", "<") 9 .replace(">", ">") 10 .replace(""", "\"") 11 .replace("'", "'") 12 .replace("/", "/") 13 .replace("=", "=") 14 .replace("'", "'") 15 .replace(" ", " ") 16 } 17 18 /// Sanitize HTML and convert to Sauron virtual DOM nodes 19 pub fn parse_html_to_nodes<MSG>(text: &str) -> Vec<Node<MSG>> { 20 // Configure ammonia to allow code-related tags and links 21 let sanitized = ammonia::Builder::default() 22 .add_tags(&["code", "pre", "tt", "a"]) // Add code formatting tags and links 23 .add_tag_attributes("a", &["href"]) // Allow href attribute on links 24 .clean(text) 25 .to_string(); 26 27 // Simple HTML parser for common HN tags 28 // This is a basic implementation - for full HTML parsing you'd want html5ever 29 parse_simple_html(&sanitized) 30 } 31 32 fn parse_simple_html<MSG>(html: &str) -> Vec<Node<MSG>> { 33 let mut nodes = Vec::new(); 34 let mut current_pos = 0; 35 36 while current_pos < html.len() { 37 if let Some(tag_start) = html[current_pos..].find('<') { 38 let tag_start = current_pos + tag_start; 39 40 // Add text before tag 41 if tag_start > current_pos { 42 let text_content = &html[current_pos..tag_start]; 43 if !text_content.trim().is_empty() { 44 nodes.push(text(decode_entities(text_content))); 45 } 46 } 47 48 // Find tag end 49 if let Some(tag_end_pos) = html[tag_start..].find('>') { 50 let tag_end = tag_start + tag_end_pos + 1; 51 let tag_content = &html[tag_start..tag_end]; 52 53 if tag_content.starts_with("<p>") || tag_content.starts_with("<p ") { 54 // Handle paragraph - find closing tag 55 if let Some(close_pos) = html[tag_end..].find("</p>") { 56 let p_content = &html[tag_end..tag_end + close_pos]; 57 nodes.push(element("p", [], parse_simple_html(p_content))); 58 current_pos = tag_end + close_pos + 4; // Skip "</p>" 59 } else { 60 // No closing tag, treat as line break 61 nodes.push(element("br", [], [])); 62 current_pos = tag_end; 63 } 64 } else if tag_content.starts_with("<i>") { 65 if let Some(close_pos) = html[tag_end..].find("</i>") { 66 let i_content = &html[tag_end..tag_end + close_pos]; 67 nodes.push(element("i", [], parse_simple_html(i_content))); 68 current_pos = tag_end + close_pos + 4; // Skip "</i>" 69 } else { 70 current_pos = tag_end; 71 } 72 } else if tag_content.starts_with("<b>") { 73 if let Some(close_pos) = html[tag_end..].find("</b>") { 74 let b_content = &html[tag_end..tag_end + close_pos]; 75 nodes.push(element("b", [], parse_simple_html(b_content))); 76 current_pos = tag_end + close_pos + 4; // Skip "</b>" 77 } else { 78 current_pos = tag_end; 79 } 80 } else if tag_content.starts_with("<br") { 81 nodes.push(element("br", [], [])); 82 current_pos = tag_end; 83 } else if tag_content.starts_with("<code>") { 84 if let Some(close_pos) = html[tag_end..].find("</code>") { 85 let code_content = &html[tag_end..tag_end + close_pos]; 86 // Don't parse code content as HTML - treat as literal text 87 nodes.push(element("code", [], [text(decode_entities(code_content))])); 88 current_pos = tag_end + close_pos + 7; // Skip "</code>" 89 } else { 90 current_pos = tag_end; 91 } 92 } else if tag_content.starts_with("<pre>") { 93 // For <pre>, we need to find the matching </pre> while ignoring any tags inside 94 let mut pre_end = tag_end; 95 let mut pre_depth = 1; 96 97 while pre_depth > 0 && pre_end < html.len() { 98 if let Some(next_tag) = html[pre_end..].find('<') { 99 pre_end += next_tag; 100 if html[pre_end..].starts_with("</pre>") { 101 pre_depth -= 1; 102 if pre_depth == 0 { 103 let pre_content = &html[tag_end..pre_end]; 104 // Parse the content inside <pre> as HTML to handle nested <code> tags 105 nodes.push(element("pre", [], parse_simple_html(pre_content))); 106 current_pos = pre_end + 6; // Skip "</pre>" 107 break; 108 } 109 } else if html[pre_end..].starts_with("<pre>") { 110 pre_depth += 1; 111 } 112 pre_end += 1; 113 } else { 114 // No more tags found, parse rest as content with potential HTML 115 let pre_content = &html[tag_end..]; 116 nodes.push(element("pre", [], parse_simple_html(pre_content))); 117 current_pos = html.len(); 118 break; 119 } 120 } 121 122 if pre_depth > 0 { 123 // Unclosed pre tag, skip it 124 current_pos = tag_end; 125 } 126 } else if tag_content.starts_with("<tt>") { 127 if let Some(close_pos) = html[tag_end..].find("</tt>") { 128 let tt_content = &html[tag_end..tag_end + close_pos]; 129 // Don't parse tt content as HTML - treat as literal text 130 nodes.push(element("tt", [], [text(decode_entities(tt_content))])); 131 current_pos = tag_end + close_pos + 5; // Skip "</tt>" 132 } else { 133 current_pos = tag_end; 134 } 135 } else if tag_content.starts_with("<a ") || tag_content.starts_with("<a>") { 136 if let Some(close_pos) = html[tag_end..].find("</a>") { 137 let link_content = &html[tag_end..tag_end + close_pos]; 138 139 // Extract href attribute if present 140 let href = if tag_content.contains("href=") { 141 // Simple href extraction - find href="..." or href='...' 142 if let Some(href_start) = tag_content.find("href=\"") { 143 let href_content_start = href_start + 6; // Skip 'href="' 144 if let Some(href_end) = tag_content[href_content_start..].find('"') { 145 Some(tag_content[href_content_start..href_content_start + href_end].to_string()) 146 } else { None } 147 } else if let Some(href_start) = tag_content.find("href='") { 148 let href_content_start = href_start + 6; // Skip "href='" 149 if let Some(href_end) = tag_content[href_content_start..].find('\'') { 150 Some(tag_content[href_content_start..href_content_start + href_end].to_string()) 151 } else { None } 152 } else { None } 153 } else { None }; 154 155 // Create link element with href attribute 156 if let Some(href_value) = href { 157 use sauron::prelude::*; 158 nodes.push(element("a", [ 159 attr("href", href_value), 160 attr("target", "_blank"), 161 attr("rel", "noopener noreferrer") 162 ], parse_simple_html(link_content))); 163 } else { 164 // No href, just render as span 165 nodes.push(element("span", [], parse_simple_html(link_content))); 166 } 167 current_pos = tag_end + close_pos + 4; // Skip "</a>" 168 } else { 169 current_pos = tag_end; 170 } 171 } else { 172 // Skip unknown tags 173 current_pos = tag_end; 174 } 175 } else { 176 // Malformed tag, treat as text 177 nodes.push(text(decode_entities(&html[current_pos..]))); 178 break; 179 } 180 } else { 181 // No more tags, add remaining text 182 let remaining = &html[current_pos..]; 183 if !remaining.trim().is_empty() { 184 nodes.push(text(decode_entities(remaining))); 185 } 186 break; 187 } 188 } 189 190 nodes 191 } 192 193 /// Return the time ago for a date 194 pub fn time_ago(date: DateTime<Utc>) -> String { 195 let now = Utc::now(); 196 197 const SECONDS_IN_MINUTE: f32 = 60.0; 198 const SECONDS_IN_HOUR: f32 = SECONDS_IN_MINUTE * 60.0; 199 const SECONDS_IN_DAY: f32 = SECONDS_IN_HOUR * 24.0; 200 const SECONDS_IN_YEAR: f32 = SECONDS_IN_DAY * 365.0; // Ignore leap years for now 201 202 let seconds = (now - date).num_seconds() as f32; 203 if seconds < SECONDS_IN_MINUTE { 204 let seconds = seconds.floor() as i32; 205 if seconds < 2 { 206 format!("{} second", seconds) 207 } else { 208 format!("{} seconds", seconds) 209 } 210 } else if seconds < SECONDS_IN_HOUR { 211 let minutes = (seconds / SECONDS_IN_MINUTE).floor() as i32; 212 if minutes < 2 { 213 format!("{} minute", minutes) 214 } else { 215 format!("{} minutes", minutes) 216 } 217 } else if seconds < SECONDS_IN_DAY { 218 let hours = (seconds / SECONDS_IN_HOUR).floor() as i32; 219 if hours < 2 { 220 format!("{} hour", hours) 221 } else { 222 format!("{} hours", hours) 223 } 224 } else if seconds < SECONDS_IN_YEAR { 225 let days = (seconds / SECONDS_IN_DAY).floor() as i32; 226 if days < 2 { 227 format!("{} day", days) 228 } else { 229 format!("{} days", days) 230 } 231 } else { 232 let years = (seconds / SECONDS_IN_YEAR).floor() as i32; 233 if years < 2 { 234 format!("{} year", years) 235 } else { 236 format!("{} years", years) 237 } 238 } 239 }