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 #[serde(default)]
120 pub full_index_thumbnail: Option<String>,
121}
122
123#[derive(Debug, Deserialize)]
124pub struct GeneratedVariant {
125 pub avif: String,
126 #[allow(dead_code)]
127 pub width: u32,
128 #[allow(dead_code)]
129 pub height: u32,
130}
131
132const CSS_STATIC: &str = include_str!("../static/style.css");
133const JS: &str = include_str!("../static/nav.js");
134const SW_JS_TEMPLATE: &str = include_str!("../static/sw.js");
135const ICON_192: &[u8] = include_bytes!("../static/icon-192.png");
138const ICON_512: &[u8] = include_bytes!("../static/icon-512.png");
139const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../static/apple-touch-icon.png");
140const FAVICON_PNG: &[u8] = include_bytes!("../static/favicon.png");
141
142fn image_sizes_attr(aspect_ratio: f64, max_generated_width: u32) -> String {
147 let vh_factor = 90.0 * aspect_ratio;
150 let cap = format!("{}px", max_generated_width);
152 if vh_factor >= 100.0 {
153 format!("(max-width: 800px) min(100vw, {cap}), min(95vw, {cap})")
155 } else {
156 format!("(max-width: 800px) min(100vw, {cap}), min({vh_factor:.1}vh, {cap})")
158 }
159}
160
161struct GalleryEntry {
163 title: String,
164 path: String,
165 thumbnail: Option<String>,
166}
167
168fn find_nav_thumbnail(item: &NavItem, albums: &[Album]) -> Option<String> {
170 if item.children.is_empty() {
171 albums
173 .iter()
174 .find(|a| a.path == item.path)
175 .map(|a| a.thumbnail.clone())
176 } else {
177 item.children
179 .first()
180 .and_then(|c| find_nav_thumbnail(c, albums))
181 }
182}
183
184fn collect_gallery_entries(children: &[NavItem], albums: &[Album]) -> Vec<GalleryEntry> {
186 children
187 .iter()
188 .map(|item| GalleryEntry {
189 title: item.title.clone(),
190 path: item.path.clone(),
191 thumbnail: find_nav_thumbnail(item, albums),
192 })
193 .collect()
194}
195
196fn path_to_breadcrumb_segments<'a>(
200 path: &str,
201 navigation: &'a [NavItem],
202) -> Vec<(&'a str, &'a str)> {
203 fn find_segments<'a>(
204 path: &str,
205 items: &'a [NavItem],
206 segments: &mut Vec<(&'a str, &'a str)>,
207 ) -> bool {
208 for item in items {
209 if item.path == path {
210 return true;
211 }
212 if path.starts_with(&format!("{}/", item.path)) {
213 segments.push((&item.title, &item.path));
214 if find_segments(path, &item.children, segments) {
215 return true;
216 }
217 segments.pop();
218 }
219 }
220 false
221 }
222
223 let mut segments = Vec::new();
224 find_segments(path, navigation, &mut segments);
225 segments
226}
227
228#[derive(Debug, Default)]
235struct CustomSnippets {
236 has_custom_css: bool,
238 head_html: Option<String>,
240 body_end_html: Option<String>,
242}
243
244fn detect_custom_snippets(output_dir: &Path) -> CustomSnippets {
248 CustomSnippets {
249 has_custom_css: output_dir.join("custom.css").exists(),
250 head_html: fs::read_to_string(output_dir.join("head.html")).ok(),
251 body_end_html: fs::read_to_string(output_dir.join("body-end.html")).ok(),
252 }
253}
254
255pub(crate) fn index_width(total: usize) -> usize {
257 match total {
258 0..=9 => 1,
259 10..=99 => 2,
260 100..=999 => 3,
261 _ => 4,
262 }
263}
264
265pub(crate) fn image_page_url(position: usize, total: usize, title: Option<&str>) -> String {
274 let width = index_width(total);
275 match title {
276 Some(t) => {
277 let escaped = escape_for_url(t);
278 format!("{:0>width$}-{}/", position, escaped)
279 }
280 None => format!("{:0>width$}/", position),
281 }
282}
283
284fn escape_for_url(title: &str) -> String {
288 let mut result = String::with_capacity(title.len());
289 let mut prev_dash = false;
290 for c in title.chars() {
291 if c == ' ' || c == '.' || c == '_' {
292 if !prev_dash {
293 result.push('-');
294 }
295 prev_dash = true;
296 } else {
297 result.extend(c.to_lowercase());
298 prev_dash = false;
299 }
300 }
301 result.trim_matches('-').to_string()
302}
303
304const SHORT_CAPTION_MAX_LEN: usize = 160;
305
306fn is_short_caption(text: &str) -> bool {
312 text.len() <= SHORT_CAPTION_MAX_LEN && !text.contains('\n')
313}
314
315pub fn generate(
316 manifest_path: &Path,
317 processed_dir: &Path,
318 output_dir: &Path,
319 source_dir: &Path,
320) -> Result<(), GenerateError> {
321 let manifest_content = fs::read_to_string(manifest_path)?;
322 let manifest: Manifest = serde_json::from_str(&manifest_content)?;
323
324 let font_url = manifest.config.font.stylesheet_url();
345 let color_css = config::generate_color_css(&manifest.config.colors);
346 let theme_css = config::generate_theme_css(&manifest.config.theme);
347 let font_css = config::generate_font_css(&manifest.config.font);
348 let css = format!(
349 "{}\n\n{}\n\n{}\n\n{}",
350 color_css, theme_css, font_css, CSS_STATIC
351 );
352
353 fs::create_dir_all(output_dir)?;
354
355 let manifest_json = serde_json::json!({
369 "name": manifest.config.site_title,
370 "short_name": manifest.config.site_title,
371 "icons": [
372 {
373 "src": "/icon-192.png",
374 "sizes": "192x192",
375 "type": "image/png"
376 },
377 {
378 "src": "/icon-512.png",
379 "sizes": "512x512",
380 "type": "image/png"
381 }
382 ],
383 "theme_color": "#ffffff",
384 "background_color": "#ffffff",
385 "display": "standalone",
386 "scope": "/",
387 "start_url": "/"
388 });
389 fs::write(
390 output_dir.join("site.webmanifest"),
391 serde_json::to_string_pretty(&manifest_json)?,
392 )?;
393
394 let version = env!("CARGO_PKG_VERSION");
397 let sw_content = SW_JS_TEMPLATE.replace(
398 "const CACHE_NAME = 'simple-gal-v1';",
399 &format!("const CACHE_NAME = 'simple-gal-v{}';", version),
400 );
401 fs::write(output_dir.join("sw.js"), sw_content)?;
402
403 fs::write(output_dir.join("icon-192.png"), ICON_192)?;
404 fs::write(output_dir.join("icon-512.png"), ICON_512)?;
405 fs::write(output_dir.join("apple-touch-icon.png"), APPLE_TOUCH_ICON)?;
406 fs::write(output_dir.join("favicon.png"), FAVICON_PNG)?;
407
408 let assets_path = source_dir.join(&manifest.config.assets_dir);
410 if assets_path.is_dir() {
411 copy_dir_recursive(&assets_path, output_dir)?;
412 }
413
414 copy_dir_recursive(processed_dir, output_dir)?;
416
417 let favicon_href = detect_favicon(output_dir);
419
420 let snippets = detect_custom_snippets(output_dir);
422
423 let index_html = render_index(
425 &manifest,
426 &css,
427 font_url.as_deref(),
428 favicon_href.as_deref(),
429 &snippets,
430 );
431 fs::write(output_dir.join("index.html"), index_html.into_string())?;
432
433 let show_all_photos = show_all_photos_link(&manifest.config);
434
435 for page in manifest.pages.iter().filter(|p| !p.is_link) {
437 let page_html = render_page(
438 page,
439 &manifest.navigation,
440 &manifest.pages,
441 &css,
442 font_url.as_deref(),
443 &manifest.config.site_title,
444 favicon_href.as_deref(),
445 &snippets,
446 show_all_photos,
447 );
448 let filename = format!("{}.html", page.slug);
449 fs::write(output_dir.join(&filename), page_html.into_string())?;
450 }
451
452 generate_gallery_list_pages(
454 &manifest.navigation,
455 &manifest.albums,
456 &manifest.navigation,
457 &manifest.pages,
458 &css,
459 font_url.as_deref(),
460 &manifest.config.site_title,
461 favicon_href.as_deref(),
462 &snippets,
463 show_all_photos,
464 output_dir,
465 )?;
466
467 for album in &manifest.albums {
469 let album_dir = output_dir.join(&album.path);
470 fs::create_dir_all(&album_dir)?;
471
472 let album_html = render_album_page(
473 album,
474 &manifest.navigation,
475 &manifest.pages,
476 &css,
477 font_url.as_deref(),
478 &manifest.config.site_title,
479 favicon_href.as_deref(),
480 &snippets,
481 show_all_photos,
482 );
483 fs::write(album_dir.join("index.html"), album_html.into_string())?;
484
485 for (idx, image) in album.images.iter().enumerate() {
487 let prev = if idx > 0 {
488 Some(&album.images[idx - 1])
489 } else {
490 None
491 };
492 let next = album.images.get(idx + 1);
493
494 let image_html = render_image_page(
495 album,
496 image,
497 prev,
498 next,
499 &manifest.navigation,
500 &manifest.pages,
501 &css,
502 font_url.as_deref(),
503 &manifest.config.site_title,
504 favicon_href.as_deref(),
505 &snippets,
506 show_all_photos,
507 );
508 let image_dir_name =
509 image_page_url(idx + 1, album.images.len(), image.title.as_deref());
510 let image_dir = album_dir.join(&image_dir_name);
511 fs::create_dir_all(&image_dir)?;
512 fs::write(image_dir.join("index.html"), image_html.into_string())?;
513 }
514 }
515
516 if manifest.config.full_index.generates {
518 let all_photos_html = render_full_index_page(
519 &manifest,
520 &css,
521 font_url.as_deref(),
522 favicon_href.as_deref(),
523 &snippets,
524 );
525 let all_photos_dir = output_dir.join("all-photos");
526 fs::create_dir_all(&all_photos_dir)?;
527 fs::write(
528 all_photos_dir.join("index.html"),
529 all_photos_html.into_string(),
530 )?;
531 }
532
533 Ok(())
534}
535
536fn detect_favicon(output_dir: &Path) -> Option<String> {
538 for (filename, _mime) in &[
539 ("favicon.svg", "image/svg+xml"),
540 ("favicon.ico", "image/x-icon"),
541 ("favicon.png", "image/png"),
542 ] {
543 if output_dir.join(filename).exists() {
544 return Some(format!("/{}", filename));
545 }
546 }
547 None
548}
549
550fn favicon_type(href: &str) -> &'static str {
552 if href.ends_with(".svg") {
553 "image/svg+xml"
554 } else if href.ends_with(".png") {
555 "image/png"
556 } else {
557 "image/x-icon"
558 }
559}
560
561fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
562 for entry in fs::read_dir(src)? {
563 let entry = entry?;
564 let src_path = entry.path();
565 let dst_path = dst.join(entry.file_name());
566
567 if src_path.is_dir() {
568 fs::create_dir_all(&dst_path)?;
569 copy_dir_recursive(&src_path, &dst_path)?;
570 } else if src_path.extension().map(|e| e != "json").unwrap_or(true) {
571 fs::copy(&src_path, &dst_path)?;
573 }
574 }
575 Ok(())
576}
577
578#[allow(clippy::too_many_arguments)]
589fn base_document(
590 title: &str,
591 css: &str,
592 font_url: Option<&str>,
593 body_class: Option<&str>,
594 head_extra: Option<Markup>,
595 favicon_href: Option<&str>,
596 snippets: &CustomSnippets,
597 content: Markup,
598) -> Markup {
599 html! {
600 (DOCTYPE)
601 html lang="en" {
602 head {
603 meta charset="UTF-8";
604 meta name="viewport" content="width=device-width, initial-scale=1.0";
605 title { (title) }
606 link rel="manifest" href="/site.webmanifest";
608 link rel="apple-touch-icon" href="/apple-touch-icon.png";
609 @if let Some(href) = favicon_href {
610 link rel="icon" type=(favicon_type(href)) href=(href);
611 }
612 @if let Some(url) = font_url {
614 link rel="preconnect" href="https://fonts.googleapis.com";
615 link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="";
616 link rel="stylesheet" href=(url);
617 }
618 style { (PreEscaped(css)) }
619 @if snippets.has_custom_css {
621 link rel="stylesheet" href="/custom.css";
622 }
623 @if let Some(extra) = head_extra {
624 (extra)
625 }
626 script {
627 (PreEscaped(r#"
628 if ('serviceWorker' in navigator && location.protocol !== 'file:') {
629 window.addEventListener('load', () => {
630 navigator.serviceWorker.register('/sw.js');
631 });
632 }
633 window.addEventListener('beforeinstallprompt', e => e.preventDefault());
634 "#))
635 }
636 @if let Some(ref html) = snippets.head_html {
637 (PreEscaped(html))
638 }
639 }
640 body class=[body_class] {
641 (content)
642 script { (PreEscaped(JS)) }
643 @if let Some(ref html) = snippets.body_end_html {
644 (PreEscaped(html))
645 }
646 }
647 }
648 }
649}
650
651fn site_header(breadcrumb: Markup, nav: Markup) -> Markup {
653 html! {
654 header.site-header {
655 nav.breadcrumb {
656 (breadcrumb)
657 }
658 nav.site-nav {
659 (nav)
660 }
661 }
662 }
663}
664
665pub fn render_nav(
674 items: &[NavItem],
675 current_path: &str,
676 pages: &[Page],
677 show_all_photos: bool,
678) -> Markup {
679 let nav_pages: Vec<&Page> = pages.iter().filter(|p| p.in_nav).collect();
680 let all_photos_current = current_path == "all-photos";
681
682 html! {
683 input.nav-toggle type="checkbox" id="nav-toggle";
684 label.nav-hamburger for="nav-toggle" {
685 span.hamburger-line {}
686 span.hamburger-line {}
687 span.hamburger-line {}
688 }
689 div.nav-panel {
690 label.nav-close for="nav-toggle" { "×" }
691 ul {
692 @for item in items {
693 (render_nav_item(item, current_path))
694 }
695 @if show_all_photos {
696 li class=[all_photos_current.then_some("current")] {
697 a href="/all-photos/" { "All Photos" }
698 }
699 }
700 @if !nav_pages.is_empty() {
701 li.nav-separator role="separator" {}
702 @for page in &nav_pages {
703 @if page.is_link {
704 li {
705 a href=(page.body.trim()) target="_blank" rel="noopener" {
706 (page.link_title)
707 }
708 }
709 } @else {
710 @let is_current = current_path == page.slug;
711 li class=[is_current.then_some("current")] {
712 a href={ "/" (page.slug) ".html" } { (page.link_title) }
713 }
714 }
715 }
716 }
717 }
718 }
719 }
720}
721
722fn render_nav_item(item: &NavItem, current_path: &str) -> Markup {
724 let is_current =
725 item.path == current_path || current_path.starts_with(&format!("{}/", item.path));
726
727 html! {
728 li class=[is_current.then_some("current")] {
729 @if item.children.is_empty() {
730 a href={ "/" (item.path) "/" } { (item.title) }
731 } @else {
732 a.nav-group href={ "/" (item.path) "/" } { (item.title) }
733 ul {
734 @for child in &item.children {
735 (render_nav_item(child, current_path))
736 }
737 }
738 }
739 }
740 }
741}
742
743fn render_index(
752 manifest: &Manifest,
753 css: &str,
754 font_url: Option<&str>,
755 favicon_href: Option<&str>,
756 snippets: &CustomSnippets,
757) -> Markup {
758 render_gallery_list_page(
759 &manifest.config.site_title,
760 "",
761 &collect_gallery_entries(&manifest.navigation, &manifest.albums),
762 manifest.description.as_deref(),
763 &manifest.navigation,
764 &manifest.pages,
765 css,
766 font_url,
767 &manifest.config.site_title,
768 favicon_href,
769 snippets,
770 show_all_photos_link(&manifest.config),
771 )
772}
773
774fn show_all_photos_link(config: &SiteConfig) -> bool {
778 config.full_index.generates && config.full_index.show_link
779}
780
781#[allow(clippy::too_many_arguments)]
783fn render_album_page(
784 album: &Album,
785 navigation: &[NavItem],
786 pages: &[Page],
787 css: &str,
788 font_url: Option<&str>,
789 site_title: &str,
790 favicon_href: Option<&str>,
791 snippets: &CustomSnippets,
792 show_all_photos: bool,
793) -> Markup {
794 let nav = render_nav(navigation, &album.path, pages, show_all_photos);
795
796 let segments = path_to_breadcrumb_segments(&album.path, navigation);
797 let breadcrumb = html! {
798 a href="/" { (site_title) }
799 @for (seg_title, seg_path) in &segments {
800 " › "
801 a href={ "/" (seg_path) "/" } { (seg_title) }
802 }
803 " › "
804 (album.title)
805 };
806
807 let album_dir_name = album.path.rsplit('/').next().unwrap_or(&album.path);
811 let strip_prefix = |path: &str| -> String {
812 path.strip_prefix(album_dir_name)
813 .and_then(|p| p.strip_prefix('/'))
814 .unwrap_or(path)
815 .to_string()
816 };
817
818 let has_desc = album.description.is_some();
819 let content = html! {
820 (site_header(breadcrumb, nav))
821 main.album-page.has-description[has_desc] {
822 header.album-header {
823 h1 { (album.title) }
824 @if let Some(desc) = &album.description {
825 input.desc-toggle type="checkbox" id="desc-toggle";
826 div.album-description { (PreEscaped(desc)) }
827 label.desc-expand for="desc-toggle" {
828 span.expand-more { "Read more" }
829 span.expand-less { "Show less" }
830 }
831 }
832 }
833 div.thumbnail-grid {
834 @for (idx, image) in album.images.iter().enumerate() {
835 a.thumb-link href=(image_page_url(idx + 1, album.images.len(), image.title.as_deref())) {
836 img src=(strip_prefix(&image.thumbnail)) alt={ "Image " (idx + 1) } loading="lazy";
837 }
838 }
839 }
840 }
841 };
842
843 base_document(
844 &album.title,
845 css,
846 font_url,
847 None,
848 None,
849 favicon_href,
850 snippets,
851 content,
852 )
853}
854
855fn format_image_label(position: usize, total: usize, title: Option<&str>) -> String {
869 let width = index_width(total);
870 match title {
871 Some(t) => format!("{:0>width$}. {}", position, t),
872 None => format!("{:0>width$}", position),
873 }
874}
875
876#[allow(clippy::too_many_arguments)]
878fn render_image_page(
879 album: &Album,
880 image: &Image,
881 prev: Option<&Image>,
882 next: Option<&Image>,
883 navigation: &[NavItem],
884 pages: &[Page],
885 css: &str,
886 font_url: Option<&str>,
887 site_title: &str,
888 favicon_href: Option<&str>,
889 snippets: &CustomSnippets,
890 show_all_photos: bool,
891) -> Markup {
892 let nav = render_nav(navigation, &album.path, pages, show_all_photos);
893
894 let album_dir_name = album.path.rsplit('/').next().unwrap_or(&album.path);
897 let strip_prefix = |path: &str| -> String {
898 let relative = path
899 .strip_prefix(album_dir_name)
900 .and_then(|p| p.strip_prefix('/'))
901 .unwrap_or(path);
902 format!("../{}", relative)
903 };
904
905 fn sorted_variants(img: &Image) -> Vec<&GeneratedVariant> {
908 let mut v: Vec<_> = img.generated.values().collect();
909 v.sort_by_key(|variant| variant.width);
910 v
911 }
912
913 let avif_srcset_for = |img: &Image| -> String {
915 sorted_variants(img)
916 .iter()
917 .map(|variant| format!("{} {}w", strip_prefix(&variant.avif), variant.width))
918 .collect::<Vec<_>>()
919 .join(", ")
920 };
921
922 let variants = sorted_variants(image);
924
925 let srcset_avif: String = avif_srcset_for(image);
926
927 let default_src = variants
929 .get(variants.len() / 2)
930 .map(|v| strip_prefix(&v.avif))
931 .unwrap_or_default();
932
933 let mid_avif = |img: &Image| -> String {
935 let v = sorted_variants(img);
936 v.get(v.len() / 2)
937 .map(|variant| strip_prefix(&variant.avif))
938 .unwrap_or_default()
939 };
940 let prev_prefetch = prev.map(&mid_avif);
941 let next_prefetch = next.map(&mid_avif);
942
943 let (width, height) = image.dimensions;
945 let aspect_ratio = width as f64 / height as f64;
946
947 let image_idx = album
949 .images
950 .iter()
951 .position(|i| i.number == image.number)
952 .unwrap();
953
954 let total = album.images.len();
955 let prev_url = match prev {
956 Some(p) => format!(
957 "../{}",
958 image_page_url(image_idx, total, p.title.as_deref())
959 ), None => "../".to_string(),
961 };
962
963 let next_url = match next {
964 Some(n) => format!(
965 "../{}",
966 image_page_url(image_idx + 2, total, n.title.as_deref())
967 ),
968 None => "../".to_string(),
969 };
970
971 let display_idx = image_idx + 1;
972 let image_label = format_image_label(display_idx, album.images.len(), image.title.as_deref());
973 let page_title = format!("{} - {}", album.title, image_label);
974
975 let segments = path_to_breadcrumb_segments(&album.path, navigation);
976 let breadcrumb = html! {
977 a href="/" { (site_title) }
978 @for (seg_title, seg_path) in &segments {
979 " › "
980 a href={ "/" (seg_path) "/" } { (seg_title) }
981 }
982 " › "
983 a href="../" { (album.title) }
984 " › "
985 (image_label)
986 };
987
988 let max_generated_width = image
989 .generated
990 .values()
991 .map(|v| v.width)
992 .max()
993 .unwrap_or(800);
994 let sizes_attr = image_sizes_attr(aspect_ratio, max_generated_width);
995
996 let aspect_style = format!("--aspect-ratio: {};", aspect_ratio);
997 let alt_text = match &image.title {
998 Some(t) => format!("{} - {}", album.title, t),
999 None => format!("{} - Image {}", album.title, display_idx),
1000 };
1001
1002 let nav_dots: Vec<String> = album
1004 .images
1005 .iter()
1006 .enumerate()
1007 .map(|(idx, img)| {
1008 format!(
1009 "../{}",
1010 image_page_url(idx + 1, total, img.title.as_deref())
1011 )
1012 })
1013 .collect();
1014
1015 let description = image.description.as_deref().filter(|d| !d.is_empty());
1016 let caption_text = description.filter(|d| is_short_caption(d));
1017 let description_text = description.filter(|d| !is_short_caption(d));
1018
1019 let body_class = match description {
1020 Some(desc) if is_short_caption(desc) => "image-view has-caption",
1021 Some(_) => "image-view has-description",
1022 None => "image-view",
1023 };
1024
1025 let head_extra = html! {
1027 link rel="expect" href="#main-image" blocking="render";
1028 @if let Some(ref href) = prev_prefetch {
1029 link rel="prefetch" as="image" href=(href);
1030 }
1031 @if let Some(ref href) = next_prefetch {
1032 link rel="prefetch" as="image" href=(href);
1033 }
1034 };
1035
1036 let content = html! {
1037 (site_header(breadcrumb, nav))
1038 main style=(aspect_style) {
1039 div.image-page {
1040 figure.image-frame {
1041 img #main-image src=(default_src) srcset=(srcset_avif) sizes=(sizes_attr) alt=(alt_text);
1042 }
1043 p.print-credit {
1044 (album.title) " › " (image_label)
1045 }
1046 @if let Some(text) = caption_text {
1047 p.image-caption { (text) }
1048 }
1049 }
1050 @if let Some(text) = description_text {
1051 div.image-description {
1052 p { (text) }
1053 }
1054 }
1055 nav.image-nav {
1056 @for (idx, url) in nav_dots.iter().enumerate() {
1057 @if idx == image_idx {
1058 a href=(url) aria-current="true" {}
1059 } @else {
1060 a href=(url) {}
1061 }
1062 }
1063 }
1064 a.nav-prev href=(prev_url) aria-label="Previous image" {}
1065 a.nav-next href=(next_url) aria-label="Next image" {}
1066 }
1067 };
1068
1069 base_document(
1070 &page_title,
1071 css,
1072 font_url,
1073 Some(body_class),
1074 Some(head_extra),
1075 favicon_href,
1076 snippets,
1077 content,
1078 )
1079}
1080
1081#[allow(clippy::too_many_arguments)]
1083fn render_page(
1084 page: &Page,
1085 navigation: &[NavItem],
1086 pages: &[Page],
1087 css: &str,
1088 font_url: Option<&str>,
1089 site_title: &str,
1090 favicon_href: Option<&str>,
1091 snippets: &CustomSnippets,
1092 show_all_photos: bool,
1093) -> Markup {
1094 let nav = render_nav(navigation, &page.slug, pages, show_all_photos);
1095
1096 let parser = Parser::new(&page.body);
1098 let mut body_html = String::new();
1099 md_html::push_html(&mut body_html, parser);
1100
1101 let breadcrumb = html! {
1102 a href="/" { (site_title) }
1103 " › "
1104 (page.title)
1105 };
1106
1107 let content = html! {
1108 (site_header(breadcrumb, nav))
1109 main.page {
1110 article.page-content {
1111 (PreEscaped(body_html))
1112 }
1113 }
1114 };
1115
1116 base_document(
1117 &page.title,
1118 css,
1119 font_url,
1120 None,
1121 None,
1122 favicon_href,
1123 snippets,
1124 content,
1125 )
1126}
1127
1128#[allow(clippy::too_many_arguments)]
1133fn render_gallery_list_page(
1134 title: &str,
1135 path: &str,
1136 entries: &[GalleryEntry],
1137 description: Option<&str>,
1138 navigation: &[NavItem],
1139 pages: &[Page],
1140 css: &str,
1141 font_url: Option<&str>,
1142 site_title: &str,
1143 favicon_href: Option<&str>,
1144 snippets: &CustomSnippets,
1145 show_all_photos: bool,
1146) -> Markup {
1147 let nav = render_nav(navigation, path, pages, show_all_photos);
1148
1149 let is_root = path.is_empty();
1150 let segments = path_to_breadcrumb_segments(path, navigation);
1151 let breadcrumb = html! {
1152 a href="/" { (site_title) }
1153 @if !is_root {
1154 @for (seg_title, seg_path) in &segments {
1155 " › "
1156 a href={ "/" (seg_path) "/" } { (seg_title) }
1157 }
1158 " › "
1159 (title)
1160 }
1161 };
1162
1163 let main_class = match description {
1164 Some(_) => "index-page has-description",
1165 None => "index-page",
1166 };
1167 let content = html! {
1168 (site_header(breadcrumb, nav))
1169 main class=(main_class) {
1170 @if let Some(desc) = description {
1171 header.index-header {
1172 h1 { (title) }
1173 input.desc-toggle type="checkbox" id="desc-toggle";
1174 div.album-description { (PreEscaped(desc)) }
1175 label.desc-expand for="desc-toggle" {
1176 span.expand-more { "Read more" }
1177 span.expand-less { "Show less" }
1178 }
1179 }
1180 }
1181 div.album-grid {
1182 @for entry in entries {
1183 a.album-card href={ "/" (entry.path) "/" } {
1184 @if let Some(ref thumb) = entry.thumbnail {
1185 img src={ "/" (thumb) } alt=(entry.title) loading="lazy";
1186 }
1187 span.album-title { (entry.title) }
1188 }
1189 }
1190 }
1191 }
1192 };
1193
1194 base_document(
1195 title,
1196 css,
1197 font_url,
1198 None,
1199 None,
1200 favicon_href,
1201 snippets,
1202 content,
1203 )
1204}
1205
1206fn render_full_index_page(
1211 manifest: &Manifest,
1212 css: &str,
1213 font_url: Option<&str>,
1214 favicon_href: Option<&str>,
1215 snippets: &CustomSnippets,
1216) -> Markup {
1217 let title = "All Photos";
1218 let path = "all-photos";
1219 let fi = &manifest.config.full_index;
1220
1221 let nav = render_nav(
1222 &manifest.navigation,
1223 path,
1224 &manifest.pages,
1225 show_all_photos_link(&manifest.config),
1226 );
1227
1228 let breadcrumb = html! {
1229 a href="/" { (manifest.config.site_title) }
1230 " › "
1231 (title)
1232 };
1233
1234 struct FullIndexEntry<'a> {
1236 thumbnail: String,
1237 link: String,
1238 alt: String,
1239 #[allow(dead_code)]
1240 album_title: &'a str,
1241 }
1242
1243 let mut entries: Vec<FullIndexEntry> = Vec::new();
1244 for album in &manifest.albums {
1245 if !album.in_nav {
1246 continue;
1247 }
1248 let total = album.images.len();
1249 for (idx, image) in album.images.iter().enumerate() {
1250 let Some(ref thumb) = image.full_index_thumbnail else {
1251 continue;
1252 };
1253 let image_dir = image_page_url(idx + 1, total, image.title.as_deref());
1254 let link = format!("/{}/{}", album.path, image_dir);
1255 let alt = match &image.title {
1256 Some(t) => format!("{} - {}", album.title, t),
1257 None => format!("{} - Image {}", album.title, idx + 1),
1258 };
1259 entries.push(FullIndexEntry {
1260 thumbnail: format!("/{}", thumb),
1261 link,
1262 alt,
1263 album_title: &album.title,
1264 });
1265 }
1266 }
1267
1268 let (rw, rh) = (fi.thumb_ratio[0].max(1), fi.thumb_ratio[1].max(1));
1280 let display_width_px = if rw >= rh {
1281 (fi.thumb_size as f64 * rw as f64 / rh as f64).round() as u32
1282 } else {
1283 fi.thumb_size
1284 };
1285 let aspect_ratio_css = format!("{} / {}", rw, rh);
1286 let main_style = format!(
1287 "--thumbnail-gap: {}; --fi-thumb-aspect: {}; --fi-thumb-col-width: {}px;",
1288 fi.thumb_gap, aspect_ratio_css, display_width_px
1289 );
1290
1291 let content = html! {
1292 (site_header(breadcrumb, nav))
1293 main.album-page.full-index-page style=(main_style) {
1294 header.album-header {
1295 h1 { (title) }
1296 }
1297 div.thumbnail-grid {
1298 @for entry in &entries {
1299 a.thumb-link href=(entry.link) {
1300 img src=(entry.thumbnail) alt=(entry.alt) loading="lazy";
1301 }
1302 }
1303 }
1304 }
1305 };
1306
1307 base_document(
1308 title,
1309 css,
1310 font_url,
1311 None,
1312 None,
1313 favicon_href,
1314 snippets,
1315 content,
1316 )
1317}
1318
1319#[allow(clippy::too_many_arguments)]
1321fn generate_gallery_list_pages(
1322 items: &[NavItem],
1323 albums: &[Album],
1324 navigation: &[NavItem],
1325 pages: &[Page],
1326 css: &str,
1327 font_url: Option<&str>,
1328 site_title: &str,
1329 favicon_href: Option<&str>,
1330 snippets: &CustomSnippets,
1331 show_all_photos: bool,
1332 output_dir: &Path,
1333) -> Result<(), GenerateError> {
1334 for item in items {
1335 if !item.children.is_empty() {
1336 let entries = collect_gallery_entries(&item.children, albums);
1337 let page_html = render_gallery_list_page(
1338 &item.title,
1339 &item.path,
1340 &entries,
1341 item.description.as_deref(),
1342 navigation,
1343 pages,
1344 css,
1345 font_url,
1346 site_title,
1347 favicon_href,
1348 snippets,
1349 show_all_photos,
1350 );
1351 let dir = output_dir.join(&item.path);
1352 fs::create_dir_all(&dir)?;
1353 fs::write(dir.join("index.html"), page_html.into_string())?;
1354
1355 generate_gallery_list_pages(
1357 &item.children,
1358 albums,
1359 navigation,
1360 pages,
1361 css,
1362 font_url,
1363 site_title,
1364 favicon_href,
1365 snippets,
1366 show_all_photos,
1367 output_dir,
1368 )?;
1369 }
1370 }
1371 Ok(())
1372}
1373
1374#[cfg(test)]
1379mod tests {
1380 use super::*;
1381
1382 fn no_snippets() -> CustomSnippets {
1383 CustomSnippets::default()
1384 }
1385
1386 fn make_page(slug: &str, link_title: &str, in_nav: bool, is_link: bool) -> Page {
1387 Page {
1388 title: link_title.to_string(),
1389 link_title: link_title.to_string(),
1390 slug: slug.to_string(),
1391 body: if is_link {
1392 "https://example.com".to_string()
1393 } else {
1394 format!("# {}\n\nContent.", link_title)
1395 },
1396 in_nav,
1397 sort_key: if in_nav { 40 } else { u32::MAX },
1398 is_link,
1399 }
1400 }
1401
1402 #[test]
1403 fn nav_renders_items() {
1404 let items = vec![NavItem {
1405 title: "Album One".to_string(),
1406 path: "010-one".to_string(),
1407 source_dir: String::new(),
1408 description: None,
1409 children: vec![],
1410 }];
1411 let html = render_nav(&items, "", &[], false).into_string();
1412 assert!(html.contains("Album One"));
1413 assert!(html.contains("/010-one/"));
1414 }
1415
1416 #[test]
1417 fn nav_includes_pages() {
1418 let pages = vec![make_page("about", "About", true, false)];
1419 let html = render_nav(&[], "", &pages, false).into_string();
1420 assert!(html.contains("About"));
1421 assert!(html.contains("/about.html"));
1422 }
1423
1424 #[test]
1425 fn nav_hides_unnumbered_pages() {
1426 let pages = vec![make_page("notes", "Notes", false, false)];
1427 let html = render_nav(&[], "", &pages, false).into_string();
1428 assert!(!html.contains("Notes"));
1429 assert!(!html.contains("nav-separator"));
1431 }
1432
1433 #[test]
1434 fn nav_renders_link_page_as_external() {
1435 let pages = vec![make_page("github", "GitHub", true, true)];
1436 let html = render_nav(&[], "", &pages, false).into_string();
1437 assert!(html.contains("GitHub"));
1438 assert!(html.contains("https://example.com"));
1439 assert!(html.contains("target=\"_blank\""));
1440 }
1441
1442 #[test]
1443 fn nav_marks_current_item() {
1444 let items = vec![
1445 NavItem {
1446 title: "First".to_string(),
1447 path: "010-first".to_string(),
1448 source_dir: String::new(),
1449 description: None,
1450 children: vec![],
1451 },
1452 NavItem {
1453 title: "Second".to_string(),
1454 path: "020-second".to_string(),
1455 source_dir: String::new(),
1456 description: None,
1457 children: vec![],
1458 },
1459 ];
1460 let html = render_nav(&items, "020-second", &[], false).into_string();
1461 assert!(html.contains(r#"class="current"#));
1463 }
1464
1465 #[test]
1466 fn nav_marks_current_page() {
1467 let pages = vec![make_page("about", "About", true, false)];
1468 let html = render_nav(&[], "about", &pages, false).into_string();
1469 assert!(html.contains(r#"class="current"#));
1470 }
1471
1472 #[test]
1473 fn nav_renders_nested_children() {
1474 let items = vec![NavItem {
1475 title: "Parent".to_string(),
1476 path: "010-parent".to_string(),
1477 source_dir: String::new(),
1478 description: None,
1479 children: vec![NavItem {
1480 title: "Child".to_string(),
1481 path: "010-parent/010-child".to_string(),
1482 source_dir: String::new(),
1483 description: None,
1484 children: vec![],
1485 }],
1486 }];
1487 let html = render_nav(&items, "", &[], false).into_string();
1488 assert!(html.contains("Parent"));
1489 assert!(html.contains("Child"));
1490 assert!(html.contains("nav-group")); }
1492
1493 #[test]
1494 fn nav_separator_only_when_pages() {
1495 let html_no_pages = render_nav(&[], "", &[], false).into_string();
1497 assert!(!html_no_pages.contains("nav-separator"));
1498
1499 let pages = vec![make_page("about", "About", true, false)];
1501 let html_with_pages = render_nav(&[], "", &pages, false).into_string();
1502 assert!(html_with_pages.contains("nav-separator"));
1503 }
1504
1505 #[test]
1506 fn base_document_includes_doctype() {
1507 let content = html! { p { "test" } };
1508 let doc = base_document(
1509 "Test",
1510 "body {}",
1511 None,
1512 None,
1513 None,
1514 None,
1515 &no_snippets(),
1516 content,
1517 )
1518 .into_string();
1519 assert!(doc.starts_with("<!DOCTYPE html>"));
1520 }
1521
1522 #[test]
1523 fn base_document_applies_body_class() {
1524 let content = html! { p { "test" } };
1525 let doc = base_document(
1526 "Test",
1527 "",
1528 None,
1529 Some("image-view"),
1530 None,
1531 None,
1532 &no_snippets(),
1533 content,
1534 )
1535 .into_string();
1536 assert!(html_contains_body_class(&doc, "image-view"));
1537 }
1538
1539 #[test]
1540 fn site_header_structure() {
1541 let breadcrumb = html! { a href="/" { "Home" } };
1542 let nav = html! { ul { li { "Item" } } };
1543 let header = site_header(breadcrumb, nav).into_string();
1544
1545 assert!(header.contains("site-header"));
1546 assert!(header.contains("breadcrumb"));
1547 assert!(header.contains("site-nav"));
1548 assert!(header.contains("Home"));
1549 }
1550
1551 fn html_contains_body_class(html: &str, class: &str) -> bool {
1553 html.contains(&format!(r#"class="{}""#, class))
1555 }
1556
1557 fn create_test_album() -> Album {
1562 Album {
1563 path: "test".to_string(),
1564 title: "Test Album".to_string(),
1565 description: Some("<p>A test album description</p>".to_string()),
1566 thumbnail: "test/001-image-thumb.avif".to_string(),
1567 images: vec![
1568 Image {
1569 number: 1,
1570 source_path: "test/001-dawn.jpg".to_string(),
1571 title: Some("Dawn".to_string()),
1572 description: None,
1573 dimensions: (1600, 1200),
1574 generated: {
1575 let mut map = BTreeMap::new();
1576 map.insert(
1577 "800".to_string(),
1578 GeneratedVariant {
1579 avif: "test/001-dawn-800.avif".to_string(),
1580 width: 800,
1581 height: 600,
1582 },
1583 );
1584 map.insert(
1585 "1400".to_string(),
1586 GeneratedVariant {
1587 avif: "test/001-dawn-1400.avif".to_string(),
1588 width: 1400,
1589 height: 1050,
1590 },
1591 );
1592 map
1593 },
1594 thumbnail: "test/001-dawn-thumb.avif".to_string(),
1595 full_index_thumbnail: None,
1596 },
1597 Image {
1598 number: 2,
1599 source_path: "test/002-night.jpg".to_string(),
1600 title: None,
1601 description: None,
1602 dimensions: (1200, 1600),
1603 generated: {
1604 let mut map = BTreeMap::new();
1605 map.insert(
1606 "800".to_string(),
1607 GeneratedVariant {
1608 avif: "test/002-night-800.avif".to_string(),
1609 width: 600,
1610 height: 800,
1611 },
1612 );
1613 map
1614 },
1615 thumbnail: "test/002-night-thumb.avif".to_string(),
1616 full_index_thumbnail: None,
1617 },
1618 ],
1619 in_nav: true,
1620 config: SiteConfig::default(),
1621 support_files: vec![],
1622 }
1623 }
1624
1625 fn create_nested_test_album() -> Album {
1631 Album {
1632 path: "NY/Night".to_string(),
1633 title: "Night".to_string(),
1634 description: None,
1635 thumbnail: "Night/001-image-thumb.avif".to_string(),
1636 images: vec![Image {
1637 number: 1,
1638 source_path: "NY/Night/001-city.jpg".to_string(),
1639 title: Some("City".to_string()),
1640 description: None,
1641 dimensions: (1600, 1200),
1642 generated: {
1643 let mut map = BTreeMap::new();
1644 map.insert(
1645 "800".to_string(),
1646 GeneratedVariant {
1647 avif: "Night/001-city-800.avif".to_string(),
1648 width: 800,
1649 height: 600,
1650 },
1651 );
1652 map.insert(
1653 "1400".to_string(),
1654 GeneratedVariant {
1655 avif: "Night/001-city-1400.avif".to_string(),
1656 width: 1400,
1657 height: 1050,
1658 },
1659 );
1660 map
1661 },
1662 thumbnail: "Night/001-city-thumb.avif".to_string(),
1663 full_index_thumbnail: None,
1664 }],
1665 in_nav: true,
1666 config: SiteConfig::default(),
1667 support_files: vec![],
1668 }
1669 }
1670
1671 #[test]
1672 fn nested_album_thumbnail_paths_are_relative_to_album_dir() {
1673 let album = create_nested_test_album();
1674 let html = render_album_page(
1675 &album,
1676 &[],
1677 &[],
1678 "",
1679 None,
1680 "Gallery",
1681 None,
1682 &no_snippets(),
1683 false,
1684 )
1685 .into_string();
1686
1687 assert!(html.contains(r#"src="001-city-thumb.avif""#));
1691 assert!(!html.contains("Night/001-city-thumb.avif"));
1692 }
1693
1694 #[test]
1695 fn nested_album_image_page_srcset_paths_are_relative() {
1696 let album = create_nested_test_album();
1697 let image = &album.images[0];
1698 let html = render_image_page(
1699 &album,
1700 image,
1701 None,
1702 None,
1703 &[],
1704 &[],
1705 "",
1706 None,
1707 "Gallery",
1708 None,
1709 &no_snippets(),
1710 false,
1711 )
1712 .into_string();
1713
1714 assert!(html.contains("../001-city-800.avif"));
1717 assert!(html.contains("../001-city-1400.avif"));
1718 assert!(!html.contains("Night/001-city-800.avif"));
1719 }
1720
1721 #[test]
1722 fn render_album_page_includes_title() {
1723 let album = create_test_album();
1724 let nav = vec![];
1725 let html = render_album_page(
1726 &album,
1727 &nav,
1728 &[],
1729 "",
1730 None,
1731 "Gallery",
1732 None,
1733 &no_snippets(),
1734 false,
1735 )
1736 .into_string();
1737
1738 assert!(html.contains("Test Album"));
1739 assert!(html.contains("<h1>"));
1740 }
1741
1742 #[test]
1743 fn render_album_page_includes_description() {
1744 let album = create_test_album();
1745 let nav = vec![];
1746 let html = render_album_page(
1747 &album,
1748 &nav,
1749 &[],
1750 "",
1751 None,
1752 "Gallery",
1753 None,
1754 &no_snippets(),
1755 false,
1756 )
1757 .into_string();
1758
1759 assert!(html.contains("A test album description"));
1760 assert!(html.contains("album-description"));
1761 }
1762
1763 #[test]
1764 fn render_album_page_thumbnail_links() {
1765 let album = create_test_album();
1766 let nav = vec![];
1767 let html = render_album_page(
1768 &album,
1769 &nav,
1770 &[],
1771 "",
1772 None,
1773 "Gallery",
1774 None,
1775 &no_snippets(),
1776 false,
1777 )
1778 .into_string();
1779
1780 assert!(html.contains("1-dawn/"));
1782 assert!(html.contains("2/"));
1783 assert!(html.contains("001-dawn-thumb.avif"));
1785 }
1786
1787 #[test]
1788 fn render_album_page_breadcrumb() {
1789 let album = create_test_album();
1790 let nav = vec![];
1791 let html = render_album_page(
1792 &album,
1793 &nav,
1794 &[],
1795 "",
1796 None,
1797 "Gallery",
1798 None,
1799 &no_snippets(),
1800 false,
1801 )
1802 .into_string();
1803
1804 assert!(html.contains(r#"href="/""#));
1806 assert!(html.contains("Gallery"));
1807 }
1808
1809 #[test]
1810 fn render_image_page_includes_img_with_srcset() {
1811 let album = create_test_album();
1812 let image = &album.images[0];
1813 let nav = vec![];
1814 let html = render_image_page(
1815 &album,
1816 image,
1817 None,
1818 Some(&album.images[1]),
1819 &nav,
1820 &[],
1821 "",
1822 None,
1823 "Gallery",
1824 None,
1825 &no_snippets(),
1826 false,
1827 )
1828 .into_string();
1829
1830 assert!(html.contains("<img"));
1831 assert!(html.contains("srcset="));
1832 assert!(html.contains(".avif"));
1833 assert!(!html.contains("<picture>"));
1834 }
1835
1836 #[test]
1837 fn render_image_page_srcset() {
1838 let album = create_test_album();
1839 let image = &album.images[0];
1840 let nav = vec![];
1841 let html = render_image_page(
1842 &album,
1843 image,
1844 None,
1845 Some(&album.images[1]),
1846 &nav,
1847 &[],
1848 "",
1849 None,
1850 "Gallery",
1851 None,
1852 &no_snippets(),
1853 false,
1854 )
1855 .into_string();
1856
1857 assert!(html.contains("srcset="));
1859 assert!(html.contains("800w"));
1860 assert!(html.contains("1400w"));
1861 }
1862
1863 #[test]
1864 fn render_image_page_nav_links() {
1865 let album = create_test_album();
1866 let image = &album.images[0];
1867 let nav = vec![];
1868 let html = render_image_page(
1869 &album,
1870 image,
1871 None,
1872 Some(&album.images[1]),
1873 &nav,
1874 &[],
1875 "",
1876 None,
1877 "Gallery",
1878 None,
1879 &no_snippets(),
1880 false,
1881 )
1882 .into_string();
1883
1884 assert!(html.contains("nav-prev"));
1885 assert!(html.contains("nav-next"));
1886 assert!(html.contains(r#"aria-label="Previous image""#));
1887 assert!(html.contains(r#"aria-label="Next image""#));
1888 }
1889
1890 #[test]
1891 fn render_image_page_prev_next_urls() {
1892 let album = create_test_album();
1893 let nav = vec![];
1894
1895 let html1 = render_image_page(
1897 &album,
1898 &album.images[0],
1899 None,
1900 Some(&album.images[1]),
1901 &nav,
1902 &[],
1903 "",
1904 None,
1905 "Gallery",
1906 None,
1907 &no_snippets(),
1908 false,
1909 )
1910 .into_string();
1911 assert!(html1.contains(r#"class="nav-prev" href="../""#));
1912 assert!(html1.contains(r#"class="nav-next" href="../2/""#));
1913
1914 let html2 = render_image_page(
1916 &album,
1917 &album.images[1],
1918 Some(&album.images[0]),
1919 None,
1920 &nav,
1921 &[],
1922 "",
1923 None,
1924 "Gallery",
1925 None,
1926 &no_snippets(),
1927 false,
1928 )
1929 .into_string();
1930 assert!(html2.contains(r#"class="nav-prev" href="../1-dawn/""#));
1931 assert!(html2.contains(r#"class="nav-next" href="../""#));
1932 }
1933
1934 #[test]
1935 fn render_image_page_aspect_ratio() {
1936 let album = create_test_album();
1937 let image = &album.images[0]; let nav = vec![];
1939 let html = render_image_page(
1940 &album,
1941 image,
1942 None,
1943 None,
1944 &nav,
1945 &[],
1946 "",
1947 None,
1948 "Gallery",
1949 None,
1950 &no_snippets(),
1951 false,
1952 )
1953 .into_string();
1954
1955 assert!(html.contains("--aspect-ratio:"));
1957 }
1958
1959 #[test]
1960 fn render_page_converts_markdown() {
1961 let page = Page {
1962 title: "About Me".to_string(),
1963 link_title: "about".to_string(),
1964 slug: "about".to_string(),
1965 body: "# About Me\n\nThis is **bold** and *italic*.".to_string(),
1966 in_nav: true,
1967 sort_key: 40,
1968 is_link: false,
1969 };
1970 let html = render_page(
1971 &page,
1972 &[],
1973 &[],
1974 "",
1975 None,
1976 "Gallery",
1977 None,
1978 &no_snippets(),
1979 false,
1980 )
1981 .into_string();
1982
1983 assert!(html.contains("<strong>bold</strong>"));
1985 assert!(html.contains("<em>italic</em>"));
1986 }
1987
1988 #[test]
1989 fn render_page_includes_title() {
1990 let page = Page {
1991 title: "About Me".to_string(),
1992 link_title: "about me".to_string(),
1993 slug: "about".to_string(),
1994 body: "Content here".to_string(),
1995 in_nav: true,
1996 sort_key: 40,
1997 is_link: false,
1998 };
1999 let html = render_page(
2000 &page,
2001 &[],
2002 &[],
2003 "",
2004 None,
2005 "Gallery",
2006 None,
2007 &no_snippets(),
2008 false,
2009 )
2010 .into_string();
2011
2012 assert!(html.contains("<title>About Me</title>"));
2013 assert!(html.contains("class=\"page\""));
2014 }
2015
2016 #[test]
2021 fn format_label_with_title() {
2022 assert_eq!(format_image_label(1, 5, Some("Museum")), "1. Museum");
2023 }
2024
2025 #[test]
2026 fn format_label_without_title() {
2027 assert_eq!(format_image_label(1, 5, None), "1");
2028 }
2029
2030 #[test]
2031 fn format_label_zero_pads_for_10_plus() {
2032 assert_eq!(format_image_label(3, 15, Some("Dawn")), "03. Dawn");
2033 assert_eq!(format_image_label(3, 15, None), "03");
2034 }
2035
2036 #[test]
2037 fn format_label_zero_pads_for_100_plus() {
2038 assert_eq!(format_image_label(7, 120, Some("X")), "007. X");
2039 assert_eq!(format_image_label(7, 120, None), "007");
2040 }
2041
2042 #[test]
2043 fn format_label_no_padding_under_10() {
2044 assert_eq!(format_image_label(3, 9, Some("Y")), "3. Y");
2045 }
2046
2047 #[test]
2048 fn image_breadcrumb_includes_title() {
2049 let album = create_test_album();
2050 let image = &album.images[0]; let nav = vec![];
2052 let html = render_image_page(
2053 &album,
2054 image,
2055 None,
2056 Some(&album.images[1]),
2057 &nav,
2058 &[],
2059 "",
2060 None,
2061 "Gallery",
2062 None,
2063 &no_snippets(),
2064 false,
2065 )
2066 .into_string();
2067
2068 assert!(html.contains("1. Dawn"));
2070 assert!(html.contains("Test Album"));
2071 }
2072
2073 #[test]
2074 fn image_breadcrumb_without_title() {
2075 let album = create_test_album();
2076 let image = &album.images[1]; let nav = vec![];
2078 let html = render_image_page(
2079 &album,
2080 image,
2081 Some(&album.images[0]),
2082 None,
2083 &nav,
2084 &[],
2085 "",
2086 None,
2087 "Gallery",
2088 None,
2089 &no_snippets(),
2090 false,
2091 )
2092 .into_string();
2093
2094 assert!(html.contains("Test Album"));
2096 assert!(html.contains(" › 2<"));
2098 }
2099
2100 #[test]
2101 fn image_page_title_includes_label() {
2102 let album = create_test_album();
2103 let image = &album.images[0];
2104 let nav = vec![];
2105 let html = render_image_page(
2106 &album,
2107 image,
2108 None,
2109 Some(&album.images[1]),
2110 &nav,
2111 &[],
2112 "",
2113 None,
2114 "Gallery",
2115 None,
2116 &no_snippets(),
2117 false,
2118 )
2119 .into_string();
2120
2121 assert!(html.contains("<title>Test Album - 1. Dawn</title>"));
2122 }
2123
2124 #[test]
2125 fn image_alt_text_uses_title() {
2126 let album = create_test_album();
2127 let image = &album.images[0]; let nav = vec![];
2129 let html = render_image_page(
2130 &album,
2131 image,
2132 None,
2133 Some(&album.images[1]),
2134 &nav,
2135 &[],
2136 "",
2137 None,
2138 "Gallery",
2139 None,
2140 &no_snippets(),
2141 false,
2142 )
2143 .into_string();
2144
2145 assert!(html.contains("Test Album - Dawn"));
2146 }
2147
2148 #[test]
2153 fn is_short_caption_short_text() {
2154 assert!(is_short_caption("A beautiful sunset"));
2155 }
2156
2157 #[test]
2158 fn is_short_caption_exactly_at_limit() {
2159 let text = "a".repeat(SHORT_CAPTION_MAX_LEN);
2160 assert!(is_short_caption(&text));
2161 }
2162
2163 #[test]
2164 fn is_short_caption_over_limit() {
2165 let text = "a".repeat(SHORT_CAPTION_MAX_LEN + 1);
2166 assert!(!is_short_caption(&text));
2167 }
2168
2169 #[test]
2170 fn is_short_caption_with_newline() {
2171 assert!(!is_short_caption("Line one\nLine two"));
2172 }
2173
2174 #[test]
2175 fn is_short_caption_empty_string() {
2176 assert!(is_short_caption(""));
2177 }
2178
2179 #[test]
2180 fn render_image_page_short_caption() {
2181 let mut album = create_test_album();
2182 album.images[0].description = Some("A beautiful sunrise over the mountains".to_string());
2183 let image = &album.images[0];
2184 let html = render_image_page(
2185 &album,
2186 image,
2187 None,
2188 Some(&album.images[1]),
2189 &[],
2190 &[],
2191 "",
2192 None,
2193 "Gallery",
2194 None,
2195 &no_snippets(),
2196 false,
2197 )
2198 .into_string();
2199
2200 assert!(html.contains("image-caption"));
2201 assert!(html.contains("A beautiful sunrise over the mountains"));
2202 assert!(html_contains_body_class(&html, "image-view has-caption"));
2203 }
2204
2205 #[test]
2206 fn render_image_page_long_description() {
2207 let mut album = create_test_album();
2208 let long_text = "a".repeat(200);
2209 album.images[0].description = Some(long_text.clone());
2210 let image = &album.images[0];
2211 let html = render_image_page(
2212 &album,
2213 image,
2214 None,
2215 Some(&album.images[1]),
2216 &[],
2217 &[],
2218 "",
2219 None,
2220 "Gallery",
2221 None,
2222 &no_snippets(),
2223 false,
2224 )
2225 .into_string();
2226
2227 assert!(html.contains("image-description"));
2228 assert!(!html.contains("image-caption"));
2229 assert!(html_contains_body_class(
2230 &html,
2231 "image-view has-description"
2232 ));
2233 }
2234
2235 #[test]
2236 fn render_image_page_multiline_is_long_description() {
2237 let mut album = create_test_album();
2238 album.images[0].description = Some("Line one\nLine two".to_string());
2239 let image = &album.images[0];
2240 let html = render_image_page(
2241 &album,
2242 image,
2243 None,
2244 Some(&album.images[1]),
2245 &[],
2246 &[],
2247 "",
2248 None,
2249 "Gallery",
2250 None,
2251 &no_snippets(),
2252 false,
2253 )
2254 .into_string();
2255
2256 assert!(html.contains("image-description"));
2257 assert!(!html.contains("image-caption"));
2258 assert!(html_contains_body_class(
2259 &html,
2260 "image-view has-description"
2261 ));
2262 }
2263
2264 #[test]
2265 fn render_image_page_no_description_no_caption() {
2266 let album = create_test_album();
2267 let image = &album.images[1]; let html = render_image_page(
2269 &album,
2270 image,
2271 Some(&album.images[0]),
2272 None,
2273 &[],
2274 &[],
2275 "",
2276 None,
2277 "Gallery",
2278 None,
2279 &no_snippets(),
2280 false,
2281 )
2282 .into_string();
2283
2284 assert!(!html.contains("image-caption"));
2285 assert!(!html.contains("image-description"));
2286 assert!(html_contains_body_class(&html, "image-view"));
2287 }
2288
2289 #[test]
2290 fn render_image_page_caption_width_matches_frame() {
2291 let mut album = create_test_album();
2292 album.images[0].description = Some("Short caption".to_string());
2293 let image = &album.images[0];
2294 let html = render_image_page(
2295 &album,
2296 image,
2297 None,
2298 Some(&album.images[1]),
2299 &[],
2300 &[],
2301 "",
2302 None,
2303 "Gallery",
2304 None,
2305 &no_snippets(),
2306 false,
2307 )
2308 .into_string();
2309
2310 assert!(html.contains("image-frame"));
2312 assert!(html.contains("image-caption"));
2313 assert!(html.contains("image-page"));
2315 }
2316
2317 #[test]
2318 fn html_escape_in_maud() {
2319 let items = vec![NavItem {
2321 title: "<script>alert('xss')</script>".to_string(),
2322 path: "test".to_string(),
2323 source_dir: String::new(),
2324 description: None,
2325 children: vec![],
2326 }];
2327 let html = render_nav(&items, "", &[], false).into_string();
2328
2329 assert!(!html.contains("<script>alert"));
2331 assert!(html.contains("<script>"));
2332 }
2333
2334 #[test]
2339 fn escape_for_url_spaces_become_dashes() {
2340 assert_eq!(escape_for_url("My Title"), "my-title");
2341 }
2342
2343 #[test]
2344 fn escape_for_url_dots_become_dashes() {
2345 assert_eq!(escape_for_url("St. Louis"), "st-louis");
2346 }
2347
2348 #[test]
2349 fn escape_for_url_collapses_consecutive() {
2350 assert_eq!(escape_for_url("A. B"), "a-b");
2351 }
2352
2353 #[test]
2354 fn escape_for_url_strips_leading_trailing() {
2355 assert_eq!(escape_for_url(". Title ."), "title");
2356 }
2357
2358 #[test]
2359 fn escape_for_url_preserves_dashes() {
2360 assert_eq!(escape_for_url("My-Title"), "my-title");
2361 }
2362
2363 #[test]
2364 fn escape_for_url_underscores_become_dashes() {
2365 assert_eq!(escape_for_url("My_Title"), "my-title");
2366 }
2367
2368 #[test]
2369 fn image_page_url_with_title() {
2370 assert_eq!(image_page_url(3, 15, Some("Dawn")), "03-dawn/");
2371 }
2372
2373 #[test]
2374 fn image_page_url_without_title() {
2375 assert_eq!(image_page_url(3, 15, None), "03/");
2376 }
2377
2378 #[test]
2379 fn image_page_url_title_with_spaces() {
2380 assert_eq!(image_page_url(1, 5, Some("My Museum")), "1-my-museum/");
2381 }
2382
2383 #[test]
2384 fn image_page_url_title_with_dot() {
2385 assert_eq!(image_page_url(1, 5, Some("St. Louis")), "1-st-louis/");
2386 }
2387
2388 #[test]
2393 fn render_image_page_has_main_image_id() {
2394 let album = create_test_album();
2395 let image = &album.images[0];
2396 let html = render_image_page(
2397 &album,
2398 image,
2399 None,
2400 Some(&album.images[1]),
2401 &[],
2402 &[],
2403 "",
2404 None,
2405 "Gallery",
2406 None,
2407 &no_snippets(),
2408 false,
2409 )
2410 .into_string();
2411
2412 assert!(html.contains(r#"id="main-image""#));
2413 }
2414
2415 #[test]
2416 fn render_image_page_has_render_blocking_link() {
2417 let album = create_test_album();
2418 let image = &album.images[0];
2419 let html = render_image_page(
2420 &album,
2421 image,
2422 None,
2423 Some(&album.images[1]),
2424 &[],
2425 &[],
2426 "",
2427 None,
2428 "Gallery",
2429 None,
2430 &no_snippets(),
2431 false,
2432 )
2433 .into_string();
2434
2435 assert!(html.contains(r#"rel="expect""#));
2436 assert!(html.contains(r##"href="#main-image""##));
2437 assert!(html.contains(r#"blocking="render""#));
2438 }
2439
2440 #[test]
2441 fn render_image_page_prefetches_next_image() {
2442 let album = create_test_album();
2443 let image = &album.images[0];
2444 let html = render_image_page(
2445 &album,
2446 image,
2447 None,
2448 Some(&album.images[1]),
2449 &[],
2450 &[],
2451 "",
2452 None,
2453 "Gallery",
2454 None,
2455 &no_snippets(),
2456 false,
2457 )
2458 .into_string();
2459
2460 assert!(html.contains(r#"rel="prefetch""#));
2462 assert!(html.contains(r#"as="image""#));
2463 assert!(html.contains("002-night-800.avif"));
2464 }
2465
2466 #[test]
2467 fn render_image_page_prefetches_prev_image() {
2468 let album = create_test_album();
2469 let image = &album.images[1];
2470 let html = render_image_page(
2471 &album,
2472 image,
2473 Some(&album.images[0]),
2474 None,
2475 &[],
2476 &[],
2477 "",
2478 None,
2479 "Gallery",
2480 None,
2481 &no_snippets(),
2482 false,
2483 )
2484 .into_string();
2485
2486 assert!(html.contains(r#"rel="prefetch""#));
2489 assert!(html.contains("001-dawn-1400.avif"));
2490 assert!(!html.contains("001-dawn-800.avif"));
2492 }
2493
2494 #[test]
2495 fn render_image_page_no_prefetch_without_adjacent() {
2496 let album = create_test_album();
2497 let image = &album.images[0];
2498 let html = render_image_page(
2500 &album,
2501 image,
2502 None,
2503 None,
2504 &[],
2505 &[],
2506 "",
2507 None,
2508 "Gallery",
2509 None,
2510 &no_snippets(),
2511 false,
2512 )
2513 .into_string();
2514
2515 assert!(html.contains(r#"rel="expect""#));
2517 assert!(!html.contains(r#"rel="prefetch""#));
2519 }
2520
2521 #[test]
2526 fn rendered_html_contains_color_css_variables() {
2527 let mut config = SiteConfig::default();
2528 config.colors.light.background = "#fafafa".to_string();
2529 config.colors.dark.background = "#111111".to_string();
2530
2531 let color_css = crate::config::generate_color_css(&config.colors);
2532 let theme_css = crate::config::generate_theme_css(&config.theme);
2533 let font_css = crate::config::generate_font_css(&config.font);
2534 let css = format!("{}\n{}\n{}", color_css, theme_css, font_css);
2535
2536 let album = create_test_album();
2537 let html = render_album_page(
2538 &album,
2539 &[],
2540 &[],
2541 &css,
2542 None,
2543 "Gallery",
2544 None,
2545 &no_snippets(),
2546 false,
2547 )
2548 .into_string();
2549
2550 assert!(html.contains("--color-bg: #fafafa"));
2551 assert!(html.contains("--color-bg: #111111"));
2552 assert!(html.contains("--color-text:"));
2553 assert!(html.contains("--color-text-muted:"));
2554 assert!(html.contains("--color-border:"));
2555 assert!(html.contains("--color-link:"));
2556 assert!(html.contains("--color-link-hover:"));
2557 }
2558
2559 #[test]
2560 fn rendered_html_contains_theme_css_variables() {
2561 let mut config = SiteConfig::default();
2562 config.theme.thumbnail_gap = "0.5rem".to_string();
2563 config.theme.mat_x.size = "5vw".to_string();
2564
2565 let theme_css = crate::config::generate_theme_css(&config.theme);
2566 let album = create_test_album();
2567 let html = render_album_page(
2568 &album,
2569 &[],
2570 &[],
2571 &theme_css,
2572 None,
2573 "Gallery",
2574 None,
2575 &no_snippets(),
2576 false,
2577 )
2578 .into_string();
2579
2580 assert!(html.contains("--thumbnail-gap: 0.5rem"));
2581 assert!(html.contains("--mat-x: clamp(1rem, 5vw, 2.5rem)"));
2582 assert!(html.contains("--mat-y:"));
2583 assert!(html.contains("--grid-padding:"));
2584 }
2585
2586 #[test]
2587 fn rendered_html_contains_font_css_variables() {
2588 let mut config = SiteConfig::default();
2589 config.font.font = "Lora".to_string();
2590 config.font.weight = "300".to_string();
2591 config.font.font_type = crate::config::FontType::Serif;
2592
2593 let font_css = crate::config::generate_font_css(&config.font);
2594 let font_url = config.font.stylesheet_url();
2595
2596 let album = create_test_album();
2597 let html = render_album_page(
2598 &album,
2599 &[],
2600 &[],
2601 &font_css,
2602 font_url.as_deref(),
2603 "Gallery",
2604 None,
2605 &no_snippets(),
2606 false,
2607 )
2608 .into_string();
2609
2610 assert!(html.contains("--font-family:"));
2611 assert!(html.contains("--font-weight: 300"));
2612 assert!(html.contains("fonts.googleapis.com"));
2613 assert!(html.contains("Lora"));
2614 }
2615
2616 #[test]
2621 fn index_page_excludes_non_nav_albums() {
2622 let manifest = Manifest {
2623 navigation: vec![NavItem {
2624 title: "Visible".to_string(),
2625 path: "visible".to_string(),
2626 source_dir: String::new(),
2627 description: None,
2628 children: vec![],
2629 }],
2630 albums: vec![
2631 Album {
2632 path: "visible".to_string(),
2633 title: "Visible".to_string(),
2634 description: None,
2635 thumbnail: "visible/thumb.avif".to_string(),
2636 images: vec![],
2637 in_nav: true,
2638 config: SiteConfig::default(),
2639 support_files: vec![],
2640 },
2641 Album {
2642 path: "hidden".to_string(),
2643 title: "Hidden".to_string(),
2644 description: None,
2645 thumbnail: "hidden/thumb.avif".to_string(),
2646 images: vec![],
2647 in_nav: false,
2648 config: SiteConfig::default(),
2649 support_files: vec![],
2650 },
2651 ],
2652 pages: vec![],
2653 description: None,
2654 config: SiteConfig::default(),
2655 };
2656
2657 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2658
2659 assert!(html.contains("Visible"));
2660 assert!(!html.contains("Hidden"));
2661 }
2662
2663 fn make_full_index_manifest() -> Manifest {
2668 let mut cfg = SiteConfig::default();
2671 cfg.full_index.generates = true;
2672 cfg.full_index.show_link = true;
2673 cfg.full_index.thumb_ratio = [4, 4];
2674 cfg.full_index.thumb_size = 1000;
2675 cfg.full_index.thumb_gap = "0.5rem".to_string();
2676
2677 let make_image = |album: &str, n: u32, slug: &str, title: &str| Image {
2678 number: n,
2679 source_path: format!("{}/00{}-{}.jpg", album, n, slug),
2680 title: Some(title.to_string()),
2681 description: None,
2682 dimensions: (1600, 1200),
2683 generated: {
2684 let mut map = BTreeMap::new();
2685 map.insert(
2686 "800".to_string(),
2687 GeneratedVariant {
2688 avif: format!("{}/00{}-{}-800.avif", album, n, slug),
2689 width: 800,
2690 height: 600,
2691 },
2692 );
2693 map
2694 },
2695 thumbnail: format!("{}/00{}-{}-thumb.avif", album, n, slug),
2696 full_index_thumbnail: Some(format!("{}/00{}-{}-fi-thumb.avif", album, n, slug)),
2697 };
2698
2699 Manifest {
2700 navigation: vec![
2701 NavItem {
2702 title: "Alpha".to_string(),
2703 path: "alpha".to_string(),
2704 source_dir: "010-Alpha".to_string(),
2705 description: None,
2706 children: vec![],
2707 },
2708 NavItem {
2709 title: "Beta".to_string(),
2710 path: "beta".to_string(),
2711 source_dir: "020-Beta".to_string(),
2712 description: None,
2713 children: vec![],
2714 },
2715 ],
2716 albums: vec![
2717 Album {
2718 path: "alpha".to_string(),
2719 title: "Alpha".to_string(),
2720 description: None,
2721 thumbnail: "alpha/001-dawn-thumb.avif".to_string(),
2722 images: vec![make_image("alpha", 1, "dawn", "Dawn")],
2723 in_nav: true,
2724 config: cfg.clone(),
2725 support_files: vec![],
2726 },
2727 Album {
2728 path: "beta".to_string(),
2729 title: "Beta".to_string(),
2730 description: None,
2731 thumbnail: "beta/001-dusk-thumb.avif".to_string(),
2732 images: vec![make_image("beta", 1, "dusk", "Dusk")],
2733 in_nav: true,
2734 config: cfg.clone(),
2735 support_files: vec![],
2736 },
2737 ],
2738 pages: vec![],
2739 description: None,
2740 config: cfg,
2741 }
2742 }
2743
2744 #[test]
2745 fn full_index_page_contains_every_image() {
2746 let manifest = make_full_index_manifest();
2747 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2748
2749 assert!(html.contains("All Photos"));
2750 assert!(html.contains("full-index-page"));
2751 assert!(html.contains("/alpha/001-dawn-fi-thumb.avif"));
2752 assert!(html.contains("/beta/001-dusk-fi-thumb.avif"));
2753 assert!(html.contains(r#"href="/alpha/1-dawn/""#));
2755 assert!(html.contains(r#"href="/beta/1-dusk/""#));
2756 }
2757
2758 #[test]
2759 fn full_index_page_applies_thumb_gap_and_aspect() {
2760 let manifest = make_full_index_manifest();
2761 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2762
2763 assert!(html.contains("--thumbnail-gap: 0.5rem"));
2765 assert!(html.contains("--fi-thumb-aspect: 4 / 4"));
2766 }
2767
2768 #[test]
2769 fn full_index_page_column_width_square_matches_thumb_size() {
2770 let manifest = make_full_index_manifest();
2772 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2773 assert!(html.contains("--fi-thumb-col-width: 1000px"));
2774 }
2775
2776 #[test]
2777 fn full_index_page_column_width_portrait_uses_short_edge() {
2778 let mut manifest = make_full_index_manifest();
2780 manifest.config.full_index.thumb_ratio = [4, 5];
2781 manifest.config.full_index.thumb_size = 400;
2782 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2783 assert!(html.contains("--fi-thumb-col-width: 400px"));
2784 }
2785
2786 #[test]
2787 fn full_index_page_column_width_landscape_scales_by_ratio() {
2788 let mut manifest = make_full_index_manifest();
2790 manifest.config.full_index.thumb_ratio = [16, 9];
2791 manifest.config.full_index.thumb_size = 400;
2792 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2793 assert!(html.contains("--fi-thumb-col-width: 711px"));
2794 }
2795
2796 #[test]
2797 fn full_index_page_excludes_hidden_albums() {
2798 let mut manifest = make_full_index_manifest();
2799 let hidden = Album {
2801 path: "hidden".to_string(),
2802 title: "Hidden".to_string(),
2803 description: None,
2804 thumbnail: "hidden/001-secret-thumb.avif".to_string(),
2805 images: vec![Image {
2806 number: 1,
2807 source_path: "hidden/001-secret.jpg".to_string(),
2808 title: Some("Secret".to_string()),
2809 description: None,
2810 dimensions: (1600, 1200),
2811 generated: BTreeMap::new(),
2812 thumbnail: "hidden/001-secret-thumb.avif".to_string(),
2813 full_index_thumbnail: Some("hidden/001-secret-fi-thumb.avif".to_string()),
2814 }],
2815 in_nav: false,
2816 config: manifest.config.clone(),
2817 support_files: vec![],
2818 };
2819 manifest.albums.push(hidden);
2820
2821 let html = render_full_index_page(&manifest, "", None, None, &no_snippets()).into_string();
2822
2823 assert!(!html.contains("secret-fi-thumb"));
2824 assert!(!html.contains("/hidden/"));
2825 }
2826
2827 #[test]
2828 fn all_photos_nav_link_appears_when_enabled() {
2829 let mut cfg = SiteConfig::default();
2830 cfg.full_index.generates = true;
2831 cfg.full_index.show_link = true;
2832 let html = render_nav(&[], "", &[], show_all_photos_link(&cfg)).into_string();
2833 assert!(html.contains("All Photos"));
2834 assert!(html.contains(r#"href="/all-photos/""#));
2835 }
2836
2837 #[test]
2838 fn all_photos_nav_link_absent_by_default() {
2839 let cfg = SiteConfig::default();
2840 let html = render_nav(&[], "", &[], show_all_photos_link(&cfg)).into_string();
2841 assert!(!html.contains("All Photos"));
2842 }
2843
2844 #[test]
2845 fn all_photos_nav_link_requires_generation() {
2846 let mut cfg = SiteConfig::default();
2848 cfg.full_index.show_link = true;
2849 cfg.full_index.generates = false;
2850 assert!(!show_all_photos_link(&cfg));
2851 }
2852
2853 #[test]
2854 fn all_photos_nav_link_marked_current_on_page() {
2855 let html = render_nav(&[], "all-photos", &[], true).into_string();
2856 assert!(html.contains(r#"class="current""#));
2857 assert!(html.contains("All Photos"));
2858 }
2859
2860 #[test]
2861 fn index_page_with_no_albums() {
2862 let manifest = Manifest {
2863 navigation: vec![],
2864 albums: vec![],
2865 pages: vec![],
2866 description: None,
2867 config: SiteConfig::default(),
2868 };
2869
2870 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2871
2872 assert!(html.contains("album-grid"));
2873 assert!(html.contains("Gallery"));
2874 }
2875
2876 #[test]
2877 fn index_page_with_description() {
2878 let manifest = Manifest {
2879 navigation: vec![],
2880 albums: vec![],
2881 pages: vec![],
2882 description: Some("<p>Welcome to the gallery.</p>".to_string()),
2883 config: SiteConfig::default(),
2884 };
2885
2886 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2887
2888 assert!(html.contains("has-description"));
2889 assert!(html.contains("index-header"));
2890 assert!(html.contains("album-description"));
2891 assert!(html.contains("Welcome to the gallery."));
2892 assert!(html.contains("desc-toggle"));
2893 assert!(html.contains("Read more"));
2894 assert!(html.contains("Show less"));
2895 assert!(html.contains("<h1>Gallery</h1>"));
2897 }
2898
2899 #[test]
2900 fn index_page_no_description_no_header() {
2901 let manifest = Manifest {
2902 navigation: vec![],
2903 albums: vec![],
2904 pages: vec![],
2905 description: None,
2906 config: SiteConfig::default(),
2907 };
2908
2909 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2910
2911 assert!(!html.contains("has-description"));
2912 assert!(!html.contains("index-header"));
2913 assert!(!html.contains("album-description"));
2914 }
2915
2916 #[test]
2921 fn single_image_album_no_prev_next() {
2922 let album = Album {
2923 path: "solo".to_string(),
2924 title: "Solo Album".to_string(),
2925 description: None,
2926 thumbnail: "solo/001-thumb.avif".to_string(),
2927 images: vec![Image {
2928 number: 1,
2929 source_path: "solo/001-photo.jpg".to_string(),
2930 title: Some("Photo".to_string()),
2931 description: None,
2932 dimensions: (1600, 1200),
2933 generated: {
2934 let mut map = BTreeMap::new();
2935 map.insert(
2936 "800".to_string(),
2937 GeneratedVariant {
2938 avif: "solo/001-photo-800.avif".to_string(),
2939 width: 800,
2940 height: 600,
2941 },
2942 );
2943 map
2944 },
2945 thumbnail: "solo/001-photo-thumb.avif".to_string(),
2946 full_index_thumbnail: None,
2947 }],
2948 in_nav: true,
2949 config: SiteConfig::default(),
2950 support_files: vec![],
2951 };
2952
2953 let image = &album.images[0];
2954 let html = render_image_page(
2955 &album,
2956 image,
2957 None,
2958 None,
2959 &[],
2960 &[],
2961 "",
2962 None,
2963 "Gallery",
2964 None,
2965 &no_snippets(),
2966 false,
2967 )
2968 .into_string();
2969
2970 assert!(html.contains(r#"class="nav-prev" href="../""#));
2972 assert!(html.contains(r#"class="nav-next" href="../""#));
2973 }
2974
2975 #[test]
2976 fn album_page_no_description() {
2977 let mut album = create_test_album();
2978 album.description = None;
2979 let html = render_album_page(
2980 &album,
2981 &[],
2982 &[],
2983 "",
2984 None,
2985 "Gallery",
2986 None,
2987 &no_snippets(),
2988 false,
2989 )
2990 .into_string();
2991
2992 assert!(!html.contains("album-description"));
2993 assert!(html.contains("Test Album"));
2994 }
2995
2996 #[test]
2997 fn render_image_page_nav_dots() {
2998 let album = create_test_album();
2999 let image = &album.images[0];
3000 let html = render_image_page(
3001 &album,
3002 image,
3003 None,
3004 Some(&album.images[1]),
3005 &[],
3006 &[],
3007 "",
3008 None,
3009 "Gallery",
3010 None,
3011 &no_snippets(),
3012 false,
3013 )
3014 .into_string();
3015
3016 assert!(html.contains("image-nav"));
3018 assert!(html.contains(r#"aria-current="true""#));
3020 assert!(html.contains(r#"href="../1-dawn/""#));
3022 assert!(html.contains(r#"href="../2/""#));
3023 }
3024
3025 #[test]
3026 fn render_image_page_nav_dots_marks_correct_current() {
3027 let album = create_test_album();
3028 let html = render_image_page(
3030 &album,
3031 &album.images[1],
3032 Some(&album.images[0]),
3033 None,
3034 &[],
3035 &[],
3036 "",
3037 None,
3038 "Gallery",
3039 None,
3040 &no_snippets(),
3041 false,
3042 )
3043 .into_string();
3044
3045 assert!(html.contains(r#"<a href="../2/" aria-current="true">"#));
3048 assert!(html.contains(r#"<a href="../1-dawn/">"#));
3049 assert!(!html.contains(r#"<a href="../1-dawn/" aria-current"#));
3051 }
3052
3053 #[test]
3058 fn index_page_uses_custom_site_title() {
3059 let mut config = SiteConfig::default();
3060 config.site_title = "My Portfolio".to_string();
3061 let manifest = Manifest {
3062 navigation: vec![],
3063 albums: vec![],
3064 pages: vec![],
3065 description: None,
3066 config,
3067 };
3068
3069 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
3070
3071 assert!(html.contains("My Portfolio"));
3072 assert!(!html.contains("Gallery"));
3073 assert!(html.contains("<title>My Portfolio</title>"));
3074 }
3075
3076 #[test]
3077 fn album_page_breadcrumb_uses_custom_site_title() {
3078 let album = create_test_album();
3079 let html = render_album_page(
3080 &album,
3081 &[],
3082 &[],
3083 "",
3084 None,
3085 "My Portfolio",
3086 None,
3087 &no_snippets(),
3088 false,
3089 )
3090 .into_string();
3091
3092 assert!(html.contains("My Portfolio"));
3093 assert!(!html.contains("Gallery"));
3094 }
3095
3096 #[test]
3097 fn image_page_breadcrumb_uses_custom_site_title() {
3098 let album = create_test_album();
3099 let image = &album.images[0];
3100 let html = render_image_page(
3101 &album,
3102 image,
3103 None,
3104 Some(&album.images[1]),
3105 &[],
3106 &[],
3107 "",
3108 None,
3109 "My Portfolio",
3110 None,
3111 &no_snippets(),
3112 false,
3113 )
3114 .into_string();
3115
3116 assert!(html.contains("My Portfolio"));
3117 assert!(!html.contains("Gallery"));
3118 }
3119
3120 #[test]
3121 fn content_page_breadcrumb_uses_custom_site_title() {
3122 let page = Page {
3123 title: "About".to_string(),
3124 link_title: "About".to_string(),
3125 slug: "about".to_string(),
3126 body: "# About\n\nContent.".to_string(),
3127 in_nav: true,
3128 sort_key: 40,
3129 is_link: false,
3130 };
3131 let html = render_page(
3132 &page,
3133 &[],
3134 &[],
3135 "",
3136 None,
3137 "My Portfolio",
3138 None,
3139 &no_snippets(),
3140 false,
3141 )
3142 .into_string();
3143
3144 assert!(html.contains("My Portfolio"));
3145 assert!(!html.contains("Gallery"));
3146 }
3147
3148 #[test]
3149 fn pwa_assets_present() {
3150 let manifest = Manifest {
3151 navigation: vec![],
3152 albums: vec![],
3153 pages: vec![],
3154 description: None,
3155 config: SiteConfig::default(),
3156 };
3157
3158 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
3159
3160 assert!(html.contains(r#"<link rel="manifest" href="/site.webmanifest">"#));
3161 assert!(html.contains(r#"<link rel="apple-touch-icon" href="/apple-touch-icon.png">"#));
3162 assert!(html.contains("navigator.serviceWorker.register('/sw.js');"));
3163 assert!(html.contains("beforeinstallprompt"));
3164 }
3165
3166 #[test]
3171 fn no_custom_css_link_by_default() {
3172 let content = html! { p { "test" } };
3173 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
3174 .into_string();
3175 assert!(!doc.contains("custom.css"));
3176 }
3177
3178 #[test]
3179 fn custom_css_link_injected_when_present() {
3180 let snippets = CustomSnippets {
3181 has_custom_css: true,
3182 ..Default::default()
3183 };
3184 let content = html! { p { "test" } };
3185 let doc =
3186 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3187 assert!(doc.contains(r#"<link rel="stylesheet" href="/custom.css">"#));
3188 }
3189
3190 #[test]
3191 fn custom_css_link_after_main_style() {
3192 let snippets = CustomSnippets {
3193 has_custom_css: true,
3194 ..Default::default()
3195 };
3196 let content = html! { p { "test" } };
3197 let doc = base_document("Test", "body{}", None, None, None, None, &snippets, content)
3198 .into_string();
3199 let style_pos = doc.find("</style>").unwrap();
3200 let link_pos = doc.find(r#"href="/custom.css""#).unwrap();
3201 assert!(
3202 link_pos > style_pos,
3203 "custom.css link should appear after main <style>"
3204 );
3205 }
3206
3207 #[test]
3208 fn head_html_injected_when_present() {
3209 let snippets = CustomSnippets {
3210 head_html: Some(r#"<script>console.log("analytics")</script>"#.to_string()),
3211 ..Default::default()
3212 };
3213 let content = html! { p { "test" } };
3214 let doc =
3215 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3216 assert!(doc.contains(r#"<script>console.log("analytics")</script>"#));
3217 }
3218
3219 #[test]
3220 fn head_html_inside_head_element() {
3221 let snippets = CustomSnippets {
3222 head_html: Some("<!-- custom head -->".to_string()),
3223 ..Default::default()
3224 };
3225 let content = html! { p { "test" } };
3226 let doc =
3227 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3228 let head_end = doc.find("</head>").unwrap();
3229 let snippet_pos = doc.find("<!-- custom head -->").unwrap();
3230 assert!(
3231 snippet_pos < head_end,
3232 "head.html should appear inside <head>"
3233 );
3234 }
3235
3236 #[test]
3237 fn no_head_html_by_default() {
3238 let content = html! { p { "test" } };
3239 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
3240 .into_string();
3241 assert!(!doc.contains("<!-- custom"));
3243 }
3244
3245 #[test]
3246 fn body_end_html_injected_when_present() {
3247 let snippets = CustomSnippets {
3248 body_end_html: Some(r#"<script src="/tracking.js"></script>"#.to_string()),
3249 ..Default::default()
3250 };
3251 let content = html! { p { "test" } };
3252 let doc =
3253 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3254 assert!(doc.contains(r#"<script src="/tracking.js"></script>"#));
3255 }
3256
3257 #[test]
3258 fn body_end_html_inside_body_before_close() {
3259 let snippets = CustomSnippets {
3260 body_end_html: Some("<!-- body end -->".to_string()),
3261 ..Default::default()
3262 };
3263 let content = html! { p { "test" } };
3264 let doc =
3265 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3266 let body_end = doc.find("</body>").unwrap();
3267 let snippet_pos = doc.find("<!-- body end -->").unwrap();
3268 assert!(
3269 snippet_pos < body_end,
3270 "body-end.html should appear before </body>"
3271 );
3272 }
3273
3274 #[test]
3275 fn body_end_html_after_content() {
3276 let snippets = CustomSnippets {
3277 body_end_html: Some("<!-- body end -->".to_string()),
3278 ..Default::default()
3279 };
3280 let content = html! { p { "main content" } };
3281 let doc =
3282 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3283 let content_pos = doc.find("main content").unwrap();
3284 let snippet_pos = doc.find("<!-- body end -->").unwrap();
3285 assert!(
3286 snippet_pos > content_pos,
3287 "body-end.html should appear after main content"
3288 );
3289 }
3290
3291 #[test]
3292 fn all_snippets_injected_together() {
3293 let snippets = CustomSnippets {
3294 has_custom_css: true,
3295 head_html: Some("<!-- head snippet -->".to_string()),
3296 body_end_html: Some("<!-- body snippet -->".to_string()),
3297 };
3298 let content = html! { p { "test" } };
3299 let doc =
3300 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
3301 assert!(doc.contains(r#"href="/custom.css""#));
3302 assert!(doc.contains("<!-- head snippet -->"));
3303 assert!(doc.contains("<!-- body snippet -->"));
3304 }
3305
3306 #[test]
3307 fn snippets_appear_in_all_page_types() {
3308 let snippets = CustomSnippets {
3309 has_custom_css: true,
3310 head_html: Some("<!-- head -->".to_string()),
3311 body_end_html: Some("<!-- body -->".to_string()),
3312 };
3313
3314 let manifest = Manifest {
3316 navigation: vec![],
3317 albums: vec![],
3318 pages: vec![],
3319 description: None,
3320 config: SiteConfig::default(),
3321 };
3322 let html = render_index(&manifest, "", None, None, &snippets).into_string();
3323 assert!(html.contains("custom.css"));
3324 assert!(html.contains("<!-- head -->"));
3325 assert!(html.contains("<!-- body -->"));
3326
3327 let album = create_test_album();
3329 let html = render_album_page(
3330 &album,
3331 &[],
3332 &[],
3333 "",
3334 None,
3335 "Gallery",
3336 None,
3337 &snippets,
3338 false,
3339 )
3340 .into_string();
3341 assert!(html.contains("custom.css"));
3342 assert!(html.contains("<!-- head -->"));
3343 assert!(html.contains("<!-- body -->"));
3344
3345 let page = make_page("about", "About", true, false);
3347 let html =
3348 render_page(&page, &[], &[], "", None, "Gallery", None, &snippets, false).into_string();
3349 assert!(html.contains("custom.css"));
3350 assert!(html.contains("<!-- head -->"));
3351 assert!(html.contains("<!-- body -->"));
3352
3353 let html = render_image_page(
3355 &album,
3356 &album.images[0],
3357 None,
3358 Some(&album.images[1]),
3359 &[],
3360 &[],
3361 "",
3362 None,
3363 "Gallery",
3364 None,
3365 &snippets,
3366 false,
3367 )
3368 .into_string();
3369 assert!(html.contains("custom.css"));
3370 assert!(html.contains("<!-- head -->"));
3371 assert!(html.contains("<!-- body -->"));
3372 }
3373
3374 #[test]
3375 fn detect_custom_snippets_finds_files() {
3376 let tmp = tempfile::TempDir::new().unwrap();
3377
3378 let snippets = detect_custom_snippets(tmp.path());
3380 assert!(!snippets.has_custom_css);
3381 assert!(snippets.head_html.is_none());
3382 assert!(snippets.body_end_html.is_none());
3383
3384 fs::write(tmp.path().join("custom.css"), "body { color: red; }").unwrap();
3386 let snippets = detect_custom_snippets(tmp.path());
3387 assert!(snippets.has_custom_css);
3388 assert!(snippets.head_html.is_none());
3389
3390 fs::write(tmp.path().join("head.html"), "<meta name=\"test\">").unwrap();
3392 let snippets = detect_custom_snippets(tmp.path());
3393 assert!(snippets.has_custom_css);
3394 assert_eq!(snippets.head_html.as_deref(), Some("<meta name=\"test\">"));
3395
3396 fs::write(
3398 tmp.path().join("body-end.html"),
3399 "<script>alert(1)</script>",
3400 )
3401 .unwrap();
3402 let snippets = detect_custom_snippets(tmp.path());
3403 assert!(snippets.has_custom_css);
3404 assert!(snippets.head_html.is_some());
3405 assert_eq!(
3406 snippets.body_end_html.as_deref(),
3407 Some("<script>alert(1)</script>")
3408 );
3409 }
3410
3411 #[test]
3416 fn sizes_attr_landscape_uses_vw() {
3417 let attr = image_sizes_attr(1600.0 / 1200.0, 1400);
3419 assert!(
3420 attr.contains("95vw"),
3421 "desktop should use 95vw for landscape: {attr}"
3422 );
3423 assert!(
3424 attr.contains("1400px"),
3425 "should cap at max generated width: {attr}"
3426 );
3427 }
3428
3429 #[test]
3430 fn sizes_attr_portrait_uses_vh() {
3431 let attr = image_sizes_attr(1200.0 / 1600.0, 600);
3433 assert!(
3434 attr.contains("vh"),
3435 "desktop should use vh for portrait: {attr}"
3436 );
3437 assert!(
3438 attr.contains("67.5vh"),
3439 "should be 90 * 0.75 = 67.5vh: {attr}"
3440 );
3441 assert!(
3442 attr.contains("600px"),
3443 "should cap at max generated width: {attr}"
3444 );
3445 }
3446
3447 #[test]
3448 fn sizes_attr_square_uses_vh() {
3449 let attr = image_sizes_attr(1.0, 2080);
3451 assert!(
3452 attr.contains("vh"),
3453 "square treated as height-constrained: {attr}"
3454 );
3455 assert!(attr.contains("90.0vh"), "should be 90 * 1.0: {attr}");
3456 }
3457
3458 #[test]
3459 fn sizes_attr_mobile_always_100vw() {
3460 for aspect in [0.5, 0.75, 1.0, 1.333, 2.0] {
3461 let attr = image_sizes_attr(aspect, 1400);
3462 assert!(
3463 attr.contains("(max-width: 800px) min(100vw,"),
3464 "mobile should always be 100vw: {attr}"
3465 );
3466 }
3467 }
3468
3469 #[test]
3470 fn sizes_attr_caps_at_max_width() {
3471 let attr = image_sizes_attr(1.5, 900);
3472 assert_eq!(
3474 attr.matches("900px").count(),
3475 2,
3476 "should have px cap in both conditions: {attr}"
3477 );
3478 }
3479
3480 #[test]
3485 fn srcset_uses_actual_width_not_target_for_portrait() {
3486 let album = create_test_album();
3487 let image = &album.images[1]; let nav = vec![];
3489 let html = render_image_page(
3490 &album,
3491 image,
3492 Some(&album.images[0]),
3493 None,
3494 &nav,
3495 &[],
3496 "",
3497 None,
3498 "Gallery",
3499 None,
3500 &no_snippets(),
3501 false,
3502 )
3503 .into_string();
3504
3505 assert!(
3507 html.contains("600w"),
3508 "srcset should use actual width 600, not target 800: {html}"
3509 );
3510 assert!(
3511 !html.contains("800w"),
3512 "srcset must not use target (height) as w descriptor"
3513 );
3514 }
3515
3516 #[test]
3517 fn srcset_uses_actual_width_for_landscape() {
3518 let album = create_test_album();
3519 let image = &album.images[0]; let nav = vec![];
3521 let html = render_image_page(
3522 &album,
3523 image,
3524 None,
3525 Some(&album.images[1]),
3526 &nav,
3527 &[],
3528 "",
3529 None,
3530 "Gallery",
3531 None,
3532 &no_snippets(),
3533 false,
3534 )
3535 .into_string();
3536
3537 assert!(html.contains("800w"));
3538 assert!(html.contains("1400w"));
3539 }
3540}