Initial router work

This commit is contained in:
Greg Johnston 2022-08-16 09:49:22 -04:00
parent 326b0ded92
commit 329eab89d2
22 changed files with 940 additions and 38 deletions

View file

@ -6,6 +6,13 @@
- [ ] Bugs in Suspense/Transitions - [ ] Bugs in Suspense/Transitions
- [ ] let render effects that _aren't_ under the transition continue running - [ ] let render effects that _aren't_ under the transition continue running
- [ ] Router - [ ] Router
- [ ] Tests
- [ ] Utils
- [ ] Components
- [ ] Integrations
- [ ] Client
- [ ] Server
- [ ] Examples
- [ ] Docs (and clippy warning to insist on docs) - [ ] Docs (and clippy warning to insist on docs)
- [ ] Read through + understand... - [ ] Read through + understand...
- [ ] `Props` macro - [ ] `Props` macro

View file

@ -30,7 +30,7 @@ pub struct Transition {
} }
impl Transition { impl Transition {
pub fn start(&self, f: impl Fn()) { pub fn start(&self, f: impl FnOnce()) {
if self.runtime.running_transition().is_some() { if self.runtime.running_transition().is_some() {
f(); f();
} else { } else {

View file

@ -3,11 +3,20 @@ name = "leptos_router"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
leptos_core = { path = "../leptos_core" } leptos_core = { path = "../leptos_core" }
leptos_dom = { path = "../leptos_dom"} leptos_dom = { path = "../leptos_dom"}
leptos_macro = { path = "../leptos_macro"} leptos_macro = { path = "../leptos_macro"}
leptos_reactive = { path = "../leptos_reactive"} leptos_reactive = { path = "../leptos_reactive"}
serde = "1" 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"]

View file

@ -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<C, D>
where
C: for<'a> IntoChild<'a>,
D: Serialize + DeserializeOwned + 'static,
{
base: Option<String>,
data: Option<Box<dyn Fn() -> D>>,
children: C,
}
pub fn Router<C, D>(cx: Scope, props: RouterProps<C, D>)
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 (
<RouterContextObj.Provider value={routerState}>{props.children}</RouterContextObj.Provider>
);
};
*/

View file

@ -0,0 +1,5 @@
mod route;
mod router;
pub use route::*;
pub use router::*;

View file

@ -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<Cow<str>> {
todo!()
}
}

View file

@ -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<C, D, Fu, T>
where
C: IntoChild,
D: Fn(Params, Location) -> Fu + Clone + 'static,
Fu: Future<Output = T>,
T: Any + 'static,
{
base: Option<String>,
data: Option<D>,
children: C,
}
#[allow(non_snake_case)]
pub fn Router<C, D, Fu, T>(cx: Scope, props: RouterProps<C, D, Fu, T>) -> C
where
C: IntoChild,
D: Fn(Params, Location) -> Fu + Clone + 'static,
Fu: Future<Output = T>,
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<String>,
set_reference: WriteSignal<String>,
referrers: RefCell<Vec<LocationChange>>,
source: ReadSignal<LocationChange>,
set_source: WriteSignal<LocationChange>,
state: ReadSignal<State>,
set_state: WriteSignal<State>,
}
impl RouterContext {
pub fn new(
cx: Scope,
integration: impl Integration,
base: Option<String>,
data: Option<DataFunction>,
) -> 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<LocationChange> = 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),
}
}
}

28
leptos_router/src/data.rs Normal file
View file

@ -0,0 +1,28 @@
use std::{any::Any, future::Future, pin::Pin};
use crate::{Location, Params};
pub struct DataFunction {
data: Box<dyn Fn(Params, Location) -> Pin<Box<dyn Future<Output = Box<dyn Any>>>>>,
}
impl<F, Fu, T> From<F> for DataFunction
where
F: Fn(Params, Location) -> Fu + Clone + 'static,
Fu: Future<Output = T>,
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<dyn Any>
}
})
}),
}
}
}

View file

@ -0,0 +1,21 @@
use leptos_reactive::{ReadSignal, Scope, WriteSignal};
use crate::LocationChange;
pub trait Integration {
fn normalize(&self, cx: Scope) -> (ReadSignal<LocationChange>, WriteSignal<LocationChange>) {
todo!()
}
}
pub struct ServerIntegration {}
impl Integration for ServerIntegration {}
pub struct HashIntegration {}
impl Integration for HashIntegration {}
pub struct HistoryIntegration {}
impl Integration for HistoryIntegration {}

View file

@ -1,3 +1,20 @@
#![feature(let_chains)]
#![feature(trait_alias)]
mod components; mod components;
mod data;
mod integrations;
mod location;
mod params;
mod routing;
mod url;
mod utils;
pub use components::*; pub use components::*;
pub use data::*;
pub use integrations::*;
pub use location::*;
pub use params::*;
pub use routing::*;
pub use url::*;
pub use utils::*;

View file

@ -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<Params>,
pub path_name: Memo<String>,
pub search: Memo<String>,
pub hash: Memo<String>,
}
#[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<Rc<dyn Any>>);
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<T> State for T where T: Any + std::fmt::Debug + PartialEq + Eq + Clone {}
*/

View file

@ -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<String, String>);
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<String> {
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)
});
}

View file

@ -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<String>, state: ReadSignal<State>) -> 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,
}
}

22
leptos_router/src/url.rs Normal file
View file

@ -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<Self, Self::Error> {
todo!()
}
}

View file

@ -0,0 +1,8 @@
use std::borrow::Cow;
#[doc(hidden)]
/* pub fn expand_optionals(pattern: &str) -> impl Iterator<Item = Cow<str>> {
todo!()
} */
const CONTAINS_OPTIONAL: &str = r#"(/?\:[^\/]+)\?"#;

View file

@ -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::<Vec<_>>();
let len = segments.len();
Self {
splat,
segments,
len,
partial,
}
}
#[doc(hidden)]
pub fn test<'b>(&self, location: &'b str) -> Option<PathMatch<'b>>
where
'a: 'b,
{
let loc_segments = location
.split('/')
.filter(|n| !n.is_empty())
.collect::<Vec<_>>();
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,
})
}
}
}

View file

@ -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::*;

View file

@ -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<Cow<'a, str>> {
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<str> {
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, "")
}

View file

View file

@ -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");
}

View file

@ -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()
)
})
);
}

View file

@ -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())
);
}