Rust library to generate static websites
5
fork

Configure Feed

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

feat: content support

+459 -13
+59
Cargo.lock
··· 905 905 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 906 906 907 907 [[package]] 908 + name = "glob" 909 + version = "0.3.1" 910 + source = "registry+https://github.com/rust-lang/crates.io-index" 911 + checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" 912 + 913 + [[package]] 908 914 name = "glob-match" 909 915 version = "0.2.1" 910 916 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1129 1135 ] 1130 1136 1131 1137 [[package]] 1138 + name = "libyml" 1139 + version = "0.0.5" 1140 + source = "registry+https://github.com/rust-lang/crates.io-index" 1141 + checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" 1142 + dependencies = [ 1143 + "anyhow", 1144 + "version_check", 1145 + ] 1146 + 1147 + [[package]] 1132 1148 name = "lightningcss" 1133 1149 version = "1.0.0-alpha.61" 1134 1150 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1203 1219 ] 1204 1220 1205 1221 [[package]] 1222 + name = "markdown" 1223 + version = "1.0.0-alpha.21" 1224 + source = "registry+https://github.com/rust-lang/crates.io-index" 1225 + checksum = "a6491e6c702bf7e3b24e769d800746d5f2c06a6c6a2db7992612e0f429029e81" 1226 + dependencies = [ 1227 + "unicode-id", 1228 + ] 1229 + 1230 + [[package]] 1206 1231 name = "matchers" 1207 1232 version = "0.1.0" 1208 1233 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1247 1272 "colored", 1248 1273 "dyn-eq", 1249 1274 "env_logger", 1275 + "glob", 1250 1276 "log", 1251 1277 "lol_html", 1278 + "markdown", 1252 1279 "maud", 1253 1280 "maudit-macros", 1254 1281 "rayon", 1255 1282 "rolldown", 1256 1283 "rustc-hash", 1284 + "serde", 1285 + "serde_yml", 1257 1286 "thiserror 2.0.9", 1258 1287 "tokio", 1259 1288 ] ··· 1275 1304 dependencies = [ 1276 1305 "maud", 1277 1306 "maudit", 1307 + ] 1308 + 1309 + [[package]] 1310 + name = "maudit-example-blog" 1311 + version = "0.1.0" 1312 + dependencies = [ 1313 + "maud", 1314 + "maudit", 1315 + "serde", 1278 1316 ] 1279 1317 1280 1318 [[package]] ··· 2743 2781 ] 2744 2782 2745 2783 [[package]] 2784 + name = "serde_yml" 2785 + version = "0.0.12" 2786 + source = "registry+https://github.com/rust-lang/crates.io-index" 2787 + checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" 2788 + dependencies = [ 2789 + "indexmap", 2790 + "itoa 1.0.14", 2791 + "libyml", 2792 + "memchr", 2793 + "ryu", 2794 + "serde", 2795 + "version_check", 2796 + ] 2797 + 2798 + [[package]] 2746 2799 name = "servo_arc" 2747 2800 version = "0.1.1" 2748 2801 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3097 3150 version = "1.17.0" 3098 3151 source = "registry+https://github.com/rust-lang/crates.io-index" 3099 3152 checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 3153 + 3154 + [[package]] 3155 + name = "unicode-id" 3156 + version = "0.3.5" 3157 + source = "registry+https://github.com/rust-lang/crates.io-index" 3158 + checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561" 3100 3159 3101 3160 [[package]] 3102 3161 name = "unicode-id-start"
+1
Cargo.toml
··· 5 5 [workspace.dependencies] 6 6 maudit = { version = "0.1.0", path = "crates/framework" } 7 7 maud = { version = "0.26.0" } 8 + serde = { version = "1.0.216" }
+4
crates/framework/Cargo.toml
··· 19 19 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 20 20 thiserror = "2.0.9" 21 21 lol_html = "2.1.0" 22 + glob = "0.3.1" 23 + markdown = "1.0.0-alpha.21" 24 + serde = { workspace = true } 25 + serde_yml = "0.0.12"
+182
crates/framework/src/content.rs
··· 1 + use std::any::Any; 2 + 3 + use glob::glob as glob_fs; 4 + use log::warn; 5 + use markdown::{mdast::Node, to_html_with_options, to_mdast, Constructs, Options, ParseOptions}; 6 + use rustc_hash::FxHashMap; 7 + use serde::de::DeserializeOwned; 8 + 9 + pub struct ContentEntry<T> { 10 + pub id: String, 11 + pub render: Box<dyn Fn() -> String>, 12 + pub data: T, 13 + } 14 + 15 + pub struct ContentSources(pub Vec<Box<dyn ContentSourceInternal>>); 16 + 17 + impl From<Vec<Box<dyn ContentSourceInternal>>> for ContentSources { 18 + fn from(collections: Vec<Box<dyn ContentSourceInternal>>) -> Self { 19 + Self(collections) 20 + } 21 + } 22 + 23 + impl ContentSources { 24 + pub fn new(collections: Vec<Box<dyn ContentSourceInternal>>) -> Self { 25 + Self(collections) 26 + } 27 + 28 + pub fn get_untyped_collection(&self, name: &str) -> &ContentSource<FxHashMap<String, String>> { 29 + self.0 30 + .iter() 31 + .find_map(|source| { 32 + match source 33 + .as_any() 34 + .downcast_ref::<ContentSource<FxHashMap<String, String>>>() 35 + { 36 + Some(source) if source.name == name => Some(source), 37 + _ => None, 38 + } 39 + }) 40 + .unwrap_or_else(|| panic!("Collection with name '{}' not found", name)) 41 + } 42 + 43 + pub fn get_untyped_collection_safe( 44 + &self, 45 + name: &str, 46 + ) -> Option<&ContentSource<FxHashMap<String, String>>> { 47 + self.0.iter().find_map(|source| { 48 + match source 49 + .as_any() 50 + .downcast_ref::<ContentSource<FxHashMap<String, String>>>() 51 + { 52 + Some(source) if source.name == name => Some(source), 53 + _ => None, 54 + } 55 + }) 56 + } 57 + 58 + pub fn get_collection<T: 'static>(&self, name: &str) -> &ContentSource<T> { 59 + self.0 60 + .iter() 61 + .find_map( 62 + |source| match source.as_any().downcast_ref::<ContentSource<T>>() { 63 + Some(source) if source.name == name => Some(source), 64 + _ => None, 65 + }, 66 + ) 67 + .unwrap_or_else(|| panic!("Collection with name '{}' not found", name)) 68 + } 69 + 70 + pub fn get_collection_safe<T: 'static>(&self, name: &str) -> Option<&ContentSource<T>> { 71 + self.0.iter().find_map( 72 + |source| match source.as_any().downcast_ref::<ContentSource<T>>() { 73 + Some(source) if source.name == name => Some(source), 74 + _ => None, 75 + }, 76 + ) 77 + } 78 + } 79 + 80 + pub struct ContentSource<T = FxHashMap<String, String>> { 81 + pub name: String, 82 + pub entries: Vec<ContentEntry<T>>, 83 + } 84 + 85 + impl<T> ContentSource<T> { 86 + pub fn get_entry(&self, id: &str) -> &ContentEntry<T> { 87 + self.entries 88 + .iter() 89 + .find(|entry| entry.id == id) 90 + .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)) 91 + } 92 + } 93 + 94 + pub trait ContentSourceInternal { 95 + fn as_any(&self) -> &dyn Any; // Used for type checking at runtime 96 + } 97 + 98 + impl<T: 'static> ContentSourceInternal for ContentSource<T> { 99 + fn as_any(&self) -> &dyn Any { 100 + self 101 + } 102 + } 103 + 104 + enum FrontmatterHolder { 105 + Yaml(String), 106 + } 107 + 108 + pub fn glob_markdown<T>(pattern: &str) -> Vec<ContentEntry<T>> 109 + where 110 + T: DeserializeOwned, 111 + { 112 + let mut entries = vec![]; 113 + 114 + for entry in glob_fs(pattern).unwrap() { 115 + let entry = entry.unwrap(); 116 + let id = entry.file_stem().unwrap().to_str().unwrap().to_string(); 117 + let content = std::fs::read_to_string(&entry).unwrap(); 118 + 119 + let extension = match entry.extension() { 120 + Some(extension) => extension, 121 + None => continue, 122 + }; 123 + 124 + if extension != "md" { 125 + warn!("Other file types than Markdown are not supported yet"); 126 + continue; 127 + } 128 + 129 + let ast = to_mdast( 130 + &content, 131 + &ParseOptions { 132 + constructs: Constructs { 133 + frontmatter: true, 134 + ..Default::default() 135 + }, 136 + ..Default::default() 137 + }, 138 + ) 139 + .unwrap(); 140 + 141 + // Check if children 142 + let children = match ast.children() { 143 + Some(children) => children, 144 + None => continue, 145 + }; 146 + 147 + // Check if frontmatter 148 + let frontmatter: Option<FrontmatterHolder> = 149 + children.iter().find_map(|child| match child { 150 + Node::Yaml(frontmatter) => Some(FrontmatterHolder::Yaml(frontmatter.value.clone())), 151 + _ => None, 152 + }); 153 + 154 + let parsed: Option<T> = frontmatter.map(|FrontmatterHolder::Yaml(frontmatter)| { 155 + serde_yml::from_str::<T>(&frontmatter).unwrap() 156 + }); 157 + 158 + entries.push(ContentEntry { 159 + id, 160 + render: Box::new({ 161 + let content = to_html_with_options( 162 + &content, 163 + &Options { 164 + parse: ParseOptions { 165 + constructs: Constructs { 166 + frontmatter: true, 167 + ..Default::default() 168 + }, 169 + ..Default::default() 170 + }, 171 + compile: Default::default(), 172 + }, 173 + ) 174 + .unwrap(); 175 + move || content.clone() 176 + }), 177 + data: parsed.unwrap(), 178 + }); 179 + } 180 + 181 + entries 182 + }
+13 -3
crates/framework/src/lib.rs
··· 1 1 // Modules the end-user will interact directly or indirectly with 2 2 mod assets; 3 + pub mod content; 3 4 pub mod errors; 4 5 pub mod page; 5 6 pub mod params; 6 7 8 + use content::ContentSources; 7 9 use errors::BuildError; 8 10 // Re-exported dependencies for user convenience 9 11 pub use rustc_hash::FxHashMap; ··· 25 27 use colored::{ColoredString, Colorize}; 26 28 use env_logger::{Builder, Env}; 27 29 use log::{info, trace}; 28 - use page::{FullPage, RenderResult, RouteContext, RouteParams}; 30 + use page::{DynamicRouteContext, FullPage, RenderResult, RouteContext, RouteParams}; 29 31 use rolldown::{Bundler, BundlerOptions, InputItem}; 30 32 use rustc_hash::FxHashSet; 31 33 ··· 86 88 87 89 pub fn coronate( 88 90 routes: Vec<&dyn FullPage>, 91 + content_sources: ContentSources, 89 92 options: BuildOptions, 90 93 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 91 94 tokio::runtime::Builder::new_multi_thread() 92 95 .enable_all() 93 96 .build() 94 97 .unwrap() 95 - .block_on(async { build(routes, options).await }) 98 + .block_on(async { build(routes, content_sources, options).await }) 96 99 } 97 100 98 101 pub async fn build( 99 102 routes: Vec<&dyn FullPage>, 103 + content_sources: ContentSources, 100 104 options: BuildOptions, 101 105 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 102 106 let build_start = SystemTime::now(); ··· 165 169 let mut build_pages_scripts: FxHashSet<assets::Script> = FxHashSet::default(); 166 170 let mut build_pages_styles: FxHashSet<assets::Style> = FxHashSet::default(); 167 171 172 + let dynamic_route_context = DynamicRouteContext { 173 + content: &content_sources, 174 + }; 175 + 168 176 for route in routes { 169 - let routes = route.routes(); 177 + let routes = route.routes(&dynamic_route_context); 170 178 match routes.is_empty() { 171 179 true => { 172 180 let route_start = SystemTime::now(); 173 181 let mut page_assets = assets::PageAssets::default(); 174 182 let mut ctx = RouteContext { 175 183 params: page::RouteParams(FxHashMap::default()), 184 + content: &content_sources, 176 185 assets: &mut page_assets, 177 186 }; 178 187 ··· 209 218 let route_start = SystemTime::now(); 210 219 let mut ctx = RouteContext { 211 220 params, 221 + content: &content_sources, 212 222 assets: &mut pages_assets, 213 223 }; 214 224
+14 -3
crates/framework/src/page.rs
··· 1 1 use crate::assets::PageAssets; 2 + use crate::content::ContentSources; 2 3 use crate::errors::UrlError; 3 4 use rustc_hash::FxHashMap; 4 5 use std::path::PathBuf; ··· 16 17 17 18 pub struct RouteContext<'a> { 18 19 pub params: RouteParams, 20 + pub content: &'a ContentSources, 19 21 pub assets: &'a mut PageAssets, 20 22 } 21 23 24 + pub struct DynamicRouteContext<'a> { 25 + pub content: &'a ContentSources, 26 + } 27 + 22 28 pub trait Page { 23 29 fn render(&self, ctx: &mut RouteContext) -> RenderResult; 24 30 } ··· 43 49 } 44 50 45 51 pub trait DynamicPage { 46 - fn routes(&self) -> Vec<RouteParams>; 52 + fn routes(&self, context: &DynamicRouteContext) -> Vec<RouteParams>; 47 53 } 48 54 49 55 pub trait InternalPage { ··· 53 59 fn url_unsafe<P: Into<RouteParams>>(params: P) -> String 54 60 where 55 61 Self: Sized; 56 - fn url<P: Into<RouteParams>>(&self, params: P) -> Result<String, UrlError> 62 + fn url<P: Into<RouteParams>>( 63 + &self, 64 + params: P, 65 + dynamic_route_context: &DynamicRouteContext, 66 + ) -> Result<String, UrlError> 57 67 where 58 68 Self: Sized; 59 69 } ··· 62 72 63 73 pub mod prelude { 64 74 pub use super::{ 65 - DynamicPage, FullPage, InternalPage, Page, RenderResult, RouteContext, RouteParams, 75 + DynamicPage, DynamicRouteContext, FullPage, InternalPage, Page, RenderResult, RouteContext, 76 + RouteParams, 66 77 }; 67 78 pub use crate::assets::Asset; 68 79 pub use maudit_macros::{route, Params};
+3 -3
crates/macros/src/lib.rs
··· 94 94 false => quote! {}, 95 95 true => quote! { 96 96 impl maudit::page::DynamicPage for #struct_name { 97 - fn routes(&self) -> Vec<maudit::page::RouteParams> { 97 + fn routes(&self, _: &maudit::page::DynamicRouteContext) -> Vec<maudit::page::RouteParams> { 98 98 Vec::new() 99 99 } 100 100 } ··· 154 154 format!(#path_for_route) 155 155 } 156 156 157 - fn url<P: Into<maudit::page::RouteParams>>(&self, params: P) -> Result<String, maudit::errors::UrlError> { 157 + fn url<P: Into<maudit::page::RouteParams>>(&self, params: P, dynamic_route_context: &maudit::page::DynamicRouteContext) -> Result<String, maudit::errors::UrlError> { 158 158 let params = params.into(); 159 159 160 160 // Check that the params refer to a page that exists 161 - let all_routes = self::DynamicPage::routes(self); 161 + let all_routes = self::DynamicPage::routes(self, dynamic_route_context); 162 162 let mut found = false; 163 163 164 164 for route in all_routes {
+1 -1
examples/basics/src/main.rs
··· 5 5 generate_pages_mod!(); 6 6 7 7 fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 8 - coronate(routes![Index], BuildOptions::default()) 8 + coronate(routes![Index], vec![].into(), BuildOptions::default()) 9 9 }
+10
examples/blog/Cargo.toml
··· 1 + [package] 2 + name = "maudit-example-blog" 3 + version = "0.1.0" 4 + edition = "2021" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit = { workspace = true } 9 + maud = { workspace = true } 10 + serde = { workspace = true }
+29
examples/blog/content/articles/first-post.md
··· 1 + --- 2 + title: First Post 3 + description: This is the first post on the blog. 4 + --- 5 + 6 + ## Section 1: Formatting Text 7 + 8 + ### Bold and Italic 9 + 10 + - **Bold Text** makes things stand out. 11 + - _Italic Text_ gives emphasis to words. 12 + - **_Bold and Italic_** combined for maximum emphasis. 13 + 14 + ### Mixed Formatting 15 + 16 + - **This is bold and _italic_** to combine styles. 17 + - _This is italic and **bold**_ as well. 18 + 19 + ## Section 2: Links 20 + 21 + You can include links like this: 22 + 23 + - [Check out the Markdown Guide](https://www.markdownguide.org/) 24 + 25 + ## Section 3: Combining All Features 26 + 27 + Here’s a sentence that combines everything: 28 + 29 + - **_Check out the [Markdown Guide](https://www.markdownguide.org/) for more tips_**.
+6
examples/blog/content/articles/second-post.md
··· 1 + --- 2 + title: Second Post 3 + description: This is the second post on the blog. 4 + --- 5 + 6 + This is another post on the blog!
+6
examples/blog/content/articles/third-post.md
··· 1 + --- 2 + title: Third Post 3 + description: This is the third post on the blog. 4 + --- 5 + 6 + Well, another post is here.
+7
examples/blog/src/content.rs
··· 1 + use serde::Deserialize; 2 + 3 + #[derive(Deserialize)] 4 + pub struct ArticleContent { 5 + pub title: String, 6 + pub description: String, 7 + }
+22
examples/blog/src/layout.rs
··· 1 + use maud::{html, Markup, PreEscaped}; 2 + 3 + pub fn layout(content: String) -> Markup { 4 + html! { 5 + html { 6 + head { 7 + title { "My Blog" } 8 + } 9 + body { 10 + header { 11 + h1 { "My Blog" } 12 + } 13 + main { 14 + (PreEscaped(content)) 15 + } 16 + footer { 17 + p { "© 2024 My Super Blog" } 18 + } 19 + } 20 + } 21 + } 22 + }
+20
examples/blog/src/main.rs
··· 1 + mod content; 2 + mod layout; 3 + use content::ArticleContent; 4 + use maudit::{ 5 + content::{glob_markdown, ContentSource, ContentSources}, 6 + coronate, generate_pages_mod, routes, BuildOptions, BuildOutput, 7 + }; 8 + 9 + generate_pages_mod!(); 10 + 11 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 12 + coronate( 13 + routes![Index, Article], 14 + ContentSources(vec![Box::new(ContentSource { 15 + name: "articles".to_string(), 16 + entries: glob_markdown::<ArticleContent>("content/articles/*.md"), 17 + })]), 18 + BuildOptions::default(), 19 + ) 20 + }
+36
examples/blog/src/pages/article.rs
··· 1 + use maudit::page::prelude::*; 2 + 3 + use crate::{content::ArticleContent, layout::layout}; 4 + 5 + #[route("/articles/[article]")] 6 + pub struct Article; 7 + 8 + #[derive(Params)] 9 + pub struct ArticleParams { 10 + pub article: String, 11 + } 12 + 13 + impl DynamicPage for Article { 14 + fn routes(&self, ctx: &DynamicRouteContext) -> Vec<RouteParams> { 15 + let articles = ctx.content.get_collection::<ArticleContent>("articles"); 16 + let mut static_routes: Vec<ArticleParams> = vec![]; 17 + 18 + for article in &articles.entries { 19 + static_routes.push(ArticleParams { 20 + article: article.id.clone(), 21 + }); 22 + } 23 + 24 + RouteParams::from_vec(static_routes) 25 + } 26 + } 27 + 28 + impl Page for Article { 29 + fn render(&self, ctx: &mut RouteContext) -> RenderResult { 30 + let params = ctx.params.parse_into::<ArticleParams>(); 31 + let articles = ctx.content.get_collection::<ArticleContent>("articles"); 32 + let article = articles.get_entry(&params.article); 33 + 34 + layout((article.render)()).into() 35 + } 36 + }
+37
examples/blog/src/pages/index.rs
··· 1 + use maud::html; 2 + use maudit::page::prelude::*; 3 + 4 + use crate::{content::ArticleContent, layout::layout, Article, ArticleParams}; 5 + 6 + #[route("/")] 7 + pub struct Index; 8 + 9 + impl Page for Index { 10 + fn render(&self, ctx: &mut RouteContext) -> RenderResult { 11 + let articles = ctx.content.get_collection::<ArticleContent>("articles"); 12 + 13 + let article_list = articles 14 + .entries 15 + .iter() 16 + .map(|entry| { 17 + html! { 18 + a href=(Article::url_unsafe(ArticleParams { article: entry.id.clone() })) { 19 + h2 { (entry.data.title) } 20 + p { (entry.data.description) } 21 + } 22 + } 23 + }) 24 + .collect::<Vec<_>>(); 25 + 26 + let markup = html! { 27 + ul { 28 + @for article in article_list { 29 + (article) 30 + } 31 + } 32 + } 33 + .into_string(); 34 + 35 + layout(markup).into() 36 + } 37 + }
+1
examples/kitchen-sink/src/main.rs
··· 5 5 fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 6 6 coronate( 7 7 routes![Index, DynamicExample, Endpoint], 8 + vec![].into(), 8 9 BuildOptions::default(), 9 10 ) 10 11 }
+2 -2
examples/kitchen-sink/src/pages/dynamic.rs
··· 1 - use maudit::page::prelude::*; 1 + use maudit::page::{prelude::*, DynamicRouteContext}; 2 2 3 3 use maud::html; 4 4 ··· 11 11 } 12 12 13 13 impl DynamicPage for DynamicExample { 14 - fn routes(&self) -> Vec<RouteParams> { 14 + fn routes(&self, _: &DynamicRouteContext) -> Vec<RouteParams> { 15 15 let mut static_routes: Vec<Params> = vec![]; 16 16 17 17 for i in 0..1 {
+6 -1
examples/kitchen-sink/src/pages/index.rs
··· 16 16 let link_to_first_dynamic = DynamicExample::url_unsafe(&DynamicExampleParams { page: 1 }); 17 17 18 18 let safe_link_to_first_dynamic = DynamicExample 19 - .url(&DynamicExampleParams { page: 0 }) 19 + .url( 20 + &DynamicExampleParams { page: 0 }, 21 + &DynamicRouteContext { 22 + content: ctx.content, 23 + }, 24 + ) 20 25 .unwrap(); 21 26 22 27 html! {