1use regex::Regex;
7use std::sync::LazyLock;
8
9static TAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
11static WHITESPACE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
12static LEADING_WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+").unwrap());
13
14pub fn html_to_text(html: &str) -> String {
16 let mut text = html.to_string();
17
18 let encrypted_re =
21 Regex::new(r#"(?s)<div[^>]*class="[^"]*encrypted-content[^"]*"[^>]*>.*?</div>\s*</div>"#)
22 .unwrap();
23 text = encrypted_re
24 .replace_all(
25 &text,
26 "\n [Encrypted content - visit web version to decrypt]\n",
27 )
28 .to_string();
29
30 let decrypt_prompt_re =
32 Regex::new(r#"(?s)<div[^>]*class="[^"]*decrypt-prompt[^"]*"[^>]*>.*?</div>"#).unwrap();
33 text = decrypt_prompt_re.replace_all(&text, "").to_string();
34
35 let encrypted_msg_re =
37 Regex::new(r#"(?s)<p[^>]*class="[^"]*encrypted-message[^"]*"[^>]*>.*?</p>"#).unwrap();
38 text = encrypted_msg_re.replace_all(&text, "").to_string();
39
40 let input_re = Regex::new(r#"<input[^>]*>"#).unwrap();
42 text = input_re.replace_all(&text, "").to_string();
43
44 let button_re = Regex::new(r#"(?s)<button[^>]*>.*?</button>"#).unwrap();
45 text = button_re.replace_all(&text, "").to_string();
46
47 let label_re = Regex::new(r#"(?s)<label[^>]*>.*?</label>"#).unwrap();
48 text = label_re.replace_all(&text, "").to_string();
49
50 let mut code_blocks: Vec<String> = Vec::new();
53 let mut tables: Vec<String> = Vec::new();
54
55 let pre_code_re = Regex::new(r"(?s)<pre[^>]*>\s*<code[^>]*>(.*?)</code>\s*</pre>").unwrap();
57 text = pre_code_re
58 .replace_all(&text, |caps: ®ex::Captures| {
59 let formatted = format_code_block(&caps[1]);
60 let idx = code_blocks.len();
61 code_blocks.push(formatted);
62 format!("{{{{CODE_BLOCK_{}}}}}", idx)
63 })
64 .to_string();
65
66 let pre_only_re = Regex::new(r"(?s)<pre[^>]*>(.*?)</pre>").unwrap();
67 text = pre_only_re
68 .replace_all(&text, |caps: ®ex::Captures| {
69 let formatted = format_code_block(&caps[1]);
70 let idx = code_blocks.len();
71 code_blocks.push(formatted);
72 format!("{{{{CODE_BLOCK_{}}}}}", idx)
73 })
74 .to_string();
75
76 let table_re = Regex::new(r"(?s)<table[^>]*>(.*?)</table>").unwrap();
78 text = table_re
79 .replace_all(&text, |caps: ®ex::Captures| {
80 let formatted = format_table(&caps[1]);
81 let idx = tables.len();
82 tables.push(formatted);
83 format!("{{{{TABLE_{}}}}}", idx)
84 })
85 .to_string();
86
87 text = process_headings(&text);
89
90 text = process_blockquotes(&text);
92
93 let code_re = Regex::new(r"<code[^>]*>(.*?)</code>").unwrap();
95 text = code_re
96 .replace_all(&text, |caps: ®ex::Captures| {
97 let content = decode_html_entities(&caps[1]);
98 let content = strip_tags(&content);
99 format!("`{}`", content.trim())
100 })
101 .to_string();
102
103 text = process_lists(&text);
105
106 text = process_links(&text);
108
109 text = text.replace("<br>", "\n");
111 text = text.replace("<br/>", "\n");
112 text = text.replace("<br />", "\n");
113 text = text.replace("</p>", "\n\n");
114 text = text.replace("</div>", "\n");
115
116 let script_re = Regex::new(r"(?s)<script[^>]*>.*?</script>").unwrap();
118 let style_re = Regex::new(r"(?s)<style[^>]*>.*?</style>").unwrap();
119 text = script_re.replace_all(&text, "").to_string();
120 text = style_re.replace_all(&text, "").to_string();
121
122 text = TAG_PATTERN.replace_all(&text, "").to_string();
124
125 text = decode_html_entities(&text);
127
128 text = WHITESPACE_PATTERN.replace_all(&text, "\n\n").to_string();
130 text = LEADING_WHITESPACE.replace(&text, "").to_string();
131
132 let lines: Vec<&str> = text.lines().collect();
134 let multi_space = Regex::new(r" {2,}").expect("invalid regex");
135 let mut cleaned_lines: Vec<String> = Vec::new();
136 for line in lines {
137 let leading_ws = line.len() - line.trim_start().len();
139 let (leading, rest) = line.split_at(leading_ws);
140 let cleaned_rest = multi_space.replace_all(rest, " ");
142 cleaned_lines.push(format!("{}{}", leading, cleaned_rest));
143 }
144 text = cleaned_lines.join("\n");
145
146 for (idx, block) in code_blocks.iter().enumerate() {
148 text = text.replace(&format!("{{{{CODE_BLOCK_{}}}}}", idx), block);
149 }
150 for (idx, table) in tables.iter().enumerate() {
151 text = text.replace(&format!("{{{{TABLE_{}}}}}", idx), table);
152 }
153
154 text.trim().to_string()
155}
156
157fn process_headings(html: &str) -> String {
159 let mut text = html.to_string();
160
161 let h1_re = Regex::new(r"(?s)<h1[^>]*>(.*?)</h1>").unwrap();
163 text = h1_re
164 .replace_all(&text, |caps: ®ex::Captures| {
165 let content = strip_tags(&caps[1]);
166 let underline = "=".repeat(content.len().min(72));
167 format!("\n\n{}\n{}\n\n", content.to_uppercase(), underline)
168 })
169 .to_string();
170
171 let h2_re = Regex::new(r"(?s)<h2[^>]*>(.*?)</h2>").unwrap();
173 text = h2_re
174 .replace_all(&text, |caps: ®ex::Captures| {
175 let content = strip_tags(&caps[1]);
176 let underline = "-".repeat(content.len().min(72));
177 format!("\n\n{}\n{}\n\n", content, underline)
178 })
179 .to_string();
180
181 let h3_re = Regex::new(r"(?s)<h3[^>]*>(.*?)</h3>").unwrap();
183 text = h3_re
184 .replace_all(&text, |caps: ®ex::Captures| {
185 let content = strip_tags(&caps[1]);
186 format!("\n\n## {}\n\n", content)
187 })
188 .to_string();
189
190 for (level, marker) in [(4, "###"), (5, "####"), (6, "#####")] {
192 let re = Regex::new(&format!(r"(?s)<h{}[^>]*>(.*?)</h{}>", level, level)).unwrap();
193 text = re
194 .replace_all(&text, |caps: ®ex::Captures| {
195 let content = strip_tags(&caps[1]);
196 format!("\n\n{} {}\n\n", marker, content)
197 })
198 .to_string();
199 }
200
201 text
202}
203
204fn process_blockquotes(html: &str) -> String {
206 let mut text = html.to_string();
207
208 let blockquote_re = Regex::new(r"(?s)<blockquote[^>]*>(.*?)</blockquote>").unwrap();
209 text = blockquote_re
210 .replace_all(&text, |caps: ®ex::Captures| {
211 let content = strip_tags(&caps[1]);
212 let indented: Vec<String> = content
213 .lines()
214 .map(|line| {
215 let trimmed = line.trim();
216 if trimmed.is_empty() {
217 " |".to_string()
218 } else {
219 format!(" | {}", trimmed)
220 }
221 })
222 .collect();
223 format!("\n{}\n", indented.join("\n"))
224 })
225 .to_string();
226
227 text
228}
229
230fn format_code_block(html_content: &str) -> String {
232 let content = decode_html_entities(html_content);
233 let content = strip_tags(&content);
234
235 let lines: Vec<String> = content
237 .lines()
238 .map(|l| {
239 let expanded = l.replace('\t', " ");
240 let cleaned: String = expanded
242 .chars()
243 .map(|c| {
244 if c.is_ascii_graphic() || c == ' ' {
245 c
246 } else {
247 ' '
248 }
249 })
250 .collect();
251 cleaned.trim_end().to_string()
252 })
253 .collect();
254
255 let start = lines.iter().position(|l| !l.is_empty()).unwrap_or(0);
257 let end = lines
258 .iter()
259 .rposition(|l| !l.is_empty())
260 .map(|i| i + 1)
261 .unwrap_or(lines.len());
262 let lines = &lines[start..end];
263
264 if lines.is_empty() {
265 return String::new();
266 }
267
268 let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0).max(20);
270
271 let mut result = String::new();
273 let border_width = max_width + 2;
274
275 result.push_str(&format!("\n +{}+\n", "-".repeat(border_width)));
276 for line in lines {
277 let padding = max_width.saturating_sub(line.len());
278 result.push_str(&format!(" | {}{} |\n", line, " ".repeat(padding)));
279 }
280 result.push_str(&format!(" +{}+\n", "-".repeat(border_width)));
281 result
282}
283
284fn clean_cell(content: &str) -> String {
286 let stripped = strip_tags(content);
287 let decoded = decode_html_entities(&stripped);
288 decoded
290 .chars()
291 .map(|c| {
292 if c.is_ascii_graphic() || c == ' ' {
293 c
294 } else {
295 ' '
296 }
297 })
298 .collect::<String>()
299 .split_whitespace()
300 .collect::<Vec<_>>()
301 .join(" ")
302}
303
304fn format_table(table_html: &str) -> String {
306 let row_re = Regex::new(r"(?s)<tr[^>]*>(.*?)</tr>").unwrap();
307 let th_re = Regex::new(r"(?s)<th[^>]*>(.*?)</th>").unwrap();
308 let td_re = Regex::new(r"(?s)<td[^>]*>(.*?)</td>").unwrap();
309
310 let mut rows: Vec<Vec<String>> = Vec::new();
311 let mut header_row_idx: Option<usize> = None;
312
313 for row_cap in row_re.captures_iter(table_html) {
314 let row_content = &row_cap[1];
315 let mut cells: Vec<String> = Vec::new();
316 let mut is_header = false;
317
318 for th_cap in th_re.captures_iter(row_content) {
320 cells.push(clean_cell(&th_cap[1]));
321 is_header = true;
322 }
323
324 for td_cap in td_re.captures_iter(row_content) {
326 cells.push(clean_cell(&td_cap[1]));
327 }
328
329 if !cells.is_empty() {
330 if is_header && header_row_idx.is_none() {
331 header_row_idx = Some(rows.len());
332 }
333 rows.push(cells);
334 }
335 }
336
337 if rows.is_empty() {
338 return String::new();
339 }
340
341 let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
343 let mut col_widths: Vec<usize> = vec![0; num_cols];
344
345 for row in &rows {
346 for (i, cell) in row.iter().enumerate() {
347 if i < num_cols {
348 col_widths[i] = col_widths[i].max(cell.len());
349 }
350 }
351 }
352
353 let mut result = String::new();
355 result.push('\n');
356
357 for (row_idx, row) in rows.iter().enumerate() {
358 result.push_str(" ");
359 for (i, &width) in col_widths.iter().enumerate() {
360 let cell = row.get(i).map(|s| s.as_str()).unwrap_or("");
361 let padding = width.saturating_sub(cell.len());
362 result.push_str(cell);
363 result.push_str(&" ".repeat(padding));
364 if i < num_cols - 1 {
365 result.push_str(" | ");
366 }
367 }
368 result.push('\n');
369
370 if Some(row_idx) == header_row_idx && row_idx < rows.len() - 1 {
372 result.push_str(" ");
373 for (i, &width) in col_widths.iter().enumerate() {
374 result.push_str(&"-".repeat(width));
375 if i < num_cols - 1 {
376 result.push_str("-+-");
377 }
378 }
379 result.push('\n');
380 }
381 }
382
383 result.push('\n');
384 result
385}
386
387fn process_lists(html: &str) -> String {
389 let mut result = String::new();
390 let mut pos = 0;
391
392 while pos < html.len() {
393 if html[pos..].starts_with("<ul") || html[pos..].starts_with("<ol") {
395 let is_ordered = html[pos..].starts_with("<ol");
396 let list_text = process_list_recursive(&html[pos..], is_ordered, 0);
397 result.push('\n');
398 result.push_str(&list_text.0);
399 result.push('\n');
400 pos += list_text.1;
401 } else {
402 if let Some(c) = html[pos..].chars().next() {
404 result.push(c);
405 pos += c.len_utf8();
406 } else {
407 break;
408 }
409 }
410 }
411
412 result
413}
414
415fn process_list_recursive(html: &str, is_ordered: bool, depth: usize) -> (String, usize) {
417 let mut result = String::new();
418 let indent = " ".repeat(depth);
419 let bullet_indent = " ".repeat(depth + 1);
420
421 let tag_end = match html.find('>') {
423 Some(i) => i + 1,
424 None => return (String::new(), html.len()),
425 };
426
427 let close_tag = if is_ordered { "</ol>" } else { "</ul>" };
428 let mut pos = tag_end;
429 let mut item_num = 1;
430
431 while pos < html.len() {
432 if html[pos..].starts_with(close_tag) {
434 pos += close_tag.len();
435 break;
436 }
437
438 if html[pos..].starts_with("<li") {
440 let li_end = match html[pos..].find('>') {
441 Some(i) => pos + i + 1,
442 None => break,
443 };
444 pos = li_end;
445
446 let mut item_content = String::new();
448 let mut nested_lists: Vec<String> = Vec::new();
449
450 while pos < html.len() && !html[pos..].starts_with("</li>") {
451 if html[pos..].starts_with("<ul") {
453 let nested = process_list_recursive(&html[pos..], false, depth + 1);
454 nested_lists.push(nested.0);
455 pos += nested.1;
456 } else if html[pos..].starts_with("<ol") {
457 let nested = process_list_recursive(&html[pos..], true, depth + 1);
458 nested_lists.push(nested.0);
459 pos += nested.1;
460 } else if let Some(c) = html[pos..].chars().next() {
461 item_content.push(c);
462 pos += c.len_utf8();
463 } else {
464 break;
465 }
466 }
467
468 if html[pos..].starts_with("</li>") {
470 pos += 5;
471 }
472
473 let item_text = strip_tags(&item_content).trim().to_string();
475 if !item_text.is_empty() {
476 let wrapped = wrap_text(&item_text, 68 - (depth * 2));
477 for (j, line) in wrapped.lines().enumerate() {
478 if j == 0 {
479 if is_ordered {
480 result.push_str(&format!("{}{}. {}\n", indent, item_num, line));
481 } else {
482 result.push_str(&format!("{}- {}\n", bullet_indent, line));
483 }
484 } else {
485 let extra_indent = if is_ordered { " " } else { " " };
486 result.push_str(&format!("{}{}{}\n", bullet_indent, extra_indent, line));
487 }
488 }
489 }
490
491 for nested in nested_lists {
493 result.push_str(&nested);
494 }
495
496 item_num += 1;
497 } else if let Some(c) = html[pos..].chars().next() {
498 pos += c.len_utf8();
500 } else {
501 break;
502 }
503 }
504
505 (result, pos)
506}
507
508fn process_links(html: &str) -> String {
510 let link_re = Regex::new(r#"<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>"#).unwrap();
511 link_re
512 .replace_all(html, |caps: ®ex::Captures| {
513 let url = &caps[1];
514 let text = strip_tags(&caps[2]);
515 if url.starts_with('#') || url == text {
516 text.to_string()
517 } else {
518 format!("{} [{}]", text, url)
519 }
520 })
521 .to_string()
522}
523
524fn strip_tags(html: &str) -> String {
526 TAG_PATTERN.replace_all(html, "").to_string()
527}
528
529fn wrap_text(text: &str, max_width: usize) -> String {
531 let mut result = String::new();
532 let mut current_line = String::new();
533
534 for word in text.split_whitespace() {
535 if current_line.is_empty() {
536 current_line = word.to_string();
537 } else if current_line.len() + 1 + word.len() <= max_width {
538 current_line.push(' ');
539 current_line.push_str(word);
540 } else {
541 result.push_str(¤t_line);
542 result.push('\n');
543 current_line = word.to_string();
544 }
545 }
546
547 if !current_line.is_empty() {
548 result.push_str(¤t_line);
549 }
550
551 result
552}
553
554fn decode_html_entities(text: &str) -> String {
556 text.replace("&", "&")
557 .replace("<", "<")
558 .replace(">", ">")
559 .replace(""", "\"")
560 .replace("'", "'")
561 .replace("'", "'")
562 .replace(" ", " ")
563 .replace(
564 [
565 '\u{00A0}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', '\u{202F}', '\u{205F}',
566 '\u{3000}',
567 ],
568 " ",
569 ) .replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") .replace("·", ".")
572 .replace("•", "*")
573 .replace("—", "-")
574 .replace("–", "-")
575 .replace("…", "...")
576 .replace("©", "(c)")
577 .replace("®", "(R)")
578 .replace("™", "(TM)")
579 .replace("'", "'")
580 .replace("/", "/")
581}
582
583fn cleanup_text(text: &str) -> String {
585 let lines: Vec<&str> = text.lines().map(|line| line.trim_end()).collect();
586
587 let mut result = String::new();
589 let mut blank_count = 0;
590
591 for line in lines {
592 if line.is_empty() {
593 blank_count += 1;
594 if blank_count <= 2 {
595 result.push('\n');
596 }
597 } else {
598 blank_count = 0;
599 result.push_str(line);
600 result.push('\n');
601 }
602 }
603
604 result.trim_end().to_string()
605}
606
607fn box_line(content: &str, width: usize) -> String {
609 let content_width = width - 2; let display_len = content.chars().count();
611 let padding = content_width.saturating_sub(display_len);
612 format!("|{}{}|\n", content, " ".repeat(padding))
613}
614
615fn box_line_centered(content: &str, width: usize) -> String {
617 let content_width = width - 2;
618 let display_len = content.chars().count();
619 let total_padding = content_width.saturating_sub(display_len);
620 let left_pad = total_padding / 2;
621 let right_pad = total_padding - left_pad;
622 format!(
623 "|{}{}{}|\n",
624 " ".repeat(left_pad),
625 content,
626 " ".repeat(right_pad)
627 )
628}
629
630#[allow(clippy::too_many_arguments)]
632pub fn format_post_text(
633 title: &str,
634 date: Option<&str>,
635 description: Option<&str>,
636 tags: &[String],
637 reading_time: u32,
638 content: &str,
639 url: &str,
640 base_url: &str,
641) -> String {
642 let mut output = String::new();
643 let width = 74; output.push('+');
647 output.push_str(&"=".repeat(width - 2));
648 output.push_str("+\n");
649
650 let title_lines = wrap_text(title, width - 6);
652 for line in title_lines.lines() {
653 output.push_str(&box_line_centered(line, width));
654 }
655
656 output.push('+');
658 output.push_str(&"=".repeat(width - 2));
659 output.push_str("+\n");
660
661 if let Some(date) = date {
663 output.push_str(&box_line(&format!(" Date: {}", date), width));
664 }
665
666 if !tags.is_empty() {
667 let tags_str = tags.join(", ");
668 let wrapped = wrap_text(&tags_str, width - 12); for (i, line) in wrapped.lines().enumerate() {
671 if i == 0 {
672 output.push_str(&box_line(&format!(" Tags: {}", line), width));
673 } else {
674 output.push_str(&box_line(&format!(" {}", line), width)); }
676 }
677 }
678
679 output.push_str(&box_line(
680 &format!(" Reading time: {} min", reading_time),
681 width,
682 ));
683
684 let full_url = format!("{}{}", base_url, url);
686 let url_line = format!(" URL: {}", full_url);
687 if url_line.chars().count() <= width - 4 {
688 output.push_str(&box_line(&url_line, width));
689 } else {
690 output.push_str(&box_line(" URL:", width));
691 output.push_str(&box_line(&format!(" {}", full_url), width));
692 }
693
694 if let Some(desc) = description {
695 output.push('+');
696 output.push_str(&"-".repeat(width - 2));
697 output.push_str("+\n");
698 let wrapped = wrap_text(desc, width - 6);
699 for line in wrapped.lines() {
700 output.push_str(&box_line(&format!(" {}", line), width));
701 }
702 }
703
704 output.push('+');
706 output.push_str(&"=".repeat(width - 2));
707 output.push_str("+\n\n");
708
709 output.push_str(&html_to_text(content));
711 output.push_str("\n\n");
712
713 output.push_str(&"-".repeat(width));
715 output.push('\n');
716
717 cleanup_text(&output)
718}
719
720pub fn format_home_text(title: &str, description: &str, content: &str, base_url: &str) -> String {
722 let mut output = String::new();
723 let width = 74;
724
725 output.push('+');
727 output.push_str(&"=".repeat(width - 2));
728 output.push_str("+\n");
729
730 output.push_str(&box_line_centered(title, width));
732
733 output.push('+');
735 output.push_str(&"=".repeat(width - 2));
736 output.push_str("+\n");
737
738 let wrapped = wrap_text(description, width - 6);
740 for line in wrapped.lines() {
741 output.push_str(&box_line(&format!(" {}", line), width));
742 }
743
744 output.push_str(&box_line(&format!(" URL: {}", base_url), width));
745
746 output.push('+');
748 output.push_str(&"=".repeat(width - 2));
749 output.push_str("+\n\n");
750
751 output.push_str(&html_to_text(content));
753 output.push_str("\n\n");
754
755 output.push_str(&"-".repeat(width));
757 output.push('\n');
758
759 cleanup_text(&output)
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn test_html_to_text_basic() {
768 let html = "<p>Hello, world!</p>";
769 assert!(html_to_text(html).contains("Hello, world!"));
770 }
771
772 #[test]
773 fn test_html_to_text_with_entities() {
774 let html = "<p>Hello & goodbye — see you!</p>";
775 let text = html_to_text(html);
776 assert!(text.contains("Hello & goodbye - see you!")); }
778
779 #[test]
780 fn test_html_to_text_list() {
781 let html = "<ul><li>First</li><li>Second</li></ul>";
782 let text = html_to_text(html);
783 assert!(text.contains("- First"));
784 assert!(text.contains("- Second"));
785 }
786
787 #[test]
788 fn test_html_to_text_strips_scripts() {
789 let html = "<p>Before</p><script>alert('bad');</script><p>After</p>";
790 let text = html_to_text(html);
791 assert!(!text.contains("script"));
792 assert!(!text.contains("alert"));
793 assert!(text.contains("Before"));
794 assert!(text.contains("After"));
795 }
796
797 #[test]
798 fn test_html_to_text_headings() {
799 let html = "<h1>Main Title</h1><h2>Section</h2><h3>Subsection</h3>";
800 let text = html_to_text(html);
801 assert!(text.contains("MAIN TITLE"));
802 assert!(text.contains("="));
803 assert!(text.contains("Section"));
804 assert!(text.contains("-"));
805 assert!(text.contains("## Subsection"));
806 }
807
808 #[test]
809 fn test_html_to_text_table() {
810 let html =
811 "<table><tr><th>Name</th><th>Age</th></tr><tr><td>Alice</td><td>30</td></tr></table>";
812 let text = html_to_text(html);
813 assert!(text.contains("Name"));
814 assert!(text.contains("Age"));
815 assert!(text.contains("Alice"));
816 assert!(text.contains("30"));
817 assert!(text.contains("|")); assert!(text.contains("-")); }
820
821 #[test]
822 fn test_html_to_text_code_block() {
823 let html = "<pre><code>fn main() {\n println!(\"Hello\");\n}</code></pre>";
824 let text = html_to_text(html);
825 assert!(text.contains("fn main()"));
826 assert!(text.contains("println!"));
827 assert!(text.contains("+"));
828 }
829
830 #[test]
831 fn test_html_to_text_blockquote() {
832 let html = "<blockquote>This is a quote</blockquote>";
833 let text = html_to_text(html);
834 assert!(text.contains("|"));
835 assert!(text.contains("This is a quote"));
836 }
837
838 #[test]
839 fn test_format_post_text() {
840 let text = format_post_text(
841 "Test Post",
842 Some("2024-01-15"),
843 Some("A test description"),
844 &["rust".to_string(), "web".to_string()],
845 5,
846 "<p>Post content here.</p>",
847 "/blog/test-post/",
848 "https://example.com",
849 );
850
851 assert!(text.contains("Test Post"));
852 assert!(text.contains("2024-01-15"));
853 assert!(text.contains("rust, web"));
854 assert!(text.contains("5 min"));
855 assert!(text.contains("https://example.com/blog/test-post/"));
856 assert!(text.contains("Post content here."));
857 assert!(text.contains("+"));
858 assert!(text.contains("="));
859 }
860
861 #[test]
862 fn test_process_links() {
863 let html = r#"<a href="https://example.com">Example</a>"#;
864 let text = process_links(html);
865 assert!(text.contains("Example [https://example.com]"));
866 }
867}