Skip to main content

rs_web/
text.rs

1//! Plain text generation from HTML content
2//!
3//! Converts HTML posts to plain text format for curl-friendly output.
4//! Produces well-formatted text with proper indentation, tables, and structure.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9/// Regex patterns for HTML-to-text conversion
10static 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
14/// Convert HTML content to plain text with beautiful formatting
15pub fn html_to_text(html: &str) -> String {
16    let mut text = html.to_string();
17
18    // Replace encrypted blocks with a friendly message
19    // Match the entire encrypted-content div structure (outer div with nested decrypt-prompt div)
20    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    // Also remove decrypt-prompt divs that might remain
31    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    // Remove any remaining UI elements from encrypted blocks
36    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    // Remove password inputs and decrypt buttons
41    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    // First pass: extract code blocks and tables, replace with placeholders
51    // This protects their formatting from whitespace normalization
52    let mut code_blocks: Vec<String> = Vec::new();
53    let mut tables: Vec<String> = Vec::new();
54
55    // Extract code blocks
56    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: &regex::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: &regex::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    // Extract tables
77    let table_re = Regex::new(r"(?s)<table[^>]*>(.*?)</table>").unwrap();
78    text = table_re
79        .replace_all(&text, |caps: &regex::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    // Process headings with decorative markers
88    text = process_headings(&text);
89
90    // Process blockquotes with indentation
91    text = process_blockquotes(&text);
92
93    // Process inline code (not pre blocks)
94    let code_re = Regex::new(r"<code[^>]*>(.*?)</code>").unwrap();
95    text = code_re
96        .replace_all(&text, |caps: &regex::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    // Process lists with proper indentation
104    text = process_lists(&text);
105
106    // Process links - show URL in brackets
107    text = process_links(&text);
108
109    // Replace block elements with newlines before stripping tags
110    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    // Remove script and style content entirely
117    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    // Strip all remaining HTML tags
123    text = TAG_PATTERN.replace_all(&text, "").to_string();
124
125    // Decode common HTML entities
126    text = decode_html_entities(&text);
127
128    // Normalize whitespace (this won't affect our placeholders)
129    text = WHITESPACE_PATTERN.replace_all(&text, "\n\n").to_string();
130    text = LEADING_WHITESPACE.replace(&text, "").to_string();
131
132    // Clean up multiple spaces within lines, but preserve leading indentation
133    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        // Find where leading whitespace ends
138        let leading_ws = line.len() - line.trim_start().len();
139        let (leading, rest) = line.split_at(leading_ws);
140        // Only collapse multiple spaces in the non-leading part
141        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    // Now restore code blocks and tables with their proper formatting
147    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
157/// Process headings into visually distinct text format
158fn process_headings(html: &str) -> String {
159    let mut text = html.to_string();
160
161    // H1: Title with double underline (ASCII)
162    let h1_re = Regex::new(r"(?s)<h1[^>]*>(.*?)</h1>").unwrap();
163    text = h1_re
164        .replace_all(&text, |caps: &regex::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    // H2: Section with single underline (ASCII)
172    let h2_re = Regex::new(r"(?s)<h2[^>]*>(.*?)</h2>").unwrap();
173    text = h2_re
174        .replace_all(&text, |caps: &regex::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    // H3: With marker prefix
182    let h3_re = Regex::new(r"(?s)<h3[^>]*>(.*?)</h3>").unwrap();
183    text = h3_re
184        .replace_all(&text, |caps: &regex::Captures| {
185            let content = strip_tags(&caps[1]);
186            format!("\n\n## {}\n\n", content)
187        })
188        .to_string();
189
190    // H4-H6: With increasing marker prefix
191    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: &regex::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
204/// Process blockquotes with indentation (ASCII)
205fn 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: &regex::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
230/// Format a code block with consistent styling (ASCII)
231fn format_code_block(html_content: &str) -> String {
232    let content = decode_html_entities(html_content);
233    let content = strip_tags(&content);
234
235    // Process lines: expand tabs, normalize to ASCII, preserve indentation
236    let lines: Vec<String> = content
237        .lines()
238        .map(|l| {
239            let expanded = l.replace('\t', "    ");
240            // Convert non-ASCII to spaces, keep ASCII printable chars
241            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    // Skip empty leading/trailing lines
256    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    // Calculate max width - use len() since we're now pure ASCII
269    let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0).max(20);
270
271    // Build code block with borders (ASCII)
272    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
284/// Clean cell content - normalize to ASCII-safe text
285fn clean_cell(content: &str) -> String {
286    let stripped = strip_tags(content);
287    let decoded = decode_html_entities(&stripped);
288    // Keep only ASCII printable characters and collapse whitespace
289    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
304/// Format a single table as simple aligned text (ASCII)
305fn 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        // Check for header cells first
319        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        // Then regular cells
325        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    // Calculate column widths - use len() since cells are now ASCII
342    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    // Build the table (ASCII)
354    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        // Header separator (ASCII)
371        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
387/// Process lists with proper indentation and markers (supports nesting)
388fn process_lists(html: &str) -> String {
389    let mut result = String::new();
390    let mut pos = 0;
391
392    while pos < html.len() {
393        // Look for list start
394        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            // Copy character as-is
403            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
415/// Recursively process a list, returning (formatted_text, bytes_consumed)
416fn 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    // Find the opening tag end
422    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        // Check for list end
433        if html[pos..].starts_with(close_tag) {
434            pos += close_tag.len();
435            break;
436        }
437
438        // Check for list item start
439        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            // Collect item content until </li>, handling nested lists
447            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                // Check for nested list
452                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            // Skip </li>
469            if html[pos..].starts_with("</li>") {
470                pos += 5;
471            }
472
473            // Format the item text (strip tags, clean up)
474            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            // Add nested lists
492            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            // Skip whitespace between items
499            pos += c.len_utf8();
500        } else {
501            break;
502        }
503    }
504
505    (result, pos)
506}
507
508/// Process links to show URL
509fn process_links(html: &str) -> String {
510    let link_re = Regex::new(r#"<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>"#).unwrap();
511    link_re
512        .replace_all(html, |caps: &regex::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
524/// Strip HTML tags from a string
525fn strip_tags(html: &str) -> String {
526    TAG_PATTERN.replace_all(html, "").to_string()
527}
528
529/// Wrap text to a maximum width
530fn 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(&current_line);
542            result.push('\n');
543            current_line = word.to_string();
544        }
545    }
546
547    if !current_line.is_empty() {
548        result.push_str(&current_line);
549    }
550
551    result
552}
553
554/// Decode common HTML entities and normalize Unicode
555fn decode_html_entities(text: &str) -> String {
556    text.replace("&amp;", "&")
557        .replace("&lt;", "<")
558        .replace("&gt;", ">")
559        .replace("&quot;", "\"")
560        .replace("&#39;", "'")
561        .replace("&apos;", "'")
562        .replace("&nbsp;", " ")
563        .replace(
564            [
565                '\u{00A0}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', '\u{202F}', '\u{205F}',
566                '\u{3000}',
567            ],
568            " ",
569        ) // Various Unicode spaces -> regular space
570        .replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") // Zero-width characters -> remove
571        .replace("&middot;", ".")
572        .replace("&bull;", "*")
573        .replace("&mdash;", "-")
574        .replace("&ndash;", "-")
575        .replace("&hellip;", "...")
576        .replace("&copy;", "(c)")
577        .replace("&reg;", "(R)")
578        .replace("&trade;", "(TM)")
579        .replace("&#x27;", "'")
580        .replace("&#x2F;", "/")
581}
582
583/// Clean up text output: trim trailing whitespace from lines, collapse multiple blank lines
584fn cleanup_text(text: &str) -> String {
585    let lines: Vec<&str> = text.lines().map(|line| line.trim_end()).collect();
586
587    // Collapse multiple consecutive blank lines into at most 2
588    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
607/// Helper to create a boxed line with proper padding (ASCII)
608fn box_line(content: &str, width: usize) -> String {
609    let content_width = width - 2; // Account for | on each side
610    let display_len = content.chars().count();
611    let padding = content_width.saturating_sub(display_len);
612    format!("|{}{}|\n", content, " ".repeat(padding))
613}
614
615/// Helper to create a centered boxed line (ASCII)
616fn 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/// Format a post as plain text with metadata header (ASCII)
631#[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; // Total width including borders
644
645    // Top border (ASCII)
646    output.push('+');
647    output.push_str(&"=".repeat(width - 2));
648    output.push_str("+\n");
649
650    // Title (centered)
651    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    // Separator (ASCII)
657    output.push('+');
658    output.push_str(&"=".repeat(width - 2));
659    output.push_str("+\n");
660
661    // Metadata
662    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        // Wrap just the tags content, then add prefix
669        let wrapped = wrap_text(&tags_str, width - 12); // Account for "  Tags: " prefix
670        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)); // Align with content
675            }
676        }
677    }
678
679    output.push_str(&box_line(
680        &format!("  Reading time: {} min", reading_time),
681        width,
682    ));
683
684    // Handle long URLs by wrapping if needed
685    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    // Bottom border of header (ASCII)
705    output.push('+');
706    output.push_str(&"=".repeat(width - 2));
707    output.push_str("+\n\n");
708
709    // Content
710    output.push_str(&html_to_text(content));
711    output.push_str("\n\n");
712
713    // Footer (ASCII)
714    output.push_str(&"-".repeat(width));
715    output.push('\n');
716
717    cleanup_text(&output)
718}
719
720/// Format home page as plain text (ASCII)
721pub 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    // Top border (ASCII)
726    output.push('+');
727    output.push_str(&"=".repeat(width - 2));
728    output.push_str("+\n");
729
730    // Title (centered)
731    output.push_str(&box_line_centered(title, width));
732
733    // Separator (ASCII)
734    output.push('+');
735    output.push_str(&"=".repeat(width - 2));
736    output.push_str("+\n");
737
738    // Description
739    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    // Bottom border of header (ASCII)
747    output.push('+');
748    output.push_str(&"=".repeat(width - 2));
749    output.push_str("+\n\n");
750
751    // Content
752    output.push_str(&html_to_text(content));
753    output.push_str("\n\n");
754
755    // Footer (ASCII)
756    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 &amp; goodbye &mdash; see you!</p>";
775        let text = html_to_text(html);
776        assert!(text.contains("Hello & goodbye - see you!")); // mdash -> ASCII dash
777    }
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("|")); // Column separator
818        assert!(text.contains("-")); // Header separator
819    }
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}