use crate::util::ClosureNew;
use serde::{Deserialize, Serialize};
use std::convert::identity;
use wasm_bindgen::{closure::Closure, JsCast, JsValue};
mod util {
pub fn window() -> web_sys::Window {
web_sys::window().expect("Can't find the global Window")
}
pub fn document() -> web_sys::Document {
window().document().expect("Can't find document")
}
pub fn history() -> web_sys::History {
window().history().expect("Can't find history")
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Url {
pub path: Vec<String>,
pub search: Option<String>,
pub hash: Option<String>,
pub title: Option<String>,
}
impl Url {
pub fn new<T: ToString>(path: Vec<T>) -> Self {
let result = Self {
path: path.into_iter().map(|p| p.to_string()).collect(),
hash: None,
search: None,
title: None,
};
clean_url(result)
}
pub fn hash(mut self, hash: &str) -> Self {
self.hash = Some(hash.into());
self
}
pub fn search(mut self, search: &str) -> Self {
self.search = Some(search.into());
self
}
pub fn title(mut self, title: &str) -> Self {
self.title = Some(title.into());
self
}
}
impl From<String> for Url {
fn from(s: String) -> Self {
let mut path: Vec<String> = s.split('/').map(ToString::to_string).collect();
if let Some(first) = path.get(0) {
if first.is_empty() {
path.remove(0); }
}
let last = path.pop();
let mut last_path = String::new();
let mut hash = String::new();
let mut search = String::new();
if let Some(l) = last {
let mut in_hash = false;
let mut in_search = false;
for c in l.chars() {
if c == '#' {
in_hash = true;
in_search = false;
continue;
}
if c == '?' {
in_hash = false;
in_search = true;
continue;
}
if in_hash {
hash.push(c);
} else if in_search {
search.push(c);
} else {
last_path.push(c);
}
}
}
if !last_path.is_empty() {
path.push(last_path);
}
Self {
path,
hash: if hash.is_empty() { None } else { Some(hash) },
search: if search.is_empty() {
None
} else {
Some(search)
},
title: None,
}
}
}
impl From<Vec<String>> for Url {
fn from(path: Vec<String>) -> Self {
Url::new(path)
}
}
impl From<Vec<&str>> for Url {
fn from(path: Vec<&str>) -> Self {
Url::new(path)
}
}
fn get_path() -> String {
let path = util::window()
.location()
.pathname()
.expect("Can't find pathname");
path[1..path.len()].to_string() }
fn get_hash() -> String {
util::window()
.location()
.hash()
.expect("Can't find hash")
.replace("#", "")
}
fn get_search() -> String {
util::window()
.location()
.search()
.expect("Can't find search")
.replace("?", "")
}
pub fn initial_url() -> Url {
let raw_path = get_path();
let path_ref: Vec<&str> = raw_path.split('/').collect();
let path: Vec<String> = path_ref.into_iter().map(ToString::to_string).collect();
let raw_hash = get_hash();
let hash = match raw_hash.len() {
0 => None,
_ => Some(raw_hash),
};
let raw_search = get_search();
let search = match raw_search.len() {
0 => None,
_ => Some(raw_search),
};
Url {
path,
hash,
search,
title: None,
}
}
fn remove_first(s: &str) -> Option<&str> {
s.chars().next().map(|c| &s[c.len_utf8()..])
}
fn clean_url(mut url: Url) -> Url {
let mut cleaned_path = vec![];
for part in &url.path {
if let Some(first) = part.chars().next() {
if first == '/' {
cleaned_path.push(remove_first(part).unwrap().to_string());
} else {
cleaned_path.push(part.to_string());
}
}
}
url.path = cleaned_path;
url
}
pub fn push_route<U: Into<Url>>(url: U) -> Url {
let mut url = url.into();
url = clean_url(url);
let data =
JsValue::from_serde(&serde_json::to_string(&url).expect("Problem serializing route data"))
.expect("Problem converting route data to JsValue");
let title = match &url.title {
Some(t) => t,
None => "",
};
let mut path = String::from("/") + &url.path.join("/");
if let Some(search) = &url.search {
path = path + "?" + search;
}
if let Some(hash) = &url.hash {
path = path + "#" + hash;
}
util::history()
.push_state_with_url(&data, title, Some(&path))
.expect("Problem pushing state");
url
}
pub fn setup_popstate_listener<Ms>(
update: impl Fn(Ms) + 'static,
updated_listener: impl Fn(Closure<dyn FnMut(web_sys::Event)>) + 'static,
routes: fn(Url) -> Option<Ms>,
) where
Ms: 'static,
{
let closure = Closure::new(move |ev: web_sys::Event| {
let ev = ev
.dyn_ref::<web_sys::PopStateEvent>()
.expect("Problem casting as Popstate event");
if let Some(state_str) = ev.state().as_string() {
let url: Url =
serde_json::from_str(&state_str).expect("Problem deserializing popstate state");
if let Some(routing_msg) = routes(url) {
update(routing_msg);
}
};
});
(util::window().as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
.expect("Problem adding popstate listener");
updated_listener(closure);
}
pub fn setup_hashchange_listener<Ms>(
update: impl Fn(Ms) + 'static,
updated_listener: impl Fn(Closure<dyn FnMut(web_sys::Event)>) + 'static,
routes: fn(Url) -> Option<Ms>,
) where
Ms: 'static,
{
let closure = Closure::new(move |ev: web_sys::Event| {
let ev = ev
.dyn_ref::<web_sys::HashChangeEvent>()
.expect("Problem casting as hashchange event");
let url: Url = ev.new_url().into();
if let Some(routing_msg) = routes(url) {
update(routing_msg);
}
});
(util::window().as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("hashchange", closure.as_ref().unchecked_ref())
.expect("Problem adding hashchange listener");
updated_listener(closure);
}
#[allow(clippy::option_map_unit_fn)]
pub fn setup_link_listener<Ms>(update: impl Fn(Ms) + 'static, routes: fn(Url) -> Option<Ms>)
where
Ms: 'static,
{
let closure = Closure::new(move |event: web_sys::Event| {
event.target()
.and_then(|et| et.dyn_into::<web_sys::Element>().ok())
.and_then(|el| el.closest("[href]").ok())
.and_then(identity) .and_then(|href_el| match href_el.tag_name().as_str() {
"Base" | "Link" => None,
_ => Some(href_el)
})
.and_then(|href_el| href_el.get_attribute("href"))
.and_then(|href| {
if href.is_empty() || href.starts_with('/') {
Some(href)
} else {
None
}
})
.map(|href| {
if href.is_empty() {
event.prevent_default(); } else {
let url = clean_url(Url::from(href));
if let Some(redirect_msg) = routes(url.clone()) {
push_route(url);
event.prevent_default(); update(redirect_msg);
}
}
});
});
(util::document().as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())
.expect("Problem setting up link interceptor");
closure.forget(); }
#[cfg(test)]
mod tests {
use wasm_bindgen_test::*;
use super::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn parse_url_simple() {
let expected = Url {
path: vec!["path1".into(), "path2".into()],
hash: None,
search: None,
title: None,
};
let actual: Url = "/path1/path2".to_string().into();
assert_eq!(expected, actual)
}
#[wasm_bindgen_test]
fn parse_url_with_hash_search() {
let expected = Url {
path: vec!["path".into()],
hash: Some("hash".into()),
search: Some("sea=rch".into()),
title: None,
};
let actual: Url = "/path/#hash?sea=rch".to_string().into();
assert_eq!(expected, actual)
}
#[wasm_bindgen_test]
fn parse_url_with_hash_only() {
let expected = Url {
path: vec!["path".into()],
hash: Some("hash".into()),
search: None,
title: None,
};
let actual: Url = "/path/#hash".to_string().into();
assert_eq!(expected, actual)
}
}