oratos 0.3.1

Website visibility intelligence for SEO, accessibility, structured metadata, and AI readiness.
Documentation
use crate::html::{is_site_root_path, HtmlPage};

/// Generate a conservative `llms.txt` from discovered pages.
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);
    }
}