AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/router.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Hash routing: `#/path?query` → a reactive [`Route`].

use crate::dom::listen;
use crate::host;
use crate::reactive::{expect_context, provide_context, signal, Signal};

/// The current route, provided as context by [`provide_router`].
#[derive(Clone, Copy)]
pub struct Route(Signal<String>);

impl Route {
    /// The path, always starting with `/` (`#/events?x=1` → `/events`).
    pub fn path(&self) -> String {
        let full = self.0.get();
        full.split('?').next().unwrap_or("/").to_string()
    }
    /// A query parameter (`#/events?day=2026-10-02`).
    pub fn query(&self, name: &str) -> Option<String> {
        let full = self.0.get();
        let (_, q) = full.split_once('?')?;
        q.split('&').find_map(|pair| {
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
            (k == name).then(|| v.to_string())
        })
    }
    /// The whole `path?query`.
    pub fn full(&self) -> String {
        self.0.get()
    }
}

fn normalized(hash: String) -> String {
    if hash.starts_with('/') {
        hash
    } else {
        format!("/{hash}")
    }
}

/// Start tracking `location.hash` and provide the [`Route`] to descendants.
pub fn provide_router() -> Route {
    let current = signal(normalized(host::location_hash()));
    listen(host::WINDOW, "hashchange", move |_| {
        current.set(normalized(host::location_hash()))
    });
    let route = Route(current);
    provide_context(route);
    route
}

/// The [`Route`] provided by an ancestor.
pub fn use_route() -> Route {
    expect_context::<Route>()
}

/// Navigate to `path` (e.g. `/events`).
pub fn navigate(path: &str) {
    host::set_location_hash(path);
}

/// Match `/events/:id` against `/events/42` → `Some(vec![("id", "42")])`.
pub fn match_path(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
    let pat: Vec<&str> = pattern.trim_matches('/').split('/').collect();
    let got: Vec<&str> = path.trim_matches('/').split('/').collect();
    if pat.len() != got.len() {
        return None;
    }
    let mut params = Vec::new();
    for (p, g) in pat.iter().zip(&got) {
        match p.strip_prefix(':') {
            Some(name) => params.push((name.to_string(), g.to_string())),
            None if p == g => {}
            None => return None,
        }
    }
    Some(params)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::html::p;
    use crate::testing;

    #[test]
    fn match_path_extracts_params() {
        assert_eq!(match_path("/", "/"), Some(vec![]));
        assert_eq!(
            match_path("/events/:id", "/events/7"),
            Some(vec![("id".into(), "7".into())])
        );
        assert_eq!(match_path("/events/:id", "/tasks/7"), None);
    }

    #[test]
    fn route_follows_hash_changes() {
        testing::render(|| {
            let route = provide_router();
            p().child(move || format!("{}|{:?}", route.path(), route.query("d")))
        });
        assert_eq!(testing::body_html(), "<p>/|None</p>");
        navigate("/week?d=2026-10-02");
        assert_eq!(
            testing::body_html(),
            r#"<p>/week|Some(&quot;2026-10-02&quot;)</p>"#
        );
    }
}