webserver_base/templates/
page.rs1use chrono::{DateTime, Utc};
4use serde::Serialize;
5use serde_json::Value;
6
7use super::robots;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct Article {
17 pub published_time: DateTime<Utc>,
19 pub modified_time: Option<DateTime<Utc>>,
21 pub section: Option<String>,
23 pub tags: Vec<String>,
25}
26
27impl Article {
28 #[must_use]
30 pub const fn new(published_time: DateTime<Utc>) -> Self {
31 Self {
32 published_time,
33 modified_time: None,
34 section: None,
35 tags: Vec::new(),
36 }
37 }
38
39 #[must_use]
41 pub const fn with_modified_time(mut self, modified_time: DateTime<Utc>) -> Self {
42 self.modified_time = Some(modified_time);
43 self
44 }
45
46 #[must_use]
48 pub fn with_section(mut self, section: impl Into<String>) -> Self {
49 self.section = Some(section.into());
50 self
51 }
52
53 #[must_use]
55 pub fn extend_tags<I, S>(mut self, tags: I) -> Self
56 where
57 I: IntoIterator<Item = S>,
58 S: Into<String>,
59 {
60 self.tags.extend(tags.into_iter().map(Into::into));
61 self
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct PageTemplateData {
72 template: String,
73 page_name: String,
74 page_url: String,
75
76 description: Option<String>,
77 social_image: Option<String>,
78 social_image_alt: Option<String>,
79 robots: Option<String>,
80 jsonld: Option<Value>,
81 article: Option<Article>,
82
83 style_sheets: Option<Vec<String>>,
84 scripts: Option<Vec<String>>,
85 extra_style_sheets: Vec<String>,
86 extra_scripts: Vec<String>,
87}
88
89impl PageTemplateData {
90 #[must_use]
99 pub fn new(
100 template: impl Into<String>,
101 page_name: impl Into<String>,
102 page_url: impl Into<String>,
103 ) -> Self {
104 Self {
105 template: template.into(),
106 page_name: page_name.into(),
107 page_url: normalize_page_url(&page_url.into()),
108 description: None,
109 social_image: None,
110 social_image_alt: None,
111 robots: None,
112 jsonld: None,
113 article: None,
114 style_sheets: None,
115 scripts: None,
116 extra_style_sheets: Vec::new(),
117 extra_scripts: Vec::new(),
118 }
119 }
120
121 #[must_use]
123 pub fn with_description(mut self, description: impl Into<String>) -> Self {
124 self.description = Some(description.into());
125 self
126 }
127
128 #[must_use]
130 pub fn with_social_image(mut self, social_image: impl Into<String>) -> Self {
131 self.social_image = Some(social_image.into());
132 self
133 }
134
135 #[must_use]
137 pub fn with_social_image_alt(mut self, social_image_alt: impl Into<String>) -> Self {
138 self.social_image_alt = Some(social_image_alt.into());
139 self
140 }
141
142 #[must_use]
145 pub fn with_robots(mut self, robots: impl Into<String>) -> Self {
146 self.robots = Some(robots.into());
147 self
148 }
149
150 #[must_use]
156 pub fn with_jsonld(mut self, jsonld: Value) -> Self {
157 self.jsonld = Some(jsonld);
158 self
159 }
160
161 #[must_use]
166 pub fn with_article(mut self, article: Article) -> Self {
167 self.article = Some(article);
168 self
169 }
170
171 #[must_use]
173 pub fn replace_style_sheets<I, S>(mut self, style_sheets: I) -> Self
174 where
175 I: IntoIterator<Item = S>,
176 S: Into<String>,
177 {
178 self.style_sheets = Some(style_sheets.into_iter().map(Into::into).collect());
179 self
180 }
181
182 #[must_use]
184 pub fn extend_style_sheets<I, S>(mut self, style_sheets: I) -> Self
185 where
186 I: IntoIterator<Item = S>,
187 S: Into<String>,
188 {
189 self.extra_style_sheets
190 .extend(style_sheets.into_iter().map(Into::into));
191 self
192 }
193
194 #[must_use]
196 pub fn replace_scripts<I, S>(mut self, scripts: I) -> Self
197 where
198 I: IntoIterator<Item = S>,
199 S: Into<String>,
200 {
201 self.scripts = Some(scripts.into_iter().map(Into::into).collect());
202 self
203 }
204
205 #[must_use]
207 pub fn extend_scripts<I, S>(mut self, scripts: I) -> Self
208 where
209 I: IntoIterator<Item = S>,
210 S: Into<String>,
211 {
212 self.extra_scripts
213 .extend(scripts.into_iter().map(Into::into));
214 self
215 }
216
217 #[must_use]
219 pub fn template(&self) -> &str {
220 &self.template
221 }
222 #[must_use]
224 pub fn page_name(&self) -> &str {
225 &self.page_name
226 }
227 #[must_use]
229 pub fn page_url(&self) -> &str {
230 &self.page_url
231 }
232 #[must_use]
234 pub fn description(&self) -> Option<&str> {
235 self.description.as_deref()
236 }
237 #[must_use]
239 pub fn social_image(&self) -> Option<&str> {
240 self.social_image.as_deref()
241 }
242 #[must_use]
244 pub fn social_image_alt(&self) -> Option<&str> {
245 self.social_image_alt.as_deref()
246 }
247 #[must_use]
249 pub fn robots(&self) -> &str {
250 self.robots.as_deref().unwrap_or(robots::DEFAULT)
251 }
252 #[must_use]
254 pub const fn jsonld(&self) -> Option<&Value> {
255 self.jsonld.as_ref()
256 }
257 #[must_use]
259 pub const fn article(&self) -> Option<&Article> {
260 self.article.as_ref()
261 }
262 #[must_use]
264 pub const fn og_type(&self) -> &'static str {
265 if self.article.is_some() {
266 "article"
267 } else {
268 "website"
269 }
270 }
271
272 #[must_use]
277 pub fn declared_assets(&self) -> Vec<String> {
278 let mut declared: Vec<String> = Vec::new();
279 for list in [self.style_sheets.as_deref(), self.scripts.as_deref()]
280 .into_iter()
281 .flatten()
282 {
283 declared.extend(list.iter().cloned());
284 }
285 declared.extend(self.extra_style_sheets.iter().cloned());
286 declared.extend(self.extra_scripts.iter().cloned());
287 if let Some(social_image) = self.social_image.as_deref() {
288 declared.push(social_image.to_string());
289 }
290 declared
291 }
292
293 #[must_use]
296 pub fn resolve_style_sheets<'a>(&'a self, base: &'a [String]) -> Vec<&'a str> {
297 resolve(self.style_sheets.as_deref(), base, &self.extra_style_sheets)
298 }
299
300 #[must_use]
303 pub fn resolve_scripts<'a>(&'a self, base: &'a [String]) -> Vec<&'a str> {
304 resolve(self.scripts.as_deref(), base, &self.extra_scripts)
305 }
306}
307
308fn resolve<'a>(
310 replacement: Option<&'a [String]>,
311 base: &'a [String],
312 additions: &'a [String],
313) -> Vec<&'a str> {
314 replacement
315 .unwrap_or(base)
316 .iter()
317 .chain(additions.iter())
318 .map(String::as_str)
319 .collect()
320}
321
322fn normalize_page_url(page_url: &str) -> String {
325 let trimmed: &str = page_url.trim();
326 if trimmed.is_empty() || trimmed == "/" {
327 return String::from("/");
328 }
329 let without_trailing: &str = trimmed.trim_end_matches('/');
330 if without_trailing.starts_with('/') {
331 without_trailing.to_string()
332 } else {
333 format!("/{without_trailing}")
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use chrono::{DateTime, TimeZone, Utc};
340 use serde_json::json;
341
342 use super::{Article, PageTemplateData, normalize_page_url};
343 use crate::templates::robots;
344
345 fn base_sheets() -> Vec<String> {
346 vec![
347 String::from("static/stylesheet/main.css"),
348 String::from("static/stylesheet/theme.css"),
349 ]
350 }
351
352 #[test]
353 fn a_page_defaults_to_maximum_indexability() {
354 let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
355
356 let expected: String = String::from(robots::DEFAULT);
357 let actual: String = page.robots().to_string();
358 assert_eq!(expected, actual);
359 }
360
361 #[test]
362 fn a_page_can_opt_out_of_indexing() {
363 let page: PageTemplateData =
364 PageTemplateData::new("404", "404", "/404").with_robots(robots::NOINDEX_FOLLOW);
365
366 let expected: String = String::from("noindex, follow");
367 let actual: String = page.robots().to_string();
368 assert_eq!(expected, actual);
369 }
370
371 #[test]
372 fn page_urls_are_normalized_to_one_leading_and_no_trailing_slash() {
373 let cases: [(&str, &str); 6] = [
374 ("/", "/"),
375 ("", "/"),
376 ("blog", "/blog"),
377 ("/blog", "/blog"),
378 ("/blog/", "/blog"),
379 (" /blog/tips/ ", "/blog/tips"),
380 ];
381 for (input, expected) in cases {
382 let actual: String = normalize_page_url(input);
383 assert_eq!(expected, actual, "input was {input:?}");
384 }
385 }
386
387 #[test]
388 fn assets_default_to_the_base_list() {
389 let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
390
391 let base: Vec<String> = base_sheets();
392 let expected: Vec<&str> = vec!["static/stylesheet/main.css", "static/stylesheet/theme.css"];
393 let actual: Vec<&str> = page.resolve_style_sheets(&base);
394 assert_eq!(expected, actual);
395 }
396
397 #[test]
398 fn extend_appends_to_the_base_list() {
399 let page: PageTemplateData = PageTemplateData::new("home", "Home", "/")
400 .extend_style_sheets(["static/stylesheet/home.css"]);
401
402 let expected: Vec<&str> = vec![
403 "static/stylesheet/main.css",
404 "static/stylesheet/theme.css",
405 "static/stylesheet/home.css",
406 ];
407 let base: Vec<String> = base_sheets();
408 let actual: Vec<&str> = page.resolve_style_sheets(&base);
409 assert_eq!(expected, actual);
410 }
411
412 #[test]
413 fn replace_discards_the_base_list_then_extend_still_appends() {
414 let page: PageTemplateData = PageTemplateData::new("play", "Play", "/play")
415 .replace_style_sheets(["static/stylesheet/play.css"])
416 .extend_style_sheets(["static/stylesheet/tiles.css"]);
417
418 let base: Vec<String> = base_sheets();
419 let expected: Vec<&str> = vec!["static/stylesheet/play.css", "static/stylesheet/tiles.css"];
420 let actual: Vec<&str> = page.resolve_style_sheets(&base);
421 assert_eq!(expected, actual);
422 }
423
424 #[test]
425 fn overrides_are_absent_until_set() {
426 let bare: PageTemplateData = PageTemplateData::new("home", "Home", "/");
427
428 assert_eq!(None, bare.description());
429 assert_eq!(None, bare.social_image());
430 assert_eq!(None, bare.social_image_alt());
431 assert_eq!(None, bare.jsonld());
432 assert_eq!(None, bare.article());
433
434 let overridden: PageTemplateData = bare
435 .with_description("A specific page.")
436 .with_social_image("/static/image/social/blog.webp")
437 .with_social_image_alt("The blog card")
438 .with_jsonld(json!({ "@type": "BlogPosting" }));
439
440 assert_eq!(Some("A specific page."), overridden.description());
441 assert_eq!(
442 Some("/static/image/social/blog.webp"),
443 overridden.social_image()
444 );
445 assert_eq!(Some("The blog card"), overridden.social_image_alt());
446 assert!(overridden.jsonld().is_some());
447 }
448
449 #[test]
450 fn a_page_is_a_website_until_it_is_given_article_metadata() {
451 let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
452
453 let expected: &str = "website";
454 let actual: &str = page.og_type();
455 assert_eq!(expected, actual);
456 }
457
458 #[test]
459 fn attaching_article_metadata_is_what_makes_the_og_type_an_article() {
460 let published: DateTime<Utc> = Utc.with_ymd_and_hms(2026, 1, 15, 0, 0, 0).unwrap();
461 let page: PageTemplateData = PageTemplateData::new("blog-post", "A Post", "/blog/a-post")
462 .with_article(
463 Article::new(published)
464 .with_section("Engineering")
465 .extend_tags(["rust", "axum"]),
466 );
467
468 let expected: &str = "article";
469 let actual: &str = page.og_type();
470 assert_eq!(expected, actual);
471
472 let article: &Article = page.article().expect("set above");
473 assert_eq!(published, article.published_time);
474 assert_eq!(None, article.modified_time);
475 assert_eq!(Some(String::from("Engineering")), article.section);
476 assert_eq!(
477 vec![String::from("rust"), String::from("axum")],
478 article.tags
479 );
480 }
481}