use crate::util::ClosureNew;
use serde::{Deserialize, Serialize};
use std::convert::{identity, TryFrom, TryInto};
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 {
Self {
path: path.into_iter().map(|p| p.to_string()).collect(),
hash: None,
search: None,
title: None,
}
}
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<web_sys::Url> for Url {
fn from(url: web_sys::Url) -> Self {
let path = {
let mut path = url.pathname();
path.remove(0);
path.split('/').map(ToOwned::to_owned).collect::<Vec<_>>()
};
let hash = {
let mut hash = url.hash();
if hash.is_empty() {
None
} else {
hash.remove(0);
Some(hash)
}
};
let search = {
let mut search = url.search();
if search.is_empty() {
None
} else {
search.remove(0);
Some(search)
}
};
Self {
path,
hash,
search,
title: None,
}
}
}
impl TryFrom<String> for Url {
type Error = String;
fn try_from(relative_url: String) -> Result<Self, Self::Error> {
let dummy_base_url = "http://example.com";
web_sys::Url::new_with_base(&relative_url, dummy_base_url)
.map(Url::from)
.map_err(|_| format!("`{}` is invalid relative URL", relative_url))
}
}
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)
}
}
pub fn current_url() -> Url {
let current_url = util::window().location().href().expect("get `href`");
web_sys::Url::new(¤t_url)
.expect("create `web_sys::Url` from the current URL")
.into()
}
pub fn push_route<U: Into<Url>>(url: U) -> Url {
let url = url.into();
let data =
JsValue::from_str(&serde_json::to_string(&url).expect("Problem serializing route data"));
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");
let url = match ev.state().as_string() {
Some(state_str) => {
serde_json::from_str(&state_str).expect("Problem deserializing popstate state")
}
None => current_url(),
};
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()
.try_into()
.expect("cast hashchange event url to `Url`");
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 = Url::try_from(href).expect("cast link href to `Url`");
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().try_into().unwrap();
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("search=query".into()),
title: None,
};
let actual: Url = "/path?search=query#hash".to_string().try_into().unwrap();
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().try_into().unwrap();
assert_eq!(expected, actual)
}
#[wasm_bindgen_test]
fn parse_url_with_hash_routing() {
let expected = Url {
path: vec!["".into()],
hash: Some("/discover".into()),
search: None,
title: None,
};
let actual: Url = "/#/discover".to_string().try_into().unwrap();
assert_eq!(expected, actual)
}
}