fix: route project roots (/<slug>/) to a landing page

The home index and every page breadcrumb link a project root
`<a href="/<slug>/">`, but the router declared only `/`, `/assets/theme.css`,
and the `/{slug}/{*page}` catch-all (which requires a concrete page segment),
so every project-root link 404'd. Add a `/{slug}/` route and a project_index
handler that renders a landing page listing the project's sections and pages
(unknown slug -> 404); the index and breadcrumb root links are now live.

RED-first via the debug skill: tests/e2e.rs
`project_root_resolves_to_landing_with_section_nav` (a GET to /aura/ asserting
200 + the section/page nav) failed 404 before the fix, passes after.
cargo test green (42); clippy clean.
This commit is contained in:
2026-06-28 18:35:20 +02:00
parent 8e6539d235
commit d5a2dce7ef
2 changed files with 41 additions and 0 deletions
+23
View File
@@ -27,6 +27,7 @@ pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/assets/theme.css", get(theme_css)) .route("/assets/theme.css", get(theme_css))
.route("/{slug}/", get(project_index))
.route("/{slug}/{*page}", get(page)) .route("/{slug}/{*page}", get(page))
.with_state(state) .with_state(state)
} }
@@ -46,6 +47,28 @@ async fn index(State(st): State<AppState>) -> Html<String> {
Html(s) Html(s)
} }
/// A project root (`/<slug>/`): 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<AppState>, Path(slug): Path<String>) -> 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!("<h1>{slug_e}</h1>");
for sec in &proj.section {
body.push_str(&format!("<h2>{}</h2><ul>", 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!("<li><a href=\"/{slug_e}/{url_e}\">{url_e}</a></li>"));
}
body.push_str("</ul>");
}
let html = crate::theme::page_html(&st.reg, &proj.slug, &proj.slug, &body);
Html(html).into_response()
}
async fn page(State(st): State<AppState>, Path((slug, page)): Path<(String, String)>) -> Response { async fn page(State(st): State<AppState>, Path((slug, page)): Path<(String, String)>) -> Response {
let url_page = page.trim_end_matches('/'); let url_page = page.trim_end_matches('/');
let Some(pref) = st.reg.resolve(&slug, url_page) else { let Some(pref) = st.reg.resolve(&slug, url_page) else {
+18
View File
@@ -192,6 +192,24 @@ docs="docs"
); );
} }
/// A registered project's root URL (`/<slug>/`) 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 `<a href="/<slug>/">` 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("<a href=\"/aura/design/INDEX\">"),
"project landing must list the project's sections/pages: {body}"
);
}
/// A Fetcher that counts `fetch_raw` calls, wrapping a MockFetcher. Lets the /// A Fetcher that counts `fetch_raw` calls, wrapping a MockFetcher. Lets the
/// E2E layer prove the cache-before-fetch data-flow. /// E2E layer prove the cache-before-fetch data-flow.
struct CountingFetcher { struct CountingFetcher {