diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..848dd20bef9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Reference: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners + +yew-router/ @hgzimmerman +yew-router-macro/ @hgzimmerman +yew-router-route-parser/ @hgzimmerman \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 2792bfe315c..bf1f03a0141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,15 @@ members = [ "yew-functional", "yew-macro", + # Router + "yew-router", + "yew-router-macro", + "yew-router-route-parser", + "yew-router/examples/guide", + "yew-router/examples/minimal", + "yew-router/examples/router_component", + "yew-router/examples/switch", + # Examples "examples/counter", "examples/crm", diff --git a/yew-router-macro/Cargo.toml b/yew-router-macro/Cargo.toml new file mode 100644 index 00000000000..1a09372f313 --- /dev/null +++ b/yew-router-macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "yew-router-macro" +version = "0.11.0" +authors = ["Henry Zimmerman "] +edition = "2018" +license = "MIT/Apache-2.0" +description = "Contains macros used with yew-router" +repository = "https://github.com/yewstack/yew_router" + +[lib] +proc-macro = true + +[dependencies] +syn = "1.0.2" +quote = "1.0.1" +yew-router-route-parser = { path = "../yew-router-route-parser" } +proc-macro2 = "1.0.1" + +[dev-dependencies] +yew-router = { path = "../yew-router" } # This should probably be removed, it makes the deploy process much more annoying. \ No newline at end of file diff --git a/yew-router-macro/src/lib.rs b/yew-router-macro/src/lib.rs new file mode 100644 index 00000000000..28b7e252da9 --- /dev/null +++ b/yew-router-macro/src/lib.rs @@ -0,0 +1,102 @@ +extern crate proc_macro; +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +mod switch; + +/// Implements the `Switch` trait based on attributes present on the struct or enum variants. +/// +/// If deriving an enum, each variant should have a `#[to = ""]` attribute, +/// and if deriving a struct, the struct itself should have a `#[to = ""]` attribute. +/// +/// Inside the `""` you should put your **route matcher string**. +/// At its simplest, the route matcher string will create your variant/struct if it exactly matches the browser's route. +/// If the route in the url bar is `http://yoursite.com/some/route` and your route matcher string +/// for an enum variant is `/some/route`, then that variant will be created when `switch()` is called with the route. +/// +/// But the route matcher has other capabilities. +/// If you want to capture data from the route matcher string, for example, extract an id or user name from the route, +/// you can use `{field_name}` to capture data from the route. +/// For example, `#[to = "/route/{id}"]` will capture the content after "/route/", +/// and if the associated variant is defined as `Route{id: usize}`, then the string that was captured will be +/// transformed into a `usize`. +/// If the conversion fails, then the match won't succeed and the next variant will be tried instead. +/// +/// There are also `{*:field_name}` and `{3:field_name}` types of capture sections that will capture +/// _everything_, and the next 3 path sections respectively. +/// `{1:field_name}` is the same as `{field_name}`. +/// +/// Tuple-structs and Tuple-enum-variants are also supported. +/// If you don't want to specify keys that don't correspond to any specific field, +/// `{}`, `{*}`, and `{4}` also denote valid capture sections when used on structs and variants without named fields. +/// In datastructures without field names, the captures will be assigned in order - left to right. +/// +/// # Note +/// It should be mentioned that the derived function for matching will try enum variants in order, +/// from top to bottom, and that the whole route doesn't need to be matched by the route +/// matcher string in order for the match to succeed. +/// What is meant by this is that `[to = "/"]` will match "/", but also "/anything/else", +/// because as soon as the "/" is satisfied, that is considered a match. +/// +/// This can be mitigated by specifying a `!` at the end of your route to inform the matcher that if +/// any characters are left after matching the route matcher string, the match should fail. +/// This means that `[to = "/!"]` will match "/" and _only_ "/". +/// +/// ----- +/// There are other attributes as well. +/// `#[rest]`, `#[rest="field_name"]` and `#[end]` attributes exist as well. +/// `#[rest]` and `#[rest="field_name"]` are equivalent to `{*}` and `{*:field_name}` respectively. +/// `#[end]` is equivalent to `!`. +/// The `#[rest]` attributes are good if you just want to delegate the whole matching of a variant to a specific +/// wrapped struct or enum that also implements `Switch`. +/// +/// ------ +/// # Example +/// ``` +/// use yew_router::Switch; +/// +/// #[derive(Switch, Clone)] +/// enum AppRoute { +/// #[to = "/some/simple/route"] +/// SomeSimpleRoute, +/// #[to = "/capture/{}"] +/// Capture(String), +/// #[to = "/named/capture/{name}"] +/// NamedCapture { name: String }, +/// #[to = "/convert/{id}"] +/// Convert { id: usize }, +/// #[rest] // shorthand for #[to="{*}"] +/// Inner(InnerRoute), +/// } +/// +/// #[derive(Switch, Clone)] +/// #[to = "/inner/route/{first}/{second}"] +/// struct InnerRoute { +/// first: String, +/// second: String, +/// } +/// ``` +/// Check out the examples directory in the repository to see some more usages of the routing syntax. +#[proc_macro_derive(Switch, attributes(to, rest, end))] +pub fn switch(tokens: TokenStream) -> TokenStream { + let input: DeriveInput = parse_macro_input!(tokens as DeriveInput); + + crate::switch::switch_impl(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +#[proc_macro_attribute] +pub fn to(_: TokenStream, _: TokenStream) -> TokenStream { + TokenStream::new() +} + +#[proc_macro_attribute] +pub fn rest(_: TokenStream, _: TokenStream) -> TokenStream { + TokenStream::new() +} + +#[proc_macro_attribute] +pub fn end(_: TokenStream, _: TokenStream) -> TokenStream { + TokenStream::new() +} diff --git a/yew-router-macro/src/switch.rs b/yew-router-macro/src/switch.rs new file mode 100644 index 00000000000..8af245b126d --- /dev/null +++ b/yew-router-macro/src/switch.rs @@ -0,0 +1,180 @@ +use crate::switch::shadow::{ShadowCaptureVariant, ShadowMatcherToken}; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{Data, DeriveInput, Fields, Ident, Variant}; + +mod attribute; +mod enum_impl; +mod shadow; +mod struct_impl; +mod switch_impl; + +use self::{attribute::AttrToken, switch_impl::SwitchImpl}; +use crate::switch::{enum_impl::EnumInner, struct_impl::StructInner}; +use yew_router_route_parser::FieldNamingScheme; + +/// Holds data that is required to derive Switch for a struct or a single enum variant. +pub struct SwitchItem { + pub matcher: Vec, + pub ident: Ident, + pub fields: Fields, +} + +pub fn switch_impl(input: DeriveInput) -> syn::Result { + let ident: Ident = input.ident; + let generics = input.generics; + + Ok(match input.data { + Data::Struct(ds) => { + let field_naming_scheme = match ds.fields { + Fields::Unnamed(_) => FieldNamingScheme::Unnamed, + Fields::Unit => FieldNamingScheme::Unit, + Fields::Named(_) => FieldNamingScheme::Named, + }; + let matcher = AttrToken::convert_attributes_to_tokens(input.attrs)? + .into_iter() + .enumerate() + .map(|(index, at)| at.into_shadow_matcher_tokens(index, field_naming_scheme)) + .flatten() + .collect::>(); + + let item = SwitchItem { + matcher, + ident: ident.clone(), // TODO make SwitchItem take references instead. + fields: ds.fields, + }; + + SwitchImpl { + target_ident: &ident, + generics: &generics, + inner: StructInner { + from_route_part: struct_impl::FromRoutePart(&item), + build_route_section: struct_impl::BuildRouteSection { + switch_item: &item, + item: &Ident::new("self", Span::call_site()), + }, + }, + } + .to_token_stream() + } + Data::Enum(de) => { + let switch_variants = de + .variants + .into_iter() + .map(|variant: Variant| { + let field_type = match variant.fields { + Fields::Unnamed(_) => yew_router_route_parser::FieldNamingScheme::Unnamed, + Fields::Unit => FieldNamingScheme::Unit, + Fields::Named(_) => yew_router_route_parser::FieldNamingScheme::Named, + }; + let matcher = AttrToken::convert_attributes_to_tokens(variant.attrs)? + .into_iter() + .enumerate() + .map(|(index, at)| at.into_shadow_matcher_tokens(index, field_type)) + .flatten() + .collect::>(); + Ok(SwitchItem { + matcher, + ident: variant.ident, + fields: variant.fields, + }) + }) + .collect::>>()?; + + SwitchImpl { + target_ident: &ident, + generics: &generics, + inner: EnumInner { + from_route_part: enum_impl::FromRoutePart { + switch_variants: &switch_variants, + enum_ident: &ident, + }, + build_route_section: enum_impl::BuildRouteSection { + switch_items: &switch_variants, + enum_ident: &ident, + match_item: &Ident::new("self", Span::call_site()), + }, + }, + } + .to_token_stream() + } + Data::Union(_du) => panic!("Deriving FromCaptures not supported for Unions."), + }) +} + +trait Flatten { + /// Because flatten is a nightly feature. I'm making a new variant of the function here for + /// stable use. The naming is changed to avoid this getting clobbered when object_flattening + /// 60258 is stabilized. + fn flatten_stable(self) -> Option; +} + +impl Flatten for Option> { + fn flatten_stable(self) -> Option { + match self { + None => None, + Some(v) => v, + } + } +} + +fn build_matcher_from_tokens(tokens: &[ShadowMatcherToken]) -> TokenStream { + quote! { + let settings = ::yew_router::matcher::MatcherSettings { + case_insensitive: true, + }; + let matcher = ::yew_router::matcher::RouteMatcher { + tokens: ::std::vec![#(#tokens),*], + settings + }; + } +} + +/// Enum indicating which sort of writer is needed. +pub(crate) enum FieldType { + Named, + Unnamed { index: usize }, + Unit, +} + +/// This assumes that the variant/struct has been destructured. +fn write_for_token(token: &ShadowMatcherToken, naming_scheme: FieldType) -> TokenStream { + match token { + ShadowMatcherToken::Exact(lit) => { + quote! { + write!(buf, "{}", #lit).unwrap(); + } + } + ShadowMatcherToken::Capture(capture) => match naming_scheme { + FieldType::Named | FieldType::Unit => match &capture { + ShadowCaptureVariant::Named(name) + | ShadowCaptureVariant::ManyNamed(name) + | ShadowCaptureVariant::NumberedNamed { name, .. } => { + let name = Ident::new(&name, Span::call_site()); + quote! { + state = state.or_else(|| #name.build_route_section(buf)); + } + } + ShadowCaptureVariant::Unnamed + | ShadowCaptureVariant::ManyUnnamed + | ShadowCaptureVariant::NumberedUnnamed { .. } => { + panic!("Unnamed matcher sections not allowed for named field types") + } + }, + FieldType::Unnamed { index } => { + let name = unnamed_field_index_item(index); + quote! { + state = state.or_else(|| #name.build_route_section(&mut buf)); + } + } + }, + ShadowMatcherToken::End => quote! {}, + } +} + +/// Creates an ident used for destructuring unnamed fields. +/// +/// There needs to be a unified way to "mangle" the unnamed fields so they can be destructured, +fn unnamed_field_index_item(index: usize) -> Ident { + Ident::new(&format!("__field_{}", index), Span::call_site()) +} diff --git a/yew-router-macro/src/switch/attribute.rs b/yew-router-macro/src/switch/attribute.rs new file mode 100644 index 00000000000..ecd46e228d7 --- /dev/null +++ b/yew-router-macro/src/switch/attribute.rs @@ -0,0 +1,91 @@ +use crate::switch::shadow::{ShadowCaptureVariant, ShadowMatcherToken}; +use syn::{spanned::Spanned, Attribute, Lit, Meta, MetaNameValue}; +use yew_router_route_parser::FieldNamingScheme; + +pub enum AttrToken { + To(String), + End, + Rest(Option), +} + +impl AttrToken { + pub fn convert_attributes_to_tokens(attributes: Vec) -> syn::Result> { + fn get_meta_name_value_str(mnv: &MetaNameValue) -> syn::Result { + match &mnv.lit { + Lit::Str(s) => Ok(s.value()), + lit => Err(syn::Error::new_spanned(lit, "expected a string literal")), + } + } + + attributes + .iter() + .filter_map(|attr: &Attribute| attr.parse_meta().ok()) + .filter_map(|meta: Meta| { + let meta_span = meta.span(); + match meta { + Meta::NameValue(mnv) => { + mnv.path + .get_ident() + .and_then(|ident| match ident.to_string().as_str() { + "to" => Some(get_meta_name_value_str(&mnv).map(AttrToken::To)), + "rest" => Some( + get_meta_name_value_str(&mnv).map(|s| AttrToken::Rest(Some(s))), + ), + _ => None, + }) + } + Meta::Path(path) => { + path.get_ident() + .and_then(|ident| match ident.to_string().as_str() { + "end" => Some(Ok(AttrToken::End)), + "rest" => Some(Ok(AttrToken::Rest(None))), + _ => None, + }) + } + Meta::List(list) => { + list.path + .get_ident() + .and_then(|ident| match ident.to_string().as_str() { + id @ "to" | id @ "rest" => Some(Err(syn::Error::new( + meta_span, + &format!( + "This syntax is not supported, did you mean `#[{} = ...]`?", + id + ), + ))), + _ => None, + }) + } + } + }) + .collect() + } + + /// The id is an unique identifier that allows otherwise unnamed captures to still be captured + /// with unique names. + pub fn into_shadow_matcher_tokens( + self, + id: usize, + field_naming_scheme: FieldNamingScheme, + ) -> Vec { + match self { + AttrToken::To(matcher_string) => { + yew_router_route_parser::parse_str_and_optimize_tokens( + &matcher_string, + field_naming_scheme, + ) + .expect("Invalid Matcher") // This is the point where users should see an error message if their matcher string has some syntax error. + .into_iter() + .map(crate::switch::shadow::ShadowMatcherToken::from) + .collect() + } + AttrToken::End => vec![ShadowMatcherToken::End], + AttrToken::Rest(Some(capture_name)) => vec![ShadowMatcherToken::Capture( + ShadowCaptureVariant::ManyNamed(capture_name), + )], + AttrToken::Rest(None) => vec![ShadowMatcherToken::Capture( + ShadowCaptureVariant::ManyNamed(id.to_string()), + )], + } + } +} diff --git a/yew-router-macro/src/switch/enum_impl.rs b/yew-router-macro/src/switch/enum_impl.rs new file mode 100644 index 00000000000..5679bc876d6 --- /dev/null +++ b/yew-router-macro/src/switch/enum_impl.rs @@ -0,0 +1,25 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; + +pub use self::{build_route_section::BuildRouteSection, from_route_part::FromRoutePart}; + +mod build_route_section; +mod from_route_part; + +pub struct EnumInner<'a> { + pub from_route_part: FromRoutePart<'a>, + pub build_route_section: BuildRouteSection<'a>, +} + +impl<'a> ToTokens for EnumInner<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let EnumInner { + from_route_part, + build_route_section, + } = self; + tokens.extend(quote! { + #from_route_part + #build_route_section + }); + } +} diff --git a/yew-router-macro/src/switch/enum_impl/build_route_section.rs b/yew-router-macro/src/switch/enum_impl/build_route_section.rs new file mode 100644 index 00000000000..e617fde8bc5 --- /dev/null +++ b/yew-router-macro/src/switch/enum_impl/build_route_section.rs @@ -0,0 +1,98 @@ +use crate::switch::{ + shadow::ShadowMatcherToken, unnamed_field_index_item, write_for_token, FieldType, SwitchItem, +}; +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::Fields; + +pub struct BuildRouteSection<'a> { + pub switch_items: &'a [SwitchItem], + pub enum_ident: &'a Ident, + pub match_item: &'a Ident, +} + +impl<'a> ToTokens for BuildRouteSection<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let serializer = + build_serializer_for_enum(self.switch_items, self.enum_ident, self.match_item); + + tokens.extend(quote!{ + fn build_route_section<__T>(self, mut buf: &mut ::std::string::String) -> ::std::option::Option<__T> { + #serializer + } + }); + } +} + +/// The serializer makes up the body of `build_route_section`. +pub fn build_serializer_for_enum( + switch_items: &[SwitchItem], + enum_ident: &Ident, + match_item: &Ident, +) -> TokenStream { + let variants = switch_items.iter().map(|switch_item: &SwitchItem| { + let SwitchItem { + matcher, + ident, + fields, + } = switch_item; + match fields { + Fields::Named(fields_named) => { + let field_names = fields_named + .named + .iter() + .filter_map(|named| named.ident.as_ref()); + let writers = matcher + .iter() + .map(|token| write_for_token(token, FieldType::Named)); + quote! { + #enum_ident::#ident{#(#field_names),*} => { + #(#writers)* + } + } + } + Fields::Unnamed(fields_unnamed) => { + let field_names = fields_unnamed + .unnamed + .iter() + .enumerate() + .map(|(index, _)| unnamed_field_index_item(index)); + let mut item_count = 0; + let writers = matcher.iter().map(|token| { + if let ShadowMatcherToken::Capture(_) = &token { + let ts = write_for_token(token, FieldType::Unnamed { index: item_count }); + item_count += 1; + ts + } else { + // Its either a literal, or something that will panic currently + write_for_token(token, FieldType::Unit) + } + }); + quote! { + #enum_ident::#ident(#(#field_names),*) => { + #(#writers)* + } + } + } + Fields::Unit => { + let writers = matcher + .iter() + .map(|token| write_for_token(token, FieldType::Unit)); + quote! { + #enum_ident::#ident => { + #(#writers)* + } + } + } + } + }); + quote! { + use ::std::fmt::Write as __Write; + let mut state: Option<__T> = None; + match #match_item { + #(#variants)*, + } + + state + } +} diff --git a/yew-router-macro/src/switch/enum_impl/from_route_part.rs b/yew-router-macro/src/switch/enum_impl/from_route_part.rs new file mode 100644 index 00000000000..37961bda2f4 --- /dev/null +++ b/yew-router-macro/src/switch/enum_impl/from_route_part.rs @@ -0,0 +1,193 @@ +use crate::switch::SwitchItem; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{Field, Fields, Type}; + +pub struct FromRoutePart<'a> { + pub switch_variants: &'a [SwitchItem], + pub enum_ident: &'a Ident, +} + +impl<'a> ToTokens for FromRoutePart<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let variant_matchers = self.switch_variants.iter().map(|sv| { + let SwitchItem { + matcher, + ident, + fields, + } = sv; + let build_from_captures = build_variant_from_captures(&self.enum_ident, ident, fields); + let matcher = super::super::build_matcher_from_tokens(&matcher); + + quote! { + #matcher + #build_from_captures + } + }); + + tokens.extend(quote!{ + fn from_route_part<__T>(route: String, mut state: Option<__T>) -> (::std::option::Option, ::std::option::Option<__T>) { + let route_string = route; + #(#variant_matchers)* + + (::std::option::Option::None, state) + } + }); + } +} + +/// Once the 'captures' exists, attempt to populate the fields from the list of captures. +fn build_variant_from_captures( + enum_ident: &Ident, + variant_ident: &Ident, + fields: &Fields, +) -> TokenStream { + match fields { + Fields::Named(named_fields) => { + let (field_declarations, fields): (Vec<_>, Vec<_>) = named_fields + .named + .iter() + .filter_map(|field: &Field| { + let field_ty: &Type = &field.ty; + field.ident.as_ref().map(|i: &Ident| { + let key = i.to_string(); + (i, key, field_ty) + }) + }) + .map(|(field_name, key, field_ty): (&Ident, String, &Type)| { + let field_decl = quote! { + let #field_name = { + let (v, s) = match captures.remove(#key) { + ::std::option::Option::Some(value) => { + <#field_ty as ::yew_router::Switch>::from_route_part( + value, + state, + ) + } + ::std::option::Option::None => { + ( + <#field_ty as ::yew_router::Switch>::key_not_available(), + state, + ) + } + }; + match v { + ::std::option::Option::Some(val) => { + state = s; // Set state for the next var. + val + }, + ::std::option::Option::None => return (None, s) // Failed + } + }; + }; + + (field_decl, field_name) + }) + .unzip(); + + quote! { + let mut state = if let ::std::option::Option::Some(mut captures) = matcher + .capture_route_into_map(&route_string) + .ok() + .map(|x| x.1) + { + let create_item = || { + #(#field_declarations)* + + let val = ::std::option::Option::Some( + #enum_ident::#variant_ident { + #(#fields),* + } + ); + + (val, state) + }; + let (val, state) = create_item(); + + if val.is_some() { + return (val, state); + } + state + } else { + state + }; + } + } + Fields::Unnamed(unnamed_fields) => { + let (field_declarations, fields): (Vec<_>, Vec<_>) = unnamed_fields + .unnamed + .iter() + .enumerate() + .map(|(idx, f)| { + let field_ty = &f.ty; + let field_var_name = Ident::new(&format!("field_{}", idx), Span::call_site()); + let field_decl = quote! { + let #field_var_name = { + let (v, s) = match drain.next() { + ::std::option::Option::Some(value) => { + <#field_ty as ::yew_router::Switch>::from_route_part( + value, + state, + ) + }, + ::std::option::Option::None => { + ( + <#field_ty as ::yew_router::Switch>::key_not_available(), + state, + ) + } + }; + match v { + ::std::option::Option::Some(val) => { + state = s; // Set state for the next var. + val + }, + ::std::option::Option::None => return (None, s) // Failed + } + }; + }; + + (field_decl, field_var_name) + }) + .unzip(); + + quote! { + let mut state = if let ::std::option::Option::Some(mut captures) = matcher + .capture_route_into_vec(&route_string) + .ok() + .map(|x| x.1) + { + let mut drain = captures.drain(..); + let create_item = || { + #(#field_declarations)* + + ( + ::std::option::Option::Some( + #enum_ident::#variant_ident( + #(#fields),* + ) + ), + state + ) + }; + let (val, state) = create_item(); + if val.is_some() { + return (val, state); + } + state + } else { + state + }; + } + } + Fields::Unit => { + quote! { + let mut state = if let ::std::option::Option::Some(_captures) = matcher.capture_route_into_map(&route_string).ok().map(|x| x.1) { + return (::std::option::Option::Some(#enum_ident::#variant_ident), state); + } else { + state + }; + } + } + } +} diff --git a/yew-router-macro/src/switch/shadow.rs b/yew-router-macro/src/switch/shadow.rs new file mode 100644 index 00000000000..cc5742091ca --- /dev/null +++ b/yew-router-macro/src/switch/shadow.rs @@ -0,0 +1,101 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use yew_router_route_parser::{CaptureVariant, MatcherToken}; + +impl ToTokens for ShadowMatcherToken { + fn to_tokens(&self, ts: &mut TokenStream) { + use ShadowMatcherToken as SOT; + let t: TokenStream = match self { + SOT::Exact(s) => quote! { + ::yew_router::matcher::MatcherToken::Exact(#s.to_string()) + }, + SOT::Capture(variant) => quote! { + ::yew_router::matcher::MatcherToken::Capture(#variant) + }, + SOT::End => quote! { + ::yew_router::matcher::MatcherToken::End + }, + }; + ts.extend(t) + } +} + +/// A shadow of the OptimizedToken type. +/// It should match it exactly so that this macro can expand to the original. +pub enum ShadowMatcherToken { + Exact(String), + Capture(ShadowCaptureVariant), + End, +} + +pub enum ShadowCaptureVariant { + /// {} + Unnamed, + /// {*} + ManyUnnamed, + /// {5} + NumberedUnnamed { + /// Number of sections to match. + sections: usize, + }, + /// {name} - captures a section and adds it to the map with a given name + Named(String), + /// {*:name} - captures over many sections and adds it to the map with a given name. + ManyNamed(String), + /// {2:name} - captures a fixed number of sections with a given name. + NumberedNamed { sections: usize, name: String }, +} + +impl ToTokens for ShadowCaptureVariant { + fn to_tokens(&self, ts: &mut TokenStream) { + let t = match self { + ShadowCaptureVariant::Named(name) => { + quote! {::yew_router::matcher::CaptureVariant::Named(#name.to_string())} + } + ShadowCaptureVariant::ManyNamed(name) => { + quote! {::yew_router::matcher::CaptureVariant::ManyNamed(#name.to_string())} + } + ShadowCaptureVariant::NumberedNamed { sections, name } => { + quote! {::yew_router::matcher::CaptureVariant::NumberedNamed{sections: #sections, name: #name.to_string()}} + } + ShadowCaptureVariant::Unnamed => { + quote! {::yew_router::matcher::CaptureVariant::Unnamed} + } + ShadowCaptureVariant::ManyUnnamed => { + quote! {::yew_router::matcher::CaptureVariant::ManyUnnamed} + } + ShadowCaptureVariant::NumberedUnnamed { sections } => { + quote! {::yew_router::matcher::CaptureVariant::NumberedUnnamed{sections: #sections}} + } + }; + ts.extend(t) + } +} + +impl From for ShadowMatcherToken { + fn from(mt: MatcherToken) -> Self { + use MatcherToken as MT; + use ShadowMatcherToken as SOT; + match mt { + MT::Exact(s) => SOT::Exact(s), + MT::Capture(capture) => SOT::Capture(capture.into()), + MT::End => SOT::End, + } + } +} + +impl From for ShadowCaptureVariant { + fn from(cv: CaptureVariant) -> Self { + use ShadowCaptureVariant as SCV; + match cv { + CaptureVariant::Named(name) => SCV::Named(name), + CaptureVariant::ManyNamed(name) => SCV::ManyNamed(name), + CaptureVariant::NumberedNamed { sections, name } => { + SCV::NumberedNamed { sections, name } + } + CaptureVariant::Unnamed => SCV::Unnamed, + CaptureVariant::ManyUnnamed => SCV::ManyUnnamed, + CaptureVariant::NumberedUnnamed { sections } => SCV::NumberedUnnamed { sections }, + } + } +} diff --git a/yew-router-macro/src/switch/struct_impl.rs b/yew-router-macro/src/switch/struct_impl.rs new file mode 100644 index 00000000000..ca618f92be0 --- /dev/null +++ b/yew-router-macro/src/switch/struct_impl.rs @@ -0,0 +1,24 @@ +pub use self::{build_route_section::BuildRouteSection, from_route_part::FromRoutePart}; +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; + +mod build_route_section; +mod from_route_part; + +pub struct StructInner<'a> { + pub from_route_part: FromRoutePart<'a>, + pub build_route_section: BuildRouteSection<'a>, +} + +impl<'a> ToTokens for StructInner<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let StructInner { + from_route_part, + build_route_section, + } = self; + tokens.extend(quote! { + #from_route_part + #build_route_section + }) + } +} diff --git a/yew-router-macro/src/switch/struct_impl/build_route_section.rs b/yew-router-macro/src/switch/struct_impl/build_route_section.rs new file mode 100644 index 00000000000..cf215b547e2 --- /dev/null +++ b/yew-router-macro/src/switch/struct_impl/build_route_section.rs @@ -0,0 +1,82 @@ +use crate::switch::{ + shadow::ShadowMatcherToken, unnamed_field_index_item, write_for_token, FieldType, SwitchItem, +}; +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::Fields; + +pub struct BuildRouteSection<'a> { + pub switch_item: &'a SwitchItem, + pub item: &'a Ident, +} + +impl<'a> ToTokens for BuildRouteSection<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let serializer = build_serializer_for_struct(self.switch_item, self.item); + tokens.extend(quote! { + fn build_route_section<__T>(self, mut buf: &mut ::std::string::String) -> ::std::option::Option<__T> { + #serializer + } + }) + } +} + +pub fn build_serializer_for_struct(switch_item: &SwitchItem, item: &Ident) -> TokenStream { + let SwitchItem { + matcher, + ident, + fields, + } = switch_item; + let destructor_and_writers = match fields { + Fields::Named(fields_named) => { + let field_names = fields_named + .named + .iter() + .filter_map(|named| named.ident.as_ref()); + let writers = matcher + .iter() + .map(|token| write_for_token(token, FieldType::Named)); + quote! { + let #ident{#(#field_names),*} = #item; + #(#writers)* + } + } + Fields::Unnamed(fields_unnamed) => { + let field_names = fields_unnamed + .unnamed + .iter() + .enumerate() + .map(|(index, _)| unnamed_field_index_item(index)); + let mut item_count = 0; + let writers = matcher.iter().map(|token| { + if let ShadowMatcherToken::Capture(_) = &token { + let ts = write_for_token(token, FieldType::Unnamed { index: item_count }); + item_count += 1; + ts + } else { + // Its either a literal, or something that will panic currently + write_for_token(token, FieldType::Unit) + } + }); + quote! { + let #ident(#(#field_names),*) = #item; + #(#writers)* + } + } + Fields::Unit => { + let writers = matcher + .iter() + .map(|token| write_for_token(token, FieldType::Unit)); + quote! { + #(#writers)* + } + } + }; + quote! { + use ::std::fmt::Write as _; + let mut state: Option<__T> = None; + #destructor_and_writers + + state + } +} diff --git a/yew-router-macro/src/switch/struct_impl/from_route_part.rs b/yew-router-macro/src/switch/struct_impl/from_route_part.rs new file mode 100644 index 00000000000..badf1e1f78a --- /dev/null +++ b/yew-router-macro/src/switch/struct_impl/from_route_part.rs @@ -0,0 +1,162 @@ +// use crate::switch::{SwitchItem, write_for_token, FieldType, unnamed_field_index_item}; +use crate::switch::SwitchItem; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{Field, Fields, Type}; + +pub struct FromRoutePart<'a>(pub &'a SwitchItem); + +impl<'a> ToTokens for FromRoutePart<'a> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let SwitchItem { + matcher, + ident, + fields, + } = &self.0; + + let matcher = super::super::build_matcher_from_tokens(&matcher); + let build_from_captures = build_struct_from_captures(ident, fields); + + tokens.extend(quote! { + fn from_route_part<__T>( + route: String, mut state: Option<__T> + ) -> (::std::option::Option, ::std::option::Option<__T>) { + #matcher + let route_string = route; + + #build_from_captures + + (::std::option::Option::None, state) + } + }) + } +} + +fn build_struct_from_captures(ident: &Ident, fields: &Fields) -> TokenStream { + match fields { + Fields::Named(named_fields) => { + let (field_declarations, fields): (Vec<_>, Vec<_>) = named_fields + .named + .iter() + .filter_map(|field: &Field| { + let field_ty: &Type = &field.ty; + field.ident.as_ref().map(|i| { + let key = i.to_string(); + (i, key, field_ty) + }) + }) + .map(|(field_name, key, field_ty): (&Ident, String, &Type)| { + let field_decl = quote! { + let #field_name = { + let (v, s) = match captures.remove(#key) { + ::std::option::Option::Some(value) => { + <#field_ty as ::yew_router::Switch>::from_route_part( + value, + state, + ) + } + ::std::option::Option::None => { + ( + <#field_ty as ::yew_router::Switch>::key_not_available(), + state, + ) + } + }; + match v { + ::std::option::Option::Some(val) => { + state = s; // Set state for the next var. + val + }, + ::std::option::Option::None => return (::std::option::Option::None, s) // Failed + } + }; + }; + + (field_decl, field_name) + }) + .unzip(); + + quote! { + if let ::std::option::Option::Some(mut captures) = matcher + .capture_route_into_map(&route_string) + .ok() + .map(|x| x.1) + { + #(#field_declarations)* + + return ( + ::std::option::Option::Some( + #ident { + #(#fields),* + } + ), + state + ); + } + } + } + Fields::Unnamed(unnamed_fields) => { + let (field_declarations, fields): (Vec<_>, Vec<_>) = unnamed_fields + .unnamed + .iter() + .enumerate() + .map(|(idx, f)| { + let field_ty = &f.ty; + let field_var_name = Ident::new(&format!("field_{}", idx), Span::call_site()); + let field_decl = quote! { + let #field_var_name = { + let (v, s) = match drain.next() { + ::std::option::Option::Some(value) => { + <#field_ty as ::yew_router::Switch>::from_route_part( + value, + state, + ) + }, + ::std::option::Option::None => { + ( + <#field_ty as ::yew_router::Switch>::key_not_available(), + state, + ) + } + }; + match v { + ::std::option::Option::Some(val) => { + state = s; // Set state for the next var. + val + }, + ::std::option::Option::None => return (::std::option::Option::None, s) // Failed + } + }; + }; + + (field_decl, field_var_name) + }) + .unzip(); + + quote! { + if let Some(mut captures) = matcher.capture_route_into_vec(&route_string).ok().map(|x| x.1) { + let mut drain = captures.drain(..); + #(#field_declarations)* + + return ( + ::std::option::Option::Some( + #ident( + #(#fields),* + ) + ), + state + ); + }; + } + } + Fields::Unit => { + return quote! { + let mut state = if let ::std::option::Option::Some(_captures) = matcher.capture_route_into_map(&route_string).ok().map(|x| x.1) { + return (::std::option::Option::Some(#ident), state); + } else { + state + }; + } + } + } +} diff --git a/yew-router-macro/src/switch/switch_impl.rs b/yew-router-macro/src/switch/switch_impl.rs new file mode 100644 index 00000000000..b1e8236ee84 --- /dev/null +++ b/yew-router-macro/src/switch/switch_impl.rs @@ -0,0 +1,49 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{punctuated::Punctuated, GenericParam, Generics}; + +// Todo, consider removing the T here and replacing it with an enum. +/// Creates the "impl ::yew_router::Switch for TypeName where etc.." line. +/// +/// Then populates the body of the implementation with the specified `T`. +pub struct SwitchImpl<'a, T> { + pub target_ident: &'a Ident, + pub generics: &'a Generics, + pub inner: T, +} + +impl<'a, T: ToTokens> ToTokens for SwitchImpl<'a, T> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = self.target_ident; + let inner = &self.inner; + + let line_tokens = if self.generics.params.is_empty() { + quote! { + impl ::yew_router::Switch for #ident { + #inner + } + } + } else { + let params = &self.generics.params; + let param_idents = params + .iter() + .map(|p: &GenericParam| { + match p { + GenericParam::Type(ty) => ty.ident.clone(), +// GenericParam::Lifetime(lt) => lt.lifetime, // TODO different type here, must be handled by collecting into a new enum and defining how to convert _that_ to tokens. + _ => unimplemented!("Not all type parameter variants (lifetimes and consts) are supported in Switch") + } + }) + .collect::>(); + + let where_clause = &self.generics.where_clause; + quote! { + impl <#params> ::yew_router::Switch for #ident <#param_idents> #where_clause + { + #inner + } + } + }; + tokens.extend(line_tokens) + } +} diff --git a/yew-router-route-parser/Cargo.toml b/yew-router-route-parser/Cargo.toml new file mode 100644 index 00000000000..3af89d73677 --- /dev/null +++ b/yew-router-route-parser/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "yew-router-route-parser" +version = "0.11.0" +authors = ["Henry Zimmerman "] +edition = "2018" +license = "MIT/Apache-2.0" +description = "The parser for the routing syntax used with yew-router" +repository = "https://github.com/yewstack/yew_router" + +[dependencies] +nom = "5.0.0" diff --git a/yew-router-route-parser/src/core.rs b/yew-router-route-parser/src/core.rs new file mode 100644 index 00000000000..6911c5d8efa --- /dev/null +++ b/yew-router-route-parser/src/core.rs @@ -0,0 +1,368 @@ +use crate::{ + error::{ExpectedToken, ParserErrorReason}, + parser::{CaptureOrExact, RefCaptureVariant, RouteParserToken}, + ParseError, +}; +use nom::{ + branch::alt, + bytes::complete::{tag, take_till1}, + character::{ + complete::{char, digit1}, + is_digit, + }, + combinator::{map, map_parser}, + error::ErrorKind, + sequence::{delimited, separated_pair}, + IResult, +}; + +/// Indicates if the parser is working to create a matcher for a datastructure with named or unnamed fields. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Ord, PartialOrd)] +pub enum FieldNamingScheme { + /// For Thing { field: String } + Named, + /// for Thing(String) + Unnamed, + /// for Thing + Unit, +} + +pub fn get_slash(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(char('/'), |_: char| RouteParserToken::Separator)(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::Separator))) +} + +pub fn get_question(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(char('?'), |_: char| RouteParserToken::QueryBegin)(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::QueryBegin))) +} + +pub fn get_and(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(char('&'), |_: char| RouteParserToken::QuerySeparator)(i).map_err(|_: nom::Err<()>| { + nom::Err::Error(ParseError::expected(ExpectedToken::QuerySeparator)) + }) +} + +/// Returns a FragmentBegin variant if the next character is '\#'. +pub fn get_hash(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(char('#'), |_: char| RouteParserToken::FragmentBegin)(i).map_err(|_: nom::Err<()>| { + nom::Err::Error(ParseError::expected(ExpectedToken::FragmentBegin)) + }) +} + +/// Returns an End variant if the next character is a '!`. +pub fn get_end(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(char('!'), |_: char| RouteParserToken::End)(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::End))) +} + +/// Returns an End variant if the next character is a '!`. +fn get_open_bracket(i: &str) -> IResult<&str, (), ParseError> { + map(char('{'), |_: char| ())(i).map_err(|_: nom::Err<()>| { + nom::Err::Error(ParseError::expected(ExpectedToken::OpenBracket)) + }) +} + +fn get_close_bracket(i: &str) -> IResult<&str, (), ParseError> { + map(char('}'), |_: char| ())(i).map_err(|_: nom::Err<()>| { + nom::Err::Error(ParseError::expected(ExpectedToken::CloseBracket)) + }) +} + +fn get_eq(i: &str) -> IResult<&str, (), ParseError> { + map(char('='), |_: char| ())(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::Equals))) +} + +fn get_star(i: &str) -> IResult<&str, (), ParseError> { + map(char('*'), |_: char| ())(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::Star))) +} + +fn get_colon(i: &str) -> IResult<&str, (), ParseError> { + map(char(':'), |_: char| ())(i) + .map_err(|_: nom::Err<()>| nom::Err::Error(ParseError::expected(ExpectedToken::Colon))) +} + +fn rust_ident(i: &str) -> IResult<&str, &str, ParseError> { + let invalid_ident_chars = r##" \|/{[]()?+=-!@#$%^&*~`'";:"##; + // Detect an ident by first reading until a } is found, + // then validating the captured section against invalid characters that can't be in rust idents. + map_parser(take_till1(move |c| c == '}'), move |i: &str| { + match take_till1::<_, _, ()>(|c| invalid_ident_chars.contains(c))(i) { + Ok((remain, got)) => { + // Detects if the first character is a digit. + if !got.is_empty() && got.starts_with(|c: char| is_digit(c as u8)) { + Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::BadRustIdent(got.chars().next().unwrap())), + expected: vec![ExpectedToken::Ident], + offset: 1, + })) + } else if !remain.is_empty() { + Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::BadRustIdent( + remain.chars().next().unwrap(), + )), + expected: vec![ExpectedToken::CloseBracket, ExpectedToken::Ident], + offset: got.len() + 1, + })) + } else { + Ok((i, i)) + } + } + Err(_) => Ok((i, i)), + } + })(i) +} + +/// Matches escaped items +fn escaped_item_impl(i: &str) -> IResult<&str, &str> { + map(alt((tag("!!"), tag("{{"), tag("}}"))), |s| match s { + "!!" => "!", + "}}" => "}", + "{{" => "{", + _ => unreachable!(), + })(i) +} + +/// Matches "". +pub fn nothing(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + if i.is_empty() { + Ok((i, RouteParserToken::Nothing)) + } else { + Err(nom::Err::Error(ParseError { + reason: None, // This should never actually report an error. + expected: vec![], + offset: 0, + })) + } +} + +/// The provided string of special characters will be used to terminate this parser. +/// +/// Due to escaped character parser, the list of special characters MUST contain the characters: +/// "!{}" within it. +fn exact_impl(special_chars: &'static str) -> impl Fn(&str) -> IResult<&str, &str, ParseError> { + // Detect either an exact ident, or an escaped item. + // At higher levels, this can be called multiple times in a row, + // and that results of those multiple parse attempts will be stitched together into one literal. + move |i: &str| { + alt(( + take_till1(move |c| special_chars.contains(c)), + escaped_item_impl, + ))(i) + .map_err(|x: nom::Err<(&str, ErrorKind)>| { + let s = match x { + nom::Err::Error((s, _)) | nom::Err::Failure((s, _)) => s, + nom::Err::Incomplete(_) => panic!(), + }; + nom::Err::Error(ParseError { + reason: Some(ParserErrorReason::BadLiteral), + expected: vec![ExpectedToken::Literal], + offset: 1 + i.len() - s.len(), + }) + }) + } +} + +const SPECIAL_CHARS: &str = r##"/?&#={}!"##; +const FRAGMENT_SPECIAL_CHARS: &str = r##"{}!"##; + +pub fn exact(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(exact_impl(SPECIAL_CHARS), RouteParserToken::Exact)(i) +} + +/// More permissive exact matchers +pub fn fragment_exact(i: &str) -> IResult<&str, RouteParserToken, ParseError> { + map(exact_impl(FRAGMENT_SPECIAL_CHARS), RouteParserToken::Exact)(i) +} + +pub fn capture<'a>( + field_naming_scheme: FieldNamingScheme, +) -> impl Fn(&'a str) -> IResult<&'a str, RouteParserToken<'a>, ParseError> { + map(capture_impl(field_naming_scheme), RouteParserToken::Capture) +} + +fn capture_single_impl<'a>( + field_naming_scheme: FieldNamingScheme, +) -> impl Fn(&'a str) -> IResult<&'a str, RefCaptureVariant<'a>, ParseError> { + move |i: &str| match field_naming_scheme { + FieldNamingScheme::Named => delimited( + get_open_bracket, + named::single_capture_impl, + get_close_bracket, + )(i), + FieldNamingScheme::Unnamed => delimited( + get_open_bracket, + alt((named::single_capture_impl, unnamed::single_capture_impl)), + get_close_bracket, + )(i), + FieldNamingScheme::Unit => { + println!("Unit encountered, erroring in capture single"); + Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::CapturesInUnit), + expected: vec![], + offset: 0, + })) + } + } +} + +/// Captures {ident}, {*:ident}, {:ident} +/// +/// Depending on the provided field naming, it may also match {}, {*}, and {} for unnamed fields, or none at all for units. +fn capture_impl<'a>( + field_naming_scheme: FieldNamingScheme, +) -> impl Fn(&'a str) -> IResult<&'a str, RefCaptureVariant, ParseError> { + move |i: &str| match field_naming_scheme { + FieldNamingScheme::Named => { + let inner = alt(( + named::many_capture_impl, + named::numbered_capture_impl, + named::single_capture_impl, + )); + delimited(get_open_bracket, inner, get_close_bracket)(i) + } + FieldNamingScheme::Unnamed => { + let inner = alt(( + named::many_capture_impl, + unnamed::many_capture_impl, + named::numbered_capture_impl, + unnamed::numbered_capture_impl, + named::single_capture_impl, + unnamed::single_capture_impl, + )); + delimited(get_open_bracket, inner, get_close_bracket)(i) + } + FieldNamingScheme::Unit => Err(nom::Err::Error(ParseError { + reason: Some(ParserErrorReason::CapturesInUnit), + expected: vec![], + offset: 0, + })), + } +} + +mod named { + use super::*; + pub fn single_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + map(rust_ident, |key| RefCaptureVariant::Named(key))(i) + } + + pub fn many_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + map( + separated_pair(get_star, get_colon, rust_ident), + |(_, key)| RefCaptureVariant::ManyNamed(key), + )(i) + } + + pub fn numbered_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + map( + separated_pair(digit1, get_colon, rust_ident), + |(number, key)| RefCaptureVariant::NumberedNamed { + sections: number.parse().unwrap(), + name: key, + }, + )(i) + } +} + +mod unnamed { + use super::*; + + /// #Note + /// because this always succeeds, try this last + pub fn single_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + Ok((i, RefCaptureVariant::Unnamed)) + } + + pub fn many_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + map(get_star, |_| RefCaptureVariant::ManyUnnamed)(i) + } + + pub fn numbered_capture_impl(i: &str) -> IResult<&str, RefCaptureVariant, ParseError> { + map(digit1, |number: &str| RefCaptureVariant::NumberedUnnamed { + sections: number.parse().unwrap(), + })(i) + } +} + +/// Gets a capture or exact, mapping it to the CaptureOrExact enum - to provide a limited subset. +fn cap_or_exact<'a>( + field_naming_scheme: FieldNamingScheme, +) -> impl Fn(&'a str) -> IResult<&'a str, CaptureOrExact<'a>, ParseError> { + move |i: &str| { + alt(( + map( + capture_single_impl(field_naming_scheme), + CaptureOrExact::Capture, + ), + map(exact_impl(SPECIAL_CHARS), CaptureOrExact::Exact), + ))(i) + } +} + +/// Matches a query +pub fn query<'a>( + field_naming_scheme: FieldNamingScheme, +) -> impl Fn(&'a str) -> IResult<&'a str, RouteParserToken<'a>, ParseError> { + move |i: &str| { + map( + separated_pair( + exact_impl(SPECIAL_CHARS), + get_eq, + cap_or_exact(field_naming_scheme), + ), + |(ident, capture_or_exact)| RouteParserToken::Query { + ident, + capture_or_exact, + }, + )(i) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn lit() { + let x = exact("hello").expect("Should parse"); + assert_eq!(x.1, RouteParserToken::Exact("hello")) + } + + #[test] + fn cap_or_exact_match_lit() { + cap_or_exact(FieldNamingScheme::Named)("lorem").expect("Should parse"); + } + #[test] + fn cap_or_exact_match_cap() { + cap_or_exact(FieldNamingScheme::Named)("{lorem}").expect("Should parse"); + } + + #[test] + fn query_section_exact() { + query(FieldNamingScheme::Named)("lorem=ipsum").expect("should parse"); + } + + #[test] + fn query_section_capture_named() { + query(FieldNamingScheme::Named)("lorem={ipsum}").expect("should parse"); + } + #[test] + fn query_section_capture_named_fails_without_key() { + query(FieldNamingScheme::Named)("lorem={}").expect_err("should not parse"); + } + #[test] + fn query_section_capture_unnamed_succeeds_without_key() { + query(FieldNamingScheme::Unnamed)("lorem={}").expect("should parse"); + } + + #[test] + fn non_leading_numbers_in_ident() { + rust_ident("hello5").expect("sholud parse"); + } + #[test] + fn leading_numbers_in_ident_fails() { + rust_ident("5hello").expect_err("sholud not parse"); + } +} diff --git a/yew-router-route-parser/src/error.rs b/yew-router-route-parser/src/error.rs new file mode 100644 index 00000000000..622053dc8b0 --- /dev/null +++ b/yew-router-route-parser/src/error.rs @@ -0,0 +1,240 @@ +use nom::error::ErrorKind; +use std::fmt; + +/// Parser error that can print itself in a human-readable format. +#[derive(Clone, PartialEq)] +pub struct PrettyParseError<'a> { + /// Inner error + pub error: ParseError, + /// Input to the parser + pub input: &'a str, + /// Remaining input after partially tokenizing + pub remaining: &'a str, +} + +/// Simple offset calculator to determine where to place the carrot for indicating an error. +fn offset(input: &str, substring: &str) -> usize { + input.len() - substring.len() +} + +impl<'a> fmt::Debug for PrettyParseError<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Could not parse route.")?; + f.write_str("\n")?; + + let route_str: &str = "Route: "; + f.write_str(route_str)?; + f.write_str(self.input)?; + f.write_str("\n")?; + + let offset = offset(self.input, self.remaining); + let offset = offset + self.error.offset; + let pad = (0..offset + route_str.len()) + .map(|_| '-') + .collect::(); + f.write_str(&format!("{}^", pad))?; + f.write_str("\n")?; + + if !self.error.expected.is_empty() { + f.write_str("Expected: ")?; + self.error.expected[..self.error.expected.len() - 1] + .iter() + .map(|expected| { + ::fmt(expected, f) + .and_then(|_| f.write_str(", ")) + }) + .collect::>()?; + self.error + .expected + .last() + .map(|expected| ::fmt(expected, f)) + .transpose()?; + f.write_str("\n")?; + } + + if let Some(reason) = self.error.reason { + f.write_str("Reason: ")?; + ::fmt(&reason, f)?; + } + + Ok(()) + } +} + +/// Error for parsing the route +#[derive(Debug, Clone, PartialEq)] +pub struct ParseError { + /// A concrete reason why the parse failed. + pub reason: Option, + /// Expected token sequences + pub expected: Vec, + /// Additional offset for failures within sub-parsers. + /// Eg. if `{` parses, but then a bad ident is presented, some offset is needed here then. + pub offset: usize, +} + +impl ParseError { + pub(crate) fn expected(expected: ExpectedToken) -> Self { + ParseError { + reason: None, + expected: vec![expected], + offset: 0, + } + } +} + +impl nom::error::ParseError<&str> for ParseError { + fn from_error_kind(_input: &str, _kind: ErrorKind) -> Self { + ParseError { + reason: None, + expected: vec![], + offset: 0, + } + } + + fn append(_input: &str, _kind: ErrorKind, other: Self) -> Self { + other + } + + fn or(mut self, other: Self) -> Self { + // It is assumed that there aren't duplicates. + self.expected.extend(other.expected); + + ParseError { + reason: other.reason.or(self.reason), // Take the right most reason + expected: self.expected, + offset: other.offset, /* Defer to the "other"'s offset. TODO it might make sense if the offsets are different, only show the other's "expected". */ + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ExpectedToken { + /// / + Separator, + /// specific string. + Literal, + /// ? + QueryBegin, + /// & + QuerySeparator, + /// \# + FragmentBegin, + /// ! + End, + /// identifier within {} + Ident, + /// { + OpenBracket, + /// } + CloseBracket, + /// = + Equals, + /// * + Star, + /// : + Colon, +} + +impl fmt::Display for ExpectedToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ExpectedToken::Separator => f.write_str("/"), + ExpectedToken::Literal => f.write_str(""), + ExpectedToken::QueryBegin => f.write_str("?"), + ExpectedToken::QuerySeparator => f.write_str("&"), + ExpectedToken::FragmentBegin => f.write_str("#"), + ExpectedToken::End => f.write_str("!"), + ExpectedToken::Ident => f.write_str(""), + ExpectedToken::OpenBracket => f.write_str("{"), + ExpectedToken::CloseBracket => f.write_str("}"), + ExpectedToken::Equals => f.write_str("="), + ExpectedToken::Star => f.write_str("*"), + ExpectedToken::Colon => f.write_str(":"), + } + } +} + +/// A concrete reason why a parse failed +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ParserErrorReason { + /// Some token encountered after the end token. + TokensAfterEndToken, + /// Two slashes are able to occur next to each other. + DoubleSlash, + /// End after a {} + EndAfterCapture, + /// A & appears before a ? + AndBeforeQuestion, + /// Captures can't be next to each other + AdjacentCaptures, + /// There can only be one question mark in the query section + MultipleQuestions, + /// The provided ident within a capture group could never match with a valid rust identifier. + BadRustIdent(char), + /// A bad literal. + BadLiteral, + /// Invalid state + InvalidState, + /// Can't have capture sections for unit structs/variants + CapturesInUnit, + /// Internal check on valid state transitions + /// This should never actually be created. + NotAllowedStateTransition, + /// Expected a specific token + Expected(ExpectedToken), +} + +impl fmt::Display for ParserErrorReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParserErrorReason::TokensAfterEndToken => { + f.write_str("Characters appeared after the end token (!).")?; + } + ParserErrorReason::DoubleSlash => { + f.write_str("Two slashes are not allowed to be next to each other (//).")?; + } + ParserErrorReason::AndBeforeQuestion => { + f.write_str("The first query must be indicated with a '?', not a '&'.")?; + } + ParserErrorReason::AdjacentCaptures => { + f.write_str("Capture groups can't be next to each other. There must be some character in between the '}' and '{' characters.")?; + } + ParserErrorReason::InvalidState => { + f.write_str("Library Error: The parser was able to enter into an invalid state.")?; + } + ParserErrorReason::NotAllowedStateTransition => { + f.write_str("Library Error: A state transition was attempted that would put the parser in an invalid state")?; + } + ParserErrorReason::MultipleQuestions => { + f.write_str("There can only be one question mark in the query section. `&` should be used to separate other queries.")?; + } + ParserErrorReason::BadRustIdent(c) => { + f.write_str(&format!( + "The character: '{}' could not be used as a Rust identifier.", + c + ))?; + } + ParserErrorReason::EndAfterCapture => { + f.write_str("The end token (!) can't appear after a capture ({}).")?; + } + ParserErrorReason::Expected(expected) => { + f.write_str(&format!("Expected: {}", expected))?; + } + ParserErrorReason::BadLiteral => { + f.write_str("Malformed literal.")?; + } + ParserErrorReason::CapturesInUnit => { + f.write_str("Cannot have a capture section for a unit struct or variant.")?; + } + } + Ok(()) + } +} + +pub(crate) fn get_reason(err: &mut nom::Err) -> &mut Option { + match err { + nom::Err::Error(err) | nom::Err::Failure(err) => &mut err.reason, + nom::Err::Incomplete(_) => panic!("Incomplete not possible"), + } +} diff --git a/yew-router-route-parser/src/lib.rs b/yew-router-route-parser/src/lib.rs new file mode 100644 index 00000000000..994aa490d55 --- /dev/null +++ b/yew-router-route-parser/src/lib.rs @@ -0,0 +1,67 @@ +//! Parser for yew-router's matcher syntax. +//! This syntax allows specifying if a route should produce an enum variant or struct, +//! and allows capturing sections from the route to be incorporated into its associated variant or struct. + +#![deny( + missing_docs, + missing_debug_implementations, + missing_copy_implementations, + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unstable_features, + unused_qualifications +)] + +mod core; +mod error; +pub mod parser; +pub use crate::core::FieldNamingScheme; +pub use error::{ParseError, PrettyParseError}; +mod optimizer; +pub use optimizer::{convert_tokens, parse_str_and_optimize_tokens}; +use std::collections::HashMap; + +/// Alias of `HashMap<&'a str, String>` that represent strings captured from a route. +/// +/// Captures contain keys corresponding to named match sections, +/// and values containing the content captured by those sections. +pub type Captures<'a> = HashMap<&'a str, String>; + +/// Tokens used to determine how to match and capture sections from a URL. +#[derive(Debug, PartialEq, Clone)] +pub enum MatcherToken { + /// Section-related tokens can be condensed into a match. + Exact(String), + /// Capture section. + Capture(CaptureVariant), + /// End token - if the string hasn't been consumed entirely, then the parse will fail. + /// This is useful for being able to specify more general matchers for variants that would + /// otherwise match above more specific variants. + End, +} + +/// Variants that indicate how part of a string should be captured. +#[derive(Debug, PartialEq, Clone)] +pub enum CaptureVariant { + /// {} + Unnamed, + /// {*} + ManyUnnamed, + /// {5} + NumberedUnnamed { + /// Number of sections to match. + sections: usize, + }, + /// {name} - captures a section and adds it to the map with a given name. + Named(String), + /// {*:name} - captures over many sections and adds it to the map with a given name. + ManyNamed(String), + /// {2:name} - captures a fixed number of sections with a given name. + NumberedNamed { + /// Number of sections to match. + sections: usize, + /// The key to be entered in the `Matches` map. + name: String, + }, +} diff --git a/yew-router-route-parser/src/optimizer.rs b/yew-router-route-parser/src/optimizer.rs new file mode 100644 index 00000000000..a4ae40684d4 --- /dev/null +++ b/yew-router-route-parser/src/optimizer.rs @@ -0,0 +1,150 @@ +use crate::{ + error::PrettyParseError, + parser::{parse, CaptureOrExact, RefCaptureVariant, RouteParserToken}, +}; + +use crate::{core::FieldNamingScheme, CaptureVariant, MatcherToken}; + +impl<'a> From> for CaptureVariant { + fn from(v: RefCaptureVariant<'a>) -> Self { + match v { + RefCaptureVariant::Named(s) => CaptureVariant::Named(s.to_string()), + RefCaptureVariant::ManyNamed(s) => CaptureVariant::ManyNamed(s.to_string()), + RefCaptureVariant::NumberedNamed { sections, name } => CaptureVariant::NumberedNamed { + sections, + name: name.to_string(), + }, + RefCaptureVariant::Unnamed => CaptureVariant::Unnamed, + RefCaptureVariant::ManyUnnamed => CaptureVariant::ManyUnnamed, + RefCaptureVariant::NumberedUnnamed { sections } => { + CaptureVariant::NumberedUnnamed { sections } + } + } + } +} + +impl<'a> From> for MatcherToken { + fn from(value: CaptureOrExact<'a>) -> Self { + match value { + CaptureOrExact::Exact(m) => MatcherToken::Exact(m.to_string()), + CaptureOrExact::Capture(v) => MatcherToken::Capture(v.into()), + } + } +} + +impl<'a> RouteParserToken<'a> { + fn as_str(&self) -> &str { + match self { + RouteParserToken::Separator => "/", + RouteParserToken::Exact(literal) => &literal, + RouteParserToken::QueryBegin => "?", + RouteParserToken::QuerySeparator => "&", + RouteParserToken::FragmentBegin => "#", + RouteParserToken::Nothing + | RouteParserToken::Capture { .. } + | RouteParserToken::Query { .. } + | RouteParserToken::End => unreachable!(), + } + } +} + +/// Parse the provided "matcher string" and then optimize the tokens. +pub fn parse_str_and_optimize_tokens( + i: &str, + field_naming_scheme: FieldNamingScheme, +) -> Result, PrettyParseError> { + let tokens = parse(i, field_naming_scheme)?; + Ok(convert_tokens(&tokens)) +} + +/// Converts a slice of `RouteParserToken` into a Vec of MatcherTokens. +/// +/// In the process of converting the tokens, this function will condense multiple RouteParserTokens +/// that represent literals into one Exact variant if multiple reducible tokens happen to occur in a row. +pub fn convert_tokens(tokens: &[RouteParserToken]) -> Vec { + let mut new_tokens: Vec = vec![]; + let mut run: Vec = vec![]; + + fn empty_run(run: &mut Vec) -> Option { + let segment = run.iter().map(RouteParserToken::as_str).collect::(); + run.clear(); + + if !segment.is_empty() { + Some(MatcherToken::Exact(segment)) + } else { + None + } + } + + fn empty_run_with_query_cap_at_end( + run: &mut Vec, + query_lhs: &str, + ) -> MatcherToken { + let segment = run + .iter() + .map(RouteParserToken::as_str) + .chain(Some(query_lhs)) + .chain(Some("=")) + .collect::(); + run.clear(); + + MatcherToken::Exact(segment) + } + + for token in tokens.iter() { + match token { + RouteParserToken::QueryBegin + | RouteParserToken::FragmentBegin + | RouteParserToken::Separator + | RouteParserToken::QuerySeparator + | RouteParserToken::Exact(_) => run.push(*token), + RouteParserToken::Capture(cap) => { + if let Some(current_run) = empty_run(&mut run) { + new_tokens.push(current_run); + } + new_tokens.push(MatcherToken::Capture(CaptureVariant::from(*cap))) + } + RouteParserToken::Query { + ident, + capture_or_exact, + } => match capture_or_exact { + CaptureOrExact::Exact(s) => { + run.push(RouteParserToken::Exact(ident)); + run.push(RouteParserToken::Exact("=")); + run.push(RouteParserToken::Exact(s)); + } + CaptureOrExact::Capture(cap) => { + new_tokens.push(empty_run_with_query_cap_at_end(&mut run, *ident)); + new_tokens.push(MatcherToken::Capture(CaptureVariant::from(*cap))) + } + }, + RouteParserToken::End => { + if let Some(current_run) = empty_run(&mut run) { + new_tokens.push(current_run); + } + new_tokens.push(MatcherToken::End); + } + RouteParserToken::Nothing => {} + } + } + + // Empty the run at the end. + if !run.is_empty() { + if let Some(current_run) = empty_run(&mut run) { + new_tokens.push(current_run); + } + } + + new_tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_creates_empty_token_list() { + let tokens = parse_str_and_optimize_tokens("", FieldNamingScheme::Unit).unwrap(); + assert_eq!(tokens, vec![]) + } +} diff --git a/yew-router-route-parser/src/parser.rs b/yew-router-route-parser/src/parser.rs new file mode 100644 index 00000000000..1159a791be0 --- /dev/null +++ b/yew-router-route-parser/src/parser.rs @@ -0,0 +1,781 @@ +//! Parser that consumes a string and produces the first representation of the matcher. +use crate::{ + core::{ + capture, exact, fragment_exact, get_and, get_end, get_hash, get_question, get_slash, + nothing, query, + }, + error::{get_reason, ParseError, ParserErrorReason, PrettyParseError}, + FieldNamingScheme, +}; +use nom::{branch::alt, IResult}; +// use crate::core::escaped_item; + +/// Tokens generated from parsing a route matcher string. +/// They will be optimized to another token type that is used to match URLs. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RouteParserToken<'a> { + /// Generated by the empty string `""`. + Nothing, + /// Match / + Separator, + /// Match a specific string. + Exact(&'a str), + /// Match {_}. See `RefCaptureVariant` for more. + Capture(RefCaptureVariant<'a>), + /// Match ? + QueryBegin, + /// Match & + QuerySeparator, + /// Match x=y + Query { + /// Identifier + ident: &'a str, + /// Capture or match + capture_or_exact: CaptureOrExact<'a>, + }, + /// Match \# + FragmentBegin, + /// Match ! + End, +} + +/// Token representing various types of captures. +/// +/// It can capture and discard for unnamed variants, or capture and store in the `Matches` for the +/// named variants. +/// +/// Its name stems from the fact that it does not have ownership over all its values. +/// It gets converted to CaptureVariant, a nearly identical enum that has owned Strings instead. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RefCaptureVariant<'a> { + /// {} + Unnamed, + /// {*} + ManyUnnamed, + /// {5} + NumberedUnnamed { + /// Number of sections to match. + sections: usize, + }, + /// {name} - captures a section and adds it to the map with a given name. + Named(&'a str), + /// {*:name} - captures over many sections and adds it to the map with a given name. + ManyNamed(&'a str), + /// {2:name} - captures a fixed number of sections with a given name. + NumberedNamed { + /// Number of sections to match. + sections: usize, + /// The key to be entered in the `Matches` map. + name: &'a str, + }, +} + +/// Either a Capture, or an Exact match +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CaptureOrExact<'a> { + /// Match a specific string. + Exact(&'a str), + /// Match a capture variant. + Capture(RefCaptureVariant<'a>), +} + +/// Represents the states the parser can be in. +#[derive(Clone, PartialEq)] +enum ParserState<'a> { + None, + Path { prev_token: RouteParserToken<'a> }, + FirstQuery { prev_token: RouteParserToken<'a> }, + NthQuery { prev_token: RouteParserToken<'a> }, + Fragment { prev_token: RouteParserToken<'a> }, + End, +} +impl<'a> ParserState<'a> { + /// Given a new route parser token, transition to a new state. + /// + /// This will set the prev token to a token able to be handled by the new state, + /// so the new state does not need to handle arbitrary "from" states. + /// + /// This function represents the valid state transition graph. + fn transition(self, token: RouteParserToken<'a>) -> Result { + match self { + ParserState::None => match token { + RouteParserToken::Separator + | RouteParserToken::Exact(_) + | RouteParserToken::Capture(_) => Ok(ParserState::Path { prev_token: token }), + RouteParserToken::QueryBegin => Ok(ParserState::FirstQuery { prev_token: token }), + RouteParserToken::QuerySeparator => Ok(ParserState::NthQuery { prev_token: token }), + RouteParserToken::Query { .. } => Err(ParserErrorReason::NotAllowedStateTransition), + RouteParserToken::FragmentBegin => Ok(ParserState::Fragment { prev_token: token }), + RouteParserToken::Nothing | RouteParserToken::End => Ok(ParserState::End), + }, + ParserState::Path { prev_token } => { + match prev_token { + RouteParserToken::Separator => match token { + RouteParserToken::Exact(_) | RouteParserToken::Capture(_) => { + Ok(ParserState::Path { prev_token: token }) + } + RouteParserToken::QueryBegin => { + Ok(ParserState::FirstQuery { prev_token: token }) + } + RouteParserToken::FragmentBegin => { + Ok(ParserState::Fragment { prev_token: token }) + } + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + RouteParserToken::Exact(_) => match token { + RouteParserToken::Exact(_) + | RouteParserToken::Separator + | RouteParserToken::Capture(_) => { + Ok(ParserState::Path { prev_token: token }) + } + RouteParserToken::QueryBegin => { + Ok(ParserState::FirstQuery { prev_token: token }) + } + RouteParserToken::FragmentBegin => { + Ok(ParserState::Fragment { prev_token: token }) + } + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + RouteParserToken::Capture(_) => match token { + RouteParserToken::Separator | RouteParserToken::Exact(_) => { + Ok(ParserState::Path { prev_token: token }) + } + RouteParserToken::QueryBegin => { + Ok(ParserState::FirstQuery { prev_token: token }) + } + RouteParserToken::FragmentBegin => { + Ok(ParserState::Fragment { prev_token: token }) + } + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + _ => Err(ParserErrorReason::InvalidState), /* Other previous token types are + * invalid within a Path state. */ + } + } + ParserState::FirstQuery { prev_token } => match prev_token { + RouteParserToken::QueryBegin => match token { + RouteParserToken::Query { .. } => { + Ok(ParserState::FirstQuery { prev_token: token }) + } + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + RouteParserToken::Query { .. } => match token { + RouteParserToken::QuerySeparator => { + Ok(ParserState::NthQuery { prev_token: token }) + } + RouteParserToken::FragmentBegin => { + Ok(ParserState::Fragment { prev_token: token }) + } + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + _ => Err(ParserErrorReason::InvalidState), + }, + ParserState::NthQuery { prev_token } => match prev_token { + RouteParserToken::QuerySeparator => match token { + RouteParserToken::Query { .. } => { + Ok(ParserState::NthQuery { prev_token: token }) + } + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + RouteParserToken::Query { .. } => match token { + RouteParserToken::QuerySeparator => { + Ok(ParserState::NthQuery { prev_token: token }) + } + RouteParserToken::FragmentBegin => { + Ok(ParserState::Fragment { prev_token: token }) + } + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::NotAllowedStateTransition), + }, + _ => Err(ParserErrorReason::InvalidState), + }, + ParserState::Fragment { prev_token } => match prev_token { + RouteParserToken::FragmentBegin + | RouteParserToken::Exact(_) + | RouteParserToken::Capture(_) => Ok(ParserState::Fragment { prev_token: token }), + RouteParserToken::End => Ok(ParserState::End), + _ => Err(ParserErrorReason::InvalidState), + }, + ParserState::End => Err(ParserErrorReason::TokensAfterEndToken), + } + } +} + +/// Parse a matching string into a vector of RouteParserTokens. +/// +/// The parsing logic involves using a state machine. +/// After a token is read, this token is fed into the state machine, causing it to transition to a new state or throw an error. +/// Because the tokens that can be parsed in each state are limited, errors are not actually thrown in the state transition, +/// due to the fact that erroneous tokens can't be fed into the transition function. +/// +/// This continues until the string is exhausted, or none of the parsers for the current state can parse the current input. +pub fn parse( + mut i: &str, + field_naming_scheme: FieldNamingScheme, +) -> Result, PrettyParseError> { + let input = i; + let mut tokens: Vec = vec![]; + let mut state = ParserState::None; + + loop { + let (ii, token) = parse_impl(i, &state, field_naming_scheme).map_err(|e| match e { + nom::Err::Error(e) | nom::Err::Failure(e) => PrettyParseError { + error: e, + input, + remaining: i, + }, + _ => panic!("parser should not be incomplete"), + })?; + i = ii; + state = state.transition(token.clone()).map_err(|reason| { + let error = ParseError { + reason: Some(reason), + expected: vec![], + offset: 0, + }; + PrettyParseError { + error, + input, + remaining: i, + } + })?; + tokens.push(token); + + // If there is no more input, break out of the loop + if i.is_empty() { + break; + } + } + Ok(tokens) +} + +fn parse_impl<'a>( + i: &'a str, + state: &ParserState, + field_naming_scheme: FieldNamingScheme, +) -> IResult<&'a str, RouteParserToken<'a>, ParseError> { + match state { + ParserState::None => alt(( + get_slash, + get_question, + get_and, + get_hash, + capture(field_naming_scheme), + exact, + get_end, + nothing, + ))(i), + ParserState::Path { prev_token } => match prev_token { + RouteParserToken::Separator => { + alt(( + exact, + capture(field_naming_scheme), + get_question, + get_hash, + get_end, + ))(i) + .map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_slash(i) + .map(|_| ParserErrorReason::DoubleSlash) + .or_else(|_| get_and(i).map(|_| ParserErrorReason::AndBeforeQuestion)) + .ok() + .or(*reason); + e + }) + } + RouteParserToken::Exact(_) => { + alt(( + get_slash, + exact, // This will handle escaped items + capture(field_naming_scheme), + get_question, + get_hash, + get_end, + ))(i) + .map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_and(i) + .map(|_| ParserErrorReason::AndBeforeQuestion) + .ok() + .or(*reason); + e + }) + } + RouteParserToken::Capture(_) => { + alt((get_slash, exact, get_question, get_hash, get_end))(i).map_err( + |mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = capture(field_naming_scheme)(i) + .map(|_| ParserErrorReason::AdjacentCaptures) + .or_else(|_| get_and(i).map(|_| ParserErrorReason::AndBeforeQuestion)) + .ok() + .or(*reason); + e + }, + ) + } + _ => Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::InvalidState), + expected: vec![], + offset: 0, + })), + }, + ParserState::FirstQuery { prev_token } => match prev_token { + RouteParserToken::QueryBegin => { + query(field_naming_scheme)(i).map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_question(i) + .map(|_| ParserErrorReason::MultipleQuestions) + .ok() + .or(*reason); + e + }) + } + RouteParserToken::Query { .. } => { + alt((get_and, get_hash, get_end))(i).map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_question(i) + .map(|_| ParserErrorReason::MultipleQuestions) + .ok() + .or(*reason); + e + }) + } + _ => Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::InvalidState), + expected: vec![], + offset: 0, + })), + }, + ParserState::NthQuery { prev_token } => match prev_token { + RouteParserToken::QuerySeparator => { + query(field_naming_scheme)(i).map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_question(i) + .map(|_| ParserErrorReason::MultipleQuestions) + .ok() + .or(*reason); + e + }) + } + RouteParserToken::Query { .. } => { + alt((get_and, get_hash, get_end))(i).map_err(|mut e: nom::Err| { + // Detect likely failures if the above failed to match. + let reason: &mut Option = get_reason(&mut e); + *reason = get_question(i) + .map(|_| ParserErrorReason::MultipleQuestions) + .ok() + .or(*reason); + e + }) + } + _ => Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::InvalidState), + expected: vec![], + offset: 0, + })), + }, + ParserState::Fragment { prev_token } => match prev_token { + RouteParserToken::FragmentBegin => { + alt((fragment_exact, capture(field_naming_scheme), get_end))(i) + } + RouteParserToken::Exact(_) => alt((capture(field_naming_scheme), get_end))(i), + RouteParserToken::Capture(_) => alt((fragment_exact, get_end))(i), + _ => Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::InvalidState), + expected: vec![], + offset: 0, + })), + }, + ParserState::End => Err(nom::Err::Failure(ParseError { + reason: Some(ParserErrorReason::TokensAfterEndToken), + expected: vec![], + offset: 0, + })), + } +} + +#[cfg(test)] +mod test { + // use super::*; + use super::parse as actual_parse; + use crate::{parser::RouteParserToken, FieldNamingScheme, PrettyParseError}; + + // Call all tests to parse with the Unnamed variant + fn parse(i: &str) -> Result, PrettyParseError> { + actual_parse(i, FieldNamingScheme::Unnamed) + } + + mod does_parse { + use super::*; + + #[test] + fn empty() { + let x = parse("").expect("Should parse"); + assert_eq!(x, vec![RouteParserToken::Nothing]) + } + + #[test] + fn slash() { + parse("/").expect("should parse"); + } + + #[test] + fn slash_exact() { + parse("/hello").expect("should parse"); + } + + #[test] + fn multiple_exact() { + parse("/lorem/ipsum").expect("should parse"); + } + + #[test] + fn capture_in_path() { + parse("/lorem/{ipsum}").expect("should parse"); + } + + #[test] + fn capture_rest_in_path() { + parse("/lorem/{*:ipsum}").expect("should parse"); + } + + #[test] + fn capture_numbered_in_path() { + parse("/lorem/{5:ipsum}").expect("should parse"); + } + + #[test] + fn exact_query_after_path() { + parse("/lorem?ipsum=dolor").expect("should parse"); + } + + #[test] + fn leading_query_separator() { + parse("&lorem=ipsum").expect("Should parse"); + } + + #[test] + fn exact_query() { + parse("?lorem=ipsum").expect("should parse"); + } + + #[test] + fn capture_query() { + parse("?lorem={ipsum}").expect("should parse"); + } + + #[test] + fn multiple_queries() { + parse("?lorem=ipsum&dolor=sit").expect("should parse"); + } + + #[test] + fn query_and_exact_fragment() { + parse("?lorem=ipsum#dolor").expect("should parse"); + } + + #[test] + fn query_with_exact_and_capture_fragment() { + parse("?lorem=ipsum#dolor{sit}").expect("should parse"); + } + + #[test] + fn query_with_capture_fragment() { + parse("?lorem=ipsum#{dolor}").expect("should parse"); + } + + #[test] + fn escaped_backslash() { + let tokens = parse(r#"/escaped\\backslash"#).expect("should parse"); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact(r#"escaped\\backslash"#), + ]; + assert_eq!(tokens, expected); + } + + #[test] + fn escaped_exclamation() { + let tokens = parse(r#"/escaped!!exclamation"#).expect("should parse"); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact(r#"escaped"#), + RouteParserToken::Exact(r#"!"#), + RouteParserToken::Exact(r#"exclamation"#), + ]; + assert_eq!(tokens, expected); + } + + #[test] + fn escaped_open_bracket() { + let tokens = parse(r#"/escaped{{bracket"#).expect("should parse"); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact(r#"escaped"#), + RouteParserToken::Exact(r#"{"#), + RouteParserToken::Exact(r#"bracket"#), + ]; + assert_eq!(tokens, expected); + } + + #[test] + fn escaped_close_bracket() { + let tokens = parse(r#"/escaped}}bracket"#).expect("should parse"); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact(r#"escaped"#), + RouteParserToken::Exact(r#"}"#), + RouteParserToken::Exact(r#"bracket"#), + ]; + assert_eq!(tokens, expected); + } + } + + mod does_not_parse { + use super::*; + use crate::error::{ExpectedToken, ParserErrorReason}; + + #[test] + fn double_slash() { + let x = parse("//").expect_err("Should not parse"); + assert_eq!(x.error.reason, Some(ParserErrorReason::DoubleSlash)) + } + + #[test] + fn slash_ampersand() { + let x = parse("/&lorem=ipsum").expect_err("Should not parse"); + assert_eq!(x.error.reason, Some(ParserErrorReason::AndBeforeQuestion)) + } + + #[test] + fn non_ident_capture() { + let x = parse("/{lor#m}").expect_err("Should not parse"); + assert_eq!(x.error.reason, Some(ParserErrorReason::BadRustIdent('#'))); + assert_eq!( + x.error.expected, + vec![ExpectedToken::CloseBracket, ExpectedToken::Ident] + ) + } + + #[test] + fn after_end() { + let x = parse("/lorem/ipsum!/dolor").expect_err("Should not parse"); + assert_eq!(x.error.reason, Some(ParserErrorReason::TokensAfterEndToken)); + } + } + + mod correct_parse { + use super::*; + use crate::parser::{CaptureOrExact, RefCaptureVariant}; + + #[test] + fn starting_literal() { + let parsed = parse("lorem").unwrap(); + let expected = vec![RouteParserToken::Exact("lorem")]; + assert_eq!(parsed, expected); + } + + #[test] + fn minimal_path() { + let parsed = parse("/lorem").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn multiple_path() { + let parsed = parse("/lorem/ipsum/dolor/sit").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + RouteParserToken::Separator, + RouteParserToken::Exact("ipsum"), + RouteParserToken::Separator, + RouteParserToken::Exact("dolor"), + RouteParserToken::Separator, + RouteParserToken::Exact("sit"), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn capture_path() { + let parsed = parse("/{lorem}/{ipsum}").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("ipsum")), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn query() { + let parsed = parse("?query=this").unwrap(); + let expected = vec![ + RouteParserToken::QueryBegin, + RouteParserToken::Query { + ident: "query", + capture_or_exact: CaptureOrExact::Exact("this"), + }, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn query_2_part() { + let parsed = parse("?lorem=ipsum&dolor=sit").unwrap(); + let expected = vec![ + RouteParserToken::QueryBegin, + RouteParserToken::Query { + ident: "lorem", + capture_or_exact: CaptureOrExact::Exact("ipsum"), + }, + RouteParserToken::QuerySeparator, + RouteParserToken::Query { + ident: "dolor", + capture_or_exact: CaptureOrExact::Exact("sit"), + }, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn query_3_part() { + let parsed = parse("?lorem=ipsum&dolor=sit&amet=consectetur").unwrap(); + let expected = vec![ + RouteParserToken::QueryBegin, + RouteParserToken::Query { + ident: "lorem", + capture_or_exact: CaptureOrExact::Exact("ipsum"), + }, + RouteParserToken::QuerySeparator, + RouteParserToken::Query { + ident: "dolor", + capture_or_exact: CaptureOrExact::Exact("sit"), + }, + RouteParserToken::QuerySeparator, + RouteParserToken::Query { + ident: "amet", + capture_or_exact: CaptureOrExact::Exact("consectetur"), + }, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn exact_fragment() { + let parsed = parse("#lorem").unwrap(); + let expected = vec![ + RouteParserToken::FragmentBegin, + RouteParserToken::Exact("lorem"), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn capture_fragment() { + let parsed = parse("#{lorem}").unwrap(); + let expected = vec![ + RouteParserToken::FragmentBegin, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn mixed_fragment() { + let parsed = parse("#{lorem}ipsum{dolor}").unwrap(); + let expected = vec![ + RouteParserToken::FragmentBegin, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + RouteParserToken::Exact("ipsum"), + RouteParserToken::Capture(RefCaptureVariant::Named("dolor")), + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn end_after_path() { + let parsed = parse("/lorem!").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + RouteParserToken::End, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn end_after_path_separator() { + let parsed = parse("/lorem/!").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + RouteParserToken::Separator, + RouteParserToken::End, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn end_after_path_capture() { + let parsed = parse("/lorem/{cap}!").unwrap(); + let expected = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("cap")), + RouteParserToken::End, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn end_after_query_capture() { + let parsed = parse("?lorem={cap}!").unwrap(); + let expected = vec![ + RouteParserToken::QueryBegin, + RouteParserToken::Query { + ident: "lorem", + capture_or_exact: CaptureOrExact::Capture(RefCaptureVariant::Named("cap")), + }, + RouteParserToken::End, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn end_after_frag_capture() { + let parsed = parse("#{cap}!").unwrap(); + let expected = vec![ + RouteParserToken::FragmentBegin, + RouteParserToken::Capture(RefCaptureVariant::Named("cap")), + RouteParserToken::End, + ]; + assert_eq!(parsed, expected); + } + + #[test] + fn just_end() { + let parsed = parse("!").unwrap(); + assert_eq!(parsed, vec![RouteParserToken::End]); + } + } +} diff --git a/yew-router/CHANGELOG.md b/yew-router/CHANGELOG.md new file mode 100644 index 00000000000..dedfa9ef8ea --- /dev/null +++ b/yew-router/CHANGELOG.md @@ -0,0 +1,95 @@ +# Changelog + + + +## ✨ **0.12.0** *(TBD)* + +- #### ⚡️ Features + - x +- #### 🛠 Fixes + - x +- #### 🚨 Breaking changes + - x + +## ✨ **0.11.0** *2020-3-14* + +- #### 🛠 Fixes + - Fixed docs.rs document generation [[254](https://github.com/yewstack/yew_router/pull/254)] (Thanks @jetli) + - Fixed clippy for web_sys target [[249](https://github.com/yewstack/yew_router/pull/249)] (Thanks @jetli) + + +## ✨ **0.10.0** *2020-3-2* + +- Bumped version of Yew from v0.12.0 to v0.13.0 +- This brings support for web_sys, which necessitates specifying either "web_sys" or "std_web" as a feature. (Thanks @tarkah) + +## ✨ **0.9.0** *2020-2-25* +- #### ⚡️ Features + - Improved error handling in macro. [[233](https://github.com/yewstack/yew_router/pull/233)] @jplatte +- #### 🛠 Fixes + - Fix RouterAnchor href [[228](https://github.com/yewstack/yew_router/pull/228)] @jetli + - Undo non-passive state for prevent_default [[240](https://github.com/yewstack/yew_router/pull/240)] @jetli + + +## ✨ **0.8.1** *(2020-1-10)* + +- #### 🛠 Fixes + - Fixed a dependency issue with `wasm-bindgen` that would cause builds to fail when building for the `wasm32-unknown-unknown` target. + +## ✨ **0.8.0** *(2020-1-9)* +- #### ⚡️ Features + - Use a default type parameter of `()` to specify state-related type parameters instead of the old macro-based solution. [[157](https://github.com/yewstack/yew_router/issues/157)] + - Remove need for `JsSerializable` bound on the state parameter used for storing extra data in the history API.[[185](https://github.com/yewstack/yew_router/issues/185)] + - RouterLink and RouterButton now support having children Html. This deprecates the `text` prop. [[192](https://github.com/yewstack/yew_router/issues/192)] + - Fragment routing is now easily implementable by using an adapter because parser rules for the routing syntax were relaxed. [[195](https://github.com/yewstack/yew_router/issues/195)] [[211](https://github.com/yewstack/yew_router/pull/211)] + - Support using this library only with the Switch derive, allowing it to run in non-web contexts. [[199](https://github.com/yewstack/yew_router/issues/199)] +- #### 🚨 Breaking changes + - If you were using `default-features = false`, you will have to now specify `features = ["service"]` to get the same behavior as before. [[199](https://github.com/yewstack/yew_router/issues/199)] + - `RouterAnchor` and `RouterButton` now have props that take a `route: SW where SW: Switch` prop instead of a `link: String` and they now have a mandatory type parameter that specifies this `SW`. [[207](https://github.com/yewstack/yew_router/issues/207)] + - `Route`'s state field now holds a `T` instead of an `Option`. [[205](https://github.com/yewstack/yew_router/issues/205)] + - Using default type parameters to specify the state typ instead of the macro that generated a module (`unit_state`) means that any imports from that module should now be replaced with the path that the type normally has in the project. [[157](https://github.com/yewstack/yew_router/issues/157)] +- #### Inconsequential + - Change state related type parameters from `T` to `STATE`. [[208](https://github.com/yewstack/yew_router/issues/208)] + +## ✨ **0.7.0** *(2019-11-11)* + +- #### ⚡️ Features + - Redirects that happen in the `Router` component actually change the url in the browser [[171](https://github.com/yewstack/yew_router/issues/171)] + - Allow parsing (almost) any character after a `#` is encountered in matcher strings. + This enables this library to be used as a fragment router. [[150](https://github.com/yewstack/yew_router/issues/150)] +- #### 🛠 Fixes + - Allow `!` to appear after `{...}` in matcher strings. [[148](https://github.com/yewstack/yew_router/issues/148)] + - Matcher strings can now start with `&`. [[168](https://github.com/yewstack/yew_router/issues/168)] +- #### 🚨 Breaking changes + - Upgrade to Yew 0.10.0 + - Switch components now need to implement `Clone` in order to be used with the `Router` [[171](https://github.com/yewstack/yew_router/issues/171)] + +## ✨ **0.6.1** *(2019-11-1)* +- #### ⚡️ Features + - Bring back `{}`, `{*}`, and `{}` capture syntax for tuple structs/enum variants. + If your variant or struct doesn't have named fields, you don't need to supply names in the matcher string [[116](https://github.com/yewstack/yew_router/issues/116)] + - Allow ! special character in more places. + - Greatly improve the quality of matcher string parsing errors. [[171](https://github.com/yewstack/yew_router/issues/149)] + - Add `impl From for Route`. Now Routes can be created from Switches easily. + - Allow escaping {, }, and ! special characters by using `{{`, `}}`, and `!!` respectively. + - Provide a correct error message when attempting to derive `Switch` for a Unit struct/variant with a capture group. + +## ✨ **0.6.0** *(2019-10-24)* +- #### ⚡️ Features + - `Switch` trait and Proc Macro enables extracting data from route strings. + - `Router` component added. + - `RouterLink` and `RouterButton` helper components added. +- #### 🚨 Breaking changes + - Nearly everything. Most items were renamed. + - Upgrade to Yew 0.9.0 diff --git a/yew-router/Cargo.toml b/yew-router/Cargo.toml new file mode 100644 index 00000000000..a829d214a73 --- /dev/null +++ b/yew-router/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "yew-router" +version = "0.11.0" +authors = ["Henry Zimmerman ", "Sascha Grunert "] +edition = "2018" +license = "MIT/Apache-2.0" +readme = "README.md" +keywords = ["web", "yew", "router"] +categories = ["gui", "web-programming"] +description = "A router implementation for the Yew framework" +repository = "https://github.com/yewstack/yew_router" + +[features] +default = ["web_sys", "core", "unit_alias"] +core = ["router", "components"] # Most everything +unit_alias = [] # TODO remove this +router = ["agent"] # The Router component +components = ["agent" ] # The button and anchor +agent = ["service"] # The RouteAgent +service = ["yew"] # The RouteService +std_web = [ + "yew/std_web", + "stdweb" +] +web_sys = [ + "yew/web_sys", + "gloo", + "js-sys", + "web-sys", + "wasm-bindgen" +] + +[dependencies] +cfg-if = "0.1.10" +cfg-match = "0.2" +gloo = { version = "0.2.0", optional = true } +js-sys = { version = "0.3.35", optional = true } +log = "0.4.8" +nom = "5.1.1" +serde = { version = "1.0.104", features = ["derive"] } +serde_json = "1.0.48" +stdweb = { version = "0.4.20", optional = true } +wasm-bindgen = { version = "0.2.58", optional = true } +yew = { path = "../yew", features = ["services", "agent"], optional = true } +yew-router-macro = { path = "../yew-router-macro" } +yew-router-route-parser = { path = "../yew-router-route-parser" } + +[dependencies.web-sys] +version = "0.3" +optional = true +features = [ + 'History', + 'HtmlLinkElement', + 'Location', + 'MouseEvent', + 'PopStateEvent', + 'Window', +] + +# Compat with building yew with wasm-pack support. +[target.'cfg(all(target_arch = "wasm32", not(target_os="wasi"), not(cargo_web)))'.dependencies] +wasm-bindgen = "0.2.58" + +[dev-dependencies] +uuid = "0.8.1" diff --git a/yew-router/README.md b/yew-router/README.md new file mode 100644 index 00000000000..656ae206a09 --- /dev/null +++ b/yew-router/README.md @@ -0,0 +1,75 @@ +# yew-router +A routing library for the [Yew](https://github.com/yewstack/yew) frontend framework. + + +### Example +```rust +#[derive(Switch, Debug)] +pub enum AppRoute { + #[to = "/profile/{id}"] + Profile(u32), + #[to = "/forum{*:rest}"] + Forum(ForumRoute), + #[to = "/"] + Index, +} + +#[derive(Switch, Debug)] +pub enum ForumRoute { + #[to = "/{subforum}/{thread_slug}"] + SubForumAndThread{subforum: String, thread_slug: String} + #[to = "/{subforum}"] + SubForum{subforum: String} +} + +html! { + + render = Router::render(|switch: AppRoute| { + match switch { + AppRoute::Profile(id) => html!{}, + AppRoute::Index => html!{}, + AppRoute::Forum(forum_route) => html!{}, + } + }) + /> +} +``` + +### How it works +This library works by getting the url location from the browser and uses it to instantiate a type that implements Switch. +Simply using `` tags to go to your route will not work out of the box, and are inefficient because the server will return the whole app bundle again at best, and at worst just return a 404 message if the server isn't configured properly. +Using this library's RouteService, RouteAgent, RouterButton, and RouterLink to set the location via `history.push_state()` will change the route without retrieving the whole app again. +#### Server configuration +In order for an external link to your webapp to work, the server must be configured to return the `index.html` file for any GET request that would otherwise return a `404` for any conceivable client-side route. +It can't be a `3xx` redirect to `index.html`, as that will change the url in the browser, causing the routing to fail - it must be a `200` response containing the content of `index.html`. +Once the content of `index.html` loads, it will in turn load the rest of your assets as expected and your app will start, the router will detect the current route, and set your application state accordingly. + +If you choose to serve the app from the same server as your api, it is recommended to mount your api under `/api` and mount your assets under `/` and have `/` return the content of `index.html`. + +Look at https://webpack.js.org/configuration/dev-server/#devserverhistoryapifallback for info on how to configure a webpack dev server to have this behavior. + + +### How to Include +You can use the released version by adding these to your dependencies. +```toml +[dependencies] +yew-router = "0.7.0" +yew = "0.10.1" +``` + +You can use the in-development version in your project by adding it to your dependencies like so: +```toml +[dependencies] +yew-router = { git = "https://github.com/yewstack/yew_router", branch="master" } +yew = {git = "https://github.com/yewstack/yew", branch = "master"} +``` + + +#### Minimum rustc +Currently, this library targets rustc 1.39.0, but development is done on the latest stable release. +This library aims to track Yew`s minimum supported rustc version. + +----- +### Contributions/Requests + +If you have any questions, suggestions, or want to contribute, please open an Issue or PR and we will get back to you in a timely manner. diff --git a/yew-router/examples/guide/Cargo.toml b/yew-router/examples/guide/Cargo.toml new file mode 100644 index 00000000000..ce327b953b1 --- /dev/null +++ b/yew-router/examples/guide/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "guide" +version = "0.1.0" +authors = ["Henry Zimmerman "] +edition = "2018" + +[dependencies] +yew = { path = "../../../yew" } +yew-router = {path = "../../" } +log = "0.4.8" +pulldown-cmark = "0.6.1" diff --git a/yew-router/examples/guide/chapters/01_intro.md b/yew-router/examples/guide/chapters/01_intro.md new file mode 100644 index 00000000000..13e0821e4d6 --- /dev/null +++ b/yew-router/examples/guide/chapters/01_intro.md @@ -0,0 +1,41 @@ +# Intro + +## What is Yew Router? +Yew Router is a router in the style of [React Router](https://reacttraining.com/react-router/web/guides/quick-start). +A router's job in the context of a frontend web application is to take part of a URL and determine what HTML to render based on that. + + + +## Important Constructs +Yew router contains a service, an agent, routing components, and components for changing the route. +You can choose to forgo using the router itself and just use the service or agent, although the `Router` provides a higher layer of abstraction over the same domain. + +#### `route!` macro +The `route!` macro allows you to specify a string that determines how the router will match part of a URL. + +#### Routing Components +The `Router` and `Route` components allow you to specify what route strings to match, and what content render when they succeed. +You can tell a `Route` to render a component directly, or you can provide a closure to render an arbitrary piece of html. + +#### Accessory Components +The `RouterLink` and `RouterButton` components wrap links and buttons respectively and provide ready-made components that can be used to change the route. + +#### Service +The routing service interfaces directly with the browser's history API. +You can register a callback to receive messages about when routes change, or you can change the route yourself. + +#### Agent +The routing agent offers a layer of orchestration to an application. +It sits between the routing components and the service, and provides an interface for you to change the route and make sure the router itself gets notified of the change. + +------ +### Example +This crate allows you to specify which components to render as easily as: +```rust +html! { + + () /> + () /> + +} +``` \ No newline at end of file diff --git a/yew-router/examples/guide/chapters/02_router_component.md b/yew-router/examples/guide/chapters/02_router_component.md new file mode 100644 index 00000000000..bd43c8d5dda --- /dev/null +++ b/yew-router/examples/guide/chapters/02_router_component.md @@ -0,0 +1,68 @@ +# Router Component + +The `Router` component is used to contain `Route` components. +The `Route` components allow you to specify which routes to match, and what to render when they do. + + +## Logic +The `Router`, when routing, wants to find a valid target. +To do this, it will look at each of its child `Route` components. +For each `Route` component, the `Router` will attempt to match its route string against the `Route`'s matcher. +If the matcher succeeds, then a `Matches` (alias to `HashMap<&str, String>`) is produced and fed to its render function (if one is provided). +If the render function returns None, then the `Router` will continue to look an the next `Route`, but if `Some` is returned, it has completed its task and will cease looking for targets. + +#### Render +If the `render` property of the `Route` component is specified, it call that function to get content to display. +The signature of this function is `fn(matches: &Matches) -> Option>`. +The `Router` will only cease its search for a target if this function returns `Some`, otherwise it will continue to try other `Route`s. + +The `component()` function allows you to specify a component to attempt render. +You can only call this with a type parameter of a component whose `Properties` have implemented `FromCaptures`. + +Alternatively, `render()` can be called instead, which takes a closure that returns an `Option>`. + +#### Children +If the match succeeds and the `Route` specified `children` instead of a `render` prop, the children will always be displayed. +Rendering children may be more ergonomic, but you loose access to the `&Matches` produced by the `Route`'s matcher, and as consequence you lose the ability to conditionally render + +#### Both +If both a render prop and children are provided, they will both render, as long as the render function returns `Some`. +If it returns `None`, then neither will be displayed and the `Router` will continue to search for a target. + +#### Neither +If neither are provided, obviously nothing will be rendered, and the search for a target will continue. + +### Example +```rust +html! { + + () /> + + + + // Will never render. + () > // DModel will render above the EModel component. + + + +} +``` + + +## Ordering +Since you can create `Route`s that have matchers that can both match a given route string, you should put the more specific one above the more general one. +This ensures that the specific case has a chance to match first. + +Additionally, using `{*}` or `{*:name}` in the last `Route` is a good way to provide a default case. + +### Example +```rust +html! { + + () /> + () /> // will match any valid url that has 3 sections, and starts with `/a/` and is not `/a/specific/path` + () /> // Will never match + () /> // Will match anything that doesn't match above. + +} +``` \ No newline at end of file diff --git a/yew-router/examples/guide/chapters/03_route_macro.md b/yew-router/examples/guide/chapters/03_route_macro.md new file mode 100644 index 00000000000..c24f9ed8ee9 --- /dev/null +++ b/yew-router/examples/guide/chapters/03_route_macro.md @@ -0,0 +1,85 @@ +# route! macro + +## Terms +* matcher string - The string provided to the `route!` macro. This string has a special syntax that defines how it matches route strings. +* route string - The section of the URL containing some combination (not necessarily all) of path query and fragment. +* matcher - The struct produced by the `route!` macro. +* path - The part of the url containing characters separated by `/` characters. +* query - The part of the url started by a `?` character, containing sections in the form `this=that`, with additional sections taking the form `&this=that`. +* fragment - The part of the url started by a `#` and can contain unstructured text. +* **any** - A section delimited by `{}` and controls capturing or skipping characters. + * **capture** - An any section that contains a alphabetical identifier within ( eg. `{capture}`). That identifier is used as the key when storing captured sections in the `Matches`. + * `Matches` - An alias to `HashMap<&str, String>`. Captured sections of the route string are stored as values, with their keys being the names that appeared in the capture section. +* **optional** - Denotes a part of the route string that does not have to match. +* **literal** - A matching route string must these sections exactly. These are made up of text as well as special characters. +* special characters - ` /?&=#`, characters that are reserved for separating sections of the route string. +* flags - extra keywords you can specify after the matcher string that determine how it will the matcher will behave. + +## Description + +The `route!` macro is used to define a matcher for a `Route`. +It accepts a matcher string and a few optional flags that determine how the matcher behaves. +The matcher string has a specific syntax that the macro checks at compile time to make sure that you won't encounter an error at runtime when the `Router` fails to properly parse a malformed matcher string. + +You don't have to use the macro though. +You can opt to use the parser at runtime instead, or construct a vector of `MatcherTokens` yourself, although this isn't recommended. + + +The parser tries to ensure that these extensions to the URL produce tokens that can be used to match route strings in a predictable manner, and wont parse "improperly" formatted URLs. + +Examples of URLs that the parser attempts to avoid parsing successfully include: +* Instances of double slashes (`/route/to/a//thing`) +* Empty or incomplete queries (`/route?query=` or (`/route?query`) +* Missing queries (`/route?&query=yes`) +* Path sections not starting with a slash (`im/not/really/a/route`) + +To do this, the parser is made up of rules dictating where you can place any and optional sections. + +### Optional +The optional section, being relatively simple in its operation, is defined mostly by where you can and cannot place them. +* The router first attempts to parse a path, then the query, then the fragment. +Optional sections are not allowed to cross these boundaries. +* To avoid the parsing double slashes in the path section, optional sections have to start with a `/` and contain either a literal, any, or a nested _optional_ if they are in a path section, and can't come immediately after a `/`, nor can a `/` come after them. +In practice, this looks like: `/a/path/to(/match)` +* Optional sections within path sections can only appear at the end of path sections. +You can't have a literal part come after an optional part. + * This means that `/a/path/(/to)(/match)` is valid. + * So is `/a/path/(/to(/match))` is also valid. + * But `/a/path(/to)/match` is not. +* Optional sections within a query can take a few forms: + * `?(query=thing)` + * `?(query=thing)(query=another_thing)` + * `(?query=thing)` + * `?query=thing(&query=another_thing)` + * `?query=thing(&query=another_thing)(&query=another_thing)` +* Optional sections for fragments are generally pretty flexible + * `(#)` + * `#(anything_you_want_here_bud)` + * `(#anything_you_want_here_bud)` + +### Any +Parts delimited by `{}` can match multiple characters and will match up until the point where the parser can identify the next literal, or if one cannot be found, the end of the route string. + +They can appear anywhere in paths, even between non-`/` characters like `/a/p{}ath`. +They can appear in the right hand part of queries: `/path/?query={}`. +And can be interspersed anywhere in a fragment: `#frag{}ment{}`. + +* There are many types of `{}` sections. + * `{}` - This will match anything, but will be terminated by a special character `/` TODO are there other characters that can stop this matching? + * `{*}` - This will match anything and cannot be stopped by a `/`, only the next literal. This is useful for matching the whole route string. This and its named variant can appear at the beginning of the matching string. + * `{4}` - A pair of brackets containing a number will consume that many path sections before being terminated by a `/`. `{1}` is equivalent to `{}`. + * `{name}` - The named capture variant will match up to the next `/` or literal, and will add the string it captured to the `Matches` `HashMap` with the key being the specified string ("name" in this case). + * `{*:name}` - Will match anything up until the next specified literal and put the contents of its captures in the `Matches`. + * `{3:name}` - Will consume the specified number of path sections and add those contents to the `Matches`. + +### Flags + +* `CaseInsensitive` - This will make the literals specified in the matcher string match both the lower case and upper case variants of characters as they appear in the route string. +* `Strict` - By default, as part of an optimization step, an optional `/` is appended to the end of the path if it doesn't already have one. Setting this flag turns that off. +* `Incomplete` - By default, a route will not match unless the entire route string is matched. Enabling this flag allows the matcher to succeed as soon as all segments in the matcher are satisfied, without having to consume all of the route string. + + +## Note +The exact semantics and allowed syntax of the matcher string aren't fully nailed down yet. +It is likely to shift slightly over time. +If you find any inconsistencies between this document and the implementation, opening an issue in the YewRouter project would be appreciated. \ No newline at end of file diff --git a/yew-router/examples/guide/chapters/04_render.md b/yew-router/examples/guide/chapters/04_render.md new file mode 100644 index 00000000000..4762aa54ee5 --- /dev/null +++ b/yew-router/examples/guide/chapters/04_render.md @@ -0,0 +1,68 @@ +# Render + +The `render` prop in a `Route` takes a `Render` struct, which is just a wrapper around a `Fn(&Matches) -> Option>`. +The reason it returns an `Option` is that it allows for rejection of routes that have captured sections that can't meet restrictions that be easily expressed in a matcher string. + +### Example +```rust + +html! { + + + +} +``` + +## `render` function +The `render` function takes a function that implements `Fn(&Matches) -> Option>`, and all it does is wrap the provided function in a `Render` struct. + +### Example +```rust +let r: Render<()> = render(|_matches: &Matches| Some(html!{"Hello"})); +``` + +## `component` function +The `component` function is a way to create this `Render` wrapper by providing the type of the component you want to render as a type parameter. + +The only caveat to being able to use the `component` function, is that the `Properties` of the specified component must implement the `FromCaptures` trait. +`FromCaptures` mandates that you implement a function called `from_matches` which has a type signature of, `Fn(&Matches) -> Option`. +Code in `component` takes the props created from this function and creates a component using them. + +There exists a shortcut, though. +If you have a simple props made up only of types that implement `FromStr`, then you can derive the `FromCaptures`. + + +### Example +```rust +pub struct MyComponent; + +#[derive(FromCaptures, Properties)] +pub struct MyComponentProps; + +impl Component for MyComponent { + type Properties = MyComponentProps; + // ... +} + +// ... + +html! { + + () /> + +} +``` + +### Note +The derive functionality of `FromCaptures` is relatively basic. +It cannot handle `Option`s that you might want to populate based on optional matching sections (`()`). +It is recommended that you implement `FromCaptures` yourself for `Properties` structs that contain types that aren't automatically convertible from Strings. + + diff --git a/yew-router/examples/guide/chapters/05_testing.md b/yew-router/examples/guide/chapters/05_testing.md new file mode 100644 index 00000000000..a010433ca92 --- /dev/null +++ b/yew-router/examples/guide/chapters/05_testing.md @@ -0,0 +1,34 @@ +# Testing + +To make sure that your router works reliably, you will want to test your `FromCaptures` implementations, as well as the output of your `route!` macros. + + +## FromCaptures +Testing implementors of is simple enough. + +Just provide a `&Matches` (an alias of `HashMap<'str, String>`) to your prop's `from_matches()` method and test the expected results. + +### Example +```rust + +#[test] +fn creates_props() { + let mut captures: Captures = HashMap::new(); + captures.insert("key", "value"); + assert!(Props::from_matches(captures).is_some()) +} +``` + +## `route!` +Testing this is often less than ideal, since you often will want to keep the macro in-line with the `Route` so you have better readability. +The best solution at the moment is to just copy + paste the `route!` macros as you see them into the tests. + +### Example +```rust + +#[test] +fn matcher_rejects_unexpected_route() { + let matcher = route!("/a/b"); + matcher.match_path("/a/b").expect("should match"); + matcher.match_path("/a/c").expect("should reject"); +} \ No newline at end of file diff --git a/yew-router/examples/guide/src/guide.rs b/yew-router/examples/guide/src/guide.rs new file mode 100644 index 00000000000..193176d73a9 --- /dev/null +++ b/yew-router/examples/guide/src/guide.rs @@ -0,0 +1,117 @@ +use crate::{ + markdown_window::MarkdownWindow, + page::{Page, PageProps}, +}; +use yew::{html::ChildrenWithProps, prelude::*, virtual_dom::VNode, Properties}; +use yew_router::{agent::RouteRequest::GetCurrentRoute, matcher::RouteMatcher, prelude::*}; + +pub struct Guide { + router_agent: Box>, + route: Option, + props: GuideProps, +} + +#[derive(Properties, Clone)] +pub struct GuideProps { + children: ChildrenWithProps, +} + +pub enum Msg { + UpdateRoute(Route), +} + +impl Component for Guide { + type Message = Msg; + type Properties = GuideProps; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let callback = link.callback(Msg::UpdateRoute); + let router_agent = RouteAgent::bridge(callback); + Guide { + router_agent, + route: None, + props, + } + } + + fn mounted(&mut self) -> ShouldRender { + self.router_agent.send(GetCurrentRoute); + false + } + + fn update(&mut self, msg: Self::Message) -> bool { + match msg { + Msg::UpdateRoute(route) => { + self.route = Some(route); + } + } + true + } + + fn change(&mut self, _: Self::Properties) -> bool { + false + } + + fn view(&self) -> VNode { + if let Some(route) = &self.route { + let active_markdown_uri: Option = self + .props + .children + .iter() + .filter_map(|child| { + if child.props.page_url == route.to_string() { + Some(child.props.uri) + } else { + None + } + }) + .next(); + log::debug!("active uri: {:?}", active_markdown_uri); + + let list_items = self.props.children.iter().map(|child| { + let x = render_page_list_item(child.props, route); + if let yew::virtual_dom::VNode::VTag(x) = &x { + log::debug!("{:?}", x.attributes); + } + x + }); + + return html! { +
+
+
    + {for list_items} +
+
+
+ { + html !{ + + } + } +
+
+ }; + } else { + return html! {}; + } + } +} + +fn render_page_list_item(props: PageProps, route: &Route) -> Html { + let pm: RouteMatcher = RouteMatcher::try_from(&props.page_url).unwrap(); + if pm.capture_route_into_map(&route.to_string()).is_ok() { + log::debug!("Found an active"); + return html! { +
  • + route=props.page_url.clone()> {&props.title} > +
  • + }; + } else { + return html! { +
  • + route=props.page_url.clone()> {&props.title} > +
  • + }; + } +} diff --git a/yew-router/examples/guide/src/lib.rs b/yew-router/examples/guide/src/lib.rs new file mode 100644 index 00000000000..a01db7871bc --- /dev/null +++ b/yew-router/examples/guide/src/lib.rs @@ -0,0 +1,9 @@ +mod guide; +mod markdown; +mod markdown_window; +mod page; + +pub use crate::{ + guide::{Guide, GuideProps}, + page::{Page, PageProps}, +}; diff --git a/yew-router/examples/guide/src/main.rs b/yew-router/examples/guide/src/main.rs new file mode 100644 index 00000000000..195d281ea49 --- /dev/null +++ b/yew-router/examples/guide/src/main.rs @@ -0,0 +1,62 @@ +use yew::prelude::*; + +use guide::{Guide, Page}; +use yew::virtual_dom::VNode; + +fn main() { + yew::initialize(); + // web_logger::init(); + App::::new().mount_to_body(); + yew::run_loop(); +} + +pub struct Model; + +impl Component for Model { + type Message = (); + type Properties = (); + + fn create(_props: Self::Properties, _link: ComponentLink) -> Self { + Model + } + + fn update(&mut self, _msg: Self::Message) -> bool { + false + } + + fn change(&mut self, _: Self::Properties) -> bool { + false + } + + fn view(&self) -> VNode { + html! { + + + + + + + + } + } +} diff --git a/yew-router/examples/guide/src/markdown.rs b/yew-router/examples/guide/src/markdown.rs new file mode 100644 index 00000000000..b65c0fa6a00 --- /dev/null +++ b/yew-router/examples/guide/src/markdown.rs @@ -0,0 +1,165 @@ +/// Original author of this code is [Nathan Ringo](https://github.com/remexre) +/// Source: https://github.com/acmumn/mentoring/blob/master/web-client/src/view/markdown.rs +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag}; +use yew::{ + html, + virtual_dom::{VNode, VTag, VText}, + Html, +}; + +/// Renders a string of Markdown to HTML with the default options (footnotes +/// disabled, tables enabled). +pub fn render_markdown(src: &str) -> Html { + let mut elems = vec![]; + let mut spine = vec![]; + + macro_rules! add_child { + ($child:expr) => {{ + let l = spine.len(); + assert_ne!(l, 0); + spine[l - 1].add_child($child); + }}; + } + + let options = Options::ENABLE_TABLES; + + for ev in Parser::new_ext(src, options) { + match ev { + Event::Start(tag) => { + spine.push(make_tag(tag)); + } + Event::End(tag) => { + // TODO Verify stack end. + let l = spine.len(); + assert!(l >= 1); + let mut top = spine.pop().unwrap(); + if let Tag::CodeBlock(_) = tag { + let mut pre = VTag::new("pre"); + pre.add_child(top.into()); + top = pre; + } else if let Tag::Table(aligns) = tag { + for r in top.children.iter_mut() { + if let VNode::VTag(ref mut vtag) = *r { + for (i, c) in vtag.children.iter_mut().enumerate() { + if let VNode::VTag(ref mut vtag) = *c { + match aligns[i] { + Alignment::None => {} + Alignment::Left => vtag.add_class("text-left"), + Alignment::Center => vtag.add_class("text-center"), + Alignment::Right => vtag.add_class("text-right"), + } + } + } + } + } + } else if let Tag::TableHead = tag { + for c in top.children.iter_mut() { + if let VNode::VTag(ref mut vtag) = *c { + // TODO + // vtag.tag = "th".into(); + vtag.add_attribute("scope", &"col"); + } + } + } + if l == 1 { + elems.push(top); + } else { + spine[l - 2].add_child(top.into()); + } + } + Event::Text(text) => add_child!(VText::new(text.to_string()).into()), + Event::SoftBreak => add_child!(VText::new("\n".to_string()).into()), + Event::HardBreak => add_child!(VTag::new("br").into()), + Event::Code(code) => { + let mut c = VTag::new("code"); + c.add_child(VText::new(code.to_string()).into()); + add_child!(c.into()); + } + _ => println!("Unknown event: {:#?}", ev), + } + } + + if elems.len() == 1 { + VNode::VTag(Box::new(elems.pop().unwrap())) + } else { + html! { +
    { for elems.into_iter() }
    + } + } +} + +fn make_tag(t: Tag) -> VTag { + match t { + Tag::Paragraph => VTag::new("p"), + Tag::BlockQuote => { + let mut el = VTag::new("blockquote"); + el.add_class("blockquote"); + el + } + Tag::CodeBlock(lang) => { + let mut el = VTag::new("code"); + // Different color schemes may be used for different code blocks, + // but a different library (likely js based at the moment) would be necessary to + // actually provide the highlighting support by locating the language + // classes and applying dom transforms on their contents. + match lang.as_ref() { + "html" => el.add_class("html-language"), + "rust" => el.add_class("rust-language"), + "java" => el.add_class("java-language"), + "c" => el.add_class("c-language"), + _ => {} // Add your own language highlighting support + }; + el + } + Tag::List(None) => VTag::new("ul"), + Tag::List(Some(1)) => VTag::new("ol"), + Tag::List(Some(ref start)) => { + let mut el = VTag::new("ol"); + el.add_attribute("start", start); + el + } + Tag::Item => VTag::new("li"), + Tag::Table(_) => { + let mut el = VTag::new("table"); + el.add_class("table"); + el + } + Tag::TableHead => VTag::new("tr"), + Tag::TableRow => VTag::new("tr"), + Tag::TableCell => VTag::new("td"), + Tag::Emphasis => { + let mut el = VTag::new("span"); + el.add_class("font-italic"); + el + } + Tag::Strong => { + let mut el = VTag::new("span"); + el.add_class("font-weight-bold"); + el + } + Tag::Link(_lt, ref href, ref title) => { + let mut el = VTag::new("a"); + el.add_attribute("href", href); + if title.as_ref() != "" { + el.add_attribute("title", title); + } + el + } + Tag::Image(_lt, ref src, ref title) => { + let mut el = VTag::new("img"); + el.add_attribute("src", src); + if title.as_ref() != "" { + el.add_attribute("title", title); + } + el + } + + Tag::FootnoteDefinition(ref _footnote_id) => VTag::new("span"), + Tag::Strikethrough => VTag::new("strike"), + Tag::Heading(n) => { + assert!(n > 0); + assert!(n < 7); + VTag::new(format!("h{}", n)) + } + } +} diff --git a/yew-router/examples/guide/src/markdown_window.rs b/yew-router/examples/guide/src/markdown_window.rs new file mode 100644 index 00000000000..16132bf0c37 --- /dev/null +++ b/yew-router/examples/guide/src/markdown_window.rs @@ -0,0 +1,92 @@ +use crate::markdown::render_markdown; +use yew::{ + format::{Nothing, Text}, + prelude::*, + services::{ + fetch::{FetchTask, Request, Response}, + FetchService, + }, + virtual_dom::VNode, +}; + +pub struct MarkdownWindow { + fetch_service: FetchService, + fetch_task: Option, + markdown: Option, + props: MdProps, + link: ComponentLink, +} + +#[derive(Properties, Debug, Clone)] +pub struct MdProps { + pub uri: Option, +} + +pub enum Msg { + MarkdownArrived(String), + MarkdownFetchFailed, +} + +impl Component for MarkdownWindow { + type Message = Msg; + type Properties = MdProps; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + MarkdownWindow { + fetch_service: FetchService::new(), + fetch_task: None, + markdown: None, + props, + link, + } + } + + fn mounted(&mut self) -> ShouldRender { + false + } + + fn update(&mut self, msg: Self::Message) -> bool { + match msg { + Msg::MarkdownArrived(md) => { + log::info!("fetching markdown succeeded"); + self.markdown = Some(md) + } + Msg::MarkdownFetchFailed => log::error!("fetching markdown failed"), + } + true + } + + fn change(&mut self, props: Self::Properties) -> bool { + log::trace!("Change props: {:?}", props); + self.props = props; + self.try_fetch_markdown(); + true + } + + fn view(&self) -> VNode { + if let Some(md) = &self.markdown { + html! { + render_markdown(md) + } + } else { + html! {} + } + } +} + +impl MarkdownWindow { + fn try_fetch_markdown(&mut self) { + if let Some(uri) = &self.props.uri { + log::info!("Getting new markdown"); + let request = Request::get(uri).body(Nothing).unwrap(); + let callback = self.link.callback(|response: Response| { + log::info!("Got response"); + match response.body() { + Ok(text) => Msg::MarkdownArrived(text.clone()), + _ => Msg::MarkdownFetchFailed, + } + }); + self.fetch_task = self.fetch_service.fetch(request, callback).ok(); + } + } +} diff --git a/yew-router/examples/guide/src/page.rs b/yew-router/examples/guide/src/page.rs new file mode 100644 index 00000000000..807aad4ba08 --- /dev/null +++ b/yew-router/examples/guide/src/page.rs @@ -0,0 +1,31 @@ +use yew::{prelude::*, virtual_dom::VNode}; + +pub struct Page; + +#[derive(Properties, Clone)] +pub struct PageProps { + pub uri: String, + pub page_url: String, + pub title: String, +} + +impl Component for Page { + type Message = (); + type Properties = PageProps; + + fn create(_props: Self::Properties, _link: ComponentLink) -> Self { + Page + } + + fn update(&mut self, _msg: Self::Message) -> bool { + false + } + + fn change(&mut self, _: Self::Properties) -> bool { + false + } + + fn view(&self) -> VNode { + unimplemented!() + } +} diff --git a/yew-router/examples/guide/static/index.html b/yew-router/examples/guide/static/index.html new file mode 100644 index 00000000000..6ceb168821b --- /dev/null +++ b/yew-router/examples/guide/static/index.html @@ -0,0 +1,29 @@ + + + + Yew Router Guide + + + + + + + + + + + diff --git a/yew-router/examples/minimal/Cargo.toml b/yew-router/examples/minimal/Cargo.toml new file mode 100644 index 00000000000..10fb277405d --- /dev/null +++ b/yew-router/examples/minimal/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "minimal-router" +version = "0.1.0" +authors = ["Henry Zimmerman "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +yew = { path = "../../../yew" } +yew-router = { path = "../.." } +web_logger = "0.2" +log = "0.4.8" +wee_alloc = "0.4.5" diff --git a/yew-router/examples/minimal/README.md b/yew-router/examples/minimal/README.md new file mode 100644 index 00000000000..4ff2befdc27 --- /dev/null +++ b/yew-router/examples/minimal/README.md @@ -0,0 +1,9 @@ +# Minimal Example + +This example shows how to use this library with only the "service" feature turned on. +Without most of the features, you lack the `Router` component and `RouteAgent` and its associated bridges and dispatchers. +This means that you must use the `RouteService` to interface with the browser to handle route changes. + +Removing the `Router` component means that you have to deal with the `RouteService` directly and propagate change route messages up to the component that contains the `RouteService`. + +The unit type aliases part of the prelude are not included without any features. You may want to turn that back for actual use. \ No newline at end of file diff --git a/yew-router/examples/minimal/src/main.rs b/yew-router/examples/minimal/src/main.rs new file mode 100644 index 00000000000..4c7aeb1e42a --- /dev/null +++ b/yew-router/examples/minimal/src/main.rs @@ -0,0 +1,109 @@ +#![recursion_limit = "256"] +use yew::prelude::*; + +use yew::virtual_dom::VNode; +use yew_router::{route::Route, service::RouteService, Switch}; + +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +fn main() { + yew::initialize(); + web_logger::init(); + App::::new().mount_to_body(); + yew::run_loop(); +} + +pub struct Model { + route_service: RouteService<()>, + route: Route<()>, + link: ComponentLink, +} + +pub enum Msg { + RouteChanged(Route<()>), + ChangeRoute(AppRoute), +} + +#[derive(Debug, Switch, Clone)] +pub enum AppRoute { + #[to = "/a/{anything}"] + A(String), + #[to = "/b/{anything}/{number}"] + B { anything: String, number: u32 }, + #[to = "/c"] + C, +} + +impl Model { + fn change_route(&self, app_route: AppRoute) -> Callback { + self.link.callback(move |_| { + let route = app_route.clone(); // TODO, I don't think I should have to clone here? + Msg::ChangeRoute(route) + }) + } +} + +impl Component for Model { + type Message = Msg; + type Properties = (); + + fn create(_: Self::Properties, link: ComponentLink) -> Self { + let mut route_service: RouteService<()> = RouteService::new(); + let route = route_service.get_route(); + let callback = link.callback(Msg::RouteChanged); + route_service.register_callback(callback); + + Model { + route_service, + route, + link, + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::RouteChanged(route) => self.route = route, + Msg::ChangeRoute(route) => { + // This might be derived in the future + let route_string = match route { + AppRoute::A(s) => format!("/a/{}", s), + AppRoute::B { anything, number } => format!("/b/{}/{}", anything, number), + AppRoute::C => "/c".to_string(), + }; + self.route_service.set_route(&route_string, ()); + self.route = Route { + route: route_string, + state: (), + }; + } + } + true + } + + fn change(&mut self, _: Self::Properties) -> ShouldRender { + false + } + + fn view(&self) -> VNode { + html! { +
    + +
    + { + match AppRoute::switch(self.route.clone()) { + Some(AppRoute::A(thing)) => VNode::from(thing.as_str()), + Some(AppRoute::B{anything, number}) => html!{
    {anything} {number}
    }, + Some(AppRoute::C) => VNode::from("C"), + None => VNode::from("404") + } + } +
    +
    + } + } +} diff --git a/yew-router/examples/router_component/Cargo.toml b/yew-router/examples/router_component/Cargo.toml new file mode 100644 index 00000000000..d7fcba27337 --- /dev/null +++ b/yew-router/examples/router_component/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "router_component" +version = "0.1.0" +authors = ["Henry Zimmerman "] + +edition="2018" + +[dependencies] +yew = { path = "../../../yew" } +yew-router = {path = "../.." } +web_logger = "0.2" +log = "0.4.8" +wee_alloc = "0.4.5" \ No newline at end of file diff --git a/yew-router/examples/router_component/src/a_component.rs b/yew-router/examples/router_component/src/a_component.rs new file mode 100644 index 00000000000..e67e035d1de --- /dev/null +++ b/yew-router/examples/router_component/src/a_component.rs @@ -0,0 +1,55 @@ +use crate::{c_component::CModel, ARoute, AppRoute}; +use yew::{prelude::*, virtual_dom::VNode, Properties}; +use yew_router::{prelude::*, switch::AllowMissing}; + +pub struct AModel { + props: Props, +} + +#[derive(Clone, PartialEq, Properties)] +pub struct Props { + pub route: Option, +} + +pub enum Msg {} + +impl Component for AModel { + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, _link: ComponentLink) -> Self { + AModel { props } + } + + fn update(&mut self, _msg: Self::Message) -> ShouldRender { + true + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props = props; + true + } + + fn view(&self) -> VNode { + html! { +
    + { "I am the A component" } +
    + + route=AppRoute::A(AllowMissing(Some(ARoute))) + /> + // {"Go to a/c"} + // > +
    +
    + { + match self.props.route { + Some(_) => html!{}, + None => html!{} + } + } +
    +
    + } + } +} diff --git a/yew-router/examples/router_component/src/b_component.rs b/yew-router/examples/router_component/src/b_component.rs new file mode 100644 index 00000000000..9159a71133a --- /dev/null +++ b/yew-router/examples/router_component/src/b_component.rs @@ -0,0 +1,179 @@ +use std::usize; +use yew::{prelude::*, virtual_dom::VNode, Properties}; +use yew_router::{agent::RouteRequest, prelude::*}; + +pub struct BModel { + props: Props, + router: Box>, + increment: Callback, + decrement: Callback, + update_subpath: Callback, +} + +#[derive(Clone, PartialEq, Properties)] +pub struct Props { + pub number: Option, + pub sub_path: Option, +} + +#[derive(Debug, Switch, Clone)] +pub enum BRoute { + #[to = "/{num}?sup_path={sub_path}"] + Both(usize, String), + #[to = "/{num}"] + NumOnly(usize), + #[to = "?sub_path={sub_path}"] + SubPathOnly(String), + #[to = "/"] + None, +} + +impl Into for BRoute { + fn into(self) -> Props { + match self { + BRoute::None => Props { + number: None, + sub_path: None, + }, + BRoute::NumOnly(number) => Props { + number: Some(number), + sub_path: None, + }, + BRoute::Both(number, sub_path) => Props { + number: Some(number), + sub_path: Some(sub_path), + }, + BRoute::SubPathOnly(sub_path) => Props { + number: None, + sub_path: Some(sub_path), + }, + } + } +} + +pub enum Msg { + Navigate(Vec), // Navigate after performing other actions + Increment, + Decrement, + UpdateSubpath(String), + NoOp, +} + +impl Component for BModel { + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let callback = link.callback(|_| Msg::NoOp); // TODO use a dispatcher instead. + let router = RouteAgent::bridge(callback); + + BModel { + props, + router, + increment: link.callback(|_| Msg::Navigate(vec![Msg::Increment])), + decrement: link.callback(|_| Msg::Navigate(vec![Msg::Decrement])), + update_subpath: link + .callback(|e: InputData| Msg::Navigate(vec![Msg::UpdateSubpath(e.value)])), + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::Navigate(msgs) => { + // Perform the wrapped updates first + for msg in msgs { + self.update(msg); + } + + // The path dictating that this component be instantiated must be provided + let route_string = "/b".to_string(); + let route_string = match &self.props.sub_path { + Some(sub_path) => route_string + "?sub_path=" + &sub_path, + None => route_string, + }; + let route_string = match &self.props.number.map(|x: usize| x.to_string()) { + Some(number) => route_string + "#" + &number, + None => route_string, + }; + + let route = Route::from(route_string); + + // Don't tell the router to alert its subscribers, + // because the changes made here only affect the current component, + // so mutation might as well be contained to the core component update loop + // instead of being sent through the router. + self.router + .send(RouteRequest::ChangeRouteNoBroadcast(route)); + true + } + Msg::NoOp => false, + Msg::Increment => { + let n = if let Some(number) = self.props.number { + number + 1 + } else { + 1 + }; + self.props.number = Some(n); + true + } + Msg::Decrement => { + let n: usize = if let Some(number) = self.props.number { + if number > 0 { + number - 1 + } else { + number + } + } else { + 0 + }; + self.props.number = Some(n); + true + } + Msg::UpdateSubpath(path) => { + self.props.sub_path = Some(path); + true + } + } + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props = props; + true + } + + fn view(&self) -> VNode { + html! { +
    +
    + { self.display_number() } + + +
    + + { self.display_subpath_input() } + +
    + } + } +} + +impl BModel { + fn display_number(&self) -> String { + if let Some(number) = self.props.number { + format!("Number: {}", number) + } else { + "Number: None".to_string() + } + } + + fn display_subpath_input(&self) -> Html { + let sub_path = self.props.sub_path.clone(); + html! { + + } + } +} diff --git a/yew-router/examples/router_component/src/c_component.rs b/yew-router/examples/router_component/src/c_component.rs new file mode 100644 index 00000000000..c88f1d90089 --- /dev/null +++ b/yew-router/examples/router_component/src/c_component.rs @@ -0,0 +1,33 @@ +use yew::{prelude::*, virtual_dom::VNode, Properties}; + +pub struct CModel; + +#[derive(Clone, PartialEq, Properties)] +pub struct Props {} + +pub enum Msg {} + +impl Component for CModel { + type Message = Msg; + type Properties = Props; + + fn create(_props: Self::Properties, _link: ComponentLink) -> Self { + CModel + } + + fn update(&mut self, _msg: Self::Message) -> ShouldRender { + false + } + + fn change(&mut self, _props: Self::Properties) -> ShouldRender { + true + } + + fn view(&self) -> VNode { + html! { +
    + {" I am the C component"} +
    + } + } +} diff --git a/yew-router/examples/router_component/src/main.rs b/yew-router/examples/router_component/src/main.rs new file mode 100644 index 00000000000..e9b4751f2dd --- /dev/null +++ b/yew-router/examples/router_component/src/main.rs @@ -0,0 +1,99 @@ +#![recursion_limit = "1024"] +mod a_component; +mod b_component; +mod c_component; + +use yew::prelude::*; + +use yew_router::{prelude::*, Switch}; + +use crate::{ + a_component::AModel, + b_component::{BModel, BRoute}, + c_component::CModel, +}; +use yew::virtual_dom::VNode; +use yew_router::switch::{AllowMissing, Permissive}; + +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +fn main() { + yew::initialize(); + web_logger::init(); + App::::new().mount_to_body(); + yew::run_loop(); +} + +pub struct Model {} + +impl Component for Model { + type Message = (); + type Properties = (); + + fn create(_: Self::Properties, _link: ComponentLink) -> Self { + Model {} + } + + fn update(&mut self, _msg: Self::Message) -> ShouldRender { + false + } + + fn change(&mut self, _props: Self::Properties) -> ShouldRender { + false + } + + fn view(&self) -> VNode { + html! { +
    + +
    + + render = Router::render(|switch: AppRoute| { + match switch { + AppRoute::A(AllowMissing(route)) => html!{}, + AppRoute::B(route) => { + let route: b_component::Props = route.into(); + html!{} + }, + AppRoute::C => html!{}, + AppRoute::E(string) => html!{format!("hello {}", string)}, + AppRoute::PageNotFound(Permissive(None)) => html!{"Page not found"}, + AppRoute::PageNotFound(Permissive(Some(missed_route))) => html!{format!("Page '{}' not found", missed_route)} + } + }) + redirect = Router::redirect(|route: Route| { + AppRoute::PageNotFound(Permissive(Some(route.route))) + }) + /> +
    +
    + } + } +} + +#[derive(Debug, Switch, Clone)] +pub enum AppRoute { + #[to = "/a{*:inner}"] + A(AllowMissing), + #[to = "/b{*:inner}"] + B(BRoute), + #[to = "/c"] + C, + #[to = "/e/{string}"] + E(String), + #[to = "/page-not-found"] + PageNotFound(Permissive), +} + +#[derive(Debug, Switch, PartialEq, Clone, Copy)] +#[to = "/c"] +pub struct ARoute; diff --git a/yew-router/examples/servers/Readme.md b/yew-router/examples/servers/Readme.md new file mode 100644 index 00000000000..810bf2f51ba --- /dev/null +++ b/yew-router/examples/servers/Readme.md @@ -0,0 +1,34 @@ +# Example Servers + +These servers allow you to serve your application from any non-api route. +This should prevent you from seeing 404 errors when refreshing your app after changing the route. + +The servers rely on having the project already deployed by cargo-web, with some modifications to the output to ensure correctness. + +## Instructions + +Run `cargo web deploy` from the `/examples/router_component` folder to build the project that will be served by a server here. +Then, navigate to the `/target/deploy/` directory and run: +```sh +sed -i 's/router_component.wasm/\/router_component.wasm/g' router_component.js +``` +and +```sh +sed -i 's/router_component.js/\/router_component.js/g' index.html +``` +If these commands aren't ran, then the server will serve the wrong files when it tries to get a compound path. +For example, if you request "/some/path" because it will serve `index.html`'s content, but then the request for the "router_component.js" it makes will attempt to get it from "/some/router_component.js", which doesn't exist. +These replacements make the browser get the file from the absolute path, so it will always get the correct item. + +Then go to your chosen server and run `cargo run` to start it. + +## As a template + +You can use these as templates for your server, or incorporate them into an existing server. +You will likely have to change the assets dir constant to point elsewhere though. +```rust + const ASSETS_DIR: &str = "your/assets/dir/here"; +``` + +### Non-cargo-web +The instructions for using `sed` and `cargo-web` above are irrelevant if you use these server templates for apps built with parcel or webpack. diff --git a/yew-router/examples/servers/actix/Cargo.toml b/yew-router/examples/servers/actix/Cargo.toml new file mode 100644 index 00000000000..6073d8083fe --- /dev/null +++ b/yew-router/examples/servers/actix/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "actix" +version = "0.1.0" +authors = ["tarkah "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "2.0.0" +actix-files = "0.2.1" +actix-rt = "1.0.0" +env_logger = "0.7" \ No newline at end of file diff --git a/yew-router/examples/servers/actix/src/main.rs b/yew-router/examples/servers/actix/src/main.rs new file mode 100644 index 00000000000..23c89c1ed48 --- /dev/null +++ b/yew-router/examples/servers/actix/src/main.rs @@ -0,0 +1,53 @@ +use actix_files::NamedFile; +use actix_web::{get, middleware, web, App, Error, HttpResponse, HttpServer}; + +// You will need to change this if you use this as a template for your application. +const ASSETS_DIR: &str = "../../../target/deploy"; + +#[get("/api")] +async fn api_404() -> HttpResponse { + HttpResponse::NotFound().finish() +} + +#[get("/api/{unconfigured_routes:.*}")] +async fn api_404_unconfigured() -> HttpResponse { + HttpResponse::NotFound().finish() +} + +#[get("/api/hello/{name}")] +async fn api_hello(name: web::Path) -> HttpResponse { + HttpResponse::Ok().body(name.into_inner()) +} + +async fn serve_index_html() -> Result { + const INDEX_HTML: &str = "index.html"; + let index_file = format!("{}/{}", ASSETS_DIR, INDEX_HTML); + + Ok(NamedFile::open(index_file)?) +} + +#[actix_rt::main] +async fn main() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info"); + env_logger::init(); + + let localhost: &str = "0.0.0.0"; + let port: u16 = 8000; + let addr = (localhost, port); + + HttpServer::new(move || { + App::new() + .wrap(middleware::Logger::default()) + .service(api_404) + .service(api_hello) + // Important this comes last so all configured api routes will match + // before this catch all + .service(api_404_unconfigured) + .service(actix_files::Files::new("/", ASSETS_DIR).index_file("index.html")) + .default_service(web::get().to(serve_index_html)) + }) + .bind(addr)? + .workers(1) + .run() + .await +} diff --git a/yew-router/examples/servers/warp/Cargo.toml b/yew-router/examples/servers/warp/Cargo.toml new file mode 100644 index 00000000000..883f2454832 --- /dev/null +++ b/yew-router/examples/servers/warp/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "warp" +version = "0.1.0" +authors = ["Henry Zimmerman "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +warp = "0.1.20" diff --git a/yew-router/examples/servers/warp/src/main.rs b/yew-router/examples/servers/warp/src/main.rs new file mode 100644 index 00000000000..385eed2db03 --- /dev/null +++ b/yew-router/examples/servers/warp/src/main.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +#[rustfmt::skip] +use warp::{ + filters::BoxedFilter, + fs::File, + path::Peek, + path, + Filter, Reply, +}; + +fn main() { + let localhost = [0, 0, 0, 0]; + let port = 8000; + let addr = (localhost, port); + + // You will need to change this if you use this as a template for your application. + + const ASSETS_DIR: &str = "../../../target/deploy"; + let assets_dir: PathBuf = PathBuf::from(ASSETS_DIR); + + let routes = api().or(static_files_handler(assets_dir)); + + warp::serve(routes).run(addr); +} + +const API_STRING: &str = "api"; + +pub fn api() -> BoxedFilter<(impl Reply,)> { + warp::path(API_STRING) + .and(path!(String)) + .and(warp::get2()) + .map(std::convert::identity) // Echos the string back in the response body + .boxed() +} + +/// Expose filters that work with static files +pub fn static_files_handler(assets_dir: PathBuf) -> BoxedFilter<(impl Reply,)> { + const INDEX_HTML: &str = "index.html"; + + let files = + assets(assets_dir.clone()).or(index_static_file_redirect(assets_dir.join(INDEX_HTML))); + + warp::any().and(files).boxed() +} + +/// If the path does not start with /api, return the index.html, so the app will bootstrap itself +/// regardless of whatever the frontend-specific path is. +fn index_static_file_redirect(index_file_path: PathBuf) -> BoxedFilter<(impl Reply,)> { + warp::get2() + .and(warp::path::peek()) + .and(warp::fs::file(index_file_path)) + .and_then(|segments: Peek, file: File| { + // Reject the request if the path starts with /api/ + if let Some(first_segment) = segments.segments().next() { + if first_segment == API_STRING { + return Err(warp::reject::not_found()); + } + } + Ok(file) + }) + .boxed() +} + +/// Gets the file within the specified dir. +fn assets(dir_path: PathBuf) -> BoxedFilter<(impl Reply,)> { + warp::get2() + .and(warp::fs::dir(dir_path)) + .and(warp::path::end()) + .boxed() +} diff --git a/yew-router/examples/switch/Cargo.toml b/yew-router/examples/switch/Cargo.toml new file mode 100644 index 00000000000..c41031c7510 --- /dev/null +++ b/yew-router/examples/switch/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "switch" +version = "0.1.0" +authors = ["Henry Zimmerman "] +edition = "2018" + +[dependencies] +yew-router = { path = "../..", default-features = false, features = ["web_sys"] } diff --git a/yew-router/examples/switch/src/main.rs b/yew-router/examples/switch/src/main.rs new file mode 100644 index 00000000000..2f7a2526e5e --- /dev/null +++ b/yew-router/examples/switch/src/main.rs @@ -0,0 +1,109 @@ +use yew_router::{route::Route, switch::Permissive, Switch}; + +fn main() { + let route = Route::new_no_state("/some/route"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/some/thing/other"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/another/other"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/inner/left"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/yeet"); // should not match + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/single/32"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/othersingle/472"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let route = Route::new_no_state("/option/test"); + let app_route = AppRoute::switch(route); + dbg!(app_route); + + let mut buf = String::new(); + AppRoute::Another("yeet".to_string()).build_route_section::<()>(&mut buf); + println!("{}", buf); + + let mut buf = String::new(); + AppRoute::Something { + thing: "yeet".to_string(), + other: "yote".to_string(), + } + .build_route_section::<()>(&mut buf); + println!("{}", buf); + + let mut buf = String::new(); + OtherSingle(23).build_route_section::<()>(&mut buf); + println!("{}", buf); +} + +#[derive(Debug, Switch, Clone)] +pub enum AppRoute { + #[to = "/some/route"] + SomeRoute, + #[to = "/some/{thing}/{other}"] + // If you have a variant with named fields, the field names should appear in the matcher string. + Something { thing: String, other: String }, + #[to = "/another/{}"] // Tuple-enums don't need names in the capture groups. + Another(String), + #[to = "/doot/{}/{something}"] + // You can still puts names in the capture groups to improve readability. + Yeet(String, String), + #[to = "/inner"] + #[rest] // same as /inner{*} + Nested(InnerRoute), + #[rest] // Rest delegates the remaining input to the next attribute + Single(Single), + #[rest] + OtherSingle(OtherSingle), + /// Because this is permissive, the inner item doesn't have to match. + #[to = "/option/{}"] + Optional(Permissive), + /// Because this is permissive, a corresponding capture group doesn't need to exist + #[to = "/missing/capture"] + MissingCapture(Permissive), +} + +#[derive(Switch, Debug, Clone)] +pub enum InnerRoute { + #[to = "/left"] + Left, + #[to = "/right"] + Right, +} + +#[derive(Switch, Debug, Clone)] +#[to = "/single/{number}"] +pub struct Single { + number: u32, +} + +#[derive(Switch, Debug, Clone)] +#[to = "/othersingle/{number}"] +pub struct OtherSingle(u32); + +//#[derive(Switch, Debug)] +// pub enum Bad { +// #[to = "/bad_route/{hello}"] +// X, +//} + +#[derive(Switch, Debug, Clone)] +#[to = "{*:path}#{route}"] +pub struct FragmentAdapter { + path: String, + route: W, +} diff --git a/yew-router/src/agent/bridge.rs b/yew-router/src/agent/bridge.rs new file mode 100644 index 00000000000..4f3253af8ab --- /dev/null +++ b/yew-router/src/agent/bridge.rs @@ -0,0 +1,56 @@ +//! Bridge to RouteAgent. +use crate::{agent::RouteAgent, route::Route, RouteState}; +use std::{ + fmt::{Debug, Error as FmtError, Formatter}, + ops::{Deref, DerefMut}, +}; +use yew::{ + agent::{Bridged, Context}, + Bridge, Callback, +}; + +/// A wrapped bridge to the route agent. +/// +/// A component that owns this can send and receive messages from the agent. +pub struct RouteAgentBridge(Box>>) +where + STATE: RouteState; + +impl RouteAgentBridge +where + STATE: RouteState, +{ + /// Creates a new bridge. + pub fn new(callback: Callback>) -> Self { + let router_agent = RouteAgent::bridge(callback); + RouteAgentBridge(router_agent) + } + + /// Experimental, may be removed + /// + /// Directly spawn a new Router + pub fn spawn(callback: Callback>) -> Self { + use yew::agent::Discoverer; + let router_agent = Context::spawn_or_join(Some(callback)); + RouteAgentBridge(router_agent) + } +} + +impl Debug for RouteAgentBridge { + fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> { + f.debug_tuple("RouteAgentBridge").finish() + } +} + +impl Deref for RouteAgentBridge { + type Target = Box>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for RouteAgentBridge { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/yew-router/src/agent/dispatcher.rs b/yew-router/src/agent/dispatcher.rs new file mode 100644 index 00000000000..bb67a9a5a56 --- /dev/null +++ b/yew-router/src/agent/dispatcher.rs @@ -0,0 +1,53 @@ +//! Dispatcher to RouteAgent. +use crate::{agent::RouteAgent, RouteState}; +use std::{ + fmt::{Debug, Error as FmtError, Formatter}, + ops::{Deref, DerefMut}, +}; +use yew::agent::{Dispatched, Dispatcher}; + +/// A wrapped dispatcher to the route agent. +/// +/// A component that owns and instance of this can send messages to the RouteAgent, but not receive them. +pub struct RouteAgentDispatcher(Dispatcher>) +where + STATE: RouteState; + +impl RouteAgentDispatcher +where + STATE: RouteState, +{ + /// Creates a new bridge. + pub fn new() -> Self { + let dispatcher = RouteAgent::dispatcher(); + RouteAgentDispatcher(dispatcher) + } +} + +impl Default for RouteAgentDispatcher +where + STATE: RouteState, +{ + fn default() -> Self { + Self::new() + } +} + +impl Debug for RouteAgentDispatcher { + fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> { + f.debug_tuple("RouteAgentDispatcher").finish() + } +} + +impl Deref for RouteAgentDispatcher { + type Target = Dispatcher>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for RouteAgentDispatcher { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/yew-router/src/agent/mod.rs b/yew-router/src/agent/mod.rs new file mode 100644 index 00000000000..73699221122 --- /dev/null +++ b/yew-router/src/agent/mod.rs @@ -0,0 +1,158 @@ +//! Routing agent. +//! +//! It wraps a route service and allows calls to be sent to it to update every subscriber, +//! or just the element that made the request. +use crate::service::RouteService; + +use yew::prelude::worker::*; + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Error as FmtError, Formatter}; + +use crate::route::{Route, RouteState}; +use log::trace; + +mod bridge; +pub use bridge::RouteAgentBridge; + +mod dispatcher; +pub use dispatcher::RouteAgentDispatcher; + +/// Internal Message used for the RouteAgent. +#[derive(Debug)] +pub enum Msg { + /// Message for when the route is changed. + BrowserNavigationRouteChanged(Route), // TODO make this a route? +} + +/// Input message type for interacting with the `RouteAgent'. +#[derive(Serialize, Deserialize, Debug)] +pub enum RouteRequest { + /// Replaces the most recent Route with a new one and alerts connected components to the route + /// change. + ReplaceRoute(Route), + /// Replaces the most recent Route with a new one, but does not alert connected components to + /// the route change. + ReplaceRouteNoBroadcast(Route), + /// Changes the route using a Route struct and alerts connected components to the route change. + ChangeRoute(Route), + /// Changes the route using a Route struct, but does not alert connected components to the + /// route change. + ChangeRouteNoBroadcast(Route), + /// Gets the current route. + GetCurrentRoute, +} + +/// The RouteAgent holds on to the RouteService singleton and mediates access to it. +/// +/// It serves as a means to propagate messages to components interested in the state of the current +/// route. +/// +/// # Warning +/// All routing-related components/agents/services should use the same type parameter across your application. +/// +/// If you use multiple agents with different types, then the Agents won't be able to communicate to +/// each other and associated components may not work as intended. +pub struct RouteAgent +where + STATE: RouteState, +{ + // In order to have the AgentLink below, apparently T must be constrained like this. + // Unfortunately, this means that everything related to an agent requires this constraint. + link: AgentLink>, + /// The service through which communication with the browser happens. + route_service: RouteService, + /// A list of all entities connected to the router. + /// When a route changes, either initiated by the browser or by the app, + /// the route change will be broadcast to all listening entities. + subscribers: HashSet, +} + +impl Debug for RouteAgent { + fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> { + f.debug_struct("RouteAgent") + .field("link", &"-") + .field("route_service", &self.route_service) + .field("subscribers", &self.subscribers.len()) + .finish() + } +} + +impl Agent for RouteAgent +where + STATE: RouteState, +{ + type Input = RouteRequest; + type Message = Msg; + type Output = Route; + type Reach = Context; + + fn create(link: AgentLink>) -> Self { + let callback = link.callback(Msg::BrowserNavigationRouteChanged); + let mut route_service = RouteService::new(); + route_service.register_callback(callback); + + RouteAgent { + link, + route_service, + subscribers: HashSet::new(), + } + } + + fn update(&mut self, msg: Self::Message) { + match msg { + Msg::BrowserNavigationRouteChanged(route) => { + trace!("Browser navigated"); + for sub in &self.subscribers { + self.link.respond(*sub, route.clone()); + } + } + } + } + + fn connected(&mut self, id: HandlerId) { + self.subscribers.insert(id); + } + + fn handle_input(&mut self, msg: Self::Input, who: HandlerId) { + match msg { + RouteRequest::ReplaceRoute(route) => { + let route_string: String = route.to_string(); + self.route_service.replace_route(&route_string, route.state); + let route = self.route_service.get_route(); + for sub in &self.subscribers { + self.link.respond(*sub, route.clone()); + } + } + RouteRequest::ReplaceRouteNoBroadcast(route) => { + let route_string: String = route.to_string(); + self.route_service.replace_route(&route_string, route.state); + } + RouteRequest::ChangeRoute(route) => { + let route_string: String = route.to_string(); + // set the route + self.route_service.set_route(&route_string, route.state); + // get the new route. + let route = self.route_service.get_route(); + // broadcast it to all listening components + for sub in &self.subscribers { + self.link.respond(*sub, route.clone()); + } + } + RouteRequest::ChangeRouteNoBroadcast(route) => { + let route_string: String = route.to_string(); + self.route_service.set_route(&route_string, route.state); + } + RouteRequest::GetCurrentRoute => { + let route = self.route_service.get_route(); + self.link.respond(who, route); + } + } + } + + fn disconnected(&mut self, id: HandlerId) { + self.subscribers.remove(&id); + } +} diff --git a/yew-router/src/alias.rs b/yew-router/src/alias.rs new file mode 100644 index 00000000000..0bcf0ad9521 --- /dev/null +++ b/yew-router/src/alias.rs @@ -0,0 +1,88 @@ +/// Generates a module named `router_state` containing aliases to common structures within +/// yew_router that deal with operating with Route and its state values as well as functions for +/// rendering routes. +/// +/// Because they should be the same across a given application, +/// its a handy way to make sure that every type that could be needed is generated. +/// +/// This macro is used to generate aliases for the state type of `()` within yew_router. +/// Instead of doing these yourself, use this macro if you need to store state in the browser. +/// +/// # Example +/// ``` +/// # #[macro_use] extern crate yew_router; +/// define_router_state!(Option); +/// use router_state::Route; // alias to Route> +/// # fn main() {} +/// ``` +#[macro_export] +macro_rules! define_router_state { + ($StateT:ty) => { + define_router_state!($StateT, stringify!($StateT)); + }; + ($StateT:ty, $StateName:expr) => { + #[doc = "A set of aliases to commonly used structures and functions used for routing."] + mod router_state { + + #[doc = "The state that can be stored by the router service."] + pub type State = $StateT; + + #[doc = "Alias to [Route<"] + #[doc = $StateName] + #[doc = ">](route/struct.Route.html)."] + pub type Route = $crate::route::Route<$StateT>; + + #[doc = "Alias to [RouteService<"] + #[doc = $StateName] + #[doc = ">](route_service/struct.RouteService.html)."] + pub type RouteService = $crate::service::RouteService<$StateT>; + + #[cfg(feature="agent")] + #[doc = "Alias to [RouteAgent<"] + #[doc = $StateName] + #[doc = ">](agent/struct.RouteAgent.html)."] + pub type RouteAgent = $crate::agent::RouteAgent<$StateT>; + + #[cfg(feature="agent")] + #[doc = "Alias to [RouteAgentBridge<"] + #[doc = $StateName] + #[doc = ">](agent/bridge/struct.RouteAgentBridge.html)`."] + pub type RouteAgentBridge = $crate::agent::RouteAgentBridge<$StateT>; + + #[cfg(feature="agent")] + #[doc = "Alias to [RouteAgentDispatcher<"] + #[doc = $StateName] + #[doc = ">](agent/struct.RouteAgentDispatcher.html)`."] + pub type RouteAgentDispatcher = $crate::agent::RouteAgentDispatcher<$StateT>; + + + #[allow(deprecated)] + #[deprecated(note = "Has been renamed to RouterAnchor")] + #[cfg(feature="components")] + #[doc = "Alias to [RouterLink<"] + #[doc = $StateName] + #[doc = ">](components/struct.RouterLink.html)`."] + pub type RouterLink = $crate::components::RouterLink<$StateT>; + + + #[cfg(feature="components")] + #[doc = "Alias to [RouterAnchor<"] + #[doc = $StateName] + #[doc = ">](components/struct.RouterAnchor.html)`."] + pub type RouterAnchor = $crate::components::RouterAnchor<$StateT>; + + #[cfg(feature="components")] + #[doc = "Alias to [RouterButton<"] + #[doc = $StateName] + #[doc = ">](components/struct.RouterButton.html)`."] + pub type RouterButton = $crate::components::RouterButton<$StateT>; + + #[cfg(feature="router")] + #[doc = "Alias to [Router<"] + #[doc = $StateName] + #[doc = ">](router/router/struct.Router.html)."] + pub type Router = $crate::router::Router<$StateT, SW>; + + } + } +} diff --git a/yew-router/src/components/mod.rs b/yew-router/src/components/mod.rs new file mode 100644 index 00000000000..3f57997e91c --- /dev/null +++ b/yew-router/src/components/mod.rs @@ -0,0 +1,45 @@ +//! Components that integrate with the [route agent](agent/struct.RouteAgent.html). +//! +//! At least one bridge to the agent needs to exist for these to work. +//! This can be done transitively by using a `Router` component, which owns a bridge to the agent. + +mod router_button; +mod router_link; + +use yew::{Children, Properties}; + +#[allow(deprecated)] +pub use self::{router_button::RouterButton, router_link::RouterAnchor, router_link::RouterLink}; +use crate::Switch; + +// TODO This should also be PartialEq and Clone. Its blocked on Children not supporting that. +// TODO This should no longer take link & String, and instead take a route: SW implementing Switch +/// Properties for `RouterButton` and `RouterLink`. +#[derive(Properties, Clone, Default, Debug)] +pub struct Props +where + SW: Switch + Clone, +{ + /// The Switched item representing the route. + pub route: SW, + #[deprecated(note = "Use children field instead (nested html)")] + /// The text to display. + #[prop_or_default] + pub text: String, + /// Html inside the component. + #[prop_or_default] + pub children: Children, + /// Disable the component. + #[prop_or_default] + pub disabled: bool, + /// Classes to be added to component. + #[prop_or_default] + pub classes: String, +} + +/// Message for `RouterButton` and `RouterLink`. +#[derive(Clone, Copy, Debug)] +pub enum Msg { + /// Tell the router to navigate the application to the Component's pre-defined route. + Clicked, +} diff --git a/yew-router/src/components/router_button.rs b/yew-router/src/components/router_button.rs new file mode 100644 index 00000000000..1726f4e2bdb --- /dev/null +++ b/yew-router/src/components/router_button.rs @@ -0,0 +1,65 @@ +//! A component wrapping a ` + } + } +} diff --git a/yew-router/src/components/router_link.rs b/yew-router/src/components/router_link.rs new file mode 100644 index 00000000000..9f29b32cb8d --- /dev/null +++ b/yew-router/src/components/router_link.rs @@ -0,0 +1,87 @@ +//! A component wrapping an `` tag that changes the route. +use crate::{ + agent::{RouteAgentDispatcher, RouteRequest}, + route::Route, + Switch, +}; +use yew::prelude::*; + +use super::{Msg, Props}; +use crate::RouterState; +use yew::virtual_dom::VNode; + +/// An anchor tag Component that when clicked, will navigate to the provided route. +/// +/// Alias to RouterAnchor. +#[deprecated(note = "Has been renamed to RouterAnchor")] +pub type RouterLink = RouterAnchor; + +/// An anchor tag Component that when clicked, will navigate to the provided route. +#[derive(Debug)] +pub struct RouterAnchor { + link: ComponentLink, + router: RouteAgentDispatcher, + props: Props, +} + +impl Component for RouterAnchor { + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let router = RouteAgentDispatcher::new(); + RouterAnchor { + link, + router, + props, + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::Clicked => { + let route = Route::from(self.props.route.clone()); + self.router.send(RouteRequest::ChangeRoute(route)); + false + } + } + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props = props; + true + } + + fn view(&self) -> VNode { + #[cfg(feature = "std_web")] + use stdweb::web::event::IEvent; + + let route: Route = Route::from(self.props.route.clone()); + let target: &str = route.as_str(); + #[cfg(feature = "std_web")] + let cb = self.link.callback(|event: ClickEvent| { + event.prevent_default(); + Msg::Clicked + }); + #[cfg(feature = "web_sys")] + let cb = self.link.callback(|event: MouseEvent| { + event.prevent_default(); + Msg::Clicked + }); + + html! { + + { + #[allow(deprecated)] + &self.props.text + } + {self.props.children.iter().collect::()} + + } + } +} diff --git a/yew-router/src/lib.rs b/yew-router/src/lib.rs new file mode 100644 index 00000000000..1b28f503862 --- /dev/null +++ b/yew-router/src/lib.rs @@ -0,0 +1,132 @@ +#![recursion_limit = "128"] +//! Provides routing faculties for the Yew web framework. +//! +//! ## Contents +//! This crate consists of multiple types, some independently useful on their own, +//! that are used together to facilitate routing within the Yew framework. +//! Among them are: +//! * RouteService - Hooks into the History API and listens to `PopStateEvent`s to respond to users +//! clicking the back/forwards buttons. +//! * RouteAgent - A singleton agent that owns a RouteService that provides an easy place for other +//! components and agents to hook into it. +//! * Switch - A trait/derive macro that allows specification of how enums or structs can be constructed +//! from Routes. +//! * Router - A component connected to the RouteAgent, and is capable of resolving Routes to +//! Switch implementors, so you can use them to render Html. +//! * Route - A struct containing an the route string and state. +//! * RouteButton & RouteLink - Wrapper components around buttons and anchor tags respectively that +//! allow users to change the route. +//! +//! ## State and Aliases +//! Because the History API allows you to store data along with a route string, +//! most types have at type parameter that allows you to specify which type is being stored. +//! As this behavior is uncommon, aliases using the unit type (`()`) are provided to remove the +//! need to specify the storage type you likely aren't using. +//! +//! If you want to store state using the history API, it is recommended that you generate your own +//! aliases using the `define_router_state` macro. +//! Give it a typename, and it will generate a module containing aliases and functions useful for +//! routing. If you specify your own router_state aliases and functions, you will want to disable +//! the `unit_alias` feature to prevent the default `()` aliases from showing up in the prelude. +//! +//! ## Features +//! This crate has some feature-flags that allow you to not include some parts in your compilation. +//! * "default" - Everything is included by default. +//! * "core" - The fully feature complete ("router", "components", "matchers"), but without +//! unit_alias. +//! * "unit_alias" - If enabled, a module will be added to the route and expanded within the prelude +//! for aliases of Router types to their `()` variants. +//! * "router" - If enabled, the Router component and its dependent infrastructure (including +//! "agent") will be included. +//! * "agent" - If enabled, the RouteAgent and its associated types will be included. +//! * "components" - If enabled, the accessory components will be made available. + +#![deny( + missing_docs, + missing_debug_implementations, + missing_copy_implementations, + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unstable_features, + unused_qualifications +)] +// This will break the project at some point, but it will break yew as well. +// It can be dealt with at the same time. +#![allow(macro_expanded_macro_exports_accessed_by_absolute_paths)] + +pub use yew_router_route_parser; + +#[macro_use] +mod alias; + +#[cfg(feature = "service")] +pub mod service; + +#[cfg(feature = "agent")] +pub mod agent; + +pub mod route; + +#[cfg(feature = "components")] +pub mod components; + +#[cfg(feature = "router")] +pub mod router; + +/// TODO remove this +/// Contains aliases and functions for working with this library using a state of type `()`. +#[cfg(feature = "unit_alias")] +pub mod unit_state { + define_router_state!(()); + pub use router_state::*; +} + +/// Prelude module that can be imported when working with the yew_router +pub mod prelude { + pub use super::matcher::Captures; + + #[cfg(feature = "service")] + pub use crate::route::RouteState; + #[cfg(feature = "service")] + pub use crate::service::RouteService; + + #[cfg(feature = "agent")] + pub use crate::agent::RouteAgent; + #[cfg(feature = "agent")] + pub use crate::agent::RouteAgentBridge; + #[cfg(feature = "agent")] + pub use crate::agent::RouteAgentDispatcher; + + #[cfg(feature = "components")] + pub use crate::components::RouterAnchor; + #[cfg(feature = "components")] + pub use crate::components::RouterButton; + + #[cfg(feature = "router")] + pub use crate::router::Router; + + #[cfg(feature = "router")] + pub use crate::router::RouterState; + + pub use crate::{ + route::Route, + switch::{Routable, Switch}, + }; + pub use yew_router_macro::Switch; +} + +pub use alias::*; + +pub mod matcher; + +pub use matcher::Captures; + +#[cfg(feature = "service")] +pub use crate::route::RouteState; +#[cfg(feature = "router")] +pub use crate::router::RouterState; + +pub mod switch; +pub use switch::Switch; +pub use yew_router_macro::Switch; diff --git a/yew-router/src/matcher/matcher_impl.rs b/yew-router/src/matcher/matcher_impl.rs new file mode 100644 index 00000000000..62994bac801 --- /dev/null +++ b/yew-router/src/matcher/matcher_impl.rs @@ -0,0 +1,399 @@ +use crate::matcher::{ + util::{consume_until, next_delimiter, tag_possibly_case_sensitive}, + Captures, MatcherSettings, +}; +use log::trace; +use nom::{ + bytes::complete::{is_not, tag}, + combinator::map, + error::ErrorKind, + sequence::terminated, + IResult, +}; +use std::{iter::Peekable, slice::Iter}; +use yew_router_route_parser::{CaptureVariant, MatcherToken}; + +/// Allows abstracting over capturing into a HashMap (Captures) or a Vec. +trait CaptureCollection<'a> { + fn new2() -> Self; + fn insert2(&mut self, key: &'a str, value: String); + fn extend2(&mut self, other: Self); +} + +impl<'a> CaptureCollection<'a> for Captures<'a> { + fn new2() -> Self { + Captures::new() + } + + fn insert2(&mut self, key: &'a str, value: String) { + self.insert(key, value); + } + + fn extend2(&mut self, other: Self) { + self.extend(other) + } +} + +impl<'a> CaptureCollection<'a> for Vec { + fn new2() -> Self { + Vec::new() + } + + fn insert2(&mut self, _key: &'a str, value: String) { + self.push(value) + } + + fn extend2(&mut self, other: Self) { + self.extend(other) + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +pub(super) fn match_into_map<'a, 'b: 'a>( + tokens: &'b [MatcherToken], + settings: &'b MatcherSettings, +) -> impl Fn(&'a str) -> IResult<&'a str, Captures<'b>> { + move |i: &str| matcher_impl(tokens, *settings, i) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +pub(super) fn match_into_vec<'a, 'b: 'a>( + tokens: &'b [MatcherToken], + settings: &'b MatcherSettings, +) -> impl Fn(&'a str) -> IResult<&'a str, Vec> { + move |i: &str| matcher_impl(tokens, *settings, i) +} + +fn matcher_impl<'a, 'b: 'a, CAP: CaptureCollection<'b>>( + tokens: &'b [MatcherToken], + settings: MatcherSettings, + mut i: &'a str, +) -> IResult<&'a str, CAP> { + trace!("Attempting to match route: {:?} using: {:?}", i, tokens); + + let mut iter = tokens.iter().peekable(); + + let mut captures: CAP = CAP::new2(); + + while let Some(token) = iter.next() { + i = match token { + MatcherToken::Exact(literal) => { + trace!("Matching '{}' against literal: '{}'", i, literal); + tag_possibly_case_sensitive(literal.as_str(), !settings.case_insensitive)(i)?.0 + } + MatcherToken::Capture(capture) => match &capture { + CaptureVariant::Named(name) => capture_named(i, &mut iter, &name, &mut captures)?, + CaptureVariant::ManyNamed(name) => { + capture_many_named(i, &mut iter, &name, &mut captures)? + } + CaptureVariant::NumberedNamed { sections, name } => { + capture_numbered_named(i, &mut iter, Some((&name, &mut captures)), *sections)? + } + CaptureVariant::Unnamed => capture_named(i, &mut iter, "", &mut captures)?, + CaptureVariant::ManyUnnamed => capture_many_named(i, &mut iter, "", &mut captures)?, + CaptureVariant::NumberedUnnamed { sections } => { + capture_numbered_named(i, &mut iter, Some(("", &mut captures)), *sections)? + } + }, + MatcherToken::End => { + if !i.is_empty() { + // this is approximately correct, but ultimately doesn't matter + return Err(nom::Err::Failure((i, ErrorKind::Eof))); + } else { + i + } + } + }; + } + trace!("Route Matched"); + + Ok((i, captures)) +} + +fn capture_named<'a, 'b: 'a, CAP: CaptureCollection<'b>>( + i: &'a str, + iter: &mut Peekable>, + capture_key: &'b str, + matches: &mut CAP, +) -> Result<&'a str, nom::Err<(&'a str, ErrorKind)>> { + log::trace!("Matching Named ({})", capture_key); + if let Some(_peaked_next_token) = iter.peek() { + let delimiter = next_delimiter(iter); + let (ii, captured) = consume_until(delimiter)(i)?; + matches.insert2(capture_key, captured); + Ok(ii) + } else { + let (ii, captured) = map(valid_capture_characters, String::from)(i)?; + matches.insert2(capture_key, captured); + Ok(ii) + } +} + +fn capture_many_named<'a, 'b, CAP: CaptureCollection<'b>>( + i: &'a str, + iter: &mut Peekable>, + capture_key: &'b str, + matches: &mut CAP, +) -> Result<&'a str, nom::Err<(&'a str, ErrorKind)>> { + log::trace!("Matching ManyUnnamed ({})", capture_key); + if let Some(_peaked_next_token) = iter.peek() { + let delimiter = next_delimiter(iter); + let (ii, captured) = consume_until(delimiter)(i)?; + matches.insert2(&capture_key, captured); + Ok(ii) + } else if i.is_empty() { + // If the route string is empty, return an empty value. + matches.insert2(&capture_key, "".to_string()); + Ok(i) // Match even if nothing is left + } else { + let (ii, c) = map(valid_many_capture_characters, String::from)(i)?; + matches.insert2(&capture_key, c); + Ok(ii) + } +} + +fn capture_numbered_named<'a, 'b, CAP: CaptureCollection<'b>>( + mut i: &'a str, + iter: &mut Peekable>, + name_and_captures: Option<(&'b str, &mut CAP)>, + mut sections: usize, +) -> Result<&'a str, nom::Err<(&'a str, ErrorKind)>> { + log::trace!("Matching NumberedNamed ({})", sections); + let mut captured = "".to_string(); + + if let Some(_peaked_next_token) = iter.peek() { + while sections > 0 { + if sections > 1 { + let (ii, c) = terminated(valid_capture_characters, tag("/"))(i)?; + i = ii; + captured += c; + captured += "/"; + } else { + let delimiter = next_delimiter(iter); + let (ii, c) = consume_until(delimiter)(i)?; + i = ii; + captured += &c; + } + sections -= 1; + } + } else { + while sections > 0 { + if sections > 1 { + let (ii, c) = terminated(valid_capture_characters, tag("/"))(i)?; + i = ii; + captured += c; + captured += "/"; + } else { + // Don't consume the next character on the last section + let (ii, c) = valid_capture_characters(i)?; + i = ii; + captured += c; + } + sections -= 1; + } + } + + if let Some((name, captures)) = name_and_captures { + captures.insert2(&name, captured); + } + Ok(i) +} + +/// Characters that don't interfere with parsing logic for capturing characters +fn valid_capture_characters(i: &str) -> IResult<&str, &str> { + const INVALID_CHARACTERS: &str = " */#&?{}="; + is_not(INVALID_CHARACTERS)(i) +} + +fn valid_many_capture_characters(i: &str) -> IResult<&str, &str> { + const INVALID_CHARACTERS: &str = " #&?="; + is_not(INVALID_CHARACTERS)(i) +} + +#[cfg(test)] +mod integration_test { + use super::*; + + use yew_router_route_parser::{self, FieldNamingScheme}; + + use super::super::Captures; + // use nom::combinator::all_consuming; + + #[test] + fn match_query_after_path() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path?lorem=ipsum", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path?lorem=ipsum") + .expect("should match"); + } + + #[test] + fn match_query_after_path_trailing_slash() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path/?lorem=ipsum", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path/?lorem=ipsum") + .expect("should match"); + } + + #[test] + fn match_query() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "?lorem=ipsum", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "?lorem=ipsum") + .expect("should match"); + } + + #[test] + fn named_capture_query() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "?lorem={ipsum}", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + let (_, matches) = matcher_impl::(&x, MatcherSettings::default(), "?lorem=ipsum") + .expect("should match"); + assert_eq!(matches["ipsum"], "ipsum".to_string()) + } + + #[test] + fn match_n_paths_3() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/{*:cap}/thing", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + let matches: Captures = + matcher_impl(&x, MatcherSettings::default(), "/anything/other/thing") + .expect("should match") + .1; + assert_eq!(matches["cap"], "anything/other".to_string()) + } + + #[test] + fn match_n_paths_4() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/{*:cap}/thing", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + let matches: Captures = + matcher_impl(&x, MatcherSettings::default(), "/anything/thing/thing") + .expect("should match") + .1; + assert_eq!(matches["cap"], "anything".to_string()) + } + + #[test] + fn match_path_5() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/{cap}/thing", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + let matches: Captures = + matcher_impl(&x, MatcherSettings::default(), "/anything/thing/thing") + .expect("should match") + .1; + assert_eq!(matches["cap"], "anything".to_string()) + } + + #[test] + fn match_fragment() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "#test", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "#test").expect("should match"); + } + + #[test] + fn match_fragment_after_path() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path/#test", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path/#test") + .expect("should match"); + } + + #[test] + fn match_fragment_after_path_no_slash() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path#test", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path#test") + .expect("should match"); + } + + #[test] + fn match_fragment_after_query() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path?query=thing#test", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path?query=thing#test") + .expect("should match"); + } + + #[test] + fn match_fragment_after_query_capture() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/a/path?query={capture}#test", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "/a/path?query=thing#test") + .expect("should match"); + } + + #[test] + fn capture_as_only_token() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "{any}", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + matcher_impl::(&x, MatcherSettings::default(), "literally_anything") + .expect("should match"); + } + + #[test] + fn case_insensitive() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/hello", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + let settings = MatcherSettings { + case_insensitive: true, + ..Default::default() + }; + matcher_impl::(&x, settings, "/HeLLo").expect("should match"); + } + + #[test] + fn end_token() { + let x = yew_router_route_parser::parse_str_and_optimize_tokens( + "/lorem!", + FieldNamingScheme::Unnamed, + ) + .expect("Should parse"); + + matcher_impl::(&x, Default::default(), "/lorem/ipsum") + .expect_err("should not match"); + } +} diff --git a/yew-router/src/matcher/mod.rs b/yew-router/src/matcher/mod.rs new file mode 100644 index 00000000000..af080d3431c --- /dev/null +++ b/yew-router/src/matcher/mod.rs @@ -0,0 +1,280 @@ +//! Module for matching route strings based on tokens generated from the yew_router_route_parser +//! crate. + +mod matcher_impl; +mod util; + +use nom::IResult; +use std::collections::HashSet; +use yew_router_route_parser::{parse_str_and_optimize_tokens, PrettyParseError}; + +pub use yew_router_route_parser::{CaptureVariant, Captures, MatcherToken}; + +/// Attempts to match routes, transform the route to Component props and render that Component. +#[derive(Debug, PartialEq, Clone)] +pub struct RouteMatcher { + /// Tokens used to determine how the matcher will match a route string. + pub tokens: Vec, + /// Settings + pub settings: MatcherSettings, +} + +/// Settings used for the matcher. +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct MatcherSettings { + /// All literal matches do not care about case. + pub case_insensitive: bool, +} + +impl Default for MatcherSettings { + fn default() -> Self { + MatcherSettings { + case_insensitive: false, + } + } +} + +impl RouteMatcher { + /// Attempt to create a RouteMatcher from a "matcher string". + pub fn try_from(i: &str) -> Result { + let settings = MatcherSettings::default(); + Self::new(i, settings) + } + + /// Creates a new Matcher with settings. + pub fn new(i: &str, settings: MatcherSettings) -> Result { + Ok(RouteMatcher { + tokens: parse_str_and_optimize_tokens( + i, + yew_router_route_parser::FieldNamingScheme::Unnamed, // The most permissive scheme + )?, /* TODO this field type should be a superset of Named, but it would be better to source this from settings, and make sure that the macro generates settings as such. */ + settings, + }) + } + + /// Match a route string, collecting the results into a map. + pub fn capture_route_into_map<'a, 'b: 'a>( + &'b self, + i: &'a str, + ) -> IResult<&'a str, Captures<'a>> { + matcher_impl::match_into_map(&self.tokens, &self.settings)(i) + } + + /// Match a route string, collecting the results into a vector. + pub fn capture_route_into_vec<'a, 'b: 'a>( + &'b self, + i: &'a str, + ) -> IResult<&'a str, Vec> { + matcher_impl::match_into_vec(&self.tokens, &self.settings)(i) + } + + /// Gets a set of all names that will be captured. + /// This is useful in determining if a given struct will be able to be populated by a given path + /// matcher before being given a concrete path to match. + pub fn capture_names(&self) -> HashSet<&str> { + fn capture_names_impl(tokens: &[MatcherToken]) -> HashSet<&str> { + tokens + .iter() + .fold(HashSet::new(), |mut acc: HashSet<&str>, token| { + match token { + MatcherToken::Exact(_) | MatcherToken::End => {} + MatcherToken::Capture(capture) => match &capture { + CaptureVariant::ManyNamed(name) + | CaptureVariant::Named(name) + | CaptureVariant::NumberedNamed { name, .. } => { + acc.insert(&name); + } + CaptureVariant::Unnamed + | CaptureVariant::ManyUnnamed + | CaptureVariant::NumberedUnnamed { .. } => {} + }, + } + acc + }) + } + capture_names_impl(&self.tokens) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use yew_router_route_parser::{ + convert_tokens, + parser::{RefCaptureVariant, RouteParserToken}, + }; + + impl<'a> From>> for RouteMatcher { + fn from(tokens: Vec>) -> Self { + let settings = MatcherSettings::default(); + RouteMatcher { + tokens: convert_tokens(&tokens), + settings, + } + } + } + + #[test] + fn basic_separator() { + let tokens = vec![RouteParserToken::Separator]; + let path_matcher = RouteMatcher::from(tokens); + path_matcher + .capture_route_into_map("/") + .expect("should parse"); + } + + #[test] + fn multiple_tokens() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("lorem"), + RouteParserToken::Separator, + ]; + + let path_matcher = RouteMatcher::from(tokens); + path_matcher + .capture_route_into_map("/lorem/") + .expect("should parse"); + } + + #[test] + fn simple_capture() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + RouteParserToken::Separator, + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, matches) = path_matcher + .capture_route_into_map("/ipsum/") + .expect("should parse"); + assert_eq!(matches["lorem"], "ipsum".to_string()) + } + + #[test] + fn simple_capture_with_no_trailing_separator() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, matches) = path_matcher + .capture_route_into_map("/ipsum") + .expect("should parse"); + assert_eq!(matches["lorem"], "ipsum".to_string()) + } + + #[test] + fn match_with_trailing_match_many() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::ManyNamed("lorem")), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, _matches) = path_matcher + .capture_route_into_map("/a/") + .expect("should parse"); + } + + #[test] + fn fail_match_with_trailing_match_single() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::Named("lorem")), + ]; + let path_matcher = RouteMatcher::from(tokens); + path_matcher + .capture_route_into_map("/a/") + .expect_err("should not parse"); + } + + #[test] + fn match_n() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::NumberedNamed { + sections: 3, + name: "lorem", + }), + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, _matches) = path_matcher + .capture_route_into_map("/garbage1/garbage2/garbage3/a") + .expect("should parse"); + } + + #[test] + fn match_n_no_overrun() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::NumberedNamed { + sections: 3, + name: "lorem", + }), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (s, _matches) = path_matcher + .capture_route_into_map("/garbage1/garbage2/garbage3") + .expect("should parse"); + assert_eq!(s.len(), 0) + } + + #[test] + fn match_n_named() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::NumberedNamed { + sections: 3, + name: "captured", + }), + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, matches) = path_matcher + .capture_route_into_map("/garbage1/garbage2/garbage3/a") + .expect("should parse"); + assert_eq!( + matches["captured"], + "garbage1/garbage2/garbage3".to_string() + ) + } + + #[test] + fn match_many() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::ManyNamed("lorem")), + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, _matches) = path_matcher + .capture_route_into_map("/garbage1/garbage2/garbage3/a") + .expect("should parse"); + } + + #[test] + fn match_many_named() { + let tokens = vec![ + RouteParserToken::Separator, + RouteParserToken::Capture(RefCaptureVariant::ManyNamed("captured")), + RouteParserToken::Separator, + RouteParserToken::Exact("a"), + ]; + let path_matcher = RouteMatcher::from(tokens); + let (_, matches) = path_matcher + .capture_route_into_map("/garbage1/garbage2/garbage3/a") + .expect("should parse"); + assert_eq!( + matches["captured"], + "garbage1/garbage2/garbage3".to_string() + ) + } +} diff --git a/yew-router/src/matcher/util.rs b/yew-router/src/matcher/util.rs new file mode 100644 index 00000000000..f9fd99ca47c --- /dev/null +++ b/yew-router/src/matcher/util.rs @@ -0,0 +1,140 @@ +use nom::{ + bytes::complete::{tag, tag_no_case}, + character::complete::anychar, + combinator::{cond, map, peek, rest}, + error::{ErrorKind, ParseError}, + multi::many_till, + sequence::pair, + IResult, +}; +use std::{iter::Peekable, rc::Rc, slice::Iter}; +use yew_router_route_parser::MatcherToken; + +/// Allows a configurable tag that can optionally be case insensitive. +pub fn tag_possibly_case_sensitive<'a, 'b: 'a>( + text: &'b str, + is_sensitive: bool, +) -> impl Fn(&'a str) -> IResult<&'a str, &'a str> { + map( + pair( + cond(is_sensitive, tag(text)), + cond(!is_sensitive, tag_no_case(text)), + ), + |(x, y): (Option<&str>, Option<&str>)| x.xor(y).unwrap(), + ) +} + +/// Similar to alt, but works on a vector of tags. +#[allow(unused)] +pub fn alternative(alternatives: Vec) -> impl Fn(&str) -> IResult<&str, &str> { + move |i: &str| { + for alternative in &alternatives { + if let done @ IResult::Ok(..) = tag(alternative.as_str())(i) { + return done; + } + } + Err(nom::Err::Error((i, ErrorKind::Tag))) // nothing found. + } +} + +/// Consumes the input until the provided parser succeeds. +/// The consumed input is returned in the form of an allocated string. +/// # Note +/// `stop_parser` only peeks its input. +pub fn consume_until<'a, F, E>(stop_parser: F) -> impl Fn(&'a str) -> IResult<&'a str, String, E> +where + E: ParseError<&'a str>, + F: Fn(&'a str) -> IResult<&'a str, &'a str, E>, +{ + // In order for the returned fn to be Fn instead of FnOnce, wrap the inner fn in an RC. + let f = Rc::new(many_till( + anychar, + peek(stop_parser), // once this succeeds, stop folding. + )); + move |i: &str| { + let (i, (first, _stop)): (&str, (Vec, &str)) = (f)(i)?; + let ret = first.into_iter().collect(); + Ok((i, ret)) + } +} + +/// Produces a parser combinator that searches for the next possible set of strings of +/// characters used to terminate a forward search. +/// +/// # Panics +/// This function assumes that the next item after a Capture must be an Exact. +/// If this is violated, this function will panic. +pub fn next_delimiter<'a>( + iter: &mut Peekable>, +) -> impl Fn(&'a str) -> IResult<&'a str, &'a str> { + let t: MatcherToken = iter + .peek() + .copied() + .cloned() + .expect("There must be at least one token to peak in next_delimiter"); + + move |i: &'a str| match &t { + MatcherToken::Exact(sequence) => tag(sequence.as_str())(i), + MatcherToken::End => rest(i), + MatcherToken::Capture(_) => { + panic!("underlying parser should not allow two captures in a row") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consume_until_simple() { + let parser = consume_until::<_, ()>(tag("z")); + let parsed = parser("abcz").expect("Should parse"); + assert_eq!(parsed, ("z", "abc".to_string())) + } + + #[test] + fn consume_until_fail() { + let parser = consume_until(tag("z")); + let e = parser("abc").expect_err("Should parse"); + assert_eq!(e, nom::Err::Error(("", ErrorKind::Eof))) + } + + #[test] + fn alternative_simple() { + let parser = alternative( + vec!["c", "d", "abc"] + .into_iter() + .map(String::from) + .collect(), + ); + let parsed = parser("abcz").expect("Should parse"); + assert_eq!(parsed, ("z", "abc")) + } + + #[test] + fn alternative_and_consume_until() { + let parser = consume_until(alternative( + vec!["c", "d", "abc"] + .into_iter() + .map(String::from) + .collect(), + )); + let parsed = parser("first_stuff_abc").expect("should parse"); + assert_eq!(parsed, ("abc", "first_stuff_".to_string())) + } + + #[test] + fn case_sensitive() { + let parser = tag_possibly_case_sensitive("lorem", true); + parser("lorem").expect("Should match"); + parser("LoReM").expect_err("Should not match"); + } + + #[test] + fn case_insensitive() { + let parser = tag_possibly_case_sensitive("lorem", false); + parser("lorem").expect("Should match"); + parser("LoREm").expect("Should match"); + } +} diff --git a/yew-router/src/route.rs b/yew-router/src/route.rs new file mode 100644 index 00000000000..6d6a48b17a8 --- /dev/null +++ b/yew-router/src/route.rs @@ -0,0 +1,79 @@ +//! Wrapper around route url string, and associated history state. +use cfg_if::cfg_if; +#[cfg(feature = "service")] +use serde::de::DeserializeOwned; + +cfg_if! { + if #[cfg(feature = "std_web")] { + use stdweb::{unstable::TryFrom, Value}; + } +} + +use serde::{Deserialize, Serialize}; +use std::{ + fmt::{self, Debug}, + ops::Deref, +}; + +/// Any state that can be used in the router agent must meet the criteria of this trait. +#[cfg(all(feature = "service", feature = "std_web"))] +pub trait RouteState: + Serialize + DeserializeOwned + Debug + Clone + Default + TryFrom + 'static +{ +} +#[cfg(all(feature = "service", feature = "std_web"))] +impl RouteState for T where + T: Serialize + DeserializeOwned + Debug + Clone + Default + TryFrom + 'static +{ +} + +/// Any state that can be used in the router agent must meet the criteria of this trait. +#[cfg(all(feature = "service", feature = "web_sys"))] +pub trait RouteState: Serialize + DeserializeOwned + Debug + Clone + Default + 'static {} +#[cfg(all(feature = "service", feature = "web_sys"))] +impl RouteState for T where T: Serialize + DeserializeOwned + Debug + Clone + Default + 'static {} + +/// The representation of a route, segmented into different sections for easy access. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Route { + /// The route string + pub route: String, + /// The state stored in the history api + pub state: STATE, +} + +impl Route<()> { + /// Creates a new route with no state out of a string. + /// + /// This Route will have `()` for its state. + pub fn new_no_state>(route: T) -> Self { + Route { + route: route.as_ref().to_string(), + state: (), + } + } +} + +impl Route { + /// Creates a new route out of a string, setting the state to its default value. + pub fn new_default_state>(route: T) -> Self { + Route { + route: route.as_ref().to_string(), + state: STATE::default(), + } + } +} + +impl fmt::Display for Route { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Display::fmt(&self.route, f) + } +} + +impl Deref for Route { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.route + } +} diff --git a/yew-router/src/router.rs b/yew-router/src/router.rs new file mode 100644 index 00000000000..4e0a349986e --- /dev/null +++ b/yew-router/src/router.rs @@ -0,0 +1,234 @@ +//! Router Component. + +use crate::{ + agent::{RouteAgentBridge, RouteRequest}, + route::Route, + RouteState, Switch, +}; +use std::{ + fmt::{self, Debug, Error as FmtError, Formatter}, + rc::Rc, +}; +use yew::{html, virtual_dom::VNode, Component, ComponentLink, Html, Properties, ShouldRender}; + +/// Any state that can be managed by the `Router` must meet the criteria of this trait. +pub trait RouterState: RouteState + PartialEq {} +impl RouterState for STATE where STATE: RouteState + PartialEq {} + +/// Rendering control flow component. +/// +/// # Example +/// ``` +/// use yew::{prelude::*, virtual_dom::VNode}; +/// use yew_router::{router::Router, Switch}; +/// +/// pub enum Msg {} +/// +/// pub struct Model {} +/// impl Component for Model { +/// //... +/// # type Message = Msg; +/// # type Properties = (); +/// # fn create(_: Self::Properties, _link: ComponentLink) -> Self { +/// # Model {} +/// # } +/// # fn update(&mut self, msg: Self::Message) -> ShouldRender { +/// # false +/// # } +/// +/// fn view(&self) -> VNode { +/// html! { +/// +/// render = Router::render(|switch: S| { +/// match switch { +/// S::Variant => html!{"variant route was matched"}, +/// } +/// }) +/// /> +/// } +/// } +/// } +/// +/// #[derive(Switch, Clone)] +/// enum S { +/// #[to = "/v"] +/// Variant, +/// } +/// ``` +// TODO, can M just be removed due to not having to explicitly deal with callbacks anymore? - Just get rid of M +#[derive(Debug)] +pub struct Router { + switch: Option, + props: Props, + router_agent: RouteAgentBridge, +} + +impl Router +where + STATE: RouterState, + SW: Switch + Clone + 'static, +{ + // TODO render fn name is overloaded now with that of the trait: Renderable<_> this should be changed. Maybe: display, show, switch, inner... + /// Wrap a render closure so that it can be used by the Router. + /// # Example + /// ``` + /// # use yew_router::Switch; + /// # use yew_router::router::{Router, Render}; + /// # use yew::{html, Html}; + /// # #[derive(Switch, Clone)] + /// # enum S { + /// # #[to = "/route"] + /// # Variant + /// # } + /// # pub enum Msg {} + /// + /// # fn dont_execute() { + /// let render: Render = Router::render(|switch: S| -> Html { + /// match switch { + /// S::Variant => html! {"Variant"}, + /// } + /// }); + /// # } + /// ``` + pub fn render, SW> + 'static>(f: F) -> Render { + Render::new(f) + } + + /// Wrap a redirect function so that it can be used by the Router. + pub fn redirect + 'static>(f: F) -> Option> { + Some(Redirect::new(f)) + } +} + +/// Message for Router. +#[derive(Debug, Clone)] +pub enum Msg { + /// Updates the route + UpdateRoute(Route), +} + +/// Render function that takes a switched route and converts it to HTML +pub trait RenderFn: Fn(SW) -> Html {} +impl RenderFn for T where T: Fn(SW) -> Html {} +/// Owned Render function. +#[derive(Clone)] +pub struct Render( + pub(crate) Rc, SW>>, +); +impl Render { + /// New render function + fn new, SW> + 'static>(f: F) -> Self { + Render(Rc::new(f)) + } +} +impl Debug for Render { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Render").finish() + } +} + +/// Redirection function that takes a route that didn't match any of the Switch variants, +/// and converts it to a switch variant. +pub trait RedirectFn: Fn(Route) -> SW {} +impl RedirectFn for T where T: Fn(Route) -> SW {} +/// Clonable Redirect function +#[derive(Clone)] +pub struct Redirect( + pub(crate) Rc>, +); +impl Redirect { + fn new + 'static>(f: F) -> Self { + Redirect(Rc::new(f)) + } +} +impl Debug for Redirect { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Redirect").finish() + } +} + +/// Properties for Router. +#[derive(Properties, Clone)] +pub struct Props { + /// Render function that takes a Switch and produces Html + pub render: Render, + /// Optional redirect function that will convert the route to a known switch variant if explicit matching fails. + /// This should mostly be used to handle 404s and redirection. + /// It is not strictly necessary as your Switch is capable of handling unknown routes using `#[to="/{*:any}"]`. + #[prop_or_default] + pub redirect: Option>, +} + +impl Debug for Props { + fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> { + f.debug_struct("Props").finish() + } +} + +impl Component for Router +where + STATE: RouterState, + SW: Switch + Clone + 'static, +{ + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let callback = link.callback(Msg::UpdateRoute); + let router_agent = RouteAgentBridge::new(callback); + + Router { + switch: Default::default(), /* This must be updated by immediately requesting a route + * update from the service bridge. */ + props, + router_agent, + } + } + + fn mounted(&mut self) -> ShouldRender { + self.router_agent.send(RouteRequest::GetCurrentRoute); + false + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::UpdateRoute(route) => { + let mut switch = SW::switch(route.clone()); + + if switch.is_none() { + if let Some(redirect) = &self.props.redirect { + let redirected: SW = (&redirect.0)(route); + + log::trace!( + "Route failed to match, but redirecting route to a known switch." + ); + // Replace the route in the browser with the redirected. + self.router_agent + .send(RouteRequest::ReplaceRouteNoBroadcast( + redirected.clone().into(), + )); + switch = Some(redirected) + } + } + + self.switch = switch; + true + } + } + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props = props; + true + } + + fn view(&self) -> VNode { + match self.switch.clone() { + Some(switch) => (&self.props.render.0)(switch), + None => { + log::warn!("No route matched, provide a redirect prop to the router to handle cases where no route can be matched"); + html! {"No route matched"} + } + } + } +} diff --git a/yew-router/src/service.rs b/yew-router/src/service.rs new file mode 100644 index 00000000000..dd0ec0f2424 --- /dev/null +++ b/yew-router/src/service.rs @@ -0,0 +1,232 @@ +//! Service that interfaces with the browser to handle routing. + +use yew::callback::Callback; + +use crate::route::{Route, RouteState}; +use cfg_if::cfg_if; +use cfg_match::cfg_match; +use std::marker::PhantomData; + +cfg_if! { + if #[cfg(feature = "std_web")] { + use stdweb::{ + js, + unstable::{TryFrom, TryInto}, + web::{event::PopStateEvent, window, EventListenerHandle, History, IEventTarget, Location}, + Value, + }; + } else if #[cfg(feature = "web_sys")] { + use web_sys::{History, Location, PopStateEvent}; + use gloo::events::EventListener; + use wasm_bindgen::{JsValue as Value, JsCast}; + } +} + +/// A service that facilitates manipulation of the browser's URL bar and responding to browser events +/// when users press 'forward' or 'back'. +/// +/// The `T` determines what route state can be stored in the route service. +#[derive(Debug)] +pub struct RouteService { + history: History, + location: Location, + #[cfg(feature = "std_web")] + event_listener: Option, + #[cfg(feature = "web_sys")] + event_listener: Option, + phantom_data: PhantomData, +} + +impl Default for RouteService +where + STATE: RouteState, +{ + fn default() -> Self { + RouteService::::new() + } +} + +impl RouteService { + /// Creates the route service. + pub fn new() -> RouteService { + let (history, location) = cfg_match! { + feature = "std_web" => ({ + ( + window().history(), + window().location().expect("browser does not support location API") + ) + }), + feature = "web_sys" => ({ + let window = web_sys::window().unwrap(); + ( + window.history().expect("browser does not support history API"), + window.location() + ) + }), + }; + + RouteService { + history, + location, + event_listener: None, + phantom_data: PhantomData, + } + } + + #[inline] + fn get_route_from_location(location: &Location) -> String { + let path = location.pathname().unwrap(); + let query = location.search().unwrap(); + let fragment = location.hash().unwrap(); + format_route_string(&path, &query, &fragment) + } + + /// Gets the path name of the current url. + pub fn get_path(&self) -> String { + self.location.pathname().unwrap() + } + + /// Gets the query string of the current url. + pub fn get_query(&self) -> String { + self.location.search().unwrap() + } + + /// Gets the fragment of the current url. + pub fn get_fragment(&self) -> String { + self.location.hash().unwrap() + } +} + +impl RouteService +where + STATE: RouteState, +{ + /// Registers a callback to the route service. + /// Callbacks will be called when the History API experiences a change such as + /// popping a state off of its stack when the forward or back buttons are pressed. + pub fn register_callback(&mut self, callback: Callback>) { + let cb = move |event: PopStateEvent| { + let state_value: Value = event.state(); + let state_string: String = cfg_match! { + feature = "std_web" => String::try_from(state_value).unwrap_or_default(), + feature = "web_sys" => state_value.as_string().unwrap_or_default(), + }; + let state: STATE = serde_json::from_str(&state_string).unwrap_or_else(|_| { + log::error!("Could not deserialize state string"); + STATE::default() + }); + + // Can't use the existing location, because this is a callback, and can't move it in + // here. + let location: Location = cfg_match! { + feature = "std_web" => window().location().unwrap(), + feature = "web_sys" => web_sys::window().unwrap().location(), + }; + let route: String = Self::get_route_from_location(&location); + + callback.emit(Route { route, state }) + }; + + cfg_if! { + if #[cfg(feature = "std_web")] { + self.event_listener = Some(window().add_event_listener(move |event: PopStateEvent| { + cb(event) + })); + } else if #[cfg(feature = "web_sys")] { + self.event_listener = Some(EventListener::new(web_sys::window().unwrap().as_ref(), "popstate", move |event| { + let event: PopStateEvent = event.clone().dyn_into().unwrap(); + cb(event) + })); + } + }; + } + + /// Sets the browser's url bar to contain the provided route, + /// and creates a history entry that can be navigated via the forward and back buttons. + /// + /// The route should be a relative path that starts with a `/`. + pub fn set_route(&mut self, route: &str, state: STATE) { + let state_string: String = serde_json::to_string(&state).unwrap_or_else(|_| { + log::error!("Could not serialize state string"); + "".to_string() + }); + cfg_match! { + feature = "std_web" => ({ + self.history.push_state(state_string, "", Some(route)); + }), + feature = "web_sys" => ({ + let _ = self.history.push_state_with_url(&Value::from_str(&state_string), "", Some(route)); + }), + }; + } + + /// Replaces the route with another one removing the most recent history event and + /// creating another history event in its place. + pub fn replace_route(&mut self, route: &str, state: STATE) { + let state_string: String = serde_json::to_string(&state).unwrap_or_else(|_| { + log::error!("Could not serialize state string"); + "".to_string() + }); + cfg_match! { + feature = "std_web" => ({ + let _ = self.history.replace_state(state_string, "", Some(route)); + }), + feature = "web_sys" => ({ + let _ = self.history.replace_state_with_url(&Value::from_str(&state_string), "", Some(route)); + }), + }; + } + + /// Gets the concatenated path, query, and fragment. + pub fn get_route(&self) -> Route { + let route_string = Self::get_route_from_location(&self.location); + let state: STATE = get_state_string(&self.history) + .or_else(|| { + log::trace!("History state is empty"); + None + }) + .and_then(|state_string| -> Option { + serde_json::from_str(&state_string) + .ok() + .or_else(|| { + log::error!("Could not deserialize state string"); + None + }) + .and_then(std::convert::identity) // flatten + }) + .unwrap_or_default(); + Route { + route: route_string, + state, + } + } +} + +/// Formats a path, query, and fragment into a string. +/// +/// # Note +/// This expects that all three already have their expected separators (?, #, etc) +pub(crate) fn format_route_string(path: &str, query: &str, fragment: &str) -> String { + format!( + "{path}{query}{fragment}", + path = path, + query = query, + fragment = fragment + ) +} + +fn get_state(history: &History) -> Value { + cfg_match! { + feature = "std_web" => js!( + return @{history}.state; + ), + feature = "web_sys" => history.state().unwrap(), + } +} + +fn get_state_string(history: &History) -> Option { + cfg_match! { + feature = "std_web" => get_state(history).try_into().ok(), + feature = "web_sys" => get_state(history).as_string(), + } +} diff --git a/yew-router/src/switch.rs b/yew-router/src/switch.rs new file mode 100644 index 00000000000..23f0a6b6793 --- /dev/null +++ b/yew-router/src/switch.rs @@ -0,0 +1,235 @@ +//! Parses routes into enums or structs. +use crate::route::Route; +use std::fmt::Write; + +/// Alias to Switch. +/// +/// Eventually Switch will be renamed to Routable and this alias will be removed. +#[allow(bare_trait_objects)] +pub type Routable = Switch; + +/// Derivable routing trait that allows instances of implementors to be constructed from Routes. +/// +/// # Note +/// Don't try to implement this yourself, rely on the derive macro. +/// +/// # Example +/// ``` +/// use yew_router::{route::Route, Switch}; +/// #[derive(Debug, Switch, PartialEq)] +/// enum TestEnum { +/// #[to = "/test/route"] +/// TestRoute, +/// #[to = "/capture/string/{path}"] +/// CaptureString { path: String }, +/// #[to = "/capture/number/{num}"] +/// CaptureNumber { num: usize }, +/// #[to = "/capture/unnamed/{doot}"] +/// CaptureUnnamed(String), +/// } +/// +/// assert_eq!( +/// TestEnum::switch(Route::new_no_state("/test/route")), +/// Some(TestEnum::TestRoute) +/// ); +/// assert_eq!( +/// TestEnum::switch(Route::new_no_state("/capture/string/lorem")), +/// Some(TestEnum::CaptureString { +/// path: "lorem".to_string() +/// }) +/// ); +/// assert_eq!( +/// TestEnum::switch(Route::new_no_state("/capture/number/22")), +/// Some(TestEnum::CaptureNumber { num: 22 }) +/// ); +/// assert_eq!( +/// TestEnum::switch(Route::new_no_state("/capture/unnamed/lorem")), +/// Some(TestEnum::CaptureUnnamed("lorem".to_string())) +/// ); +/// ``` +pub trait Switch: Sized { + /// Based on a route, possibly produce an itself. + fn switch(route: Route) -> Option { + Self::from_route_part(route.route, Some(route.state)).0 + } + + /// Get self from a part of the state + fn from_route_part(part: String, state: Option) -> (Option, Option); + + /// Build part of a route from itself. + fn build_route_section(self, route: &mut String) -> Option; + + /// Called when the key (the named capture group) can't be located. Instead of failing outright, + /// a default item can be provided instead. + /// + /// Its primary motivation for existing is to allow implementing Switch for Option. + /// This doesn't make sense at the moment because this only works for the individual key section + /// - any surrounding literals are pretty much guaranteed to make the parse step fail. + /// because of this, this functionality might be removed in favor of using a nested Switch enum, + /// or multiple variants. + fn key_not_available() -> Option { + None + } +} + +/// Wrapper that requires that an implementor of Switch must start with a `/`. +/// +/// This is needed for any non-derived type provided by yew-router to be used by itself. +/// +/// This is because route strings will almost always start with `/`, so in order to get a std type +/// with the `rest` attribute, without a specified leading `/`, this wrapper is needed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LeadingSlash(pub T); +impl Switch for LeadingSlash { + fn from_route_part(part: String, state: Option) -> (Option, Option) { + if part.starts_with('/') { + let part = part[1..].to_string(); + let (inner, state) = U::from_route_part(part, state); + (inner.map(LeadingSlash), state) + } else { + (None, None) + } + } + + fn build_route_section(self, route: &mut String) -> Option { + write!(route, "/").ok()?; + self.0.build_route_section(route) + } +} + +/// Successfully match even when the captured section can't be found. +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct Permissive(pub Option); + +impl Switch for Permissive { + /// Option is very permissive in what is allowed. + fn from_route_part(part: String, state: Option) -> (Option, Option) { + let (inner, inner_state) = U::from_route_part(part, state); + if inner.is_some() { + (Some(Permissive(inner)), inner_state) + } else { + // The Some(None) here indicates that this will produce a None, if the wrapped value can't be parsed + (Some(Permissive(None)), None) + } + } + + fn build_route_section(self, route: &mut String) -> Option { + if let Some(inner) = self.0 { + inner.build_route_section(route) + } else { + None + } + } + + fn key_not_available() -> Option { + Some(Permissive(None)) + } +} + +// TODO the AllowMissing shim doesn't appear to offer much over Permissive. +// Documentation should improve (need examples - to show the difference) or it should be removed. + +/// Allows a section to match, providing a None value, +/// if its contents are entirely missing, or starts with a '/'. +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct AllowMissing(pub Option); +impl Switch for AllowMissing { + fn from_route_part(part: String, state: Option) -> (Option, Option) { + let route = part.clone(); + let (inner, inner_state) = U::from_route_part(part, state); + + if inner.is_some() { + (Some(AllowMissing(inner)), inner_state) + } else if &route == "" + || (&route).starts_with('/') + || (&route).starts_with('?') + || (&route).starts_with('&') + || (&route).starts_with('#') + { + (Some(AllowMissing(None)), inner_state) + } else { + (None, None) + } + } + + fn build_route_section(self, route: &mut String) -> Option { + if let AllowMissing(Some(inner)) = self { + inner.build_route_section(route) + } else { + None + } + } +} + +/// Builds a route from a switch. +fn build_route_from_switch(switch: SW) -> Route { + // URLs are recommended to not be over 255 characters, + // although browsers frequently support up to about 2000. + // Routes, being a subset of URLs should probably be smaller than 255 characters for the vast + // majority of circumstances, preventing reallocation under most conditions. + let mut buf = String::with_capacity(255); + let state: STATE = switch.build_route_section(&mut buf).unwrap_or_default(); + buf.shrink_to_fit(); + + Route { route: buf, state } +} + +impl From for Route { + fn from(switch: SW) -> Self { + build_route_from_switch(switch) + } +} + +impl Switch for T { + fn from_route_part(part: String, state: Option) -> (Option, Option) { + (::std::str::FromStr::from_str(&part).ok(), state) + } + + fn build_route_section(self, route: &mut String) -> Option { + write!(route, "{}", self).expect("Writing to string should never fail."); + None + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn isize_build_route() { + let mut route = "/".to_string(); + let mut _state: Option = None; + _state = _state.or((-432isize).build_route_section(&mut route)); + assert_eq!(route, "/-432".to_string()); + } + + #[test] + fn can_get_string_from_empty_str() { + let (s, _state) = String::from_route_part::<()>("".to_string(), Some(())); + assert_eq!(s, Some("".to_string())) + } + + #[test] + fn uuid_from_route() { + let x = uuid::Uuid::switch::<()>(Route { + route: "5dc48134-35b5-4b8c-aa93-767bf00ae1d8".to_string(), + state: (), + }); + assert!(x.is_some()) + } + #[test] + fn uuid_to_route() { + use std::str::FromStr; + let id = + uuid::Uuid::from_str("5dc48134-35b5-4b8c-aa93-767bf00ae1d8").expect("should parse"); + let mut buf = String::new(); + id.build_route_section::<()>(&mut buf); + assert_eq!(buf, "5dc48134-35b5-4b8c-aa93-767bf00ae1d8".to_string()) + } + + #[test] + fn can_get_option_string_from_empty_str() { + let (s, _state): (Option>, Option<()>) = + Permissive::from_route_part("".to_string(), Some(())); + assert_eq!(s, Some(Permissive(Some("".to_string())))) + } +}