Skip to main content

lean_ctx/core/web/
html_to_text.rs

1//! Dependency-free HTML → Markdown / plain-text conversion.
2//!
3//! A small tag tokenizer feeds a state-machine renderer. The goal is *clean,
4//! readable* content for an LLM, not a faithful DOM: noise elements (script,
5//! style, nav chrome) are dropped, block structure becomes Markdown headings /
6//! lists / paragraphs, links become `[text](href)`, and `<pre>` becomes fenced
7//! code. Implemented without an HTML crate to stay in line with the project's
8//! zero-heavy-dependency stance.
9
10/// A hyperlink extracted from the document.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Link {
13    pub text: String,
14    pub href: String,
15}
16
17/// Parsed document: optional `<title>`, rendered Markdown, and extracted links.
18#[derive(Debug, Clone)]
19pub struct HtmlDoc {
20    pub title: Option<String>,
21    pub markdown: String,
22    pub links: Vec<Link>,
23}
24
25/// Convert an HTML document into Markdown plus extracted metadata.
26pub fn parse(html: &str) -> HtmlDoc {
27    let title = extract_title(html);
28    let content = select_main(html);
29    let mut renderer = Renderer::default();
30    for token in tokenize(content) {
31        renderer.consume(&token);
32    }
33    let markdown = normalize(&renderer.out);
34    HtmlDoc {
35        title,
36        markdown,
37        links: renderer.links,
38    }
39}
40
41/// Extract just the document `<title>` without rendering the body.
42pub fn title(html: &str) -> Option<String> {
43    extract_title(html)
44}
45
46/// Strip Markdown decorations to obtain flowing plain text.
47pub fn markdown_to_text(markdown: &str) -> String {
48    let mut out = String::with_capacity(markdown.len());
49    let mut in_fence = false;
50    for line in markdown.lines() {
51        if line.trim_start().starts_with("```") {
52            in_fence = !in_fence;
53            continue;
54        }
55        if in_fence {
56            out.push_str(line);
57            out.push('\n');
58            continue;
59        }
60        let stripped = strip_inline_markup(line);
61        out.push_str(&stripped);
62        out.push('\n');
63    }
64    out.trim().to_string()
65}
66
67fn strip_inline_markup(line: &str) -> String {
68    let without_heading = line.trim_start().trim_start_matches('#').trim_start();
69    replace_links_with_text(without_heading)
70}
71
72/// Replace `[text](href)` spans with their visible text only.
73fn replace_links_with_text(s: &str) -> String {
74    let mut out = String::with_capacity(s.len());
75    let bytes = s.as_bytes();
76    let mut i = 0;
77    while i < bytes.len() {
78        if bytes[i] == b'['
79            && let Some(rel_close) = s[i + 1..].find(']')
80        {
81            let close = i + 1 + rel_close;
82            if s[close + 1..].starts_with('(')
83                && let Some(rel_paren) = s[close + 2..].find(')')
84            {
85                out.push_str(&s[i + 1..close]);
86                i = close + 2 + rel_paren + 1;
87                continue;
88            }
89        }
90        // Copy a whole UTF-8 char starting at i.
91        let ch_len = utf8_len(bytes[i]);
92        out.push_str(&s[i..i + ch_len]);
93        i += ch_len;
94    }
95    out
96}
97
98fn utf8_len(first: u8) -> usize {
99    match first {
100        b if b < 0x80 => 1,
101        b if b >> 5 == 0b110 => 2,
102        b if b >> 4 == 0b1110 => 3,
103        _ => 4,
104    }
105}
106
107// ── Main-content selection ────────────────────────────────────────────────
108
109fn select_main(html: &str) -> &str {
110    if let Some(inner) = first_element_inner(html, "main") {
111        return inner;
112    }
113    if let Some(inner) = first_element_inner(html, "body") {
114        return inner;
115    }
116    html
117}
118
119/// Return the inner slice of the first `<tag ...> ... </tag>` (case-insensitive).
120fn first_element_inner<'a>(html: &'a str, tag: &str) -> Option<&'a str> {
121    let lower = html.to_ascii_lowercase();
122    let open_marker = format!("<{tag}");
123    let open_pos = lower.find(&open_marker)?;
124    // The char after the tag name must be a delimiter, not more name chars.
125    let after_name = open_pos + open_marker.len();
126    let delim_ok = lower[after_name..]
127        .chars()
128        .next()
129        .is_some_and(|c| c == '>' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/');
130    if !delim_ok {
131        return None;
132    }
133    let gt = lower[open_pos..].find('>')? + open_pos;
134    let close_marker = format!("</{tag}");
135    let close_pos = lower[gt + 1..].find(&close_marker).map(|p| gt + 1 + p)?;
136    Some(&html[gt + 1..close_pos])
137}
138
139fn extract_title(html: &str) -> Option<String> {
140    let inner = first_element_inner(html, "title")?;
141    let decoded = decode_entities(inner);
142    let collapsed = collapse_ws(&decoded);
143    let trimmed = collapsed.trim();
144    if trimmed.is_empty() {
145        None
146    } else {
147        Some(trimmed.to_string())
148    }
149}
150
151// ── Tokenizer ─────────────────────────────────────────────────────────────
152
153enum Token<'a> {
154    Open {
155        name: String,
156        attrs: &'a str,
157        self_closing: bool,
158    },
159    Close {
160        name: String,
161    },
162    Text(&'a str),
163}
164
165fn tokenize(html: &str) -> Vec<Token<'_>> {
166    let bytes = html.as_bytes();
167    let n = bytes.len();
168    let mut tokens = Vec::new();
169    let mut i = 0;
170
171    while i < n {
172        if bytes[i] == b'<' {
173            if html[i..].starts_with("<!--") {
174                match html[i + 4..].find("-->") {
175                    Some(end) => i = i + 4 + end + 3,
176                    None => break,
177                }
178                continue;
179            }
180            if i + 1 < n && bytes[i + 1] == b'!' {
181                match html[i..].find('>') {
182                    Some(end) => i += end + 1,
183                    None => break,
184                }
185                continue;
186            }
187            if let Some(end) = tag_end(bytes, i) {
188                parse_tag(&html[i + 1..end], &mut tokens);
189                i = end + 1;
190            } else {
191                tokens.push(Token::Text(&html[i..]));
192                break;
193            }
194        } else {
195            let start = i;
196            while i < n && bytes[i] != b'<' {
197                i += 1;
198            }
199            tokens.push(Token::Text(&html[start..i]));
200        }
201    }
202    tokens
203}
204
205/// Index of the `>` that closes a tag opened at `start`, honoring quotes.
206fn tag_end(bytes: &[u8], start: usize) -> Option<usize> {
207    let mut i = start + 1;
208    let mut quote = 0u8;
209    while i < bytes.len() {
210        let b = bytes[i];
211        if quote != 0 {
212            if b == quote {
213                quote = 0;
214            }
215        } else if b == b'"' || b == b'\'' {
216            quote = b;
217        } else if b == b'>' {
218            return Some(i);
219        }
220        i += 1;
221    }
222    None
223}
224
225fn parse_tag<'a>(inner: &'a str, tokens: &mut Vec<Token<'a>>) {
226    let trimmed = inner.trim_start();
227    if let Some(rest) = trimmed.strip_prefix('/') {
228        let name = take_name(rest);
229        if !name.is_empty() {
230            tokens.push(Token::Close { name });
231        }
232        return;
233    }
234    let name = take_name(trimmed);
235    if name.is_empty() {
236        return;
237    }
238    let attrs = &trimmed[name.len()..];
239    let self_closing = trimmed.trim_end().ends_with('/');
240    tokens.push(Token::Open {
241        name,
242        attrs,
243        self_closing,
244    });
245}
246
247fn take_name(s: &str) -> String {
248    s.chars()
249        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == ':')
250        .collect::<String>()
251        .to_ascii_lowercase()
252}
253
254fn get_attr(attrs: &str, key: &str) -> Option<String> {
255    let lower = attrs.to_ascii_lowercase();
256    let mut from = 0;
257    while let Some(pos) = lower[from..].find(key) {
258        let idx = from + pos;
259        let boundary = idx == 0 || lower.as_bytes()[idx - 1].is_ascii_whitespace();
260        let after = idx + key.len();
261        let rest = attrs[after..].trim_start();
262        if boundary && rest.starts_with('=') {
263            return Some(parse_attr_value(rest[1..].trim_start()));
264        }
265        from = after;
266    }
267    None
268}
269
270fn parse_attr_value(s: &str) -> String {
271    let bytes = s.as_bytes();
272    if let Some(&q) = bytes.first()
273        && (q == b'"' || q == b'\'')
274    {
275        let quote = q as char;
276        return match s[1..].find(quote) {
277            Some(end) => s[1..=end].to_string(),
278            None => s[1..].to_string(),
279        };
280    }
281    s.split_whitespace()
282        .next()
283        .unwrap_or("")
284        .trim_end_matches('/')
285        .to_string()
286}
287
288// ── Renderer ──────────────────────────────────────────────────────────────
289
290struct ListCtx {
291    ordered: bool,
292    index: usize,
293}
294
295/// Accumulates a `<table>` so it can be flushed as a GitHub-Flavored-Markdown
296/// table (leading `|`, header separator row) instead of the ambiguous
297/// `cell | cell |` lines the streaming renderer produced before.
298#[derive(Default)]
299struct TableCtx {
300    /// Completed rows, each a list of finalized (escaped) cell strings.
301    rows: Vec<Vec<String>>,
302    /// Cells of the row currently being built.
303    cur_row: Vec<String>,
304    /// Active cell buffer; text/inline content is routed here while open.
305    cell: Option<String>,
306    /// Index into `rows` of the row to use as the header, if known.
307    header_idx: Option<usize>,
308    /// Inside a `<thead>` section.
309    in_thead: bool,
310    /// The in-progress row contains at least one `<th>`.
311    cur_row_has_th: bool,
312}
313
314#[derive(Default)]
315struct Renderer {
316    out: String,
317    links: Vec<Link>,
318    skip_depth: usize,
319    pre_depth: usize,
320    anchor: Option<(String, String)>,
321    list_stack: Vec<ListCtx>,
322    table_stack: Vec<TableCtx>,
323}
324
325impl Renderer {
326    fn consume(&mut self, token: &Token<'_>) {
327        match token {
328            Token::Text(t) => self.text(t),
329            Token::Open {
330                name,
331                attrs,
332                self_closing,
333            } => self.open(name, attrs, *self_closing),
334            Token::Close { name } => self.close(name),
335        }
336    }
337
338    fn text(&mut self, raw: &str) {
339        if self.skip_depth > 0 {
340            return;
341        }
342        let decoded = decode_entities(raw);
343        if self.pre_depth > 0 {
344            self.active_sink().push_str(&decoded);
345            return;
346        }
347        let collapsed = collapse_ws(&decoded);
348        if collapsed.is_empty() {
349            return;
350        }
351        self.active_sink().push_str(&collapsed);
352    }
353
354    /// The string buffer inline content should currently be written to:
355    /// an open `<a>` anchor first, then the innermost open table cell, else the
356    /// main document body. Keeps link/cell capture working when nested.
357    fn active_sink(&mut self) -> &mut String {
358        if let Some((_, buf)) = self.anchor.as_mut() {
359            return buf;
360        }
361        // Probe immutably first so that borrow is released before the mutable
362        // re-borrow returns (keeps the borrow checker happy across fields).
363        let cell_active = matches!(self.table_stack.last(), Some(tc) if tc.cell.is_some());
364        if cell_active {
365            return self
366                .table_stack
367                .last_mut()
368                .and_then(|tc| tc.cell.as_mut())
369                .expect("cell active by the check above");
370        }
371        &mut self.out
372    }
373
374    fn open(&mut self, name: &str, attrs: &str, self_closing: bool) {
375        if self.skip_depth > 0 {
376            if is_skip(name) && !self_closing && !is_void(name) {
377                self.skip_depth += 1;
378            }
379            return;
380        }
381        if is_skip(name) {
382            if !self_closing && !is_void(name) {
383                self.skip_depth += 1;
384            }
385            return;
386        }
387        if self_closing || is_void(name) {
388            self.open_void(name);
389            return;
390        }
391
392        match name {
393            "a" => self.open_anchor(attrs),
394            "pre" => {
395                self.block_break();
396                self.out.push_str("```");
397                self.newline();
398                self.pre_depth += 1;
399            }
400            "code" if self.pre_depth == 0 => {
401                self.active_sink().push('`');
402            }
403            "table" => {
404                self.block_break();
405                self.table_stack.push(TableCtx::default());
406            }
407            "thead" => {
408                if let Some(tc) = self.table_stack.last_mut() {
409                    tc.in_thead = true;
410                }
411            }
412            "tr" => {
413                if let Some(tc) = self.table_stack.last_mut() {
414                    tc.cur_row = Vec::new();
415                    tc.cur_row_has_th = false;
416                } else {
417                    // A stray <tr> outside any table: keep the legacy line break.
418                    self.newline();
419                }
420            }
421            "td" => {
422                if let Some(tc) = self.table_stack.last_mut() {
423                    tc.cell = Some(String::new());
424                }
425            }
426            "th" => {
427                if let Some(tc) = self.table_stack.last_mut() {
428                    tc.cell = Some(String::new());
429                    tc.cur_row_has_th = true;
430                }
431            }
432            "ul" => {
433                self.list_stack.push(ListCtx {
434                    ordered: false,
435                    index: 0,
436                });
437                self.block_break();
438            }
439            "ol" => {
440                self.list_stack.push(ListCtx {
441                    ordered: true,
442                    index: 0,
443                });
444                self.block_break();
445            }
446            "li" => {
447                self.newline();
448                let marker = match self.list_stack.last_mut() {
449                    Some(ctx) if ctx.ordered => {
450                        ctx.index += 1;
451                        format!("{}. ", ctx.index)
452                    }
453                    _ => "- ".to_string(),
454                };
455                self.out.push_str(&marker);
456            }
457            "blockquote" => {
458                self.block_break();
459                self.out.push_str("> ");
460            }
461            h if is_heading(h) => {
462                self.block_break();
463                for _ in 0..heading_level(h) {
464                    self.out.push('#');
465                }
466                self.out.push(' ');
467            }
468            b if is_block(b) => self.block_break(),
469            _ => {}
470        }
471    }
472
473    fn open_void(&mut self, name: &str) {
474        match name {
475            "br" => self.newline(),
476            "hr" => {
477                self.block_break();
478                self.out.push_str("---");
479                self.block_break();
480            }
481            _ => {}
482        }
483    }
484
485    fn open_anchor(&mut self, attrs: &str) {
486        if self.anchor.is_some() {
487            return;
488        }
489        if let Some(href) = get_attr(attrs, "href") {
490            let href = href.trim();
491            if !href.is_empty() && !href.starts_with("javascript:") && !href.starts_with('#') {
492                self.anchor = Some((href.to_string(), String::new()));
493            }
494        }
495    }
496
497    fn close(&mut self, name: &str) {
498        if self.skip_depth > 0 {
499            if is_skip(name) {
500                self.skip_depth -= 1;
501            }
502            return;
503        }
504        match name {
505            "a" => {
506                if let Some((href, text)) = self.anchor.take() {
507                    let text = text.trim();
508                    if !text.is_empty() {
509                        self.out.push_str(&format!("[{text}]({href})"));
510                        self.links.push(Link {
511                            text: text.to_string(),
512                            href,
513                        });
514                    }
515                }
516            }
517            "pre" => {
518                self.pre_depth = self.pre_depth.saturating_sub(1);
519                self.newline();
520                self.out.push_str("```");
521                self.block_break();
522            }
523            "code" if self.pre_depth == 0 => {
524                self.active_sink().push('`');
525            }
526            "ul" | "ol" => {
527                self.list_stack.pop();
528                self.block_break();
529            }
530            "td" | "th" => {
531                if let Some(tc) = self.table_stack.last_mut()
532                    && let Some(cell) = tc.cell.take()
533                {
534                    tc.cur_row.push(finalize_cell(&cell));
535                }
536            }
537            "tr" => {
538                if let Some(tc) = self.table_stack.last_mut() {
539                    let row = std::mem::take(&mut tc.cur_row);
540                    if !row.is_empty() {
541                        if tc.header_idx.is_none() && (tc.in_thead || tc.cur_row_has_th) {
542                            tc.header_idx = Some(tc.rows.len());
543                        }
544                        tc.rows.push(row);
545                    }
546                }
547            }
548            "thead" => {
549                if let Some(tc) = self.table_stack.last_mut() {
550                    tc.in_thead = false;
551                }
552            }
553            "table" => {
554                if let Some(tc) = self.table_stack.pop() {
555                    let rendered = render_gfm_table(&tc);
556                    if rendered.is_empty() {
557                        return;
558                    }
559                    // A nested table flushes into its parent cell; a top-level
560                    // one becomes its own block in the document body.
561                    if self.table_stack.last().is_some_and(|p| p.cell.is_some()) {
562                        self.active_sink().push_str(&rendered);
563                    } else {
564                        self.block_break();
565                        self.out.push_str(&rendered);
566                        self.block_break();
567                    }
568                }
569            }
570            h if is_heading(h) => self.block_break(),
571            b if is_block(b) => self.block_break(),
572            _ => {}
573        }
574    }
575
576    fn newline(&mut self) {
577        if !self.out.ends_with('\n') {
578            self.out.push('\n');
579        }
580    }
581
582    fn block_break(&mut self) {
583        while self.out.ends_with(' ') {
584            self.out.pop();
585        }
586        if self.out.is_empty() {
587            return;
588        }
589        if self.out.ends_with("\n\n") {
590            return;
591        }
592        if self.out.ends_with('\n') {
593            self.out.push('\n');
594        } else {
595            self.out.push_str("\n\n");
596        }
597    }
598}
599
600/// Clean a raw cell buffer into a single-line, pipe-escaped GFM cell.
601fn finalize_cell(raw: &str) -> String {
602    let single_line = raw.replace(['\n', '\r'], " ");
603    collapse_ws(&single_line).trim().replace('|', "\\|")
604}
605
606/// Render an accumulated [`TableCtx`] as a GitHub-Flavored-Markdown table.
607///
608/// Guarantees a header row + separator (GFM requires both): an explicit
609/// `<thead>`/`<th>` row is used when present, otherwise the first row becomes
610/// the header. Rows are padded to a uniform column count. Returns an empty
611/// string for an empty table so the caller can skip it.
612fn render_gfm_table(tc: &TableCtx) -> String {
613    if tc.rows.is_empty() {
614        return String::new();
615    }
616    let cols = tc.rows.iter().map(Vec::len).max().unwrap_or(0);
617    if cols == 0 {
618        return String::new();
619    }
620
621    let header_idx = tc.header_idx.unwrap_or(0);
622    let header = tc.rows.get(header_idx).cloned().unwrap_or_default();
623
624    let mut out = String::new();
625    push_table_row(&mut out, &header, cols);
626    push_separator_row(&mut out, cols);
627    for (i, row) in tc.rows.iter().enumerate() {
628        if i == header_idx {
629            continue;
630        }
631        push_table_row(&mut out, row, cols);
632    }
633    out.trim_end().to_string()
634}
635
636fn push_table_row(out: &mut String, cells: &[String], cols: usize) {
637    out.push('|');
638    for c in 0..cols {
639        let cell = cells.get(c).map_or("", String::as_str);
640        out.push(' ');
641        out.push_str(cell);
642        out.push_str(" |");
643    }
644    out.push('\n');
645}
646
647fn push_separator_row(out: &mut String, cols: usize) {
648    out.push('|');
649    for _ in 0..cols {
650        out.push_str(" --- |");
651    }
652    out.push('\n');
653}
654
655fn is_skip(name: &str) -> bool {
656    matches!(
657        name,
658        "script"
659            | "style"
660            | "noscript"
661            | "svg"
662            | "template"
663            | "iframe"
664            | "head"
665            | "object"
666            | "embed"
667            | "canvas"
668            | "math"
669    )
670}
671
672fn is_void(name: &str) -> bool {
673    matches!(
674        name,
675        "br" | "hr"
676            | "img"
677            | "input"
678            | "meta"
679            | "link"
680            | "source"
681            | "col"
682            | "area"
683            | "base"
684            | "wbr"
685            | "track"
686            | "param"
687    )
688}
689
690fn is_block(name: &str) -> bool {
691    matches!(
692        name,
693        "p" | "div"
694            | "section"
695            | "article"
696            | "main"
697            | "header"
698            | "footer"
699            | "aside"
700            | "nav"
701            | "dl"
702            | "dd"
703            | "dt"
704            | "figure"
705            | "figcaption"
706            | "address"
707            | "form"
708            | "fieldset"
709            | "details"
710            | "summary"
711    )
712}
713
714fn is_heading(name: &str) -> bool {
715    name.len() == 2 && name.starts_with('h') && matches!(name.as_bytes()[1], b'1'..=b'6')
716}
717
718fn heading_level(name: &str) -> usize {
719    (name.as_bytes()[1] - b'0') as usize
720}
721
722// ── Whitespace + entities ──────────────────────────────────────────────────
723
724fn collapse_ws(s: &str) -> String {
725    let mut out = String::with_capacity(s.len());
726    let mut prev_space = false;
727    for c in s.chars() {
728        if c.is_whitespace() {
729            if !prev_space {
730                out.push(' ');
731                prev_space = true;
732            }
733        } else {
734            out.push(c);
735            prev_space = false;
736        }
737    }
738    out
739}
740
741fn normalize(s: &str) -> String {
742    let mut result = String::with_capacity(s.len());
743    let mut in_fence = false;
744    let mut blank_run = 0;
745
746    for line in s.lines() {
747        if line.trim() == "```" {
748            in_fence = !in_fence;
749            result.push_str("```\n");
750            blank_run = 0;
751            continue;
752        }
753        if in_fence {
754            result.push_str(line);
755            result.push('\n');
756            continue;
757        }
758        let trimmed = line.trim();
759        if trimmed.is_empty() {
760            blank_run += 1;
761            if blank_run <= 1 {
762                result.push('\n');
763            }
764            continue;
765        }
766        blank_run = 0;
767        result.push_str(trimmed);
768        result.push('\n');
769    }
770    result.trim().to_string()
771}
772
773/// Decode HTML/XML character entities (`&amp;`, `&#39;`, `&#x2019;`, …).
774///
775/// Exposed for sibling modules (e.g. the YouTube srv3 transcript parser) so
776/// entity handling lives in exactly one place.
777pub fn decode_entities(s: &str) -> String {
778    if !s.contains('&') {
779        return s.to_string();
780    }
781    let mut out = String::with_capacity(s.len());
782    let bytes = s.as_bytes();
783    let mut i = 0;
784    while i < bytes.len() {
785        if bytes[i] == b'&' {
786            if let Some(rel_end) = s[i..].find(';') {
787                let end = i + rel_end;
788                let entity = &s[i + 1..end];
789                if let Some(decoded) = decode_one(entity) {
790                    out.push_str(&decoded);
791                    i = end + 1;
792                    continue;
793                }
794            }
795            out.push('&');
796            i += 1;
797        } else {
798            let ch_len = utf8_len(bytes[i]);
799            out.push_str(&s[i..i + ch_len]);
800            i += ch_len;
801        }
802    }
803    out
804}
805
806fn decode_one(entity: &str) -> Option<String> {
807    if let Some(num) = entity.strip_prefix('#') {
808        let code = if let Some(hex) = num.strip_prefix(['x', 'X']) {
809            u32::from_str_radix(hex, 16).ok()?
810        } else {
811            num.parse::<u32>().ok()?
812        };
813        return char::from_u32(code).map(|c| c.to_string());
814    }
815    let named = match entity {
816        "amp" => "&",
817        "lt" => "<",
818        "gt" => ">",
819        "quot" => "\"",
820        "apos" => "'",
821        "nbsp" => " ",
822        "mdash" => "—",
823        "ndash" => "–",
824        "hellip" => "…",
825        "copy" => "©",
826        "reg" => "®",
827        "trade" => "™",
828        "laquo" => "«",
829        "raquo" => "»",
830        "lsquo" => "‘",
831        "rsquo" => "’",
832        "ldquo" => "“",
833        "rdquo" => "”",
834        "bull" => "•",
835        "middot" => "·",
836        "euro" => "€",
837        "pound" => "£",
838        "deg" => "°",
839        "times" => "×",
840        "divide" => "÷",
841        _ => return None,
842    };
843    Some(named.to_string())
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn extracts_title_and_decodes() {
852        let doc =
853            parse("<html><head><title>Foo &amp; Bar</title></head><body><p>Hi</p></body></html>");
854        assert_eq!(doc.title.as_deref(), Some("Foo & Bar"));
855    }
856
857    #[test]
858    fn drops_script_and_style() {
859        let html = "<body><script>var x=1;</script><style>.a{}</style><p>Visible</p></body>";
860        let doc = parse(html);
861        assert_eq!(doc.markdown, "Visible");
862        assert!(!doc.markdown.contains("var x"));
863    }
864
865    #[test]
866    fn renders_headings_and_paragraphs() {
867        let html = "<body><h1>Title</h1><p>First.</p><p>Second.</p></body>";
868        let doc = parse(html);
869        assert_eq!(doc.markdown, "# Title\n\nFirst.\n\nSecond.");
870    }
871
872    #[test]
873    fn renders_links_and_collects_them() {
874        let html = r#"<body><p>See <a href="https://x.com/a">the site</a> now.</p></body>"#;
875        let doc = parse(html);
876        assert!(doc.markdown.contains("[the site](https://x.com/a)"));
877        assert_eq!(doc.links.len(), 1);
878        assert_eq!(doc.links[0].href, "https://x.com/a");
879        assert_eq!(doc.links[0].text, "the site");
880    }
881
882    #[test]
883    fn renders_unordered_and_ordered_lists() {
884        let html = "<body><ul><li>one</li><li>two</li></ul><ol><li>a</li><li>b</li></ol></body>";
885        let doc = parse(html);
886        assert!(doc.markdown.contains("- one"));
887        assert!(doc.markdown.contains("- two"));
888        assert!(doc.markdown.contains("1. a"));
889        assert!(doc.markdown.contains("2. b"));
890    }
891
892    #[test]
893    fn prefers_main_over_chrome() {
894        let html = "<body><nav><a href=/x>menu</a></nav><main><p>Core content</p></main><footer>foot</footer></body>";
895        let doc = parse(html);
896        assert_eq!(doc.markdown, "Core content");
897    }
898
899    #[test]
900    fn preserves_pre_as_fenced_code() {
901        let html = "<body><pre>line1\n  line2</pre></body>";
902        let doc = parse(html);
903        assert!(doc.markdown.contains("```"));
904        assert!(doc.markdown.contains("line1\n  line2"));
905    }
906
907    #[test]
908    fn renders_table_as_gfm_with_header_separator() {
909        let html = "<body><table>\
910            <thead><tr><th>A</th><th>B</th></tr></thead>\
911            <tbody><tr><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr></tbody>\
912            </table></body>";
913        let doc = parse(html);
914        assert_eq!(
915            doc.markdown, "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |",
916            "thead row must become the GFM header with a separator row"
917        );
918    }
919
920    #[test]
921    fn table_without_thead_promotes_first_row_to_header() {
922        let html = "<body><table><tr><td>h1</td><td>h2</td></tr>\
923            <tr><td>a</td><td>b</td></tr></table></body>";
924        let doc = parse(html);
925        assert_eq!(doc.markdown, "| h1 | h2 |\n| --- | --- |\n| a | b |");
926    }
927
928    #[test]
929    fn table_cells_escape_pipes_and_keep_links() {
930        let html = r#"<body><table><tr><th>name</th><th>url</th></tr>
931            <tr><td>a|b</td><td><a href="https://x.com/p">site</a></td></tr></table></body>"#;
932        let doc = parse(html);
933        assert!(doc.markdown.contains(r"| a\|b |"), "pipe must be escaped");
934        assert!(
935            doc.markdown.contains("[site](https://x.com/p)"),
936            "links inside cells must render: {}",
937            doc.markdown
938        );
939        assert_eq!(doc.links.len(), 1, "cell links are still collected");
940    }
941
942    #[test]
943    fn ragged_table_rows_are_padded() {
944        let html = "<body><table><tr><th>a</th><th>b</th><th>c</th></tr>\
945            <tr><td>1</td></tr></table></body>";
946        let doc = parse(html);
947        // Short row padded to 3 columns so the GFM grid stays valid.
948        assert_eq!(
949            doc.markdown,
950            "| a | b | c |\n| --- | --- | --- |\n| 1 |  |  |"
951        );
952    }
953
954    #[test]
955    fn markdown_to_text_strips_markup() {
956        let md = "# Heading\n\nSee [link](https://x.com) here.";
957        let text = markdown_to_text(md);
958        assert_eq!(text, "Heading\n\nSee link here.");
959    }
960
961    #[test]
962    fn handles_unterminated_tag_gracefully() {
963        let doc = parse("<body><p>ok</p><broken");
964        assert!(doc.markdown.contains("ok"));
965    }
966
967    #[test]
968    fn decodes_numeric_entities() {
969        assert_eq!(decode_entities("A&#38;B&#x41;"), "A&BA");
970    }
971}