1pub fn convert_text_to_hashiverse_html(text: &str) -> String {
21 let escaped = html_escape(text);
22 let chars: Vec<char> = escaped.chars().collect();
23 let len = chars.len();
24 let mut output = String::with_capacity(escaped.len() * 2);
25 let mut i = 0;
26
27 while i < len {
28 match chars[i] {
29 '#' => {
30 let start = i + 1;
31 let mut end = start;
32 while end < len && chars[end].is_alphanumeric() {
33 end += 1;
34 }
35 if end > start {
36 let hashtag_text: String = chars[start..end].iter().collect();
37 output.push_str(&convert_text_to_hashiverse_html_x_hashtag(&hashtag_text));
38 i = end;
39 } else {
40 output.push('#');
41 i += 1;
42 }
43 }
44 '@' => {
45 let start = i + 1;
46 let mut end = start;
47 while end < len && end - start < 64 && is_hex_char(chars[end]) {
48 end += 1;
49 }
50 let hex_len = end - start;
51 if hex_len == 64 && (end >= len || !is_hex_char(chars[end])) {
54 let hex_string: String = chars[start..end].iter().collect();
55 output.push_str(&convert_text_to_hashiverse_html_x_mention(&hex_string));
56 i = end;
57 } else {
58 output.push('@');
59 i += 1;
60 }
61 }
62 '\n' => {
63 output.push_str("<br>");
64 i += 1;
65 }
66 '\r' => {
67 i += 1;
69 }
70 c => {
71 output.push(c);
72 i += 1;
73 }
74 }
75 }
76
77 output
78}
79
80pub fn convert_text_to_hashiverse_html_x_hashtag(hashtag: &str) -> String {
90 let stripped = hashtag.strip_prefix('#').unwrap_or(hashtag);
91 if stripped.is_empty() || !stripped.chars().all(char::is_alphanumeric) {
92 return hashtag.to_string();
93 }
94 let stripped_lower = stripped.to_lowercase();
95 format!(
96 "<hashtag hashtag=\"{}\"><span class=\"plugin-hashtag-left\">#</span><span class=\"plugin-hashtag-right\">{}</span></hashtag>",
97 stripped_lower, stripped,
98 )
99}
100
101pub fn convert_text_to_hashiverse_html_x_mention(client_id: &str) -> String {
104 format!("<mention client_id=\"{}\"></mention>", client_id)
105}
106
107pub fn convert_text_to_hashiverse_html_x_url_preview(
119 title: &str,
120 description: &str,
121 image_url: &str,
122 url: &str,
123) -> String {
124 let domain = extract_host_or_url(url);
125 let mut out = String::with_capacity(512);
126 out.push_str("<div class=\"plugin-urlpreview-card\">");
127 if !image_url.is_empty() {
128 out.push_str("<div class=\"plugin-urlpreview-card-image-container\">");
129 out.push_str(&format!(
130 "<a class=\"plugin-urlpreview-card-image-link\" href=\"{}\" rel=\"noopener noreferrer nofollow\"><img src=\"{}\" alt=\"\" class=\"plugin-urlpreview-card-image unblur-image\"></a>",
131 html_escape(url),
132 html_escape(image_url),
133 ));
134 out.push_str(&format!(
135 "<div class=\"plugin-urlpreview-card-domain\">{}</div>",
136 html_escape(domain),
137 ));
138 out.push_str("</div>");
139 }
140 out.push_str("<div class=\"plugin-urlpreview-card-inner\">");
141 if image_url.is_empty() {
142 out.push_str(&format!(
143 "<div class=\"plugin-urlpreview-card-domain\">{}</div>",
144 html_escape(domain),
145 ));
146 }
147 out.push_str(&format!(
148 "<a class=\"plugin-urlpreview-card-title\" href=\"{}\" rel=\"noopener noreferrer nofollow\">{}</a>",
149 html_escape(url),
150 html_escape(title),
151 ));
152 if !description.is_empty() {
153 out.push_str(&format!(
154 "<div class=\"plugin-urlpreview-card-description\">{}</div>",
155 html_escape(description),
156 ));
157 }
158 out.push_str("</div>");
159 out.push_str("</div>");
160 out
161}
162
163fn extract_host_or_url(url: &str) -> &str {
164 match url.split_once("://") {
165 Some((_, after)) => after
166 .split(['/', '?', '#'])
167 .next()
168 .filter(|s| !s.is_empty())
169 .unwrap_or(url),
170 None => url,
171 }
172}
173
174fn html_escape(text: &str) -> String {
175 let mut escaped = String::with_capacity(11 * text.len() / 10);
177 for c in text.chars() {
178 match c {
179 '&' => escaped.push_str("&"),
180 '<' => escaped.push_str("<"),
181 '>' => escaped.push_str(">"),
182 '"' => escaped.push_str("""),
183 other => escaped.push(other),
184 }
185 }
186 escaped
187}
188
189fn is_hex_char(c: char) -> bool {
190 c.is_ascii_hexdigit()
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
200 fn test_hashtag_at_start() {
201 let result = convert_text_to_hashiverse_html("#rust is great");
202 assert!(result.contains("<hashtag hashtag=\"rust\">"));
203 assert!(result.contains("<span class=\"plugin-hashtag-right\">rust</span>"));
204 assert!(result.ends_with(" is great"));
205 }
206
207 #[test]
208 fn test_hashtag_at_end() {
209 let result = convert_text_to_hashiverse_html("hello #rust");
210 assert!(result.starts_with("hello "));
211 assert!(result.contains("<hashtag hashtag=\"rust\">"));
212 }
213
214 #[test]
215 fn test_hashtag_in_middle() {
216 let result = convert_text_to_hashiverse_html("I love #rust programming");
217 assert!(result.contains("<hashtag hashtag=\"rust\">"));
218 assert!(result.contains(" programming"));
219 }
220
221 #[test]
222 fn test_multiple_hashtags() {
223 let result = convert_text_to_hashiverse_html("#rust and #golang");
224 assert!(result.contains("<hashtag hashtag=\"rust\">"));
225 assert!(result.contains("<hashtag hashtag=\"golang\">"));
226 }
227
228 #[test]
229 fn test_adjacent_hashtags() {
230 let result = convert_text_to_hashiverse_html("#rust#golang");
231 assert!(result.contains("<hashtag hashtag=\"rust\">"));
232 assert!(result.contains("<hashtag hashtag=\"golang\">"));
233 }
234
235 #[test]
236 fn test_hashtag_case_lowered_in_attribute() {
237 let result = convert_text_to_hashiverse_html("#RuStLang");
238 assert!(result.contains("hashtag=\"rustlang\""));
239 assert!(result.contains("<span class=\"plugin-hashtag-right\">RuStLang</span>"));
241 }
242
243 #[test]
244 fn test_bare_hash_no_alphanumeric() {
245 assert_eq!(convert_text_to_hashiverse_html("# alone"), "# alone");
246 }
247
248 #[test]
249 fn test_hash_at_end_of_string() {
250 assert_eq!(convert_text_to_hashiverse_html("test #"), "test #");
251 }
252
253 #[test]
254 fn test_unicode_hashtag() {
255 let result = convert_text_to_hashiverse_html("#日本語");
256 assert!(result.contains("hashtag=\"日本語\""));
257 assert!(result.contains("<span class=\"plugin-hashtag-right\">日本語</span>"));
258 }
259
260 #[test]
261 fn test_hashtag_with_numbers() {
262 let result = convert_text_to_hashiverse_html("#web3");
263 assert!(result.contains("hashtag=\"web3\""));
264 }
265
266 #[test]
267 fn test_hashtag_terminated_by_punctuation() {
268 let result = convert_text_to_hashiverse_html("#rust, nice");
269 assert!(result.contains("<hashtag hashtag=\"rust\">"));
270 assert!(result.contains("</hashtag>, nice"));
271 }
272
273 #[test]
276 fn test_valid_mention() {
277 let hex_id = "a".repeat(64);
278 let input = format!("hello @{} world", hex_id);
279 let result = convert_text_to_hashiverse_html(&input);
280 assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
281 assert!(result.starts_with("hello "));
282 assert!(result.ends_with(" world"));
283 }
284
285 #[test]
286 fn test_mention_mixed_case_hex() {
287 let hex_id = "aAbBcCdDeEfF0011223344556677889900112233445566778899aAbBcCdDeEfF";
288 assert_eq!(hex_id.len(), 64);
289 let input = format!("@{}", hex_id);
290 let result = convert_text_to_hashiverse_html(&input);
291 assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
292 }
293
294 #[test]
295 fn test_mention_too_short() {
296 let result = convert_text_to_hashiverse_html("@abcdef");
297 assert_eq!(result, "@abcdef");
298 assert!(!result.contains("<mention"));
299 }
300
301 #[test]
302 fn test_mention_non_hex_after_at() {
303 let result = convert_text_to_hashiverse_html("@hello");
304 assert_eq!(result, "@hello");
305 }
306
307 #[test]
308 fn test_bare_at() {
309 assert_eq!(convert_text_to_hashiverse_html("@"), "@");
310 }
311
312 #[test]
313 fn test_at_end_of_string() {
314 assert_eq!(convert_text_to_hashiverse_html("test @"), "test @");
315 }
316
317 #[test]
318 fn test_mention_65_hex_chars_not_matched() {
319 let hex_65 = "a".repeat(65);
321 let input = format!("@{}", hex_65);
322 let result = convert_text_to_hashiverse_html(&input);
323 assert!(!result.contains("<mention"));
324 }
325
326 #[test]
327 fn test_mention_64_hex_then_non_hex() {
328 let hex_id = "b".repeat(64);
329 let input = format!("@{}xyz", hex_id);
330 let result = convert_text_to_hashiverse_html(&input);
331 assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
332 assert!(result.ends_with("xyz"));
333 }
334
335 #[test]
338 fn test_html_injection_escaped() {
339 let result = convert_text_to_hashiverse_html("<script>alert(1)</script>");
340 assert!(result.contains("<script>"));
341 assert!(!result.contains("<script>"));
342 }
343
344 #[test]
345 fn test_ampersand_escaped() {
346 let result = convert_text_to_hashiverse_html("AT&T");
347 assert_eq!(result, "AT&T");
348 }
349
350 #[test]
351 fn test_quotes_escaped() {
352 let result = convert_text_to_hashiverse_html("he said \"hello\"");
353 assert!(result.contains("""));
354 }
355
356 #[test]
359 fn test_newline_to_br() {
360 let result = convert_text_to_hashiverse_html("line1\nline2");
361 assert_eq!(result, "line1<br>line2");
362 }
363
364 #[test]
365 fn test_crlf_to_br() {
366 let result = convert_text_to_hashiverse_html("line1\r\nline2");
367 assert_eq!(result, "line1<br>line2");
368 }
369
370 #[test]
371 fn test_bare_cr_skipped() {
372 let result = convert_text_to_hashiverse_html("line1\rline2");
373 assert_eq!(result, "line1line2");
374 }
375
376 #[test]
379 fn test_combined_post() {
380 let hex_id = "c".repeat(64);
381 let input = format!("Hello #hashiverse from @{}!\nGreat to be here.", hex_id);
382 let result = convert_text_to_hashiverse_html(&input);
383 assert!(result.contains("<hashtag hashtag=\"hashiverse\">"));
384 assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
385 assert!(result.contains("<br>"));
386 assert!(result.contains("Great to be here."));
387 }
388
389 #[test]
390 fn test_empty_string() {
391 assert_eq!(convert_text_to_hashiverse_html(""), "");
392 }
393
394 #[test]
395 fn test_plain_text_no_specials() {
396 assert_eq!(convert_text_to_hashiverse_html("just a normal post"), "just a normal post");
397 }
398
399 #[test]
402 fn test_round_trip_hashtag_extraction() {
403 let result = convert_text_to_hashiverse_html("I love #Rust and #golang");
404 let html = scraper::Html::parse_fragment(&result);
405 let selector = scraper::Selector::parse("hashtag").unwrap();
406 let hashtags: Vec<&str> = html.select(&selector)
407 .filter_map(|el| el.attr("hashtag"))
408 .collect();
409 assert_eq!(hashtags, vec!["rust", "golang"]);
410 }
411
412 #[test]
413 fn test_round_trip_mention_extraction() {
414 let hex_id = "d".repeat(64);
415 let result = convert_text_to_hashiverse_html(&format!("hello @{}", hex_id));
416 let html = scraper::Html::parse_fragment(&result);
417 let selector = scraper::Selector::parse("mention").unwrap();
418 let client_ids: Vec<&str> = html.select(&selector)
419 .filter_map(|el| el.attr("client_id"))
420 .collect();
421 assert_eq!(client_ids, vec![hex_id.as_str()]);
422 }
423
424 #[test]
425 fn test_round_trip_combined() {
426 let hex_id = "e".repeat(64);
427 let input = format!("#hashiverse post by @{} about #Rust", hex_id);
428 let result = convert_text_to_hashiverse_html(&input);
429 let html = scraper::Html::parse_fragment(&result);
430
431 let hashtag_selector = scraper::Selector::parse("hashtag").unwrap();
432 let hashtags: Vec<&str> = html.select(&hashtag_selector)
433 .filter_map(|el| el.attr("hashtag"))
434 .collect();
435 assert_eq!(hashtags, vec!["hashiverse", "rust"]);
436
437 let mention_selector = scraper::Selector::parse("mention").unwrap();
438 let client_ids: Vec<&str> = html.select(&mention_selector)
439 .filter_map(|el| el.attr("client_id"))
440 .collect();
441 assert_eq!(client_ids, vec![hex_id.as_str()]);
442 }
443
444 #[test]
447 fn test_x_hashtag_lowercases_attribute_preserves_span_text() {
448 let result = convert_text_to_hashiverse_html_x_hashtag("RuStLang");
449 assert!(result.contains("hashtag=\"rustlang\""));
450 assert!(result.contains("<span class=\"plugin-hashtag-right\">RuStLang</span>"));
451 assert!(result.contains("<span class=\"plugin-hashtag-left\">#</span>"));
452 }
453
454 #[test]
455 fn test_x_hashtag_handles_unicode() {
456 let result = convert_text_to_hashiverse_html_x_hashtag("日本語");
457 assert!(result.contains("hashtag=\"日本語\""));
458 assert!(result.contains("<span class=\"plugin-hashtag-right\">日本語</span>"));
459 }
460
461 #[test]
462 fn test_x_hashtag_non_alphanumeric_returns_input_untouched() {
463 for bad in ["a<b", "a\"b", "a b", "a&b", "##rust", "#a<b"] {
467 assert_eq!(convert_text_to_hashiverse_html_x_hashtag(bad), bad);
468 }
469 }
470
471 #[test]
472 fn test_x_hashtag_empty_returns_empty() {
473 assert_eq!(convert_text_to_hashiverse_html_x_hashtag(""), "");
474 }
475
476 #[test]
477 fn test_x_hashtag_lone_hash_returns_lone_hash() {
478 assert_eq!(convert_text_to_hashiverse_html_x_hashtag("#"), "#");
480 }
481
482 #[test]
483 fn test_x_hashtag_strips_leading_hash_if_provided() {
484 let with_hash = convert_text_to_hashiverse_html_x_hashtag("#rust");
485 let without_hash = convert_text_to_hashiverse_html_x_hashtag("rust");
486 assert_eq!(with_hash, without_hash);
487 assert!(with_hash.contains("hashtag=\"rust\""));
488 assert!(with_hash.contains("<span class=\"plugin-hashtag-right\">rust</span>"));
489 }
490
491 #[test]
492 fn test_x_hashtag_no_op_does_not_emit_hashtag_element() {
493 for bad in ["a<b", "a\"b", "a b", "", "##rust", "#a<b"] {
496 let result = convert_text_to_hashiverse_html_x_hashtag(bad);
497 assert!(!result.contains("<hashtag"), "unexpected element for {bad:?}: {result:?}");
498 assert!(!result.contains("plugin-hashtag-left"), "unexpected span for {bad:?}: {result:?}");
499 }
500 }
501
502 #[test]
503 fn test_x_mention_emits_64hex_client_id() {
504 let hex_id = "f".repeat(64);
505 let result = convert_text_to_hashiverse_html_x_mention(&hex_id);
506 assert_eq!(result, format!("<mention client_id=\"{}\"></mention>", hex_id));
507 }
508
509 #[test]
510 fn test_x_url_preview_with_image_renders_image_container() {
511 let result = convert_text_to_hashiverse_html_x_url_preview(
512 "Title",
513 "Desc",
514 "https://img.example/x.png",
515 "https://example.com/path",
516 );
517 assert!(result.starts_with("<div class=\"plugin-urlpreview-card\"><div class=\"plugin-urlpreview-card-image-container\">"));
518 assert!(result.contains(
519 "<a class=\"plugin-urlpreview-card-image-link\" href=\"https://example.com/path\" rel=\"noopener noreferrer nofollow\"><img src=\"https://img.example/x.png\" alt=\"\" class=\"plugin-urlpreview-card-image unblur-image\"></a>"
520 ));
521 assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">example.com</div>"));
522 assert!(result.contains("<a class=\"plugin-urlpreview-card-title\" href=\"https://example.com/path\" rel=\"noopener noreferrer nofollow\">Title</a>"));
523 assert!(result.contains("<div class=\"plugin-urlpreview-card-description\">Desc</div>"));
524 }
525
526 #[test]
527 fn test_x_url_preview_image_link_uses_same_href_as_title() {
528 let url = "https://example.com/?a=1&b=2";
531 let result = convert_text_to_hashiverse_html_x_url_preview("Title", "", "https://img.example/x.png", url);
532 assert!(result.contains(
533 "<a class=\"plugin-urlpreview-card-image-link\" href=\"https://example.com/?a=1&b=2\" rel=\"noopener noreferrer nofollow\"><img"
534 ));
535 assert!(result.contains(
536 "<a class=\"plugin-urlpreview-card-title\" href=\"https://example.com/?a=1&b=2\" rel=\"noopener noreferrer nofollow\">Title</a>"
537 ));
538 }
539
540 #[test]
541 fn test_x_url_preview_without_image_moves_domain_into_inner() {
542 let result = convert_text_to_hashiverse_html_x_url_preview(
543 "Title",
544 "Desc",
545 "",
546 "https://example.com/path",
547 );
548 assert!(!result.contains("plugin-urlpreview-card-image-container"));
549 assert!(!result.contains("<img "));
550 let inner_pos = result.find("plugin-urlpreview-card-inner").unwrap();
552 let domain_pos = result.find("plugin-urlpreview-card-domain").unwrap();
553 let title_pos = result.find("plugin-urlpreview-card-title").unwrap();
554 assert!(inner_pos < domain_pos && domain_pos < title_pos);
555 }
556
557 #[test]
558 fn test_x_url_preview_omits_description_when_blank() {
559 let result = convert_text_to_hashiverse_html_x_url_preview(
560 "Title",
561 "",
562 "https://img.example/x.png",
563 "https://example.com/",
564 );
565 assert!(!result.contains("plugin-urlpreview-card-description"));
566 }
567
568 #[test]
569 fn test_x_url_preview_extracts_domain_from_https_url() {
570 let result = convert_text_to_hashiverse_html_x_url_preview(
571 "T", "", "", "https://sub.example.com:8443/path?q=1#frag",
572 );
573 assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">sub.example.com:8443</div>"));
574 }
575
576 #[test]
577 fn test_x_url_preview_falls_back_to_full_url_when_no_scheme() {
578 let result = convert_text_to_hashiverse_html_x_url_preview("T", "", "", "not-a-url");
579 assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">not-a-url</div>"));
580 }
581
582 #[test]
583 fn test_x_url_preview_html_escapes_attribute_and_text_values() {
584 let result = convert_text_to_hashiverse_html_x_url_preview(
585 "Title with <script> & \"quotes\"",
586 "Desc with <html> & \"chars\"",
587 "https://i/x?a=1&b=2",
588 "https://example.com/?q=1&r=2",
589 );
590 assert!(result.contains(">Title with <script> & "quotes"</a>"));
592 assert!(result.contains(">Desc with <html> & "chars"</div>"));
594 assert!(result.contains("src=\"https://i/x?a=1&b=2\""));
596 assert!(result.contains("href=\"https://example.com/?q=1&r=2\""));
597 }
598
599 #[test]
600 fn test_existing_convert_text_to_hashiverse_html_unchanged_after_refactor() {
601 let hex = "a".repeat(64);
605 let input = format!("Hi #Rust @{} bye", hex);
606 let result = convert_text_to_hashiverse_html(&input);
607 assert_eq!(
608 result,
609 format!(
610 "Hi <hashtag hashtag=\"rust\"><span class=\"plugin-hashtag-left\">#</span><span class=\"plugin-hashtag-right\">Rust</span></hashtag> <mention client_id=\"{}\"></mention> bye",
611 hex
612 )
613 );
614 }
615}