Rust library to generate static websites
1use chrono::{Datelike, Utc};
2use maud::{DOCTYPE, Markup, PreEscaped, html};
3mod docs_sidebars;
4mod header;
5
6use docs_sidebars::{left_sidebar, right_sidebar};
7
8pub use header::header;
9use maudit::assets::StyleOptions;
10use maudit::content::MarkdownHeading;
11use maudit::maud::generator;
12use maudit::route::{PageContext, RenderResult};
13
14pub struct SeoMeta {
15 pub title: String,
16 pub description: Option<String>,
17 pub canonical_url: Option<String>,
18}
19
20impl Default for SeoMeta {
21 fn default() -> Self {
22 Self {
23 title: "Maudit".to_string(),
24 description: Some("A Rust library to build static websites.".to_string()),
25 canonical_url: None,
26 }
27 }
28}
29
30impl SeoMeta {
31 /// Create a new `SeoMeta` with the given title.
32 pub fn render(&self, base_url: &Option<String>) -> Markup {
33 let base_url = base_url.as_ref().unwrap();
34
35 let formatted_title = if self.title == "Maudit" {
36 self.title.clone()
37 } else {
38 format!("{} - Maudit", self.title)
39 };
40
41 let description = self
42 .description
43 .clone()
44 .unwrap_or_else(|| SeoMeta::default().description.unwrap());
45
46 let canonical_url = self.canonical_url.as_ref();
47
48 let social_image_url = format!("{}/social-image.png", base_url);
49
50 html! {
51 title { (formatted_title) }
52 meta name="description" content=(description);
53
54 // Open Graph meta tags
55 meta property="og:title" content=(formatted_title);
56 meta property="og:description" content=(description);
57 meta property="og:type" content="website";
58 meta property="og:image" content=(social_image_url);
59 @if let Some(canonical_url) = &canonical_url {
60 meta property="og:url" content=(canonical_url);
61 link rel="canonical" href=(canonical_url);
62 }
63
64 // Twitter Card meta tags
65 meta name="twitter:card" content="summary";
66 meta name="twitter:title" content=(formatted_title);
67 meta name="twitter:description" content=(description);
68 meta name="twitter:image" content=(social_image_url);
69 }
70 }
71}
72
73pub fn docs_layout(
74 main: Markup,
75 ctx: &mut PageContext,
76 headings: &[MarkdownHeading],
77 seo: Option<SeoMeta>,
78) -> impl Into<RenderResult> {
79 ctx.assets.include_script("assets/docs-sidebar.ts")?;
80
81 layout(
82 html! {
83 // Second header for docs navigation (mobile only)
84 header.sticky.top-0.z-40.bg-our-white.border-b.border-borders.md:hidden.bg-linear-to-b."from-darker-white" {
85 div.flex.items-center.justify-between {
86 button id="left-sidebar-toggle" .px-4.py-3.flex.items-center.gap-x-2.text-base.font-medium.text-our-black aria-label="Toggle navigation menu" {
87 (PreEscaped(include_str!("../assets/side-menu.svg")))
88 span { "Menu" }
89 }
90 button id="right-sidebar-toggle" .px-4.py-3.flex.items-center.gap-x-2.text-base.font-medium.text-our-black aria-label="Toggle table of contents" {
91 span { "On this page" }
92 (PreEscaped(include_str!("../assets/toc.svg")))
93 }
94 }
95 }
96
97 // Mobile left sidebar overlay
98 div id="mobile-left-sidebar" .fixed."inset-0 bg-black/50".transition-opacity.opacity-0.pointer-events-none.z-50 {
99 div.w-80.max-w-sm.h-full.bg-our-white.overflow-y-auto.transform."-translate-x-full".transition-transform {
100 div.px-4.py-4 {
101 (left_sidebar(ctx))
102 }
103 }
104 }
105
106 // Mobile right sidebar overlay
107 div id="mobile-right-sidebar" .fixed."inset-0 bg-black/50".transition-opacity.opacity-0.pointer-events-none.z-50.flex.justify-end {
108 div.w-80.max-w-sm.h-full.bg-our-white.overflow-y-auto.transform."translate-x-full".transition-transform {
109 div.px-4.py-4 {
110 (right_sidebar(headings))
111 }
112 }
113 }
114
115 div.container.mx-auto."md:grid-cols-(--docs-tablet-columns)"."lg:grid-cols-(--docs-columns)".md:grid."min-h-[calc(100%-64px)]".px-6.md:px-0.pt-2.md:pt-0 {
116 aside.bg-linear-to-l."from-darker-white"."py-8"."h-full".border-r.border-r-borders.hidden.md:block."md:pr-4"."lg:px-0" {
117 (left_sidebar(ctx))
118 }
119 main.w-full.max-w-larger-prose.mx-auto.md:pt-8.py-4.pb-8.md:pb-16.md:px-6.lg:px-0 {
120 (main)
121 }
122 aside."py-8".hidden."lg:block" {
123 (right_sidebar(headings))
124 }
125 }
126 },
127 true,
128 false,
129 ctx,
130 seo,
131 )
132}
133
134pub fn layout(
135 main: Markup,
136 bottom_border: bool,
137 licenses: bool,
138 ctx: &mut PageContext,
139 seo: Option<SeoMeta>,
140) -> Result<Markup, Box<dyn std::error::Error>> {
141 ctx.assets
142 .include_style_with_options("assets/prin.css", StyleOptions { tailwind: true })?;
143
144 let seo_data = seo.unwrap_or_default();
145 let current_year = Utc::now().year();
146
147 Ok(html! {
148 (DOCTYPE)
149 html lang="en" {
150 head {
151 meta charset="utf-8";
152 meta name="viewport" content="width=device-width, initial-scale=1";
153 (generator())
154 link rel="icon" href="/favicon.svg";
155 (seo_data.render(ctx.base_url))
156 }
157 body {
158 div.relative.bg-our-white {
159 (header(ctx, bottom_border)?)
160 (main)
161 footer.bg-our-black.text-white {
162 div.container.mx-auto.px-8.py-8.flex.justify-between.items-center.flex-col-reverse."sm:flex-row".gap-y-12 {
163 div.grow."basis-[0]" {
164 a.text-md.font-bold href="https://bruits.org" {
165 "Copyright © " (current_year) " Bruits."
166 }
167 @if licenses {
168 br;
169 a.text-sm href="https://www.netlify.com" { "Site powered by Netlify" }
170 p.text-sm {"Wax seal icon by " a href="https://game-icons.net/" { "Game-icons.net" } " under " a href="https://creativecommons.org/licenses/by/3.0/" { "CC BY 3.0" } }
171 }
172 }
173 div { (PreEscaped(include_str!("../assets/logo.svg")))}
174 div.flex.gap-x-6.grow.justify-end."basis-[0]".items-center {
175 a href="https://bsky.app/profile/bruits.org" {
176 span.sr-only { "Follow Maudit on Bluesky" }
177 (PreEscaped(include_str!("../assets/bsky.svg")))
178 }
179 a href="/chat/" {
180 span.sr-only { "Join the Maudit community on Discord" }
181 (PreEscaped(include_str!("../assets/discord.svg")))
182 }
183 a href="https://github.com/bruits/maudit" {
184 span.sr-only { "View Maudit on GitHub" }
185 (PreEscaped(include_str!("../assets/github.svg")))
186 }
187 }
188 }
189 }
190 }
191 }
192 }
193 })
194}