Menu
Public repository / master
AkurAI-Framework / crates/cli/src/routes.rs
Latest change 6773bf3b2333914e30899245eaf1e789211fa835 - OLI-188: fix clippy items-after-test-module by Ólafur Búi Ólafsson
//! Declarative page routing from `backend/routes.json`.
//!
//! A route table maps URL patterns to template names, e.g.
//! ```json
//! { "routes": [
//! { "path": "/", "template": "index" },
//! { "path": "/posts/:id", "template": "post" },
//! { "path": "/files/*path", "template": "file" }
//! ] }
//! ```
//! Patterns use [`akurai_router`] semantics: `:param` captures one segment,
//! `*wildcard` captures the rest, and the most specific pattern wins. Captured
//! params are injected into the render context under `params` (so a template can
//! use `{{ params.id }}`). The file is optional — without it the server keeps
//! its original "template named after the path" behaviour, so existing projects
//! are unaffected.
//!
//! A route may also declare an optional `"layout"`: a shared shell template that
//! wraps the page's own output. When present, the page `template` is rendered
//! first and its HTML injected into the layout's context as `content` (consumed
//! with `{{{ content }}}`), the same composition the docs pages use. A route
//! without a `layout` renders exactly as before.
//!
//! Finally, a route may declare an optional `"data"`: the name of a JSON file
//! under `backend/` whose contents are loaded into the render context before the
//! template runs, so data-driven pages need no handler code. See
//! [`crate::cmd_serve`] for the merge rule. A route without a `data` field
//! renders exactly as before.
use akurai_json::Value;
use akurai_router::Router;
use std::fs;
use std::path::Path;
/// A resolved route: the page template to render, optional shell/data settings,
/// prerender policy, and SEO metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteDef {
/// The page template name (file stem of a `frontend/*.html`).
pub template: String,
/// An optional shell template that wraps the page output via `{{{ content }}}`.
pub layout: Option<String>,
/// An optional JSON data file (relative to `backend/`) merged into the
/// render context before the template runs.
pub data: Option<String>,
/// Pure SSG: render once, cache, and serve the cached HTML thereafter.
pub prerender: bool,
/// ISR: serve cached HTML and refresh after this many seconds.
pub revalidate: Option<u64>,
/// Optional `<title>` metadata for the route.
pub title: Option<String>,
/// Optional meta description for the route.
pub description: Option<String>,
}
/// A literal route path paired with its [`RouteDef`], for callers that need to
/// enumerate routes (e.g. the SSG startup warm). Pattern routes (`:param`/
/// `*wildcard`) are excluded — they have no single concrete URL to prerender.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteEntry {
/// The literal request path (no `:param`/`*wildcard` segments).
pub path: String,
/// The route's resolved definition.
pub def: RouteDef,
}
/// Load the route table for the project rooted at `frontend` dir `root`, or
/// `None` if `backend/routes.json` is absent or unreadable. A malformed entry is
/// skipped rather than failing the whole table.
pub fn load(root: &Path) -> Option<Router<RouteDef>> {
let path = root.parent()?.join("backend/routes.json");
let text = fs::read_to_string(path).ok()?;
parse(&text)
}
/// Parse one `routes.json` entry into `(pattern, RouteDef)`, or `None` if it
/// lacks the required `path`/`template`. `layout`/`data` are optional strings;
/// `prerender` is an optional bool (default `false`); `revalidate` is an optional
/// number of seconds. Any other fields (such as SEO metadata) are ignored.
fn entry_def(entry: &Value) -> Option<(String, RouteDef)> {
let pattern = entry.get("path").and_then(Value::as_str)?;
let template = entry.get("template").and_then(Value::as_str)?;
let layout = entry
.get("layout")
.and_then(Value::as_str)
.map(String::from);
let data = entry.get("data").and_then(Value::as_str).map(String::from);
let prerender = matches!(entry.get("prerender"), Some(Value::Bool(true)));
let revalidate = entry.get("revalidate").and_then(as_u64);
let title = entry.get("title").and_then(Value::as_str).map(String::from);
let description = entry
.get("description")
.and_then(Value::as_str)
.map(String::from);
Some((
pattern.to_string(),
RouteDef {
template: template.to_string(),
layout,
data,
title,
description,
prerender,
revalidate,
},
))
}
/// Read a non-negative whole number of seconds from a JSON value, accepting an
/// integer literal (`30`). Negative or non-integer values are rejected.
fn as_u64(value: &Value) -> Option<u64> {
value.as_i64().filter(|n| *n >= 0).map(|n| n as u64)
}
/// Parse a `routes.json` document into a router, independent of the filesystem.
/// Each entry needs a `path` and `template`; `layout`/`data`/`prerender`/
/// `revalidate` are optional and any other fields are ignored here.
fn parse(text: &str) -> Option<Router<RouteDef>> {
let entries = parse_entries(text)?;
if entries.is_empty() {
return None;
}
let mut router = Router::new();
for RouteEntry { path, def } in entries {
router.route(&path, def);
}
Some(router)
}
/// Parse a `routes.json` document into a flat list of [`RouteEntry`], preserving
/// declaration order. Malformed entries are skipped; a missing `routes` array
/// yields `None`.
fn parse_entries(text: &str) -> Option<Vec<RouteEntry>> {
let value = akurai_json::parse(text).ok()?;
let Value::Array(entries) = value.get("routes")? else {
return None;
};
Some(
entries
.iter()
.filter_map(entry_def)
.map(|(path, def)| RouteEntry { path, def })
.collect(),
)
}
/// Load the flat list of route entries for the project rooted at `frontend` dir
/// `root`, or an empty vec when `backend/routes.json` is absent/unreadable.
/// Used by the SSG startup warm to find prerenderable routes.
pub fn entries(root: &Path) -> Vec<RouteEntry> {
root.parent()
.map(|p| p.join("backend/routes.json"))
.and_then(|path| fs::read_to_string(path).ok())
.and_then(|text| parse_entries(&text))
.unwrap_or_default()
}
/// Turn captured route params into a `params` object for the render context, so
/// templates resolve `{{ params.<name> }}`.
pub fn params_value(params: &[(String, String)]) -> Value {
Value::Object(
params
.iter()
.map(|(k, v)| (k.clone(), Value::Str(v.clone())))
.collect(),
)
}
/// Whether `backend/routes.json` sets top-level `"locale_prefix": true`
/// (default locale at `/`, other locales under `/<code>`).
pub fn locale_prefix(root: &Path) -> bool {
let Some(path) = root.parent().map(|p| p.join("backend/routes.json")) else {
return false;
};
fs::read_to_string(path)
.ok()
.and_then(|t| akurai_json::parse(&t).ok())
.is_some_and(|v| matches!(v.get("locale_prefix"), Some(Value::Bool(true))))
}
#[cfg(test)]
mod tests {
use super::*;
// Build a router straight from JSON text, bypassing the filesystem.
fn router_from(json: &str) -> Option<Router<RouteDef>> {
parse(json)
}
#[test]
fn parses_and_matches_a_param_route() {
let r = router_from(
r#"{"routes":[{"path":"/","template":"index"},{"path":"/posts/:id","template":"post"}]}"#,
)
.unwrap();
let m = r.match_path("/posts/42").unwrap();
assert_eq!(m.value.template, "post");
assert_eq!(m.param("id"), Some("42"));
assert_eq!(r.match_path("/").unwrap().value.template, "index");
}
#[test]
fn empty_or_missing_table_is_none() {
assert!(router_from(r#"{"routes":[]}"#).is_none());
assert!(router_from(r#"{}"#).is_none());
}
#[test]
fn route_without_layout_yields_none() {
let r = router_from(r#"{"routes":[{"path":"/","template":"index"}]}"#).unwrap();
let def = &r.match_path("/").unwrap().value;
assert_eq!(def.template, "index");
assert_eq!(def.layout, None);
assert_eq!(def.data, None);
}
#[test]
fn route_with_data_exposes_it() {
let r = router_from(
r#"{"routes":[{"path":"/team","template":"team","data":"data/team.json"}]}"#,
)
.unwrap();
let def = &r.match_path("/team").unwrap().value;
assert_eq!(def.template, "team");
assert_eq!(def.layout, None);
assert_eq!(def.data.as_deref(), Some("data/team.json"));
}
#[test]
fn route_combines_layout_and_data() {
let r = router_from(
r#"{"routes":[{"path":"/team","template":"team","layout":"marketing","data":"team.json"}]}"#,
)
.unwrap();
let def = &r.match_path("/team").unwrap().value;
assert_eq!(def.layout.as_deref(), Some("marketing"));
assert_eq!(def.data.as_deref(), Some("team.json"));
}
#[test]
fn route_with_layout_exposes_it() {
let r = router_from(
r#"{"routes":[{"path":"/welcome","template":"welcome","layout":"marketing"}]}"#,
)
.unwrap();
let def = &r.match_path("/welcome").unwrap().value;
assert_eq!(def.template, "welcome");
assert_eq!(def.layout.as_deref(), Some("marketing"));
assert_eq!(def.data, None);
}
#[test]
fn legacy_path_template_entries_still_parse() {
// Entries carrying extra fields (e.g. SEO title/description) but no
// layout keep parsing, with `layout: None`.
let r = router_from(
r#"{"routes":[{"path":"/x","template":"x","title":"T","description":"D"}]}"#,
)
.unwrap();
let def = &r.match_path("/x").unwrap().value;
assert_eq!(def.template, "x");
assert_eq!(def.layout, None);
assert_eq!(def.data, None);
}
#[test]
fn ssg_fields_default_to_off() {
// A route without prerender/revalidate stays dynamic SSR.
let r = router_from(r#"{"routes":[{"path":"/","template":"index"}]}"#).unwrap();
let def = &r.match_path("/").unwrap().value;
assert!(!def.prerender);
assert_eq!(def.revalidate, None);
}
#[test]
fn parses_prerender_and_revalidate() {
let r = router_from(
r#"{"routes":[{"path":"/p","template":"p","prerender":true,"revalidate":60}]}"#,
)
.unwrap();
let def = &r.match_path("/p").unwrap().value;
assert!(def.prerender);
assert_eq!(def.revalidate, Some(60));
}
#[test]
fn revalidate_rejects_negative_and_non_integer() {
let r = router_from(
r#"{"routes":[{"path":"/a","template":"a","revalidate":-5},{"path":"/b","template":"b","revalidate":1.5}]}"#,
)
.unwrap();
assert_eq!(r.match_path("/a").unwrap().value.revalidate, None);
assert_eq!(r.match_path("/b").unwrap().value.revalidate, None);
}
#[test]
fn entries_lists_paths_with_defs_in_order() {
let list = parse_entries(
r#"{"routes":[{"path":"/","template":"index","prerender":true},{"path":"/x","template":"x"}]}"#,
)
.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].path, "/");
assert!(list[0].def.prerender);
assert_eq!(list[1].path, "/x");
assert!(!list[1].def.prerender);
}
#[test]
fn params_become_a_context_object() {
let v = params_value(&[("id".into(), "7".into())]);
assert_eq!(v.get("id").and_then(Value::as_str), Some("7"));
}
}