Rust library to generate static websites
5
fork

Configure Feed

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

feat(content): Add support for resolving assets in content (#21)

* feat(content): Add support for resolving assets in content

* fix: test

authored by

Erika and committed by
GitHub
ce1f1c02 fd5bf515

+125 -72
+1 -1
benchmarks/md-benchmark/src/page.rs
··· 25 25 .get_source::<UntypedMarkdownContent>("articles") 26 26 .get_entry(params.file.as_str()); 27 27 28 - entry.render().into() 28 + entry.render(ctx).into() 29 29 } 30 30 }
+14 -10
crates/maudit/src/content.rs
··· 9 9 pub mod markdown; 10 10 mod slugger; 11 11 12 - use crate::page::RouteParams; 12 + use crate::page::{RouteContext, RouteParams}; 13 13 pub use markdown::{ 14 14 components::{ 15 15 BlockQuoteKind, BlockquoteComponent, CodeComponent, EmphasisComponent, HardBreakComponent, ··· 130 130 /// let params = ctx.params::<ArticleParams>(); 131 131 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 132 132 /// let article = articles.get_entry(&params.article); 133 - /// article.render().into() 133 + /// article.render(ctx).into() 134 134 /// } 135 135 /// 136 136 /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { ··· 219 219 /// fn render(&self, ctx: &mut RouteContext) -> RenderResult { 220 220 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 221 221 /// let article = articles.get_entry("my-article"); // returns a ContentEntry 222 - /// article.render().into() 222 + /// article.render(ctx).into() 223 223 /// } 224 224 /// } 225 225 /// ``` ··· 227 227 pub id: String, 228 228 render: OptionalContentRenderFn, 229 229 pub raw_content: Option<String>, 230 - data_loader: Option<Box<dyn Fn() -> T + Send + Sync>>, 230 + data_loader: OptionalDataLoadingFn<T>, 231 231 cached_data: std::sync::OnceLock<T>, 232 232 pub file_path: Option<PathBuf>, 233 233 } 234 234 235 - type OptionalContentRenderFn = Option<Box<dyn Fn(&str) -> String + Send + Sync>>; 235 + type OptionalDataLoadingFn<T> = 236 + Option<Box<dyn Fn(&mut crate::page::RouteContext) -> T + Send + Sync>>; 237 + 238 + type OptionalContentRenderFn = 239 + Option<Box<dyn Fn(&str, &mut crate::page::RouteContext) -> String + Send + Sync>>; 236 240 237 241 impl<T> ContentEntry<T> { 238 242 pub fn new( ··· 256 260 id: String, 257 261 render: OptionalContentRenderFn, 258 262 raw_content: Option<String>, 259 - data_loader: Box<dyn Fn() -> T + Send + Sync>, 263 + data_loader: Box<dyn Fn(&mut crate::page::RouteContext) -> T + Send + Sync>, 260 264 file_path: Option<PathBuf>, 261 265 ) -> Self { 262 266 Self { ··· 269 273 } 270 274 } 271 275 272 - pub fn data(&self) -> &T { 276 + pub fn data(&self, ctx: &mut RouteContext) -> &T { 273 277 self.cached_data.get_or_init(|| { 274 278 if let Some(ref loader) = self.data_loader { 275 - loader() 279 + loader(ctx) 276 280 } else { 277 281 panic!("No data loader available and no cached data") 278 282 } 279 283 }) 280 284 } 281 285 282 - pub fn render(&self) -> String { 283 - (self.render.as_ref().unwrap())(self.raw_content.as_ref().unwrap()) 286 + pub fn render(&self, ctx: &mut RouteContext) -> String { 287 + (self.render.as_ref().unwrap())(self.raw_content.as_ref().unwrap(), ctx) 284 288 } 285 289 } 286 290
+54 -12
crates/maudit/src/content/markdown.rs
··· 1 - use std::sync::Arc; 1 + use std::{path::Path, sync::Arc}; 2 2 3 3 use glob::glob as glob_fs; 4 4 use log::warn; ··· 8 8 pub mod components; 9 9 10 10 use components::{LinkType, ListType, MarkdownComponents, TableAlignment}; 11 + 12 + use crate::{assets::Asset, page::RouteContext}; 11 13 12 14 use super::{highlight::CodeBlock, slugger, ContentEntry}; 13 15 ··· 34 36 /// fn render(&self, ctx: &mut RouteContext) -> Markup { 35 37 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 36 38 /// let article = articles.get_entry("my-article"); 37 - /// let headings = article.data().get_headings(); // returns a Vec<MarkdownHeading> 39 + /// let headings = article.data(ctx).get_headings(); // returns a Vec<MarkdownHeading> 38 40 /// let toc = html! { 39 41 /// ul { 40 42 /// @for heading in headings { ··· 46 48 /// }; 47 49 /// html! { 48 50 /// main { 49 - /// h1 { (article.data().title) } 51 + /// h1 { (article.data(ctx).title) } 50 52 /// nav { (toc) } 51 53 /// } 52 54 /// } ··· 195 197 196 198 // Clone content for the closure 197 199 let content_clone = content.clone(); 198 - let data_loader = Box::new(move || { 200 + let data_loader = Box::new(move |_: &mut RouteContext| { 199 201 let mut slugger = slugger::Slugger::new(); 200 202 201 203 let mut options = Options::empty(); ··· 249 251 // Perhaps not ideal, but I don't know better. We're at the "get it working" stage - erika, 2025-08-24 250 252 // Ideally, we'd at least avoid the allocation here whenever `options` is None, not sure how to do that ergonomically 251 253 let opts = options.clone(); 254 + let path = entry.clone(); 252 255 253 256 entries.push(ContentEntry::new_lazy( 254 257 id, 255 - Some(Box::new(move |content: &str| { 256 - render_markdown(content, opts.as_deref()) 258 + Some(Box::new(move |content: &str, route_ctx| { 259 + render_markdown(content, opts.as_deref(), Some(&path), Some(route_ctx)) 257 260 })), 258 261 Some(content), 259 262 data_loader, ··· 335 338 /// }; 336 339 /// let html = render_markdown(markdown, Some(&options)); 337 340 /// ``` 338 - pub fn render_markdown(content: &str, options: Option<&MarkdownOptions>) -> String { 341 + pub fn render_markdown( 342 + content: &str, 343 + options: Option<&MarkdownOptions>, 344 + path: Option<&Path>, 345 + mut route_ctx: Option<&mut RouteContext>, 346 + ) -> String { 339 347 let mut slugger = slugger::Slugger::new(); 340 348 let mut html_output = String::new(); 341 349 let parser_options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS ··· 367 375 continue; 368 376 } 369 377 378 + // TODO: Write an integration test for assets resolution - erika, 2025-08-27 379 + Event::Start(Tag::Image { 380 + dest_url, 381 + link_type, 382 + id, 383 + title, 384 + }) => { 385 + // TODO: Figure out a cleaner way to do this, it's a lot of if-lets and checks - erika, 2025-08-27 386 + let new_event = if dest_url.starts_with("./") || dest_url.starts_with("../") { 387 + path.and_then(|p| p.parent()) 388 + .and_then(|parent| { 389 + let resolved = parent.join(dest_url.to_string()); 390 + route_ctx 391 + .as_mut() 392 + .and_then(|ctx| ctx.assets.add_image(resolved).url()) 393 + }) 394 + .map(|image_url| { 395 + Event::Start(Tag::Image { 396 + dest_url: image_url.into(), 397 + title: title.clone(), 398 + link_type: *link_type, 399 + id: id.clone(), 400 + }) 401 + }) 402 + } else { 403 + None 404 + }; 405 + 406 + if let Some(event) = new_event { 407 + events[i] = event; 408 + } 409 + } 410 + 370 411 // TODO: Handle this differently so it's compatible with the component system - erika, 2025-08-24 371 412 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(ref fence))) => { 372 413 let (block, begin) = CodeBlock::new(fence); ··· 764 805 765 806 More content here."#; 766 807 767 - let html = render_markdown(markdown, None); 808 + let html = render_markdown(markdown, None, None, None); 768 809 769 810 // Test basic markdown rendering 770 811 assert!(html.contains("<h1")); ··· 783 824 }; 784 825 let markdown = r#"# Hello, world!"#; 785 826 786 - let html = render_markdown(markdown, Some(&options)); 787 - let default_html = render_markdown(markdown, None); 827 + let html = render_markdown(markdown, Some(&options), None, None); 828 + let default_html = render_markdown(markdown, None, None, None); 788 829 789 830 // Should be the same as default rendering when no custom components are provided 790 831 assert_eq!(html, default_html); ··· 799 840 ### Another Level"#; 800 841 801 842 // Render without any options 802 - let html_no_options = render_markdown(markdown, None); 843 + let html_no_options = render_markdown(markdown, None, None, None); 803 844 804 845 // Render with options but no custom heading component 805 846 let options_no_heading = MarkdownOptions { 806 847 components: MarkdownComponents::new(), 807 848 }; 808 - let html_with_empty_options = render_markdown(markdown, Some(&options_no_heading)); 849 + let html_with_empty_options = 850 + render_markdown(markdown, Some(&options_no_heading), None, None); 809 851 810 852 // Both should produce identical output 811 853 assert_eq!(html_no_options, html_with_empty_options);
+24 -13
crates/maudit/src/content/markdown/components.rs
··· 619 619 components: MarkdownComponents::new().heading(TestCustomHeading), 620 620 }; 621 621 622 - let html = render_markdown("# Hello, world!", Some(&options)); 622 + let html = render_markdown("# Hello, world!", Some(&options), None, None); 623 623 assert!(html.contains("🎯Hello, world!")); 624 624 } 625 625 ··· 629 629 components: MarkdownComponents::new().paragraph(TestCustomParagraph), 630 630 }; 631 631 632 - let content = render_markdown("This is a paragraph.", Some(&options)); 632 + let content = render_markdown("This is a paragraph.", Some(&options), None, None); 633 633 assert!(content.contains( 634 634 "<p class=\"custom-paragraph\">This is a paragraph.</p><!-- end custom paragraph -->" 635 635 )); ··· 641 641 components: MarkdownComponents::new().link(TestCustomLink), 642 642 }; 643 643 644 - let content = render_markdown("[Example](https://example.com)", Some(&options)); 644 + let content = render_markdown("[Example](https://example.com)", Some(&options), None, None); 645 645 assert!( 646 646 content.contains("<a href=\"https://example.com\" class=\"custom-link\">🔗Example</a>") 647 647 ); ··· 653 653 components: MarkdownComponents::new().image(TestCustomImage), 654 654 }; 655 655 656 - let content = render_markdown("![Alt text](image.jpg)", Some(&options)); 656 + let content = render_markdown("![Alt text](image.jpg)", Some(&options), None, None); 657 657 assert!( 658 658 content.contains("<img src=\"image.jpg\" alt=\"Alt text\" class=\"custom-image\" />📸") 659 659 ); ··· 665 665 components: MarkdownComponents::new().strong(TestCustomStrong), 666 666 }; 667 667 668 - let content = render_markdown("**Bold text**", Some(&options)); 668 + let content = render_markdown("**Bold text**", Some(&options), None, None); 669 669 assert!(content.contains("<strong class=\"custom-strong\">💪Bold text</strong>")); 670 670 } 671 671 ··· 675 675 components: MarkdownComponents::new().emphasis(TestCustomEmphasis), 676 676 }; 677 677 678 - let content = render_markdown("*Italic text*", Some(&options)); 678 + let content = render_markdown("*Italic text*", Some(&options), None, None); 679 679 assert!(content.contains("<em class=\"custom-emphasis\">✨Italic text</em>")); 680 680 } 681 681 ··· 685 685 components: MarkdownComponents::new().code(TestCustomCode), 686 686 }; 687 687 688 - let content = render_markdown("`console.log('hello')`", Some(&options)); 688 + let content = render_markdown("`console.log('hello')`", Some(&options), None, None); 689 689 assert!(content.contains("<code class=\"custom-code\">💻console.log('hello')</code>")); 690 690 } 691 691 ··· 695 695 components: MarkdownComponents::new().blockquote(TestCustomBlockquote), 696 696 }; 697 697 698 - let content = render_markdown("> This is a quote", Some(&options)); 698 + let content = render_markdown("> This is a quote", Some(&options), None, None); 699 699 assert!(content.contains("<blockquote class=\"custom-blockquote\">📝")); 700 700 assert!(content.contains("</blockquote>")); 701 701 assert!(content.contains("This is a quote")); ··· 714 714 let content = render_markdown( 715 715 "# Title\n\nThis is a **bold** [link](https://example.com).", 716 716 Some(&options), 717 + None, 718 + None, 717 719 ); 718 720 719 721 assert!(content.contains("🎯Title")); ··· 737 739 let content = render_markdown( 738 740 "> This is a **bold** and *italic* with `code`", 739 741 Some(&options), 742 + None, 743 + None, 740 744 ); 741 745 assert!(content.contains("<blockquote class=\"custom-blockquote\">📝")); 742 746 assert!(content.contains("<strong class=\"custom-strong\">💪bold</strong>")); ··· 866 870 let options = MarkdownOptions { 867 871 components: MarkdownComponents::new().hard_break(TestHardBreak), 868 872 }; 869 - let content = render_markdown("Line 1 \nLine 2", Some(&options)); 873 + let content = render_markdown("Line 1 \nLine 2", Some(&options), None, None); 870 874 assert!(content.contains("<br class=\"custom-break\" />")); 871 875 } 872 876 ··· 875 879 let options = MarkdownOptions { 876 880 components: MarkdownComponents::new().horizontal_rule(TestHorizontalRule), 877 881 }; 878 - let content = render_markdown("---", Some(&options)); 882 + let content = render_markdown("---", Some(&options), None, None); 879 883 assert!(content.contains("<hr class=\"custom-rule\" />")); 880 884 } 881 885 ··· 886 890 .list(TestList) 887 891 .list_item(TestListItem), 888 892 }; 889 - let content = render_markdown("1. First\n2. Second\n\n- Bullet\n- Point", Some(&options)); 893 + let content = render_markdown( 894 + "1. First\n2. Second\n\n- Bullet\n- Point", 895 + Some(&options), 896 + None, 897 + None, 898 + ); 890 899 assert!(content.contains("<ol class=\"custom-list\" start=\"1\">")); 891 900 assert!(content.contains("<ul class=\"custom-list\">")); 892 901 assert!(content.contains("<li class=\"custom-item\">")); ··· 897 906 let options = MarkdownOptions { 898 907 components: MarkdownComponents::new().strikethrough(TestStrikethrough), 899 908 }; 900 - let content = render_markdown("~~strikethrough~~", Some(&options)); 909 + let content = render_markdown("~~strikethrough~~", Some(&options), None, None); 901 910 assert!(content.contains("<del class=\"custom-strike\">")); 902 911 } 903 912 ··· 906 915 let options = MarkdownOptions { 907 916 components: MarkdownComponents::new().task_list_marker(TestTaskListMarker), 908 917 }; 909 - let content = render_markdown("- [x] Done\n- [ ] Todo", Some(&options)); 918 + let content = render_markdown("- [x] Done\n- [ ] Todo", Some(&options), None, None); 910 919 assert!(content.contains("<input type=\"checkbox\" checked class=\"custom-task\" />")); 911 920 assert!(content.contains("<input type=\"checkbox\" class=\"custom-task\" />")); 912 921 } ··· 923 932 let content = render_markdown( 924 933 "| Header | Header |\n|--------|--------|\n| Cell | Cell |", 925 934 Some(&options), 935 + None, 936 + None, 926 937 ); 927 938 assert!(content.contains("<table class=\"custom-table\">")); 928 939 assert!(content.contains("<thead class=\"custom-thead\">"));
+1 -1
crates/maudit/src/page.rs
··· 85 85 /// (logo) 86 86 /// ul { 87 87 /// @for entry in last_entries { 88 - /// li { (entry.data().title) } 88 + /// li { (entry.data(ctx).title) } 89 89 /// } 90 90 /// } 91 91 /// }
+4 -4
crates/oubli/src/archetypes/blog.rs
··· 19 19 main { 20 20 @for entry in &blog_entries.entries { 21 21 a href=(get_page_url(&route, BlogEntryParams { entry: entry.id.clone() })) { 22 - h2 { (entry.data().title) } 23 - p { (entry.data().description) } 22 + h2 { (entry.data(ctx).title) } 23 + p { (entry.data(ctx).description) } 24 24 } 25 25 } 26 26 } ··· 57 57 .get_source::<BlogEntryContent>(stringified_ident); 58 58 let blog_entry = blog_entries.get_entry(&params.entry); 59 59 60 - let headings = blog_entry.data().get_headings(); 60 + let headings = blog_entry.data(ctx).get_headings(); 61 61 println!("{:?}", headings); 62 62 63 - layout(name, blog_entry.render()) 63 + layout(name, blog_entry.render(ctx)) 64 64 }
+2 -2
examples/blog/src/pages/article.rs
··· 25 25 let articles = ctx.content.get_source::<ArticleContent>("articles"); 26 26 let article = articles.get_entry(&params.article); 27 27 28 - let headings = article.data().get_headings(); 28 + let headings = article.data(ctx).get_headings(); 29 29 println!("{:?}", headings); 30 30 31 - layout(article.render()) 31 + layout(article.render(ctx)) 32 32 } 33 33 }
+2 -2
examples/blog/src/pages/index.rs
··· 19 19 @for entry in &articles.entries { 20 20 li { 21 21 a href=(get_page_url(&Article, ArticleParams { article: entry.id.clone() })) { 22 - h2 { (entry.data().title) } 22 + h2 { (entry.data(ctx).title) } 23 23 } 24 - p { (entry.data().description) } 24 + p { (entry.data(ctx).description) } 25 25 } 26 26 } 27 27 }
+1 -4
examples/markdown-components/src/pages.rs
··· 13 13 let examples = ctx.content.get_source::<ComponentExample>("examples"); 14 14 let example = examples.get_entry("showcase"); 15 15 16 - // The content is already rendered with the custom components 17 - // when it was loaded via glob_markdown with options 18 - let content_html = example.render(); 19 - 16 + let content_html = example.render(ctx); 20 17 html! { 21 18 (DOCTYPE) 22 19 html lang="en" {
+1 -1
examples/oubli-basics/src/pages/index.rs
··· 17 17 (logo) 18 18 h1 { "Hello World" } 19 19 @for archetype in &archetype_store.entries { 20 - a href=(archetype.id) { (archetype.data().title) } 20 + a href=(archetype.id) { (archetype.data(ctx).title) } 21 21 } 22 22 }) 23 23 .into()
+1 -1
website/content/news/maudit01.md
··· 50 50 51 51 On a 2020 M1 MacBook Pro, [we've found that the final binary of a Maudit project can build a project with 4000 Markdown files in around 700ms](https://github.com/bruits/maudit/tree/main/benchmarks/md-benchmark), which we consider quite reasonable. 52 52 53 - [![A graph showing the performance of Maudit building 250, 500, 1000, 2000 and 4000 pages. Respectively, it takes 37ms, 75ms, 151ms, 319ms and 676ms for the build to complete.](/01-performance.png)](https://github.com/bruits/maudit/tree/main/benchmarks/md-benchmark) 53 + [![A graph showing the performance of Maudit building 250, 500, 1000, 2000 and 4000 pages. Respectively, it takes 37ms, 75ms, 151ms, 319ms and 676ms for the build to complete.](./01-performance.png)](https://github.com/bruits/maudit/tree/main/benchmarks/md-benchmark) 54 54 55 55 As we add more features, it's possible that Maudit will become slower, but we'll monitor performance and ensure that, yeah, it's reasonably fast. 56 56
+2 -3
website/src/layout/docs_sidebars.rs
··· 9 9 let mut sections = std::collections::HashMap::new(); 10 10 11 11 for entry in content.entries.iter() { 12 - if let Some(section) = &entry.data().section { 12 + if let Some(section) = &entry.data(ctx).section { 13 13 sections.entry(section).or_insert_with(Vec::new).push(entry); 14 14 } 15 15 } ··· 43 43 @let url = format!("/docs/{}", entry.id); 44 44 @let is_current_page = url == ctx.current_url; 45 45 li."border-l-2"."hover:border-brand-red"."pl-3"."py-1".(if is_current_page { "text-brand-red border-brand-red" } else { "border-borders" }) { 46 - a.block href=(format!("/docs/{}/", entry.id)) { (entry.data().title) } // TODO: Use type-safe routing 46 + a.block href=(format!("/docs/{}/", entry.id)) { (entry.data(ctx).title) } // TODO: Use type-safe routing 47 47 } 48 48 } 49 49 } ··· 82 82 html!( 83 83 h2.text-lg.font-bold { "On This Page" } 84 84 nav.sticky.top-8 { 85 - // TODO: Implement this properly 86 85 ul { 87 86 @for heading in html_headings { 88 87 (heading)
+9 -9
website/src/pages/docs.rs
··· 13 13 .get_source::<DocsContent>("docs") 14 14 .get_entry("index"); 15 15 16 - let headings = index_page.data().get_headings().clone(); 16 + let headings = index_page.data(ctx).get_headings().clone(); 17 17 18 - docs_layout(render_entry(index_page), ctx, &headings) 18 + docs_layout(render_entry(index_page, ctx), ctx, &headings) 19 19 } 20 20 } 21 21 22 - fn render_entry(entry: &ContentEntry<DocsContent>) -> Markup { 22 + fn render_entry(entry: &ContentEntry<DocsContent>, ctx: &mut RouteContext) -> Markup { 23 23 html! { 24 24 section.mb-4.border-b."border-[#e9e9e7]".pb-2 { 25 - @if let Some(section) = &entry.data().section { 25 + @if let Some(section) = &entry.data(ctx).section { 26 26 p.text-sm.font-bold { (section) } 27 27 } 28 - h2.text-5xl.font-bold.mb-2 { (entry.data().title) } 29 - @if let Some(description) = &entry.data().description { 28 + h2.text-5xl.font-bold.mb-2 { (entry.data(ctx).title) } 29 + @if let Some(description) = &entry.data(ctx).description { 30 30 h3.text-lg { (description) } 31 31 } 32 32 } 33 33 section.prose."lg:prose-lg".max-w-none { 34 - (PreEscaped(entry.render())) 34 + (PreEscaped(entry.render(ctx))) 35 35 } 36 36 } 37 37 } ··· 60 60 .get_source::<DocsContent>("docs") 61 61 .get_entry(&slug); 62 62 63 - let headings = entry.data().get_headings().clone(); 64 - docs_layout(render_entry(entry), ctx, &headings) 63 + let headings = entry.data(ctx).get_headings().clone(); 64 + docs_layout(render_entry(entry, ctx), ctx, &headings) 65 65 } 66 66 }
+9 -9
website/src/pages/news.rs
··· 17 17 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new(); 18 18 19 19 for article in &content.entries { 20 - if let Some(date) = &article.data().date { 20 + if let Some(date) = &article.data(ctx).date { 21 21 // Extract year from date (format: 2025-08-16) 22 22 let year = date.split('-').next().unwrap_or("Unknown").to_string(); 23 23 articles_by_year ··· 42 42 ul.space-y-8 { 43 43 @for article in articles { 44 44 li.border-b.border-gray-200.pb-4 { 45 - @if let Some(date) = &article.data().date { 45 + @if let Some(date) = &article.data(ctx).date { 46 46 p.text-sm.font-bold { (date) } 47 47 } 48 48 h3.text-5xl { 49 49 a."hover:text-brand-red" href=(article.id) { 50 - (article.data().title) 50 + (article.data(ctx).title) 51 51 } 52 52 } 53 - @if let Some(description) = &article.data().description { 53 + @if let Some(description) = &article.data(ctx).description { 54 54 p.text-lg.text-gray-600 { (description) } 55 55 } 56 56 } ··· 106 106 html! { 107 107 div.container.mx-auto."py-10"."pb-24"."max-w-[80ch]"."px-8"."sm:px-0" { 108 108 section.mb-4.border-b."border-[#e9e9e7]".pb-2 { 109 - @if let Some(date) = &entry.data().date { 109 + @if let Some(date) = &entry.data(ctx).date { 110 110 p.text-sm.font-bold { (date) } 111 111 } 112 - h1."text-6xl"."sm:text-7xl".font-bold { (entry.data().title) } 113 - @if let Some(description) = &entry.data().description { 112 + h1."text-6xl"."sm:text-7xl".font-bold { (entry.data(ctx).title) } 113 + @if let Some(description) = &entry.data(ctx).description { 114 114 p.text-xl."sm:text-2xl" { (description) } 115 115 } 116 116 } 117 117 118 118 section.prose."lg:prose-lg".max-w-none { 119 - (PreEscaped(entry.render())) 119 + (PreEscaped(entry.render(ctx))) 120 120 } 121 121 122 - @if let Some(author) = &entry.data().author { 122 + @if let Some(author) = &entry.data(ctx).author { 123 123 h2."text-xl".font-bold.mt-12.text-center { (author) } 124 124 } 125 125 }
website/static/01-performance.png website/content/news/01-performance.png