use dioxus::server::axum::{Router, http::header, routing::get};
use crate::blog::BlogRegistry;
use crate::components::seo::xml_escape;
use crate::registry::DocsRegistry;
const TEXT: &str = "text/plain; charset=utf-8";
const XML: &str = "application/xml; charset=utf-8";
const MD: &str = "text/markdown; charset=utf-8";
const RSS: &str = "application/rss+xml; charset=utf-8";
pub struct SeoRouter {
site_url: String,
site_title: String,
site_description: String,
docs: Option<(&'static DocsRegistry, String)>,
blog: Option<(&'static BlogRegistry, String)>,
}
fn normalize_base(base_path: &str) -> String {
let trimmed = base_path.trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
impl SeoRouter {
pub fn new(site_url: &str, site_title: &str, site_description: &str) -> Self {
Self {
site_url: site_url.trim_end_matches('/').to_string(),
site_title: site_title.to_string(),
site_description: site_description.to_string(),
docs: None,
blog: None,
}
}
pub fn with_docs(mut self, registry: &'static DocsRegistry, base_path: &str) -> Self {
self.docs = Some((registry, normalize_base(base_path)));
self
}
pub fn with_blog(mut self, registry: &'static BlogRegistry, base_path: &str) -> Self {
self.blog = Some((registry, normalize_base(base_path)));
self
}
pub fn into_router(self) -> Router {
let mut router = Router::new();
let site_url = &self.site_url;
let mut sitemap_index_entries: Vec<String> = Vec::new();
if let Some((docs, base)) = &self.docs {
for path in docs.get_all_paths() {
if let Some(markdown) = docs.get_doc_content(path) {
router = router.route(
&format!("{base}/{path}.md"),
get(move || async move { ([(header::CONTENT_TYPE, MD)], markdown) }),
);
}
}
let docs_base_url = format!("{site_url}{base}");
let llms =
docs.generate_llms_txt(&self.site_title, &self.site_description, &docs_base_url);
router = router.route(
"/llms.txt",
get(move || async move { ([(header::CONTENT_TYPE, TEXT)], llms) }),
);
let llms_full = docs.generate_llms_full_txt(
&self.site_title,
&self.site_description,
&docs_base_url,
);
router = router.route(
"/llms-full.txt",
get(move || async move { ([(header::CONTENT_TYPE, TEXT)], llms_full) }),
);
let sitemap = docs.generate_sitemap(site_url, base);
router = router.route(
"/sitemap-docs.xml",
get(move || async move { ([(header::CONTENT_TYPE, XML)], sitemap) }),
);
sitemap_index_entries.push(format!("{site_url}/sitemap-docs.xml"));
}
if let Some((blog, base)) = &self.blog {
for post in blog.all_posts() {
let markdown = post.raw_markdown.as_str();
router = router.route(
&format!("{base}/{}.md", post.slug),
get(move || async move { ([(header::CONTENT_TYPE, MD)], markdown) }),
);
}
let rss = blog.generate_rss(&self.site_title, site_url, base);
router = router.route(
&format!("{base}/rss.xml"),
get(move || async move { ([(header::CONTENT_TYPE, RSS)], rss) }),
);
let sitemap = blog.generate_sitemap(site_url, base);
router = router.route(
"/sitemap-blog.xml",
get(move || async move { ([(header::CONTENT_TYPE, XML)], sitemap) }),
);
sitemap_index_entries.push(format!("{site_url}/sitemap-blog.xml"));
}
let sitemap_index = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n{}\
</sitemapindex>\n",
sitemap_index_entries
.iter()
.map(|loc| format!("<sitemap><loc>{}</loc></sitemap>\n", xml_escape(loc)))
.collect::<String>()
);
router = router.route(
"/sitemap.xml",
get(move || async move { ([(header::CONTENT_TYPE, XML)], sitemap_index) }),
);
let robots = robots_txt(site_url);
router.route(
"/robots.txt",
get(move || async move { ([(header::CONTENT_TYPE, TEXT)], robots) }),
)
}
}
fn robots_txt(site_url: &str) -> String {
const AI_CRAWLERS: &[&str] = &[
"GPTBot",
"ChatGPT-User",
"OAI-SearchBot",
"ClaudeBot",
"anthropic-ai",
"Claude-Web",
"Google-Extended",
"PerplexityBot",
"CCBot",
];
let mut out = String::from("User-agent: *\nAllow: /\n");
for bot in AI_CRAWLERS {
out.push_str(&format!("\nUser-agent: {bot}\nAllow: /\n"));
}
out.push_str(&format!("\nSitemap: {site_url}/sitemap.xml\n"));
out
}
#[cfg(test)]
mod tests {
use super::{normalize_base, robots_txt};
#[test]
fn normalizes_base_paths() {
assert_eq!(normalize_base("/docs"), "/docs");
assert_eq!(normalize_base("docs/"), "/docs");
assert_eq!(normalize_base("/"), "");
assert_eq!(normalize_base(""), "");
}
#[test]
fn robots_txt_lists_ai_crawlers_and_sitemap() {
let out = robots_txt("https://example.com");
assert!(out.starts_with("User-agent: *\nAllow: /\n"));
assert!(out.contains("User-agent: GPTBot\nAllow: /\n"));
assert!(out.contains("User-agent: ClaudeBot\nAllow: /\n"));
assert!(out.ends_with("Sitemap: https://example.com/sitemap.xml\n"));
}
}