1use crate::error::SsgError;
7use crate::util::head_dom::extract_head_meta;
8use std::path::{Path, PathBuf};
9
10pub fn extract_title(html: &str) -> String {
26 extract_head_meta(html).title
27}
28
29pub(super) fn extract_description(html: &str, max_len: usize) -> String {
35 let content = extract_main_content(html);
36
37 let clean = strip_inline_tags(&content, &["script", "style"]);
38
39 let text = strip_tags(&clean);
40 let trimmed = text.trim();
41 truncate_at_word_boundary(trimmed, max_len)
42}
43
44fn extract_main_content(html: &str) -> String {
47 if let Some(inner) = extract_tag_inner(html, "main") {
48 return inner;
49 }
50
51 let body =
52 extract_tag_inner(html, "body").unwrap_or_else(|| html.to_string());
53 strip_inline_tags(&body, &["script", "style", "nav", "header", "footer"])
54}
55
56fn extract_tag_inner(html: &str, tag_name: &str) -> Option<String> {
58 let open = format!("<{tag_name}");
59 let close = format!("</{tag_name}>");
60 let start = html.find(&open)?;
61 let after = &html[start..];
62 let gt = after.find('>')?;
63 let inner = &after[gt + 1..];
64 if let Some(end) = inner.find(&close) {
65 Some(inner[..end].to_string())
66 } else {
67 Some(inner.to_string())
68 }
69}
70
71fn strip_inline_tags(html: &str, tags: &[&str]) -> String {
73 let mut clean = html.to_string();
74 for tag in tags {
75 let open = format!("<{tag}");
76 let close = format!("</{tag}>");
77 while let Some(start) = clean.find(&open) {
78 if let Some(end) = clean[start..].find(&close) {
79 clean.replace_range(start..start + end + close.len(), " ");
80 } else {
81 break;
82 }
83 }
84 }
85 clean
86}
87
88fn truncate_at_word_boundary(text: &str, max_len: usize) -> String {
90 if text.len() <= max_len {
91 return text.to_string();
92 }
93 let mut end = max_len;
94 while end > 0 && !text.is_char_boundary(end) {
95 end -= 1;
96 }
97 let truncated = &text[..end];
98 if let Some(last_space) = truncated.rfind(' ') {
99 truncated[..last_space].to_string()
100 } else {
101 truncated.to_string()
102 }
103}
104
105pub(super) fn strip_tags(html: &str) -> String {
107 let mut result = String::with_capacity(html.len());
108 let mut in_tag = false;
109 for ch in html.chars() {
110 match ch {
111 '<' => in_tag = true,
112 '>' => {
113 in_tag = false;
114 result.push(' ');
115 }
116 _ if !in_tag => result.push(ch),
117 _ => {}
118 }
119 }
120 let mut collapsed = String::with_capacity(result.len());
122 let mut prev_space = false;
123 for ch in result.chars() {
124 if ch.is_whitespace() {
125 if !prev_space {
126 collapsed.push(' ');
127 prev_space = true;
128 }
129 } else {
130 collapsed.push(ch);
131 prev_space = false;
132 }
133 }
134 collapsed.trim().to_string()
135}
136
137#[allow(dead_code)] pub(super) fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
140 crate::walk::walk_files(dir, "html")
141}
142
143const MAX_ENTITY_BYTES: usize = 32;
146
147fn scan_existing_entity(bytes: &[u8], start: usize) -> Option<usize> {
153 let limit = bytes.len().min(start + MAX_ENTITY_BYTES);
154 let mut i = start + 1;
155 if i >= limit {
156 return None;
157 }
158 let numeric = bytes[i] == b'#';
159 if numeric {
160 i += 1;
161 if i < limit && (bytes[i] == b'x' || bytes[i] == b'X') {
162 i += 1;
163 }
164 }
165 let value_start = i;
166 while i < limit {
167 match bytes[i] {
168 b';' if i > value_start => return Some(i + 1),
169 b'0'..=b'9' => i += 1,
170 b'a'..=b'z' | b'A'..=b'Z' if !numeric => i += 1,
171 b'a'..=b'f' | b'A'..=b'F' if numeric => i += 1,
172 _ => return None,
173 }
174 }
175 None
176}
177
178pub(super) fn escape_attr(s: &str) -> String {
194 let bytes = s.as_bytes();
195 let mut out = String::with_capacity(s.len());
196 let mut i = 0;
197 let mut start = 0;
198 while i < bytes.len() {
199 let replacement = match bytes[i] {
200 b'&' => {
201 if let Some(end) = scan_existing_entity(bytes, i) {
202 i = end;
204 continue;
205 }
206 "&"
207 }
208 b'"' => """,
209 b'<' => "<",
210 b'>' => ">",
211 _ => {
212 i += 1;
213 continue;
214 }
215 };
216 if start < i {
217 out.push_str(&s[start..i]);
218 }
219 out.push_str(replacement);
220 i += 1;
221 start = i;
222 }
223 if start < s.len() {
224 out.push_str(&s[start..]);
225 }
226 out
227}
228
229pub fn has_meta_tag(html: &str, attr: &str) -> bool {
248 html.contains(&format!("<meta property=\"{attr}\""))
249 || html.contains(&format!("<meta property='{attr}'"))
250 || html.contains(&format!("<meta name=\"{attr}\""))
251 || html.contains(&format!("<meta name='{attr}'"))
252}
253
254pub(super) fn extract_canonical(html: &str) -> String {
260 extract_head_meta(html).canonical
261}
262
263pub(super) fn extract_existing_meta(html: &str, attr: &str) -> String {
270 use std::cell::RefCell;
271 use std::rc::Rc;
272
273 use lol_html::element;
274
275 use crate::util::html_rewriter::rewrite_html;
276
277 let found: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
284 let sink = Rc::clone(&found);
285 let want = attr.to_ascii_lowercase();
286
287 let handlers = vec![element!("meta", move |el| {
288 if !sink.borrow().is_empty() {
289 return Ok(());
290 }
291 let keyed = ["name", "property"].iter().any(|k| {
294 el.get_attribute(k)
295 .is_some_and(|v| v.eq_ignore_ascii_case(&want))
296 });
297 if keyed {
298 if let Some(content) = el.get_attribute("content") {
299 let value = content.trim();
300 if !value.is_empty() {
301 sink.borrow_mut().push_str(value);
302 }
303 }
304 }
305 Ok(())
306 })];
307
308 let _ = rewrite_html(html, handlers);
309
310 let out = found.borrow().clone();
311 out
312}
313
314pub(super) fn extract_html_lang(html: &str) -> String {
319 extract_head_meta(html).lang
320}
321
322pub(super) fn extract_first_content_image(html: &str) -> String {
324 let search_region = if let Some(start) = html.find("<main") {
326 &html[start..]
327 } else if let Some(start) = html.find("<article") {
328 &html[start..]
329 } else {
330 return String::new();
331 };
332
333 if let Some(img_pos) = search_region.find("<img") {
334 let after_img = &search_region[img_pos..];
335 let mut tag_end = after_img.find('>').unwrap_or(500).min(500);
338 while !after_img.is_char_boundary(tag_end) {
339 tag_end -= 1;
340 }
341 let img_tag = &after_img[..tag_end];
342 if let Some(src_pos) = img_tag.find("src=\"") {
343 let after_src = &img_tag[src_pos + 5..];
344 if let Some(end) = after_src.find('"') {
345 return after_src[..end].to_string();
346 }
347 }
348 }
349 String::new()
350}
351
352pub(super) fn extract_meta_author(html: &str) -> String {
354 let from_meta = extract_existing_meta(html, "author");
356 if !from_meta.is_empty() {
357 return from_meta;
358 }
359 for pattern in &["class=\"author\">", "class='author'>", "rel=\"author\">"]
361 {
362 if let Some(pos) = html.find(pattern) {
363 let after = &html[pos + pattern.len()..];
364 if let Some(end) = after.find('<') {
365 let name = after[..end].trim();
366 let name = name.strip_prefix("by ").unwrap_or(name).trim();
368 if !name.is_empty() {
369 return name.to_string();
370 }
371 }
372 }
373 }
374 String::new()
375}
376
377pub(super) fn extract_date_from_html(
379 html: &str,
380 field: &str,
381) -> Option<String> {
382 let pattern = format!("\"{field}\":\"");
383 if let Some(pos) = html.find(&pattern) {
384 let after = &html[pos + pattern.len()..];
385 if let Some(end) = after.find('"') {
386 let date = &after[..end];
387 if !date.is_empty() {
388 return Some(date.to_string());
389 }
390 }
391 }
392 None
393}
394
395pub(super) fn extract_meta_date(html: &str) -> Option<String> {
397 let meta = extract_existing_meta(html, "article:published_time");
399 if !meta.is_empty() {
400 return Some(meta);
401 }
402 if let Some(pos) = html.find("datetime=\"") {
404 let after = &html[pos + 10..];
405 if let Some(end) = after.find('"') {
406 let date = &after[..end];
407 if !date.is_empty() {
408 return Some(date.to_string());
409 }
410 }
411 }
412 None
413}
414
415#[allow(dead_code)] pub(super) fn collect_html_files_recursive(
418 dir: &Path,
419) -> Result<Vec<PathBuf>, SsgError> {
420 crate::walk::walk_files(dir, "html")
421}
422
423#[cfg(test)]
424mod tests {
425
426 #[test]
430 fn ac1_title_ignores_commented_title() {
431 let html = "<html><head><!-- <title>Old</title> --><title>Real</title></head><body></body></html>";
432 assert_eq!(extract_title(html), "Real");
433 }
434
435 #[test]
437 fn ac3_canonical_is_detected() {
438 let html = r#"<html><head><link rel="canonical" href="https://x"></head><body></body></html>"#;
439 assert_eq!(extract_canonical(html), "https://x");
440 }
441
442 #[test]
444 fn ac_meta_ignores_commented_meta() {
445 let html = concat!(
446 "<html><head>",
447 "<!-- <meta name=\"twitter:image\" content=\"COMMENTED\"> -->",
448 "<meta name=\"twitter:image\" content=\"REAL\">",
449 "</head><body></body></html>"
450 );
451 assert_eq!(extract_existing_meta(html, "twitter:image"), "REAL");
452 }
453
454 #[test]
456 fn ac_meta_ignores_meta_in_pre_block() {
457 let html = concat!(
458 "<html><head><meta name=\"description\" content=\"REAL\"></head>",
459 "<body><pre><meta name=\"description\" content=\"EXAMPLE\"></pre></body></html>"
460 );
461 assert_eq!(extract_existing_meta(html, "description"), "REAL");
462 }
463
464 use super::*;
465 use std::fs;
466 use tempfile::tempdir;
467
468 #[test]
469 fn extract_title_from_html() {
470 let html = "<html><head><title>Test Page</title></head></html>";
471 assert_eq!(extract_title(html), "Test Page");
472 }
473
474 #[test]
475 fn extract_existing_meta_minified_unquoted_and_reordered() {
476 let html = "<head><meta content=https://ex.test/img.png \
479 name=twitter:image></head>";
480 assert_eq!(
481 extract_existing_meta(html, "twitter:image"),
482 "https://ex.test/img.png"
483 );
484 let html2 =
485 "<head><meta content=\"https://ex.test/og.png\" property=og:image></head>";
486 assert_eq!(
487 extract_existing_meta(html2, "og:image"),
488 "https://ex.test/og.png"
489 );
490 }
491
492 #[test]
493 fn extract_existing_meta_quoted_forms_still_work() {
494 let html = r#"<meta name="author" content="Alice">"#;
495 assert_eq!(extract_existing_meta(html, "author"), "Alice");
496 let html2 = r#"<meta property='og:image' content='/x.png'>"#;
497 assert_eq!(extract_existing_meta(html2, "og:image"), "/x.png");
498 }
499
500 #[test]
501 fn extract_existing_meta_absent_returns_empty() {
502 let html = "<head><meta name=viewport content=width=device-width>\
503 </head>";
504 assert_eq!(extract_existing_meta(html, "og:image"), "");
505 }
506
507 #[test]
508 fn extract_existing_meta_skips_empty_content_and_keeps_scanning() {
509 let html = "<meta name=author content=\"\">\
510 <meta name=author content=\"Bea\">";
511 assert_eq!(extract_existing_meta(html, "author"), "Bea");
512 }
513
514 #[test]
515 fn extract_title_empty_no_tag() {
516 let html = "<html><head></head><body>Hello</body></html>";
517 assert_eq!(extract_title(html), "");
518 }
519
520 #[test]
521 fn extract_title_empty_tag() {
522 let html = "<html><head><title></title></head></html>";
523 assert_eq!(extract_title(html), "");
524 }
525
526 #[test]
527 fn extract_title_nested_tags() {
528 let html = "<title><span>Inner</span></title>";
529 assert_eq!(extract_title(html), "Inner");
531 }
532
533 #[test]
534 fn extract_description_from_body() {
535 let html = "<html><body><main><p>Short description here.</p></main></body></html>";
536 let desc = extract_description(html, 200);
537 assert!(desc.contains("Short description here"));
538 }
539
540 #[test]
541 fn extract_description_truncation() {
542 let long_text = "word ".repeat(100);
543 let html = format!("<main><p>{long_text}</p></main>");
544 let desc = extract_description(&html, 50);
545 assert!(desc.len() <= 50);
546 }
547
548 #[test]
549 fn strip_tags_basic() {
550 assert_eq!(strip_tags("<p>Hello <b>world</b></p>"), "Hello world");
551 }
552
553 #[test]
554 fn strip_tags_empty() {
555 assert_eq!(strip_tags(""), "");
556 }
557
558 #[test]
559 fn strip_tags_no_tags() {
560 assert_eq!(strip_tags("plain text"), "plain text");
561 }
562
563 #[test]
564 fn strip_tags_self_closing() {
565 let result = strip_tags("<img src=\"x\"/>text");
566 assert!(result.contains("text"));
567 assert!(!result.contains("img"));
568 }
569
570 #[test]
571 fn truncate_short_text_unchanged() {
572 assert_eq!(truncate_at_word_boundary("short", 100), "short");
573 }
574
575 #[test]
576 fn truncate_long_text_at_word() {
577 let text = "one two three four five six";
578 let result = truncate_at_word_boundary(text, 15);
579 assert!(result.len() <= 15);
580 assert!(!result.ends_with(' '));
582 assert_eq!(result, "one two three");
583 }
584
585 #[test]
586 fn truncate_unicode() {
587 let text = "日本語 テスト データ";
588 let result = truncate_at_word_boundary(text, 15);
589 assert!(result.len() <= 15);
591 }
592
593 #[test]
594 fn collect_html_files_finds_files() {
595 let tmp = tempdir().unwrap();
596 let sub = tmp.path().join("sub");
597 fs::create_dir_all(&sub).unwrap();
598 fs::write(tmp.path().join("index.html"), "<html></html>").unwrap();
599 fs::write(sub.join("page.html"), "<html></html>").unwrap();
600
601 let files = collect_html_files(tmp.path()).unwrap();
602 assert_eq!(files.len(), 2);
603 }
604
605 #[test]
606 fn collect_html_files_recursive_finds_files() {
607 let tmp = tempdir().unwrap();
608 let sub = tmp.path().join("sub");
609 fs::create_dir_all(&sub).unwrap();
610 fs::write(tmp.path().join("index.html"), "<html></html>").unwrap();
611 fs::write(sub.join("page.html"), "<html></html>").unwrap();
612 fs::write(sub.join("style.css"), "body{}").unwrap();
613
614 let files = collect_html_files_recursive(tmp.path()).unwrap();
615 assert_eq!(files.len(), 2);
616 assert!(files.iter().all(|p| p.extension().unwrap() == "html"));
617 }
618
619 #[test]
620 fn collect_html_files_recursive_empty_dir() {
621 let tmp = tempdir().unwrap();
622 let files = collect_html_files_recursive(tmp.path()).unwrap();
623 assert!(files.is_empty(), "{:?}", files);
624 }
625
626 #[test]
627 fn escape_attr_special_chars() {
628 assert_eq!(escape_attr("a&b<c>d\"e"), "a&b<c>d"e");
629 }
630
631 #[test]
635 fn escape_attr_preserves_existing_entities() {
636 assert_eq!(escape_attr("A & B"), "A & B");
637 assert_eq!(escape_attr("A < B"), "A < B");
638 assert_eq!(escape_attr("A ' B"), "A ' B");
639 assert_eq!(escape_attr("A ' B"), "A ' B");
640 assert_eq!(escape_attr(" "), " ");
641 }
642
643 #[test]
646 fn escape_attr_is_idempotent() {
647 for input in [
648 "AI, Payments & Post-Quantum Cryptography",
649 "a&b<c>d\"e",
650 "Tom & Jerry's <b>show</b>",
651 "already & escaped",
652 "mixed & already & both",
653 "",
654 "no metacharacters at all",
655 ] {
656 let once = escape_attr(input);
657 let twice = escape_attr(&once);
658 assert_eq!(once, twice, "not idempotent for {input:?}");
659 }
660 }
661
662 #[test]
666 fn escape_attr_still_escapes_bare_ampersands() {
667 assert_eq!(escape_attr("Tom & Jerry"), "Tom & Jerry");
668 assert_eq!(escape_attr("a & b & c"), "a & b & c");
669 assert_eq!(escape_attr("&"), "&amp");
671 assert_eq!(escape_attr("&;"), "&;");
672 assert_eq!(escape_attr("&#;"), "&#;");
673 let long = format!("&{};", "a".repeat(40));
675 assert!(escape_attr(&long).starts_with("&"));
676 }
677
678 #[test]
679 fn has_meta_tag_present() {
680 let html = r#"<meta property="og:title" content="Hi">"#;
681 assert!(has_meta_tag(html, "og:title"));
682 }
683
684 #[test]
685 fn has_meta_tag_absent() {
686 let html = "<html><head></head></html>";
687 assert!(!has_meta_tag(html, "og:title"));
688 }
689
690 #[test]
691 fn extract_canonical_found() {
692 let html = r#"<link rel="canonical" href="https://example.com/page">"#;
693 assert_eq!(extract_canonical(html), "https://example.com/page");
694 }
695
696 #[test]
697 fn extract_canonical_missing() {
698 let html = "<html><head></head></html>";
699 assert_eq!(extract_canonical(html), "");
700 }
701
702 #[test]
703 fn extract_existing_meta_by_name() {
704 let html = r#"<meta name="author" content="Alice">"#;
705 assert_eq!(extract_existing_meta(html, "author"), "Alice");
706 }
707
708 #[test]
709 fn extract_html_lang_found() {
710 let html = r#"<html lang="fr"><head></head></html>"#;
711 assert_eq!(extract_html_lang(html), "fr");
712 }
713
714 #[test]
715 fn extract_html_lang_missing() {
716 let html = "<html><head></head></html>";
717 assert_eq!(extract_html_lang(html), "");
718 }
719
720 #[test]
721 fn extract_date_from_html_found() {
722 let html = r#"{"datePublished":"2025-01-15"}"#;
723 assert_eq!(
724 extract_date_from_html(html, "datePublished"),
725 Some("2025-01-15".to_string())
726 );
727 }
728
729 #[test]
730 fn extract_date_from_html_missing() {
731 assert_eq!(
732 extract_date_from_html("<html></html>", "datePublished"),
733 None
734 );
735 }
736
737 #[test]
738 fn extract_existing_meta_skips_tag_without_content_attribute() {
739 let html = "<meta name=\"author\">\
742 <meta name=\"author\" content=\"Cid\">";
743 assert_eq!(extract_existing_meta(html, "author"), "Cid");
744 }
745
746 #[test]
747 fn extract_existing_meta_no_content_anywhere_returns_empty() {
748 let html = "<meta name=\"author\">";
749 assert_eq!(extract_existing_meta(html, "author"), "");
750 }
751
752 #[test]
753 fn extract_first_content_image_src_without_closing_quote() {
754 let html = "<main><img src=\"broken.png</main>";
757 assert_eq!(extract_first_content_image(html), "");
758 }
759
760 #[test]
761 fn extract_first_content_image_img_without_src_attribute() {
762 let html = "<main><img alt=\"decorative\"></main>";
763 assert_eq!(extract_first_content_image(html), "");
764 }
765
766 #[test]
767 fn extract_meta_author_byline_with_empty_name_returns_empty() {
768 let html = "<html><body><span class=\"author\"></span></body></html>";
771 assert_eq!(extract_meta_author(html), "");
772 }
773
774 #[test]
775 fn extract_meta_author_byline_without_following_tag_returns_empty() {
776 let html = "<span class=\"author\">Dana";
779 assert_eq!(extract_meta_author(html), "");
780 }
781
782 #[test]
783 fn extract_date_from_html_empty_date_returns_none() {
784 let html = r#"{"datePublished":""}"#;
785 assert_eq!(extract_date_from_html(html, "datePublished"), None);
786 }
787
788 #[test]
789 fn extract_date_from_html_unterminated_value_returns_none() {
790 let html = r#"{"datePublished":"2026-01-01"#;
792 assert_eq!(extract_date_from_html(html, "datePublished"), None);
793 }
794
795 #[test]
796 fn extract_meta_date_empty_datetime_returns_none() {
797 let html = r#"<time datetime="">x</time>"#;
798 assert_eq!(extract_meta_date(html), None);
799 }
800
801 #[test]
802 fn extract_meta_date_unterminated_datetime_returns_none() {
803 let html = r#"<time datetime="2026-01-01"#;
804 assert_eq!(extract_meta_date(html), None);
805 }
806
807 #[test]
816 fn first_content_image_src_before_long_multibyte_alt() {
817 let html = format!(
818 "<main><img src=\"/ta.png\" alt=\"{}\"></main>",
819 "ட".repeat(200)
820 );
821 assert_eq!(extract_first_content_image(&html), "/ta.png");
822 }
823
824 #[test]
826 fn first_content_image_src_past_cap_inside_multibyte_run() {
827 let html = format!(
829 "<main><img alt=\"{}\" src=\"/late.png\"></main>",
830 "ட".repeat(200)
831 );
832 assert_eq!(extract_first_content_image(&html), "");
833 }
834
835 #[test]
838 fn first_content_image_unterminated_short_tag() {
839 assert_eq!(
840 extract_first_content_image("<main><img src=\"/a.png\""),
841 "/a.png"
842 );
843 }
844}