//! Documentation page routes: index and individual doc pages. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use tower_sessions::Session; use crate::{ auth::MaybeUserUnverified, error::{AppError, Result}, helpers::get_csrf_token, templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate}, AppState, }; /// GET /docs: index page listing all docs grouped by section. #[tracing::instrument(skip_all, name = "docs::docs_index")] pub async fn docs_index( State(state): 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 state.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 async fn docs_search_index( State(state): State, ) -> Json> { Json(state.docs.search_index()) } /// GET /docs/{slug}: individual doc page. #[tracing::instrument(skip_all, name = "docs::doc_page")] pub async fn doc_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Path(slug): Path, ) -> Result { let page = state.docs.get(&slug).ok_or(AppError::NotFound)?; let csrf_token = get_csrf_token(&session).await; Ok(DocTemplate { csrf_token, session_user: maybe_user, title: page.title.clone(), section: page.section.clone(), content: page.html_content.clone(), }) }