Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(routing): Move route definition to runtime (#10)

* feat(routing): Move route definition to runtime

* fix: doctests

* fix: docs

* fix: params in docs

authored by

Erika and committed by
GitHub
967591b8 c2f8b560

+419 -338
+6 -40
Cargo.lock
··· 1724 1724 "log", 1725 1725 "lol_html", 1726 1726 "maud", 1727 - "maudit-macros 0.2.0", 1728 - "maudit_rolldown", 1729 - "pulldown-cmark", 1730 - "rustc-hash", 1731 - "serde", 1732 - "serde_yml", 1733 - "thiserror 2.0.11", 1734 - "tokio", 1735 - ] 1736 - 1737 - [[package]] 1738 - name = "maudit" 1739 - version = "0.2.0" 1740 - source = "registry+https://github.com/rust-lang/crates.io-index" 1741 - checksum = "3a43d06acbe0b824e6c4d0f66a326db3b632650fd8b5478eea258fd559774935" 1742 - dependencies = [ 1743 - "chrono", 1744 - "colored", 1745 - "dyn-eq", 1746 - "env_logger", 1747 - "glob", 1748 - "log", 1749 - "lol_html", 1750 - "maud", 1751 - "maudit-macros 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 1727 + "maudit-macros", 1752 1728 "maudit_rolldown", 1753 1729 "pulldown-cmark", 1754 1730 "rustc-hash", ··· 1763 1739 version = "0.1.0" 1764 1740 dependencies = [ 1765 1741 "maud", 1766 - "maudit 0.2.0", 1742 + "maudit", 1767 1743 ] 1768 1744 1769 1745 [[package]] ··· 1800 1776 version = "0.1.0" 1801 1777 dependencies = [ 1802 1778 "maud", 1803 - "maudit 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 1779 + "maudit", 1804 1780 ] 1805 1781 1806 1782 [[package]] ··· 1808 1784 version = "0.1.0" 1809 1785 dependencies = [ 1810 1786 "maud", 1811 - "maudit 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 1787 + "maudit", 1812 1788 "serde", 1813 1789 ] 1814 1790 ··· 1817 1793 version = "0.1.0" 1818 1794 dependencies = [ 1819 1795 "maud", 1820 - "maudit 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 1796 + "maudit", 1821 1797 ] 1822 1798 1823 1799 [[package]] ··· 1829 1805 ] 1830 1806 1831 1807 [[package]] 1832 - name = "maudit-macros" 1833 - version = "0.2.0" 1834 - source = "registry+https://github.com/rust-lang/crates.io-index" 1835 - checksum = "0e4e05a151d3aa99a22dce5679fd04b25a21cf0440938b431a199d36f3e705bc" 1836 - dependencies = [ 1837 - "quote", 1838 - "syn 2.0.98", 1839 - ] 1840 - 1841 - [[package]] 1842 1808 name = "maudit-website" 1843 1809 version = "0.1.0" 1844 1810 dependencies = [ 1845 1811 "maud", 1846 - "maudit 0.2.0", 1812 + "maudit", 1847 1813 "serde", 1848 1814 ] 1849 1815
+1 -1
Cargo.toml
··· 3 3 resolver = "2" 4 4 5 5 [workspace.dependencies] 6 - maudit = { path = "crates/framework" } 6 + maudit = { path = "crates/framework", version = "*" } 7 7 maud = { version = "0.26.0" } 8 8 serde = { version = "1.0.216" } 9 9
+7
crates/cli/src/init.rs
··· 275 275 276 276 cargo_toml["package"]["name"] = toml_edit::value(project_name); 277 277 278 + let maudit_intended_version = &cargo_toml["package"]["metadata"]["maudit"]["intended_version"]; 279 + 280 + // If the template is using the workspace version, remove the `workspace = true` property 281 + if let toml_edit::Item::Value(v) = maudit_intended_version { 282 + cargo_toml["dependencies"]["maudit"] = toml_edit::value(v); 283 + } 284 + 278 285 std::fs::write(&cargo_toml_path, cargo_toml.to_string()).unwrap(); 279 286 280 287 Ok(())
+23 -8
crates/framework/src/build.rs
··· 12 12 errors::BuildError, 13 13 logging::print_title, 14 14 page::{DynamicRouteContext, FullPage, RenderResult, RouteContext, RouteParams, RouteType}, 15 + route::{ 16 + extract_params_from_raw_route, get_route_file_path, get_route_type_from_route_params, 17 + get_route_url, ParameterDef, 18 + }, 15 19 BuildOptions, BuildOutput, 16 20 }; 17 21 use colored::{ColoredString, Colorize}; ··· 100 104 101 105 let mut page_count = 0; 102 106 for route in routes { 103 - match route.route_type() { 107 + let params_def = extract_params_from_raw_route(&route.route_raw()); 108 + let route_type = get_route_type_from_route_params(&params_def); 109 + match route_type { 104 110 RouteType::Static => { 105 111 let route_start = SystemTime::now(); 106 112 let mut page_assets = assets::PageAssets { ··· 110 116 }; 111 117 112 118 let params = RouteParams(FxHashMap::default()); 119 + 113 120 let mut content = Content::new(&content_sources.0); 114 121 let mut ctx = RouteContext { 115 122 raw_params: &params, 116 123 content: &mut content, 117 124 assets: &mut page_assets, 118 - current_url: route.url_untyped(&params), 125 + current_url: String::new(), // TODO 119 126 }; 120 127 121 - let (file_path, mut file) = create_route_file(*route, ctx.raw_params, &dist_dir)?; 128 + let (file_path, mut file) = 129 + create_route_file(*route, &params_def, ctx.raw_params, &dist_dir)?; 122 130 let result = route.render_internal(&mut ctx); 123 131 124 132 finish_route( ··· 129 137 route.route_raw(), 130 138 )?; 131 139 132 - info!(target: "build", "{} -> {} {}", route.route(&RouteParams(FxHashMap::default())), file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options).unwrap()); 140 + info!(target: "build", "{} -> {} {}", get_route_url(&route.route_raw(), &params_def, &params), file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options).unwrap()); 133 141 134 142 build_pages_assets.extend(page_assets.assets); 135 143 build_pages_scripts.extend(page_assets.scripts); ··· 152 160 let routes = route.routes_internal(&mut dynamic_route_context); 153 161 154 162 if routes.is_empty() { 155 - info!(target: "build", "{} is a dynamic route, but its implementation of DynamicRoute::routes returned no routes. No pages will be generated for this route.", route.route_raw().to_string().bold()); 163 + info!(target: "build", "{} is a dynamic route, but its implementation of Page::routes returned an empty Vec. No pages will be generated for this route.", route.route_raw().to_string().bold()); 156 164 continue; 157 165 } else { 158 166 info!(target: "build", "{}", route.route_raw().to_string().bold()); ··· 170 178 raw_params: &params, 171 179 content: &mut content, 172 180 assets: &mut pages_assets, 173 - current_url: route.url_untyped(&params), 181 + current_url: String::new(), // TODO 174 182 }; 175 183 176 184 let (file_path, mut file) = 177 - create_route_file(*route, ctx.raw_params, &dist_dir)?; 185 + create_route_file(*route, &params_def, ctx.raw_params, &dist_dir)?; 186 + 178 187 let result = route.render_internal(&mut ctx); 179 188 180 189 build_metadata.add_page( ··· 315 324 316 325 fn create_route_file( 317 326 route: &dyn FullPage, 327 + params_def: &Vec<ParameterDef>, 318 328 params: &RouteParams, 319 329 dist_dir: &Path, 320 330 ) -> Result<(PathBuf, File), Box<dyn std::error::Error>> { 321 - let file_path = dist_dir.join(route.file_path(params)); 331 + let file_path = dist_dir.join(get_route_file_path( 332 + &route.route_raw(), 333 + params_def, 334 + params, 335 + route.is_endpoint(), 336 + )); 322 337 323 338 // Create the parent directories if it doesn't exist 324 339 if let Some(parent_dir) = file_path.parent() {
+11 -18
crates/framework/src/content.rs
··· 1 1 //! Core functions and structs to define the content sources of your website. 2 2 //! 3 - //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. Typically used in [`DynamicRoute`](crate::page::DynamicRoute). 3 + //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. 4 4 use std::{any::Any, path::PathBuf}; 5 5 6 6 use glob::glob as glob_fs; ··· 115 115 /// pub article: String, 116 116 /// } 117 117 /// 118 - /// impl DynamicRoute<ArticleParams> for Article { 119 - /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 120 - /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 121 - /// 122 - /// articles.into_params(|entry| ArticleParams { 123 - /// article: entry.id.clone(), 124 - /// }) 125 - /// } 126 - /// } 127 - /// 128 - /// impl Page for Article { 118 + /// impl Page<ArticleParams> for Article { 129 119 /// fn render(&self, ctx: &mut RouteContext) -> RenderResult { 130 120 /// let params = ctx.params::<ArticleParams>(); 131 121 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 132 122 /// let article = articles.get_entry(&params.article); 133 123 /// article.render().into() 124 + /// } 125 + /// 126 + /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 127 + /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 128 + /// 129 + /// articles.into_params(|entry| ArticleParams { 130 + /// article: entry.id.clone(), 131 + /// }) 134 132 /// } 135 133 /// } 136 134 /// ``` ··· 346 344 /// #[route("/articles/my-article")] 347 345 /// pub struct Article; 348 346 /// 349 - /// #[derive(Params)] 350 - /// pub struct ArticleParams { 351 - /// pub article: String, 352 - /// } 353 - /// 354 - /// impl Page<Markup> for Article { 347 + /// impl Page<RouteParams, Markup> for Article { 355 348 /// fn render(&self, ctx: &mut RouteContext) -> Markup { 356 349 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 357 350 /// let article = articles.get_entry("my-article");
+6 -4
crates/framework/src/lib.rs
··· 12 12 pub mod page; 13 13 pub mod params; 14 14 15 + mod route; 16 + 15 17 // Exports for end-users 16 18 pub use build::metadata::{BuildOutput, PageOutput, StaticAssetOutput}; 17 19 pub use build::options::BuildOptions; ··· 37 39 //! #[route("/")] 38 40 //! pub struct Index; 39 41 //! 40 - //! impl Page<Markup> for Index { 42 + //! impl Page<RouteParams, Markup> for Index { 41 43 //! fn render(&self, ctx: &mut RouteContext) -> Markup { 42 44 //! html! { 43 45 //! h1 { "Hello, world!" } ··· 72 74 /// # 73 75 /// # #[route("/")] 74 76 /// # pub struct Index; 75 - /// # impl Page<String> for Index { 77 + /// # impl Page<RouteParams, String> for Index { 76 78 /// # fn render(&self, _ctx: &mut RouteContext) -> String { 77 79 /// # "Hello, world!".to_string() 78 80 /// # } ··· 80 82 /// # #[route("/article")] 81 83 /// # pub struct Article; 82 84 /// # 83 - /// # impl Page<String> for Article { 85 + /// # impl Page<RouteParams, String> for Article { 84 86 /// # fn render(&self, _ctx: &mut RouteContext) -> String { 85 87 /// # "Hello, world!".to_string() 86 88 /// # } ··· 97 99 /// ``` 98 100 /// 99 101 macro_rules! routes { 100 - [$($route:path),*] => { 102 + [$($route:expr),*] => { 101 103 &[$(&$route),*] 102 104 }; 103 105 }
+27 -62
crates/framework/src/page.rs
··· 1 1 //! Core traits and structs to define the pages of your website. 2 2 //! 3 - //! Every page must implement the [`Page`] trait, and optionally the [`DynamicRoute`] trait. Then, pages can be passed to [`coronate()`](crate::coronate), through the [`routes!`](crate::routes) macro, to be built. 3 + //! Every page must implement the [`Page`] trait. Then, pages can be passed to [`coronate()`](crate::coronate), through the [`routes!`](crate::routes) macro, to be built. 4 4 use crate::assets::PageAssets; 5 5 use crate::content::Content; 6 + use crate::route::{extract_params_from_raw_route, get_route_url, guess_if_route_is_endpoint}; 6 7 use rustc_hash::FxHashMap; 7 - use std::path::PathBuf; 8 8 9 9 /// Represents the result of a page render, can be either text or raw bytes. 10 10 /// ··· 108 108 } 109 109 } 110 110 111 - /// Allows to access the content source in a [`DynamicRoute`] implementation. 111 + /// Allows to access the content source in the [`Page::routes`] method. 112 112 /// 113 113 /// ## Example 114 114 /// ```rust ··· 129 129 /// pub article: String, 130 130 /// } 131 131 /// 132 - /// impl DynamicRoute<ArticleParams> for Article { 133 - /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 134 - /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 135 - /// 136 - /// articles.into_params(|entry| ArticleParams { 137 - /// article: entry.id.clone(), 138 - /// }) 139 - /// } 140 - /// } 141 - /// 142 - /// impl Page for Article { 132 + /// impl Page<ArticleParams> for Article { 143 133 /// fn render(&self, ctx: &mut RouteContext) -> RenderResult { 144 134 /// let params = ctx.params::<ArticleParams>(); 145 135 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 146 136 /// let article = articles.get_entry(&params.article); 147 137 /// article.render().into() 148 138 /// } 139 + /// 140 + /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 141 + /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 142 + /// 143 + /// articles.into_params(|entry| ArticleParams { 144 + /// article: entry.id.clone(), 145 + /// }) 146 + /// } 149 147 /// } 150 148 /// ``` 151 149 pub struct DynamicRouteContext<'a> { ··· 169 167 /// } 170 168 /// } 171 169 /// ``` 172 - pub trait Page<T = RenderResult> 170 + pub trait Page<P = RouteParams, T = RenderResult> 173 171 where 172 + P: Into<RouteParams>, 174 173 T: Into<RenderResult>, 175 174 { 175 + fn routes(&self, _ctx: &mut DynamicRouteContext) -> Vec<P> { 176 + Vec::new() 177 + } 176 178 fn render(&self, ctx: &mut RouteContext) -> T; 177 179 } 178 180 ··· 211 213 } 212 214 } 213 215 214 - /// Must be implemented for every dynamic route of your website. 215 - /// 216 - /// Dynamic route allows creating many pages that share the same structure and logic, but with different content. Typically used for a [`ContentSource`](crate::content::ContentSource). 217 - /// 218 - /// ## Example 219 - /// ```rust 220 - /// use maudit::page::prelude::*; 221 - /// 222 - /// #[route("/tags/[id]")] 223 - /// pub struct Tags; 224 - /// 225 - /// #[derive(Params)] 226 - /// struct Params { 227 - /// id: String, 228 - /// } 229 - /// 230 - /// impl DynamicRoute for Tags { 231 - /// fn routes(&self, context: &mut DynamicRouteContext) -> Vec<RouteParams> { 232 - /// let tags = vec!["rust", "web", "programming"].iter().map(|tag| Params { id: tag.to_string() }).collect(); 233 - /// RouteParams::from_vec(tags) 234 - /// } 235 - /// } 236 - /// 237 - /// impl Page for Tags { 238 - /// fn render(&self, ctx: &mut RouteContext) -> RenderResult { 239 - /// let tag = ctx.params::<Params>().id; 240 - /// format!("<h1>Tag: {}</h1>", tag).into() 241 - /// } 242 - /// } 243 - /// ``` 244 - pub trait DynamicRoute<P = RouteParams> 245 - where 246 - P: Into<RouteParams>, 247 - { 248 - // Intentionally does not have a default implementation even though it'd be useful in our macros in order to force 249 - // the user to implement it explicitly, even if it's just returning an empty Vec. 250 - fn routes(&self, context: &mut DynamicRouteContext) -> Vec<P>; 251 - } 252 - 253 216 #[doc(hidden)] 217 + #[derive(PartialEq, Eq, Debug)] 254 218 /// Used internally by Maudit and should not be implemented by the user. 255 219 /// We expose it because [`maudit_macros::route`] implements it for the user behind the scenes. 256 220 pub enum RouteType { ··· 262 226 /// Used internally by Maudit and should not be implemented by the user. 263 227 /// We expose it because the derive macro implements it for the user behind the scenes. 264 228 pub trait InternalPage { 265 - fn route_type(&self) -> RouteType; 266 229 fn route_raw(&self) -> String; 267 - fn route(&self, params: &RouteParams) -> String; 268 - fn file_path(&self, params: &RouteParams) -> PathBuf; 269 - fn url_unsafe<P: Into<RouteParams>>(params: P) -> String 270 - where 271 - Self: Sized; 272 - fn url_untyped(&self, params: &RouteParams) -> String; 230 + fn is_endpoint(&self) -> bool { 231 + guess_if_route_is_endpoint(&self.route_raw()) 232 + } 273 233 } 274 234 275 235 #[doc(hidden)] ··· 280 240 fn routes_internal(&self, context: &mut DynamicRouteContext) -> Vec<RouteParams>; 281 241 } 282 242 243 + pub fn get_page_url<T: Into<RouteParams>>(route: impl FullPage, params: T) -> String { 244 + let params_defs = extract_params_from_raw_route(&route.route_raw()); 245 + get_route_url(&route.route_raw(), &params_defs, &params.into()) 246 + } 247 + 283 248 pub mod prelude { 284 249 //! Re-exports of the most commonly used types and traits for defining pages. 285 250 //! ··· 290 255 //! use maudit::page::prelude::*; 291 256 //! ``` 292 257 pub use super::{ 293 - DynamicRoute, DynamicRouteContext, Page, RenderResult, RouteContext, RouteParams, 258 + get_page_url, DynamicRouteContext, Page, RenderResult, RouteContext, RouteParams, 294 259 }; 295 260 #[doc(hidden)] 296 261 pub use super::{FullPage, InternalPage};
+281
crates/framework/src/route.rs
··· 1 + use std::path::Path; 2 + 3 + use crate::page::{RouteParams, RouteType}; 4 + 5 + #[derive(Debug, PartialEq)] 6 + pub struct ParameterDef { 7 + key: String, 8 + index: usize, 9 + length: usize, 10 + } 11 + 12 + pub fn extract_params_from_raw_route(raw_route: &str) -> Vec<ParameterDef> { 13 + let mut params = Vec::new(); 14 + let mut start = false; 15 + let mut escape = false; 16 + let mut current_value = String::new(); 17 + 18 + for (i, c) in raw_route.char_indices() { 19 + if escape { 20 + escape = false; 21 + if start { 22 + current_value.push(c); 23 + } 24 + continue; 25 + } 26 + 27 + match c { 28 + '\\' => { 29 + escape = true; 30 + } 31 + '[' => { 32 + if !escape { 33 + start = true; 34 + current_value.clear(); 35 + } 36 + } 37 + ']' => { 38 + if start { 39 + params.push(ParameterDef { 40 + key: current_value.clone(), 41 + index: i - (current_value.len() + 1), // -1 for the starting [ 42 + length: current_value.len() + 2, // +2 for the [ and ] 43 + }); 44 + start = false; 45 + } 46 + } 47 + _ => { 48 + if start { 49 + current_value.push(c); 50 + } 51 + } 52 + } 53 + } 54 + 55 + params 56 + } 57 + 58 + pub fn get_route_type_from_route_params(params_def: &[ParameterDef]) -> RouteType { 59 + if params_def.is_empty() { 60 + RouteType::Static 61 + } else { 62 + RouteType::Dynamic 63 + } 64 + } 65 + 66 + /// "/articles/[article]" (params: Hashmap {article: "truc"}) -> "articles/truc/index.html" 67 + pub fn get_route_file_path( 68 + raw_route: &str, 69 + params_def: &Vec<ParameterDef>, 70 + params: &RouteParams, 71 + is_endpoint: bool, 72 + ) -> String { 73 + // Replace every param_def with the value from the params hashmap for said key 74 + // So, ex: "/articles/[article]" (params: Hashmap {article: "truc"}) -> "/articles/truc" 75 + let mut route = String::from(raw_route); 76 + 77 + for param_def in params_def { 78 + let value = params.0.get(&param_def.key); 79 + 80 + match value { 81 + Some(value) => { 82 + route.replace_range(param_def.index..param_def.index + param_def.length, value); 83 + } 84 + None => { 85 + panic!( 86 + "Route {:?} is missing parameter {:?}", 87 + raw_route, param_def.key 88 + ); 89 + } 90 + } 91 + } 92 + 93 + let cleaned_raw_route = route.trim_start_matches('/').to_string(); 94 + 95 + match is_endpoint { 96 + true => cleaned_raw_route, 97 + false => match cleaned_raw_route.is_empty() { 98 + true => "index.html".to_string(), 99 + false => format!("{}/index.html", cleaned_raw_route), 100 + }, 101 + } 102 + } 103 + 104 + pub fn get_route_url( 105 + raw_route: &str, 106 + params_def: &Vec<ParameterDef>, 107 + params: &RouteParams, 108 + ) -> String { 109 + // Replace every param_def with the value from the params hashmap for said key 110 + // So, ex: "/articles/[article]" (params: Hashmap {article: "truc"}) -> "/articles/truc" 111 + let mut route = String::from(raw_route); 112 + 113 + for param_def in params_def { 114 + let value = params.0.get(&param_def.key); 115 + 116 + match value { 117 + Some(value) => { 118 + route.replace_range(param_def.index..param_def.index + param_def.length, value); 119 + } 120 + None => { 121 + panic!( 122 + "Route {:?} is missing parameter {:?}", 123 + raw_route, param_def.key 124 + ); 125 + } 126 + } 127 + } 128 + 129 + route 130 + } 131 + 132 + pub fn guess_if_route_is_endpoint(raw_route: &str) -> bool { 133 + let real_path = Path::new(&raw_route); 134 + 135 + real_path.extension().is_some() 136 + } 137 + 138 + #[cfg(test)] 139 + mod tests { 140 + use crate::{ 141 + page::{RouteParams, RouteType}, 142 + route::{ 143 + extract_params_from_raw_route, get_route_file_path, get_route_type_from_route_params, 144 + ParameterDef, 145 + }, 146 + }; 147 + 148 + #[test] 149 + fn test_extract_params() { 150 + let input = "/articles/[article]"; 151 + let expected = vec![ParameterDef { 152 + key: "article".to_string(), 153 + index: 10, 154 + length: 9, 155 + }]; 156 + 157 + assert_eq!(extract_params_from_raw_route(input), expected); 158 + } 159 + 160 + #[test] 161 + fn test_extract_params_multiple() { 162 + let input = "/articles/[article]/[id]"; 163 + let expected = vec![ 164 + ParameterDef { 165 + key: "article".to_string(), 166 + index: 10, 167 + length: 9, 168 + }, 169 + ParameterDef { 170 + key: "id".to_string(), 171 + index: 20, 172 + length: 4, 173 + }, 174 + ]; 175 + 176 + assert_eq!(extract_params_from_raw_route(input), expected); 177 + } 178 + 179 + #[test] 180 + fn test_extract_params_no_params() { 181 + let input = "/articles"; 182 + let expected: Vec<ParameterDef> = Vec::new(); 183 + 184 + assert_eq!(extract_params_from_raw_route(input), expected); 185 + } 186 + 187 + #[test] 188 + fn test_extract_params_escaped() { 189 + let input = "/articles/\\[article\\]"; 190 + let expected: Vec<ParameterDef> = Vec::new(); 191 + 192 + assert_eq!(extract_params_from_raw_route(input), expected); 193 + } 194 + 195 + #[test] 196 + fn test_extract_params_escaped_brackets() { 197 + let input = "/articles/\\[article\\]/\\[id\\]"; 198 + let expected: Vec<ParameterDef> = Vec::new(); 199 + 200 + assert_eq!(extract_params_from_raw_route(input), expected); 201 + } 202 + 203 + #[test] 204 + fn test_extract_params_escaped_brackets_with_params() { 205 + let input = "/articles/\\[article\\]/[id]"; 206 + let expected = vec![ParameterDef { 207 + key: "id".to_string(), 208 + index: 22, 209 + length: 4, 210 + }]; 211 + 212 + assert_eq!(extract_params_from_raw_route(input), expected); 213 + } 214 + 215 + #[test] 216 + fn test_route_type_static() { 217 + let input = "/articles"; 218 + let params_def = extract_params_from_raw_route(input); 219 + assert_eq!( 220 + get_route_type_from_route_params(&params_def), 221 + RouteType::Static 222 + ); 223 + } 224 + 225 + #[test] 226 + fn test_route_type_dynamic() { 227 + let input = "/articles/[article]"; 228 + let params_def = extract_params_from_raw_route(input); 229 + assert_eq!( 230 + get_route_type_from_route_params(&params_def), 231 + RouteType::Dynamic 232 + ); 233 + } 234 + 235 + #[test] 236 + fn test_route_type_dynamic_multiple() { 237 + let input = "/articles/[article]/[id]"; 238 + let params_def = extract_params_from_raw_route(input); 239 + assert_eq!( 240 + get_route_type_from_route_params(&params_def), 241 + RouteType::Dynamic 242 + ); 243 + } 244 + 245 + #[test] 246 + fn test_route_type_dynamic_escaped() { 247 + let input = "/articles/\\[article\\]"; 248 + let params_def = extract_params_from_raw_route(input); 249 + assert_eq!( 250 + get_route_type_from_route_params(&params_def), 251 + RouteType::Static 252 + ); 253 + } 254 + 255 + #[test] 256 + fn test_route_type_dynamic_mixed_escaped_brackets() { 257 + let input = "/articles/\\[article\\]/[id]"; 258 + let params_def = extract_params_from_raw_route(input); 259 + assert_eq!( 260 + get_route_type_from_route_params(&params_def), 261 + RouteType::Dynamic 262 + ); 263 + } 264 + 265 + #[test] 266 + fn test_get_route_file_path() { 267 + let raw_route = "/articles/[article]"; 268 + let is_endpoint = false; 269 + let params_def = extract_params_from_raw_route(raw_route); 270 + let mut params = RouteParams::default(); 271 + 272 + params 273 + .0 274 + .insert("article".to_string(), "something".to_string()); 275 + 276 + assert_eq!( 277 + get_route_file_path(raw_route, &params_def, &params, is_endpoint), 278 + "articles/something/index.html" 279 + ); 280 + } 281 + }
+9 -159
crates/macros/src/lib.rs
··· 1 - use std::path::Path; 2 - 3 1 use proc_macro::TokenStream; 4 - use quote::{format_ident, quote}; 2 + use quote::quote; 5 3 use syn::parse::{self, Parse, ParseStream, Parser as _, Result}; 6 - use syn::{parse_macro_input, ItemStruct, LitStr}; 4 + use syn::{parse_macro_input, Expr, ItemStruct}; 7 5 8 6 struct Args { 9 - path: LitStr, 10 - is_endpoint_file: bool, 7 + path: Expr, 11 8 } 12 9 13 10 impl Parse for Args { 14 11 fn parse(input: ParseStream) -> Result<Self> { 15 - let path = input.parse::<LitStr>()?; 12 + let path = input.parse()?; 16 13 17 - // If the path ends with a file extension, it is a file, handle any file extensions 18 - 19 - let binding = path.value(); 20 - let real_path = Path::new(&binding); 21 - 22 - Ok(Args { 23 - path, 24 - is_endpoint_file: real_path.extension().is_some(), 25 - }) 14 + Ok(Args { path }) 26 15 } 27 16 } 28 17 ··· 33 22 let attrs = syn::parse_macro_input!(attrs as Args); 34 23 35 24 let struct_name = &item_struct.ident; 36 - 37 - let params = extract_values(&attrs.path.value()); 38 - 39 - let dynamic_page_impl = match params.is_empty() { 40 - false => quote! { 41 - fn routes_internal(&self, ctx: &mut maudit::page::DynamicRouteContext) -> Vec<maudit::page::RouteParams> { 42 - self.routes(ctx).iter().map(Into::into).collect() 43 - } 44 - }, 45 - true => quote! { 46 - fn routes_internal(&self, _: &mut maudit::page::DynamicRouteContext) -> Vec<maudit::page::RouteParams> { 47 - Vec::new() 48 - } 49 - }, 50 - }; 51 - 52 - let path = attrs.path.value(); 53 - 54 - let list_params = params 55 - .iter() 56 - .map(|v| { 57 - let key = format_ident!("{}", v.key); 58 - quote! { let #key = params.0.get(stringify!(#key)).unwrap().to_string() } 59 - }) 60 - .collect::<Vec<_>>(); 61 - 62 - let path_for_route = make_params_dynamic(&path, &params, 0); 63 - let file_path_for_route = url_to_file_path(&path, attrs.is_endpoint_file, &params); 64 - 65 - let route_type = if params.is_empty() { 66 - quote! { maudit::page::RouteType::Static } 67 - } else { 68 - quote! { maudit::page::RouteType::Dynamic } 69 - }; 25 + let path = &attrs.path; 70 26 71 27 let expanded = quote! { 72 28 impl maudit::page::InternalPage for #struct_name { 73 - fn route_type(&self) -> maudit::page::RouteType { 74 - #route_type 75 - } 76 - 77 29 fn route_raw(&self) -> String { 78 30 #path.to_string() 79 31 } 80 - 81 - fn route(&self, params: &maudit::page::RouteParams) -> String { 82 - #(#list_params;)* 83 - return format!(#path_for_route); 84 - } 85 - 86 - fn file_path(&self, params: &maudit::page::RouteParams) -> std::path::PathBuf { 87 - #(#list_params;)* 88 - std::path::PathBuf::from(format!(#file_path_for_route)) 89 - } 90 - 91 - fn url_unsafe<P: Into<maudit::page::RouteParams>>(params: P) -> String { 92 - let params = params.into(); 93 - #(#list_params;)* 94 - format!(#path_for_route) 95 - } 96 - 97 - fn url_untyped(&self, params: &maudit::page::RouteParams) -> String { 98 - #(#list_params;)* 99 - format!(#path_for_route) 100 - } 101 32 } 102 33 103 34 impl maudit::page::FullPage for #struct_name { ··· 105 36 self.render(ctx).into() 106 37 } 107 38 108 - #dynamic_page_impl 39 + fn routes_internal(&self, ctx: &mut maudit::page::DynamicRouteContext) -> Vec<maudit::page::RouteParams> { 40 + self.routes(ctx).iter().map(Into::into).collect() 41 + } 109 42 } 110 43 111 44 #item_struct 112 45 }; 113 46 114 47 TokenStream::from(expanded) 115 - } 116 - 117 - struct Parameter { 118 - key: String, 119 - index: usize, 120 - length: usize, 121 - } 122 - 123 - // Naive implementation to extract dynamic values from a path 124 - fn extract_values(input: &str) -> Vec<Parameter> { 125 - let input = input.trim_matches('"'); 126 - let mut values = Vec::new(); 127 - let mut start = false; 128 - let mut current_value = String::new(); 129 - let mut start_index = 0; 130 - 131 - for (i, c) in input.chars().enumerate() { 132 - match c { 133 - '[' => { 134 - start = true; 135 - current_value.clear(); 136 - start_index = i; 137 - } 138 - ']' => { 139 - if start { 140 - values.push(Parameter { 141 - key: current_value.clone(), 142 - index: start_index, 143 - length: i - start_index + 1, 144 - }); 145 - start = false; 146 - } 147 - } 148 - _ => { 149 - if start { 150 - current_value.push(c); 151 - } 152 - } 153 - } 154 - } 155 - 156 - values 157 - } 158 - 159 - fn url_to_file_path(url: &str, is_file: bool, params: &[Parameter]) -> String { 160 - let file_path = match is_file { 161 - false => { 162 - // Remove the leading '/' from the URL if it exists 163 - let path_str = url.trim_start_matches('/'); 164 - 165 - // If the URL is empty (i.e., root), return "index.html" 166 - if path_str.is_empty() { 167 - return "index.html".to_string(); 168 - } 169 - 170 - format!("{}/index.html", path_str) 171 - } 172 - true => { 173 - // Remove the leading '/' from the URL if it exists 174 - let path_str = url.trim_start_matches('/'); 175 - 176 - // If the URL is empty (i.e., root), return "index.html" 177 - if path_str.is_empty() { 178 - panic!("Invalid file path"); 179 - } 180 - 181 - path_str.to_string() 182 - } 183 - }; 184 - 185 - make_params_dynamic(&file_path, params, 1) 186 - } 187 - 188 - fn make_params_dynamic(file_path: &str, params: &[Parameter], offset: usize) -> String { 189 - let mut file_path = file_path.to_string(); 190 - for param in params.iter().rev() { 191 - file_path.replace_range( 192 - param.index - offset..param.index + param.length - offset, 193 - &format!("{{{}}}", param.key), 194 - ); 195 - } 196 - 197 - file_path 198 48 } 199 49 200 50 #[proc_macro_derive(Params)]
+3 -1
crates/md-benchmark/src/main.rs
··· 13 13 println!("Building with {} markdown files", markdown_count); 14 14 15 15 coronate( 16 - routes![page::Article], 16 + routes![page::Article { 17 + route: "/yeehaw/[file]".to_string() 18 + }], 17 19 content_sources!["articles" => glob_markdown::<UntypedMarkdownContent>(&format!("content/{}/*.md", markdown_count))], 18 20 BuildOptions::default(), 19 21 )
+5 -5
crates/md-benchmark/src/page.rs
··· 1 1 use maud::{html, PreEscaped}; 2 2 use maudit::{content::UntypedMarkdownContent, page::prelude::*}; 3 3 4 - #[route("/[file]")] 5 - pub struct Article; 4 + #[route(self.route)] 5 + pub struct Article { 6 + pub route: String, 7 + } 6 8 7 9 #[derive(Params)] 8 10 struct Params { 9 11 file: String, 10 12 } 11 13 12 - impl DynamicRoute<Params> for Article { 14 + impl Page<Params> for Article { 13 15 fn routes(&self, context: &mut DynamicRouteContext) -> Vec<Params> { 14 16 context 15 17 .content ··· 18 20 file: entry.id.clone(), 19 21 }) 20 22 } 21 - } 22 23 23 - impl Page for Article { 24 24 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 25 25 let params = ctx.params::<Params>(); 26 26 let entry = ctx
+4 -1
examples/basics/Cargo.toml
··· 4 4 edition = "2021" 5 5 publish = false 6 6 7 + [package.metadata.maudit] 8 + intended_version = "0.2.0" 9 + 7 10 [dependencies] 8 - maudit = "0.2.0" 11 + maudit = { workspace = true } 9 12 maud = "0.26.0"
+4 -1
examples/blog/Cargo.toml
··· 4 4 edition = "2021" 5 5 publish = false 6 6 7 + [package.metadata.maudit] 8 + intended_version = "0.2.0" 9 + 7 10 [dependencies] 8 - maudit = "0.2.0" 11 + maudit = { workspace = true } 9 12 maud = "0.26.0" 10 13 serde = { version = "1.0.216" }
+1 -3
examples/blog/src/pages/article.rs
··· 11 11 pub article: String, 12 12 } 13 13 14 - impl DynamicRoute<ArticleParams> for Article { 14 + impl Page<ArticleParams, Markup> for Article { 15 15 fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 16 16 let articles = ctx.content.get_source::<ArticleContent>("articles"); 17 17 ··· 19 19 article: entry.id.clone(), 20 20 }) 21 21 } 22 - } 23 22 24 - impl Page<Markup> for Article { 25 23 fn render(&self, ctx: &mut RouteContext) -> Markup { 26 24 let params = ctx.params::<ArticleParams>(); 27 25 let articles = ctx.content.get_source::<ArticleContent>("articles");
+1 -1
examples/blog/src/pages/index.rs
··· 18 18 ul { 19 19 @for entry in &articles.entries { 20 20 li { 21 - a href=(Article::url_unsafe(ArticleParams { article: entry.id.clone() })) { 21 + a href=(get_page_url(Article, ArticleParams { article: entry.id.clone() })) { 22 22 h2 { (entry.data.title) } 23 23 } 24 24 p { (entry.data.description) }
+4 -1
examples/kitchen-sink/Cargo.toml
··· 4 4 edition = "2021" 5 5 publish = false 6 6 7 + [package.metadata.maudit] 8 + intended_version = "0.2.0" 9 + 7 10 [dependencies] 8 - maudit = "0.2.0" 11 + maudit = { workspace = true } 9 12 maud = "0.26.0"
+1 -3
examples/kitchen-sink/src/pages/dynamic.rs
··· 10 10 pub page: u128, 11 11 } 12 12 13 - impl DynamicRoute<Params> for DynamicExample { 13 + impl Page<Params> for DynamicExample { 14 14 fn routes(&self, _: &mut DynamicRouteContext) -> Vec<Params> { 15 15 (0..1).map(|i| Params { page: i }).collect() 16 16 } 17 - } 18 17 19 - impl Page for DynamicExample { 20 18 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 21 19 let params = ctx.params::<Params>(); 22 20 let image = ctx.assets.add_image("data/social-card.png");
+1 -1
examples/kitchen-sink/src/pages/index.rs
··· 13 13 let script = ctx.assets.add_script("data/some_other_script.js"); 14 14 let style = ctx.assets.add_style("data/tailwind.css", true); 15 15 16 - let link_to_first_dynamic = DynamicExample::url_unsafe(&DynamicExampleParams { page: 1 }); 16 + let link_to_first_dynamic = get_page_url(DynamicExample, &DynamicExampleParams { page: 1 }); 17 17 18 18 html! { 19 19 head {
+2 -2
website/content/docs/assets.md
··· 55 55 #[route("/blog")] 56 56 pub struct Blog; 57 57 58 - impl Page<Markup> for Blog { 58 + impl Page<RouteParams, Markup> for Blog { 59 59 fn render(&self, ctx: &mut RouteContext) -> Markup { 60 60 let style = ctx.assets.add_style("style.css", false); 61 61 ··· 111 111 #[route("/blog")] 112 112 pub struct Blog; 113 113 114 - impl Page<Markup> for Blog { 114 + impl Page<RouteParams, Markup> for Blog { 115 115 fn render(&self, ctx: &mut RouteContext) -> Markup { 116 116 let script = ctx.assets.add_script("script.js"); 117 117
+18 -18
website/content/docs/routing.md
··· 6 6 7 7 ### Static Routes 8 8 9 - Maudit uses a simple and intuitive API to define routes and pages. To create a new page, define a struct that implements the `Page` trait, adding the `#[route]` attribute to the struct definition with the path of the route as an argument. 9 + To create a new page in your Maudit project, create a struct and implement the `Page` trait for it, adding the `#[route]` attribute to the struct definition with the path of the route as an argument. The path can be any Rust expression, as long as it returns a `String`. 10 10 11 11 ```rust 12 12 use maudit::page::prelude::*; ··· 27 27 28 28 ### Ergonomic returns 29 29 30 - The `Page` trait accepts a generic parameter for the return type of the `render` method. This type must implement `Into<RenderResult>`, enabling more ergonomic returns in certain cases. 30 + The `Page` trait accepts a generic parameter in second position for the return type of the `render` method. This type must implement `Into<RenderResult>`, enabling more ergonomic returns in certain cases. 31 31 32 32 ```rust 33 - impl Page<String> for HelloWorld { 33 + impl Page<RouteParams, String> for HelloWorld { 34 34 fn render(&self, ctx: &mut RouteContext) -> String { 35 35 "Hello, world!".to_string() 36 36 } ··· 63 63 } 64 64 ``` 65 65 66 - In addition to the `Page` trait, dynamic routes must implement the `DynamicRoute` trait for their struct. This trait requires a `routes` function that returns a list of all the possible values for each parameter in the route's path. 66 + In addition to the `render` method, dynamic routes must implement a `routes` method for Page. The `routes` method returns a list of all the possible values for each parameter in the route's path, so that Maudit can generate all the necessary pages. 67 67 68 68 ```rust 69 69 use maudit::{page::prelude::*, FxHashMap}; ··· 71 71 #[route("/posts/[slug]")] 72 72 pub struct Post; 73 73 74 - impl DynamicRoute for Post { 74 + impl Page for Post { 75 + fn render(&self, ctx: &mut RouteContext) -> RenderResult { 76 + RenderResult::Text(format!("Hello, {}!", ctx.params.get("slug").unwrap())) 77 + } 78 + 75 79 fn routes(&self, ctx: &DynamicRouteContext) -> Vec<RouteParams> { 76 80 let mut routes = FxHashMap::default(); 77 81 routes.insert("slug".to_string(), "hello-world".to_string()); ··· 79 83 vec![RouteParams(routes)] 80 84 } 81 85 } 82 - 83 - impl Page for Post { 84 - fn render(&self, ctx: &mut RouteContext) -> RenderResult { 85 - RenderResult::Text(format!("Hello, {}!", ctx.params.get("slug").unwrap())) 86 - } 87 - } 88 86 ``` 89 87 90 88 The `RouteParams` type is a [newtype](https://doc.rust-lang.org/rust-by-example/generics/new_types.html) around a `FxHashMap<String, String>`, representing the raw parameters as if they were directly extracted from an URL. If the hashmap contains a key that is not present in the route path, it will be ignored and a warning will be logged during the build process. ··· 93 91 94 92 #### Type-safe parameters 95 93 96 - Interacting with HashMaps in Rust can be a bit cumbersome, so Maudit provides the ability to use a struct to define your params and use it in the `DynamicRoute` trait. 94 + Interacting with HashMaps in Rust can be a bit cumbersome, so Maudit provides the ability to use a struct to define your params. This struct must derive the `Params` trait. 97 95 98 96 ```rust 99 97 #[derive(Params)] ··· 101 99 pub slug: String, 102 100 } 103 101 104 - impl<Params> DynamicRoute for Post { 102 + impl Page<Params> for Post { 105 103 fn routes(&self, ctx: &DynamicRouteContext) -> Vec<Params> { 106 104 vec![Params { 107 105 slug: "hello-world".to_string(), 108 106 }] 109 107 } 108 + 109 + // ... 110 110 } 111 111 ``` 112 112 113 - This struct can also be used when implementing the `Page` trait, making it possible to access the parameters in a type-safe way. 113 + This struct can also be used inside `render`, making it possible to access the parameters in a type-safe way. 114 114 115 115 ```rust 116 116 #[derive(Params)] ··· 118 118 pub slug: String, 119 119 } 120 120 121 - impl Page for Post { 121 + impl Page<Params> for Post { 122 + // ... 123 + 122 124 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 123 125 let params = ctx.params::<Params>(); 124 126 ··· 163 165 pub slug: String, 164 166 } 165 167 166 - impl DynamicRoute for PostJson { 168 + impl Page<Params> for PostJson { 167 169 fn routes(&self, ctx: &DynamicRouteContext) -> Vec<RouteParams> { 168 170 let routes = vec![Params { slug: "hello-world".to_string() }]; 169 171 170 172 RouteParams::from_vec(routes) 171 173 } 172 - } 173 174 174 - impl Page for PostJson { 175 175 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 176 176 let params = ctx.params::<Params>(); 177 177
+2 -2
website/content/docs/templating.md
··· 21 21 #[route("/")] 22 22 pub struct Index; 23 23 24 - impl Page<Markup> for Index { 24 + impl Page<RouteParams, Markup> for Index { 25 25 fn render(&self, _: &mut RouteContext) -> Markup { 26 26 html! { 27 27 h1 { "Hello, world!" } ··· 39 39 #[route("/")] 40 40 pub struct Index; 41 41 42 - impl Page<Markup> for Index { 42 + impl Page<RouteParams, Markup> for Index { 43 43 fn render(&self, ctx: &mut RouteContext) -> Markup { 44 44 let logo = ctx.add_image("./logo.png"); 45 45
+1 -3
website/src/pages/docs.rs
··· 44 44 slug: String, 45 45 } 46 46 47 - impl DynamicRoute<DocsPageParams> for DocsPage { 47 + impl Page<DocsPageParams> for DocsPage { 48 48 fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<DocsPageParams> { 49 49 let content = ctx.content.get_source::<DocsContent>("docs"); 50 50 ··· 52 52 slug: entry.id.clone(), 53 53 }) 54 54 } 55 - } 56 55 57 - impl Page for DocsPage { 58 56 fn render(&self, ctx: &mut RouteContext) -> RenderResult { 59 57 let slug = ctx.params::<DocsPageParams>().slug.clone(); 60 58 let entry = ctx
+1 -4
website/src/pages/index.rs
··· 14 14 let features = [ 15 15 ("Performant", "Generate a site with thousands of pages in seconds using minimal resources."), 16 16 ("Content", "Bring your content to life with built-in support for Markdown, syntax highlighting, and more."), 17 - ("SEO-optimized", "Ensure your site is SEO-friendly by default with built-in support for sitemaps."), 17 + ("Style your way", "Supports popular CSS frameworks and preprocessors, like Tailwind CSS and Sass."), 18 18 ("Powerful routing", "Flexible and powerful routing system allows you to create complex sites with ease."), 19 19 ("Ecosystem-ready", "Maudit utilize <a class=\"underline\" href=\"https://rolldown.rs\">Rolldown</a>, a fast bundler for JavaScript and CSS, enabling the usage of TypeScript and the npm ecosystem."), 20 20 ("Bring your templates", "Use your preferred templating engine to craft your website's pages. If it renders to HTML, Maudit supports it."), 21 - ("Type-safe routing", "Ensure your links stay correct, even through site refactors."), 22 - ("Style your way", "Supports popular CSS frameworks and preprocessors, like Tailwind CSS and Sass.") 23 21 ].map(|(name, description)| {(name, PreEscaped(description))}); 24 22 25 23 layout( ··· 55 53 p { (description) } 56 54 } 57 55 } 58 - span.opacity-75.italic.block.mt-1 { "And a lot more!" } 59 56 } 60 57 } 61 58 }