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("2026-10-02")</p>"#
);
}
}