1use serde::{Deserialize, Serialize};
4
5use super::site_entity::SiteEntity;
6use super::theme::{Fallback, ThemeColor, ThemeScript};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BaseTemplateDataParams {
15 pub project: String,
17 pub description: String,
19 pub author: String,
21 pub base_url: String,
23 pub twitter_username: String,
25 pub social_image: String,
27 pub social_image_alt: String,
29 pub theme_color: ThemeColor,
31 pub theme_fallback: Fallback,
35 pub site_entity: SiteEntity,
37 pub language_code: String,
39 pub country_code: String,
41 pub see_also: Vec<String>,
43 pub copyright_start: String,
45 pub style_sheets: Vec<String>,
47 pub scripts: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct BaseTemplateData {
58 project: String,
59 description: String,
60 author: String,
61 base_url: String,
62 twitter_username: String,
63 social_image: String,
64 social_image_alt: String,
65 theme_color: ThemeColor,
66 theme_fallback: Fallback,
67 site_entity: SiteEntity,
68 language_code: String,
69 country_code: String,
70 see_also: Vec<String>,
71 copyright_start: String,
72 style_sheets: Vec<String>,
73 scripts: Vec<String>,
74}
75
76impl BaseTemplateData {
77 #[must_use]
86 pub fn new(params: BaseTemplateDataParams) -> Self {
87 Self {
88 project: params.project,
89 description: params.description,
90 author: params.author,
91 base_url: normalize_base_url(¶ms.base_url),
92 twitter_username: params.twitter_username,
93 social_image: normalize_asset_path(¶ms.social_image),
94 social_image_alt: params.social_image_alt,
95 theme_color: params.theme_color,
96 theme_fallback: params.theme_fallback,
97 site_entity: params.site_entity,
98 language_code: params.language_code,
99 country_code: params.country_code,
100 see_also: params.see_also,
101 copyright_start: params.copyright_start,
102 style_sheets: params.style_sheets,
103 scripts: params.scripts,
104 }
105 }
106
107 #[must_use]
109 pub fn with_author(mut self, author: impl Into<String>) -> Self {
110 self.author = author.into();
111 self
112 }
113
114 #[must_use]
116 pub fn with_twitter_username(mut self, twitter_username: impl Into<String>) -> Self {
117 self.twitter_username = twitter_username.into();
118 self
119 }
120
121 #[must_use]
123 pub fn with_site_entity(mut self, site_entity: SiteEntity) -> Self {
124 self.site_entity = site_entity;
125 self
126 }
127
128 #[must_use]
130 pub fn with_language_code(mut self, language_code: impl Into<String>) -> Self {
131 self.language_code = language_code.into();
132 self
133 }
134
135 #[must_use]
137 pub fn with_country_code(mut self, country_code: impl Into<String>) -> Self {
138 self.country_code = country_code.into();
139 self
140 }
141
142 #[must_use]
146 pub fn replace_see_also<I, S>(mut self, see_also: I) -> Self
147 where
148 I: IntoIterator<Item = S>,
149 S: Into<String>,
150 {
151 self.see_also = see_also.into_iter().map(Into::into).collect();
152 self
153 }
154
155 #[must_use]
160 pub fn extend_see_also<I, S>(mut self, see_also: I) -> Self
161 where
162 I: IntoIterator<Item = S>,
163 S: Into<String>,
164 {
165 self.see_also.extend(see_also.into_iter().map(Into::into));
166 self
167 }
168
169 #[must_use]
171 pub fn extend_style_sheets<I, S>(mut self, style_sheets: I) -> Self
172 where
173 I: IntoIterator<Item = S>,
174 S: Into<String>,
175 {
176 self.style_sheets
177 .extend(style_sheets.into_iter().map(Into::into));
178 self
179 }
180
181 #[must_use]
183 pub fn extend_scripts<I, S>(mut self, scripts: I) -> Self
184 where
185 I: IntoIterator<Item = S>,
186 S: Into<String>,
187 {
188 self.scripts.extend(scripts.into_iter().map(Into::into));
189 self
190 }
191
192 #[must_use]
194 pub fn project(&self) -> &str {
195 &self.project
196 }
197 #[must_use]
199 pub fn description(&self) -> &str {
200 &self.description
201 }
202 #[must_use]
204 pub fn author(&self) -> &str {
205 &self.author
206 }
207 #[must_use]
209 pub fn base_url(&self) -> &str {
210 &self.base_url
211 }
212 #[must_use]
214 pub fn twitter_username(&self) -> &str {
215 &self.twitter_username
216 }
217 #[must_use]
219 pub fn social_image(&self) -> &str {
220 &self.social_image
221 }
222 #[must_use]
224 pub fn social_image_alt(&self) -> &str {
225 &self.social_image_alt
226 }
227 #[must_use]
229 pub const fn theme_color(&self) -> &ThemeColor {
230 &self.theme_color
231 }
232 #[must_use]
234 pub const fn site_entity(&self) -> &SiteEntity {
235 &self.site_entity
236 }
237 #[must_use]
239 pub const fn theme_script(&self) -> ThemeScript {
240 ThemeScript::new(self.theme_fallback)
241 }
242 #[must_use]
244 pub fn language_code(&self) -> &str {
245 &self.language_code
246 }
247 #[must_use]
249 pub fn country_code(&self) -> &str {
250 &self.country_code
251 }
252 #[must_use]
254 pub fn see_also(&self) -> &[String] {
255 &self.see_also
256 }
257 #[must_use]
259 pub fn copyright_start(&self) -> &str {
260 &self.copyright_start
261 }
262 #[must_use]
264 pub fn style_sheets(&self) -> &[String] {
265 &self.style_sheets
266 }
267 #[must_use]
269 pub fn scripts(&self) -> &[String] {
270 &self.scripts
271 }
272}
273
274fn normalize_base_url(base_url: &str) -> String {
276 base_url.trim().trim_end_matches('/').to_string()
277}
278
279fn normalize_asset_path(path: &str) -> String {
281 path.trim().trim_start_matches('/').to_string()
282}
283
284#[cfg(feature = "preset")]
290pub const GODDTRIFFIN_SEE_ALSO: [&str; 8] = [
291 "https://www.toddgriffin.me/",
292 "https://x.com/goddtriffin",
293 "https://github.com/goddtriffin",
294 "https://www.instagram.com/goddtriffin/",
295 "https://www.youtube.com/@goddtriffin",
296 "https://www.facebook.com/goddtriffin/",
297 "https://stackoverflow.com/users/11767294/goddtriffin",
298 "https://www.reddit.com/user/goddtriffin",
299];
300
301#[cfg(feature = "preset")]
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct GoddtriffinParams {
311 pub project: String,
313 pub description: String,
315 pub base_url: String,
317 pub social_image: String,
319 pub theme_color: ThemeColor,
321 pub theme_fallback: Fallback,
323 pub copyright_start: String,
325 pub style_sheets: Vec<String>,
327 pub scripts: Vec<String>,
329}
330
331#[cfg(feature = "preset")]
332impl BaseTemplateData {
333 #[must_use]
350 pub fn goddtriffin(params: GoddtriffinParams) -> Self {
351 let social_image_alt: String = format!("{}: {}", params.project, params.description);
352
353 Self::new(BaseTemplateDataParams {
354 project: params.project,
355 description: params.description,
356 author: String::from("Todd Everett Griffin"),
357 base_url: params.base_url,
358 twitter_username: String::from("goddtriffin"),
359 social_image: params.social_image,
360 social_image_alt,
361 theme_color: params.theme_color,
362 theme_fallback: params.theme_fallback,
363 site_entity: SiteEntity::person("Todd Everett Griffin"),
364 language_code: String::from("en"),
365 country_code: String::from("US"),
366 see_also: GODDTRIFFIN_SEE_ALSO
367 .iter()
368 .map(|url| (*url).to_string())
369 .collect(),
370 copyright_start: params.copyright_start,
371 style_sheets: params.style_sheets,
372 scripts: params.scripts,
373 })
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::{BaseTemplateData, BaseTemplateDataParams, Fallback, SiteEntity, ThemeColor};
380
381 fn params() -> BaseTemplateDataParams {
382 BaseTemplateDataParams {
383 project: String::from("Test Project"),
384 description: String::from("A test."),
385 author: String::from("Someone"),
386 base_url: String::from("https://www.example.com"),
387 twitter_username: String::from("someone"),
388 social_image: String::from("static/social.webp"),
389 social_image_alt: String::from("A test image."),
390 theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
391 theme_fallback: Fallback::Dark,
392 site_entity: SiteEntity::person("Someone"),
393 language_code: String::from("en"),
394 country_code: String::from("US"),
395 see_also: vec![String::from("https://www.example.com/")],
396 copyright_start: String::from("1998"),
397 style_sheets: vec![String::from("static/stylesheet/main.css")],
398 scripts: vec![String::from("static/script/main.js")],
399 }
400 }
401
402 #[test]
403 fn a_trailing_slash_is_stripped_so_urls_never_double_up() {
404 let mut with_slash: BaseTemplateDataParams = params();
405 with_slash.base_url = String::from("https://www.example.com/");
406
407 let expected: String = String::from("https://www.example.com");
408 let actual: String = BaseTemplateData::new(with_slash).base_url().to_string();
409 assert_eq!(expected, actual);
410 }
411
412 #[test]
413 fn a_leading_slash_on_the_social_image_is_stripped_so_it_can_be_cache_busted() {
414 let mut with_slash: BaseTemplateDataParams = params();
415 with_slash.social_image = String::from("/static/image/social/card.webp");
416
417 let expected: String = String::from("static/image/social/card.webp");
421 let actual: String = BaseTemplateData::new(with_slash).social_image().to_string();
422 assert_eq!(expected, actual);
423 }
424
425 #[test]
426 fn every_field_survives_construction() {
427 let expected: BaseTemplateDataParams = params();
428 let actual: BaseTemplateData = BaseTemplateData::new(params());
429
430 assert_eq!(expected.project, actual.project());
431 assert_eq!(expected.description, actual.description());
432 assert_eq!(expected.author, actual.author());
433 assert_eq!(expected.base_url, actual.base_url());
434 assert_eq!(expected.twitter_username, actual.twitter_username());
435 assert_eq!(expected.social_image, actual.social_image());
436 assert_eq!(expected.social_image_alt, actual.social_image_alt());
437 assert_eq!(&expected.theme_color, actual.theme_color());
438 assert_eq!(expected.theme_fallback, actual.theme_fallback);
439 assert_eq!(&expected.site_entity, actual.site_entity());
440 assert_eq!(expected.language_code, actual.language_code());
441 assert_eq!(expected.country_code, actual.country_code());
442 assert_eq!(expected.see_also, actual.see_also());
443 assert_eq!(expected.copyright_start, actual.copyright_start());
444 assert_eq!(expected.style_sheets, actual.style_sheets());
445 assert_eq!(expected.scripts, actual.scripts());
446 }
447
448 #[test]
449 fn extend_appends_without_discarding() {
450 let base: BaseTemplateData = BaseTemplateData::new(params())
451 .extend_see_also(["https://www.youtube.com/@tripleentendreband"])
452 .extend_scripts(["static/script/home.js"]);
453
454 let expected_see_also: Vec<String> = vec![
455 String::from("https://www.example.com/"),
456 String::from("https://www.youtube.com/@tripleentendreband"),
457 ];
458 let actual_see_also: Vec<String> = base.see_also().to_vec();
459 assert_eq!(expected_see_also, actual_see_also);
460
461 let expected_scripts: Vec<String> = vec![
462 String::from("static/script/main.js"),
463 String::from("static/script/home.js"),
464 ];
465 let actual_scripts: Vec<String> = base.scripts().to_vec();
466 assert_eq!(expected_scripts, actual_scripts);
467 }
468
469 #[cfg(feature = "preset")]
470 #[test]
471 fn the_preset_fills_identity_and_leaves_the_rest_required() {
472 use super::{GODDTRIFFIN_SEE_ALSO, GoddtriffinParams};
473
474 let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
475 project: String::from("Boggledygook"),
476 description: String::from("Boggle, exhaustively."),
477 base_url: String::from("https://www.boggledygook.com"),
478 social_image: String::from("/og-image.png"),
479 theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
480 theme_fallback: Fallback::Dark,
481 copyright_start: String::from("2025"),
482 style_sheets: vec![],
483 scripts: vec![],
484 });
485
486 let expected_author: String = String::from("Todd Everett Griffin");
487 let actual_author: String = base.author().to_string();
488 assert_eq!(expected_author, actual_author);
489
490 let expected_handle: String = String::from("goddtriffin");
491 let actual_handle: String = base.twitter_username().to_string();
492 assert_eq!(expected_handle, actual_handle);
493
494 let expected_entity: SiteEntity = SiteEntity::person("Todd Everett Griffin");
495 assert_eq!(&expected_entity, base.site_entity());
496
497 let expected_see_also: Vec<String> = GODDTRIFFIN_SEE_ALSO
498 .iter()
499 .map(|url| (*url).to_string())
500 .collect();
501 let actual_see_also: Vec<String> = base.see_also().to_vec();
502 assert_eq!(expected_see_also, actual_see_also);
503 }
504
505 #[cfg(feature = "preset")]
506 #[test]
507 fn the_preset_see_also_leads_with_the_personal_site_then_x_then_github() {
508 use super::GODDTRIFFIN_SEE_ALSO;
509
510 let expected: [&str; 3] = [
511 "https://www.toddgriffin.me/",
512 "https://x.com/goddtriffin",
513 "https://github.com/goddtriffin",
514 ];
515 let actual: [&str; 3] = [
516 GODDTRIFFIN_SEE_ALSO[0],
517 GODDTRIFFIN_SEE_ALSO[1],
518 GODDTRIFFIN_SEE_ALSO[2],
519 ];
520 assert_eq!(expected, actual);
521
522 assert!(
523 !GODDTRIFFIN_SEE_ALSO
524 .iter()
525 .any(|url| url.contains("twitter.com")),
526 "twitter.com was replaced by x.com"
527 );
528 }
529
530 #[cfg(feature = "preset")]
531 #[test]
532 fn a_preset_field_can_still_be_overridden() {
533 use super::GoddtriffinParams;
534
535 let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
536 project: String::from("Triple Entendre"),
537 description: String::from("A band."),
538 base_url: String::from("https://www.tripleentendreband.com"),
539 social_image: String::from("/static/social.webp"),
540 theme_color: ThemeColor::dark("#000000"),
541 theme_fallback: Fallback::Dark,
542 copyright_start: String::from("2019"),
543 style_sheets: vec![],
544 scripts: vec![],
545 })
546 .with_twitter_username("tripleentendre")
547 .extend_see_also(["https://www.youtube.com/@tripleentendreband"]);
548
549 let expected_handle: String = String::from("tripleentendre");
550 let actual_handle: String = base.twitter_username().to_string();
551 assert_eq!(expected_handle, actual_handle);
552
553 let expected_len: usize = 9;
554 let actual_len: usize = base.see_also().len();
555 assert_eq!(expected_len, actual_len);
556 }
557
558 #[cfg(feature = "preset")]
559 #[test]
560 fn the_preset_derives_social_image_alt_from_project_and_description() {
561 use super::GoddtriffinParams;
562
563 let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
564 project: String::from("Scannable Codes"),
565 description: String::from("Every scannable code, explained."),
566 base_url: String::from("https://www.scannablecodes.com"),
567 social_image: String::from("/static/social.webp"),
568 theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
569 theme_fallback: Fallback::Dark,
570 copyright_start: String::from("1998"),
571 style_sheets: vec![],
572 scripts: vec![],
573 });
574
575 let expected: String = String::from("Scannable Codes: Every scannable code, explained.");
576 let actual: String = base.social_image_alt().to_string();
577 assert_eq!(expected, actual);
578 }
579
580 #[test]
581 fn replace_see_also_discards_whatever_was_there() {
582 let base: BaseTemplateData = BaseTemplateData::new(params())
583 .replace_see_also(["https://www.youtube.com/@tripleentendreband"]);
584
585 let expected: Vec<String> =
586 vec![String::from("https://www.youtube.com/@tripleentendreband")];
587 let actual: Vec<String> = base.see_also().to_vec();
588 assert_eq!(expected, actual);
589 }
590
591 #[test]
592 fn replace_then_extend_composes_in_that_order() {
593 let base: BaseTemplateData = BaseTemplateData::new(params())
594 .replace_see_also(["https://a.example.com"])
595 .extend_see_also(["https://b.example.com"]);
596
597 let expected: Vec<String> = vec![
598 String::from("https://a.example.com"),
599 String::from("https://b.example.com"),
600 ];
601 let actual: Vec<String> = base.see_also().to_vec();
602 assert_eq!(expected, actual);
603 }
604}