Repository: ahecha-co/ahecha (language: rust)

Issue:
Page (SSR component + route)



Discussion / hints:

Fix patch:
diff --git a/core/codegen/src/lib.rs b/core/codegen/src/lib.rs
index 61df0d5..1c4e449 100644
--- a/core/codegen/src/lib.rs
+++ b/core/codegen/src/lib.rs
@@ -12,7 +12,9 @@ use syn::{parse_macro_input, ItemFn};
 
 mod custom_element;
 mod html;
+mod page;
 mod partial;
+mod routes;
 mod utils;
 
 #[proc_macro_attribute]
@@ -74,3 +76,10 @@ pub fn partial(_metadata: TokenStream, item: TokenStream) -> TokenStream {
   let f = parse_macro_input!(item as ItemFn);
   partial::create_partial(f)
 }
+
+#[proc_macro_attribute]
+#[proc_macro_error]
+pub fn page(_metadata: TokenStream, item: TokenStream) -> TokenStream {
+  let f = parse_macro_input!(item as ItemFn);
+  page::create_page(f)
+}
diff --git a/core/codegen/src/page.rs b/core/codegen/src/page.rs
new file mode 100644
index 0000000..f36b509
--- /dev/null
+++ b/core/codegen/src/page.rs
@@ -0,0 +1,54 @@
+use proc_macro::TokenStream;
+use proc_macro_error::emit_error;
+use quote::quote;
+
+use crate::{
+  routes::{generate_route_path, RouteType},
+  utils::FnStruct,
+};
+
+pub fn create_page(f: syn::ItemFn) -> TokenStream {
+  let fn_struct: FnStruct = f.into();
+
+  let vis = fn_struct.vis();
+  let struct_name = fn_struct.name();
+  let impl_generics = fn_struct.impl_generics();
+  let ty_generics = fn_struct.type_generics();
+  let where_clause = fn_struct.where_clause();
+  let input_blocks = fn_struct.input_blocks();
+  let input_fields = fn_struct.input_fields();
+
+  let struct_str_name = struct_name.to_string();
+  if struct_str_name.to_uppercase().chars().next().unwrap()
+    != struct_str_name.chars().next().unwrap()
+  {
+    emit_error!(struct_name.span(), "Pages must start with a upper letter");
+  }
+
+  if !struct_str_name.ends_with("Page") {
+    emit_error!(
+      struct_name.span(),
+      "Pages must have the `Page` suffix, example: `{}Page`",
+      struct_str_name
+    );
+  }
+
+  let route = generate_route_path(RouteType::Page, fn_struct.inputs());
+  let uri = route.build_uri();
+  let mount_route = route.build(&fn_struct);
+
+  quote! {
+    #vis struct #struct_name #impl_generics #input_blocks
+
+    impl #ty_generics #struct_name #impl_generics #where_clause {
+      pub fn uri(#input_fields) -> String {
+        #uri
+      }
+
+      pub fn mount(#input_fields) -> String {
+        #mount_route .render()
+      }
+    }
+  }
+  .into()
+}
diff --git a/core/codegen/src/routes.rs b/core/codegen/src/routes.rs
new file mode 100644
index 0000000..45fb057
--- /dev/null
+++ b/core/codegen/src/routes.rs
@@ -0,0 +1,235 @@
+use proc_macro2::{Ident, Span};
+use proc_macro_error::emit_error;
+use quote::quote;
+use syn::{punctuated::Punctuated, spanned::Spanned, token::Comma, FnArg, Pat, PatIdent, PatType};
+
+use crate::utils::FnStruct;
+
+pub enum RouteType {
+  Api,
+  Page,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy)]
+pub enum HttpMethod {
+  Get,
+  Post,
+  Put,
+  Delete,
+  Patch,
+  Head,
+  Options,
+}
+
+impl From<String> for HttpMethod {
+  fn from(method: String) -> Self {
+    match method.to_lowercase().as_str() {
+      "post" => HttpMethod::Post,
+      "put" => HttpMethod::Put,
+      "delete" => HttpMethod::Delete,
+      "patch" => HttpMethod::Patch,
+      "head" => HttpMethod::Head,
+      "options" => HttpMethod::Options,
+      _ => HttpMethod::Get,
+    }
+  }
+}
+
+impl From<Ident> for HttpMethod {
+  fn from(method: Ident) -> Self {
+    match method.to_string().to_lowercase().as_str() {
+      "post" => HttpMethod::Post,
+      "put" => HttpMethod::Put,
+      "delete" => HttpMethod::Delete,
+      "patch" => HttpMethod::Patch,
+      "head" => HttpMethod::Head,
+      "options" => HttpMethod::Options,
+      _ => HttpMethod::Get,
+    }
+  }
+}
+
+impl Into<Ident> for HttpMethod {
+  fn into(self) -> Ident {
+    match self {
+      HttpMethod::Get => Ident::new("get", Span::call_site()),
+      HttpMethod::Post => Ident::new("post", Span::call_site()),
+      HttpMethod::Put => Ident::new("put", Span::call_site()),
+      HttpMethod::Delete => Ident::new("delete", Span::call_site()),
+      HttpMethod::Patch => Ident::new("patch", Span::call_site()),
+      HttpMethod::Head => Ident::new("head", Span::call_site()),
+      HttpMethod::Options => Ident::new("options", Span::call_site()),
+    }
+  }
+}
+
+#[derive(Clone)]
+pub struct RoutePartDynamic {
+  ident: Ident,
+  ty: Box<syn::Type>,
+}
+
+impl RoutePartDynamic {
+  fn from(arg: &FnArg) -> Option<Self> {
+    match arg {
+      FnArg::Typed(PatType { pat, ty, .. }) => {
+        if let Pat::Ident(PatIdent { ident, .. }) = &**pat {
+          Some(RoutePartDynamic {
+            ident: ident.clone(),
+            ty: ty.clone(),
+          })
+        } else {
+          None
+        }
+      }
+      _ => None,
+    }
+  }
+
+  fn cmp(&self, ident: &str) -> bool {
+    &self.ident.to_string() == ident
+  }
+}
+
+impl ToString for RoutePartDynamic {
+  fn to_string(&self) -> String {
+    format!("<{}>", self.ident)
+  }
+}
+
+#[derive(Clone)]
+pub enum RoutePart {
+  Static(String),
+  Dynamic(RoutePartDynamic),
+}
+
+impl ToString for RoutePart {
+  fn to_string(&self) -> String {
+    match self {
+      RoutePart::Static(s) => s.clone(),
+      RoutePart::Dynamic(d) => d.to_string(),
+    }
+  }
+}
+
+// #[derive(Debug)]
+pub struct Route {
+  pub method: HttpMethod,
+  pub parts: Vec<RoutePart>,
+  pub route_type: RouteType,
+}
+
+impl Route {
+  pub fn new(route_type: RouteType, url_path: String, fields: &Punctuated<FnArg, Comma>) -> Self {
+    let mut url_params = fields.iter().flat_map(|arg| RoutePartDynamic::from(arg));
+    let parts = url_path
+      .split('/')
+      .map(|part| {
+        let mut part = if part.ends_with(".rs") {
+          part.get(..part.len() - 3).unwrap().to_string()
+        } else {
+          part.to_string()
+        };
+
+        if part.starts_with("__") && part.ends_with("__") {
+          if let Some(part) =
+            url_params.find(|param| param.cmp(part.get(2..part.len() - 2).unwrap()))
+          {
+            RoutePart::Dynamic(part.clone())
+          } else {
+            emit_error!(part.span(), "route parameter `{}` not found", part);
+            RoutePart::Static(part.to_string())
+          }
+        } else {
+          part = if part == "index" {
+            "".to_string()
+          } else {
+            part
+          };
+
+          RoutePart::Static(part)
+        }
+      })
+      .collect::<Vec<RoutePart>>();
+
+    Self {
+      method: HttpMethod::Get,
+      parts,
+      route_type,
+    }
+  }
+
+  pub fn build(&self, fn_struct: &FnStruct) -> proc_macro2::TokenStream {
+    let block = fn_struct.block();
+
+    quote! {
+      #block
+    }
+  }
+
+  pub fn build_uri(&self) -> proc_macro2::TokenStream {
+    let params = self
+      .parts
+      .iter()
+      .flat_map(|part| match part {
+        RoutePart::Static(_) => None,
+        RoutePart::Dynamic(d) => {
+          let ident = d.ident.clone();
+          Some(quote! { #ident })
+        }
+      })
+      .collect::<Vec<_>>();
+
+    let url_path = self
+      .parts
+      .iter()
+      .map(|part| match part {
+        RoutePart::Static(s) => s.clone(),
+        RoutePart::Dynamic(_) => "{}".to_string(),
+      })
+      .collect::<Vec<_>>()
+      .join("/");
+
+    if params.is_empty() {
+      quote! {
+        #url_path .to_string()
+      }
+    } else {
+      quote! {
+        format!(#url_path, #(#params,)*)
+      }
+    }
+  }
+
+  pub fn params_types(&self) -> proc_macro2::TokenStream {
+    let types = self
+      .parts
+      .iter()
+      .filter_map(|part| match part {
+        RoutePart::Static(_) => None,
+        RoutePart::Dynamic(d) => Some(&d.ty),
+      })
+      .map(|ty| quote!( #ty ))
+      .collect::<Vec<_>>();
+
+    quote!( #(#types),* )
+  }
+}
+
+pub fn generate_route_path(route_type: RouteType, fields: &Punctuated<FnArg, Comma>) -> Route {
+  let span = proc_macro::Span::call_site();
+  let source = span.source_file();
+  let path = source.path().to_str().unwrap().to_owned();
+
+  let url_path = match route_type {
+    RouteType::Api => path.split("/api/"),
+    RouteType::Page => path.split("/page/"),
+  }
+  .map(|s| s.to_string())
+  .collect::<Vec<_>>()
+  .get(1)
+  .unwrap()
+  .to_owned();
+
+  Route::new(route_type, format!("/{}", url_path), fields)
+}
diff --git a/core/codegen/src/utils.rs b/core/codegen/src/utils.rs
index b95e3cf..7f3e861 100644
--- a/core/codegen/src/utils.rs
+++ b/core/codegen/src/utils.rs
@@ -55,6 +55,17 @@ impl FnStruct {
     )
   }
 
+  pub fn input_fields(&self) -> TokenStream {
+    let input_fields = if !self.inputs().is_empty() {
+      let input_names: Vec<_> = self.inputs().iter().collect();
+      quote!(#(#input_names),*,)
+    } else {
+      quote!()
+    };
+
+    quote!(#input_fields)
+  }
+
   pub fn input_readings(&self) -> TokenStream {
     let input_readings = if self.inputs().is_empty() {
       quote!()


Test patch:
diff --git a/core/codegen/tests/app/mod.rs b/core/codegen/tests/app/mod.rs
new file mode 100644
index 0000000..79d2861
--- /dev/null
+++ b/core/codegen/tests/app/mod.rs
@@ -0,0 +1,1 @@
+pub mod page;
diff --git a/core/codegen/tests/app/page/__count__.rs b/core/codegen/tests/app/page/__count__.rs
new file mode 100644
index 0000000..6489048
--- /dev/null
+++ b/core/codegen/tests/app/page/__count__.rs
@@ -0,0 +1,13 @@
+use ahecha::view::Render;
+use ahecha_codegen::*;
+
+mod ahecha {
+  pub use ahecha_view as view;
+}
+
+#[page]
+pub fn CountPage(count: u32) {
+  html! {
+    <div>Test page<span>{ count }</span></div>
+  }
+}
diff --git a/core/codegen/tests/app/page/index.rs b/core/codegen/tests/app/page/index.rs
new file mode 100644
index 0000000..1519d86
--- /dev/null
+++ b/core/codegen/tests/app/page/index.rs
@@ -0,0 +1,13 @@
+use ahecha::view::Render;
+use ahecha_codegen::*;
+
+mod ahecha {
+  pub use ahecha_view as view;
+}
+
+#[page]
+pub fn IndexPage() {
+  html! {
+    <div>Index page</div>
+  }
+}
diff --git a/core/codegen/tests/app/page/mod.rs b/core/codegen/tests/app/page/mod.rs
new file mode 100644
index 0000000..e08de62
--- /dev/null
+++ b/core/codegen/tests/app/page/mod.rs
@@ -0,0 +1,2 @@
+pub mod __count__;
+pub mod index;
diff --git a/core/codegen/tests/page.rs b/core/codegen/tests/page.rs
new file mode 100644
index 0000000..7ee04d2
--- /dev/null
+++ b/core/codegen/tests/page.rs
@@ -0,0 +1,25 @@
+use app::page;
+
+mod app;
+
+mod ahecha {
+  pub use ahecha_view as view;
+}
+
+#[test]
+fn test_generate_route() {
+  assert_eq!(page::index::IndexPage::uri(), "/");
+  assert_eq!(page::__count__::CountPage::uri(5), "/5");
+}
+
+#[test]
+fn test_index_page_request() {
+  let response = page::index::IndexPage::mount();
+  assert_eq!(response, "<div>Index page</div>");
+}
+
+#[test]
+fn test_test_page_request() {
+  let response = page::__count__::CountPage::mount(5);
+  assert_eq!(response, "<div>Test page<span>5</span></div>");
+}