1#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Link {
13 pub text: String,
14 pub href: String,
15}
16
17#[derive(Debug, Clone)]
19pub struct HtmlDoc {
20 pub title: Option<String>,
21 pub markdown: String,
22 pub links: Vec<Link>,
23}
24
25pub 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
41pub fn title(html: &str) -> Option<String> {
43 extract_title(html)
44}
45
46pub 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
72fn 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 if let Some(rel_close) = s[i + 1..].find(']') {
80 let close = i + 1 + rel_close;
81 if s[close + 1..].starts_with('(') {
82 if let Some(rel_paren) = s[close + 2..].find(')') {
83 out.push_str(&s[i + 1..close]);
84 i = close + 2 + rel_paren + 1;
85 continue;
86 }
87 }
88 }
89 }
90 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
107fn 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
119fn 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 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
151enum 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
205fn 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 if q == b'"' || q == b'\'' {
274 let quote = q as char;
275 return match s[1..].find(quote) {
276 Some(end) => s[1..=end].to_string(),
277 None => s[1..].to_string(),
278 };
279 }
280 }
281 s.split_whitespace()
282 .next()
283 .unwrap_or("")
284 .trim_end_matches('/')
285 .to_string()
286}
287
288struct ListCtx {
291 ordered: bool,
292 index: usize,
293}
294
295#[derive(Default)]
299struct TableCtx {
300 rows: Vec<Vec<String>>,
302 cur_row: Vec<String>,
304 cell: Option<String>,
306 header_idx: Option<usize>,
308 in_thead: bool,
310 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 fn active_sink(&mut self) -> &mut String {
358 if let Some((_, buf)) = self.anchor.as_mut() {
359 return buf;
360 }
361 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 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 if let Some(cell) = tc.cell.take() {
533 tc.cur_row.push(finalize_cell(&cell));
534 }
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 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
600fn finalize_cell(raw: &str) -> String {
602 let single_line = raw.replace(['\n', '\r'], " ");
603 collapse_ws(&single_line).trim().replace('|', "\\|")
604}
605
606fn 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
722fn 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
773pub 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 & 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 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&BA"), "A&BA");
970 }
971}