AkurAI Build
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"));
    }
}