From 1f1c49b9c95af66fb420309d3258a7de0374e00f Mon Sep 17 00:00:00 2001 From: Adrian Wannenmacher Date: Sat, 3 Dec 2022 21:49:16 +0100 Subject: [PATCH] add router core --- Cargo.toml | 3 +- packages/router-core/Cargo.toml | 21 + packages/router-core/README.md | 1 + packages/router-core/src/history/memory.rs | 256 ++++ packages/router-core/src/history/mod.rs | 187 +++ packages/router-core/src/lib.rs | 80 ++ packages/router-core/src/name/mod.rs | 76 ++ packages/router-core/src/name/segments.rs | 133 +++ packages/router-core/src/navigation.rs | 488 ++++++++ packages/router-core/src/outlet.rs | 201 ++++ packages/router-core/src/routes/atom.rs | 29 + packages/router-core/src/routes/content.rs | 306 +++++ packages/router-core/src/routes/matcher.rs | 25 + .../router-core/src/routes/parameter_route.rs | 275 +++++ packages/router-core/src/routes/route.rs | 247 ++++ packages/router-core/src/routes/segment.rs | 457 ++++++++ packages/router-core/src/service.rs | 1033 +++++++++++++++++ packages/router-core/src/state.rs | 288 +++++ packages/router-core/src/utils/name.rs | 144 +++ packages/router-core/src/utils/route.rs | 489 ++++++++ packages/router-core/src/utils/sitemap.rs | 147 +++ packages/router-core/src/utils/target.rs | 120 ++ 22 files changed, 5005 insertions(+), 1 deletion(-) create mode 100644 packages/router-core/Cargo.toml create mode 100644 packages/router-core/README.md create mode 100644 packages/router-core/src/history/memory.rs create mode 100644 packages/router-core/src/history/mod.rs create mode 100644 packages/router-core/src/lib.rs create mode 100644 packages/router-core/src/name/mod.rs create mode 100644 packages/router-core/src/name/segments.rs create mode 100644 packages/router-core/src/navigation.rs create mode 100644 packages/router-core/src/outlet.rs create mode 100644 packages/router-core/src/routes/atom.rs create mode 100644 packages/router-core/src/routes/content.rs create mode 100644 packages/router-core/src/routes/matcher.rs create mode 100644 packages/router-core/src/routes/parameter_route.rs create mode 100644 packages/router-core/src/routes/route.rs create mode 100644 packages/router-core/src/routes/segment.rs create mode 100644 packages/router-core/src/service.rs create mode 100644 packages/router-core/src/state.rs create mode 100644 packages/router-core/src/utils/name.rs create mode 100644 packages/router-core/src/utils/route.rs create mode 100644 packages/router-core/src/utils/sitemap.rs create mode 100644 packages/router-core/src/utils/target.rs diff --git a/Cargo.toml b/Cargo.toml index 2b67e1b1a..378193a17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "packages/dioxus", "packages/core", "packages/core-macro", + "packages/router-core", "packages/router", "packages/html", "packages/hooks", @@ -63,4 +64,4 @@ env_logger = "0.9.0" [profile.release] opt-level = 3 lto = true -debug = true \ No newline at end of file +debug = true diff --git a/packages/router-core/Cargo.toml b/packages/router-core/Cargo.toml new file mode 100644 index 000000000..5fcf3d2df --- /dev/null +++ b/packages/router-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "dioxus-router-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-rwlock = "1.3.0" +either = "1.8.0" +futures-channel = "0.3.25" +futures-util = "0.3.25" +regex = { version = "1.6.0", optional = true } +serde = { version = "1.0.147", optional = true } +serde_urlencoded = { version = "0.7.1", optional = true } +url = "2.3.1" +urlencoding = "2.1.2" + +[features] +regex = ["dep:regex"] +serde = ["dep:serde", "serde_urlencoded"] diff --git a/packages/router-core/README.md b/packages/router-core/README.md new file mode 100644 index 000000000..a63cad00e --- /dev/null +++ b/packages/router-core/README.md @@ -0,0 +1 @@ +# Dioxus Router Core diff --git a/packages/router-core/src/history/memory.rs b/packages/router-core/src/history/memory.rs new file mode 100644 index 000000000..0267e8fe8 --- /dev/null +++ b/packages/router-core/src/history/memory.rs @@ -0,0 +1,256 @@ +use std::str::FromStr; + +use url::{ParseError, Url}; + +use super::HistoryProvider; + +const INITIAL_URL: &str = "dioxus-router-core://initial_url.invalid/"; + +/// A [`HistoryProvider`] that stores all navigation information in memory. +pub struct MemoryHistory { + current: Url, + history: Vec, + future: Vec, +} + +impl MemoryHistory { + /// Create a [`MemoryHistory`] starting at `path`. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::with_initial_path("/some/path").unwrap(); + /// assert_eq!(history.current_path(), "/some/path"); + /// assert_eq!(history.can_go_back(), false); + /// ``` + pub fn with_initial_path(path: impl Into) -> Result { + let mut path = path.into(); + if path.starts_with('/') { + path.remove(0); + } + let url = Url::parse(&format!("{INITIAL_URL}{path}"))?; + + Ok(Self { + current: url, + ..Default::default() + }) + } +} + +impl Default for MemoryHistory { + fn default() -> Self { + Self { + current: Url::from_str(INITIAL_URL).unwrap(), + history: Vec::new(), + future: Vec::new(), + } + } +} + +impl HistoryProvider for MemoryHistory { + fn current_path(&self) -> String { + self.current.path().to_string() + } + + fn current_query(&self) -> Option { + self.current.query().map(|q| q.to_string()) + } + + fn can_go_back(&self) -> bool { + !self.history.is_empty() + } + + fn go_back(&mut self) { + if let Some(last) = self.history.pop() { + self.future.push(self.current.to_string()); + self.current = Url::parse(&last).unwrap(/* past URLs are always valid */); + } + } + + fn can_go_forward(&self) -> bool { + !self.future.is_empty() + } + + fn go_forward(&mut self) { + if let Some(next) = self.future.pop() { + self.history.push(self.current.to_string()); + self.current = Url::parse(&next).unwrap(/* future URLs are always valid */); + } + } + + fn push(&mut self, path: String) { + let wrong = path.starts_with("//"); + debug_assert!( + !wrong, + "cannot navigate to paths starting with \"//\": {path}" + ); + if wrong { + return; + } + + if let Ok(new) = self.current.join(&path) { + self.history.push(self.current.to_string()); + self.current = new; + self.future.clear(); + } + } + + fn replace(&mut self, path: String) { + let wrong = path.starts_with("//"); + debug_assert!( + !wrong, + "cannot navigate to paths starting with \"//\": {path}" + ); + if wrong { + return; + } + + if let Ok(new) = self.current.join(&path) { + self.current = new; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default() { + let mem = MemoryHistory::default(); + assert_eq!(mem.current, Url::parse(INITIAL_URL).unwrap()); + assert_eq!(mem.history, Vec::::new()); + assert_eq!(mem.future, Vec::::new()); + } + + #[test] + fn with_initial_path() { + let mem = MemoryHistory::with_initial_path("something").unwrap(); + assert_eq!( + mem.current, + Url::parse(&format!("{INITIAL_URL}something")).unwrap() + ); + assert_eq!(mem.history, Vec::::new()); + assert_eq!(mem.future, Vec::::new()); + } + + #[test] + fn with_initial_path_with_leading_slash() { + let mem = MemoryHistory::with_initial_path("/something").unwrap(); + assert_eq!( + mem.current, + Url::parse(&format!("{INITIAL_URL}something")).unwrap() + ); + assert_eq!(mem.history, Vec::::new()); + assert_eq!(mem.future, Vec::::new()); + } + + #[test] + fn can_go_back() { + let mut mem = MemoryHistory::default(); + assert!(!mem.can_go_back()); + + mem.push(String::from("/test")); + assert!(mem.can_go_back()); + } + + #[test] + fn go_back() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("/test")); + mem.go_back(); + + assert_eq!(mem.current, Url::parse(INITIAL_URL).unwrap()); + assert!(mem.history.is_empty()); + assert_eq!(mem.future, vec![format!("{INITIAL_URL}test")]); + } + + #[test] + fn can_go_forward() { + let mut mem = MemoryHistory::default(); + assert!(!mem.can_go_forward()); + + mem.push(String::from("/test")); + mem.go_back(); + + assert!(mem.can_go_forward()); + } + + #[test] + fn go_forward() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("/test")); + mem.go_back(); + mem.go_forward(); + + assert_eq!( + mem.current, + Url::parse(&format!("{INITIAL_URL}test")).unwrap() + ); + assert_eq!(mem.history, vec![INITIAL_URL.to_string()]); + assert!(mem.future.is_empty()); + } + + #[test] + fn push() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("/test")); + + assert_eq!( + mem.current, + Url::parse(&format!("{INITIAL_URL}test")).unwrap() + ); + assert_eq!(mem.history, vec![INITIAL_URL.to_string()]); + assert!(mem.future.is_empty()); + } + + #[test] + #[should_panic = r#"cannot navigate to paths starting with "//": //test"#] + #[cfg(debug_assertions)] + fn push_debug() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("//test")); + } + + #[test] + #[cfg(not(debug_assertions))] + fn push_release() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("//test")); + + assert_eq!(mem.current, Url::parse(INITIAL_URL).unwrap()); + assert!(mem.history.is_empty()) + } + + #[test] + fn replace() { + let mut mem = MemoryHistory::default(); + mem.push(String::from("/test")); + mem.push(String::from("/other")); + mem.go_back(); + mem.replace(String::from("/replace")); + + assert_eq!( + mem.current, + Url::parse(&format!("{INITIAL_URL}replace")).unwrap() + ); + assert_eq!(mem.history, vec![INITIAL_URL.to_string()]); + assert_eq!(mem.future, vec![format!("{INITIAL_URL}other")]); + } + + #[test] + #[should_panic = r#"cannot navigate to paths starting with "//": //test"#] + #[cfg(debug_assertions)] + fn replace_debug() { + let mut mem = MemoryHistory::default(); + mem.replace(String::from("//test")); + } + + #[test] + #[cfg(not(debug_assertions))] + fn replace_release() { + let mut mem = MemoryHistory::default(); + mem.replace(String::from("//test")); + + assert_eq!(mem.current, Url::parse(INITIAL_URL).unwrap()); + } +} diff --git a/packages/router-core/src/history/mod.rs b/packages/router-core/src/history/mod.rs new file mode 100644 index 000000000..1a443ed42 --- /dev/null +++ b/packages/router-core/src/history/mod.rs @@ -0,0 +1,187 @@ +//! History Integration +//! +//! dioxus-router-core relies on so-called [`HistoryProvider`]s to store the current URL, and possibly a +//! history (i.e. a browsers back button) and future (i.e. a browsers forward button). +//! +//! To integrate dioxus-router-core with a any type of history, all you have to do is implement the +//! [`HistoryProvider`] trait. dioxus-router-core also comes with some (for now one) default implementations. + +use std::sync::Arc; + +mod memory; +pub use memory::*; + +/// An integration with some kind of navigation history. +/// +/// Depending on your use case, your implementation may deviate from the described procedure. This +/// is fine, as long as both `current_path` and `current_query` match the described format. +/// +/// However, you should document all deviations. Also, make sure the navigation is user-friendly. +/// The described behaviors are designed to mimic a web browser, which most users should already +/// know. Deviations might confuse them. +pub trait HistoryProvider { + /// Get the path of the current URL. + /// + /// **Must start** with `/`. **Must _not_ contain** the prefix. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.push(String::from("/path")); + /// assert_eq!(history.current_path(), "/path"); + /// ``` + #[must_use] + fn current_path(&self) -> String; + /// Get the query string of the current URL. + /// + /// **Must _not_** start with `?`. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.current_query(), None); + /// + /// history.push(String::from("?some=value")); + /// assert_eq!(history.current_query(), Some("some=value".to_string())); + /// ``` + fn current_query(&self) -> Option; + /// Get the current path prefix of the URL. + /// + /// Not all [`HistoryProvider`]s need a prefix feature. It is meant for environments where a + /// dioxus-router-core-routed application is not running on `/`. The [`HistoryProvider`] is responsible + /// for removing the prefix from the dioxus-router-core-internal path, and also for adding it back in + /// during navigation. This functions value is only used for creating `href`s (e.g. for SSR or + /// display (but not navigation) in a web app). + fn current_prefix(&self) -> Option { + None + } + + /// Check whether there is a previous page to navigate back to. + /// + /// If a [`HistoryProvider`] cannot know this, it should return [`true`]. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.can_go_back(), false); + /// + /// history.push(String::from("/some-other-page")); + /// assert_eq!(history.can_go_back(), true); + /// ``` + #[must_use] + fn can_go_back(&self) -> bool { + true + } + /// Go back to a previous page. + /// + /// If a [`HistoryProvider`] cannot go to a previous page, it should do nothing. This method + /// might be called, even if `can_go_back` returns [`false`]. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.go_back(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.push(String::from("/some-other-page")); + /// assert_eq!(history.current_path(), "/some-other-page"); + /// + /// history.go_back(); + /// assert_eq!(history.current_path(), "/"); + /// ``` + fn go_back(&mut self); + + /// Check whether there is a future page to navigate forward to. + /// + /// If a [`HistoryProvider`] cannot know this, it should return [`true`]. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.can_go_forward(), false); + /// + /// history.push(String::from("/some-other-page")); + /// assert_eq!(history.can_go_forward(), false); + /// + /// history.go_back(); + /// assert_eq!(history.can_go_forward(), true); + /// ``` + #[must_use] + fn can_go_forward(&self) -> bool { + true + } + /// Go forward to a future page. + /// + /// If a [`HistoryProvider`] cannot go to a previous page, it should do nothing. This method + /// might be called, even if `can_go_forward` returns [`false`]. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// history.push(String::from("/some-other-page")); + /// assert_eq!(history.current_path(), "/some-other-page"); + /// + /// history.go_back(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.go_forward(); + /// assert_eq!(history.current_path(), "/some-other-page"); + /// ``` + fn go_forward(&mut self); + + /// Go to another page. + /// + /// This should do three things: + /// 1. Merge the current URL with the `path` parameter (which may also include a query part). + /// 2. Remove the previous URL to the navigation history. + /// 3. Clear the navigation future. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.push(String::from("/some-other-page")); + /// assert_eq!(history.current_path(), "/some-other-page"); + /// assert!(history.can_go_back()); + /// ``` + fn push(&mut self, path: String); + /// Replace the current page with another one. + /// + /// This should merge the current URL with the `path` parameter (which may also include a query + /// part). In contrast to the `push` function, the navigation history and future should stay + /// untouched. + /// + /// ```rust + /// # use dioxus_router_core::history::{HistoryProvider, MemoryHistory}; + /// let mut history = MemoryHistory::default(); + /// assert_eq!(history.current_path(), "/"); + /// + /// history.replace(String::from("/some-other-page")); + /// assert_eq!(history.current_path(), "/some-other-page"); + /// assert!(!history.can_go_back()); + /// ``` + fn replace(&mut self, path: String); + + /// Navigate to an external URL. + /// + /// This should navigate to an external URL, which isn't controlled by the router. If a + /// [`HistoryProvider`] cannot do that, it should return [`false`], otherwise [`true`]. + /// + /// Returning [`false`] will cause the router to handle the external navigation failure. + #[allow(unused_variables)] + fn external(&mut self, url: String) -> bool { + false + } + + /// Provide the [`HistoryProvider`] with an update callback. + /// + /// Some [`HistoryProvider`]s may receive URL updates from outside the router. When such + /// updates are received, they should call `callback`, which will cause the router to update. + #[allow(unused_variables)] + fn updater(&mut self, callback: Arc) {} +} diff --git a/packages/router-core/src/lib.rs b/packages/router-core/src/lib.rs new file mode 100644 index 000000000..454aa1e4f --- /dev/null +++ b/packages/router-core/src/lib.rs @@ -0,0 +1,80 @@ +#![doc = include_str!("../README.md")] +#![forbid(missing_docs)] + +pub mod history; + +mod name; +pub use name::*; + +pub mod navigation; + +mod outlet; +pub use outlet::*; + +/// Types for defining the available routes. +pub mod routes { + mod atom; + pub use atom::*; + + mod content; + pub use content::*; + + mod matcher; + pub use matcher::*; + + mod route; + pub use route::*; + + mod segment; + pub use segment::*; + + mod parameter_route; + pub use parameter_route::*; +} + +mod service; +pub use service::*; + +mod state; +pub use state::*; + +mod utils { + mod name; + pub use name::*; + + mod route; + pub use route::*; + + mod sitemap; + pub use sitemap::*; + + mod target; + pub use target::*; +} + +/// A collection of useful types most applications might need. +pub mod prelude { + pub use crate::navigation::*; + pub use crate::routes::*; + + /// An external navigation failure. + /// + /// These occur when the router tries to navigate to a [`NavigationTarget::External`] and the + /// [`HistoryProvider`](crate::history::HistoryProvider) doesn't support that. + pub struct FailureExternalNavigation; + + /// A named navigation failure. + /// + /// These occur when the router tries to navigate to a [`NavigationTarget::Named`] and the + /// specified [`Name`](crate::Name) is unknown, or a parameter is missing. + pub struct FailureNamedNavigation; + + /// A redirection limit breach. + /// + /// These occur when the router tries to navigate to any target, but encounters 25 consecutive + /// redirects. + pub struct FailureRedirectionLimit; + + /// The [`Name`](crate::Name) equivalent of `/`. + pub struct RootIndex; +} diff --git a/packages/router-core/src/name/mod.rs b/packages/router-core/src/name/mod.rs new file mode 100644 index 000000000..a00a71ad9 --- /dev/null +++ b/packages/router-core/src/name/mod.rs @@ -0,0 +1,76 @@ +use std::{ + any::{type_name, TypeId}, + fmt::Display, + hash::Hash, +}; + +pub(crate) mod segments; + +/// A combination of a types [`TypeId`] and its name. +/// +/// This is used inside the router wherever a name is needed. This has the advantage that typos will +/// be caught by the compiler. +/// +/// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only for +/// brevity. It is recommend to use types with descriptive names, and create unit structs if needed, +/// like this. +/// +/// ```rust +/// # use dioxus_router_core::Name; +/// struct SomeName; +/// let name = Name::of::(); +/// ``` +#[derive(Clone, Debug)] +pub struct Name { + id: TypeId, + name: &'static str, +} + +impl Name { + /// Get the [`Name`] of `T`. + /// + /// ```rust + /// # use dioxus_router_core::Name; + /// struct SomeName; + /// let name = Name::of::(); + /// ``` + #[must_use] + pub fn of() -> Self { + Self { + id: TypeId::of::(), + name: type_name::(), + } + } +} + +impl PartialEq for Name { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for Name {} + +impl PartialOrd for Name { + fn partial_cmp(&self, other: &Self) -> Option { + self.id.partial_cmp(&other.id) + } +} + +impl Ord for Name { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.id.cmp(&other.id) + } +} + +impl Display for Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Hash for Name { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} diff --git a/packages/router-core/src/name/segments.rs b/packages/router-core/src/name/segments.rs new file mode 100644 index 000000000..6acdb1d2f --- /dev/null +++ b/packages/router-core/src/name/segments.rs @@ -0,0 +1,133 @@ +use std::collections::BTreeMap; + +use crate::{ + prelude::RootIndex, + routes::{ParameterRoute, Route, Segment}, + Name, +}; + +pub type NameMap = BTreeMap>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NamedSegment { + Fixed(String), + Parameter(Name), +} + +impl NamedSegment { + pub fn from_segment(segment: &Segment) -> NameMap { + let mut res = BTreeMap::new(); + res.insert(Name::of::(), vec![]); + Self::from_segment_inner(Vec::new(), segment, &mut res); + res + } + + fn from_segment_inner( + current: Vec, + segment: &Segment, + result: &mut NameMap, + ) { + for (p, r) in &segment.fixed { + Self::from_route(current.clone(), p, r, result); + } + + for (_, r) in &segment.matching { + Self::from_parameter_route(current.clone(), r, result); + } + + if let Some(r) = &segment.catch_all { + Self::from_parameter_route(current, r, result); + } + } + + fn from_route( + mut current: Vec, + path: &str, + route: &Route, + result: &mut NameMap, + ) { + current.push(Self::Fixed(path.to_string())); + + if let Some(n) = &route.name { + debug_assert!(!result.contains_key(n), "duplicate name: {n}"); + result.entry(n.clone()).or_insert_with(|| current.clone()); + } + + if let Some(n) = &route.nested { + Self::from_segment_inner(current, n, result); + } + } + + fn from_parameter_route( + mut current: Vec, + route: &ParameterRoute, + result: &mut NameMap, + ) { + current.push(Self::Parameter(route.key.clone())); + + if let Some(n) = &route.name { + debug_assert!(!result.contains_key(n), "duplicate name: {n}"); + result.entry(n.clone()).or_insert_with(|| current.clone()); + } + + if let Some(n) = &route.nested { + Self::from_segment_inner(current, n, result); + } + } +} + +#[cfg(test)] +mod tests { + use super::{NamedSegment::*, *}; + + #[test] + fn create_map() { + assert_eq!( + NamedSegment::from_segment( + &Segment::<&'static str>::empty() + .fixed( + "fixed", + Route::empty() + .name::() + .nested(Segment::empty().fixed("nested", Route::empty().name::())) + ) + .matching( + String::from(""), + ParameterRoute::empty::().name::().nested( + Segment::empty().matching( + String::from(""), + ParameterRoute::empty::().name::() + ) + ) + ) + .catch_all(ParameterRoute::empty::().name::().nested( + Segment::empty().catch_all(ParameterRoute::empty::().name::()) + )) + ), + { + let mut r = BTreeMap::new(); + r.insert(Name::of::(), vec![]); + + r.insert(Name::of::(), vec![Fixed(String::from("fixed"))]); + r.insert( + Name::of::(), + vec![Fixed(String::from("fixed")), Fixed(String::from("nested"))], + ); + + r.insert(Name::of::(), vec![Parameter(Name::of::())]); + r.insert( + Name::of::(), + vec![Parameter(Name::of::()), Parameter(Name::of::())], + ); + + r.insert(Name::of::(), vec![Parameter(Name::of::())]); + r.insert( + Name::of::(), + vec![Parameter(Name::of::()), Parameter(Name::of::())], + ); + + r + } + ) + } +} diff --git a/packages/router-core/src/navigation.rs b/packages/router-core/src/navigation.rs new file mode 100644 index 000000000..65559812f --- /dev/null +++ b/packages/router-core/src/navigation.rs @@ -0,0 +1,488 @@ +//! Types pertaining to navigation. + +use std::{collections::HashMap, str::FromStr}; + +use url::{ParseError, Url}; + +use crate::Name; + +/// A target for the router to navigate to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NavigationTarget { + /// An internal path that the router can navigate to by itself. + /// + /// ```rust + /// # use dioxus_router_core::navigation::NavigationTarget; + /// let explicit = NavigationTarget::Internal(String::from("/internal")); + /// let implicit: NavigationTarget = "/internal".into(); + /// assert_eq!(explicit, implicit); + /// ``` + Internal(String), + /// An internal target that the router can navigate to by itself. + /// + /// ```rust + /// # use std::collections::HashMap; + /// # use dioxus_router_core::{Name, navigation::{named, NavigationTarget}}; + /// let mut parameters = HashMap::new(); + /// parameters.insert(Name::of::(), String::from("some parameter")); + /// + /// let explicit = NavigationTarget::Named { + /// name: Name::of::(), + /// parameters, + /// query: Some("some=query".into()) + /// }; + /// + /// let implicit = named::().parameter::("some parameter").query("some=query"); + /// + /// assert_eq!(explicit, implicit); + /// ``` + /// + /// It will automatically find the route with the matching name, insert all required parameters + /// and add the query. + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + Named { + /// The name of the [`Route`](crate::routes::Route) or + /// [`ParameterRoute`](crate::routes::ParameterRoute) to navigate to. + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is + /// only for brevity. It is recommend to use types with descriptive names, and create unit + /// structs if needed. + name: Name, + /// The parameters required to get to the specified route. + parameters: HashMap, + /// A query to add to the route. + query: Option, + }, + /// An external target that the router doesn't control. + /// + /// ```rust + /// # use dioxus_router_core::navigation::NavigationTarget; + /// let explicit = NavigationTarget::External(String::from("https://dioxuslabs.com/")); + /// let implicit: NavigationTarget = "https://dioxuslabs.com/".into(); + /// assert_eq!(explicit, implicit); + /// ``` + External(String), +} + +impl NavigationTarget { + /// Create a new [`NavigationTarget::Named`] with `N` as the name. + /// + /// ```rust + /// # use dioxus_router_core::navigation::NavigationTarget; + /// let target = NavigationTarget::named::(); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + pub fn named() -> Self { + Self::Named { + name: Name::of::(), + parameters: HashMap::new(), + query: None, + } + } + + /// Add a parameter to a [`NavigationTarget::Named`]. + /// + /// ```rust + /// # use dioxus_router_core::navigation::NavigationTarget; + /// let target = NavigationTarget::named::().parameter::("some parameter"); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + /// + /// # Error Handling + /// 1. An error occurs if `self` is any other [`NavigationTarget`] variant than + /// [`NavigationTarget::Named`]. In _debug mode_ this will trigger a panic. In _release mode_ + /// nothing will happen. + /// 2. Parameters need to be unique within the [`NavigationTarget`]. In _debug mode_ the + /// second call with a duplicate name will panic. In _release mode_, all calls after the + /// first will be ignored. + pub fn parameter(mut self, value: impl Into) -> Self { + let n = Name::of::(); + + if let Self::Named { + name, + parameters, + query: _, + } = &mut self + { + debug_assert!( + !parameters.contains_key(&n), + "duplicate parameter: {name} - {n}", + ); + parameters.entry(n).or_insert_with(|| value.into()); + } else { + #[cfg(debug_assertions)] + panic!("parameter only available for named target: {n}"); + } + + self + } + + /// Add a parameter to a [`NavigationTarget::Named`]. + /// + /// ```rust + /// # use dioxus_router_core::navigation::NavigationTarget; + /// let target = NavigationTarget::named::().query("some=query"); + /// ``` + /// + /// # Error Handling + /// 1. An error occurs if `self` is any other [`NavigationTarget`] variant than + /// [`NavigationTarget::Named`]. In _debug mode_ this will trigger a panic. In _release mode_ + /// nothing will happen. + /// 2. This function may only be called once per [`NavigationTarget`]. In _debug mode_, the + /// second call will panic. In _release mode_, all calls after the first will be ignored. + pub fn query(mut self, query: impl Into) -> Self { + if let Self::Named { + name, + parameters: _, + query: q, + } = &mut self + { + debug_assert!(q.is_none(), "query cannot be changed: {name}",); + q.get_or_insert(query.into()); + } else { + #[cfg(debug_assertions)] + panic!("query only available for named target",); + } + + self + } +} + +/// Create a new [`NavigationTarget::Named`] with `N` as the name. +/// +/// ```rust +/// # use dioxus_router_core::navigation::named; +/// let target = named::(); +/// ``` +/// +/// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only +/// for brevity. It is recommend to use types with descriptive names, and create unit structs if +/// needed. +/// +/// This is a shortcut for [`NavigationTarget`]s `named` function. +pub fn named() -> NavigationTarget { + NavigationTarget::named::() +} + +impl FromStr for NavigationTarget { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match Url::parse(s) { + Ok(_) => Ok(Self::External(s.to_string())), + Err(ParseError::RelativeUrlWithoutBase) => Ok(Self::Internal(s.to_string())), + Err(e) => Err(e), + } + } +} + +impl> From for NavigationTarget { + fn from(v: T) -> Self { + let v = v.into(); + v.clone().parse().unwrap_or(Self::Internal(v)) + } +} + +/// A representation of a query string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Query { + /// A basic query string. + /// + /// ```rust + /// # use dioxus_router_core::navigation::Query; + /// let explicit = Query::Single(String::from("some=query")); + /// let implicit: Query = "some=query".into(); + /// assert_eq!(explicit, implicit); + /// ``` + Single(String), + /// A query separated into key-value-pairs. + /// + /// ```rust + /// # use dioxus_router_core::navigation::Query; + /// let explicit = Query::List(vec![(String::from("some"), String::from("query"))]); + /// let implicit: Query = vec![("some", "query")].into(); + /// assert_eq!(explicit, implicit); + /// ``` + #[cfg(feature = "serde")] + List(Vec<(String, String)>), +} + +impl Query { + /// Create a [`Query`] from a [`Serialize`](serde::Serialize)able object. + #[cfg(feature = "serde")] + pub fn from_serde(query: impl serde::Serialize) -> Result { + serde_urlencoded::to_string(query).map(|q| Self::Single(q)) + } +} + +impl From for Query { + fn from(v: String) -> Self { + Self::Single(v) + } +} + +impl From<&str> for Query { + fn from(v: &str) -> Self { + v.to_string().into() + } +} + +#[cfg(feature = "serde")] +impl> From> for Query { + fn from(v: Vec<(T, T)>) -> Self { + Self::List(v.into_iter().map(|(a, b)| (a.into(), b.into())).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_enum() { + assert_eq!( + NavigationTarget::named::(), + NavigationTarget::Named { + name: Name::of::(), + parameters: HashMap::new(), + query: None + } + ) + } + + #[test] + fn named_func() { + assert_eq!( + named::(), + NavigationTarget::Named { + name: Name::of::(), + parameters: HashMap::new(), + query: None + } + ) + } + + #[test] + fn parameter() { + assert_eq!( + named::() + .parameter::("integer") + .parameter::("unsigned"), + NavigationTarget::Named { + name: Name::of::(), + parameters: { + let mut r = HashMap::new(); + r.insert(Name::of::(), "integer".to_string()); + r.insert(Name::of::(), "unsigned".to_string()); + r + }, + query: None + } + ) + } + + #[test] + #[should_panic = "duplicate parameter: bool - i32"] + #[cfg(debug_assertions)] + fn parameter_duplicate_debug() { + named::() + .parameter::("integer") + .parameter::("duplicate"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn parameter_duplicate_release() { + assert_eq!( + named::() + .parameter::("integer") + .parameter::("duplicate"), + NavigationTarget::Named { + name: Name::of::(), + parameters: { + let mut r = HashMap::new(); + r.insert(Name::of::(), "integer".to_string()); + r + }, + query: None + } + ); + } + + #[test] + #[should_panic = "parameter only available for named target: i32"] + #[cfg(debug_assertions)] + fn parameter_internal_debug() { + NavigationTarget::from("/test").parameter::("integer"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn parameter_internal_release() { + assert_eq!( + NavigationTarget::from("/test").parameter::("integer"), + NavigationTarget::from("/test") + ); + } + + #[test] + #[should_panic = "parameter only available for named target: i32"] + #[cfg(debug_assertions)] + fn parameter_external_debug() { + NavigationTarget::from("https://dioxuslabs.com/").parameter::("integer"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn parameter_external_release() { + assert_eq!( + NavigationTarget::from("https://dioxuslabs.com/").parameter::("integer"), + NavigationTarget::from("https://dioxuslabs.com/") + ); + } + + #[test] + fn query() { + assert_eq!( + named::().query("test"), + NavigationTarget::Named { + name: Name::of::(), + parameters: HashMap::new(), + query: Some(Query::Single("test".to_string())) + } + ) + } + + #[test] + #[should_panic = "query cannot be changed: bool"] + #[cfg(debug_assertions)] + fn query_multiple_debug() { + named::().query("test").query("other"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn query_multiple_release() { + assert_eq!( + named::().query("test").query("other"), + NavigationTarget::Named { + name: Name::of::(), + parameters: HashMap::new(), + query: Some(Query::Single("test".to_string())) + } + ) + } + + #[test] + #[should_panic = "query only available for named target"] + #[cfg(debug_assertions)] + fn query_internal_debug() { + NavigationTarget::from("/test").query("test"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn query_internal_release() { + assert_eq!( + NavigationTarget::from("/test").query("test"), + NavigationTarget::from("/test") + ); + } + + #[test] + #[should_panic = "query only available for named target"] + #[cfg(debug_assertions)] + fn query_external_debug() { + NavigationTarget::from("https://dioxuslabs.com/").query("test"); + } + + #[test] + #[cfg(not(debug_assertions))] + fn query_external_release() { + assert_eq!( + NavigationTarget::from("https://dioxuslabs.com/").query("test"), + NavigationTarget::from("https://dioxuslabs.com/") + ); + } + + #[test] + fn target_parse_internal() { + assert_eq!( + "/test".parse::(), + Ok(NavigationTarget::Internal("/test".to_string())) + ); + } + + #[test] + fn target_parse_external() { + assert_eq!( + "https://dioxuslabs.com/".parse::(), + Ok(NavigationTarget::External( + "https://dioxuslabs.com/".to_string() + )) + ) + } + + #[test] + fn target_from_str_internal() { + assert_eq!( + NavigationTarget::from("/test"), + NavigationTarget::Internal("/test".to_string()) + ); + } + + #[test] + fn target_from_str_external() { + assert_eq!( + NavigationTarget::from("https://dioxuslabs.com/"), + NavigationTarget::External("https://dioxuslabs.com/".to_string()) + ) + } + + #[test] + fn target_from_string_internal() { + assert_eq!( + NavigationTarget::from("/test".to_string()), + NavigationTarget::Internal("/test".to_string()) + ); + } + + #[test] + fn target_from_string_external() { + assert_eq!( + NavigationTarget::from("https://dioxuslabs.com/".to_string()), + NavigationTarget::External("https://dioxuslabs.com/".to_string()) + ) + } + + #[test] + fn query_from_string() { + assert_eq!( + Query::from("test".to_string()), + Query::Single("test".to_string()) + ) + } + + #[test] + fn query_from_str() { + assert_eq!(Query::from("test"), Query::Single("test".to_string())); + } + + #[test] + #[cfg(feature = "serde")] + fn query_form_vec() { + assert_eq!( + Query::from(vec![("test", "1234")]), + Query::List(vec![("test".to_string(), "1234".to_string())]) + ) + } +} diff --git a/packages/router-core/src/outlet.rs b/packages/router-core/src/outlet.rs new file mode 100644 index 000000000..f2dc99e1c --- /dev/null +++ b/packages/router-core/src/outlet.rs @@ -0,0 +1,201 @@ +use std::collections::BTreeMap; + +use crate::Name; + +/// Information outlets can use to find out what to render. +/// +/// Outlets (which must be implemented by crates tying dioxus-router-core to UI crates) can use this +/// information to find out how deeply nested they are within other outlets, and communicate the +/// same to outlets nested inside them. +#[derive(Debug, Default, Clone)] +pub struct OutletData { + main: Option, + named: BTreeMap, +} + +impl OutletData { + /// Create some [`OutletData`] nested one level deeper and get the current depth. + /// + /// ```rust + /// # use dioxus_router_core::{Name, OutletData}; + /// let mut d = OutletData::default(); + /// let (m, a, n); + /// (m, d) = d.next(None); + /// (a, d) = d.next(None); + /// (n, d) = d.next(Some(Name::of::())); + /// + /// assert_eq!(m, 0); + /// assert_eq!(a, 1); + /// assert_eq!(n, 0); + /// ``` + pub fn next(&self, name: Option) -> (usize, Self) { + let mut next = self.clone(); + + let depth = next.depth(name.clone()).map(|d| d + 1).unwrap_or(0); + + next.set_depth(name, depth); + + (depth, next) + } + + /// Get the current depth for `name`. + /// + /// ```rust + /// # use dioxus_router_core::OutletData; + /// let mut d = OutletData::default(); + /// let b = d.depth(None); + /// d.set_depth(None, 18); + /// let a = d.depth(None); + /// + /// assert_eq!(b, None); + /// assert_eq!(a, Some(18)); + /// ``` + pub fn depth(&self, name: Option) -> Option { + match name { + None => self.main, + Some(n) => self.named.get(&n).copied(), + } + } + + /// Set the depth for `name`. + /// + /// ```rust + /// # use dioxus_router_core::OutletData; + /// let mut d = OutletData::default(); + /// let b = d.depth(None); + /// d.set_depth(None, 18); + /// let a = d.depth(None); + /// + /// assert_eq!(b, None); + /// assert_eq!(a, Some(18)); + /// ``` + pub fn set_depth(&mut self, name: Option, depth: usize) { + match name { + None => self.main = Some(depth), + Some(n) => _ = self.named.insert(n, depth), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_data() -> OutletData { + let mut named = BTreeMap::new(); + named.insert(Name::of::(), 0); + named.insert(Name::of::(), 8); + named.insert(Name::of::(), 16); + named.insert(Name::of::(), 32); + named.insert(Name::of::(), 64); + + OutletData { + main: Some(18), + named, + } + } + + #[test] + fn default() { + let d = OutletData::default(); + + assert!(d.main.is_none()); + assert!(d.named.is_empty()); + } + + #[test] + fn depth() { + let td = test_data(); + + assert_eq!(td.depth(None), Some(18)); + assert_eq!(td.depth(Some(Name::of::())), Some(0)); + + assert_eq!(td.depth(Some(Name::of::())), Some(8)); + assert_eq!(td.depth(Some(Name::of::())), Some(16)); + assert_eq!(td.depth(Some(Name::of::())), Some(32)); + assert_eq!(td.depth(Some(Name::of::())), Some(64)); + + assert_eq!(td.depth(Some(Name::of::())), None); + assert_eq!(td.depth(Some(Name::of::())), None); + assert_eq!(td.depth(Some(Name::of::())), None); + assert_eq!(td.depth(Some(Name::of::())), None); + } + + #[test] + fn set_depth() { + let mut td = test_data(); + + // set + td.set_depth(None, 0); + td.set_depth(Some(Name::of::()), 1); + + td.set_depth(Some(Name::of::()), 2); + td.set_depth(Some(Name::of::()), 4); + td.set_depth(Some(Name::of::()), 8); + td.set_depth(Some(Name::of::()), 16); + + td.set_depth(Some(Name::of::()), 32); + td.set_depth(Some(Name::of::()), 64); + td.set_depth(Some(Name::of::()), 128); + td.set_depth(Some(Name::of::()), 256); + + // check + assert_eq!(td.depth(None), Some(0)); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 1); + + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 2); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 4); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 8); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 16); + + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 32); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 64); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 128); + assert_eq!(*td.named.get(&Name::of::()).unwrap(), 256); + } + + #[test] + fn next() { + let td = test_data(); + + let (current, next) = td.next(None); + assert_eq!(current, 19); + assert_eq!(next.depth(None), Some(19)); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 1); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 1); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 9); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 9); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 17); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 17); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 33); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 33); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 65); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 65); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 0); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 0); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 0); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 0); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 0); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 0); + + let (current, next) = td.next(Some(Name::of::())); + assert_eq!(current, 0); + assert_eq!(*next.named.get(&Name::of::()).unwrap(), 0); + } +} diff --git a/packages/router-core/src/routes/atom.rs b/packages/router-core/src/routes/atom.rs new file mode 100644 index 000000000..20ddd5f1e --- /dev/null +++ b/packages/router-core/src/routes/atom.rs @@ -0,0 +1,29 @@ +use std::fmt::Debug; + +/// The basic content type of dioxus-router-core. +/// +/// For actual route definitions this type is basically useless. However, it allows the router to +/// support convenience [`From`] implementations which can tell content and redirects apart. +/// +/// ```rust +/// # use dioxus_router_core::routes::ContentAtom; +/// let content = ContentAtom("some content"); +/// ``` +#[derive(Clone)] +pub struct ContentAtom(pub T) +where + T: Clone; + +impl Debug for ContentAtom { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("ContentAtom").field(&self.0).finish() + } +} + +impl PartialEq for ContentAtom { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for ContentAtom {} diff --git a/packages/router-core/src/routes/content.rs b/packages/router-core/src/routes/content.rs new file mode 100644 index 000000000..6082f5c32 --- /dev/null +++ b/packages/router-core/src/routes/content.rs @@ -0,0 +1,306 @@ +use std::{collections::BTreeMap, fmt::Debug}; + +use crate::{navigation::NavigationTarget, Name}; + +use super::ContentAtom; + +/// The content of a route. +#[derive(Clone)] +pub enum RouteContent { + /// Some actual content. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, RouteContent}; + /// let explicit = RouteContent::Content(ContentAtom("content")); + /// let implicit: RouteContent<_> = ContentAtom("content").into(); + /// assert_eq!(explicit, implicit); + /// ``` + Content(ContentAtom), + /// A redirect to another location. + /// + /// ```rust + /// # use dioxus_router_core::routes::RouteContent; + /// let explicit = RouteContent::<&'static str>::Redirect("/some_path".into()); + /// let implicit: RouteContent<&'static str> = "/some_path".into(); + /// assert_eq!(explicit, implicit); + /// ``` + Redirect(NavigationTarget), + /// Multiple content. + /// + /// This may contain some main content, and named content. + /// + /// ```rust + /// # use std::collections::BTreeMap; + /// # use dioxus_router_core::{Name, routes::{ContentAtom, multi, RouteContent}}; + /// let explicit = RouteContent::MultiContent{ + /// main: Some(ContentAtom("main")), + /// named: { + /// let mut r = BTreeMap::new(); + /// r.insert(Name::of::(), ContentAtom("first")); + /// r.insert(Name::of::(), ContentAtom("second")); + /// r + /// } + /// }; + /// let implicit = multi(Some(ContentAtom("main"))) + /// .add_named::(ContentAtom("first")) + /// .add_named::(ContentAtom("second")); + /// assert_eq!(explicit, implicit); + /// ``` + MultiContent { + /// The main content. + main: Option>, + /// Named content. + named: BTreeMap>, + }, +} + +impl RouteContent { + /// Create a new [`RouteContent::MultiContent`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, multi, RouteContent}; + /// let content = multi(Some(ContentAtom("main"))) + /// .add_named::(ContentAtom("first")) + /// .add_named::(ContentAtom("second")); + /// ``` + pub fn multi(main: Option>) -> Self { + Self::MultiContent { + main, + named: BTreeMap::new(), + } + } + + /// Add some named content to a [`RouteContent::MultiContent`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, multi, RouteContent}; + /// let content = multi(Some(ContentAtom("main"))) + /// .add_named::(ContentAtom("first")) + /// .add_named::(ContentAtom("second")); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed, like this. + /// + /// # Error Handling + /// An error occurs if `self` is any other [`RouteContent`] variant then + /// [`RouteContent::MultiContent`]. In _debug mode_, this will trigger a panic. In _release + /// mode_ nothing will happen. + pub fn add_named(mut self, content: ContentAtom) -> Self { + debug_assert!( + matches!(self, Self::MultiContent { main: _, named: _ }), + "add_named only available for MultiContent" + ); + + if let Self::MultiContent { main: _, named } = &mut self { + let name = Name::of::(); + debug_assert!( + !named.contains_key(&name), + "name not unique within MultiContent: {name}" + ); + named.entry(name).or_insert(content); + } + + self + } +} + +/// Create a new [`RouteContent::MultiContent`]. +/// +/// ```rust +/// # use dioxus_router_core::routes::{ContentAtom, multi, RouteContent}; +/// let content = multi(Some(ContentAtom("main"))) +/// .add_named::(ContentAtom("first")) +/// .add_named::(ContentAtom("second")); +/// ``` +/// +/// This is a shortcut for [`RouteContent`]s `multi` method. +pub fn multi(main: Option>) -> RouteContent { + RouteContent::multi(main) +} + +#[cfg(test)] +pub(crate) fn test_content() -> RouteContent<&'static str> { + RouteContent::Content(ContentAtom("test content")) +} + +impl From> for RouteContent { + fn from(c: ContentAtom) -> Self { + Self::Content(c) + } +} + +impl> From for RouteContent { + fn from(nt: N) -> Self { + Self::Redirect(nt.into()) + } +} + +impl Debug for RouteContent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Content(_) => f.debug_tuple("Content").finish(), + Self::Redirect(nt) => f.debug_tuple("Target").field(nt).finish(), + Self::MultiContent { main: _, named } => { + f.debug_tuple("MultiContent").field(&named.keys()).finish() + } + } + } +} + +impl PartialEq for RouteContent { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Content(l0), Self::Content(r0)) => l0 == r0, + (Self::Redirect(l), Self::Redirect(r)) => l == r, + ( + Self::MultiContent { + main: lm, + named: ln, + }, + Self::MultiContent { + main: rm, + named: rn, + }, + ) => lm == rm && ln == rn, + _ => false, + } + } +} + +impl Eq for RouteContent {} + +#[cfg(test)] +mod tests { + use crate::{navigation::named, Name}; + + use super::*; + + #[test] + fn content_from_content() { + assert_eq!( + Into::>::into(ContentAtom("test content")), + test_content() + ) + } + + #[test] + fn content_from_target() { + assert_eq!( + Into::>::into(named::()), + RouteContent::<&str>::Redirect(NavigationTarget::Named { + name: Name::of::(), + parameters: Default::default(), + query: None + }) + ) + } + + #[test] + fn content_from_string() { + let internal = "/test"; + assert_eq!( + Into::>::into(internal.to_string()), + RouteContent::Redirect(internal.into()) + ); + + let external = "https://dioxuslabs.com/"; + assert_eq!( + Into::>::into(external.to_string()), + RouteContent::Redirect(external.into()) + ) + } + + #[test] + fn content_from_str() { + let internal = "/test"; + assert_eq!( + Into::>::into(internal), + RouteContent::Redirect(internal.into()) + ); + + let external = "https://dioxuslabs.com/"; + assert_eq!( + Into::>::into(external), + RouteContent::Redirect(external.into()) + ) + } + + #[test] + fn multi() { + let c = RouteContent::multi(Some(ContentAtom("test"))); + match c { + RouteContent::MultiContent { main, named } => { + assert_eq!(main, Some(ContentAtom("test"))); + assert!(named.is_empty()); + } + _ => panic!("wrong kind"), + }; + } + + #[test] + fn multi_add() { + let c = RouteContent::multi(None) + .add_named::(ContentAtom("1")) + .add_named::(ContentAtom("2")); + + match c { + RouteContent::MultiContent { main, named } => { + assert!(main.is_none()); + assert_eq!(named, { + let mut r = BTreeMap::new(); + r.insert(Name::of::(), ContentAtom("1")); + r.insert(Name::of::(), ContentAtom("2")); + r + }); + } + _ => panic!("wrong kind"), + }; + } + + #[test] + #[should_panic = "add_named only available for MultiContent"] + #[cfg(debug_assertions)] + fn multi_add_wrong_kind_debug() { + RouteContent::Content(ContentAtom("1")).add_named::(ContentAtom("2")); + } + + #[test] + #[cfg(not(debug_assertions))] + fn multi_add_wrong_kind_release() { + assert_eq!( + RouteContent::Content(ContentAtom("1")).add_named::(ContentAtom("2")), + RouteContent::Content(ContentAtom("1")) + ); + } + + #[test] + #[should_panic = "name not unique within MultiContent: u8"] + #[cfg(debug_assertions)] + fn multi_add_duplicate_debug() { + RouteContent::multi(None) + .add_named::(ContentAtom("1")) + .add_named::(ContentAtom("2")); + } + + #[test] + #[cfg(not(debug_assertions))] + fn multi_add_duplicate_release() { + let c = RouteContent::multi(None) + .add_named::(ContentAtom("1")) + .add_named::(ContentAtom("2")); + + match c { + RouteContent::MultiContent { main, named } => { + assert!(main.is_none()); + assert_eq!(named, { + let mut r = BTreeMap::new(); + r.insert(Name::of::(), ContentAtom("1")); + r + }); + } + _ => panic!("wrong kind"), + }; + } +} diff --git a/packages/router-core/src/routes/matcher.rs b/packages/router-core/src/routes/matcher.rs new file mode 100644 index 000000000..3ea695045 --- /dev/null +++ b/packages/router-core/src/routes/matcher.rs @@ -0,0 +1,25 @@ +/// Something that can check whether a string meets a condition. +/// +/// This is used by matching routes (see the [`Segment`](super::Segment) `matching` function for +/// more details) to see if they are active. +pub trait Matcher: std::fmt::Debug { + /// Check whether `segment_value` fulfills the [`Matcher`]s requirement. + fn matches(&self, segment_value: &str) -> bool; +} + +// The following implementation is for test purposes only. It could later be replaced by an +// implementation providing wildcard syntax or something similar. + +#[cfg(test)] +impl Matcher for String { + fn matches(&self, segment_value: &str) -> bool { + self == segment_value + } +} + +#[cfg(feature = "regex")] +impl Matcher for regex::Regex { + fn matches(&self, segment_value: &str) -> bool { + self.is_match(segment_value) + } +} diff --git a/packages/router-core/src/routes/parameter_route.rs b/packages/router-core/src/routes/parameter_route.rs new file mode 100644 index 000000000..cd0d9d5b7 --- /dev/null +++ b/packages/router-core/src/routes/parameter_route.rs @@ -0,0 +1,275 @@ +use super::{RouteContent, Segment}; +use crate::{ + prelude::{ + FailureExternalNavigation, FailureNamedNavigation, FailureRedirectionLimit, RootIndex, + }, + Name, +}; + +/// A parameter route. +#[derive(Debug)] +pub struct ParameterRoute { + pub(crate) content: Option>, + pub(crate) name: Option, + pub(crate) nested: Option>, + pub(crate) key: Name, +} + +impl ParameterRoute { + /// Create a new [`ParameterRoute`] with `N` as the key. + /// + /// ```rust + /// # use dioxus_router_core::routes::ParameterRoute; + /// let route: ParameterRoute<&'static str> = ParameterRoute::empty::(); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive keys, and create unit structs if + /// needed. + pub fn empty() -> Self { + Self { + content: None, + name: None, + nested: None, + key: Name::of::(), + } + } + + /// Create a new [`ParameterRoute`] with `N` as the key and some `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, ParameterRoute}; + /// let route = ParameterRoute::content::(ContentAtom("some content")); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + pub fn content(content: impl Into>) -> Self { + Self { + content: Some(content.into()), + name: None, + nested: None, + key: Name::of::(), + } + } + + /// Create a new [`ParameterRoute`] with `N` as the key and possibly some `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, ParameterRoute}; + /// let route = ParameterRoute::new::(Some(ContentAtom("some content"))); + /// ``` + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + pub fn new(content: Option>>) -> Self { + match content { + Some(c) => Self::content::(c), + None => Self::empty::(), + } + } + + /// Add a name to a [`ParameterRoute`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, ParameterRoute}; + /// ParameterRoute::content::(ContentAtom("some content")).name::(); + /// ``` + /// + /// Names must be unique within their top level [`Segment`]. [`RootIndex`], + /// [`FailureExternalNavigation`], [`FailureNamedNavigation`] and [`FailureRedirectionLimit`] + /// are reserved and **may not** be used. + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + /// + /// # Error Handling + /// 1. This function may only be called once per [`ParameterRoute`]. In _debug mode_, the second + /// call will panic. In _release mode_, all calls after the first will be ignored. + /// 2. If one of the forbidden names (see above) is used, this function will panic, even in + /// _release mode_. + pub fn name(mut self) -> Self { + let new = Name::of::(); + + debug_assert!( + self.name.is_none(), + "name cannot be changed: {} to {new}", + self.name.as_ref().unwrap(), + ); + assert_ne!(new, Name::of::(), "forbidden name: {new}"); + assert_ne!( + new, + Name::of::(), + "forbidden name: {new}" + ); + assert_ne!( + new, + Name::of::(), + "forbidden name: {new}" + ); + assert_ne!( + new, + Name::of::(), + "forbidden name: {new}" + ); + + self.name.get_or_insert(new); + self + } + + /// Add a nested [`Segment`] to the [`ParameterRoute`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, ParameterRoute, Segment}; + /// ParameterRoute::content::(ContentAtom("some content")).nested(Segment::empty()); + /// ``` + /// + /// # Error Handling + /// This function may only be called once per [`ParameterRoute`]. In _debug mode_, the second + /// call will panic. In _release mode_, all calls after the first will be ignored. + pub fn nested(mut self, nested: impl Into>) -> Self { + debug_assert!(self.nested.is_none(), "nested segment cannot be changed"); + self.nested.get_or_insert(nested.into()); + self + } +} + +impl>, N: 'static> From<(C, N)> for ParameterRoute { + fn from((c, _): (C, N)) -> Self { + Self::content::(c) + } +} + +#[cfg(test)] +mod tests { + use crate::routes::{test_content, ContentAtom}; + + use super::*; + + #[test] + fn empty() { + let p = ParameterRoute::<&str>::empty::(); + + assert!(p.content.is_none()); + assert!(p.name.is_none()); + assert!(p.nested.is_none()); + assert_eq!(p.key, Name::of::()); + } + + #[test] + fn content() { + let p = ParameterRoute::content::(test_content()); + + assert_eq!(p.content, Some(test_content())); + assert!(p.name.is_none()); + assert!(p.nested.is_none()); + assert_eq!(p.key, Name::of::()); + } + + #[test] + fn new_empty() { + let p = ParameterRoute::<&str>::new::(None::); + + assert!(p.content.is_none()); + assert!(p.name.is_none()); + assert!(p.nested.is_none()); + assert_eq!(p.key, Name::of::()); + } + + #[test] + fn new_content() { + let p = ParameterRoute::new::(Some(test_content())); + + assert_eq!(p.content, Some(test_content())); + assert!(p.name.is_none()); + assert!(p.nested.is_none()); + assert_eq!(p.key, Name::of::()); + } + + #[test] + fn name_initial() { + let route = ParameterRoute::<&str>::empty::().name::<&str>(); + + assert_eq!(route.name, Some(Name::of::<&str>())) + } + + #[test] + #[should_panic = "name cannot be changed: alloc::string::String to &str"] + #[cfg(debug_assertions)] + fn name_debug() { + ParameterRoute::<&str>::empty::() + .name::() + .name::<&str>(); + } + + #[test] + #[cfg(not(debug_assertions))] + fn name_release() { + let route = ParameterRoute::<&str>::empty::() + .name::() + .name::<&str>(); + + assert_eq!(route.name, Some(Name::of::())); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::RootIndex"] + fn name_root_index() { + ParameterRoute::<&str>::empty::<&str>().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureExternalNavigation"] + fn name_external_navigation() { + ParameterRoute::<&str>::empty::<&str>().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureNamedNavigation"] + fn name_named_navigation() { + ParameterRoute::<&str>::empty::<&str>().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureRedirectionLimit"] + fn name_redirection_limit() { + ParameterRoute::<&str>::empty::<&str>().name::(); + } + + #[test] + fn nested_initial() { + let route = ParameterRoute::empty::().nested(nested_segment()); + assert!(route.nested.is_some()); + + let n = route.nested.unwrap(); + assert_eq!(n.index, nested_segment().index); + } + + #[test] + #[should_panic = "nested segment cannot be changed"] + #[cfg(debug_assertions)] + fn nested_debug() { + ParameterRoute::empty::() + .nested(nested_segment()) + .nested(nested_segment()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn nested_release() { + let route = ParameterRoute::empty::() + .nested(nested_segment()) + .nested(Segment::empty()); + assert!(route.nested.is_some()); + + let n = route.nested.unwrap(); + assert_eq!(n.index, nested_segment().index); + } + + fn nested_segment() -> Segment<&'static str> { + Segment::content(RouteContent::Content(ContentAtom("nested"))) + } +} diff --git a/packages/router-core/src/routes/route.rs b/packages/router-core/src/routes/route.rs new file mode 100644 index 000000000..994dc1955 --- /dev/null +++ b/packages/router-core/src/routes/route.rs @@ -0,0 +1,247 @@ +use super::{RouteContent, Segment}; +use crate::{ + prelude::{ + FailureExternalNavigation, FailureNamedNavigation, FailureRedirectionLimit, RootIndex, + }, + Name, +}; + +/// A fixed route. +#[derive(Debug)] +pub struct Route { + pub(crate) content: Option>, + pub(crate) name: Option, + pub(crate) nested: Option>, +} + +impl Route { + /// Create a new [`Route`] without content. + /// + /// ```rust + /// # use dioxus_router_core::routes::Route; + /// let r: Route<&'static str> = Route::empty(); + /// ``` + #[must_use] + pub fn empty() -> Self { + Self { + content: None, + name: None, + nested: None, + } + } + + /// Create a new [`Route`] with some `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Route}; + /// let r = Route::content(ContentAtom("some content")); + /// ``` + #[must_use] + pub fn content(content: impl Into>) -> Self { + Self { + content: Some(content.into()), + name: None, + nested: None, + } + } + + /// Create a new [`Route`], possible with some `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Route}; + /// let r = Route::new(Some(ContentAtom("some content"))); + /// ``` + #[must_use] + pub fn new(content: Option>>) -> Self { + match content { + Some(c) => Self::content(c), + None => Self::empty(), + } + } + + /// Add a name to a [`Route`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Route}; + /// Route::content(ContentAtom("some content")).name::(); + /// ``` + /// + /// Names must be unique within their top level [`Segment`]. [`RootIndex`], + /// [`FailureExternalNavigation`], [`FailureNamedNavigation`] and [`FailureRedirectionLimit`] + /// are reserved and **may not** be used. + /// + /// **Note:** The dioxus-router-core documentation and tests mostly use standard Rust types. This is only + /// for brevity. It is recommend to use types with descriptive names, and create unit structs if + /// needed. + /// + /// # Error Handling + /// 1. This function may only be called once per [`Route`]. In _debug mode_, the second call + /// will panic. In _release mode_, all calls after the first will be ignored. + /// 2. If one of the forbidden names (see above) is used, this function will panic, even in + /// _release mode_. + pub fn name(mut self) -> Self { + let new = Name::of::(); + + debug_assert!( + self.name.is_none(), + "name cannot be changed: {} to {new}", + self.name.as_ref().unwrap(), + ); + if new == Name::of::() + || new == Name::of::() + || new == Name::of::() + || new == Name::of::() + { + panic!("forbidden name: {new}"); + } + + self.name.get_or_insert(new); + self + } + + /// Add a nested [`Segment`] to the [`Route`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Route, Segment}; + /// Route::content(ContentAtom("some content")).nested(Segment::empty()); + /// ``` + /// + /// # Error Handling + /// This function may only be called once per [`Route`]. In _debug mode_, the second call will + /// panic. In _release mode_, all calls after the first will be ignored. + pub fn nested(mut self, nested: impl Into>) -> Self { + debug_assert!(self.nested.is_none(), "nested segment cannot be changed"); + self.nested.get_or_insert(nested.into()); + self + } +} + +impl>> From for Route { + fn from(c: C) -> Self { + Self::content(c) + } +} + +#[cfg(test)] +mod tests { + use crate::routes::{test_content, ContentAtom}; + + use super::*; + + #[test] + fn empty() { + let route = Route::<&str>::empty(); + + assert!(route.content.is_none()); + assert!(route.name.is_none()); + assert!(route.nested.is_none()); + } + + #[test] + fn content() { + let route = Route::content(test_content()); + + assert_eq!(route.content, Some(test_content())); + assert!(route.name.is_none()); + assert!(route.nested.is_none()); + } + + #[test] + fn new_empty() { + let route = Route::<&str>::new(None::<&str>); + + assert!(route.content.is_none()); + assert!(route.name.is_none()); + assert!(route.nested.is_none()); + } + + #[test] + fn new_content() { + let route = Route::new(Some(test_content())); + + assert_eq!(route.content, Some(test_content())); + assert!(route.name.is_none()); + assert!(route.nested.is_none()); + } + + #[test] + fn name_initial() { + let route = Route::<&str>::empty().name::<&str>(); + + assert_eq!(route.name, Some(Name::of::<&str>())) + } + + #[test] + #[should_panic = "name cannot be changed: alloc::string::String to &str"] + #[cfg(debug_assertions)] + fn name_debug() { + Route::<&str>::empty().name::().name::<&str>(); + } + + #[test] + #[cfg(not(debug_assertions))] + fn name_release() { + let route = Route::<&str>::empty().name::().name::<&str>(); + + assert_eq!(route.name, Some(Name::of::())); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::RootIndex"] + fn name_root_index() { + Route::<&str>::empty().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureExternalNavigation"] + fn name_external_navigation() { + Route::<&str>::empty().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureNamedNavigation"] + fn name_named_navigation() { + Route::<&str>::empty().name::(); + } + + #[test] + #[should_panic = "forbidden name: dioxus_router_core::prelude::FailureRedirectionLimit"] + fn name_redirection_limit() { + Route::<&str>::empty().name::(); + } + + #[test] + fn nested_initial() { + let route = Route::empty().nested(nested_segment()); + assert!(route.nested.is_some()); + + let n = route.nested.unwrap(); + assert_eq!(n.index, nested_segment().index); + } + + #[test] + #[should_panic = "nested segment cannot be changed"] + #[cfg(debug_assertions)] + fn nested_debug() { + Route::empty() + .nested(nested_segment()) + .nested(nested_segment()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn nested_release() { + let route = Route::empty() + .nested(nested_segment()) + .nested(Segment::empty()); + + assert!(route.nested.is_some()); + + let n = route.nested.unwrap(); + assert_eq!(n.index, nested_segment().index); + } + + fn nested_segment() -> Segment<&'static str> { + Segment::content(RouteContent::Content(ContentAtom("nested"))) + } +} diff --git a/packages/router-core/src/routes/segment.rs b/packages/router-core/src/routes/segment.rs new file mode 100644 index 000000000..445ec07f8 --- /dev/null +++ b/packages/router-core/src/routes/segment.rs @@ -0,0 +1,457 @@ +use std::collections::BTreeMap; + +use crate::{ + utils::{gen_parameter_sitemap, gen_sitemap}, + Name, +}; + +use super::{Matcher, ParameterRoute, Route, RouteContent}; + +/// A segment, representing a segment of the URLs path part (i.e. the stuff between two slashes). +#[derive(Debug)] +pub struct Segment { + pub(crate) index: Option>, + + pub(crate) fallback: Option>, + pub(crate) clear_fallback: Option, + + pub(crate) fixed: BTreeMap>, + pub(crate) matching: Vec<(Box, ParameterRoute)>, + pub(crate) catch_all: Option>>, +} + +impl Segment { + /// Create a new [`Segment`] without index content. + /// + /// ```rust + /// # use dioxus_router_core::routes::Segment; + /// let seg: Segment<&'static str> = Segment::empty(); + /// ``` + pub fn empty() -> Self { + Default::default() + } + + /// Create a new [`Segment`] with some index `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// let seg = Segment::content(ContentAtom("some content")); + /// ``` + pub fn content(content: impl Into>) -> Self { + Self { + index: Some(content.into()), + ..Default::default() + } + } + + /// Create a new [`Segment`], possibly with some index `content`. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// let seg = Segment::new(Some(ContentAtom("some content"))); + /// ``` + pub fn new(content: Option>>) -> Self { + match content { + Some(content) => Self::content(content), + None => Self::empty(), + } + } + + /// Add fallback content to a [`Segment`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// Segment::content(ContentAtom("some content")).fallback(ContentAtom("fallback content")); + /// ``` + /// + /// The fallback content of the innermost matched [`Segment`] is used, if the router cannot find + /// a full matching route. + /// + /// # Error Handling + /// This function may only be called once per [`Segment`]. In _debug mode_ the second call will + /// panic. In _release mode_, all calls after the first will be ignored. + pub fn fallback(mut self, content: impl Into>) -> Self { + debug_assert!( + self.fallback.is_none(), + "fallback content cannot be changed" + ); + self.fallback.get_or_insert(content.into()); + + self + } + + /// Set whether to clear matched content when using the fallback. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// Segment::content(ContentAtom("some content")) + /// .fallback(ContentAtom("fallback content")) + /// .clear_fallback(true); + /// ``` + /// + /// When this is [`true`], the router will remove all content it previously found when falling + /// back to this [`Segment`]s fallback content. If not set, a [`Segment`] will inherit this + /// value from its parent segment. For the root [`Segment`], this defaults to [`false`]. + /// + /// # Error Handling + /// This function may only be called once per [`Segment`]. In _debug mode_ the second call will + /// panic. In _release mode_, all calls after the first will be ignored. + pub fn clear_fallback(mut self, clear: bool) -> Self { + debug_assert!( + self.clear_fallback.is_none(), + "fallback clearing cannot be changed" + ); + self.clear_fallback.get_or_insert(clear); + + self + } + + /// Add a fixed [`Route`] to the [`Segment`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// Segment::empty().fixed("path", ContentAtom("fixed route content")); + /// ``` + /// + /// A fixed route is active only when the corresponding URL segment is exactly the same as its + /// path. + /// + /// # Error Handling + /// An error occurs if multiple fixed routes on the same [`Segment`] have the same `path`. In + /// _debug mode_, the second call with panic. In _release mode_, the later routes will be + /// ignored and the initial preserved. + pub fn fixed(mut self, path: impl Into, content: impl Into>) -> Self { + let path = path.into(); + + debug_assert!( + !self.fixed.contains_key(&path), + "duplicate fixed route: {path}" + ); + self.fixed.entry(path).or_insert_with(|| content.into()); + + self + } + + /// Add a matching [`ParameterRoute`] to the [`Segment`]. + /// + /// ```rust,ignore + /// # use dioxus_router_core::routes::Segment; + /// Segment::empty().matching("some matcher", (true, ContentAtom("matching route content"))); + /// ``` + /// + /// A matching route is active only when the corresponding URL segment is accepted by its + /// [`Matcher`], and no previously added matching route is. + /// + /// The example above is not checked by the compiler. This is because dioxus-router-core doesn't ship any + /// [`Matcher`]s by default. However, you can implement your own, or turn on the `regex` feature + /// to enable a regex implementation. + pub fn matching( + mut self, + matcher: impl Matcher + 'static, + content: impl Into>, + ) -> Self { + self.matching.push((Box::new(matcher), content.into())); + self + } + + /// Add a catch all [`ParameterRoute`] to the [`Segment`]. + /// + /// ```rust + /// # use dioxus_router_core::routes::{ContentAtom, Segment}; + /// Segment::empty().catch_all((ContentAtom("catch all route content"), true)); + /// ``` + /// + /// A catch all route is active only if no fixed or matching route is. + /// + /// # Error Handling + /// This function may only be called once per [`Segment`]. In _debug mode_ the second call will + /// panic. In _release mode_, all calls after the first will be ignored. + pub fn catch_all(mut self, content: impl Into>) -> Self { + debug_assert!(self.catch_all.is_none(), "duplicate catch all route"); + self.catch_all.get_or_insert(Box::new(content.into())); + self + } + + /// Generate a site map. + /// + /// ```rust + /// # use std::collections::BTreeMap; + /// # use dioxus_router_core::{Name, routes::Segment}; + /// let seg = Segment::::empty().fixed("fixed", "").catch_all(("", true)); + /// let sitemap = seg.gen_sitemap(); + /// assert_eq!(sitemap, vec!["/", "/fixed", "/\\bool"]); + /// ``` + /// + /// This function returns a [`Vec`] containing all routes the [`Segment`] knows about, as a + /// path. Fixed routes are passed in as is, while matching and catch all routes are represented + /// by their key, marked with a leading `\`. Since the otherwise all paths should be valid in + /// URLs, and `\` is not, this doesn't cause a conflict. + pub fn gen_sitemap(&self) -> Vec { + let mut res = Vec::new(); + res.push(String::from("/")); + gen_sitemap(&self, "", &mut res); + res + } + + /// Generate a site map with parameters filled in. + /// + /// ```rust + /// # use std::collections::BTreeMap; + /// # use dioxus_router_core::{Name, routes::Segment}; + /// let seg = Segment::::empty().fixed("fixed", "").catch_all(("", true)); + /// let mut parameters = BTreeMap::new(); + /// parameters.insert(Name::of::(), vec![String::from("1"), String::from("2")]); + /// + /// let sitemap = seg.gen_parameter_sitemap(¶meters); + /// assert_eq!(sitemap, vec!["/", "/fixed", "/1", "/2"]); + /// ``` + /// + /// This function returns a [`Vec`] containing all routes the [`Segment`] knows about, as a + /// path. Fixed routes are passed in as is, while matching and catch all will be represented + /// with all `parameters` provided for their key. Matching routes will also filter out all + /// invalid parameters. + pub fn gen_parameter_sitemap(&self, parameters: &BTreeMap>) -> Vec { + let mut res = Vec::new(); + res.push(String::from("/")); + gen_parameter_sitemap(&self, parameters, "", &mut res); + res + } +} + +impl Default for Segment { + fn default() -> Self { + Self { + index: None, + fallback: None, + clear_fallback: None, + fixed: BTreeMap::new(), + matching: Vec::new(), + catch_all: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::routes::{content::test_content, ContentAtom}; + + use super::*; + + #[test] + fn default() { + let seg: Segment<&str> = Default::default(); + + assert!(seg.index.is_none()); + assert!(seg.fallback.is_none()); + assert!(seg.clear_fallback.is_none()); + assert!(seg.fixed.is_empty()); + assert!(seg.matching.is_empty()); + assert!(seg.catch_all.is_none()); + } + + #[test] + fn empty() { + let seg = Segment::<&str>::empty(); + + assert!(seg.index.is_none()); + assert!(seg.fallback.is_none()); + assert!(seg.clear_fallback.is_none()); + assert!(seg.fixed.is_empty()); + assert!(seg.matching.is_empty()); + assert!(seg.catch_all.is_none()); + } + + #[test] + fn content() { + let seg = Segment::content(test_content()); + + assert_eq!(seg.index, Some(test_content())); + assert!(seg.fallback.is_none()); + assert!(seg.clear_fallback.is_none()); + assert!(seg.fixed.is_empty()); + assert!(seg.matching.is_empty()); + assert!(seg.catch_all.is_none()); + } + + #[test] + fn new_empty() { + let seg = Segment::<&str>::new(None::); + + assert!(seg.index.is_none()); + assert!(seg.fallback.is_none()); + assert!(seg.clear_fallback.is_none()); + assert!(seg.fixed.is_empty()); + assert!(seg.matching.is_empty()); + assert!(seg.catch_all.is_none()); + } + + #[test] + fn new_content() { + let seg = Segment::new(Some(test_content())); + + assert_eq!(seg.index, Some(test_content())); + assert!(seg.fallback.is_none()); + assert!(seg.clear_fallback.is_none()); + assert!(seg.fixed.is_empty()); + assert!(seg.matching.is_empty()); + assert!(seg.catch_all.is_none()); + } + + #[test] + fn fallback_initial() { + let seg = Segment::empty().fallback(test_content()); + + assert_eq!(seg.fallback, Some(test_content())); + } + + #[test] + #[should_panic = "fallback content cannot be changed"] + #[cfg(debug_assertions)] + fn fallback_debug() { + Segment::empty() + .fallback(test_content()) + .fallback(test_content()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn fallback_release() { + let seg = Segment::empty() + .fallback(test_content()) + .fallback(RouteContent::Content(ContentAtom("invalid"))); + + assert_eq!(seg.fallback, Some(test_content())); + } + + #[test] + fn clear_fallback() { + let mut seg = Segment::<&str>::empty(); + assert!(seg.clear_fallback.is_none()); + + seg = seg.clear_fallback(true); + assert_eq!(seg.clear_fallback, Some(true)); + } + + #[test] + #[should_panic = "fallback clearing cannot be changed"] + #[cfg(debug_assertions)] + fn clear_fallback_debug() { + Segment::<&str>::empty() + .clear_fallback(true) + .clear_fallback(false); + } + + #[test] + #[cfg(not(debug_assertions))] + fn clear_fallback_release() { + let seg = Segment::<&str>::empty() + .clear_fallback(true) + .clear_fallback(false); + assert_eq!(seg.clear_fallback, Some(true)); + } + + #[test] + fn fixed() { + let test = RouteContent::Content(ContentAtom("test")); + let other = RouteContent::Content(ContentAtom("other")); + let seg = Segment::empty() + .fixed("test", Route::content(test.clone())) + .fixed("other", Route::content(other.clone())); + + assert_eq!(seg.fixed.len(), 2); + assert_eq!(seg.fixed["test"].content, Some(test)); + assert_eq!(seg.fixed["other"].content, Some(other)); + } + + #[test] + #[should_panic = "duplicate fixed route: test"] + #[cfg(debug_assertions)] + fn fixed_debug() { + Segment::empty() + .fixed( + "test", + Route::content(RouteContent::Content(ContentAtom("test"))), + ) + .fixed( + "test", + Route::content(RouteContent::Content(ContentAtom("other"))), + ); + } + + #[test] + #[cfg(not(debug_assertions))] + fn fixed_release() { + let test = RouteContent::Content(ContentAtom("test")); + let other = RouteContent::Content(ContentAtom("other")); + let seg = Segment::empty() + .fixed("test", Route::content(test.clone())) + .fixed("test", Route::content(other.clone())); + + assert_eq!(seg.fixed.len(), 1); + assert_eq!(seg.fixed["test"].content, Some(test)); + } + + #[test] + fn matching() { + let test = RouteContent::Content(ContentAtom("test")); + let other = RouteContent::Content(ContentAtom("other")); + let seg = Segment::empty() + .matching( + String::from("test"), + ParameterRoute::content::(test.clone()), + ) + .matching( + String::from("other"), + ParameterRoute::content::(other.clone()), + ); + + assert_eq!(seg.matching.len(), 2); + assert_eq!(seg.matching[0].1.content, Some(test)); + assert_eq!(seg.matching[1].1.content, Some(other)); + } + + #[test] + fn catch_all_initial() { + let seg = Segment::empty().catch_all(ParameterRoute::content::(test_content())); + + assert!(seg.catch_all.is_some()); + assert_eq!(seg.catch_all.unwrap().content, Some(test_content())); + } + + #[test] + #[should_panic = "duplicate catch all route"] + #[cfg(debug_assertions)] + fn catch_all_debug() { + Segment::empty() + .catch_all(ParameterRoute::content::(test_content())) + .catch_all(ParameterRoute::content::(test_content())); + } + + #[test] + #[cfg(not(debug_assertions))] + fn catch_all_release() { + let seg = Segment::empty() + .catch_all(ParameterRoute::content::(test_content())) + .catch_all(ParameterRoute::empty::()); + + assert!(seg.catch_all.is_some()); + assert_eq!(seg.catch_all.unwrap().content, Some(test_content())); + } + + // Check whether the returned sitemap includes "/". More elaborate tests are located alongside + // the internal `gen_sitemap` function. + #[test] + fn gen_sitemap() { + assert_eq!(Segment::<&'static str>::empty().gen_sitemap(), vec!["/"]); + } + + // Check whether the returned sitemap includes "/". More elaborate tests are located alongside + // the internal `gen_parameter_sitemap` function. + #[test] + fn gen_parameter_sitemap() { + assert_eq!( + Segment::<&'static str>::empty().gen_parameter_sitemap(&BTreeMap::new()), + vec!["/"] + ); + } +} diff --git a/packages/router-core/src/service.rs b/packages/router-core/src/service.rs new file mode 100644 index 000000000..765ff47bd --- /dev/null +++ b/packages/router-core/src/service.rs @@ -0,0 +1,1033 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::{Arc, Weak}, +}; + +use async_rwlock::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use either::Either; +use futures_channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use futures_util::StreamExt; + +use crate::{ + history::HistoryProvider, + navigation::NavigationTarget, + prelude::{ + FailureExternalNavigation, FailureNamedNavigation, FailureRedirectionLimit, RootIndex, + }, + routes::{ContentAtom, Segment}, + segments::{NameMap, NamedSegment}, + utils::{resolve_target, route_segment}, + Name, RouterState, +}; + +/// Messages that the [`RouterService`] can handle. +pub enum RouterMessage { + /// Subscribe to router update. + Subscribe(Arc), + /// Navigate to the specified target. + Push(NavigationTarget), + /// Replace the current location with the specified target. + Replace(NavigationTarget), + /// Trigger a routing update. + Update, + /// Navigate to the previous history entry. + GoBack, + /// Navigate to the next history entry. + GoForward, +} + +enum NavigationFailure { + External(String), + Named(Name), +} + +/// A function the router will call after every routing update. +pub type RoutingCallback = Arc) -> Option>; + +/// A collection of router data that manages all routing functionality. +pub struct RouterService +where + T: Clone, + I: Clone + PartialEq + Eq, +{ + history: Box, + routes: Segment, + names: Arc, + + receiver: UnboundedReceiver>, + state: Arc>>, + + subscribers: Vec>, + subscriber_update: Arc, + routing_callback: Option>, + + failure_external_navigation: ContentAtom, + failure_named_navigation: ContentAtom, + failure_redirection_limit: ContentAtom, +} + +impl RouterService +where + T: Clone, + I: Clone + PartialEq + Eq + Send + Sync + 'static, +{ + /// Create a new [`RouterService`]. + /// + /// # Parameters + /// 1. `routes`: The root [`Segment`] the router should handle. + /// 2. `history`: A [`HistoryProvider`] to handle the navigation history. + /// 3. `subscriber_callback`: A function the rooter can call to update UI integrations. + /// 4. `failure_external_navigation`: Content to be displayed when an external navigation fails. + /// 5. `failure_named_navigation`: Content to be displayed when a named navigation fails. + /// 6. `failure_redirection_limit`: Content to be displayed when the redirection limit is + /// breached. + /// + /// # Returns + /// 1. The [`RouterService`]. + /// 2. A [`Sender`] to send [`RouterMessage`]s to the [`RouterService`]. + /// 3. Access to the [`RouterState`]. **DO NOT WRITE TO THIS!!!** Seriously, **READ ONLY!!!** + #[allow(clippy::type_complexity)] + pub fn new( + routes: Segment, + mut history: Box, + subscriber_update: Arc, + routing_callback: Option>, + failure_external_navigation: ContentAtom, + failure_named_navigation: ContentAtom, + failure_redirection_limit: ContentAtom, + ) -> ( + Self, + UnboundedSender>, + Arc>>, + ) { + // index names + let names = Arc::new(NamedSegment::from_segment(&routes)); + + // create channel + let (sender, receiver) = unbounded(); + + // initialize history + let history_sender = sender.clone(); + history.updater(Arc::new(move || { + let _ = history_sender.unbounded_send(RouterMessage::Update); + })); + let state = Arc::new(RwLock::new(RouterState { + name_map: Arc::clone(&names), + ..Default::default() + })); + + ( + Self { + history, + names: names, + routes, + receiver, + state: Arc::clone(&state), + subscribers: Vec::new(), + subscriber_update, + routing_callback, + failure_external_navigation, + failure_named_navigation, + failure_redirection_limit, + }, + sender, + state, + ) + } + + /// Perform the initial routing. + /// + /// Call this once, as soon as possible after creating the [`RouterService`]. Do not call this, + /// if you are going to call the `run` function. + pub fn init(&mut self) { + *self.sync_state_write_lock() = self + .update_routing() + .map_right(|err| self.handle_navigation_failure(&self.sync_state_read_lock(), err)) + .either_into(); + } + + /// Handle all messages the router has received and then return. + /// + /// Call `init` before calling this function. + pub fn run_current(&mut self) { + let mut state = None; + while let Ok(Some(msg)) = self.receiver.try_next() { + let current = match self.handle_message(msg) { + (_, Some(err)) => Either::Right(err), + (true, _) => self.update_routing(), + _ => continue, + } + .left_and_then(|state| { + if let Some(cb) = &self.routing_callback { + if let Some(nt) = cb(&state) { + self.replace(nt); + return self.update_routing(); + } + } + + Either::Left(state) + }) + .map_right(|err| self.handle_navigation_failure(&self.sync_state_read_lock(), err)) + .either_into(); + state = Some(current); + } + + if let Some(state) = state { + *self.sync_state_write_lock() = state; + } + + self.update_subscribers(); + } + + fn sync_state_read_lock(&self) -> RwLockReadGuard> { + loop { + if let Some(s) = self.state.try_read() { + return s; + } + } + } + + fn sync_state_write_lock(&mut self) -> RwLockWriteGuard> { + loop { + if let Some(s) = self.state.try_write() { + return s; + } + } + } + + /// Handle all routing messages until ended from the outside. + pub async fn run(&mut self) { + // init (unlike function with same name this is async) + { + *self.state.write().await = match self.update_routing() { + Either::Left(state) => state, + Either::Right(err) => { + self.handle_navigation_failure(&*self.state.read().await, err) + } + }; + } + self.update_subscribers(); + + while let Some(msg) = self.receiver.next().await { + let state = match self.handle_message(msg) { + (_, Some(err)) => Either::Right(err), + (true, _) => self.update_routing(), + _ => continue, + } + .left_and_then(|state| { + if let Some(cb) = &self.routing_callback { + if let Some(nt) = cb(&state) { + self.replace(nt); + return self.update_routing(); + } + } + + Either::Left(state) + }); + + *self.state.write().await = match state { + Either::Left(state) => state, + Either::Right(err) => { + self.handle_navigation_failure(&*self.state.read().await, err) + } + }; + + self.update_subscribers(); + } + } + + fn handle_navigation_failure( + &self, + state: &RouterState, + err: NavigationFailure, + ) -> RouterState { + match err { + NavigationFailure::External(url) => RouterState { + can_go_back: state.can_go_back, + can_go_forward: state.can_go_forward, + path: state.path.clone(), + query: state.query.clone(), + prefix: state.prefix.clone(), + names: { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }, + parameters: { + let mut r = HashMap::new(); + r.insert(Name::of::(), url); + r + }, + name_map: Arc::clone(&state.name_map), + content: vec![self.failure_external_navigation.clone()], + named_content: BTreeMap::new(), + }, + NavigationFailure::Named(n) => RouterState { + can_go_back: state.can_go_back, + can_go_forward: state.can_go_forward, + path: state.path.clone(), + query: state.query.clone(), + prefix: state.prefix.clone(), + names: { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }, + parameters: { + let mut r = HashMap::new(); + r.insert(Name::of::(), n.to_string()); + r + }, + name_map: Arc::clone(&state.name_map), + content: vec![self.failure_named_navigation.clone()], + named_content: BTreeMap::new(), + }, + } + } + + #[must_use] + fn handle_message(&mut self, msg: RouterMessage) -> (bool, Option) { + let failure = match msg { + RouterMessage::Subscribe(id) => { + self.subscribe(id); + return (false, None); + } + RouterMessage::Push(nt) => self.push(nt), + RouterMessage::Replace(nt) => self.replace(nt), + RouterMessage::Update => None, + RouterMessage::GoBack => { + self.history.go_back(); + None + } + RouterMessage::GoForward => { + self.history.go_forward(); + None + } + }; + + (true, failure) + } + + #[must_use] + fn update_routing(&mut self) -> Either, NavigationFailure> { + for _ in 0..=25 { + match self.update_routing_inner() { + Either::Left(state) => return Either::Left(state), + Either::Right(nt) => { + if let Some(err) = self.replace(nt) { + return Either::Right(err); + } + } + } + } + + #[cfg(debug_assertions)] + panic!("reached redirect limit of 25"); + #[allow(unreachable_code)] + Either::Left(RouterState { + content: vec![self.failure_redirection_limit.clone()], + can_go_back: self.history.can_go_back(), + can_go_forward: self.history.can_go_forward(), + path: self.history.current_path(), + query: self.history.current_query(), + prefix: self.history.current_prefix(), + name_map: Arc::clone(&self.names), + names: { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }, + ..Default::default() + }) + } + + #[must_use] + fn update_routing_inner(&mut self) -> Either, NavigationTarget> { + // prepare path + let mut path = self.history.current_path(); + path.remove(0); + if path.ends_with('/') { + path.pop(); + } + + let values = match path.is_empty() { + false => path.split('/').collect::>(), + true => Vec::new(), + }; + + // add root index name + let mut names = HashSet::new(); + if values.is_empty() { + names.insert(Name::of::()); + }; + + route_segment( + &self.routes, + &values, + RouterState { + can_go_back: self.history.can_go_back(), + can_go_forward: self.history.can_go_forward(), + path: self.history.current_path(), + query: self.history.current_query(), + prefix: self.history.current_prefix(), + name_map: Arc::clone(&self.names), + names, + ..Default::default() + }, + ) + } + + fn push(&mut self, target: NavigationTarget) -> Option { + match resolve_target(&self.names, &target) { + Either::Left(Either::Left(p)) => self.history.push(p), + Either::Left(Either::Right(n)) => return Some(NavigationFailure::Named(n)), + Either::Right(e) => return self.external(e), + } + + None + } + + fn replace(&mut self, target: NavigationTarget) -> Option { + match resolve_target(&self.names, &target) { + Either::Left(Either::Left(p)) => self.history.replace(p), + Either::Left(Either::Right(n)) => return Some(NavigationFailure::Named(n)), + Either::Right(e) => return self.external(e), + } + + None + } + + fn external(&mut self, external: String) -> Option { + match self.history.external(external.clone()) { + true => None, + false => Some(NavigationFailure::External(external)), + } + } + + fn subscribe(&mut self, id: Arc) { + self.subscribers.push(Arc::downgrade(&id)); + (self.subscriber_update)(id.as_ref().clone()); + } + + fn update_subscribers(&mut self) { + let mut previous = Vec::new(); + self.subscribers.retain(|id| { + if let Some(id) = id.upgrade() { + if previous.contains(&id) { + false + } else { + (self.subscriber_update)(id.as_ref().clone()); + previous.push(id.clone()); + true + } + } else { + false + } + }); + } +} + +#[cfg(test)] +mod tests { + //! The tests for [`RouterService`] test various functions that are not exposed as public. + //! However, several of those have an observable effect on the behavior of exposed functions. + //! + //! The alternative would be to send messages via the services channel and calling one of the + //! `run` functions. However, for readability and clarity, it was chosen to directly call the + //! private functions. + + use std::sync::Mutex; + + use crate::{ + history::MemoryHistory, + routes::{ParameterRoute, Route, RouteContent}, + }; + + use super::*; + + fn test_segment() -> Segment<&'static str> { + Segment::content(RouteContent::Content(ContentAtom("index"))) + .fixed( + "fixed", + Route::content(RouteContent::Content(ContentAtom("fixed"))).name::(), + ) + .fixed( + "redirect", + Route::content(RouteContent::Redirect(NavigationTarget::Internal( + String::from("fixed"), + ))), + ) + .fixed( + "redirection-loop", + Route::content(RouteContent::Redirect(NavigationTarget::Internal( + String::from("/redirection-loop"), + ))), + ) + .fixed( + "%F0%9F%8E%BA", + Route::content(RouteContent::Content(ContentAtom("🎺"))), + ) + .catch_all(ParameterRoute::empty::()) + } + + #[test] + fn new_provides_update_to_history() { + struct TestHistory {} + + impl HistoryProvider for TestHistory { + fn current_path(&self) -> String { + todo!() + } + + fn current_query(&self) -> Option { + todo!() + } + + fn go_back(&mut self) { + todo!() + } + + fn go_forward(&mut self) { + todo!() + } + + fn push(&mut self, _path: String) { + todo!() + } + + fn replace(&mut self, _path: String) { + todo!() + } + + fn updater(&mut self, callback: Arc) { + callback(); + } + } + + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(TestHistory {}), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + + assert!(matches!( + s.receiver.try_next().unwrap().unwrap(), + RouterMessage::Update + )); + } + + #[test] + fn update_routing() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/fixed?test=value").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + assert_eq!(s.names, s.state.try_read().unwrap().name_map); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.query, Some(String::from("test=value"))); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + assert_eq!(s.names, state.name_map); + } + + #[test] + fn update_routing_root_index() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("index")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/")); + assert!(state.query.is_none()); + assert!(state.prefix.is_none()); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn update_routing_redirect() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/redirect").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + #[should_panic = "reached redirect limit of 25"] + #[cfg(debug_assertions)] + fn update_routing_redirect_debug() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/redirection-loop").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + } + + #[test] + #[cfg(not(debug_assertions))] + fn update_routing_redirect_release() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/redirection-loop").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("redirect limit")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/redirection-loop")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn update_subscribers() { + let ids = Arc::new(Mutex::new(Vec::new())); + let ids2 = Arc::clone(&ids); + + let (mut s, _, _) = RouterService::<_, u8>::new( + Segment::empty(), + Box::new(MemoryHistory::default()), + Arc::new(move |id| { + ids2.lock().unwrap().push(id); + }), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + + let id0 = Arc::new(0); + s.subscribe(Arc::clone(&id0)); + + let id1 = Arc::new(1); + s.subscribe(Arc::clone(&id1)); + + let id1 = Arc::try_unwrap(id1).unwrap(); + s.update_subscribers(); + + assert_eq!(s.subscribers.len(), 1); + assert_eq!(s.subscribers[0].upgrade().unwrap(), id0); + assert_eq!(*ids.lock().unwrap(), vec![*id0, id1, *id0]); + } + + #[test] + fn push_internal() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.push(NavigationTarget::Internal(String::from("/fixed"))); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, true); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn push_named() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.push(NavigationTarget::named::()); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, true); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn push_external() { + let (mut s, tx, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/fixed").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + tx.unbounded_send(RouterMessage::Push(NavigationTarget::External( + String::from("https://dioxuslabs.com/"), + ))) + .unwrap(); + s.run_current(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("external target")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert( + Name::of::(), + String::from("https://dioxuslabs.com/"), + ); + r + }); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn replace_named() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.replace(NavigationTarget::named::()); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn replace_internal() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.replace(NavigationTarget::Internal(String::from("/fixed"))); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn replace_external() { + let (mut s, tx, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/fixed").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + tx.unbounded_send(RouterMessage::Replace(NavigationTarget::External( + String::from("https://dioxuslabs.com/"), + ))) + .unwrap(); + s.run_current(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("external target")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert( + Name::of::(), + String::from("https://dioxuslabs.com/"), + ); + r + }); + assert_eq!(state.path, String::from("/fixed")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn subscribe() { + let (mut s, _, _) = RouterService::<_, u8>::new( + Segment::empty(), + Box::new(MemoryHistory::default()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + + let id = Arc::new(0); + s.subscribe(Arc::clone(&id)); + + assert_eq!(s.subscribers.len(), 1); + assert_eq!(s.subscribers[0].upgrade().unwrap(), id); + } + + #[test] + fn routing_callback() { + let paths = Arc::new(Mutex::new(Vec::new())); + let paths2 = Arc::clone(&paths); + + let (mut s, c, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/fixed").unwrap()), + Arc::new(|_| {}), + Some(Arc::new(move |state| { + paths2.lock().unwrap().push(state.path.clone()); + Some("/%F0%9F%8E%BA".into()) + })), + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + + s.init(); + assert!(paths.lock().unwrap().is_empty()); + + c.unbounded_send(RouterMessage::Update).unwrap(); + s.run_current(); + assert_eq!(*paths.lock().unwrap(), vec![String::from("/fixed")]); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("🎺")]) + } + + #[test] + fn url_decoding_do() { + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/%F0%9F%A5%B3").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + + let state = s.state.try_read().unwrap(); + assert!(state.content.is_empty()); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("🥳")); + r + }); + assert_eq!(state.path, String::from("/%F0%9F%A5%B3")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn url_decoding_do_not() { + let (mut s, c, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(MemoryHistory::with_initial_path("/%F0%9F%8E%BA").unwrap()), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + c.unbounded_send(RouterMessage::Update).unwrap(); + s.run_current(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("🎺")]); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/%F0%9F%8E%BA")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } + + #[test] + fn prefix() { + struct TestHistory {} + + impl HistoryProvider for TestHistory { + fn current_path(&self) -> String { + String::from("/") + } + + fn current_query(&self) -> Option { + None + } + + fn current_prefix(&self) -> Option { + Some(String::from("/prefix")) + } + + fn can_go_back(&self) -> bool { + false + } + + fn can_go_forward(&self) -> bool { + false + } + + fn go_back(&mut self) { + todo!() + } + + fn go_forward(&mut self) { + todo!() + } + + fn push(&mut self, _path: String) { + todo!() + } + + fn replace(&mut self, _path: String) { + todo!() + } + + fn updater(&mut self, callback: Arc) { + callback(); + } + } + + let (mut s, _, _) = RouterService::<_, u8>::new( + test_segment(), + Box::new(TestHistory {}), + Arc::new(|_| {}), + None, + ContentAtom("external target"), + ContentAtom("named target"), + ContentAtom("redirect limit"), + ); + s.init(); + + let state = s.state.try_read().unwrap(); + assert_eq!(state.content, vec![ContentAtom("index")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/")); + assert!(state.query.is_none()); + assert_eq!(state.prefix, Some(String::from("/prefix"))); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, false); + } +} diff --git a/packages/router-core/src/state.rs b/packages/router-core/src/state.rs new file mode 100644 index 000000000..67af9e829 --- /dev/null +++ b/packages/router-core/src/state.rs @@ -0,0 +1,288 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +use either::Either; + +use crate::{ + navigation::NavigationTarget, routes::ContentAtom, segments::NameMap, utils::resolve_target, + Name, +}; + +/// The current state of the router. +#[derive(Debug)] +pub struct RouterState { + /// Whether there is a previous page to navigate back to. + /// + /// Even if this is [`true`], there might not be a previous page. However, it is nonetheless + /// safe to tell the router to go back. + pub can_go_back: bool, + /// Whether there is a future page to navigate forward to. + /// + /// Even if this is [`true`], there might not be a future page. However, it is nonetheless safe + /// to tell the router to go forward. + pub can_go_forward: bool, + + /// The current path. + pub path: String, + /// The current query. + pub query: Option, + /// The current prefix. + pub prefix: Option, + + /// The names of currently active routes. + pub names: HashSet, + /// The current path parameters. + pub parameters: HashMap, + pub(crate) name_map: Arc, + + /// The current main content. + /// + /// This should only be used by UI integration crates, and not by applications. + pub content: Vec>, + /// The current named content. + /// + /// This should only be used by UI integration crates, and not by applications. + pub named_content: BTreeMap>>, +} + +impl RouterState { + /// Get a parameter. + /// + /// ```rust + /// # use dioxus_router_core::{RouterState, Name}; + /// let mut state = RouterState::<&'static str>::default(); + /// assert_eq!(state.parameter::(), None); + /// + /// // Do not do this! For illustrative purposes only! + /// state.parameters.insert(Name::of::(), String::from("some parameter")); + /// assert_eq!(state.parameter::(), Some("some parameter".to_string())); + /// ``` + pub fn parameter(&self) -> Option { + self.parameters.get(&Name::of::()).cloned() + } + + /// Get the `href` for the `target`. + pub fn href(&self, target: &NavigationTarget) -> String { + match resolve_target(&self.name_map, &target) { + Either::Left(Either::Left(i)) => match &self.prefix { + Some(p) => format!("{p}{i}"), + None => i, + }, + Either::Left(Either::Right(n)) => { + // the following assert currently cannot trigger, as resolve_target (or more + // precisely resolve_name, which is called by resolve_targe) will panic in debug + debug_assert!(false, "requested href for unknown name or parameter: {n}"); + String::new() + } + Either::Right(e) => e, + } + } + + /// Check whether the `target` is currently active. + /// + /// # Normal mode + /// 1. For internal targets wrapping an absolute path, the current path has to start with it. + /// 2. For internal targets wrapping a relative path, it has to match the last current segment + /// exactly. + /// 3. For named targets, the provided name needs to be active. + /// 4. For external targets [`false`]. + /// + /// # Exact mode + /// 1. For internal targets, the current path must match the wrapped path exactly. + /// 2. For named targets, the provided name needs to be active and all parameters need to match + /// exactly. + /// 3. For external targets [`false`]. + pub fn is_at(&self, target: &NavigationTarget, exact: bool) -> bool { + match target { + NavigationTarget::Internal(i) => { + if exact { + i == &self.path + } else if i.starts_with('/') { + self.path.starts_with(i) + } else if let Some((_, s)) = self.path.rsplit_once('/') { + s == i + } else { + false + } + } + NavigationTarget::Named { + name, + parameters, + query: _, + } => { + if !self.names.contains(name) { + false + } else if exact { + for (k, v) in parameters { + match self.parameters.get(k) { + Some(p) if p != v => return false, + None => return false, + _ => {} + } + } + + true + } else { + true + } + } + NavigationTarget::External(_) => false, + } + } +} + +// manual impl required because derive macro requires default for T unnecessarily +impl Default for RouterState { + fn default() -> Self { + Self { + can_go_back: Default::default(), + can_go_forward: Default::default(), + path: Default::default(), + query: Default::default(), + prefix: Default::default(), + names: Default::default(), + parameters: Default::default(), + name_map: Default::default(), + content: Default::default(), + named_content: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{navigation::named, prelude::RootIndex, routes::Segment, segments::NamedSegment}; + + use super::*; + + #[test] + fn href_internal() { + let state = RouterState::<&str> { + prefix: Some(String::from("/prefix")), + ..Default::default() + }; + + assert_eq!(state.href(&"/test".into()), String::from("/prefix/test")) + } + + #[test] + fn href_named() { + let state = RouterState::<&str> { + name_map: Arc::new(NamedSegment::from_segment(&Segment::<&str>::empty())), + prefix: Some(String::from("/prefix")), + ..Default::default() + }; + + assert_eq!(state.href(&named::()), String::from("/prefix/")) + } + + #[test] + #[should_panic = "named navigation to unknown name: bool"] + #[cfg(debug_assertions)] + fn href_named_debug() { + let state = RouterState::<&str> { + name_map: Arc::new(NamedSegment::from_segment(&Segment::<&str>::empty())), + prefix: Some(String::from("/prefix")), + ..Default::default() + }; + + state.href(&named::()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn href_named_release() { + let state = RouterState::<&str> { + name_map: Arc::new(NamedSegment::from_segment(&Segment::<&str>::empty())), + prefix: Some(String::from("/prefix")), + ..Default::default() + }; + + assert_eq!(state.href(&named::()), String::new()) + } + + #[test] + fn href_external() { + let state = RouterState::<&str> { + prefix: Some(String::from("/prefix")), + ..Default::default() + }; + + assert_eq!( + state.href(&"https://dioxuslabs.com/".into()), + String::from("https://dioxuslabs.com/") + ) + } + + #[test] + fn is_at_internal_absolute() { + let state = test_state(); + + assert!(!state.is_at(&"/levels".into(), false)); + assert!(!state.is_at(&"/levels".into(), true)); + + assert!(state.is_at(&"/test".into(), false)); + assert!(!state.is_at(&"/test".into(), true)); + + assert!(state.is_at(&"/test/with/some/nested/levels".into(), false)); + assert!(state.is_at(&"/test/with/some/nested/levels".into(), true)); + } + + #[test] + fn is_at_internal_relative() { + let state = test_state(); + + assert!(state.is_at(&"levels".into(), false)); + assert!(!state.is_at(&"levels".into(), true)); + + assert!(!state.is_at(&"test".into(), false)); + assert!(!state.is_at(&"test".into(), true)); + + assert!(!state.is_at(&"test/with/some/nested/levels".into(), false)); + assert!(!state.is_at(&"test/with/some/nested/levels".into(), true)); + } + + #[test] + fn is_at_named() { + let state = test_state(); + + assert!(!state.is_at(&named::(), false)); + assert!(!state.is_at(&named::(), true)); + + assert!(state.is_at(&named::(), false)); + assert!(state.is_at(&named::(), true)); + + assert!(state.is_at(&named::().parameter::("test"), false)); + assert!(state.is_at(&named::().parameter::("test"), true)); + + assert!(state.is_at(&named::().parameter::("test"), false)); + assert!(!state.is_at(&named::().parameter::("test"), true)); + } + + #[test] + fn is_at_external() { + let state = test_state(); + + assert!(!state.is_at(&"https://dioxuslabs.com/".into(), false)); + assert!(!state.is_at(&"https://dioxuslabs.com/".into(), true)); + } + + fn test_state() -> RouterState<&'static str> { + RouterState { + path: String::from("/test/with/some/nested/levels"), + names: { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }, + parameters: { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("test")); + r + }, + ..Default::default() + } + } +} diff --git a/packages/router-core/src/utils/name.rs b/packages/router-core/src/utils/name.rs new file mode 100644 index 000000000..7d08d347a --- /dev/null +++ b/packages/router-core/src/utils/name.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; + +use urlencoding::encode; + +use crate::{ + segments::{NameMap, NamedSegment}, + Name, +}; + +pub fn resolve_name( + map: &NameMap, + name: &Name, + parameters: &HashMap, +) -> Option { + debug_assert!( + map.contains_key(&name), + "named navigation to unknown name: {name}" + ); + let target = map.get(&name)?; + + let mut res = String::new(); + for t in target { + res += "/"; + match t { + NamedSegment::Fixed(f) => res += f, + NamedSegment::Parameter(p) => { + debug_assert!( + parameters.contains_key(p), + "named navigation is missing parameter: target {name} parameter {p}" + ); + let val = parameters.get(p)?; + + res += &encode(val); + } + } + } + + if res.is_empty() { + res += "/"; + } + + Some(res) +} + +#[cfg(test)] +mod tests { + use crate::{ + prelude::RootIndex, + routes::{ParameterRoute, Route, Segment}, + }; + + use super::*; + + fn test_map() -> NameMap { + NamedSegment::from_segment( + &Segment::<&str>::empty() + .fixed( + "fixed", + Route::empty().name::().nested( + Segment::empty().catch_all(ParameterRoute::empty::().name::()), + ), + ) + .catch_all(ParameterRoute::empty::().name::()), + ) + } + + #[test] + fn root_index() { + assert_eq!( + resolve_name(&test_map(), &Name::of::(), &HashMap::new()), + Some(String::from("/")) + ) + } + + #[test] + fn fixed() { + assert_eq!( + resolve_name(&test_map(), &Name::of::(), &HashMap::new()), + Some(String::from("/fixed")) + ) + } + + #[test] + fn matching() { + assert_eq!( + resolve_name(&test_map(), &Name::of::(), &{ + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("test")); + r + }), + Some(String::from("/test")) + ); + } + + #[test] + fn nested() { + assert_eq!( + resolve_name(&test_map(), &Name::of::(), &{ + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("nested")); + r + }), + Some(String::from("/fixed/nested")) + ); + } + + #[test] + #[should_panic = "named navigation to unknown name: bool"] + #[cfg(debug_assertions)] + fn missing_name_debug() { + resolve_name(&test_map(), &Name::of::(), &HashMap::new()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn missing_name_release() { + assert!(resolve_name(&test_map(), &Name::of::(), &HashMap::new()).is_none()); + } + + #[test] + #[should_panic = "named navigation is missing parameter: target u32 parameter u16"] + #[cfg(debug_assertions)] + fn missing_parameter_debug() { + resolve_name(&test_map(), &Name::of::(), &HashMap::new()); + } + + #[test] + #[cfg(not(debug_assertions))] + fn missing_parameter_release() { + assert!(resolve_name(&test_map(), &Name::of::(), &HashMap::new()).is_none()); + } + + #[test] + fn url_encoding() { + assert_eq!( + resolve_name(&test_map(), &Name::of::(), &{ + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("🥳")); + r + }), + Some(String::from("/fixed/%F0%9F%A5%B3")) + ); + } +} diff --git a/packages/router-core/src/utils/route.rs b/packages/router-core/src/utils/route.rs new file mode 100644 index 000000000..635cfefdb --- /dev/null +++ b/packages/router-core/src/utils/route.rs @@ -0,0 +1,489 @@ +use either::Either; +use urlencoding::decode; + +use crate::{ + navigation::NavigationTarget, + routes::{ParameterRoute, Route, RouteContent, Segment}, + RouterState, +}; + +pub fn route_segment( + segment: &Segment, + values: &[&str], + state: RouterState, +) -> Either, NavigationTarget> { + route_segment_internal(segment, values, state, None, false) +} + +fn route_segment_internal( + segment: &Segment, + values: &[&str], + state: RouterState, + mut fallback: Option>, + mut clear_fallback: bool, +) -> Either, NavigationTarget> { + // fallback + if let Some(fb) = &segment.fallback { + fallback = Some(fb.clone()); + } + if let Some(clear) = &segment.clear_fallback { + clear_fallback = *clear; + } + + // index route + if values.is_empty() { + if let Some(c) = &segment.index { + return merge(state, c.clone()); + } + return Either::Left(state); + } + + // fixed route + if let Some(r) = segment.fixed.get(values[0]) { + return merge_route(values, r, state, fallback, clear_fallback); + } + + // matching routes + for (m, r) in &segment.matching { + if m.matches(values[0]) { + return merge_parameter_route(values, r, state, fallback, clear_fallback); + } + } + + // catchall + if let Some(c) = &segment.catch_all { + return merge_parameter_route(values, c.as_ref(), state, fallback, clear_fallback); + } + + merge_fallback(state, fallback, clear_fallback) +} + +fn merge( + mut state: RouterState, + content: RouteContent, +) -> Either, NavigationTarget> { + match content { + RouteContent::Content(c) => state.content.push(c), + RouteContent::Redirect(t) => return Either::Right(t), + RouteContent::MultiContent { main, named } => { + if let Some(main) = main { + state.content.push(main); + } + + for (name, content) in named { + state.named_content.entry(name).or_default().push(content); + } + } + } + Either::Left(state) +} + +fn merge_route( + values: &[&str], + route: &Route, + mut state: RouterState, + fallback: Option>, + clear_fallback: bool, +) -> Either, NavigationTarget> { + // merge content + if let Some(c) = &route.content { + match merge(state, c.clone()) { + Either::Left(s) => state = s, + Either::Right(t) => return Either::Right(t), + } + } + + if let Some(n) = &route.name { + state.names.insert(n.clone()); + } + + match (&route.nested, values.is_empty()) { + (Some(n), _) => route_segment_internal(n, &values[1..], state, fallback, clear_fallback), + (None, false) => merge_fallback(state, fallback, clear_fallback), + _ => Either::Left(state), + } +} + +fn merge_parameter_route( + values: &[&str], + route: &ParameterRoute, + mut state: RouterState, + fallback: Option>, + clear_fallback: bool, +) -> Either, NavigationTarget> { + // merge content + if let Some(c) = &route.content { + match merge(state, c.clone()) { + Either::Left(s) => state = s, + Either::Right(t) => return Either::Right(t), + } + } + + if let Some(n) = &route.name { + state.names.insert(n.clone()); + } + + state.parameters.insert( + route.key.clone(), + decode(values[0]).unwrap(/* string already is UTF-8 */).into_owned(), + ); + + match (&route.nested, values.is_empty()) { + (Some(n), _) => route_segment_internal(n, &values[1..], state, fallback, clear_fallback), + (None, false) => merge_fallback(state, fallback, clear_fallback), + _ => Either::Left(state), + } +} + +fn merge_fallback( + mut state: RouterState, + fallback: Option>, + clear_fallback: bool, +) -> Either, NavigationTarget> { + // fallback clearing + if clear_fallback { + state.content.clear(); + state.names.clear(); + state.parameters.clear(); + } + + // fallback content + match fallback { + Some(fallback) => merge(state, fallback), + None => Either::Left(state), + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap, HashSet}; + + use crate::{ + routes::{multi, ContentAtom}, + Name, + }; + + use super::*; + + fn test_segment() -> Segment<&'static str> { + Segment::content(ContentAtom("index")) + .fixed("fixed", Route::content(ContentAtom("fixed")).name::()) + .matching( + String::from("matching"), + ParameterRoute::content::(ContentAtom("matching")) + .nested(Segment::empty().fixed("nested", ContentAtom("matching nested"))), + ) + .catch_all( + ParameterRoute::content::(ContentAtom("catch all")) + .nested(Segment::empty().fixed("nested", ContentAtom("catch all nested"))), + ) + .fixed( + "nested", + Route::content(ContentAtom("nested")).name::().nested( + Segment::content(ContentAtom("nested index")) + .fixed("again", ContentAtom("nested again")), + ), + ) + .fixed("redirect", "/redirect") + .fixed( + "fallback", + Route::content(ContentAtom("fallback")).nested( + Segment::empty() + .fixed( + "keep", + Route::content(ContentAtom("keep route")).nested( + Segment::content(ContentAtom("keep index")) + .fallback(ContentAtom("keep")), + ), + ) + .fixed( + "clear", + Route::content(ContentAtom("clear route")).nested( + Segment::empty() + .fallback(ContentAtom("clear")) + .clear_fallback(true), + ), + ), + ), + ) + .fixed( + "no_fallback", + Route::content(ContentAtom("no fallback")).nested( + Segment::empty() + .fixed( + "keep", + Route::content(ContentAtom("keep route")) + .nested(Segment::empty().clear_fallback(false)), + ) + .fixed( + "clear", + Route::content(ContentAtom("clear route")) + .nested(Segment::empty().clear_fallback(true)), + ), + ), + ) + .fixed( + "named_content", + Route::content( + multi(None) + .add_named::(ContentAtom("1")) + .add_named::(ContentAtom("2")), + ) + .nested(Segment::content(multi(Some(ContentAtom("3"))))), + ) + } + + #[test] + fn route_index() { + let state = route_segment( + &test_segment(), + &[], + RouterState { + path: String::from("/"), + can_go_back: false, + can_go_forward: true, + ..Default::default() + }, + ); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("index")]); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + assert_eq!(state.path, String::from("/")); + assert_eq!(state.can_go_back, false); + assert_eq!(state.can_go_forward, true); + } + + #[test] + fn route_fixed() { + let state = route_segment(&test_segment(), &["fixed"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("fixed")]); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + } + + #[test] + fn route_matching() { + let state = route_segment(&test_segment(), &["matching"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("matching")]); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("matching")); + r + }); + } + + #[test] + fn route_matching_nested() { + let state = route_segment(&test_segment(), &["matching", "nested"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ContentAtom("matching"), ContentAtom("matching nested")] + ); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("matching")); + r + }); + } + + #[test] + fn route_catch_all() { + let state = route_segment(&test_segment(), &["invalid"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("catch all")]); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("invalid")); + r + }); + } + + #[test] + fn route_catch_all_nested() { + let state = route_segment(&test_segment(), &["invalid", "nested"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ContentAtom("catch all"), ContentAtom("catch all nested")] + ); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), String::from("invalid")); + r + }); + } + + #[test] + fn route_nested_index() { + let state = route_segment(&test_segment(), &["nested"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ContentAtom("nested"), ContentAtom("nested index")] + ); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + } + + #[test] + fn route_nested_again() { + let state = route_segment(&test_segment(), &["nested", "again"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ContentAtom("nested"), ContentAtom("nested again")] + ); + assert_eq!(state.names, { + let mut r = HashSet::new(); + r.insert(Name::of::()); + r + }); + assert!(state.parameters.is_empty()); + } + + #[test] + fn route_redirect() { + let state = route_segment(&test_segment(), &["redirect"], Default::default()); + assert_eq!(state.unwrap_right(), "/redirect".into()); + } + + #[test] + fn route_fallback_keep() { + let state = route_segment( + &test_segment(), + &["fallback", "keep", "invalid"], + Default::default(), + ); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ + ContentAtom("fallback"), + ContentAtom("keep route"), + ContentAtom("keep") + ] + ); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + } + + #[test] + fn route_fallback_clear() { + let state = route_segment( + &test_segment(), + &["fallback", "clear", "invalid"], + Default::default(), + ); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("clear")]); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + } + + #[test] + fn route_named_content() { + let state = route_segment(&test_segment(), &["named_content"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("3")]); + assert_eq!(state.named_content, { + let mut r = BTreeMap::new(); + r.insert(Name::of::(), vec![ContentAtom("1")]); + r.insert(Name::of::(), vec![ContentAtom("2")]); + r + }); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + } + + #[test] + #[ignore = "not yet implemented"] + fn no_fallback() { + let state = route_segment( + &test_segment(), + &["no_fallback", "keep", "invalid"], + Default::default(), + ); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!( + state.content, + vec![ + ContentAtom("fallback"), + ContentAtom("keep route"), + ContentAtom("keep") + ] + ); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + } + + #[test] + #[ignore = "not yet implemented"] + fn no_fallback_with_clearing() { + let state = route_segment( + &test_segment(), + &["fallback", "clear", "invalid"], + Default::default(), + ); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert!(state.content.is_empty()); + assert!(state.names.is_empty()); + assert!(state.parameters.is_empty()); + } + + #[test] + fn url_encoding() { + let state = route_segment(&test_segment(), &["%F0%9F%A5%B3"], Default::default()); + assert!(state.is_left()); + + let state = state.unwrap_left(); + assert_eq!(state.content, vec![ContentAtom("catch all")]); + assert!(state.names.is_empty()); + assert_eq!(state.parameters, { + let mut r = HashMap::new(); + r.insert(Name::of::(), "🥳".to_string()); + r + }); + } +} diff --git a/packages/router-core/src/utils/sitemap.rs b/packages/router-core/src/utils/sitemap.rs new file mode 100644 index 000000000..e2076ea50 --- /dev/null +++ b/packages/router-core/src/utils/sitemap.rs @@ -0,0 +1,147 @@ +use std::collections::BTreeMap; + +use urlencoding::encode; + +use crate::{routes::Segment, Name}; + +pub fn gen_sitemap(seg: &Segment, current: &str, map: &mut Vec) { + for (p, r) in &seg.fixed { + let current = format!("{current}/{p}"); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_sitemap(n, ¤t, map); + } + } + + for (_, r) in &seg.matching { + let current = format!("{current}/\\{}", r.key); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_sitemap(n, ¤t, map); + } + } + + if let Some(r) = &seg.catch_all { + let current = format!("{current}/\\{}", r.key); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_sitemap(n, ¤t, map) + } + } +} + +pub fn gen_parameter_sitemap( + seg: &Segment, + parameters: &BTreeMap>, + current: &str, + map: &mut Vec, +) { + for (p, r) in &seg.fixed { + let current = format!("{current}/{p}"); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_parameter_sitemap(n, parameters, ¤t, map); + } + } + + for (m, r) in &seg.matching { + if let Some(rp) = parameters.get(&r.key) { + for p in rp { + if m.matches(p) { + let current = format!("{current}/{}", encode(p).into_owned()); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_parameter_sitemap(n, parameters, ¤t, map); + } + } + } + } + } + + if let Some(r) = &seg.catch_all { + if let Some(rp) = parameters.get(&r.key) { + for p in rp { + let current = format!("{current}/{}", encode(p).into_owned()); + map.push(current.clone()); + if let Some(n) = &r.nested { + gen_parameter_sitemap(n, parameters, ¤t, map); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::routes::{ParameterRoute, Route}; + + use super::*; + + fn test_segment() -> Segment<&'static str> { + Segment::empty() + .fixed( + "fixed", + Route::empty().nested(Segment::empty().fixed("nested", Route::empty())), + ) + .matching( + String::from("m1"), + ParameterRoute::empty::().nested( + Segment::empty().matching(String::from("n2"), ParameterRoute::empty::()), + ), + ) + .matching(String::from("no match"), ParameterRoute::empty::()) + .matching(String::from("no parameter"), ParameterRoute::empty::()) + .catch_all( + ParameterRoute::empty::() + .nested(Segment::empty().catch_all(ParameterRoute::empty::())), + ) + } + + #[test] + fn sitemap() { + let mut result = Vec::new(); + result.push(String::from("/")); + gen_sitemap(&test_segment(), "", &mut result); + + assert_eq!( + result, + vec![ + "/", + "/fixed", + "/fixed/nested", + "/\\u8", + "/\\u8/\\u16", + "/\\u32", + "/\\u64", + "/\\u32", + "/\\u32/\\u16" + ] + ); + } + + #[test] + fn sitemap_with_parameters() { + let mut parameters = BTreeMap::new(); + parameters.insert(Name::of::(), vec!["m1".to_string(), "m2".to_string()]); + parameters.insert(Name::of::(), vec!["n1".to_string(), "n2".to_string()]); + parameters.insert(Name::of::(), vec!["catch all".to_string()]); + + let mut result = Vec::new(); + result.push(String::from("/")); + gen_parameter_sitemap(&test_segment(), ¶meters, "", &mut result); + + assert_eq!( + result, + vec![ + "/", + "/fixed", + "/fixed/nested", + "/m1", + "/m1/n2", + "/catch%20all", + "/catch%20all/n1", + "/catch%20all/n2" + ] + ); + } +} diff --git a/packages/router-core/src/utils/target.rs b/packages/router-core/src/utils/target.rs new file mode 100644 index 000000000..7c3ef2117 --- /dev/null +++ b/packages/router-core/src/utils/target.rs @@ -0,0 +1,120 @@ +use either::Either; + +use crate::{ + navigation::{NavigationTarget, Query}, + segments::NameMap, + Name, +}; + +use super::resolve_name; + +pub fn resolve_target( + names: &NameMap, + target: &NavigationTarget, +) -> Either, String> { + match target { + NavigationTarget::Internal(i) => Either::Left(Either::Left(i.clone())), + NavigationTarget::Named { + name, + parameters, + query, + } => Either::Left( + resolve_name(names, name, parameters) + .map(|mut p| { + if let Some(q) = query { + match q { + Query::Single(s) => { + if !s.starts_with('?') { + p += "?"; + } + p += &s; + } + #[cfg(feature = "serde")] + Query::List(l) => { + let res = serde_urlencoded::to_string(l); + // TODO: find a test case where this assertion is not met + debug_assert!(res.is_ok(), "cannot serialize query list: {l:?}"); + if let Ok(q) = res { + p += "?"; + p += &q; + } + } + } + } + + p + }) + .map(|p| Either::Left(p)) + .unwrap_or(Either::Right(name.clone())), + ), + NavigationTarget::External(e) => Either::Right(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use crate::{prelude::RootIndex, routes::Segment, segments::NamedSegment}; + + use super::*; + + #[test] + fn resolve_internal() { + let names = NamedSegment::from_segment(&Segment::<&str>::empty()); + assert_eq!( + resolve_target(&names, &NavigationTarget::Internal("/test".to_string())), + Either::Left(Either::Left(String::from("/test"))) + ); + } + + #[test] + fn resolve_named() { + let names = NamedSegment::from_segment(&Segment::<&str>::empty()); + assert_eq!( + resolve_target(&names, &NavigationTarget::named::()), + Either::Left(Either::Left(String::from("/"))) + ); + } + + #[test] + fn resolve_named_with_query_single() { + let names = NamedSegment::from_segment(&Segment::<&str>::empty()); + let without = resolve_target( + &names, + &NavigationTarget::named::().query("huhu"), + ); + let with = resolve_target( + &names, + &NavigationTarget::named::().query("?huhu"), + ); + let correct = Either::Left(Either::Left(String::from("/?huhu"))); + assert_eq!(with, correct); + assert_eq!(without, correct); + assert_eq!(with, without); + } + + #[test] + #[cfg(feature = "serde")] + fn resolve_named_with_query_list() { + let names = NamedSegment::from_segment(&Segment::<&str>::empty()); + assert_eq!( + resolve_target( + &names, + &NavigationTarget::named::() + .query(vec![("some", "test"), ("another", "value")]) + ), + Either::Left(Either::Left(String::from("/?some=test&another=value"))) + ); + } + + #[test] + fn resolve_external() { + let names = NamedSegment::from_segment(&Segment::<&str>::empty()); + assert_eq!( + resolve_target( + &names, + &NavigationTarget::External("https://dioxuslabs.com/".to_string()) + ), + Either::Right(String::from("https://dioxuslabs.com/")) + ); + } +}