1use crate::config::{self, SiteConfig};
62use crate::types::{NavItem, Page};
63use maud::{DOCTYPE, Markup, PreEscaped, html};
64use pulldown_cmark::{Parser, html as md_html};
65use serde::Deserialize;
66use std::collections::BTreeMap;
67use std::fs;
68use std::path::Path;
69use thiserror::Error;
70
71#[derive(Error, Debug)]
72pub enum GenerateError {
73 #[error("IO error: {0}")]
74 Io(#[from] std::io::Error),
75 #[error("JSON error: {0}")]
76 Json(#[from] serde_json::Error),
77}
78
79#[derive(Debug, Deserialize)]
81pub struct Manifest {
82 pub navigation: Vec<NavItem>,
83 pub albums: Vec<Album>,
84 #[serde(default)]
85 pub pages: Vec<Page>,
86 #[serde(default)]
87 pub description: Option<String>,
88 pub config: SiteConfig,
89}
90
91#[derive(Debug, Deserialize)]
92pub struct Album {
93 pub path: String,
94 pub title: String,
95 pub description: Option<String>,
96 pub thumbnail: String,
97 pub images: Vec<Image>,
98 pub in_nav: bool,
99 #[allow(dead_code)]
101 pub config: SiteConfig,
102 #[serde(default)]
103 #[allow(dead_code)]
104 pub support_files: Vec<String>,
105}
106
107#[derive(Debug, Deserialize)]
108pub struct Image {
109 pub number: u32,
110 #[allow(dead_code)]
111 pub source_path: String,
112 #[serde(default)]
113 pub title: Option<String>,
114 #[serde(default)]
115 pub description: Option<String>,
116 pub dimensions: (u32, u32),
117 pub generated: BTreeMap<String, GeneratedVariant>,
118 pub thumbnail: String,
119}
120
121#[derive(Debug, Deserialize)]
122pub struct GeneratedVariant {
123 pub avif: String,
124 #[allow(dead_code)]
125 pub width: u32,
126 #[allow(dead_code)]
127 pub height: u32,
128}
129
130const CSS_STATIC: &str = include_str!("../static/style.css");
131const JS: &str = include_str!("../static/nav.js");
132const SW_JS_TEMPLATE: &str = include_str!("../static/sw.js");
133const ICON_192: &[u8] = include_bytes!("../static/icon-192.png");
136const ICON_512: &[u8] = include_bytes!("../static/icon-512.png");
137const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../static/apple-touch-icon.png");
138const FAVICON_PNG: &[u8] = include_bytes!("../static/favicon.png");
139
140fn image_sizes_attr(aspect_ratio: f64, max_generated_width: u32) -> String {
145 let vh_factor = 90.0 * aspect_ratio;
148 let cap = format!("{}px", max_generated_width);
150 if vh_factor >= 100.0 {
151 format!("(max-width: 800px) min(100vw, {cap}), min(95vw, {cap})")
153 } else {
154 format!("(max-width: 800px) min(100vw, {cap}), min({vh_factor:.1}vh, {cap})")
156 }
157}
158
159struct GalleryEntry {
161 title: String,
162 path: String,
163 thumbnail: Option<String>,
164}
165
166fn find_nav_thumbnail(item: &NavItem, albums: &[Album]) -> Option<String> {
168 if item.children.is_empty() {
169 albums
171 .iter()
172 .find(|a| a.path == item.path)
173 .map(|a| a.thumbnail.clone())
174 } else {
175 item.children
177 .first()
178 .and_then(|c| find_nav_thumbnail(c, albums))
179 }
180}
181
182fn collect_gallery_entries(children: &[NavItem], albums: &[Album]) -> Vec<GalleryEntry> {
184 children
185 .iter()
186 .map(|item| GalleryEntry {
187 title: item.title.clone(),
188 path: item.path.clone(),
189 thumbnail: find_nav_thumbnail(item, albums),
190 })
191 .collect()
192}
193
194fn path_to_breadcrumb_segments<'a>(
198 path: &str,
199 navigation: &'a [NavItem],
200) -> Vec<(&'a str, &'a str)> {
201 fn find_segments<'a>(
202 path: &str,
203 items: &'a [NavItem],
204 segments: &mut Vec<(&'a str, &'a str)>,
205 ) -> bool {
206 for item in items {
207 if item.path == path {
208 return true;
209 }
210 if path.starts_with(&format!("{}/", item.path)) {
211 segments.push((&item.title, &item.path));
212 if find_segments(path, &item.children, segments) {
213 return true;
214 }
215 segments.pop();
216 }
217 }
218 false
219 }
220
221 let mut segments = Vec::new();
222 find_segments(path, navigation, &mut segments);
223 segments
224}
225
226#[derive(Debug, Default)]
233struct CustomSnippets {
234 has_custom_css: bool,
236 head_html: Option<String>,
238 body_end_html: Option<String>,
240}
241
242fn detect_custom_snippets(output_dir: &Path) -> CustomSnippets {
246 CustomSnippets {
247 has_custom_css: output_dir.join("custom.css").exists(),
248 head_html: fs::read_to_string(output_dir.join("head.html")).ok(),
249 body_end_html: fs::read_to_string(output_dir.join("body-end.html")).ok(),
250 }
251}
252
253pub(crate) fn index_width(total: usize) -> usize {
255 match total {
256 0..=9 => 1,
257 10..=99 => 2,
258 100..=999 => 3,
259 _ => 4,
260 }
261}
262
263pub(crate) fn image_page_url(position: usize, total: usize, title: Option<&str>) -> String {
272 let width = index_width(total);
273 match title {
274 Some(t) => {
275 let escaped = escape_for_url(t);
276 format!("{:0>width$}-{}/", position, escaped)
277 }
278 None => format!("{:0>width$}/", position),
279 }
280}
281
282fn escape_for_url(title: &str) -> String {
286 let mut result = String::with_capacity(title.len());
287 let mut prev_dash = false;
288 for c in title.chars() {
289 if c == ' ' || c == '.' || c == '_' {
290 if !prev_dash {
291 result.push('-');
292 }
293 prev_dash = true;
294 } else {
295 result.extend(c.to_lowercase());
296 prev_dash = false;
297 }
298 }
299 result.trim_matches('-').to_string()
300}
301
302const SHORT_CAPTION_MAX_LEN: usize = 160;
303
304fn is_short_caption(text: &str) -> bool {
310 text.len() <= SHORT_CAPTION_MAX_LEN && !text.contains('\n')
311}
312
313pub fn generate(
314 manifest_path: &Path,
315 processed_dir: &Path,
316 output_dir: &Path,
317 source_dir: &Path,
318) -> Result<(), GenerateError> {
319 let manifest_content = fs::read_to_string(manifest_path)?;
320 let manifest: Manifest = serde_json::from_str(&manifest_content)?;
321
322 let font_url = manifest.config.font.stylesheet_url();
343 let color_css = config::generate_color_css(&manifest.config.colors);
344 let theme_css = config::generate_theme_css(&manifest.config.theme);
345 let font_css = config::generate_font_css(&manifest.config.font);
346 let css = format!(
347 "{}\n\n{}\n\n{}\n\n{}",
348 color_css, theme_css, font_css, CSS_STATIC
349 );
350
351 fs::create_dir_all(output_dir)?;
352
353 let manifest_json = serde_json::json!({
367 "name": manifest.config.site_title,
368 "short_name": manifest.config.site_title,
369 "icons": [
370 {
371 "src": "/icon-192.png",
372 "sizes": "192x192",
373 "type": "image/png"
374 },
375 {
376 "src": "/icon-512.png",
377 "sizes": "512x512",
378 "type": "image/png"
379 }
380 ],
381 "theme_color": "#ffffff",
382 "background_color": "#ffffff",
383 "display": "standalone",
384 "scope": "/",
385 "start_url": "/"
386 });
387 fs::write(
388 output_dir.join("site.webmanifest"),
389 serde_json::to_string_pretty(&manifest_json)?,
390 )?;
391
392 let version = env!("CARGO_PKG_VERSION");
395 let sw_content = SW_JS_TEMPLATE.replace(
396 "const CACHE_NAME = 'simple-gal-v1';",
397 &format!("const CACHE_NAME = 'simple-gal-v{}';", version),
398 );
399 fs::write(output_dir.join("sw.js"), sw_content)?;
400
401 fs::write(output_dir.join("icon-192.png"), ICON_192)?;
402 fs::write(output_dir.join("icon-512.png"), ICON_512)?;
403 fs::write(output_dir.join("apple-touch-icon.png"), APPLE_TOUCH_ICON)?;
404 fs::write(output_dir.join("favicon.png"), FAVICON_PNG)?;
405
406 let assets_path = source_dir.join(&manifest.config.assets_dir);
408 if assets_path.is_dir() {
409 copy_dir_recursive(&assets_path, output_dir)?;
410 }
411
412 copy_dir_recursive(processed_dir, output_dir)?;
414
415 let favicon_href = detect_favicon(output_dir);
417
418 let snippets = detect_custom_snippets(output_dir);
420
421 let index_html = render_index(
423 &manifest,
424 &css,
425 font_url.as_deref(),
426 favicon_href.as_deref(),
427 &snippets,
428 );
429 fs::write(output_dir.join("index.html"), index_html.into_string())?;
430
431 for page in manifest.pages.iter().filter(|p| !p.is_link) {
433 let page_html = render_page(
434 page,
435 &manifest.navigation,
436 &manifest.pages,
437 &css,
438 font_url.as_deref(),
439 &manifest.config.site_title,
440 favicon_href.as_deref(),
441 &snippets,
442 );
443 let filename = format!("{}.html", page.slug);
444 fs::write(output_dir.join(&filename), page_html.into_string())?;
445 }
446
447 generate_gallery_list_pages(
449 &manifest.navigation,
450 &manifest.albums,
451 &manifest.navigation,
452 &manifest.pages,
453 &css,
454 font_url.as_deref(),
455 &manifest.config.site_title,
456 favicon_href.as_deref(),
457 &snippets,
458 output_dir,
459 )?;
460
461 for album in &manifest.albums {
463 let album_dir = output_dir.join(&album.path);
464 fs::create_dir_all(&album_dir)?;
465
466 let album_html = render_album_page(
467 album,
468 &manifest.navigation,
469 &manifest.pages,
470 &css,
471 font_url.as_deref(),
472 &manifest.config.site_title,
473 favicon_href.as_deref(),
474 &snippets,
475 );
476 fs::write(album_dir.join("index.html"), album_html.into_string())?;
477
478 for (idx, image) in album.images.iter().enumerate() {
480 let prev = if idx > 0 {
481 Some(&album.images[idx - 1])
482 } else {
483 None
484 };
485 let next = album.images.get(idx + 1);
486
487 let image_html = render_image_page(
488 album,
489 image,
490 prev,
491 next,
492 &manifest.navigation,
493 &manifest.pages,
494 &css,
495 font_url.as_deref(),
496 &manifest.config.site_title,
497 favicon_href.as_deref(),
498 &snippets,
499 );
500 let image_dir_name =
501 image_page_url(idx + 1, album.images.len(), image.title.as_deref());
502 let image_dir = album_dir.join(&image_dir_name);
503 fs::create_dir_all(&image_dir)?;
504 fs::write(image_dir.join("index.html"), image_html.into_string())?;
505 }
506 }
507
508 Ok(())
509}
510
511fn detect_favicon(output_dir: &Path) -> Option<String> {
513 for (filename, _mime) in &[
514 ("favicon.svg", "image/svg+xml"),
515 ("favicon.ico", "image/x-icon"),
516 ("favicon.png", "image/png"),
517 ] {
518 if output_dir.join(filename).exists() {
519 return Some(format!("/{}", filename));
520 }
521 }
522 None
523}
524
525fn favicon_type(href: &str) -> &'static str {
527 if href.ends_with(".svg") {
528 "image/svg+xml"
529 } else if href.ends_with(".png") {
530 "image/png"
531 } else {
532 "image/x-icon"
533 }
534}
535
536fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
537 for entry in fs::read_dir(src)? {
538 let entry = entry?;
539 let src_path = entry.path();
540 let dst_path = dst.join(entry.file_name());
541
542 if src_path.is_dir() {
543 fs::create_dir_all(&dst_path)?;
544 copy_dir_recursive(&src_path, &dst_path)?;
545 } else if src_path.extension().map(|e| e != "json").unwrap_or(true) {
546 fs::copy(&src_path, &dst_path)?;
548 }
549 }
550 Ok(())
551}
552
553#[allow(clippy::too_many_arguments)]
564fn base_document(
565 title: &str,
566 css: &str,
567 font_url: Option<&str>,
568 body_class: Option<&str>,
569 head_extra: Option<Markup>,
570 favicon_href: Option<&str>,
571 snippets: &CustomSnippets,
572 content: Markup,
573) -> Markup {
574 html! {
575 (DOCTYPE)
576 html lang="en" {
577 head {
578 meta charset="UTF-8";
579 meta name="viewport" content="width=device-width, initial-scale=1.0";
580 title { (title) }
581 link rel="manifest" href="/site.webmanifest";
583 link rel="apple-touch-icon" href="/apple-touch-icon.png";
584 @if let Some(href) = favicon_href {
585 link rel="icon" type=(favicon_type(href)) href=(href);
586 }
587 @if let Some(url) = font_url {
589 link rel="preconnect" href="https://fonts.googleapis.com";
590 link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="";
591 link rel="stylesheet" href=(url);
592 }
593 style { (PreEscaped(css)) }
594 @if snippets.has_custom_css {
596 link rel="stylesheet" href="/custom.css";
597 }
598 @if let Some(extra) = head_extra {
599 (extra)
600 }
601 script {
602 (PreEscaped(r#"
603 if ('serviceWorker' in navigator && location.protocol !== 'file:') {
604 window.addEventListener('load', () => {
605 navigator.serviceWorker.register('/sw.js');
606 });
607 }
608 window.addEventListener('beforeinstallprompt', e => e.preventDefault());
609 "#))
610 }
611 @if let Some(ref html) = snippets.head_html {
612 (PreEscaped(html))
613 }
614 }
615 body class=[body_class] {
616 (content)
617 script { (PreEscaped(JS)) }
618 @if let Some(ref html) = snippets.body_end_html {
619 (PreEscaped(html))
620 }
621 }
622 }
623 }
624}
625
626fn site_header(breadcrumb: Markup, nav: Markup) -> Markup {
628 html! {
629 header.site-header {
630 nav.breadcrumb {
631 (breadcrumb)
632 }
633 nav.site-nav {
634 (nav)
635 }
636 }
637 }
638}
639
640pub fn render_nav(items: &[NavItem], current_path: &str, pages: &[Page]) -> Markup {
645 let nav_pages: Vec<&Page> = pages.iter().filter(|p| p.in_nav).collect();
646
647 html! {
648 input.nav-toggle type="checkbox" id="nav-toggle";
649 label.nav-hamburger for="nav-toggle" {
650 span.hamburger-line {}
651 span.hamburger-line {}
652 span.hamburger-line {}
653 }
654 div.nav-panel {
655 label.nav-close for="nav-toggle" { "×" }
656 ul {
657 @for item in items {
658 (render_nav_item(item, current_path))
659 }
660 @if !nav_pages.is_empty() {
661 li.nav-separator role="separator" {}
662 @for page in &nav_pages {
663 @if page.is_link {
664 li {
665 a href=(page.body.trim()) target="_blank" rel="noopener" {
666 (page.link_title)
667 }
668 }
669 } @else {
670 @let is_current = current_path == page.slug;
671 li class=[is_current.then_some("current")] {
672 a href={ "/" (page.slug) ".html" } { (page.link_title) }
673 }
674 }
675 }
676 }
677 }
678 }
679 }
680}
681
682fn render_nav_item(item: &NavItem, current_path: &str) -> Markup {
684 let is_current =
685 item.path == current_path || current_path.starts_with(&format!("{}/", item.path));
686
687 html! {
688 li class=[is_current.then_some("current")] {
689 @if item.children.is_empty() {
690 a href={ "/" (item.path) "/" } { (item.title) }
691 } @else {
692 a.nav-group href={ "/" (item.path) "/" } { (item.title) }
693 ul {
694 @for child in &item.children {
695 (render_nav_item(child, current_path))
696 }
697 }
698 }
699 }
700 }
701}
702
703fn render_index(
712 manifest: &Manifest,
713 css: &str,
714 font_url: Option<&str>,
715 favicon_href: Option<&str>,
716 snippets: &CustomSnippets,
717) -> Markup {
718 render_gallery_list_page(
719 &manifest.config.site_title,
720 "",
721 &collect_gallery_entries(&manifest.navigation, &manifest.albums),
722 manifest.description.as_deref(),
723 &manifest.navigation,
724 &manifest.pages,
725 css,
726 font_url,
727 &manifest.config.site_title,
728 favicon_href,
729 snippets,
730 )
731}
732
733#[allow(clippy::too_many_arguments)]
735fn render_album_page(
736 album: &Album,
737 navigation: &[NavItem],
738 pages: &[Page],
739 css: &str,
740 font_url: Option<&str>,
741 site_title: &str,
742 favicon_href: Option<&str>,
743 snippets: &CustomSnippets,
744) -> Markup {
745 let nav = render_nav(navigation, &album.path, pages);
746
747 let segments = path_to_breadcrumb_segments(&album.path, navigation);
748 let breadcrumb = html! {
749 a href="/" { (site_title) }
750 @for (seg_title, seg_path) in &segments {
751 " › "
752 a href={ "/" (seg_path) "/" } { (seg_title) }
753 }
754 " › "
755 (album.title)
756 };
757
758 let album_dir_name = album.path.rsplit('/').next().unwrap_or(&album.path);
762 let strip_prefix = |path: &str| -> String {
763 path.strip_prefix(album_dir_name)
764 .and_then(|p| p.strip_prefix('/'))
765 .unwrap_or(path)
766 .to_string()
767 };
768
769 let has_desc = album.description.is_some();
770 let content = html! {
771 (site_header(breadcrumb, nav))
772 main.album-page.has-description[has_desc] {
773 header.album-header {
774 h1 { (album.title) }
775 @if let Some(desc) = &album.description {
776 input.desc-toggle type="checkbox" id="desc-toggle";
777 div.album-description { (PreEscaped(desc)) }
778 label.desc-expand for="desc-toggle" {
779 span.expand-more { "Read more" }
780 span.expand-less { "Show less" }
781 }
782 }
783 }
784 div.thumbnail-grid {
785 @for (idx, image) in album.images.iter().enumerate() {
786 a.thumb-link href=(image_page_url(idx + 1, album.images.len(), image.title.as_deref())) {
787 img src=(strip_prefix(&image.thumbnail)) alt={ "Image " (idx + 1) } loading="lazy";
788 }
789 }
790 }
791 }
792 };
793
794 base_document(
795 &album.title,
796 css,
797 font_url,
798 None,
799 None,
800 favicon_href,
801 snippets,
802 content,
803 )
804}
805
806fn format_image_label(position: usize, total: usize, title: Option<&str>) -> String {
820 let width = index_width(total);
821 match title {
822 Some(t) => format!("{:0>width$}. {}", position, t),
823 None => format!("{:0>width$}", position),
824 }
825}
826
827#[allow(clippy::too_many_arguments)]
829fn render_image_page(
830 album: &Album,
831 image: &Image,
832 prev: Option<&Image>,
833 next: Option<&Image>,
834 navigation: &[NavItem],
835 pages: &[Page],
836 css: &str,
837 font_url: Option<&str>,
838 site_title: &str,
839 favicon_href: Option<&str>,
840 snippets: &CustomSnippets,
841) -> Markup {
842 let nav = render_nav(navigation, &album.path, pages);
843
844 let album_dir_name = album.path.rsplit('/').next().unwrap_or(&album.path);
847 let strip_prefix = |path: &str| -> String {
848 let relative = path
849 .strip_prefix(album_dir_name)
850 .and_then(|p| p.strip_prefix('/'))
851 .unwrap_or(path);
852 format!("../{}", relative)
853 };
854
855 fn sorted_variants(img: &Image) -> Vec<&GeneratedVariant> {
858 let mut v: Vec<_> = img.generated.values().collect();
859 v.sort_by_key(|variant| variant.width);
860 v
861 }
862
863 let avif_srcset_for = |img: &Image| -> String {
865 sorted_variants(img)
866 .iter()
867 .map(|variant| format!("{} {}w", strip_prefix(&variant.avif), variant.width))
868 .collect::<Vec<_>>()
869 .join(", ")
870 };
871
872 let variants = sorted_variants(image);
874
875 let srcset_avif: String = avif_srcset_for(image);
876
877 let default_src = variants
879 .get(variants.len() / 2)
880 .map(|v| strip_prefix(&v.avif))
881 .unwrap_or_default();
882
883 let mid_avif = |img: &Image| -> String {
885 let v = sorted_variants(img);
886 v.get(v.len() / 2)
887 .map(|variant| strip_prefix(&variant.avif))
888 .unwrap_or_default()
889 };
890 let prev_prefetch = prev.map(&mid_avif);
891 let next_prefetch = next.map(&mid_avif);
892
893 let (width, height) = image.dimensions;
895 let aspect_ratio = width as f64 / height as f64;
896
897 let image_idx = album
899 .images
900 .iter()
901 .position(|i| i.number == image.number)
902 .unwrap();
903
904 let total = album.images.len();
905 let prev_url = match prev {
906 Some(p) => format!(
907 "../{}",
908 image_page_url(image_idx, total, p.title.as_deref())
909 ), None => "../".to_string(),
911 };
912
913 let next_url = match next {
914 Some(n) => format!(
915 "../{}",
916 image_page_url(image_idx + 2, total, n.title.as_deref())
917 ),
918 None => "../".to_string(),
919 };
920
921 let display_idx = image_idx + 1;
922 let image_label = format_image_label(display_idx, album.images.len(), image.title.as_deref());
923 let page_title = format!("{} - {}", album.title, image_label);
924
925 let segments = path_to_breadcrumb_segments(&album.path, navigation);
926 let breadcrumb = html! {
927 a href="/" { (site_title) }
928 @for (seg_title, seg_path) in &segments {
929 " › "
930 a href={ "/" (seg_path) "/" } { (seg_title) }
931 }
932 " › "
933 a href="../" { (album.title) }
934 " › "
935 (image_label)
936 };
937
938 let max_generated_width = image
939 .generated
940 .values()
941 .map(|v| v.width)
942 .max()
943 .unwrap_or(800);
944 let sizes_attr = image_sizes_attr(aspect_ratio, max_generated_width);
945
946 let aspect_style = format!("--aspect-ratio: {};", aspect_ratio);
947 let alt_text = match &image.title {
948 Some(t) => format!("{} - {}", album.title, t),
949 None => format!("{} - Image {}", album.title, display_idx),
950 };
951
952 let nav_dots: Vec<String> = album
954 .images
955 .iter()
956 .enumerate()
957 .map(|(idx, img)| {
958 format!(
959 "../{}",
960 image_page_url(idx + 1, total, img.title.as_deref())
961 )
962 })
963 .collect();
964
965 let description = image.description.as_deref().filter(|d| !d.is_empty());
966 let caption_text = description.filter(|d| is_short_caption(d));
967 let description_text = description.filter(|d| !is_short_caption(d));
968
969 let body_class = match description {
970 Some(desc) if is_short_caption(desc) => "image-view has-caption",
971 Some(_) => "image-view has-description",
972 None => "image-view",
973 };
974
975 let head_extra = html! {
977 link rel="expect" href="#main-image" blocking="render";
978 @if let Some(ref href) = prev_prefetch {
979 link rel="prefetch" as="image" href=(href);
980 }
981 @if let Some(ref href) = next_prefetch {
982 link rel="prefetch" as="image" href=(href);
983 }
984 };
985
986 let content = html! {
987 (site_header(breadcrumb, nav))
988 main style=(aspect_style) {
989 div.image-page {
990 figure.image-frame {
991 img #main-image src=(default_src) srcset=(srcset_avif) sizes=(sizes_attr) alt=(alt_text);
992 }
993 p.print-credit {
994 (album.title) " › " (image_label)
995 }
996 @if let Some(text) = caption_text {
997 p.image-caption { (text) }
998 }
999 }
1000 @if let Some(text) = description_text {
1001 div.image-description {
1002 p { (text) }
1003 }
1004 }
1005 nav.image-nav {
1006 @for (idx, url) in nav_dots.iter().enumerate() {
1007 @if idx == image_idx {
1008 a href=(url) aria-current="true" {}
1009 } @else {
1010 a href=(url) {}
1011 }
1012 }
1013 }
1014 a.nav-prev href=(prev_url) aria-label="Previous image" {}
1015 a.nav-next href=(next_url) aria-label="Next image" {}
1016 }
1017 };
1018
1019 base_document(
1020 &page_title,
1021 css,
1022 font_url,
1023 Some(body_class),
1024 Some(head_extra),
1025 favicon_href,
1026 snippets,
1027 content,
1028 )
1029}
1030
1031#[allow(clippy::too_many_arguments)]
1033fn render_page(
1034 page: &Page,
1035 navigation: &[NavItem],
1036 pages: &[Page],
1037 css: &str,
1038 font_url: Option<&str>,
1039 site_title: &str,
1040 favicon_href: Option<&str>,
1041 snippets: &CustomSnippets,
1042) -> Markup {
1043 let nav = render_nav(navigation, &page.slug, pages);
1044
1045 let parser = Parser::new(&page.body);
1047 let mut body_html = String::new();
1048 md_html::push_html(&mut body_html, parser);
1049
1050 let breadcrumb = html! {
1051 a href="/" { (site_title) }
1052 " › "
1053 (page.title)
1054 };
1055
1056 let content = html! {
1057 (site_header(breadcrumb, nav))
1058 main.page {
1059 article.page-content {
1060 (PreEscaped(body_html))
1061 }
1062 }
1063 };
1064
1065 base_document(
1066 &page.title,
1067 css,
1068 font_url,
1069 None,
1070 None,
1071 favicon_href,
1072 snippets,
1073 content,
1074 )
1075}
1076
1077#[allow(clippy::too_many_arguments)]
1082fn render_gallery_list_page(
1083 title: &str,
1084 path: &str,
1085 entries: &[GalleryEntry],
1086 description: Option<&str>,
1087 navigation: &[NavItem],
1088 pages: &[Page],
1089 css: &str,
1090 font_url: Option<&str>,
1091 site_title: &str,
1092 favicon_href: Option<&str>,
1093 snippets: &CustomSnippets,
1094) -> Markup {
1095 let nav = render_nav(navigation, path, pages);
1096
1097 let is_root = path.is_empty();
1098 let segments = path_to_breadcrumb_segments(path, navigation);
1099 let breadcrumb = html! {
1100 a href="/" { (site_title) }
1101 @if !is_root {
1102 @for (seg_title, seg_path) in &segments {
1103 " › "
1104 a href={ "/" (seg_path) "/" } { (seg_title) }
1105 }
1106 " › "
1107 (title)
1108 }
1109 };
1110
1111 let main_class = match description {
1112 Some(_) => "index-page has-description",
1113 None => "index-page",
1114 };
1115 let content = html! {
1116 (site_header(breadcrumb, nav))
1117 main class=(main_class) {
1118 @if let Some(desc) = description {
1119 header.index-header {
1120 h1 { (title) }
1121 input.desc-toggle type="checkbox" id="desc-toggle";
1122 div.album-description { (PreEscaped(desc)) }
1123 label.desc-expand for="desc-toggle" {
1124 span.expand-more { "Read more" }
1125 span.expand-less { "Show less" }
1126 }
1127 }
1128 }
1129 div.album-grid {
1130 @for entry in entries {
1131 a.album-card href={ "/" (entry.path) "/" } {
1132 @if let Some(ref thumb) = entry.thumbnail {
1133 img src={ "/" (thumb) } alt=(entry.title) loading="lazy";
1134 }
1135 span.album-title { (entry.title) }
1136 }
1137 }
1138 }
1139 }
1140 };
1141
1142 base_document(
1143 title,
1144 css,
1145 font_url,
1146 None,
1147 None,
1148 favicon_href,
1149 snippets,
1150 content,
1151 )
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1156fn generate_gallery_list_pages(
1157 items: &[NavItem],
1158 albums: &[Album],
1159 navigation: &[NavItem],
1160 pages: &[Page],
1161 css: &str,
1162 font_url: Option<&str>,
1163 site_title: &str,
1164 favicon_href: Option<&str>,
1165 snippets: &CustomSnippets,
1166 output_dir: &Path,
1167) -> Result<(), GenerateError> {
1168 for item in items {
1169 if !item.children.is_empty() {
1170 let entries = collect_gallery_entries(&item.children, albums);
1171 let page_html = render_gallery_list_page(
1172 &item.title,
1173 &item.path,
1174 &entries,
1175 item.description.as_deref(),
1176 navigation,
1177 pages,
1178 css,
1179 font_url,
1180 site_title,
1181 favicon_href,
1182 snippets,
1183 );
1184 let dir = output_dir.join(&item.path);
1185 fs::create_dir_all(&dir)?;
1186 fs::write(dir.join("index.html"), page_html.into_string())?;
1187
1188 generate_gallery_list_pages(
1190 &item.children,
1191 albums,
1192 navigation,
1193 pages,
1194 css,
1195 font_url,
1196 site_title,
1197 favicon_href,
1198 snippets,
1199 output_dir,
1200 )?;
1201 }
1202 }
1203 Ok(())
1204}
1205
1206#[cfg(test)]
1211mod tests {
1212 use super::*;
1213
1214 fn no_snippets() -> CustomSnippets {
1215 CustomSnippets::default()
1216 }
1217
1218 fn make_page(slug: &str, link_title: &str, in_nav: bool, is_link: bool) -> Page {
1219 Page {
1220 title: link_title.to_string(),
1221 link_title: link_title.to_string(),
1222 slug: slug.to_string(),
1223 body: if is_link {
1224 "https://example.com".to_string()
1225 } else {
1226 format!("# {}\n\nContent.", link_title)
1227 },
1228 in_nav,
1229 sort_key: if in_nav { 40 } else { u32::MAX },
1230 is_link,
1231 }
1232 }
1233
1234 #[test]
1235 fn nav_renders_items() {
1236 let items = vec![NavItem {
1237 title: "Album One".to_string(),
1238 path: "010-one".to_string(),
1239 source_dir: String::new(),
1240 description: None,
1241 children: vec![],
1242 }];
1243 let html = render_nav(&items, "", &[]).into_string();
1244 assert!(html.contains("Album One"));
1245 assert!(html.contains("/010-one/"));
1246 }
1247
1248 #[test]
1249 fn nav_includes_pages() {
1250 let pages = vec![make_page("about", "About", true, false)];
1251 let html = render_nav(&[], "", &pages).into_string();
1252 assert!(html.contains("About"));
1253 assert!(html.contains("/about.html"));
1254 }
1255
1256 #[test]
1257 fn nav_hides_unnumbered_pages() {
1258 let pages = vec![make_page("notes", "Notes", false, false)];
1259 let html = render_nav(&[], "", &pages).into_string();
1260 assert!(!html.contains("Notes"));
1261 assert!(!html.contains("nav-separator"));
1263 }
1264
1265 #[test]
1266 fn nav_renders_link_page_as_external() {
1267 let pages = vec![make_page("github", "GitHub", true, true)];
1268 let html = render_nav(&[], "", &pages).into_string();
1269 assert!(html.contains("GitHub"));
1270 assert!(html.contains("https://example.com"));
1271 assert!(html.contains("target=\"_blank\""));
1272 }
1273
1274 #[test]
1275 fn nav_marks_current_item() {
1276 let items = vec![
1277 NavItem {
1278 title: "First".to_string(),
1279 path: "010-first".to_string(),
1280 source_dir: String::new(),
1281 description: None,
1282 children: vec![],
1283 },
1284 NavItem {
1285 title: "Second".to_string(),
1286 path: "020-second".to_string(),
1287 source_dir: String::new(),
1288 description: None,
1289 children: vec![],
1290 },
1291 ];
1292 let html = render_nav(&items, "020-second", &[]).into_string();
1293 assert!(html.contains(r#"class="current"#));
1295 }
1296
1297 #[test]
1298 fn nav_marks_current_page() {
1299 let pages = vec![make_page("about", "About", true, false)];
1300 let html = render_nav(&[], "about", &pages).into_string();
1301 assert!(html.contains(r#"class="current"#));
1302 }
1303
1304 #[test]
1305 fn nav_renders_nested_children() {
1306 let items = vec![NavItem {
1307 title: "Parent".to_string(),
1308 path: "010-parent".to_string(),
1309 source_dir: String::new(),
1310 description: None,
1311 children: vec![NavItem {
1312 title: "Child".to_string(),
1313 path: "010-parent/010-child".to_string(),
1314 source_dir: String::new(),
1315 description: None,
1316 children: vec![],
1317 }],
1318 }];
1319 let html = render_nav(&items, "", &[]).into_string();
1320 assert!(html.contains("Parent"));
1321 assert!(html.contains("Child"));
1322 assert!(html.contains("nav-group")); }
1324
1325 #[test]
1326 fn nav_separator_only_when_pages() {
1327 let html_no_pages = render_nav(&[], "", &[]).into_string();
1329 assert!(!html_no_pages.contains("nav-separator"));
1330
1331 let pages = vec![make_page("about", "About", true, false)];
1333 let html_with_pages = render_nav(&[], "", &pages).into_string();
1334 assert!(html_with_pages.contains("nav-separator"));
1335 }
1336
1337 #[test]
1338 fn base_document_includes_doctype() {
1339 let content = html! { p { "test" } };
1340 let doc = base_document(
1341 "Test",
1342 "body {}",
1343 None,
1344 None,
1345 None,
1346 None,
1347 &no_snippets(),
1348 content,
1349 )
1350 .into_string();
1351 assert!(doc.starts_with("<!DOCTYPE html>"));
1352 }
1353
1354 #[test]
1355 fn base_document_applies_body_class() {
1356 let content = html! { p { "test" } };
1357 let doc = base_document(
1358 "Test",
1359 "",
1360 None,
1361 Some("image-view"),
1362 None,
1363 None,
1364 &no_snippets(),
1365 content,
1366 )
1367 .into_string();
1368 assert!(html_contains_body_class(&doc, "image-view"));
1369 }
1370
1371 #[test]
1372 fn site_header_structure() {
1373 let breadcrumb = html! { a href="/" { "Home" } };
1374 let nav = html! { ul { li { "Item" } } };
1375 let header = site_header(breadcrumb, nav).into_string();
1376
1377 assert!(header.contains("site-header"));
1378 assert!(header.contains("breadcrumb"));
1379 assert!(header.contains("site-nav"));
1380 assert!(header.contains("Home"));
1381 }
1382
1383 fn html_contains_body_class(html: &str, class: &str) -> bool {
1385 html.contains(&format!(r#"class="{}""#, class))
1387 }
1388
1389 fn create_test_album() -> Album {
1394 Album {
1395 path: "test".to_string(),
1396 title: "Test Album".to_string(),
1397 description: Some("<p>A test album description</p>".to_string()),
1398 thumbnail: "test/001-image-thumb.avif".to_string(),
1399 images: vec![
1400 Image {
1401 number: 1,
1402 source_path: "test/001-dawn.jpg".to_string(),
1403 title: Some("Dawn".to_string()),
1404 description: None,
1405 dimensions: (1600, 1200),
1406 generated: {
1407 let mut map = BTreeMap::new();
1408 map.insert(
1409 "800".to_string(),
1410 GeneratedVariant {
1411 avif: "test/001-dawn-800.avif".to_string(),
1412 width: 800,
1413 height: 600,
1414 },
1415 );
1416 map.insert(
1417 "1400".to_string(),
1418 GeneratedVariant {
1419 avif: "test/001-dawn-1400.avif".to_string(),
1420 width: 1400,
1421 height: 1050,
1422 },
1423 );
1424 map
1425 },
1426 thumbnail: "test/001-dawn-thumb.avif".to_string(),
1427 },
1428 Image {
1429 number: 2,
1430 source_path: "test/002-night.jpg".to_string(),
1431 title: None,
1432 description: None,
1433 dimensions: (1200, 1600),
1434 generated: {
1435 let mut map = BTreeMap::new();
1436 map.insert(
1437 "800".to_string(),
1438 GeneratedVariant {
1439 avif: "test/002-night-800.avif".to_string(),
1440 width: 600,
1441 height: 800,
1442 },
1443 );
1444 map
1445 },
1446 thumbnail: "test/002-night-thumb.avif".to_string(),
1447 },
1448 ],
1449 in_nav: true,
1450 config: SiteConfig::default(),
1451 support_files: vec![],
1452 }
1453 }
1454
1455 fn create_nested_test_album() -> Album {
1461 Album {
1462 path: "NY/Night".to_string(),
1463 title: "Night".to_string(),
1464 description: None,
1465 thumbnail: "Night/001-image-thumb.avif".to_string(),
1466 images: vec![Image {
1467 number: 1,
1468 source_path: "NY/Night/001-city.jpg".to_string(),
1469 title: Some("City".to_string()),
1470 description: None,
1471 dimensions: (1600, 1200),
1472 generated: {
1473 let mut map = BTreeMap::new();
1474 map.insert(
1475 "800".to_string(),
1476 GeneratedVariant {
1477 avif: "Night/001-city-800.avif".to_string(),
1478 width: 800,
1479 height: 600,
1480 },
1481 );
1482 map.insert(
1483 "1400".to_string(),
1484 GeneratedVariant {
1485 avif: "Night/001-city-1400.avif".to_string(),
1486 width: 1400,
1487 height: 1050,
1488 },
1489 );
1490 map
1491 },
1492 thumbnail: "Night/001-city-thumb.avif".to_string(),
1493 }],
1494 in_nav: true,
1495 config: SiteConfig::default(),
1496 support_files: vec![],
1497 }
1498 }
1499
1500 #[test]
1501 fn nested_album_thumbnail_paths_are_relative_to_album_dir() {
1502 let album = create_nested_test_album();
1503 let html = render_album_page(&album, &[], &[], "", None, "Gallery", None, &no_snippets())
1504 .into_string();
1505
1506 assert!(html.contains(r#"src="001-city-thumb.avif""#));
1510 assert!(!html.contains("Night/001-city-thumb.avif"));
1511 }
1512
1513 #[test]
1514 fn nested_album_image_page_srcset_paths_are_relative() {
1515 let album = create_nested_test_album();
1516 let image = &album.images[0];
1517 let html = render_image_page(
1518 &album,
1519 image,
1520 None,
1521 None,
1522 &[],
1523 &[],
1524 "",
1525 None,
1526 "Gallery",
1527 None,
1528 &no_snippets(),
1529 )
1530 .into_string();
1531
1532 assert!(html.contains("../001-city-800.avif"));
1535 assert!(html.contains("../001-city-1400.avif"));
1536 assert!(!html.contains("Night/001-city-800.avif"));
1537 }
1538
1539 #[test]
1540 fn render_album_page_includes_title() {
1541 let album = create_test_album();
1542 let nav = vec![];
1543 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1544 .into_string();
1545
1546 assert!(html.contains("Test Album"));
1547 assert!(html.contains("<h1>"));
1548 }
1549
1550 #[test]
1551 fn render_album_page_includes_description() {
1552 let album = create_test_album();
1553 let nav = vec![];
1554 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1555 .into_string();
1556
1557 assert!(html.contains("A test album description"));
1558 assert!(html.contains("album-description"));
1559 }
1560
1561 #[test]
1562 fn render_album_page_thumbnail_links() {
1563 let album = create_test_album();
1564 let nav = vec![];
1565 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1566 .into_string();
1567
1568 assert!(html.contains("1-dawn/"));
1570 assert!(html.contains("2/"));
1571 assert!(html.contains("001-dawn-thumb.avif"));
1573 }
1574
1575 #[test]
1576 fn render_album_page_breadcrumb() {
1577 let album = create_test_album();
1578 let nav = vec![];
1579 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1580 .into_string();
1581
1582 assert!(html.contains(r#"href="/""#));
1584 assert!(html.contains("Gallery"));
1585 }
1586
1587 #[test]
1588 fn render_image_page_includes_img_with_srcset() {
1589 let album = create_test_album();
1590 let image = &album.images[0];
1591 let nav = vec![];
1592 let html = render_image_page(
1593 &album,
1594 image,
1595 None,
1596 Some(&album.images[1]),
1597 &nav,
1598 &[],
1599 "",
1600 None,
1601 "Gallery",
1602 None,
1603 &no_snippets(),
1604 )
1605 .into_string();
1606
1607 assert!(html.contains("<img"));
1608 assert!(html.contains("srcset="));
1609 assert!(html.contains(".avif"));
1610 assert!(!html.contains("<picture>"));
1611 }
1612
1613 #[test]
1614 fn render_image_page_srcset() {
1615 let album = create_test_album();
1616 let image = &album.images[0];
1617 let nav = vec![];
1618 let html = render_image_page(
1619 &album,
1620 image,
1621 None,
1622 Some(&album.images[1]),
1623 &nav,
1624 &[],
1625 "",
1626 None,
1627 "Gallery",
1628 None,
1629 &no_snippets(),
1630 )
1631 .into_string();
1632
1633 assert!(html.contains("srcset="));
1635 assert!(html.contains("800w"));
1636 assert!(html.contains("1400w"));
1637 }
1638
1639 #[test]
1640 fn render_image_page_nav_links() {
1641 let album = create_test_album();
1642 let image = &album.images[0];
1643 let nav = vec![];
1644 let html = render_image_page(
1645 &album,
1646 image,
1647 None,
1648 Some(&album.images[1]),
1649 &nav,
1650 &[],
1651 "",
1652 None,
1653 "Gallery",
1654 None,
1655 &no_snippets(),
1656 )
1657 .into_string();
1658
1659 assert!(html.contains("nav-prev"));
1660 assert!(html.contains("nav-next"));
1661 assert!(html.contains(r#"aria-label="Previous image""#));
1662 assert!(html.contains(r#"aria-label="Next image""#));
1663 }
1664
1665 #[test]
1666 fn render_image_page_prev_next_urls() {
1667 let album = create_test_album();
1668 let nav = vec![];
1669
1670 let html1 = render_image_page(
1672 &album,
1673 &album.images[0],
1674 None,
1675 Some(&album.images[1]),
1676 &nav,
1677 &[],
1678 "",
1679 None,
1680 "Gallery",
1681 None,
1682 &no_snippets(),
1683 )
1684 .into_string();
1685 assert!(html1.contains(r#"class="nav-prev" href="../""#));
1686 assert!(html1.contains(r#"class="nav-next" href="../2/""#));
1687
1688 let html2 = render_image_page(
1690 &album,
1691 &album.images[1],
1692 Some(&album.images[0]),
1693 None,
1694 &nav,
1695 &[],
1696 "",
1697 None,
1698 "Gallery",
1699 None,
1700 &no_snippets(),
1701 )
1702 .into_string();
1703 assert!(html2.contains(r#"class="nav-prev" href="../1-dawn/""#));
1704 assert!(html2.contains(r#"class="nav-next" href="../""#));
1705 }
1706
1707 #[test]
1708 fn render_image_page_aspect_ratio() {
1709 let album = create_test_album();
1710 let image = &album.images[0]; let nav = vec![];
1712 let html = render_image_page(
1713 &album,
1714 image,
1715 None,
1716 None,
1717 &nav,
1718 &[],
1719 "",
1720 None,
1721 "Gallery",
1722 None,
1723 &no_snippets(),
1724 )
1725 .into_string();
1726
1727 assert!(html.contains("--aspect-ratio:"));
1729 }
1730
1731 #[test]
1732 fn render_page_converts_markdown() {
1733 let page = Page {
1734 title: "About Me".to_string(),
1735 link_title: "about".to_string(),
1736 slug: "about".to_string(),
1737 body: "# About Me\n\nThis is **bold** and *italic*.".to_string(),
1738 in_nav: true,
1739 sort_key: 40,
1740 is_link: false,
1741 };
1742 let html =
1743 render_page(&page, &[], &[], "", None, "Gallery", None, &no_snippets()).into_string();
1744
1745 assert!(html.contains("<strong>bold</strong>"));
1747 assert!(html.contains("<em>italic</em>"));
1748 }
1749
1750 #[test]
1751 fn render_page_includes_title() {
1752 let page = Page {
1753 title: "About Me".to_string(),
1754 link_title: "about me".to_string(),
1755 slug: "about".to_string(),
1756 body: "Content here".to_string(),
1757 in_nav: true,
1758 sort_key: 40,
1759 is_link: false,
1760 };
1761 let html =
1762 render_page(&page, &[], &[], "", None, "Gallery", None, &no_snippets()).into_string();
1763
1764 assert!(html.contains("<title>About Me</title>"));
1765 assert!(html.contains("class=\"page\""));
1766 }
1767
1768 #[test]
1773 fn format_label_with_title() {
1774 assert_eq!(format_image_label(1, 5, Some("Museum")), "1. Museum");
1775 }
1776
1777 #[test]
1778 fn format_label_without_title() {
1779 assert_eq!(format_image_label(1, 5, None), "1");
1780 }
1781
1782 #[test]
1783 fn format_label_zero_pads_for_10_plus() {
1784 assert_eq!(format_image_label(3, 15, Some("Dawn")), "03. Dawn");
1785 assert_eq!(format_image_label(3, 15, None), "03");
1786 }
1787
1788 #[test]
1789 fn format_label_zero_pads_for_100_plus() {
1790 assert_eq!(format_image_label(7, 120, Some("X")), "007. X");
1791 assert_eq!(format_image_label(7, 120, None), "007");
1792 }
1793
1794 #[test]
1795 fn format_label_no_padding_under_10() {
1796 assert_eq!(format_image_label(3, 9, Some("Y")), "3. Y");
1797 }
1798
1799 #[test]
1800 fn image_breadcrumb_includes_title() {
1801 let album = create_test_album();
1802 let image = &album.images[0]; let nav = vec![];
1804 let html = render_image_page(
1805 &album,
1806 image,
1807 None,
1808 Some(&album.images[1]),
1809 &nav,
1810 &[],
1811 "",
1812 None,
1813 "Gallery",
1814 None,
1815 &no_snippets(),
1816 )
1817 .into_string();
1818
1819 assert!(html.contains("1. Dawn"));
1821 assert!(html.contains("Test Album"));
1822 }
1823
1824 #[test]
1825 fn image_breadcrumb_without_title() {
1826 let album = create_test_album();
1827 let image = &album.images[1]; let nav = vec![];
1829 let html = render_image_page(
1830 &album,
1831 image,
1832 Some(&album.images[0]),
1833 None,
1834 &nav,
1835 &[],
1836 "",
1837 None,
1838 "Gallery",
1839 None,
1840 &no_snippets(),
1841 )
1842 .into_string();
1843
1844 assert!(html.contains("Test Album"));
1846 assert!(html.contains(" › 2<"));
1848 }
1849
1850 #[test]
1851 fn image_page_title_includes_label() {
1852 let album = create_test_album();
1853 let image = &album.images[0];
1854 let nav = vec![];
1855 let html = render_image_page(
1856 &album,
1857 image,
1858 None,
1859 Some(&album.images[1]),
1860 &nav,
1861 &[],
1862 "",
1863 None,
1864 "Gallery",
1865 None,
1866 &no_snippets(),
1867 )
1868 .into_string();
1869
1870 assert!(html.contains("<title>Test Album - 1. Dawn</title>"));
1871 }
1872
1873 #[test]
1874 fn image_alt_text_uses_title() {
1875 let album = create_test_album();
1876 let image = &album.images[0]; let nav = vec![];
1878 let html = render_image_page(
1879 &album,
1880 image,
1881 None,
1882 Some(&album.images[1]),
1883 &nav,
1884 &[],
1885 "",
1886 None,
1887 "Gallery",
1888 None,
1889 &no_snippets(),
1890 )
1891 .into_string();
1892
1893 assert!(html.contains("Test Album - Dawn"));
1894 }
1895
1896 #[test]
1901 fn is_short_caption_short_text() {
1902 assert!(is_short_caption("A beautiful sunset"));
1903 }
1904
1905 #[test]
1906 fn is_short_caption_exactly_at_limit() {
1907 let text = "a".repeat(SHORT_CAPTION_MAX_LEN);
1908 assert!(is_short_caption(&text));
1909 }
1910
1911 #[test]
1912 fn is_short_caption_over_limit() {
1913 let text = "a".repeat(SHORT_CAPTION_MAX_LEN + 1);
1914 assert!(!is_short_caption(&text));
1915 }
1916
1917 #[test]
1918 fn is_short_caption_with_newline() {
1919 assert!(!is_short_caption("Line one\nLine two"));
1920 }
1921
1922 #[test]
1923 fn is_short_caption_empty_string() {
1924 assert!(is_short_caption(""));
1925 }
1926
1927 #[test]
1928 fn render_image_page_short_caption() {
1929 let mut album = create_test_album();
1930 album.images[0].description = Some("A beautiful sunrise over the mountains".to_string());
1931 let image = &album.images[0];
1932 let html = render_image_page(
1933 &album,
1934 image,
1935 None,
1936 Some(&album.images[1]),
1937 &[],
1938 &[],
1939 "",
1940 None,
1941 "Gallery",
1942 None,
1943 &no_snippets(),
1944 )
1945 .into_string();
1946
1947 assert!(html.contains("image-caption"));
1948 assert!(html.contains("A beautiful sunrise over the mountains"));
1949 assert!(html_contains_body_class(&html, "image-view has-caption"));
1950 }
1951
1952 #[test]
1953 fn render_image_page_long_description() {
1954 let mut album = create_test_album();
1955 let long_text = "a".repeat(200);
1956 album.images[0].description = Some(long_text.clone());
1957 let image = &album.images[0];
1958 let html = render_image_page(
1959 &album,
1960 image,
1961 None,
1962 Some(&album.images[1]),
1963 &[],
1964 &[],
1965 "",
1966 None,
1967 "Gallery",
1968 None,
1969 &no_snippets(),
1970 )
1971 .into_string();
1972
1973 assert!(html.contains("image-description"));
1974 assert!(!html.contains("image-caption"));
1975 assert!(html_contains_body_class(
1976 &html,
1977 "image-view has-description"
1978 ));
1979 }
1980
1981 #[test]
1982 fn render_image_page_multiline_is_long_description() {
1983 let mut album = create_test_album();
1984 album.images[0].description = Some("Line one\nLine two".to_string());
1985 let image = &album.images[0];
1986 let html = render_image_page(
1987 &album,
1988 image,
1989 None,
1990 Some(&album.images[1]),
1991 &[],
1992 &[],
1993 "",
1994 None,
1995 "Gallery",
1996 None,
1997 &no_snippets(),
1998 )
1999 .into_string();
2000
2001 assert!(html.contains("image-description"));
2002 assert!(!html.contains("image-caption"));
2003 assert!(html_contains_body_class(
2004 &html,
2005 "image-view has-description"
2006 ));
2007 }
2008
2009 #[test]
2010 fn render_image_page_no_description_no_caption() {
2011 let album = create_test_album();
2012 let image = &album.images[1]; let html = render_image_page(
2014 &album,
2015 image,
2016 Some(&album.images[0]),
2017 None,
2018 &[],
2019 &[],
2020 "",
2021 None,
2022 "Gallery",
2023 None,
2024 &no_snippets(),
2025 )
2026 .into_string();
2027
2028 assert!(!html.contains("image-caption"));
2029 assert!(!html.contains("image-description"));
2030 assert!(html_contains_body_class(&html, "image-view"));
2031 }
2032
2033 #[test]
2034 fn render_image_page_caption_width_matches_frame() {
2035 let mut album = create_test_album();
2036 album.images[0].description = Some("Short caption".to_string());
2037 let image = &album.images[0];
2038 let html = render_image_page(
2039 &album,
2040 image,
2041 None,
2042 Some(&album.images[1]),
2043 &[],
2044 &[],
2045 "",
2046 None,
2047 "Gallery",
2048 None,
2049 &no_snippets(),
2050 )
2051 .into_string();
2052
2053 assert!(html.contains("image-frame"));
2055 assert!(html.contains("image-caption"));
2056 assert!(html.contains("image-page"));
2058 }
2059
2060 #[test]
2061 fn html_escape_in_maud() {
2062 let items = vec![NavItem {
2064 title: "<script>alert('xss')</script>".to_string(),
2065 path: "test".to_string(),
2066 source_dir: String::new(),
2067 description: None,
2068 children: vec![],
2069 }];
2070 let html = render_nav(&items, "", &[]).into_string();
2071
2072 assert!(!html.contains("<script>alert"));
2074 assert!(html.contains("<script>"));
2075 }
2076
2077 #[test]
2082 fn escape_for_url_spaces_become_dashes() {
2083 assert_eq!(escape_for_url("My Title"), "my-title");
2084 }
2085
2086 #[test]
2087 fn escape_for_url_dots_become_dashes() {
2088 assert_eq!(escape_for_url("St. Louis"), "st-louis");
2089 }
2090
2091 #[test]
2092 fn escape_for_url_collapses_consecutive() {
2093 assert_eq!(escape_for_url("A. B"), "a-b");
2094 }
2095
2096 #[test]
2097 fn escape_for_url_strips_leading_trailing() {
2098 assert_eq!(escape_for_url(". Title ."), "title");
2099 }
2100
2101 #[test]
2102 fn escape_for_url_preserves_dashes() {
2103 assert_eq!(escape_for_url("My-Title"), "my-title");
2104 }
2105
2106 #[test]
2107 fn escape_for_url_underscores_become_dashes() {
2108 assert_eq!(escape_for_url("My_Title"), "my-title");
2109 }
2110
2111 #[test]
2112 fn image_page_url_with_title() {
2113 assert_eq!(image_page_url(3, 15, Some("Dawn")), "03-dawn/");
2114 }
2115
2116 #[test]
2117 fn image_page_url_without_title() {
2118 assert_eq!(image_page_url(3, 15, None), "03/");
2119 }
2120
2121 #[test]
2122 fn image_page_url_title_with_spaces() {
2123 assert_eq!(image_page_url(1, 5, Some("My Museum")), "1-my-museum/");
2124 }
2125
2126 #[test]
2127 fn image_page_url_title_with_dot() {
2128 assert_eq!(image_page_url(1, 5, Some("St. Louis")), "1-st-louis/");
2129 }
2130
2131 #[test]
2136 fn render_image_page_has_main_image_id() {
2137 let album = create_test_album();
2138 let image = &album.images[0];
2139 let html = render_image_page(
2140 &album,
2141 image,
2142 None,
2143 Some(&album.images[1]),
2144 &[],
2145 &[],
2146 "",
2147 None,
2148 "Gallery",
2149 None,
2150 &no_snippets(),
2151 )
2152 .into_string();
2153
2154 assert!(html.contains(r#"id="main-image""#));
2155 }
2156
2157 #[test]
2158 fn render_image_page_has_render_blocking_link() {
2159 let album = create_test_album();
2160 let image = &album.images[0];
2161 let html = render_image_page(
2162 &album,
2163 image,
2164 None,
2165 Some(&album.images[1]),
2166 &[],
2167 &[],
2168 "",
2169 None,
2170 "Gallery",
2171 None,
2172 &no_snippets(),
2173 )
2174 .into_string();
2175
2176 assert!(html.contains(r#"rel="expect""#));
2177 assert!(html.contains(r##"href="#main-image""##));
2178 assert!(html.contains(r#"blocking="render""#));
2179 }
2180
2181 #[test]
2182 fn render_image_page_prefetches_next_image() {
2183 let album = create_test_album();
2184 let image = &album.images[0];
2185 let html = render_image_page(
2186 &album,
2187 image,
2188 None,
2189 Some(&album.images[1]),
2190 &[],
2191 &[],
2192 "",
2193 None,
2194 "Gallery",
2195 None,
2196 &no_snippets(),
2197 )
2198 .into_string();
2199
2200 assert!(html.contains(r#"rel="prefetch""#));
2202 assert!(html.contains(r#"as="image""#));
2203 assert!(html.contains("002-night-800.avif"));
2204 }
2205
2206 #[test]
2207 fn render_image_page_prefetches_prev_image() {
2208 let album = create_test_album();
2209 let image = &album.images[1];
2210 let html = render_image_page(
2211 &album,
2212 image,
2213 Some(&album.images[0]),
2214 None,
2215 &[],
2216 &[],
2217 "",
2218 None,
2219 "Gallery",
2220 None,
2221 &no_snippets(),
2222 )
2223 .into_string();
2224
2225 assert!(html.contains(r#"rel="prefetch""#));
2228 assert!(html.contains("001-dawn-1400.avif"));
2229 assert!(!html.contains("001-dawn-800.avif"));
2231 }
2232
2233 #[test]
2234 fn render_image_page_no_prefetch_without_adjacent() {
2235 let album = create_test_album();
2236 let image = &album.images[0];
2237 let html = render_image_page(
2239 &album,
2240 image,
2241 None,
2242 None,
2243 &[],
2244 &[],
2245 "",
2246 None,
2247 "Gallery",
2248 None,
2249 &no_snippets(),
2250 )
2251 .into_string();
2252
2253 assert!(html.contains(r#"rel="expect""#));
2255 assert!(!html.contains(r#"rel="prefetch""#));
2257 }
2258
2259 #[test]
2264 fn rendered_html_contains_color_css_variables() {
2265 let mut config = SiteConfig::default();
2266 config.colors.light.background = "#fafafa".to_string();
2267 config.colors.dark.background = "#111111".to_string();
2268
2269 let color_css = crate::config::generate_color_css(&config.colors);
2270 let theme_css = crate::config::generate_theme_css(&config.theme);
2271 let font_css = crate::config::generate_font_css(&config.font);
2272 let css = format!("{}\n{}\n{}", color_css, theme_css, font_css);
2273
2274 let album = create_test_album();
2275 let html = render_album_page(
2276 &album,
2277 &[],
2278 &[],
2279 &css,
2280 None,
2281 "Gallery",
2282 None,
2283 &no_snippets(),
2284 )
2285 .into_string();
2286
2287 assert!(html.contains("--color-bg: #fafafa"));
2288 assert!(html.contains("--color-bg: #111111"));
2289 assert!(html.contains("--color-text:"));
2290 assert!(html.contains("--color-text-muted:"));
2291 assert!(html.contains("--color-border:"));
2292 assert!(html.contains("--color-link:"));
2293 assert!(html.contains("--color-link-hover:"));
2294 }
2295
2296 #[test]
2297 fn rendered_html_contains_theme_css_variables() {
2298 let mut config = SiteConfig::default();
2299 config.theme.thumbnail_gap = "0.5rem".to_string();
2300 config.theme.mat_x.size = "5vw".to_string();
2301
2302 let theme_css = crate::config::generate_theme_css(&config.theme);
2303 let album = create_test_album();
2304 let html = render_album_page(
2305 &album,
2306 &[],
2307 &[],
2308 &theme_css,
2309 None,
2310 "Gallery",
2311 None,
2312 &no_snippets(),
2313 )
2314 .into_string();
2315
2316 assert!(html.contains("--thumbnail-gap: 0.5rem"));
2317 assert!(html.contains("--mat-x: clamp(1rem, 5vw, 2.5rem)"));
2318 assert!(html.contains("--mat-y:"));
2319 assert!(html.contains("--grid-padding:"));
2320 }
2321
2322 #[test]
2323 fn rendered_html_contains_font_css_variables() {
2324 let mut config = SiteConfig::default();
2325 config.font.font = "Lora".to_string();
2326 config.font.weight = "300".to_string();
2327 config.font.font_type = crate::config::FontType::Serif;
2328
2329 let font_css = crate::config::generate_font_css(&config.font);
2330 let font_url = config.font.stylesheet_url();
2331
2332 let album = create_test_album();
2333 let html = render_album_page(
2334 &album,
2335 &[],
2336 &[],
2337 &font_css,
2338 font_url.as_deref(),
2339 "Gallery",
2340 None,
2341 &no_snippets(),
2342 )
2343 .into_string();
2344
2345 assert!(html.contains("--font-family:"));
2346 assert!(html.contains("--font-weight: 300"));
2347 assert!(html.contains("fonts.googleapis.com"));
2348 assert!(html.contains("Lora"));
2349 }
2350
2351 #[test]
2356 fn index_page_excludes_non_nav_albums() {
2357 let manifest = Manifest {
2358 navigation: vec![NavItem {
2359 title: "Visible".to_string(),
2360 path: "visible".to_string(),
2361 source_dir: String::new(),
2362 description: None,
2363 children: vec![],
2364 }],
2365 albums: vec![
2366 Album {
2367 path: "visible".to_string(),
2368 title: "Visible".to_string(),
2369 description: None,
2370 thumbnail: "visible/thumb.avif".to_string(),
2371 images: vec![],
2372 in_nav: true,
2373 config: SiteConfig::default(),
2374 support_files: vec![],
2375 },
2376 Album {
2377 path: "hidden".to_string(),
2378 title: "Hidden".to_string(),
2379 description: None,
2380 thumbnail: "hidden/thumb.avif".to_string(),
2381 images: vec![],
2382 in_nav: false,
2383 config: SiteConfig::default(),
2384 support_files: vec![],
2385 },
2386 ],
2387 pages: vec![],
2388 description: None,
2389 config: SiteConfig::default(),
2390 };
2391
2392 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2393
2394 assert!(html.contains("Visible"));
2395 assert!(!html.contains("Hidden"));
2396 }
2397
2398 #[test]
2399 fn index_page_with_no_albums() {
2400 let manifest = Manifest {
2401 navigation: vec![],
2402 albums: vec![],
2403 pages: vec![],
2404 description: None,
2405 config: SiteConfig::default(),
2406 };
2407
2408 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2409
2410 assert!(html.contains("album-grid"));
2411 assert!(html.contains("Gallery"));
2412 }
2413
2414 #[test]
2415 fn index_page_with_description() {
2416 let manifest = Manifest {
2417 navigation: vec![],
2418 albums: vec![],
2419 pages: vec![],
2420 description: Some("<p>Welcome to the gallery.</p>".to_string()),
2421 config: SiteConfig::default(),
2422 };
2423
2424 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2425
2426 assert!(html.contains("has-description"));
2427 assert!(html.contains("index-header"));
2428 assert!(html.contains("album-description"));
2429 assert!(html.contains("Welcome to the gallery."));
2430 assert!(html.contains("desc-toggle"));
2431 assert!(html.contains("Read more"));
2432 assert!(html.contains("Show less"));
2433 assert!(html.contains("<h1>Gallery</h1>"));
2435 }
2436
2437 #[test]
2438 fn index_page_no_description_no_header() {
2439 let manifest = Manifest {
2440 navigation: vec![],
2441 albums: vec![],
2442 pages: vec![],
2443 description: None,
2444 config: SiteConfig::default(),
2445 };
2446
2447 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2448
2449 assert!(!html.contains("has-description"));
2450 assert!(!html.contains("index-header"));
2451 assert!(!html.contains("album-description"));
2452 }
2453
2454 #[test]
2459 fn single_image_album_no_prev_next() {
2460 let album = Album {
2461 path: "solo".to_string(),
2462 title: "Solo Album".to_string(),
2463 description: None,
2464 thumbnail: "solo/001-thumb.avif".to_string(),
2465 images: vec![Image {
2466 number: 1,
2467 source_path: "solo/001-photo.jpg".to_string(),
2468 title: Some("Photo".to_string()),
2469 description: None,
2470 dimensions: (1600, 1200),
2471 generated: {
2472 let mut map = BTreeMap::new();
2473 map.insert(
2474 "800".to_string(),
2475 GeneratedVariant {
2476 avif: "solo/001-photo-800.avif".to_string(),
2477 width: 800,
2478 height: 600,
2479 },
2480 );
2481 map
2482 },
2483 thumbnail: "solo/001-photo-thumb.avif".to_string(),
2484 }],
2485 in_nav: true,
2486 config: SiteConfig::default(),
2487 support_files: vec![],
2488 };
2489
2490 let image = &album.images[0];
2491 let html = render_image_page(
2492 &album,
2493 image,
2494 None,
2495 None,
2496 &[],
2497 &[],
2498 "",
2499 None,
2500 "Gallery",
2501 None,
2502 &no_snippets(),
2503 )
2504 .into_string();
2505
2506 assert!(html.contains(r#"class="nav-prev" href="../""#));
2508 assert!(html.contains(r#"class="nav-next" href="../""#));
2509 }
2510
2511 #[test]
2512 fn album_page_no_description() {
2513 let mut album = create_test_album();
2514 album.description = None;
2515 let html = render_album_page(&album, &[], &[], "", None, "Gallery", None, &no_snippets())
2516 .into_string();
2517
2518 assert!(!html.contains("album-description"));
2519 assert!(html.contains("Test Album"));
2520 }
2521
2522 #[test]
2523 fn render_image_page_nav_dots() {
2524 let album = create_test_album();
2525 let image = &album.images[0];
2526 let html = render_image_page(
2527 &album,
2528 image,
2529 None,
2530 Some(&album.images[1]),
2531 &[],
2532 &[],
2533 "",
2534 None,
2535 "Gallery",
2536 None,
2537 &no_snippets(),
2538 )
2539 .into_string();
2540
2541 assert!(html.contains("image-nav"));
2543 assert!(html.contains(r#"aria-current="true""#));
2545 assert!(html.contains(r#"href="../1-dawn/""#));
2547 assert!(html.contains(r#"href="../2/""#));
2548 }
2549
2550 #[test]
2551 fn render_image_page_nav_dots_marks_correct_current() {
2552 let album = create_test_album();
2553 let html = render_image_page(
2555 &album,
2556 &album.images[1],
2557 Some(&album.images[0]),
2558 None,
2559 &[],
2560 &[],
2561 "",
2562 None,
2563 "Gallery",
2564 None,
2565 &no_snippets(),
2566 )
2567 .into_string();
2568
2569 assert!(html.contains(r#"<a href="../2/" aria-current="true">"#));
2572 assert!(html.contains(r#"<a href="../1-dawn/">"#));
2573 assert!(!html.contains(r#"<a href="../1-dawn/" aria-current"#));
2575 }
2576
2577 #[test]
2582 fn index_page_uses_custom_site_title() {
2583 let mut config = SiteConfig::default();
2584 config.site_title = "My Portfolio".to_string();
2585 let manifest = Manifest {
2586 navigation: vec![],
2587 albums: vec![],
2588 pages: vec![],
2589 description: None,
2590 config,
2591 };
2592
2593 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2594
2595 assert!(html.contains("My Portfolio"));
2596 assert!(!html.contains("Gallery"));
2597 assert!(html.contains("<title>My Portfolio</title>"));
2598 }
2599
2600 #[test]
2601 fn album_page_breadcrumb_uses_custom_site_title() {
2602 let album = create_test_album();
2603 let html = render_album_page(
2604 &album,
2605 &[],
2606 &[],
2607 "",
2608 None,
2609 "My Portfolio",
2610 None,
2611 &no_snippets(),
2612 )
2613 .into_string();
2614
2615 assert!(html.contains("My Portfolio"));
2616 assert!(!html.contains("Gallery"));
2617 }
2618
2619 #[test]
2620 fn image_page_breadcrumb_uses_custom_site_title() {
2621 let album = create_test_album();
2622 let image = &album.images[0];
2623 let html = render_image_page(
2624 &album,
2625 image,
2626 None,
2627 Some(&album.images[1]),
2628 &[],
2629 &[],
2630 "",
2631 None,
2632 "My Portfolio",
2633 None,
2634 &no_snippets(),
2635 )
2636 .into_string();
2637
2638 assert!(html.contains("My Portfolio"));
2639 assert!(!html.contains("Gallery"));
2640 }
2641
2642 #[test]
2643 fn content_page_breadcrumb_uses_custom_site_title() {
2644 let page = Page {
2645 title: "About".to_string(),
2646 link_title: "About".to_string(),
2647 slug: "about".to_string(),
2648 body: "# About\n\nContent.".to_string(),
2649 in_nav: true,
2650 sort_key: 40,
2651 is_link: false,
2652 };
2653 let html = render_page(
2654 &page,
2655 &[],
2656 &[],
2657 "",
2658 None,
2659 "My Portfolio",
2660 None,
2661 &no_snippets(),
2662 )
2663 .into_string();
2664
2665 assert!(html.contains("My Portfolio"));
2666 assert!(!html.contains("Gallery"));
2667 }
2668
2669 #[test]
2670 fn pwa_assets_present() {
2671 let manifest = Manifest {
2672 navigation: vec![],
2673 albums: vec![],
2674 pages: vec![],
2675 description: None,
2676 config: SiteConfig::default(),
2677 };
2678
2679 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2680
2681 assert!(html.contains(r#"<link rel="manifest" href="/site.webmanifest">"#));
2682 assert!(html.contains(r#"<link rel="apple-touch-icon" href="/apple-touch-icon.png">"#));
2683 assert!(html.contains("navigator.serviceWorker.register('/sw.js');"));
2684 assert!(html.contains("beforeinstallprompt"));
2685 }
2686
2687 #[test]
2692 fn no_custom_css_link_by_default() {
2693 let content = html! { p { "test" } };
2694 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
2695 .into_string();
2696 assert!(!doc.contains("custom.css"));
2697 }
2698
2699 #[test]
2700 fn custom_css_link_injected_when_present() {
2701 let snippets = CustomSnippets {
2702 has_custom_css: true,
2703 ..Default::default()
2704 };
2705 let content = html! { p { "test" } };
2706 let doc =
2707 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2708 assert!(doc.contains(r#"<link rel="stylesheet" href="/custom.css">"#));
2709 }
2710
2711 #[test]
2712 fn custom_css_link_after_main_style() {
2713 let snippets = CustomSnippets {
2714 has_custom_css: true,
2715 ..Default::default()
2716 };
2717 let content = html! { p { "test" } };
2718 let doc = base_document("Test", "body{}", None, None, None, None, &snippets, content)
2719 .into_string();
2720 let style_pos = doc.find("</style>").unwrap();
2721 let link_pos = doc.find(r#"href="/custom.css""#).unwrap();
2722 assert!(
2723 link_pos > style_pos,
2724 "custom.css link should appear after main <style>"
2725 );
2726 }
2727
2728 #[test]
2729 fn head_html_injected_when_present() {
2730 let snippets = CustomSnippets {
2731 head_html: Some(r#"<script>console.log("analytics")</script>"#.to_string()),
2732 ..Default::default()
2733 };
2734 let content = html! { p { "test" } };
2735 let doc =
2736 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2737 assert!(doc.contains(r#"<script>console.log("analytics")</script>"#));
2738 }
2739
2740 #[test]
2741 fn head_html_inside_head_element() {
2742 let snippets = CustomSnippets {
2743 head_html: Some("<!-- custom head -->".to_string()),
2744 ..Default::default()
2745 };
2746 let content = html! { p { "test" } };
2747 let doc =
2748 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2749 let head_end = doc.find("</head>").unwrap();
2750 let snippet_pos = doc.find("<!-- custom head -->").unwrap();
2751 assert!(
2752 snippet_pos < head_end,
2753 "head.html should appear inside <head>"
2754 );
2755 }
2756
2757 #[test]
2758 fn no_head_html_by_default() {
2759 let content = html! { p { "test" } };
2760 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
2761 .into_string();
2762 assert!(!doc.contains("<!-- custom"));
2764 }
2765
2766 #[test]
2767 fn body_end_html_injected_when_present() {
2768 let snippets = CustomSnippets {
2769 body_end_html: Some(r#"<script src="/tracking.js"></script>"#.to_string()),
2770 ..Default::default()
2771 };
2772 let content = html! { p { "test" } };
2773 let doc =
2774 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2775 assert!(doc.contains(r#"<script src="/tracking.js"></script>"#));
2776 }
2777
2778 #[test]
2779 fn body_end_html_inside_body_before_close() {
2780 let snippets = CustomSnippets {
2781 body_end_html: Some("<!-- body end -->".to_string()),
2782 ..Default::default()
2783 };
2784 let content = html! { p { "test" } };
2785 let doc =
2786 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2787 let body_end = doc.find("</body>").unwrap();
2788 let snippet_pos = doc.find("<!-- body end -->").unwrap();
2789 assert!(
2790 snippet_pos < body_end,
2791 "body-end.html should appear before </body>"
2792 );
2793 }
2794
2795 #[test]
2796 fn body_end_html_after_content() {
2797 let snippets = CustomSnippets {
2798 body_end_html: Some("<!-- body end -->".to_string()),
2799 ..Default::default()
2800 };
2801 let content = html! { p { "main content" } };
2802 let doc =
2803 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2804 let content_pos = doc.find("main content").unwrap();
2805 let snippet_pos = doc.find("<!-- body end -->").unwrap();
2806 assert!(
2807 snippet_pos > content_pos,
2808 "body-end.html should appear after main content"
2809 );
2810 }
2811
2812 #[test]
2813 fn all_snippets_injected_together() {
2814 let snippets = CustomSnippets {
2815 has_custom_css: true,
2816 head_html: Some("<!-- head snippet -->".to_string()),
2817 body_end_html: Some("<!-- body snippet -->".to_string()),
2818 };
2819 let content = html! { p { "test" } };
2820 let doc =
2821 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2822 assert!(doc.contains(r#"href="/custom.css""#));
2823 assert!(doc.contains("<!-- head snippet -->"));
2824 assert!(doc.contains("<!-- body snippet -->"));
2825 }
2826
2827 #[test]
2828 fn snippets_appear_in_all_page_types() {
2829 let snippets = CustomSnippets {
2830 has_custom_css: true,
2831 head_html: Some("<!-- head -->".to_string()),
2832 body_end_html: Some("<!-- body -->".to_string()),
2833 };
2834
2835 let manifest = Manifest {
2837 navigation: vec![],
2838 albums: vec![],
2839 pages: vec![],
2840 description: None,
2841 config: SiteConfig::default(),
2842 };
2843 let html = render_index(&manifest, "", None, None, &snippets).into_string();
2844 assert!(html.contains("custom.css"));
2845 assert!(html.contains("<!-- head -->"));
2846 assert!(html.contains("<!-- body -->"));
2847
2848 let album = create_test_album();
2850 let html =
2851 render_album_page(&album, &[], &[], "", None, "Gallery", None, &snippets).into_string();
2852 assert!(html.contains("custom.css"));
2853 assert!(html.contains("<!-- head -->"));
2854 assert!(html.contains("<!-- body -->"));
2855
2856 let page = make_page("about", "About", true, false);
2858 let html = render_page(&page, &[], &[], "", None, "Gallery", None, &snippets).into_string();
2859 assert!(html.contains("custom.css"));
2860 assert!(html.contains("<!-- head -->"));
2861 assert!(html.contains("<!-- body -->"));
2862
2863 let html = render_image_page(
2865 &album,
2866 &album.images[0],
2867 None,
2868 Some(&album.images[1]),
2869 &[],
2870 &[],
2871 "",
2872 None,
2873 "Gallery",
2874 None,
2875 &snippets,
2876 )
2877 .into_string();
2878 assert!(html.contains("custom.css"));
2879 assert!(html.contains("<!-- head -->"));
2880 assert!(html.contains("<!-- body -->"));
2881 }
2882
2883 #[test]
2884 fn detect_custom_snippets_finds_files() {
2885 let tmp = tempfile::TempDir::new().unwrap();
2886
2887 let snippets = detect_custom_snippets(tmp.path());
2889 assert!(!snippets.has_custom_css);
2890 assert!(snippets.head_html.is_none());
2891 assert!(snippets.body_end_html.is_none());
2892
2893 fs::write(tmp.path().join("custom.css"), "body { color: red; }").unwrap();
2895 let snippets = detect_custom_snippets(tmp.path());
2896 assert!(snippets.has_custom_css);
2897 assert!(snippets.head_html.is_none());
2898
2899 fs::write(tmp.path().join("head.html"), "<meta name=\"test\">").unwrap();
2901 let snippets = detect_custom_snippets(tmp.path());
2902 assert!(snippets.has_custom_css);
2903 assert_eq!(snippets.head_html.as_deref(), Some("<meta name=\"test\">"));
2904
2905 fs::write(
2907 tmp.path().join("body-end.html"),
2908 "<script>alert(1)</script>",
2909 )
2910 .unwrap();
2911 let snippets = detect_custom_snippets(tmp.path());
2912 assert!(snippets.has_custom_css);
2913 assert!(snippets.head_html.is_some());
2914 assert_eq!(
2915 snippets.body_end_html.as_deref(),
2916 Some("<script>alert(1)</script>")
2917 );
2918 }
2919
2920 #[test]
2925 fn sizes_attr_landscape_uses_vw() {
2926 let attr = image_sizes_attr(1600.0 / 1200.0, 1400);
2928 assert!(
2929 attr.contains("95vw"),
2930 "desktop should use 95vw for landscape: {attr}"
2931 );
2932 assert!(
2933 attr.contains("1400px"),
2934 "should cap at max generated width: {attr}"
2935 );
2936 }
2937
2938 #[test]
2939 fn sizes_attr_portrait_uses_vh() {
2940 let attr = image_sizes_attr(1200.0 / 1600.0, 600);
2942 assert!(
2943 attr.contains("vh"),
2944 "desktop should use vh for portrait: {attr}"
2945 );
2946 assert!(
2947 attr.contains("67.5vh"),
2948 "should be 90 * 0.75 = 67.5vh: {attr}"
2949 );
2950 assert!(
2951 attr.contains("600px"),
2952 "should cap at max generated width: {attr}"
2953 );
2954 }
2955
2956 #[test]
2957 fn sizes_attr_square_uses_vh() {
2958 let attr = image_sizes_attr(1.0, 2080);
2960 assert!(
2961 attr.contains("vh"),
2962 "square treated as height-constrained: {attr}"
2963 );
2964 assert!(attr.contains("90.0vh"), "should be 90 * 1.0: {attr}");
2965 }
2966
2967 #[test]
2968 fn sizes_attr_mobile_always_100vw() {
2969 for aspect in [0.5, 0.75, 1.0, 1.333, 2.0] {
2970 let attr = image_sizes_attr(aspect, 1400);
2971 assert!(
2972 attr.contains("(max-width: 800px) min(100vw,"),
2973 "mobile should always be 100vw: {attr}"
2974 );
2975 }
2976 }
2977
2978 #[test]
2979 fn sizes_attr_caps_at_max_width() {
2980 let attr = image_sizes_attr(1.5, 900);
2981 assert_eq!(
2983 attr.matches("900px").count(),
2984 2,
2985 "should have px cap in both conditions: {attr}"
2986 );
2987 }
2988
2989 #[test]
2994 fn srcset_uses_actual_width_not_target_for_portrait() {
2995 let album = create_test_album();
2996 let image = &album.images[1]; let nav = vec![];
2998 let html = render_image_page(
2999 &album,
3000 image,
3001 Some(&album.images[0]),
3002 None,
3003 &nav,
3004 &[],
3005 "",
3006 None,
3007 "Gallery",
3008 None,
3009 &no_snippets(),
3010 )
3011 .into_string();
3012
3013 assert!(
3015 html.contains("600w"),
3016 "srcset should use actual width 600, not target 800: {html}"
3017 );
3018 assert!(
3019 !html.contains("800w"),
3020 "srcset must not use target (height) as w descriptor"
3021 );
3022 }
3023
3024 #[test]
3025 fn srcset_uses_actual_width_for_landscape() {
3026 let album = create_test_album();
3027 let image = &album.images[0]; let nav = vec![];
3029 let html = render_image_page(
3030 &album,
3031 image,
3032 None,
3033 Some(&album.images[1]),
3034 &nav,
3035 &[],
3036 "",
3037 None,
3038 "Gallery",
3039 None,
3040 &no_snippets(),
3041 )
3042 .into_string();
3043
3044 assert!(html.contains("800w"));
3045 assert!(html.contains("1400w"));
3046 }
3047}