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