1use crate::error::SsgError;
14use crate::plugin::{Plugin, PluginContext};
15use crate::util::head_dom::inject_before_head_close;
16use anyhow::Result;
17use std::{
18 collections::BTreeMap,
19 fs,
20 path::{Path, PathBuf},
21};
22
23#[derive(Debug, Clone, Copy)]
30pub struct AiPlugin;
31
32impl Plugin for AiPlugin {
33 fn name(&self) -> &'static str {
34 "ai"
35 }
36
37 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
38 if !ctx.site_dir.exists() {
39 return Ok(());
40 }
41
42 generate_llms_txt(&ctx.site_dir, ctx.config.as_ref())
43 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
44 generate_llms_full_txt(&ctx.site_dir, ctx.config.as_ref())
45 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
46
47 let html_files = collect_html_files(&ctx.site_dir)
48 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
49 let pages_with_missing_alt =
50 process_html_for_ai(&html_files, &ctx.site_dir)
51 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
52
53 if pages_with_missing_alt > 0 {
54 log::warn!(
55 "[ai] {pages_with_missing_alt} page(s) have images without alt text"
56 );
57 }
58
59 Ok(())
60 }
61}
62
63fn process_html_for_ai(
65 html_files: &[PathBuf],
66 site_dir: &Path,
67) -> Result<usize> {
68 let mut pages_with_missing_alt = 0usize;
69
70 for path in html_files {
71 let html = fs::read_to_string(path)?;
72 let modified = inject_max_snippet(&html);
73
74 check_alt_text(path, &modified, site_dir, &mut pages_with_missing_alt);
75
76 if modified != html {
77 fs::write(path, modified)?;
78 }
79 }
80
81 Ok(pages_with_missing_alt)
82}
83
84fn inject_max_snippet(html: &str) -> String {
86 if html.contains("max-snippet") {
87 return html.to_string();
88 }
89 let tag = "<meta name=\"robots\" content=\"max-snippet:-1, max-image-preview:large, max-video-preview:-1\">\n";
90 inject_before_head_close(html, tag)
91}
92
93fn check_alt_text(
95 path: &Path,
96 html: &str,
97 site_dir: &Path,
98 counter: &mut usize,
99) {
100 let missing = count_missing_alt(html);
101 if missing > 0 {
102 let rel = path.strip_prefix(site_dir).unwrap_or(path).display();
103 log::warn!("[ai] {missing} image(s) missing alt text in {rel}");
104 *counter += 1;
105 }
106}
107
108fn collect_page_entries(
117 site_dir: &Path,
118) -> Result<Vec<(String, String, String)>> {
119 let html_files = collect_html_files(site_dir)?;
120 let mut entries = Vec::new();
121
122 for html_path in &html_files {
123 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
124
125 let sidecar_path = html_path.with_extension("meta.json");
127 let meta: serde_json::Map<String, serde_json::Value> =
128 if sidecar_path.exists() {
129 if let Ok(content) = fs::read_to_string(&sidecar_path) {
130 serde_json::from_str(&content).unwrap_or_default()
131 } else {
132 serde_json::Map::new()
133 }
134 } else {
135 serde_json::Map::new()
136 };
137
138 if is_excluded_page(rel, &meta) {
139 continue;
140 }
141
142 let title = meta
143 .get("title")
144 .and_then(serde_json::Value::as_str)
145 .unwrap_or_default()
146 .to_string();
147 let description = meta
148 .get("description")
149 .and_then(serde_json::Value::as_str)
150 .unwrap_or_default()
151 .to_string();
152
153 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
155
156 if !title.is_empty() {
157 entries.push((title, url, description));
158 }
159 }
160
161 Ok(entries)
162}
163
164fn is_excluded_page(
168 path: &Path,
169 frontmatter: &serde_json::Map<String, serde_json::Value>,
170) -> bool {
171 let file_name = path
173 .file_name()
174 .map(|n| n.to_string_lossy().to_lowercase())
175 .unwrap_or_default();
176 if file_name == "404.html" || file_name.starts_with("error") {
177 return true;
178 }
179
180 if let Some(draft) = frontmatter.get("draft") {
182 if draft.as_bool().unwrap_or(false)
183 || draft.as_str().is_some_and(|s| s == "true")
184 {
185 return true;
186 }
187 }
188
189 if let Some(private) = frontmatter.get("private") {
191 if private.as_bool().unwrap_or(false)
192 || private.as_str().is_some_and(|s| s == "true")
193 {
194 return true;
195 }
196 }
197
198 false
199}
200
201fn group_pages_by_section(
206 entries: &[(String, String, String)],
207) -> BTreeMap<String, Vec<(String, String, String)>> {
208 let mut sections: BTreeMap<String, Vec<(String, String, String)>> =
209 BTreeMap::new();
210
211 for (title, url, description) in entries {
212 let trimmed = url.trim_start_matches('/');
214 let section = if let Some(slash) = trimmed.find('/') {
215 let dir = &trimmed[..slash];
216 titlecase_word(dir)
217 } else {
218 "Pages".to_string()
219 };
220
221 sections.entry(section).or_default().push((
222 title.clone(),
223 url.clone(),
224 description.clone(),
225 ));
226 }
227
228 sections
229}
230
231fn titlecase_word(s: &str) -> String {
233 let mut chars = s.chars();
234 match chars.next() {
235 None => String::new(),
236 Some(first) => {
237 let upper: String = first.to_uppercase().collect();
238 format!("{upper}{}", chars.as_str().to_lowercase())
239 }
240 }
241}
242
243fn parse_robots_disallow(site_dir: &Path) -> Vec<String> {
245 let robots_path = site_dir.join("robots.txt");
246 let Ok(content) = fs::read_to_string(&robots_path) else {
247 return Vec::new();
248 };
249
250 content
251 .lines()
252 .filter_map(|line| {
253 let trimmed = line.trim();
254 if let Some(rest) = trimmed.strip_prefix("Disallow:") {
255 let pattern = rest.trim();
256 if !pattern.is_empty() {
257 return Some(pattern.to_string());
258 }
259 }
260 None
261 })
262 .collect()
263}
264
265fn generate_llms_txt(
282 site_dir: &Path,
283 config: Option<&crate::cmd::SsgConfig>,
284) -> Result<()> {
285 let site_name = config.map_or("Site", |c| c.site_name.as_str());
286 let base_url = config.map_or("", |c| c.base_url.as_str());
287 let description = config.map_or("", |c| c.site_description.as_str());
288 let language = config
289 .map(|c| c.language.as_str())
290 .filter(|l| !l.is_empty())
291 .unwrap_or("en");
292 let canonical_root = base_url.trim_end_matches('/');
293
294 let mut content =
295 format!("# {site_name}\n\n> {description}\n\nLanguage: {language}\n");
296
297 let entries = collect_page_entries(site_dir).unwrap_or_default();
299 let sections = group_pages_by_section(&entries);
300
301 for (section, pages) in §ions {
302 content.push_str(&format!("\n## {section}\n"));
303 for (title, url, desc) in pages {
304 let full_url = if canonical_root.is_empty() {
305 url.clone()
306 } else {
307 format!("{canonical_root}{url}")
308 };
309 if desc.is_empty() {
310 content.push_str(&format!("- [{title}]({full_url})\n"));
311 } else {
312 content.push_str(&format!("- [{title}]({full_url}): {desc}\n"));
313 }
314 }
315 }
316
317 let disallow = parse_robots_disallow(site_dir);
319 if !disallow.is_empty() {
320 content.push_str("\n## Disallow\n");
321 for pattern in &disallow {
322 content.push_str(&format!("- {pattern}\n"));
323 }
324 }
325
326 fs::write(site_dir.join("llms.txt"), content)?;
327 log::info!("[ai] Generated llms.txt");
328 Ok(())
329}
330
331fn generate_llms_full_txt(
336 site_dir: &Path,
337 config: Option<&crate::cmd::SsgConfig>,
338) -> Result<()> {
339 let site_name = config.map_or("Site", |c| c.site_name.as_str());
340 let base_url = config.map_or("", |c| c.base_url.as_str());
341 let description = config.map_or("", |c| c.site_description.as_str());
342 let language = config
343 .map(|c| c.language.as_str())
344 .filter(|l| !l.is_empty())
345 .unwrap_or("en");
346 let canonical_root = base_url.trim_end_matches('/');
347
348 let mut content =
349 format!("# {site_name}\n\n> {description}\n\nLanguage: {language}\n");
350
351 let html_files = collect_html_files(site_dir)?;
352
353 for html_path in &html_files {
354 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
355
356 let sidecar_path = html_path.with_extension("meta.json");
358 let meta: serde_json::Map<String, serde_json::Value> =
359 if sidecar_path.exists() {
360 if let Ok(c) = fs::read_to_string(&sidecar_path) {
361 serde_json::from_str(&c).unwrap_or_default()
362 } else {
363 serde_json::Map::new()
364 }
365 } else {
366 serde_json::Map::new()
367 };
368
369 if is_excluded_page(rel, &meta) {
370 continue;
371 }
372
373 let title = meta
374 .get("title")
375 .and_then(serde_json::Value::as_str)
376 .unwrap_or_default();
377
378 if title.is_empty() {
379 continue;
380 }
381
382 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
383 let full_url = if canonical_root.is_empty() {
384 url.clone()
385 } else {
386 format!("{canonical_root}{url}")
387 };
388
389 let html = fs::read_to_string(html_path).unwrap_or_default();
391 let body_text = strip_html_tags(&extract_body(&html));
392 let trimmed = collapse_whitespace(&body_text);
393
394 content.push_str(&format!("\n---\n\n## [{title}]({full_url})\n\n"));
395 if !trimmed.is_empty() {
396 content.push_str(&trimmed);
397 content.push('\n');
398 }
399 }
400
401 fs::write(site_dir.join("llms-full.txt"), content)?;
402 log::info!("[ai] Generated llms-full.txt");
403 Ok(())
404}
405
406fn extract_body(html: &str) -> String {
408 let lower = html.to_lowercase();
409 let start = lower
410 .find("<body")
411 .and_then(|i| lower[i..].find('>').map(|j| i + j + 1))
412 .unwrap_or(0);
413 let end = lower.find("</body>").unwrap_or(html.len());
414 html[start..end].to_string()
415}
416
417fn strip_html_tags(html: &str) -> String {
419 let mut result = String::with_capacity(html.len());
420 let mut in_tag = false;
421 for ch in html.chars() {
422 match ch {
423 '<' => in_tag = true,
424 '>' => in_tag = false,
425 _ if !in_tag => result.push(ch),
426 _ => {}
427 }
428 }
429 result
430}
431
432fn collapse_whitespace(s: &str) -> String {
434 let mut result = String::with_capacity(s.len());
435 let mut prev_ws = true; for ch in s.chars() {
437 if ch.is_whitespace() {
438 if !prev_ws {
439 result.push(' ');
440 prev_ws = true;
441 }
442 } else {
443 result.push(ch);
444 prev_ws = false;
445 }
446 }
447 if result.ends_with(' ') {
449 let _ = result.pop();
450 }
451 result
452}
453
454fn count_missing_alt(html: &str) -> usize {
461 let lower = html.to_ascii_lowercase();
462 let mut count = 0;
463 let mut pos = 0;
464 while let Some(start) = lower[pos..].find("<img") {
465 let abs = pos + start;
466 let tag_end = ssg_a11y::find_tag_end(&lower, abs);
467 if ssg_a11y::img_missing_alt(&lower[abs..tag_end]) {
468 count += 1;
469 }
470 pos = tag_end;
471 }
472 count
473}
474
475fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
477 fail_point!("ai::collect-html-files", |_| {
478 Err(anyhow::anyhow!("injected: ai::collect-html-files"))
479 });
480 crate::walk::walk_files(dir, "html").map_err(Into::into)
481}
482
483#[cfg(test)]
484mod tests {
485 #![allow(clippy::unwrap_used, clippy::expect_used)]
486
487 use super::*;
488 use crate::cmd::SsgConfig;
489 use crate::test_support::init_logger;
490 use std::path::PathBuf;
491 use tempfile::{tempdir, TempDir};
492
493 fn make_site() -> (TempDir, PathBuf, PluginContext) {
498 init_logger();
499 let dir = tempdir().expect("create tempdir");
500 let site = dir.path().join("site");
501 fs::create_dir_all(&site).expect("mkdir site");
502 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
503 (dir, site, ctx)
504 }
505
506 fn write_page(
508 site: &Path,
509 rel_path: &str,
510 title: &str,
511 description: &str,
512 extra_fields: &str,
513 ) {
514 let html_path = site.join(rel_path);
515 if let Some(parent) = html_path.parent() {
516 fs::create_dir_all(parent).unwrap();
517 }
518 let html = format!(
519 "<html><head><title>{title}</title></head>\
520 <body><h1>{title}</h1><p>{description}</p></body></html>"
521 );
522 fs::write(&html_path, html).unwrap();
523
524 let mut sidecar_json =
525 format!(r#"{{"title": "{title}", "description": "{description}""#);
526 if !extra_fields.is_empty() {
527 sidecar_json.push_str(", ");
528 sidecar_json.push_str(extra_fields);
529 }
530 sidecar_json.push('}');
531 fs::write(html_path.with_extension("meta.json"), sidecar_json).unwrap();
532 }
533
534 #[test]
539 fn ai_plugin_is_copy_after_move() {
540 let plugin = AiPlugin;
542 let _copy = plugin;
543 assert_eq!(plugin.name(), "ai");
544 }
545
546 #[test]
547 fn name_returns_static_ai_identifier() {
548 assert_eq!(AiPlugin.name(), "ai");
549 }
550
551 #[test]
556 fn count_missing_alt_table_driven() {
557 let cases: &[(&str, usize, &str)] = &[
558 (
560 r#"<img src="a.jpg" alt="ok">"#,
561 0,
562 "alt present and non-empty",
563 ),
564 (r#"<img src="a.jpg">"#, 1, "no alt attribute at all"),
565 (r#"<img src="a.jpg" alt="">"#, 1, "empty double-quoted alt"),
566 (r#"<img src="a.jpg" alt=''>"#, 1, "empty single-quoted alt"),
567 (
568 r#"<img src="a.jpg"><img src="b.jpg" alt="ok">"#,
569 1,
570 "first missing, second ok",
571 ),
572 (
573 r#"<img src="a.jpg"><img src="b.jpg">"#,
574 2,
575 "both missing — sequential scan progresses",
576 ),
577 ("", 0, "empty input → zero"),
578 ("<p>no images here</p>", 0, "no <img> tags at all"),
579 (r#"<IMG SRC="a.jpg" ALT="ok">"#, 0, "case-insensitive ALT"),
580 (r#"<IMG SRC="a.jpg">"#, 1, "uppercase tag, no alt"),
581 (
582 "<img class=\"brand-mark\" src=\"/assets/logo.svg\"\n \
583 width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />",
584 0,
585 "empty alt on an aria-hidden image is decorative",
586 ),
587 (
588 r#"<img src="a.jpg" alt="" role="presentation">"#,
589 0,
590 "empty alt on a role=presentation image is decorative",
591 ),
592 (
593 r#"<img src="a.jpg" aria-hidden="true">"#,
594 1,
595 "aria-hidden does not excuse a missing alt attribute",
596 ),
597 (
598 r#"<img src="a.jpg" alt="" aria-hidden="false">"#,
599 1,
600 "aria-hidden=false is not decorative",
601 ),
602 (r#"<img src=a.jpg alt=photo>"#, 0, "unquoted alt"),
603 (
604 r#"<img src="data:image/svg+xml,<svg>" alt="ok">"#,
605 0,
606 "a '>' inside a quoted src does not end the tag",
607 ),
608 ];
609 for (input, expected, comment) in cases {
610 assert_eq!(
611 count_missing_alt(input),
612 *expected,
613 "{comment}: count_missing_alt({input:?})"
614 );
615 }
616 }
617
618 #[test]
619 fn count_missing_alt_unterminated_tag_does_not_panic() {
620 let result = count_missing_alt("<img src=foo");
621 assert!(result <= 1);
622 }
623
624 #[test]
629 fn test_parse_robots_disallow() {
630 let dir = tempdir().expect("tempdir");
631
632 fs::write(
634 dir.path().join("robots.txt"),
635 "User-agent: *\nDisallow: /admin/\nDisallow: /private/\nAllow: /\n",
636 )
637 .unwrap();
638 let result = parse_robots_disallow(dir.path());
639 assert_eq!(result, vec!["/admin/", "/private/"]);
640 }
641
642 #[test]
643 fn test_parse_robots_disallow_empty_file() {
644 let dir = tempdir().expect("tempdir");
645 fs::write(dir.path().join("robots.txt"), "").unwrap();
646 let result = parse_robots_disallow(dir.path());
647 assert!(result.is_empty(), "{:?}", result);
648 }
649
650 #[test]
651 fn test_parse_robots_disallow_no_disallow_lines() {
652 let dir = tempdir().expect("tempdir");
653 fs::write(
654 dir.path().join("robots.txt"),
655 "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml\n",
656 )
657 .unwrap();
658 let result = parse_robots_disallow(dir.path());
659 assert!(result.is_empty(), "{:?}", result);
660 }
661
662 #[test]
663 fn test_parse_robots_disallow_multiple_user_agents() {
664 let dir = tempdir().expect("tempdir");
665 fs::write(
666 dir.path().join("robots.txt"),
667 "User-agent: Googlebot\nDisallow: /nogoogle/\n\n\
668 User-agent: *\nDisallow: /secret/\n",
669 )
670 .unwrap();
671 let result = parse_robots_disallow(dir.path());
672 assert_eq!(result, vec!["/nogoogle/", "/secret/"]);
673 }
674
675 #[test]
676 fn test_parse_robots_disallow_missing_file() {
677 let dir = tempdir().expect("tempdir");
678 let result = parse_robots_disallow(dir.path());
679 assert!(result.is_empty(), "{:?}", result);
680 }
681
682 #[test]
683 fn test_parse_robots_disallow_empty_pattern_skipped() {
684 let dir = tempdir().expect("tempdir");
686 fs::write(
687 dir.path().join("robots.txt"),
688 "User-agent: *\nDisallow:\nDisallow: /blocked/\n",
689 )
690 .unwrap();
691 let result = parse_robots_disallow(dir.path());
692 assert_eq!(result, vec!["/blocked/"]);
693 }
694
695 #[test]
700 fn test_is_excluded_page_draft() {
701 let mut meta = serde_json::Map::new();
702 let _ = meta.insert("draft".to_string(), serde_json::Value::Bool(true));
703 assert!(is_excluded_page(Path::new("post.html"), &meta));
704 }
705
706 #[test]
707 fn test_is_excluded_page_draft_string() {
708 let mut meta = serde_json::Map::new();
709 let _ = meta.insert(
710 "draft".to_string(),
711 serde_json::Value::String("true".to_string()),
712 );
713 assert!(is_excluded_page(Path::new("post.html"), &meta));
714 }
715
716 #[test]
717 fn test_is_excluded_page_private() {
718 let mut meta = serde_json::Map::new();
719 let _ =
720 meta.insert("private".to_string(), serde_json::Value::Bool(true));
721 assert!(is_excluded_page(Path::new("post.html"), &meta));
722 }
723
724 #[test]
725 fn test_is_excluded_page_404() {
726 let meta = serde_json::Map::new();
727 assert!(is_excluded_page(Path::new("404.html"), &meta));
728 }
729
730 #[test]
731 fn test_is_excluded_page_normal() {
732 let mut meta = serde_json::Map::new();
733 let _ = meta.insert(
734 "title".to_string(),
735 serde_json::Value::String("Hello".to_string()),
736 );
737 assert!(!is_excluded_page(Path::new("index.html"), &meta));
738 }
739
740 #[test]
741 fn test_is_excluded_page_error_page() {
742 let meta = serde_json::Map::new();
743 assert!(is_excluded_page(Path::new("error500.html"), &meta));
744 }
745
746 #[test]
751 fn test_group_pages_by_section() {
752 let entries = vec![
753 (
754 "Home".to_string(),
755 "/index.html".to_string(),
756 "Welcome".to_string(),
757 ),
758 (
759 "Post 1".to_string(),
760 "/blog/post1.html".to_string(),
761 "First".to_string(),
762 ),
763 (
764 "Post 2".to_string(),
765 "/blog/post2.html".to_string(),
766 "Second".to_string(),
767 ),
768 (
769 "API Ref".to_string(),
770 "/docs/api.html".to_string(),
771 "API docs".to_string(),
772 ),
773 ];
774 let grouped = group_pages_by_section(&entries);
775
776 assert_eq!(grouped.len(), 3);
777 assert!(grouped.contains_key("Pages"));
778 assert!(grouped.contains_key("Blog"));
779 assert!(grouped.contains_key("Docs"));
780 assert_eq!(grouped["Pages"].len(), 1);
781 assert_eq!(grouped["Blog"].len(), 2);
782 assert_eq!(grouped["Docs"].len(), 1);
783 }
784
785 #[test]
786 fn test_group_pages_by_section_root_only() {
787 let entries = vec![
788 (
789 "About".to_string(),
790 "/about.html".to_string(),
791 String::new(),
792 ),
793 (
794 "Contact".to_string(),
795 "/contact.html".to_string(),
796 String::new(),
797 ),
798 ];
799 let grouped = group_pages_by_section(&entries);
800 assert_eq!(grouped.len(), 1);
801 assert_eq!(grouped["Pages"].len(), 2);
802 }
803
804 #[test]
805 fn test_group_pages_by_section_deterministic_order() {
806 let entries = vec![
807 ("Z".to_string(), "/zebra/z.html".to_string(), String::new()),
808 ("A".to_string(), "/alpha/a.html".to_string(), String::new()),
809 ("M".to_string(), "/middle/m.html".to_string(), String::new()),
810 ];
811 let grouped = group_pages_by_section(&entries);
812 let keys: Vec<&String> = grouped.keys().collect();
813 assert_eq!(keys, vec!["Alpha", "Middle", "Zebra"]);
814 }
815
816 #[test]
821 #[serial_test::parallel(ai_failpoint)]
822 fn generate_llms_txt_with_full_config_includes_all_fields() {
823 let dir = tempdir().expect("tempdir");
824 let config = SsgConfig {
825 site_name: "My Site".to_string(),
826 site_description: "A great site".to_string(),
827 base_url: "https://example.com".to_string(),
828 language: "en".to_string(),
829 ..Default::default()
830 };
831
832 generate_llms_txt(dir.path(), Some(&config)).unwrap();
833 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
834 assert!(body.contains("# My Site"));
835 assert!(body.contains("> A great site"));
836 assert!(body.contains("Language: en"));
837 }
838
839 #[test]
840 #[serial_test::parallel(ai_failpoint)]
841 fn generate_llms_txt_without_config_uses_defaults() {
842 let dir = tempdir().expect("tempdir");
843 generate_llms_txt(dir.path(), None).unwrap();
844
845 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
846 assert!(body.contains("# Site"));
847 assert!(body.contains("Language: en"));
848 }
849
850 #[test]
851 #[serial_test::parallel(ai_failpoint)]
852 fn generate_llms_txt_strips_trailing_slash_from_base_url() {
853 let dir = tempdir().expect("tempdir");
854 let config = SsgConfig {
855 site_name: "S".to_string(),
856 site_description: "D".to_string(),
857 base_url: "https://example.com/".to_string(),
858 ..Default::default()
859 };
860
861 write_page(dir.path(), "index.html", "Home", "Welcome", "");
863
864 generate_llms_txt(dir.path(), Some(&config)).unwrap();
865 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
866 assert!(
868 !body.contains("//index.html"),
869 "trailing slash should be normalised:\n{body}"
870 );
871 }
872
873 #[test]
874 #[serial_test::parallel(ai_failpoint)]
875 fn generate_llms_txt_into_missing_parent_returns_err() {
876 let bogus = Path::new("/this/path/should/not/exist");
877 assert!(generate_llms_txt(bogus, None).is_err());
878 }
879
880 #[test]
881 #[serial_test::parallel(ai_failpoint)]
882 fn test_llms_txt_contains_language() {
883 let dir = tempdir().expect("tempdir");
884 let config = SsgConfig {
885 language: "fr".to_string(),
886 ..Default::default()
887 };
888 generate_llms_txt(dir.path(), Some(&config)).unwrap();
889 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
890 assert!(
891 body.contains("Language: fr"),
892 "llms.txt must include Language field:\n{body}"
893 );
894 }
895
896 #[test]
897 #[serial_test::parallel(ai_failpoint)]
898 fn test_llms_txt_contains_language_defaults_to_en() {
899 let dir = tempdir().expect("tempdir");
900 let config = SsgConfig {
901 language: String::new(),
902 ..Default::default()
903 };
904 generate_llms_txt(dir.path(), Some(&config)).unwrap();
905 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
906 assert!(
907 body.contains("Language: en"),
908 "empty language should default to en:\n{body}"
909 );
910 }
911
912 #[test]
913 #[serial_test::parallel(ai_failpoint)]
914 fn test_llms_txt_excludes_drafts() {
915 let dir = tempdir().expect("tempdir");
916 write_page(dir.path(), "published.html", "Published", "Visible", "");
917 write_page(
918 dir.path(),
919 "draft.html",
920 "Draft Post",
921 "Hidden",
922 r#""draft": true"#,
923 );
924
925 generate_llms_txt(dir.path(), None).unwrap();
926 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
927 assert!(
928 body.contains("Published"),
929 "published page must appear:\n{body}"
930 );
931 assert!(
932 !body.contains("Draft Post"),
933 "draft page must be excluded:\n{body}"
934 );
935 }
936
937 #[test]
938 #[serial_test::parallel(ai_failpoint)]
939 fn test_llms_txt_excludes_private() {
940 let dir = tempdir().expect("tempdir");
941 write_page(dir.path(), "public.html", "Public", "Visible", "");
942 write_page(
943 dir.path(),
944 "secret.html",
945 "Secret",
946 "Hidden",
947 r#""private": true"#,
948 );
949
950 generate_llms_txt(dir.path(), None).unwrap();
951 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
952 assert!(
953 !body.contains("Secret"),
954 "private page must be excluded:\n{body}"
955 );
956 }
957
958 #[test]
959 #[serial_test::parallel(ai_failpoint)]
960 fn test_llms_txt_excludes_404() {
961 let dir = tempdir().expect("tempdir");
962 write_page(dir.path(), "index.html", "Home", "Welcome", "");
963 write_page(dir.path(), "404.html", "Not Found", "Error page", "");
964
965 generate_llms_txt(dir.path(), None).unwrap();
966 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
967 assert!(
968 !body.contains("Not Found"),
969 "404 page must be excluded:\n{body}"
970 );
971 }
972
973 #[test]
974 #[serial_test::parallel(ai_failpoint)]
975 fn test_llms_txt_contains_sections() {
976 let dir = tempdir().expect("tempdir");
977 write_page(dir.path(), "index.html", "Home", "Welcome", "");
978 write_page(dir.path(), "blog/post.html", "My Post", "A blog post", "");
979 write_page(
980 dir.path(),
981 "docs/api.html",
982 "API Docs",
983 "API reference",
984 "",
985 );
986
987 generate_llms_txt(dir.path(), None).unwrap();
988 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
989 assert!(
990 body.contains("## Pages"),
991 "should have Pages section:\n{body}"
992 );
993 assert!(
994 body.contains("## Blog"),
995 "should have Blog section:\n{body}"
996 );
997 assert!(
998 body.contains("## Docs"),
999 "should have Docs section:\n{body}"
1000 );
1001 assert!(
1002 body.contains("- [My Post]"),
1003 "should contain page link:\n{body}"
1004 );
1005 }
1006
1007 #[test]
1008 #[serial_test::parallel(ai_failpoint)]
1009 fn test_llms_txt_contains_disallow_section() {
1010 let dir = tempdir().expect("tempdir");
1011 fs::write(
1012 dir.path().join("robots.txt"),
1013 "User-agent: *\nDisallow: /admin/\n",
1014 )
1015 .unwrap();
1016
1017 generate_llms_txt(dir.path(), None).unwrap();
1018 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1019 assert!(
1020 body.contains("## Disallow"),
1021 "should have Disallow section:\n{body}"
1022 );
1023 assert!(
1024 body.contains("- /admin/"),
1025 "should contain disallow pattern:\n{body}"
1026 );
1027 }
1028
1029 #[test]
1030 #[serial_test::parallel(ai_failpoint)]
1031 fn test_llms_txt_no_disallow_without_robots() {
1032 let dir = tempdir().expect("tempdir");
1033 generate_llms_txt(dir.path(), None).unwrap();
1034 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1035 assert!(
1036 !body.contains("## Disallow"),
1037 "no robots.txt means no Disallow section:\n{body}"
1038 );
1039 }
1040
1041 #[test]
1046 #[serial_test::parallel(ai_failpoint)]
1047 fn test_llms_full_txt_contains_body_content() {
1048 let dir = tempdir().expect("tempdir");
1049 write_page(dir.path(), "index.html", "Home", "Welcome home", "");
1050
1051 generate_llms_full_txt(dir.path(), None).unwrap();
1052 let body =
1053 fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1054 assert!(body.contains("# Site"), "header present:\n{body}");
1055 assert!(body.contains("Language: en"), "language present:\n{body}");
1056 assert!(body.contains("## [Home]"), "page title present:\n{body}");
1057 assert!(body.contains("Welcome home"), "body text present:\n{body}");
1058 }
1059
1060 #[test]
1061 #[serial_test::parallel(ai_failpoint)]
1062 fn test_llms_full_txt_excludes_drafts() {
1063 let dir = tempdir().expect("tempdir");
1064 write_page(dir.path(), "ok.html", "Visible", "Content", "");
1065 write_page(
1066 dir.path(),
1067 "hidden.html",
1068 "Hidden",
1069 "Secret",
1070 r#""draft": true"#,
1071 );
1072
1073 generate_llms_full_txt(dir.path(), None).unwrap();
1074 let body =
1075 fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1076 assert!(body.contains("Visible"), "published page present:\n{body}");
1077 assert!(!body.contains("Hidden"), "draft excluded:\n{body}");
1078 }
1079
1080 #[test]
1081 #[serial_test::parallel(ai_failpoint)]
1082 fn test_llms_full_txt_excludes_404() {
1083 let dir = tempdir().expect("tempdir");
1084 write_page(dir.path(), "index.html", "Home", "Welcome", "");
1085 write_page(dir.path(), "404.html", "Not Found", "Error", "");
1086
1087 generate_llms_full_txt(dir.path(), None).unwrap();
1088 let body =
1089 fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1090 assert!(!body.contains("Not Found"), "404 excluded:\n{body}");
1091 }
1092
1093 #[test]
1098 fn test_strip_html_tags() {
1099 assert_eq!(strip_html_tags("<p>hello</p>"), "hello");
1100 assert_eq!(strip_html_tags("<div><b>bold</b> text</div>"), "bold text");
1101 assert_eq!(strip_html_tags("no tags"), "no tags");
1102 assert_eq!(strip_html_tags(""), "");
1103 }
1104
1105 #[test]
1106 fn test_extract_body() {
1107 let html =
1108 "<html><head><title>T</title></head><body>Content</body></html>";
1109 assert_eq!(extract_body(html), "Content");
1110 }
1111
1112 #[test]
1113 fn test_extract_body_with_attributes() {
1114 let html = "<html><body class=\"main\">Content</body></html>";
1115 assert_eq!(extract_body(html), "Content");
1116 }
1117
1118 #[test]
1119 fn test_extract_body_no_body_tag() {
1120 let html = "<p>Just a fragment</p>";
1121 assert_eq!(extract_body(html), html);
1122 }
1123
1124 #[test]
1125 fn test_collapse_whitespace() {
1126 assert_eq!(collapse_whitespace(" hello world "), "hello world");
1127 assert_eq!(collapse_whitespace("no extra"), "no extra");
1128 assert_eq!(collapse_whitespace(""), "");
1129 }
1130
1131 #[test]
1136 fn test_titlecase_word() {
1137 assert_eq!(titlecase_word("blog"), "Blog");
1138 assert_eq!(titlecase_word("DOCS"), "Docs");
1139 assert_eq!(titlecase_word(""), "");
1140 assert_eq!(titlecase_word("a"), "A");
1141 }
1142
1143 #[test]
1148 fn after_compile_missing_site_dir_returns_ok_without_writing() {
1149 let dir = tempdir().expect("tempdir");
1150 let missing = dir.path().join("missing");
1151 let ctx =
1152 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
1153
1154 AiPlugin.after_compile(&ctx).expect("missing site is fine");
1155 assert!(!missing.exists());
1156 assert!(!dir.path().join("llms.txt").exists());
1157 }
1158
1159 #[test]
1160 #[serial_test::parallel(ai_failpoint)]
1161 fn after_compile_injects_max_snippet_meta_tag() {
1162 let (_tmp, site, ctx) = make_site();
1163 let html = "<html><head><title>X</title></head><body></body></html>";
1164 fs::write(site.join("index.html"), html).unwrap();
1165
1166 AiPlugin.after_compile(&ctx).unwrap();
1167 let output = fs::read_to_string(site.join("index.html")).unwrap();
1168 assert!(output.contains("max-snippet"));
1169 assert!(output.contains("max-image-preview:large"));
1170 }
1171
1172 #[test]
1173 #[serial_test::parallel(ai_failpoint)]
1174 fn after_compile_creates_llms_txt_in_site_root() {
1175 let (_tmp, site, ctx) = make_site();
1176 AiPlugin.after_compile(&ctx).unwrap();
1177 assert!(site.join("llms.txt").exists());
1178 }
1179
1180 #[test]
1181 #[serial_test::parallel(ai_failpoint)]
1182 fn after_compile_creates_llms_full_txt_in_site_root() {
1183 let (_tmp, site, ctx) = make_site();
1184 AiPlugin.after_compile(&ctx).unwrap();
1185 assert!(site.join("llms-full.txt").exists());
1186 }
1187
1188 #[test]
1189 #[serial_test::parallel(ai_failpoint)]
1190 fn after_compile_idempotent_does_not_duplicate_meta_tag() {
1191 let (_tmp, site, ctx) = make_site();
1192 let html = "<html><head><title>X</title></head><body></body></html>";
1193 fs::write(site.join("index.html"), html).unwrap();
1194
1195 AiPlugin.after_compile(&ctx).unwrap();
1196 AiPlugin.after_compile(&ctx).unwrap();
1197
1198 let output = fs::read_to_string(site.join("index.html")).unwrap();
1199 assert_eq!(output.matches("max-snippet").count(), 1);
1200 }
1201
1202 #[test]
1203 #[serial_test::parallel(ai_failpoint)]
1204 fn after_compile_skips_html_files_without_head_tag() {
1205 let (_tmp, site, ctx) = make_site();
1206 fs::write(site.join("fragment.html"), "<p>just a fragment</p>")
1207 .unwrap();
1208
1209 AiPlugin.after_compile(&ctx).unwrap();
1210 let output = fs::read_to_string(site.join("fragment.html")).unwrap();
1211 assert!(!output.contains("max-snippet"));
1212 assert_eq!(output, "<p>just a fragment</p>");
1213 }
1214
1215 #[test]
1216 #[serial_test::parallel(ai_failpoint)]
1217 fn after_compile_processes_files_in_subdirectories() {
1218 let (_tmp, site, ctx) = make_site();
1219 let nested = site.join("blog");
1220 fs::create_dir_all(&nested).unwrap();
1221 fs::write(
1222 nested.join("post.html"),
1223 "<html><head></head><body></body></html>",
1224 )
1225 .unwrap();
1226
1227 AiPlugin.after_compile(&ctx).unwrap();
1228 let output = fs::read_to_string(nested.join("post.html")).unwrap();
1229 assert!(output.contains("max-snippet"));
1230 }
1231
1232 #[test]
1233 #[serial_test::parallel(ai_failpoint)]
1234 fn after_compile_logs_warning_for_pages_with_missing_alt() {
1235 let (_tmp, site, ctx) = make_site();
1236 fs::write(
1237 site.join("bad.html"),
1238 r#"<html><head></head><body><img src="a.jpg"></body></html>"#,
1239 )
1240 .unwrap();
1241 fs::write(
1242 site.join("worse.html"),
1243 r#"<html><head></head><body><img src="a.jpg" alt=""></body></html>"#,
1244 )
1245 .unwrap();
1246
1247 AiPlugin.after_compile(&ctx).unwrap();
1248 let bad = fs::read_to_string(site.join("bad.html")).unwrap();
1249 assert!(bad.contains("max-snippet"));
1250 }
1251
1252 #[test]
1253 #[serial_test::parallel(ai_failpoint)]
1254 fn after_compile_does_not_rewrite_unchanged_files() {
1255 let (_tmp, site, ctx) = make_site();
1256 let html = "<html><head><meta name=\"robots\" content=\"max-snippet:-1\"></head><body></body></html>";
1257 fs::write(site.join("index.html"), html).unwrap();
1258 let original_mtime = fs::metadata(site.join("index.html"))
1259 .unwrap()
1260 .modified()
1261 .unwrap();
1262
1263 AiPlugin.after_compile(&ctx).unwrap();
1264 let after = fs::read_to_string(site.join("index.html")).unwrap();
1265 assert_eq!(after, html, "unchanged file body must be preserved");
1266 let _ = original_mtime;
1267 }
1268
1269 #[test]
1274 #[serial_test::parallel(ai_failpoint)]
1275 fn collect_html_files_returns_empty_for_missing_directory() {
1276 let dir = tempdir().expect("tempdir");
1277 let result = collect_html_files(&dir.path().join("missing")).unwrap();
1278 assert!(result.is_empty(), "{:?}", result);
1279 }
1280
1281 #[test]
1282 #[serial_test::parallel(ai_failpoint)]
1283 fn collect_html_files_filters_non_html_extensions() {
1284 let dir = tempdir().expect("tempdir");
1285 fs::write(dir.path().join("a.html"), "").unwrap();
1286 fs::write(dir.path().join("b.css"), "").unwrap();
1287 fs::write(dir.path().join("c.js"), "").unwrap();
1288
1289 let result = collect_html_files(dir.path()).unwrap();
1290 assert_eq!(result.len(), 1);
1291 }
1292
1293 #[test]
1294 #[serial_test::parallel(ai_failpoint)]
1295 fn collect_html_files_recurses_into_nested_subdirectories() {
1296 let dir = tempdir().expect("tempdir");
1297 let nested = dir.path().join("a").join("b");
1298 fs::create_dir_all(&nested).unwrap();
1299 fs::write(dir.path().join("top.html"), "").unwrap();
1300 fs::write(nested.join("deep.html"), "").unwrap();
1301
1302 let result = collect_html_files(dir.path()).unwrap();
1303 assert_eq!(result.len(), 2);
1304 }
1305
1306 #[test]
1307 #[serial_test::parallel(ai_failpoint)]
1308 fn collect_html_files_returns_results_sorted() {
1309 let dir = tempdir().expect("tempdir");
1310 for name in ["zebra.html", "apple.html", "mango.html"] {
1311 fs::write(dir.path().join(name), "").unwrap();
1312 }
1313 let result = collect_html_files(dir.path()).unwrap();
1314 let names: Vec<_> = result
1315 .iter()
1316 .map(|p| p.file_name().unwrap().to_str().unwrap())
1317 .collect();
1318 assert_eq!(names, vec!["apple.html", "mango.html", "zebra.html"]);
1319 }
1320
1321 #[test]
1326 fn test_is_excluded_page_draft_false_is_not_excluded() {
1327 let mut meta = serde_json::Map::new();
1328 let _ =
1329 meta.insert("draft".to_string(), serde_json::Value::Bool(false));
1330 assert!(!is_excluded_page(Path::new("post.html"), &meta));
1331 }
1332
1333 #[test]
1334 fn test_is_excluded_page_private_string_true() {
1335 let mut meta = serde_json::Map::new();
1336 let _ = meta.insert(
1337 "private".to_string(),
1338 serde_json::Value::String("true".to_string()),
1339 );
1340 assert!(is_excluded_page(Path::new("post.html"), &meta));
1341 }
1342
1343 #[test]
1344 fn test_is_excluded_page_private_false_is_not_excluded() {
1345 let mut meta = serde_json::Map::new();
1346 let _ =
1347 meta.insert("private".to_string(), serde_json::Value::Bool(false));
1348 assert!(!is_excluded_page(Path::new("post.html"), &meta));
1349 }
1350
1351 #[test]
1357 #[serial_test::parallel(ai_failpoint)]
1358 fn collect_page_entries_unreadable_sidecar_falls_back_to_empty_meta() {
1359 let dir = tempdir().expect("tempdir");
1360 fs::write(
1361 dir.path().join("page.html"),
1362 "<html><head></head><body>Hi</body></html>",
1363 )
1364 .unwrap();
1365 fs::create_dir_all(dir.path().join("page.meta.json")).unwrap();
1369
1370 let entries = collect_page_entries(dir.path()).unwrap();
1371 assert!(
1372 entries.is_empty(),
1373 "titleless page must be dropped: {entries:?}"
1374 );
1375 }
1376
1377 #[test]
1378 #[serial_test::parallel(ai_failpoint)]
1379 fn llms_full_txt_unreadable_sidecar_skips_page() {
1380 let dir = tempdir().expect("tempdir");
1381 fs::write(
1382 dir.path().join("page.html"),
1383 "<html><head></head><body>Hidden body</body></html>",
1384 )
1385 .unwrap();
1386 fs::create_dir_all(dir.path().join("page.meta.json")).unwrap();
1387
1388 generate_llms_full_txt(dir.path(), None).unwrap();
1389 let body =
1390 fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1391 assert!(
1392 !body.contains("Hidden body"),
1393 "page without readable sidecar (no title) must be skipped:\n{body}"
1394 );
1395 }
1396
1397 #[test]
1402 #[serial_test::parallel(ai_failpoint)]
1403 fn llms_txt_entry_without_description_omits_colon_suffix() {
1404 let dir = tempdir().expect("tempdir");
1405 write_page(dir.path(), "index.html", "Home", "", "");
1406
1407 generate_llms_txt(dir.path(), None).unwrap();
1408 let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1409 assert!(
1410 body.contains("- [Home](/index.html)\n"),
1411 "description-less entry must be a bare link:\n{body}"
1412 );
1413 assert!(
1414 !body.contains("- [Home](/index.html):"),
1415 "no trailing colon without a description:\n{body}"
1416 );
1417 }
1418
1419 #[test]
1424 #[serial_test::parallel(ai_failpoint)]
1425 fn llms_full_txt_uses_canonical_root_for_urls() {
1426 let dir = tempdir().expect("tempdir");
1427 write_page(dir.path(), "index.html", "Home", "Welcome", "");
1428
1429 let config = SsgConfig {
1430 site_name: "S".to_string(),
1431 base_url: "https://example.com/".to_string(),
1432 ..Default::default()
1433 };
1434 generate_llms_full_txt(dir.path(), Some(&config)).unwrap();
1435 let body =
1436 fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1437 assert!(
1438 body.contains("## [Home](https://example.com/index.html)"),
1439 "page URL must be prefixed with the canonical root:\n{body}"
1440 );
1441 }
1442
1443 #[test]
1453 fn test_is_excluded_page_draft_string_non_true_is_not_excluded() {
1454 let mut meta = serde_json::Map::new();
1455 let _ = meta.insert(
1456 "draft".to_string(),
1457 serde_json::Value::String("no".to_string()),
1458 );
1459 assert!(!is_excluded_page(Path::new("post.html"), &meta));
1460 }
1461
1462 #[test]
1463 fn test_is_excluded_page_private_string_non_true_is_not_excluded() {
1464 let mut meta = serde_json::Map::new();
1465 let _ = meta.insert(
1466 "private".to_string(),
1467 serde_json::Value::String("no".to_string()),
1468 );
1469 assert!(!is_excluded_page(Path::new("post.html"), &meta));
1470 }
1471
1472 #[test]
1480 #[serial_test::parallel(ai_failpoint)]
1481 fn after_compile_propagates_llms_txt_write_error() {
1482 let (_tmp, site, ctx) = make_site();
1483 fs::create_dir_all(site.join("llms.txt")).unwrap();
1487 let err = AiPlugin.after_compile(&ctx).unwrap_err();
1488 assert!(!format!("{err}").is_empty(), "should not be empty");
1489 }
1490
1491 #[test]
1492 #[serial_test::parallel(ai_failpoint)]
1493 fn after_compile_propagates_llms_full_txt_write_error() {
1494 let (_tmp, site, ctx) = make_site();
1495 fs::create_dir_all(site.join("llms-full.txt")).unwrap();
1498 let err = AiPlugin.after_compile(&ctx).unwrap_err();
1499 assert!(!format!("{err}").is_empty(), "should not be empty");
1500 }
1501
1502 #[test]
1503 #[serial_test::parallel(ai_failpoint)]
1504 #[cfg(unix)]
1505 fn after_compile_fails_when_html_file_is_unreadable() {
1506 use std::os::unix::fs::PermissionsExt;
1507 let (_tmp, site, ctx) = make_site();
1508 let html = site.join("locked.html");
1509 fs::write(&html, "<html><head></head><body></body></html>").unwrap();
1510 fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1511
1512 let res = AiPlugin.after_compile(&ctx);
1513
1514 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1515 if let Err(e) = res {
1518 assert!(!format!("{e}").is_empty(), "should not be empty");
1519 }
1520 }
1521}
1522
1523#[cfg(all(test, feature = "test-fault-injection"))]
1532mod fault_tests {
1533 use super::*;
1534 use crate::plugin::PluginContext;
1535 use tempfile::tempdir;
1536
1537 struct FailGuard(&'static str);
1539
1540 impl Drop for FailGuard {
1541 fn drop(&mut self) {
1542 let _ = fail::cfg(self.0, "off");
1543 }
1544 }
1545
1546 #[test]
1547 #[serial_test::serial(ai_failpoint)]
1548 fn collect_html_files_third_call_failpoint_propagates() {
1549 let _guard = FailGuard("ai::collect-html-files");
1550 fail::cfg("ai::collect-html-files", "2*off->1*return")
1554 .expect("activate failpoint");
1555
1556 let dir = tempdir().unwrap();
1557 let site = dir.path().join("site");
1558 fs::create_dir_all(&site).unwrap();
1559 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1560
1561 let err = AiPlugin
1562 .after_compile(&ctx)
1563 .expect_err("third collect_html_files call must propagate");
1564 assert!(format!("{err:?}").contains("injected: ai::collect-html-files"));
1565 }
1566}