diff --git a/src/server/mod.rs b/src/server/mod.rs index b092543..6fa6457 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -27,6 +27,7 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/", get(index)) .route("/assets/theme.css", get(theme_css)) + .route("/{slug}/", get(project_index)) .route("/{slug}/{*page}", get(page)) .with_state(state) } @@ -46,6 +47,28 @@ async fn index(State(st): State) -> Html { Html(s) } +/// A project root (`//`): a landing page listing the project's sections +/// and their pages, so the home index and the per-page breadcrumb both have a +/// live destination. Unknown slug -> 404. +async fn project_index(State(st): State, Path(slug): Path) -> Response { + let Some(proj) = st.reg.project(&slug) else { + return (StatusCode::NOT_FOUND, "404 — unknown project").into_response(); + }; + let slug_e = crate::theme::html_escape(&proj.slug); + let mut body = format!("

{slug_e}

"); + for sec in &proj.section { + body.push_str(&format!("

{}

    ", crate::theme::html_escape(&sec.title))); + for page in &sec.pages { + let url = page.strip_suffix(".md").unwrap_or(page); + let url_e = crate::theme::html_escape(url); + body.push_str(&format!("
  • {url_e}
  • ")); + } + body.push_str("
"); + } + let html = crate::theme::page_html(&st.reg, &proj.slug, &proj.slug, &body); + Html(html).into_response() +} + async fn page(State(st): State, Path((slug, page)): Path<(String, String)>) -> Response { let url_page = page.trim_end_matches('/'); let Some(pref) = st.reg.resolve(&slug, url_page) else { diff --git a/tests/e2e.rs b/tests/e2e.rs index fc184f4..578fa6d 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -192,6 +192,24 @@ docs="docs" ); } +/// A registered project's root URL (`//`) must resolve to a landing page +/// that lists that project's sections/pages — not 404. Both the home index +/// (`index`, src/server/mod.rs) and every page breadcrumb (`page_html`, +/// src/theme/mod.rs) emit `` for the project root, so it has +/// to be a live route. The router declares only `/`, `/assets/theme.css`, and a +/// `/{slug}/{*page}` catch-all that requires a concrete page segment, leaving a +/// bare project root such as `/aura/` unrouted (404) while a concrete page like +/// `/aura/design/INDEX` resolves — so every index and breadcrumb root link is dead. +#[tokio::test] +async fn project_root_resolves_to_landing_with_section_nav() { + let (status, body) = get(router(state()), "/aura/").await; + assert_eq!(status, StatusCode::OK, "a project root must resolve, not 404: {body}"); + assert!( + body.contains(""), + "project landing must list the project's sections/pages: {body}" + ); +} + /// A Fetcher that counts `fetch_raw` calls, wrapping a MockFetcher. Lets the /// E2E layer prove the cache-before-fetch data-flow. struct CountingFetcher {