//! Documentation page routes: index and individual doc pages. use axum::{ Json, extract::{Path, State}, response::IntoResponse, }; use tower_sessions::Session; use crate::{ auth::MaybeUserUnverified, error::{AppError, Result}, helpers::get_csrf_token, templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate}, }; /// GET /docs: index page listing all docs grouped by section. #[tracing::instrument(skip_all, name = "docs::docs_index")] pub(super) async fn docs_index( State(docs): State>, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { let csrf_token = get_csrf_token(&session).await; // Group index entries by section, preserving load order. let mut sections: Vec = Vec::new(); for entry in docs.index() { let section = sections.iter_mut().find(|s| s.name == entry.section); match section { Some(s) => { s.entries.push(DocSectionEntry { title: entry.title.clone(), slug: entry.slug.clone(), }); } None => { sections.push(DocSection { name: entry.section.clone(), entries: vec![DocSectionEntry { title: entry.title.clone(), slug: entry.slug.clone(), }], subsections: Vec::new(), }); } } } // Post-process the Guide section: bucket entries into subcategories. if let Some(guide) = sections.iter_mut().find(|s| s.name == "Guide") { const SUBCATEGORIES: &[(&str, &[&str])] = &[ ( "Getting Started", &[ "getting-started", "sandbox", "profile", "security", "best-practices", ], ), ( "Content & Organization", &[ "02-content", "items", "projects", "audio", "video", "software", "tags", "metadata", "collections", "blog", ], ), ( "Selling & Revenue", &[ "03-selling", "pricing", "payouts", "analytics", "promo-codes", "contact-sharing", "fan-plus", ], ), ( "Fans & Distribution", &["fan-guide", "discovery", "rss", "mailing-lists", "export"], ), ("Advanced", &["custom-domains", "git", "migration", "tiers"]), ]; let entries = std::mem::take(&mut guide.entries); for &(label, slugs) in SUBCATEGORIES { let sub_entries: Vec = slugs .iter() .filter_map(|&slug| entries.iter().find(|e| e.slug == slug)) .map(|e| DocSectionEntry { title: e.title.clone(), slug: e.slug.clone(), }) .collect(); if !sub_entries.is_empty() { guide.subsections.push(DocSubsection { label: label.to_string(), entries: sub_entries, }); } } } Ok(DocIndexTemplate { csrf_token, session_user: maybe_user, sections, }) } /// GET /docs/search.json: full-text search index for client-side filtering. #[tracing::instrument(skip_all, name = "docs::docs_search_index")] pub(super) async fn docs_search_index( State(docs): State>, ) -> Json> { Json(docs.search_index()) } /// GET /docs/{slug}: individual doc page. #[tracing::instrument(skip_all, name = "docs::doc_page")] pub(super) async fn doc_page( State(docs): State>, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Path(slug): Path, ) -> Result { let page = docs.get(&slug).ok_or(AppError::NotFound)?; let csrf_token = get_csrf_token(&session).await; // "What links here": resolve each source slug to its title off the link // graph. A source is always a served page; filter_map skips any that ever // stops resolving rather than rendering a titleless entry. let backlinks: Vec = docs .backlinks(&slug) .iter() .filter_map(|src| { docs.get(src).map(|p| DocSectionEntry { title: p.title.clone(), slug: src.clone(), }) }) .collect(); Ok(DocTemplate { csrf_token, session_user: maybe_user, title: page.title.clone(), section: page.section.clone(), content: page.html_content.clone(), backlinks, }) }