use crate::html::{is_site_root_path, HtmlPage};
pub fn generate_llms_txt(pages: &[HtmlPage], site_title: Option<&str>) -> String {
let mut out = String::new();
let title = site_title
.or_else(|| pages.first().and_then(|p| p.title.as_deref()))
.unwrap_or("Website");
out.push_str(&format!("# {title}\n\n"));
out.push_str(
"> Generated by Oratos. llms.txt is an emerging convention — supplement, do not replace, \
good HTML, metadata, sitemaps, and structured data.\n\n",
);
let summary = pages
.iter()
.find_map(|p| p.meta_description.clone())
.or_else(|| pages.first().map(|p| truncate(&p.main_text, 200)));
if let Some(s) = summary {
out.push_str("## Summary\n\n");
out.push_str(&s);
out.push_str("\n\n");
}
out.push_str("## Important pages\n\n");
for page in important_pages(pages) {
let desc = page_description(page);
out.push_str(&format!(
"- [{}]({})",
page.display_name(),
page.url_or_path
));
if !desc.is_empty() {
out.push_str(&format!(" — {desc}"));
}
out.push('\n');
}
out.push_str("\n## Page summaries\n\n");
for page in pages {
let summary = page_description(page);
if summary.is_empty() {
continue;
}
out.push_str(&format!("### {}\n\n", page.display_name()));
out.push_str(&summary);
out.push_str("\n\n");
}
out
}
fn important_pages(pages: &[HtmlPage]) -> Vec<&HtmlPage> {
let mut important: Vec<&HtmlPage> = pages
.iter()
.filter(|p| is_site_root_path(&p.url_or_path) || p.headings.iter().any(|h| h.level == 1))
.collect();
if important.len() < 10 {
for page in pages {
if important.iter().any(|p| p.url_or_path == page.url_or_path) {
continue;
}
important.push(page);
if important.len() == 10 {
break;
}
}
}
if important.is_empty() {
pages.iter().take(10).collect()
} else if important.len() > 10 {
important.into_iter().take(10).collect()
} else {
important
}
}
fn page_description(page: &HtmlPage) -> String {
if let Some(desc) = &page.meta_description {
return desc.clone();
}
if let Some(h1) = page.headings.iter().find(|h| h.level == 1) {
return h1.text.clone();
}
truncate(&page.main_text, 160)
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let end = char_boundary_before(s, max.saturating_sub(1));
format!("{}…", &s[..end])
}
}
fn char_boundary_before(s: &str, max: usize) -> usize {
if max >= s.len() {
return s.len();
}
let mut boundary = 0usize;
for (idx, _) in s.char_indices() {
if idx > max {
break;
}
boundary = idx;
}
boundary
}
#[cfg(test)]
mod tests {
use super::*;
use crate::html::parse_html;
#[test]
fn generates_llms_txt_header() {
let page = parse_html("/index.html", "<html><head><title>My Site</title></head><body><h1>Home</h1><p>Welcome to my site with enough text for extraction purposes here.</p></body></html>", true);
let txt = generate_llms_txt(&[page], None);
assert!(txt.contains("# My Site"));
assert!(txt.contains("Important pages"));
}
#[test]
fn truncate_handles_utf8_boundaries() {
let s = "Good Example Site — Home with extra text";
let truncated = truncate(s, 20);
assert!(truncated.ends_with('…'));
}
#[test]
fn important_pages_fills_to_ten_when_h1_subset_is_small() {
let mut pages = Vec::new();
pages.push(parse_html(
"/one.html",
"<html><head><title>One</title></head><body><h1>One</h1></body></html>",
true,
));
for i in 2..=12 {
let path = format!("/{i}.html");
pages.push(parse_html(
&path,
"<html><head><title>Page</title></head><body><p>body</p></body></html>",
true,
));
}
let selected = important_pages(&pages);
assert_eq!(selected.len(), 10);
}
}