Menu
Public repository / 3a1d4dc0df9425a910df82402300a2aeb40318ae
AkurAI-Framework / crates/cli/src/routes.rs
Latest change 7d64b4511fc8d94d569ca5b7df933a213ab69a05 - feat(cli): route layouts (shared shell templates) 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.
use akurai_json::Value;
use akurai_router::Router;
use std::fs;
use std::path::Path;
/// A resolved route: the page `template` to render, plus an optional `layout`
/// shell template to wrap it. Other declarative fields on the entry (e.g. the
/// SEO `title`/`description` consumed in [`crate::seo`]) are read independently
/// and are not part of this struct.
#[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>,
}
/// 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 a `routes.json` document into a router, independent of the filesystem.
/// Each entry needs a `path` and `template`; a `layout` is optional and any
/// other fields (such as SEO metadata) are ignored here.
fn parse(text: &str) -> Option<Router<RouteDef>> {
let value = akurai_json::parse(text).ok()?;
let Value::Array(entries) = value.get("routes")? else {
return None;
};
let mut router = Router::new();
let mut any = false;
for entry in entries {
if let (Some(pattern), Some(template)) = (
entry.get("path").and_then(Value::as_str),
entry.get("template").and_then(Value::as_str),
) {
let layout = entry
.get("layout")
.and_then(Value::as_str)
.map(String::from);
router.route(
pattern,
RouteDef {
template: template.to_string(),
layout,
},
);
any = true;
}
}
any.then_some(router)
}
/// 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(),
)
}
#[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);
}
#[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"));
}
#[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);
}
#[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"));
}
}