//! 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}, }; /// The curated order of the Guide section on `/docs`. /// /// An allowlist, so it names slugs rather than deriving them, and a slug named /// here that no longer exists is simply skipped. The unit test below is what /// keeps that silent skip from accumulating; entries that were never valid /// ("security", "pricing", which live in other sections) sat here unnoticed /// until it existed. const SUBCATEGORIES: &[(&str, &[&str])] = &[ ( "Getting Started", &[ "getting-started", "sandbox", "profile", "account-security", "password-reset", "account-lifecycle", "creator-pause", "best-practices", ], ), ( "Content & Organization", &[ "02-content", "items", "projects", "audio", "video", "software", "tags", "metadata", "collections", "blog", "media-library", "dynamic-clips", "custom-pages", "import", ], ), ( "Selling & Revenue", &[ "03-selling", "payouts", "analytics", "promo-codes", "contact-sharing", "fan-plus", "bundles", "tips", "splits", "cart", "stripe", ], ), ( "Fans & Distribution", &[ "fan-guide", "discovery", "feed", "rss", "mailing-lists", "email-you-cannot-turn-off", "embeds", "wishlist", "forum-moderation", "export", ], ), ("Advanced", &["custom-domains", "git", "migration", "tiers"]), ]; /// Bucket the Guide entries into [`SUBCATEGORIES`] order. /// /// Curated order first, then a "More" bucket holding everything the allowlist /// does not name. That tail is the whole point: the allowlist used to be the /// only way onto the index, so 17 of 82 published pages rendered fine on a /// direct URL and could not be navigated to from anywhere (loose-wire g2-04). /// Every entry in must appear exactly once out. fn bucket_guide(entries: &[DocSectionEntry]) -> Vec { let mut subsections = Vec::new(); let mut placed: Vec<&str> = Vec::new(); for &(label, slugs) in SUBCATEGORIES { let sub_entries: Vec = slugs .iter() .filter_map(|&slug| entries.iter().find(|e| e.slug == slug)) .map(|e| { placed.push(e.slug.as_str()); DocSectionEntry { title: e.title.clone(), slug: e.slug.clone(), } }) .collect(); if !sub_entries.is_empty() { subsections.push(DocSubsection { label: label.to_string(), entries: sub_entries, }); } } let uncategorized: Vec = entries .iter() .filter(|e| !placed.contains(&e.slug.as_str())) .map(|e| DocSectionEntry { title: e.title.clone(), slug: e.slug.clone(), }) .collect(); if !uncategorized.is_empty() { subsections.push(DocSubsection { label: "More".to_string(), entries: uncategorized, }); } subsections } /// 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") { let entries = std::mem::take(&mut guide.entries); guide.subsections = bucket_guide(&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, }) } #[cfg(test)] mod subcategory_tests { use super::{DocSectionEntry, SUBCATEGORIES, bucket_guide}; fn entry(slug: &str) -> DocSectionEntry { DocSectionEntry { title: slug.to_string(), slug: slug.to_string(), } } /// The g2-04 regression: a page the allowlist does not name must still be /// reachable, because the index is the only way to find it. #[test] fn an_unlisted_page_lands_in_more_rather_than_vanishing() { let subs = bucket_guide(&[entry("getting-started"), entry("brand-new-page")]); let more = subs .iter() .find(|s| s.label == "More") .expect("unlisted page needs a bucket"); assert_eq!(more.entries.len(), 1); assert_eq!(more.entries[0].slug, "brand-new-page"); } /// Nothing is dropped and nothing is duplicated, whatever the input. #[test] fn every_entry_appears_exactly_once() { let entries: Vec = ["getting-started", "items", "tips", "unlisted-one"] .iter() .map(|s| entry(s)) .collect(); let subs = bucket_guide(&entries); let mut out: Vec<&str> = subs .iter() .flat_map(|s| s.entries.iter().map(|e| e.slug.as_str())) .collect(); out.sort_unstable(); let mut expected: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect(); expected.sort_unstable(); assert_eq!(out, expected); } /// With every entry named, "More" does not appear at all. #[test] fn no_more_bucket_when_everything_is_categorized() { let subs = bucket_guide(&[entry("getting-started"), entry("items")]); assert!(subs.iter().all(|s| s.label != "More")); } /// Every slug the curated order names must still be a Guide page. /// /// The bucketing skips a slug it cannot resolve, so a renamed or moved page /// leaves a dead entry that nothing complains about. Two of these ("security" /// and "pricing", both of which live in other sections and so were never /// resolvable here) rode along until the g2-04 sweep. #[test] fn every_listed_slug_is_a_guide_page() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/site-docs/public/guide"); let on_disk: Vec = std::fs::read_dir(dir) .expect("guide docs directory") .filter_map(std::result::Result::ok) .filter_map(|e| { let p = e.path(); (p.extension()? == "md").then(|| p.file_stem()?.to_str().map(String::from))? }) .collect(); let dead: Vec<&str> = SUBCATEGORIES .iter() .flat_map(|&(_, slugs)| slugs.iter().copied()) .filter(|slug| !on_disk.iter().any(|f| f == slug)) .collect(); assert!( dead.is_empty(), "SUBCATEGORIES names missing pages: {dead:?}" ); } /// A slug listed twice would render the same page under two headings. #[test] fn no_slug_is_listed_twice() { let mut seen: Vec<&str> = SUBCATEGORIES .iter() .flat_map(|&(_, slugs)| slugs.iter().copied()) .collect(); let before = seen.len(); seen.sort_unstable(); seen.dedup(); assert_eq!(before, seen.len(), "a slug appears in two subcategories"); } } #[cfg(test)] mod template_link_tests { /// Every `/docs/...` href in a template must be served. /// /// `MNW_CHECK_DOCS` walks the link graph *inside* the doc corpus, so a doc /// linking a moved page fails the pipeline. Nothing did the same for links /// pointing *into* the corpus from a template, which is how the landing /// page's own "Video" link came to be verified by hand rather than by the /// build. Templates are the higher-traffic direction: `/docs` is one page, /// the landing page is the front door. /// /// Two shapes fail here, and only the first is a typo: /// - a single-segment slug the loader does not resolve; /// - any two-segment path, e.g. `/docs/guide/tiers`. The route is /// `/docs/{slug}` and axum matches exactly one segment, so a /// section-qualified link never reaches `doc_page` at all. Docs are /// addressed by bare slug; the section is a display grouping. #[test] fn every_template_docs_link_resolves() { // Registered as exact routes before the `/docs/{slug}` catch-all, so // they are reachable without being loader slugs. const NON_SLUG_ROUTES: &[&str] = &[ "", // /docs, the index "search.json", // the search payload "economics", // 301 to /economics; the markdown source is gone ]; let assumptions = crate::site_docs::load_assumptions().expect("assumptions load"); let docs = crate::site_docs::build_doc_loader(assumptions); let re = regex::Regex::new(r"/docs(?:/([a-z0-9][a-z0-9./-]*))?").expect("valid regex"); let mut dead: Vec = Vec::new(); for path in html_templates(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")) { let body = std::fs::read_to_string(&path).expect("readable template"); for caps in re.captures_iter(&body) { let target = caps.get(1).map_or("", |m| m.as_str()); if NON_SLUG_ROUTES.contains(&target) { continue; } let ok = !target.contains('/') && docs.get(target).is_some(); if !ok { let file = path.rsplit('/').next().unwrap_or(&path); dead.push(format!("{file}: /docs/{target}")); } } } dead.sort_unstable(); dead.dedup(); assert!( dead.is_empty(), "templates link to docs that are not served:\n {}", dead.join("\n ") ); } /// Recursive `.html` walk. No `walkdir` in the tree, and this is the only /// caller. fn html_templates(root: &str) -> Vec { let mut out = Vec::new(); let mut stack = vec![std::path::PathBuf::from(root)]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for entry in entries.filter_map(std::result::Result::ok) { let p = entry.path(); if p.is_dir() { stack.push(p); } else if p.extension().is_some_and(|e| e == "html") { out.push(p.to_string_lossy().into_owned()); } } } out } }