#[derive(Debug, Clone)]
pub struct Section {
pub title: String,
pub pages: Vec<(String, String, String)>, }
pub fn generate_index(sections: &[Section]) -> String {
let mut out = String::new();
out.push_str("# Documentation Index\n\n");
for section in sections {
out.push_str(&format!("## {}\n\n", section.title));
for (title, url, _relpath) in §ion.pages {
out.push_str(&format!("- [{}]({})\n", title, url));
}
out.push('\n');
}
out
}
pub type LlmsEntry = (String, String, String, Option<String>);
pub fn generate_llms_txt(pages: &[LlmsEntry]) -> String {
let mut out = String::new();
for (title, url, _body, descr) in pages {
match descr {
Some(d) if !d.trim().is_empty() => {
out.push_str(&format!("- [{}]({}): {}\n", title, url, d.trim()));
}
_ => out.push_str(&format!("- [{}]({})\n", title, url)),
}
}
out
}
pub fn generate_llms_full_txt(
site_title: &str,
site_summary: &str,
pages: &[(String, String, String)], ) -> String {
let mut out = String::new();
out.push_str(&format!("# {}\n\n", site_title));
out.push_str(&format!("> {}\n\n", site_summary));
for (title, url, body) in pages {
out.push_str(&format!("# {}\n\n", title));
out.push_str(&format!("URL: {}\n\n", url));
let body_trimmed = body.trim_end_matches('\n');
out.push_str(body_trimmed);
out.push_str("\n\n---\n\n");
}
out
}
pub type AgentSection = (String, Vec<(String, String, String)>);
pub fn build_agents_md(
site_title: &str,
source_url: &str,
generated_at: &str,
sections: &[AgentSection],
overview_first_para: Option<&str>,
) -> String {
let total_pages: usize = sections.iter().map(|(_, p)| p.len()).sum();
let mut out = String::new();
out.push_str(&format!("# {site_title} — Agent Context\n\n"));
out.push_str(&format!(
"> Auto-generated by `doc-scraper-rs` from `{source_url}` on {generated_at}. \
{total_pages} pages across {} sections.\n\n",
sections.len()
));
if let Some(para) = overview_first_para {
let trimmed = para.trim();
if !trimmed.is_empty() {
out.push_str("## What this is\n\n");
out.push_str(trimmed);
out.push_str("\n\n");
}
}
out.push_str("## Sections\n\n");
for (section_title, pages) in sections {
out.push_str(&format!("### {section_title}\n\n"));
for (title, _url, relpath) in pages {
out.push_str(&format!("- [{title}]({relpath})\n"));
}
out.push('\n');
}
out.push_str("## Per-topic deep dives\n\n");
out.push_str(
"Each top-level section is also a standalone file under `skills/`, \
for agents that prefer to load context on demand.\n\n",
);
for (idx, (section_title, pages)) in sections.iter().enumerate() {
let slug = slugify_section(section_title);
let fname = format!("{:02}-{slug}.md", idx);
out.push_str(&format!(
"- [{}](skills/{}) — {} ({} pages)\n",
fname,
fname,
section_title,
pages.len()
));
}
out.push('\n');
out.push_str("## How to use this\n\n");
out.push_str("- For a quick orientation, read \"What this is\" above.\n");
out.push_str("- For section-specific questions, read the matching `skills/NN-*.md` file — the numeric prefix matches the order in **Sections** above.\n");
out.push_str("- For exhaustive context, read `llms-full.txt` (every page concatenated).\n");
out.push_str("- For a navigable index of pages, read `llms.txt`.\n");
out.push_str("- For per-page files at the original URLs, browse the directory tree.\n");
out
}
pub fn generate_skill_md(
section_title: &str,
site_title: &str,
pages: &[(String, String, String)], ) -> String {
let mut out = String::new();
out.push_str(&format!("# {section_title} — {site_title}\n\n"));
out.push_str(&format!(
"> Topic-scoped context for {site_title}. {} pages in this section.\n\n",
pages.len()
));
for (title, url, body) in pages {
out.push_str(&format!("# {title}\n\n"));
out.push_str(&format!("URL: {url}\n\n"));
let body_trimmed = body.trim_end_matches('\n');
out.push_str(body_trimmed);
out.push_str("\n\n---\n\n");
}
out
}
pub fn slugify_section(title: &str) -> String {
let mut out = String::with_capacity(title.len());
let mut last_dash = false;
for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash && !out.is_empty() {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
out.push_str("section");
}
out
}
pub fn group_into_sections(pages: Vec<(String, String, String)>) -> Vec<Section> {
use std::collections::BTreeMap;
let mut map: BTreeMap<String, Vec<(String, String, String)>> = BTreeMap::new();
for (title, url, relpath) in pages {
let top = url
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("")
.to_string();
let key = if top.is_empty() { "index".into() } else { top };
map.entry(key).or_default().push((title, url, relpath));
}
map.into_iter()
.map(|(title, pages)| Section { title, pages })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn p(title: &str, url: &str) -> (String, String, String) {
(title.into(), url.into(), format!("{}.md", url))
}
#[test]
fn group_into_sections_orders_alphabetically() {
let pages = vec![
p(
"Protocol Overview",
"technical-documentation/protocol-overview",
),
p("Why Strata", "introduction/why-strata"),
p("Ethena USDe", "markets/ethena-usde"),
p("Senior Tranche", "introduction/senior-tranche"),
];
let sections = group_into_sections(pages);
assert_eq!(sections[0].title, "introduction");
assert_eq!(sections[0].pages.len(), 2);
assert_eq!(sections[1].title, "markets");
assert_eq!(sections[2].title, "technical-documentation");
}
#[test]
fn generate_index_emits_h2_per_section() {
let sections = group_into_sections(vec![p("Foo", "intro/foo"), p("Bar", "markets/bar")]);
let md = generate_index(§ions);
assert!(md.starts_with("# Documentation Index\n\n"));
assert!(md.contains("## intro\n"));
assert!(md.contains("## markets\n"));
assert!(md.contains("- [Foo](intro/foo)\n"));
}
#[test]
fn generate_llms_txt_with_and_without_description() {
let pages: Vec<LlmsEntry> = vec![
(
"A".into(),
"/a".into(),
String::new(),
Some("First page".into()),
),
("B".into(), "/b".into(), String::new(), None),
("C".into(), "/c".into(), String::new(), Some("".into())),
];
let s = generate_llms_txt(&pages);
assert!(s.contains("- [A](/a): First page\n"));
assert!(s.contains("- [B](/b)\n"));
assert!(s.contains("- [C](/c)\n"));
}
#[test]
fn generate_llms_full_txt_header_only_for_empty_pages() {
let s = generate_llms_full_txt("Example", "summary line", &[]);
assert_eq!(s, "# Example\n\n> summary line\n\n");
}
#[test]
fn generate_llms_full_txt_emits_header_and_per_page_block() {
let pages = vec![
(
"Tranching".into(),
"https://x/t".into(),
"Senior tranche absorbs first loss.\n".into(),
),
(
"Overview".into(),
"https://x/o".into(),
"Top-level intro.\n".into(),
),
];
let s = generate_llms_full_txt("Pareto", "Concatenated corpus.", &pages);
assert!(s.starts_with("# Pareto\n\n> Concatenated corpus.\n\n"));
assert!(s.contains(
"# Tranching\n\nURL: https://x/t\n\nSenior tranche absorbs first loss.\n\n---\n\n"
));
assert!(s.contains("# Overview\n\nURL: https://x/o\n\nTop-level intro.\n\n---\n\n"));
assert_eq!(s.matches("\n---\n").count(), 2);
}
#[test]
fn generate_llms_full_txt_preserves_in_page_hr() {
let pages = vec![(
"Has HR".into(),
"https://x/h".into(),
"before\n\n---\n\nafter\n".into(),
)];
let s = generate_llms_full_txt("Site", "sum", &pages);
assert_eq!(s.matches("---").count(), 2);
assert!(s.ends_with("\n\n---\n\n"));
}
#[test]
fn generate_llms_full_txt_handles_empty_body() {
let pages = vec![("Empty".into(), "https://x/e".into(), String::new())];
let s = generate_llms_full_txt("Site", "sum", &pages);
assert!(s.contains("# Empty\n\nURL: https://x/e\n\n\n\n---\n\n"));
}
fn section(title: &str, pages: &[(&str, &str, &str)]) -> AgentSection {
(
title.into(),
pages
.iter()
.map(|(t, u, rp)| (t.to_string(), u.to_string(), rp.to_string()))
.collect(),
)
}
#[test]
fn build_agents_md_empty_sections() {
let s = build_agents_md("Site", "https://x/", "2026-07-08", &[], None);
assert!(s.starts_with("# Site — Agent Context\n\n"));
assert!(s.contains("0 pages across 0 sections"));
assert!(!s.contains("## What this is"));
assert!(s.contains("## Sections\n\n"));
assert!(s.contains("## Per-topic deep dives\n\n"));
assert!(s.contains("## How to use this\n\n"));
}
#[test]
fn build_agents_md_with_overview_and_sections() {
let sections = vec![
section(
"introduction",
&[("Why Strata", "https://x/i/w", "introduction/why-strata.md")],
),
section(
"markets",
&[("Ethena", "https://x/m/e", "markets/ethena.md")],
),
];
let s = build_agents_md(
"Strata",
"https://docs.strata.markets/",
"2026-07-08",
§ions,
Some("Strata is a structured-yield protocol that splits risk into senior and junior tranches."),
);
assert!(s.starts_with("# Strata — Agent Context\n\n"));
assert!(s.contains("2 pages across 2 sections"));
assert!(s.contains("## What this is\n\nStrata is a structured-yield protocol"));
let intro_pos = s.find("### introduction").unwrap();
let markets_pos = s.find("### markets").unwrap();
assert!(intro_pos < markets_pos);
assert!(s.contains("- [Why Strata](introduction/why-strata.md)"));
assert!(s.contains("- [00-introduction.md](skills/00-introduction.md)"));
assert!(s.contains("- [01-markets.md](skills/01-markets.md)"));
}
#[test]
fn build_agents_md_overview_omitted_when_empty() {
let s = build_agents_md("Site", "https://x/", "2026-07-08", &[], Some(""));
assert!(!s.contains("## What this is"));
}
#[test]
fn generate_skill_md_emits_per_page_blocks() {
let pages = vec![
(
"Why Strata".into(),
"https://x/w".into(),
"Senior tranche absorbs first loss.\n".into(),
),
(
"Overview".into(),
"https://x/o".into(),
"Top-level intro.\n".into(),
),
];
let s = generate_skill_md("introduction", "Strata", &pages);
assert!(s.starts_with("# introduction — Strata\n\n"));
assert!(s.contains("> Topic-scoped context for Strata. 2 pages in this section."));
assert!(s.contains(
"# Why Strata\n\nURL: https://x/w\n\nSenior tranche absorbs first loss.\n\n---\n\n"
));
assert!(s.contains("# Overview\n\nURL: https://x/o\n\nTop-level intro.\n\n---\n\n"));
assert_eq!(s.matches("\n---\n").count(), 2);
}
#[test]
fn generate_skill_md_handles_empty_body() {
let pages = vec![("Empty".into(), "https://x/e".into(), String::new())];
let s = generate_skill_md("section", "Site", &pages);
assert!(s.contains("# Empty\n\nURL: https://x/e\n\n\n\n---\n\n"));
}
#[test]
fn slugify_section_basic() {
assert_eq!(slugify_section("Risk Framework"), "risk-framework");
assert_eq!(slugify_section("introduction"), "introduction");
assert_eq!(slugify_section("UPPER_case"), "upper-case");
}
#[test]
fn slugify_section_collapses_runs_of_separators() {
assert_eq!(slugify_section("Markets / Ethena"), "markets-ethena");
assert_eq!(slugify_section("A B"), "a-b");
assert_eq!(slugify_section("--leading--"), "leading");
}
#[test]
fn slugify_section_falls_back_when_all_separators() {
assert_eq!(slugify_section("///"), "section");
assert_eq!(slugify_section(""), "section");
}
}