Rust library to generate static websites
5
fork

Configure Feed

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

feat: opengraph on website

+280 -29
+5
.sampo/changesets/bold-knight-aino.md
··· 1 + --- 2 + maudit: patch 3 + --- 4 + 5 + Add a new `base_url` setting and `canonical_url()` method on PageContext to make it easier to build absolute URLs inside pages
+25
assets/README.md
··· 1 + # Press Resources 2 + 3 + ## Logo 4 + 5 + The Maudit logo is available in both light and dark versions, for use on different backgrounds. 6 + 7 + For light backgrounds, use the dark version: 8 + 9 + ![Maudit Logo Dark](logo.svg) 10 + 11 + For dark backgrounds, use the light version: 12 + 13 + ![Maudit Logo Light](logo_light.svg) 14 + 15 + These logos may be used in promotional materials, websites, and other media about Maudit 16 + 17 + ## Colors 18 + 19 + Maudit uses the following color palette: 20 + 21 + | Color Name | Hex Code | Usage | 22 + | ---------- | -------- | --------------------------------------------------------- | 23 + | Primary | #ab3038 | Main brand color | 24 + | Background | #fafafa | Background color for light mode, text color for dark mode | 25 + | Background | #0d0d0d | Background color for dark mode, text color for light mode |
+1
assets/logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="357.3" height="281" fill="none"><path fill="#0d0d0d" d="M303 267c-51-6-51-6-83-5h-32l-63 7a1419 1419 0 0 0-92 12L22 173 12 66l-3-1C3 63-1 55 0 46c0-5 1-6 6-9 5-4 5-4 11-3 5 0 7 1 9 3 3 3 3 3 4 12 0 8 0 10-2 12l-2 2 15 19c22 27 58 67 59 66l4-15a1924 1924 0 0 1 13-64l-4-8c-3-3-7-11-9-16l-3-10 6-7c6-6 6-7 13-8 9-3 13-1 18 5 5 8 5 11 1 23l-4 12 7 21c7 19 7 19 27 52 20 32 25 39 26 36l3-14 14-60 10-48c0-2-2-3-5-6l-5-7 1-6c1-6 2-7 7-11 4-2 6-3 10-2 6 0 8 1 12 7 4 7 5 15 3 19l-6 5-5 3c0 5 48 117 49 117 4 0 5-5 18-63l13-59-6-4-5-3-1-10-1-10 6-8 6-8 8-1c8 0 8 0 13 3s6 4 7 11c2 8 2 15 0 18l-8 6-6 4 8 34 8 34 8 81c7 80 7 81 5 81z"/></svg>
+1
assets/logo_light.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="357.3" height="281" fill="none"><path fill="#f2f2f2" d="M303 267c-51-6-51-6-83-5h-32l-63 7a1419 1419 0 0 0-92 12L22 173 12 66l-3-1C3 63-1 55 0 46c0-5 1-6 6-9 5-4 5-4 11-3 5 0 7 1 9 3 3 3 3 3 4 12 0 8 0 10-2 12l-2 2 15 19c22 27 58 67 59 66l4-15a1924 1924 0 0 1 13-64l-4-8c-3-3-7-11-9-16l-3-10 6-7c6-6 6-7 13-8 9-3 13-1 18 5 5 8 5 11 1 23l-4 12 7 21c7 19 7 19 27 52 20 32 25 39 26 36l3-14 14-60 10-48c0-2-2-3-5-6l-5-7 1-6c1-6 2-7 7-11 4-2 6-3 10-2 6 0 8 1 12 7 4 7 5 15 3 19l-6 5-5 3c0 5 48 117 49 117 4 0 5-5 18-63l13-59-6-4-5-3-1-10-1-10 6-8 6-8 8-1c8 0 8 0 13 3s6 4 7 11c2 8 2 15 0 18l-8 6-6 4 8 34 8 34 8 81c7 80 7 81 5 81z"/></svg>
+51
assets/social-image.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 + 4 + <svg 5 + width="1500mm" 6 + height="1500mm" 7 + viewBox="0 0 1500 1500" 8 + version="1.1" 9 + id="svg1" 10 + xml:space="preserve" 11 + inkscape:version="1.3.2 (091e20e, 2023-11-25)" 12 + sodipodi:docname="social-image.svg" 13 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 + xmlns="http://www.w3.org/2000/svg" 16 + xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview 17 + id="namedview1" 18 + pagecolor="#505050" 19 + bordercolor="#eeeeee" 20 + borderopacity="1" 21 + inkscape:showpageshadow="0" 22 + inkscape:pageopacity="0" 23 + inkscape:pagecheckerboard="0" 24 + inkscape:deskcolor="#505050" 25 + inkscape:document-units="mm" 26 + inkscape:zoom="0.073212926" 27 + inkscape:cx="3694.7028" 28 + inkscape:cy="3585.4324" 29 + inkscape:window-width="1920" 30 + inkscape:window-height="1052" 31 + inkscape:window-x="1" 32 + inkscape:window-y="0" 33 + inkscape:window-maximized="0" 34 + inkscape:current-layer="layer1" /><defs 35 + id="defs1" /><g 36 + inkscape:label="Layer 1" 37 + inkscape:groupmode="layer" 38 + id="layer1"><rect 39 + style="fill:#ab3038;fill-opacity:1;stroke-width:0;paint-order:stroke fill markers" 40 + id="rect1" 41 + width="1500" 42 + height="1500" 43 + x="0" 44 + y="0" /><g 45 + style="fill:#fafafa;fill-opacity:1" 46 + id="g1" 47 + transform="matrix(3.5226246,0,0,3.5226246,121.21589,255.07124)"><path 48 + fill="currentColor" 49 + d="m 303,267 c -51,-6 -51,-6 -83,-5 h -32 l -63,7 A 1419,1419 0 0 0 33,281 L 22,173 12,66 9,65 C 3,63 -1,55 0,46 c 0,-5 1,-6 6,-9 5,-4 5,-4 11,-3 5,0 7,1 9,3 3,3 3,3 4,12 0,8 0,10 -2,12 l -2,2 15,19 c 22,27 58,67 59,66 l 4,-15 a 1924,1924 0 0 1 13,-64 l -4,-8 c -3,-3 -7,-11 -9,-16 l -3,-10 6,-7 c 6,-6 6,-7 13,-8 9,-3 13,-1 18,5 5,8 5,11 1,23 l -4,12 7,21 c 7,19 7,19 27,52 20,32 25,39 26,36 l 3,-14 14,-60 10,-48 c 0,-2 -2,-3 -5,-6 l -5,-7 1,-6 c 1,-6 2,-7 7,-11 4,-2 6,-3 10,-2 6,0 8,1 12,7 4,7 5,15 3,19 l -6,5 -5,3 c 0,5 48,117 49,117 4,0 5,-5 18,-63 l 13,-59 -6,-4 -5,-3 -1,-10 -1,-10 6,-8 6,-8 8,-1 c 8,0 8,0 13,3 5,3 6,4 7,11 2,8 2,15 0,18 l -8,6 -6,4 8,34 8,34 8,81 c 7,80 7,81 5,81 z" 50 + id="path1" 51 + style="fill:#fafafa;fill-opacity:1" /></g></g></svg>
+3
crates/maudit-cli/src/dev.rs
··· 165 165 let start_time = Instant::now(); 166 166 167 167 // Run the build command 168 + // TODO: Right now we always run `cargo run`, but for the sake of performance, we should detect in advance 169 + // if the change even needs a full rebuild (e.g. if only content files changed, we can skip rebuilding the Rust binary) 170 + // Perhaps this could be done by parsing the `.d` files that cargo generates. 168 171 let child = std::process::Command::new("cargo") 169 172 .args(["run", "--quiet"]) 170 173 .envs([
+2
crates/maudit/src/build.rs
··· 238 238 &content, 239 239 &mut page_assets, 240 240 &url, 241 + &options.base_url, 241 242 ))?; 242 243 243 244 let file_path = route.file_path(&params, &options.output_dir); ··· 284 285 &content, 285 286 &mut page_assets, 286 287 &url, 288 + &options.base_url, 287 289 ))?; 288 290 289 291 let file_path = route.file_path(&page.0, &options.output_dir);
+5
crates/maudit/src/build/options.rs
··· 43 43 /// } 44 44 /// ``` 45 45 pub struct BuildOptions { 46 + /// Base URL for the site, e.g. `https://example.com` or `https://example.com/subdir`. 47 + /// This value is used to generate canonical URLs and can be used wherever the full site URL is needed (e.g. in SEO meta tags) through [`PageContext::base_url`](crate::route::PageContext::base_url) in pages. 48 + pub base_url: Option<String>, 49 + 46 50 pub output_dir: PathBuf, 47 51 pub static_dir: PathBuf, 48 52 ··· 126 130 impl Default for BuildOptions { 127 131 fn default() -> Self { 128 132 Self { 133 + base_url: None, 129 134 output_dir: "dist".into(), 130 135 static_dir: "static".into(), 131 136 clean_output_dir: true,
+2 -1
crates/maudit/src/content/markdown/shortcodes_tests.rs
··· 72 72 let mut ctx = PageContext { 73 73 content: &content, 74 74 assets: &mut page_assets, 75 - current_url: &"/test".to_string(), 75 + current_path: &"/test".to_string(), 76 76 params: &(), 77 77 props: &(), 78 + base_url: &None, 78 79 }; 79 80 80 81 f(&mut ctx)
+19 -5
crates/maudit/src/route.rs
··· 253 253 pub props: &'a dyn Any, 254 254 pub content: &'a RouteContent<'a>, 255 255 pub assets: &'a mut RouteAssets, 256 - pub current_url: &'a String, 256 + /// The current path being rendered, e.g. `/articles/my-article`. 257 + pub current_path: &'a String, 258 + /// The base URL as defined in [`BuildOptions::base_url`](crate::BuildOptions::base_url) 259 + pub base_url: &'a Option<String>, 257 260 } 258 261 259 262 impl<'a> PageContext<'a> { 260 263 pub fn from_static_route( 261 264 content: &'a RouteContent, 262 265 assets: &'a mut RouteAssets, 263 - current_url: &'a String, 266 + current_path: &'a String, 267 + base_url: &'a Option<String>, 264 268 ) -> Self { 265 269 Self { 266 270 params: &(), 267 271 props: &(), 268 272 content, 269 273 assets, 270 - current_url, 274 + current_path, 275 + base_url, 271 276 } 272 277 } 273 278 ··· 275 280 dynamic_page: &'a PagesResult, 276 281 content: &'a RouteContent, 277 282 assets: &'a mut RouteAssets, 278 - current_url: &'a String, 283 + current_path: &'a String, 284 + base_url: &'a Option<String>, 279 285 ) -> Self { 280 286 Self { 281 287 params: dynamic_page.1.as_ref(), 282 288 props: dynamic_page.2.as_ref(), 283 289 content, 284 290 assets, 285 - current_url, 291 + current_path, 292 + base_url, 286 293 } 287 294 } 288 295 ··· 310 317 self.props 311 318 .downcast_ref::<T>() 312 319 .unwrap_or_else(|| panic!("Props type mismatch: got {}", std::any::type_name::<T>())) 320 + } 321 + 322 + /// Returns the canonical URL for the current page. If [`BuildOptions::base_url`](crate::BuildOptions::base_url) is not set, this will return `None`. 323 + pub fn canonical_url(&self) -> Option<String> { 324 + self.base_url 325 + .as_ref() 326 + .map(|base| format!("{}{}", base, self.current_path)) 313 327 } 314 328 } 315 329
+14 -4
examples/library/src/build.rs
··· 10 10 pub fn build_website( 11 11 routes: &[&dyn FullRoute], 12 12 mut content_sources: ContentSources, 13 - options: BuildOptions, 13 + options: &BuildOptions, 14 14 ) -> Result<(), Box<dyn std::error::Error>> { 15 15 // Initialize all the content sources; 16 16 content_sources.init_all(); ··· 35 35 36 36 // Every page has a PageContext, which contains information about the current page, as well as access to content and assets. 37 37 let url = route.url(&params); 38 - let mut ctx = PageContext::from_static_route(&content, &mut page_assets, &url); 38 + let mut ctx = PageContext::from_static_route( 39 + &content, 40 + &mut page_assets, 41 + &url, 42 + &options.base_url, 43 + ); 39 44 40 45 let content = route.build(&mut ctx)?; 41 46 ··· 78 83 79 84 // Here the context is created from a dynamic route, as the context has to include the page parameters and properties. 80 85 let url = route.url(params); 81 - let mut ctx = 82 - PageContext::from_dynamic_route(&page, &content, &mut page_assets, &url); 86 + let mut ctx = PageContext::from_dynamic_route( 87 + &page, 88 + &content, 89 + &mut page_assets, 90 + &url, 91 + &options.base_url, 92 + ); 83 93 84 94 // Everything below here is the same as for static routes. 85 95
+1 -1
examples/library/src/main.rs
··· 20 20 content_sources![ 21 21 "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 22 22 ], 23 - BuildOptions::default(), 23 + &BuildOptions::default(), 24 24 ) 25 25 }
+2 -1
website/content/docs/library.md
··· 60 60 61 61 // Every page has a PageContext, which contains information about the current route, as well as access to content and assets. 62 62 let url = route.url(&params); 63 - let mut ctx = PageContext::from_static_route(&content, &mut route_assets, &url); 63 + let mut ctx = PageContext::from_static_route(&content, &mut route_assets, &url, &options.base_url); 64 64 65 65 let content = route.build(&mut ctx)?; 66 66 ··· 152 152 &content, 153 153 &mut page_assets, 154 154 &url, 155 + &options.base_url 155 156 ); 156 157 157 158 // Everything after this is the same as for static routes.
+65 -1
website/src/layout.rs
··· 10 10 use maudit::maud::generator; 11 11 use maudit::route::{PageContext, RenderResult}; 12 12 13 + pub struct SeoMeta { 14 + pub title: String, 15 + pub description: Option<String>, 16 + pub canonical_url: Option<String>, 17 + } 18 + 19 + impl Default for SeoMeta { 20 + fn default() -> Self { 21 + Self { 22 + title: "Maudit".to_string(), 23 + description: Some("A modern static site generator built with Rust".to_string()), 24 + canonical_url: None, 25 + } 26 + } 27 + } 28 + 29 + impl SeoMeta { 30 + /// Create a new `SeoMeta` with the given title. 31 + pub fn render(&self, base_url: &Option<String>) -> Markup { 32 + let base_url = base_url.as_ref().unwrap(); 33 + 34 + let formatted_title = if self.title == "Maudit" { 35 + self.title.clone() 36 + } else { 37 + format!("{} - Maudit", self.title) 38 + }; 39 + 40 + let description = self 41 + .description 42 + .clone() 43 + .unwrap_or_else(|| SeoMeta::default().description.unwrap()); 44 + 45 + let canonical_url = self.canonical_url.as_ref(); 46 + 47 + let social_image_url = format!("{}/social-image.png", base_url); 48 + 49 + html! { 50 + title { (formatted_title) } 51 + meta name="description" content=(description); 52 + 53 + // Open Graph meta tags 54 + meta property="og:title" content=(formatted_title); 55 + meta property="og:description" content=(description); 56 + meta property="og:type" content="website"; 57 + meta property="og:image" content=(social_image_url); 58 + @if let Some(canonical_url) = &canonical_url { 59 + meta property="og:url" content=(canonical_url); 60 + link rel="canonical" href=(canonical_url); 61 + } 62 + 63 + // Twitter Card meta tags 64 + meta name="twitter:card" content="summary_large_image"; 65 + meta name="twitter:title" content=(formatted_title); 66 + meta name="twitter:description" content=(description); 67 + meta name="twitter:image" content=(social_image_url); 68 + } 69 + } 70 + } 71 + 13 72 pub fn docs_layout( 14 73 main: Markup, 15 74 ctx: &mut PageContext, 16 75 headings: &[MarkdownHeading], 76 + seo: Option<SeoMeta>, 17 77 ) -> impl Into<RenderResult> { 18 78 layout( 19 79 html! { ··· 32 92 true, 33 93 false, 34 94 ctx, 95 + seo, 35 96 ) 36 97 } 37 98 ··· 40 101 bottom_border: bool, 41 102 licenses: bool, 42 103 ctx: &mut PageContext, 104 + seo: Option<SeoMeta>, 43 105 ) -> impl Into<RenderResult> { 44 106 ctx.assets 45 107 .include_style_with_options("assets/prin.css", StyleOptions { tailwind: true }); 46 108 109 + let seo_data = seo.unwrap_or_default(); 110 + 47 111 html! { 48 112 (DOCTYPE) 49 113 html lang="en" { 50 114 head { 51 115 meta charset="utf-8"; 52 116 meta name="viewport" content="width=device-width, initial-scale=1"; 53 - title { "Maudit" } 54 117 (generator()) 55 118 link rel="icon" href="/favicon.svg"; 119 + (seo_data.render(&ctx.base_url)) 56 120 } 57 121 body { 58 122 div.bg-our-white {
+1 -1
website/src/layout/docs_sidebars.rs
··· 42 42 ul { 43 43 @for entry in entries { 44 44 @let url = &format!("/docs/{}", entry.id); 45 - @let is_current_page = url == ctx.current_url; 45 + @let is_current_page = url == ctx.current_path; 46 46 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" }) { 47 47 a.block href=(format!("/docs/{}/", entry.id)) { (entry.data(ctx).title) } // TODO: Use type-safe routing 48 48 }
+1
website/src/main.rs
··· 21 21 ], 22 22 content_sources(), 23 23 BuildOptions { 24 + base_url: Some("https://maudit.org".to_string()), 24 25 assets: AssetsOptions { 25 26 tailwind_binary_path: "../node_modules/.bin/tailwindcss".into(), 26 27 ..Default::default()
+8 -1
website/src/routes/404.rs
··· 1 1 use maud::{html, PreEscaped}; 2 2 use maudit::route::prelude::*; 3 3 4 - use crate::layout::layout; 4 + use crate::layout::{layout, SeoMeta}; 5 5 6 6 #[route("404.html")] 7 7 pub struct NotFound; ··· 20 20 false, 21 21 false, 22 22 ctx, 23 + Some(SeoMeta { 24 + title: "404 - Page Not Found".to_string(), 25 + description: Some( 26 + "All the site's a stage, but this page plays not its part.".to_string(), 27 + ), 28 + ..Default::default() 29 + }), 23 30 ) 24 31 } 25 32 }
+9 -2
website/src/routes/contribute.rs
··· 1 1 use maud::html; 2 2 use maudit::route::prelude::*; 3 3 4 - use crate::layout::layout; 4 + use crate::layout::{layout, SeoMeta}; 5 5 6 - #[route("/contribute")] 6 + #[route("/contribute/")] 7 7 pub struct Contribute; 8 8 9 9 impl Route for Contribute { ··· 62 62 true, 63 63 false, 64 64 ctx, 65 + Some(SeoMeta { 66 + title: "Contribute to Maudit".to_string(), 67 + description: Some( 68 + "Discover various ways to contribute to the Maudit project.".to_string(), 69 + ), 70 + canonical_url: ctx.canonical_url(), 71 + }), 65 72 ) 66 73 } 67 74 }
+32 -6
website/src/routes/docs.rs
··· 1 1 use maud::{html, Markup, PreEscaped}; 2 2 use maudit::{content::EntryInner, route::prelude::*}; 3 3 4 - use crate::{content::DocsContent, layout::docs_layout}; 4 + use crate::{ 5 + content::DocsContent, 6 + layout::{docs_layout, SeoMeta}, 7 + }; 5 8 6 - #[route("/docs")] 9 + #[route("/docs/")] 7 10 pub struct DocsIndex; 8 11 9 12 impl Route for DocsIndex { ··· 15 18 16 19 let headings = index_page.data(ctx).get_headings().clone(); 17 20 18 - docs_layout(render_entry(index_page, ctx), ctx, &headings) 21 + docs_layout( 22 + render_entry(index_page, ctx), 23 + ctx, 24 + &headings, 25 + Some(SeoMeta { 26 + title: "Documentation".to_string(), 27 + description: Some( 28 + "Comprehensive guides and documentation to help you start working with Maudit." 29 + .to_string(), 30 + ), 31 + canonical_url: ctx.canonical_url(), 32 + }), 33 + ) 19 34 } 20 35 } 21 36 ··· 36 51 } 37 52 } 38 53 39 - #[route("/docs/[slug]")] 54 + #[route("/docs/[slug]/")] 40 55 pub struct DocsPage; 41 56 42 57 #[derive(Params, Clone)] ··· 62 77 .get_source::<DocsContent>("docs") 63 78 .get_entry(&slug); 64 79 65 - let headings = entry.data(ctx).get_headings().clone(); 66 - docs_layout(render_entry(entry, ctx), ctx, &headings) 80 + let entry_data = entry.data(ctx); 81 + 82 + let headings = entry_data.get_headings().clone(); 83 + docs_layout( 84 + render_entry(entry, ctx), 85 + ctx, 86 + &headings, 87 + Some(SeoMeta { 88 + title: format!("{} - Documentation", entry_data.title), 89 + description: entry_data.description.clone(), 90 + canonical_url: ctx.canonical_url(), 91 + }), 92 + ) 67 93 } 68 94 }
+6
website/src/routes/index.rs
··· 3 3 use maudit::route::prelude::*; 4 4 5 5 use crate::layout::layout; 6 + use crate::layout::SeoMeta; 6 7 7 8 #[route("/")] 8 9 pub struct Index; ··· 75 76 true, 76 77 true, 77 78 ctx, 79 + Some(SeoMeta { 80 + title: "Maudit".to_string(), 81 + description: None, 82 + canonical_url: ctx.canonical_url(), 83 + }), 78 84 ) 79 85 } 80 86 }
+27 -6
website/src/routes/news.rs
··· 5 5 6 6 use crate::content::NewsContent; 7 7 use crate::layout::layout; 8 + use crate::layout::SeoMeta; 8 9 9 - #[route("/news")] 10 + #[route("/news/")] 10 11 pub struct NewsIndex; 11 12 12 13 impl Route for NewsIndex { ··· 74 75 true, 75 76 true, 76 77 ctx, 78 + Some(SeoMeta { 79 + title: "News".to_string(), 80 + description: Some( 81 + "Stay updated with the latest news and articles about Maudit.".to_string(), 82 + ), 83 + canonical_url: ctx.canonical_url(), 84 + }), 77 85 ) 78 86 } 79 87 } 80 88 81 - #[route("/news/[slug]")] 89 + #[route("/news/[slug]/")] 82 90 pub struct NewsPage; 83 91 84 92 #[derive(Params, Clone)] ··· 104 112 .get_source::<NewsContent>("news") 105 113 .get_entry(&slug); 106 114 115 + let NewsContent { 116 + title, 117 + description, 118 + author, 119 + date, 120 + .. 121 + } = entry.data(ctx); 122 + 107 123 layout( 108 124 html! { 109 125 div.container.mx-auto."py-10"."pb-24"."max-w-[80ch]"."px-8"."sm:px-0" { 110 126 section.mb-4.border-b."border-[#e9e9e7]".pb-2 { 111 - @if let Some(date) = &entry.data(ctx).date { 127 + @if let Some(date) = &date { 112 128 p.text-sm.font-bold { (date) } 113 129 } 114 - h1."text-6xl"."sm:text-7xl".font-bold { (entry.data(ctx).title) } 115 - @if let Some(description) = &entry.data(ctx).description { 130 + h1."text-6xl"."sm:text-7xl".font-bold { (title) } 131 + @if let Some(description) = &description { 116 132 p.text-xl."sm:text-2xl" { (description) } 117 133 } 118 134 } ··· 121 137 (PreEscaped(entry.render(ctx))) 122 138 } 123 139 124 - @if let Some(author) = &entry.data(ctx).author { 140 + @if let Some(author) = &author { 125 141 h2."text-xl".font-bold.mt-12.text-center { (author) } 126 142 } 127 143 } ··· 129 145 false, 130 146 true, 131 147 ctx, 148 + Some(SeoMeta { 149 + title: title.to_string(), 150 + description: description.clone(), 151 + canonical_url: ctx.canonical_url(), 152 + }), 132 153 ) 133 154 } 134 155 }
website/static/social-image.png

This is a binary file and will not be displayed.