mirror of
https://github.com/leptos-rs/leptos
synced 2024-11-10 06:44:17 +00:00
Initial router work
This commit is contained in:
parent
326b0ded92
commit
329eab89d2
22 changed files with 940 additions and 38 deletions
7
TODO.md
7
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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
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"]
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
*/
|
5
leptos_router/src/components/mod.rs
Normal file
5
leptos_router/src/components/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod route;
|
||||
mod router;
|
||||
|
||||
pub use route::*;
|
||||
pub use router::*;
|
13
leptos_router/src/components/route.rs
Normal file
13
leptos_router/src/components/route.rs
Normal 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!()
|
||||
}
|
||||
}
|
222
leptos_router/src/components/router.rs
Normal file
222
leptos_router/src/components/router.rs
Normal 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
28
leptos_router/src/data.rs
Normal 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>
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
21
leptos_router/src/integrations.rs
Normal file
21
leptos_router/src/integrations.rs
Normal 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 {}
|
|
@ -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::*;
|
||||
|
|
37
leptos_router/src/location.rs
Normal file
37
leptos_router/src/location.rs
Normal 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 {}
|
||||
*/
|
44
leptos_router/src/params.rs
Normal file
44
leptos_router/src/params.rs
Normal 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)
|
||||
});
|
||||
}
|
29
leptos_router/src/routing.rs
Normal file
29
leptos_router/src/routing.rs
Normal 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
22
leptos_router/src/url.rs
Normal 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!()
|
||||
}
|
||||
}
|
8
leptos_router/src/utils/expand_optionals.rs
Normal file
8
leptos_router/src/utils/expand_optionals.rs
Normal 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#"(/?\:[^\/]+)\?"#;
|
108
leptos_router/src/utils/matcher.rs
Normal file
108
leptos_router/src/utils/matcher.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
7
leptos_router/src/utils/mod.rs
Normal file
7
leptos_router/src/utils/mod.rs
Normal 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::*;
|
126
leptos_router/src/utils/resolve_path.rs
Normal file
126
leptos_router/src/utils/resolve_path.rs
Normal 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, "")
|
||||
}
|
0
leptos_router/tests/expand_optionals.rs
Normal file
0
leptos_router/tests/expand_optionals.rs
Normal file
42
leptos_router/tests/join_paths.rs
Normal file
42
leptos_router/tests/join_paths.rs
Normal 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");
|
||||
}
|
90
leptos_router/tests/matcher.rs
Normal file
90
leptos_router/tests/matcher.rs
Normal 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()
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
101
leptos_router/tests/resolve_path.rs
Normal file
101
leptos_router/tests/resolve_path.rs
Normal 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())
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue