diff --git a/TODO.md b/TODO.md index 1dd507181..23e185c0f 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,13 @@ - [ ] Bugs in Suspense/Transitions - [ ] let render effects that _aren't_ under the transition continue running - [ ] Router + - [ ] Tests + - [ ] Utils + - [ ] Components + - [ ] Integrations + - [ ] Client + - [ ] Server + - [ ] Examples - [ ] Docs (and clippy warning to insist on docs) - [ ] Read through + understand... - [ ] `Props` macro diff --git a/leptos_reactive/src/transition.rs b/leptos_reactive/src/transition.rs index d811750c7..941af3d31 100644 --- a/leptos_reactive/src/transition.rs +++ b/leptos_reactive/src/transition.rs @@ -30,7 +30,7 @@ pub struct Transition { } impl Transition { - pub fn start(&self, f: impl Fn()) { + pub fn start(&self, f: impl FnOnce()) { if self.runtime.running_transition().is_some() { f(); } else { diff --git a/leptos_router/Cargo.toml b/leptos_router/Cargo.toml index 21e583a0b..bb27ebe69 100644 --- a/leptos_router/Cargo.toml +++ b/leptos_router/Cargo.toml @@ -3,11 +3,20 @@ name = "leptos_router" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] leptos_core = { path = "../leptos_core" } leptos_dom = { path = "../leptos_dom"} leptos_macro = { path = "../leptos_macro"} leptos_reactive = { path = "../leptos_reactive"} -serde = "1" \ No newline at end of file +common_macros = "0.1" +lazy_static = "1" +linear-map = "1" +log = "0.4" +regex = "1" +serde = { version = "1", features = ["derive"] } +thiserror = "1" +web-sys = { version = "0.3", optional = true } +js-sys = { version = "0.3", optional = true } + +[features] +browser = ["dep:web-sys", "dep:js-sys"] diff --git a/leptos_router/src/components.rs b/leptos_router/src/components.rs deleted file mode 100644 index fd054dffe..000000000 --- a/leptos_router/src/components.rs +++ /dev/null @@ -1,34 +0,0 @@ -use leptos_core as leptos; -use leptos_dom::IntoChild; -use leptos_macro::{component, Props}; -use leptos_reactive::Scope; -use serde::{de::DeserializeOwned, Serialize}; - -pub struct RouterProps -where - C: for<'a> IntoChild<'a>, - D: Serialize + DeserializeOwned + 'static, -{ - base: Option, - data: Option D>>, - children: C, -} - -pub fn Router(cx: Scope, props: RouterProps) -where - C: for<'a> IntoChild<'a>, - D: Serialize + DeserializeOwned + 'static, -{ -} - -/* pub fn Router = (props: RouterProps) => { - const { source, url, base, data, out } = props; - const integration = - source || (isServer ? staticIntegration({ value: url || "" }) : pathIntegration()); - const routerState = createRouterContext(integration, base, data, out); - - return ( - {props.children} - ); -}; - */ diff --git a/leptos_router/src/components/mod.rs b/leptos_router/src/components/mod.rs new file mode 100644 index 000000000..8c6223a0f --- /dev/null +++ b/leptos_router/src/components/mod.rs @@ -0,0 +1,5 @@ +mod route; +mod router; + +pub use route::*; +pub use router::*; diff --git a/leptos_router/src/components/route.rs b/leptos_router/src/components/route.rs new file mode 100644 index 000000000..b4df7815a --- /dev/null +++ b/leptos_router/src/components/route.rs @@ -0,0 +1,13 @@ +use std::borrow::Cow; + +pub struct RouteContext {} + +impl RouteContext { + pub fn new(path: &str) -> Self { + Self {} + } + + pub fn resolve_path(&self, to: &str) -> Option> { + todo!() + } +} diff --git a/leptos_router/src/components/router.rs b/leptos_router/src/components/router.rs new file mode 100644 index 000000000..3addfa309 --- /dev/null +++ b/leptos_router/src/components/router.rs @@ -0,0 +1,222 @@ +use std::{any::Any, cell::RefCell, future::Future}; + +use leptos_dom::IntoChild; +use leptos_reactive::{ReadSignal, Scope, WriteSignal}; +use thiserror::Error; + +use crate::{ + create_location, resolve_path, DataFunction, HistoryIntegration, Integration, Location, + LocationChange, Params, RouteContext, State, +}; + +pub struct RouterProps +where + C: IntoChild, + D: Fn(Params, Location) -> Fu + Clone + 'static, + Fu: Future, + T: Any + 'static, +{ + base: Option, + data: Option, + children: C, +} + +#[allow(non_snake_case)] +pub fn Router(cx: Scope, props: RouterProps) -> C +where + C: IntoChild, + D: Fn(Params, Location) -> Fu + Clone + 'static, + Fu: Future, + T: Any + 'static, +{ + let integration = HistoryIntegration {}; + cx.provide_context(RouterContext::new( + cx, + integration, + props.base, + props.data.map(|data| DataFunction::from(data)), + )); + + props.children +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouterContext { + cx: Scope, + reference: ReadSignal, + set_reference: WriteSignal, + referrers: RefCell>, + source: ReadSignal, + set_source: WriteSignal, + state: ReadSignal, + set_state: WriteSignal, +} + +impl RouterContext { + pub fn new( + cx: Scope, + integration: impl Integration, + base: Option, + data: Option, + ) -> Self { + let (source, set_source) = integration.normalize(cx); + let base = base.unwrap_or_default(); + let base_path = resolve_path("", &base, None); + if let Some(base_path) = &base_path && source.with(|s| s.value.is_empty()) { + set_source(|source| *source = LocationChange { + value: base_path.to_string(), + replace: true, + scroll: false, + state: State(None) + }); + } + let (reference, set_reference) = cx.create_signal(source.with(|s| s.value.clone())); + let (state, set_state) = cx.create_signal(source.with(|s| s.state.clone())); + let transition = cx.use_transition(); + let location = create_location(cx, reference, state); + let referrers: Vec = Vec::new(); + + let base_path = RouteContext::new(&base_path.unwrap_or_default()); + + if let Some(data) = data { + todo!() + } + + cx.create_render_effect(move |_| { + let LocationChange { value, state, .. } = source(); + cx.untrack(move || { + if value != reference() { + transition.start(move || { + set_reference(|r| *r = value.clone()); + set_state(|s| *s = state.clone()); + }); + } + }); + }); + + // TODO handle anchor click + + Self { + cx, + reference, + set_reference, + referrers: RefCell::new(referrers), + source, + set_source, + state, + set_state, + } + } + + pub fn navigate_from_route( + &self, + route: &RouteContext, + to: &str, + options: &NavigateOptions, + ) -> Result<(), NavigationError> { + self.cx.untrack(move || { + let resolved_to = if options.resolve { + route.resolve_path(to) + } else { + resolve_path("", to, None) + }; + + match resolved_to { + None => Err(NavigationError::NotRoutable(to.to_string())), + Some(resolved_to) => { + if self.referrers.borrow().len() > 32 { + return Err(NavigationError::MaxRedirects); + } + + let current = self.reference.get(); + + if resolved_to != current || options.state != self.state.get() { + if cfg!(feature = "server") { + // TODO server out + self.set_source.update(|source| { + *source = LocationChange { + value: resolved_to.to_string(), + replace: options.replace, + scroll: options.scroll, + state: options.state.clone(), + } + }); + } else { + { + self.referrers.borrow_mut().push(LocationChange { + value: resolved_to.to_string(), + replace: options.replace, + scroll: options.scroll, + state: self.state.get(), + }); + } + let len = self.referrers.borrow().len(); + + let transition = self.cx.use_transition(); + transition.start(move || { + self.set_reference.update({ + let resolved = resolved_to.to_string(); + move |r| *r = resolved + }); + self.set_state.update({ + let next_state = options.state.clone(); + move |state| *state = next_state + }); + if self.referrers.borrow().len() == len { + self.navigate_end(LocationChange { + value: resolved_to.to_string(), + replace: false, + scroll: true, + state: options.state.clone(), + }) + } + }); + } + } + + Ok(()) + } + } + }) + } + + fn navigate_end(&self, next: LocationChange) { + let first = self.referrers.borrow().get(0).cloned(); + if let Some(first) = first { + if next.value != first.value || next.state != first.state { + self.set_source.update(|source| { + *source = next; + source.replace = first.replace; + source.scroll = first.scroll; + }) + } + self.referrers.borrow_mut().clear(); + } + } +} + +#[derive(Debug, Error)] +pub enum NavigationError { + #[error("Path {0:?} is not routable")] + NotRoutable(String), + #[error("Too many redirects")] + MaxRedirects, +} + +pub struct NavigateOptions { + pub resolve: bool, + pub replace: bool, + pub scroll: bool, + pub state: State, +} + +impl Default for NavigateOptions { + fn default() -> Self { + Self { + resolve: true, + replace: false, + scroll: true, + state: State(None), + } + } +} diff --git a/leptos_router/src/data.rs b/leptos_router/src/data.rs new file mode 100644 index 000000000..6ec1c0eaf --- /dev/null +++ b/leptos_router/src/data.rs @@ -0,0 +1,28 @@ +use std::{any::Any, future::Future, pin::Pin}; + +use crate::{Location, Params}; + +pub struct DataFunction { + data: Box Pin>>>>, +} + +impl From for DataFunction +where + F: Fn(Params, Location) -> Fu + Clone + 'static, + Fu: Future, + T: Any + 'static, +{ + fn from(f: F) -> Self { + Self { + data: Box::new(move |params, location| { + Box::pin({ + let f = f.clone(); + async move { + let data = f(params, location).await; + Box::new(data) as Box + } + }) + }), + } + } +} diff --git a/leptos_router/src/integrations.rs b/leptos_router/src/integrations.rs new file mode 100644 index 000000000..e86f374ea --- /dev/null +++ b/leptos_router/src/integrations.rs @@ -0,0 +1,21 @@ +use leptos_reactive::{ReadSignal, Scope, WriteSignal}; + +use crate::LocationChange; + +pub trait Integration { + fn normalize(&self, cx: Scope) -> (ReadSignal, WriteSignal) { + todo!() + } +} + +pub struct ServerIntegration {} + +impl Integration for ServerIntegration {} + +pub struct HashIntegration {} + +impl Integration for HashIntegration {} + +pub struct HistoryIntegration {} + +impl Integration for HistoryIntegration {} diff --git a/leptos_router/src/lib.rs b/leptos_router/src/lib.rs index 61af34bfe..9f6b493c3 100644 --- a/leptos_router/src/lib.rs +++ b/leptos_router/src/lib.rs @@ -1,3 +1,20 @@ +#![feature(let_chains)] +#![feature(trait_alias)] + mod components; +mod data; +mod integrations; +mod location; +mod params; +mod routing; +mod url; +mod utils; pub use components::*; +pub use data::*; +pub use integrations::*; +pub use location::*; +pub use params::*; +pub use routing::*; +pub use url::*; +pub use utils::*; diff --git a/leptos_router/src/location.rs b/leptos_router/src/location.rs new file mode 100644 index 000000000..e70e6d7c5 --- /dev/null +++ b/leptos_router/src/location.rs @@ -0,0 +1,37 @@ +use std::{any::Any, rc::Rc}; + +use leptos_reactive::Memo; + +use crate::Params; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Location { + pub query: Memo, + pub path_name: Memo, + pub search: Memo, + pub hash: Memo, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocationChange { + pub value: String, + pub replace: bool, + pub scroll: bool, + pub state: State, +} + +#[derive(Debug, Clone)] +pub struct State(pub Option>); + +impl PartialEq for State { + fn eq(&self, other: &Self) -> bool { + matches!((self.0.as_ref(), other.0.as_ref()), (None, None)) + } +} + +impl Eq for State {} + +/* pub trait State {} + +impl State for T where T: Any + std::fmt::Debug + PartialEq + Eq + Clone {} + */ diff --git a/leptos_router/src/params.rs b/leptos_router/src/params.rs new file mode 100644 index 000000000..a958382e5 --- /dev/null +++ b/leptos_router/src/params.rs @@ -0,0 +1,44 @@ +use linear_map::LinearMap; + +// For now, implemented with a `LinearMap`, as `n` is small enough +// that O(n) iteration over a vectorized map is (*probably*) more space- +// and time-efficient than hashing and using an actual `HashMap` +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Params(pub LinearMap); + +impl Params { + pub fn new() -> Self { + Self(LinearMap::new()) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self(LinearMap::with_capacity(capacity)) + } + + pub fn insert(&mut self, key: String, value: String) -> Option { + self.0.insert(key, value) + } +} + +impl Default for Params { + fn default() -> Self { + Self::new() + } +} + +// Adapted from hash_map! in common_macros crate +// Copyright (c) 2019 Philipp Korber +// https://github.com/rustonaut/common_macros/blob/master/src/lib.rs +#[macro_export] +macro_rules! params { + ($($key:expr => $val:expr),* ,) => ( + $crate::params!($($key => $val),*) + ); + ($($key:expr => $val:expr),*) => ({ + let start_capacity = common_macros::const_expr_count!($($key);*); + #[allow(unused_mut)] + let mut map = linear_map::LinearMap::with_capacity(start_capacity); + $( map.insert($key, $val); )* + $crate::Params(map) + }); +} diff --git a/leptos_router/src/routing.rs b/leptos_router/src/routing.rs new file mode 100644 index 000000000..ed9503fe9 --- /dev/null +++ b/leptos_router/src/routing.rs @@ -0,0 +1,29 @@ +use std::{any::Any, rc::Rc}; + +use leptos_reactive::{ReadSignal, Scope}; + +use crate::{Location, Url, State}; + +pub fn create_location(cx: Scope, path: ReadSignal, state: ReadSignal) -> Location { + let url = cx.create_memo(move |prev: Option<&Url>| { + path.with(|path| match Url::try_from(path.as_str()) { + Ok(url) => url, + Err(e) => { + log::error!("[Leptos Router] Invalid path {path}\n\n{e:?}"); + prev.unwrap().clone() + } + }) + }); + + let path_name = cx.create_memo(move |_| url.with(|url| url.path_name.clone())); + let search = cx.create_memo(move |_| url.with(|url| url.search.clone())); + let hash = cx.create_memo(move |_| url.with(|url| url.hash.clone())); + let query = cx.create_memo(move |_| url.with(|url| url.search_params())); + + Location { + path_name, + search, + hash, + query, + } +} diff --git a/leptos_router/src/url.rs b/leptos_router/src/url.rs new file mode 100644 index 000000000..ac28a836d --- /dev/null +++ b/leptos_router/src/url.rs @@ -0,0 +1,22 @@ +use crate::Params; + +#[derive(Debug, Clone)] +pub struct Url { + pub path_name: String, + pub search: String, + pub hash: String, +} + +impl Url { + pub fn search_params(&self) -> Params { + todo!() + } +} + +impl TryFrom<&str> for Url { + type Error = (); + + fn try_from(value: &str) -> Result { + todo!() + } +} diff --git a/leptos_router/src/utils/expand_optionals.rs b/leptos_router/src/utils/expand_optionals.rs new file mode 100644 index 000000000..5a3e1b844 --- /dev/null +++ b/leptos_router/src/utils/expand_optionals.rs @@ -0,0 +1,8 @@ +use std::borrow::Cow; + +#[doc(hidden)] +/* pub fn expand_optionals(pattern: &str) -> impl Iterator> { + todo!() +} */ + +const CONTAINS_OPTIONAL: &str = r#"(/?\:[^\/]+)\?"#; diff --git a/leptos_router/src/utils/matcher.rs b/leptos_router/src/utils/matcher.rs new file mode 100644 index 000000000..b9aa5bd57 --- /dev/null +++ b/leptos_router/src/utils/matcher.rs @@ -0,0 +1,108 @@ +// Implementation based on Solid Router +// see https://github.com/solidjs/solid-router/blob/main/src/utils.ts + +use std::borrow::Cow; + +use crate::Params; + +#[derive(Debug, PartialEq, Eq)] +#[doc(hidden)] +pub struct PathMatch<'a> { + pub path: Cow<'a, str>, + pub params: Params, +} + +#[doc(hidden)] +pub struct Matcher<'a> { + splat: Option<&'a str>, + segments: Vec<&'a str>, + len: usize, + partial: bool, +} + +impl<'a> Matcher<'a> { + #[doc(hidden)] + pub fn new(path: &'a str) -> Self { + Self::new_with_partial(path, false) + } + + #[doc(hidden)] + pub fn new_with_partial(path: &'a str, partial: bool) -> Self { + let (pattern, splat) = match path.split_once("/*") { + Some((p, s)) => (p, Some(s)), + None => (path, None), + }; + let segments = pattern + .split('/') + .filter(|n| !n.is_empty()) + .collect::>(); + + let len = segments.len(); + + Self { + splat, + segments, + len, + partial, + } + } + + #[doc(hidden)] + pub fn test<'b>(&self, location: &'b str) -> Option> + where + 'a: 'b, + { + let loc_segments = location + .split('/') + .filter(|n| !n.is_empty()) + .collect::>(); + let loc_len = loc_segments.len(); + let len_diff = loc_len - self.len; + + // quick path: not a match if + // 1) matcher has add'l segments not found in location + // 2) location has add'l segments, there's no splat, and partial matches not allowed + if loc_len < self.len || (len_diff > 0 && self.splat.is_none() && !self.partial) { + None + } + // otherwise, start building a match + else { + /* let matched = PathMatch { + path: if self.len > 0 { + "".into() + } else { + "/".into() + }, + params: Params::new() + }; */ + + let mut path = String::new(); + let mut params = Params::new(); + for (segment, loc_segment) in self.segments.iter().zip(loc_segments.iter()) { + if let Some(param_name) = segment.strip_prefix(':') { + params.insert(param_name.into(), (*loc_segment).into()); + } else if segment != loc_segment { + // if any segment doesn't match and isn't a param, there's no path match + return None; + } + + path.push('/'); + path.push_str(loc_segment); + } + + if let Some(splat) = self.splat && !splat.is_empty() { + let value = if len_diff > 0 { + loc_segments[self.len..].join("/").into() + } else { + "".into() + }; + params.insert(splat.into(), value); + } + + Some(PathMatch { + path: path.into(), + params, + }) + } + } +} diff --git a/leptos_router/src/utils/mod.rs b/leptos_router/src/utils/mod.rs new file mode 100644 index 000000000..429c91847 --- /dev/null +++ b/leptos_router/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod expand_optionals; +pub mod matcher; +pub mod resolve_path; + +pub use expand_optionals::*; +pub use matcher::*; +pub use resolve_path::*; diff --git a/leptos_router/src/utils/resolve_path.rs b/leptos_router/src/utils/resolve_path.rs new file mode 100644 index 000000000..0a0b5897e --- /dev/null +++ b/leptos_router/src/utils/resolve_path.rs @@ -0,0 +1,126 @@ +// Implementation based on Solid Router +// see https://github.com/solidjs/solid-router/blob/main/src/utils.ts + +use std::borrow::Cow; + +#[doc(hidden)] +pub fn resolve_path<'a>( + base: &'a str, + path: &'a str, + from: Option<&'a str>, +) -> Option> { + if has_scheme(path) { + None + } else { + let base_path = normalize(base, false); + let from_path = from.map(|from| normalize(from, false)); + let result = if let Some(from_path) = from_path { + if path.starts_with('/') { + base_path + } else if from_path.to_lowercase().find(&base_path.to_lowercase()) != Some(0) { + base_path + from_path + } else { + from_path + } + } else { + base_path + }; + + let result_empty = result.is_empty(); + let prefix = if result_empty { "/".into() } else { result }; + + Some(prefix + normalize(path, result_empty)) + } +} + +fn has_scheme(path: &str) -> bool { + use regex::Regex; + lazy_static::lazy_static! { + pub static ref HAS_SCHEME_RE: Regex = + Regex::new(HAS_SCHEME).expect("couldn't compile HAS_SCHEME_RE"); + } + + HAS_SCHEME_RE.is_match(path) +} + +#[doc(hidden)] +pub fn normalize(path: &str, omit_slash: bool) -> Cow<'_, str> { + let s = replace_trim_path(path, ""); + if !s.is_empty() { + if omit_slash || begins_with_query_or_hash(&s) { + s + } else { + format!("/{s}").into() + } + } else { + "".into() + } +} + +#[doc(hidden)] +pub fn join_paths<'a>(from: &'a str, to: &'a str) -> String { + let from = replace_query(&normalize(from, false)).to_string(); + from + &normalize(to, false) +} + +const TRIM_PATH: &str = r#"^/+|/+$"#; +const BEGINS_WITH_QUERY_OR_HASH: &str = r#"^[?#]"#; +const HAS_SCHEME: &str = r#"^(?:[a-z0-9]+:)?//"#; +const QUERY: &str = r#"/*(\*.*)?$"#; + +#[cfg(feature = "browser")] +fn replace_trim_path<'a>(text: &'a str, replace: &str) -> Cow<'a, str> { + let re = js_sys::Regexp::new(TRIM_PATH, "g"); + js_sys::JsString::from(text) + .replace_by_pattern(&re, "") + .as_string() + .unwrap() + .into() +} + +#[cfg(feature = "browser")] +fn begins_with_query_or_hash(text: &str) -> bool { + let re = js_sys::Regexp::new(BEGINS_WITH_QUERY_OR_HASH, ""); + re.test(text) +} + +#[cfg(feature = "browser")] +fn replace_query(text: &str) -> String { + let re = js_sys::Regexp::new(QUERY, "g"); + js_sys::JsString::from(text) + .replace_by_pattern(&re, "") + .as_string() + .unwrap() + .into() +} + +#[cfg(not(feature = "browser"))] +fn replace_trim_path<'a>(text: &'a str, replace: &str) -> Cow<'a, str> { + use regex::Regex; + lazy_static::lazy_static! { + pub static ref TRIM_PATH_RE: Regex = + Regex::new(TRIM_PATH).expect("couldn't compile TRIM_PATH_RE"); + } + + TRIM_PATH_RE.replace(text, replace) +} + +#[cfg(not(feature = "browser"))] +fn begins_with_query_or_hash(text: &str) -> bool { + use regex::Regex; + lazy_static::lazy_static! { + pub static ref BEGINS_WITH_QUERY_OR_HASH_RE: Regex = + Regex::new(BEGINS_WITH_QUERY_OR_HASH).expect("couldn't compile BEGINS_WITH_HASH_RE"); + } + BEGINS_WITH_QUERY_OR_HASH_RE.is_match(text) +} + +#[cfg(not(feature = "browser"))] +fn replace_query(text: &str) -> Cow { + use regex::Regex; + lazy_static::lazy_static! { + pub static ref QUERY_RE: Regex = + Regex::new(QUERY).expect("couldn't compile QUERY_RE"); + } + QUERY_RE.replace(text, "") +} diff --git a/leptos_router/tests/expand_optionals.rs b/leptos_router/tests/expand_optionals.rs new file mode 100644 index 000000000..e69de29bb diff --git a/leptos_router/tests/join_paths.rs b/leptos_router/tests/join_paths.rs new file mode 100644 index 000000000..a7e90e7c8 --- /dev/null +++ b/leptos_router/tests/join_paths.rs @@ -0,0 +1,42 @@ +use leptos_router::join_paths; + +// Test cases drawn from Solid Router +// see https://github.com/solidjs/solid-router/blob/main/test/utils.spec.ts + +#[test] +fn join_paths_should_join_with_a_single_slash() { + assert_eq!(join_paths("/foo", "bar"), "/foo/bar"); + assert_eq!(join_paths("/foo/", "bar"), "/foo/bar"); + assert_eq!(join_paths("/foo", "/bar"), "/foo/bar"); + assert_eq!(join_paths("/foo/", "/bar"), "/foo/bar"); +} + +#[test] +fn join_paths_should_ensure_leading_slash() { + assert_eq!(join_paths("/foo", ""), "/foo"); + assert_eq!(join_paths("foo", ""), "/foo"); + assert_eq!(join_paths("", "foo"), "/foo"); + assert_eq!(join_paths("", "/foo"), "/foo"); + assert_eq!(join_paths("/", "foo"), "/foo"); + assert_eq!(join_paths("/", "/foo"), "/foo"); +} + +#[test] +fn join_paths_should_strip_tailing_slash_asterisk() { + assert_eq!(join_paths("foo/*", ""), "/foo"); + assert_eq!(join_paths("foo/*", "/"), "/foo"); + assert_eq!(join_paths("/foo/*all", ""), "/foo"); + assert_eq!(join_paths("/foo/*", "bar"), "/foo/bar"); + assert_eq!(join_paths("/foo/*all", "bar"), "/foo/bar"); + assert_eq!(join_paths("/*", "foo"), "/foo"); + assert_eq!(join_paths("/*all", "foo"), "/foo"); + assert_eq!(join_paths("*", "foo"), "/foo"); +} + +#[test] +fn join_paths_should_preserve_parameters() { + assert_eq!(join_paths("/foo/:bar", ""), "/foo/:bar"); + assert_eq!(join_paths("/foo/:bar", "baz"), "/foo/:bar/baz"); + assert_eq!(join_paths("/foo", ":bar/baz"), "/foo/:bar/baz"); + assert_eq!(join_paths("", ":bar/baz"), "/:bar/baz"); +} diff --git a/leptos_router/tests/matcher.rs b/leptos_router/tests/matcher.rs new file mode 100644 index 000000000..9e7863648 --- /dev/null +++ b/leptos_router/tests/matcher.rs @@ -0,0 +1,90 @@ +// Test cases drawn from Solid Router +// see https://github.com/solidjs/solid-router/blob/main/test/utils.spec.ts + +use leptos_router::{params, Matcher, PathMatch}; + +#[test] +fn create_matcher_should_return_no_params_when_location_matches_exactly() { + let matcher = Matcher::new("/foo/bar"); + let matched = matcher.test("/foo/bar"); + assert_eq!( + matched, + Some(PathMatch { + path: "/foo/bar".into(), + params: params!() + }) + ); +} + +#[test] +fn create_matcher_should_return_none_when_location_doesnt_match() { + let matcher = Matcher::new("/foo/bar"); + let matched = matcher.test("/foo/baz"); + assert_eq!(matched, None); +} + +#[test] +fn create_matcher_should_build_params_collection() { + let matcher = Matcher::new("/foo/:id"); + let matched = matcher.test("/foo/abc-123"); + assert_eq!( + matched, + Some(PathMatch { + path: "/foo/abc-123".into(), + params: params!( + "id".into() => "abc-123".into() + ) + }) + ); +} + +#[test] +fn create_matcher_should_match_past_end_when_ending_in_asterisk() { + let matcher = Matcher::new("/foo/bar/*"); + let matched = matcher.test("/foo/bar/baz"); + assert_eq!( + matched, + Some(PathMatch { + path: "/foo/bar".into(), + params: params!() + }) + ); +} + +#[test] +fn create_matcher_should_not_match_past_end_when_not_ending_in_asterisk() { + let matcher = Matcher::new("/foo/bar"); + let matched = matcher.test("/foo/bar/baz"); + assert_eq!(matched, None); +} + +#[test] +fn create_matcher_should_include_remaining_unmatched_location_as_param_when_ending_in_asterisk_and_name( +) { + let matcher = Matcher::new("/foo/bar/*something"); + let matched = matcher.test("/foo/bar/baz/qux"); + assert_eq!( + matched, + Some(PathMatch { + path: "/foo/bar".into(), + params: params!( + "something".into() => "baz/qux".into() + ) + }) + ); +} + +#[test] +fn create_matcher_should_include_empty_param_when_perfect_match_ends_in_asterisk_and_name() { + let matcher = Matcher::new("/foo/bar/*something"); + let matched = matcher.test("/foo/bar"); + assert_eq!( + matched, + Some(PathMatch { + path: "/foo/bar".into(), + params: params!( + "something".into() => "".into() + ) + }) + ); +} diff --git a/leptos_router/tests/resolve_path.rs b/leptos_router/tests/resolve_path.rs new file mode 100644 index 000000000..14e664e49 --- /dev/null +++ b/leptos_router/tests/resolve_path.rs @@ -0,0 +1,101 @@ +// Test cases drawn from Solid Router +// see https://github.com/solidjs/solid-router/blob/main/test/utils.spec.ts + +use leptos_router::{normalize, resolve_path}; + +#[test] +fn normalize_query_string_with_opening_slash() { + assert_eq!(normalize("/?foo=bar", false), "?foo=bar"); +} + +#[test] +fn resolve_path_should_normalize_base_arg() { + assert_eq!(resolve_path("base", "", None), Some("/base".into())); +} + +#[test] +fn resolve_path_should_normalize_path_arg() { + assert_eq!(resolve_path("", "path", None), Some("/path".into())); +} + +#[test] +fn resolve_path_should_normalize_from_arg() { + assert_eq!(resolve_path("", "", Some("from")), Some("/from".into())); +} + +#[test] +fn resolve_path_should_return_default_when_all_empty() { + assert_eq!(resolve_path("", "", None), Some("/".into())); +} + +#[test] +fn resolve_path_should_resolve_root_against_base_and_ignore_from() { + assert_eq!( + resolve_path("/base", "/", Some("/base/foo")), + Some("/base".into()) + ); +} + +#[test] +fn resolve_path_should_resolve_rooted_paths_against_base_and_ignore_from() { + assert_eq!( + resolve_path("/base", "/bar", Some("/base/foo")), + Some("/base/bar".into()) + ); +} + +#[test] +fn resolve_path_should_resolve_empty_path_against_from() { + assert_eq!( + resolve_path("/base", "", Some("/base/foo")), + Some("/base/foo".into()) + ); +} + +#[test] +fn resolve_path_should_resolve_relative_paths_against_from() { + assert_eq!( + resolve_path("/base", "bar", Some("/base/foo")), + Some("/base/foo/bar".into()) + ); +} + +#[test] +fn resolve_path_should_prepend_base_if_from_doesnt_start_with_it() { + assert_eq!( + resolve_path("/base", "bar", Some("/foo")), + Some("/base/foo/bar".into()) + ); +} + +#[test] +fn resolve_path_should_test_start_of_from_against_base_case_insensitive() { + assert_eq!( + resolve_path("/base", "bar", Some("BASE/foo")), + Some("/BASE/foo/bar".into()) + ); +} + +#[test] +fn resolve_path_should_work_with_rooted_search_and_base() { + assert_eq!( + resolve_path("/base", "/?foo=bar", Some("/base/page")), + Some("/base?foo=bar".into()) + ); +} + +#[test] +fn resolve_path_should_work_with_rooted_search() { + assert_eq!( + resolve_path("", "/?foo=bar", None), + Some("/?foo=bar".into()) + ); +} + +#[test] +fn preserve_spaces() { + assert_eq!( + resolve_path(" foo ", " bar baz ", None), + Some("/ foo / bar baz ".into()) + ); +}