1use crate::config::{self, SiteConfig};
56use crate::types::{NavItem, Page};
57use maud::{DOCTYPE, Markup, PreEscaped, html};
58use pulldown_cmark::{Parser, html as md_html};
59use serde::Deserialize;
60use std::collections::BTreeMap;
61use std::fs;
62use std::path::Path;
63use thiserror::Error;
64
65#[derive(Error, Debug)]
66pub enum GenerateError {
67 #[error("IO error: {0}")]
68 Io(#[from] std::io::Error),
69 #[error("JSON error: {0}")]
70 Json(#[from] serde_json::Error),
71}
72
73#[derive(Debug, Deserialize)]
75pub struct Manifest {
76 pub navigation: Vec<NavItem>,
77 pub albums: Vec<Album>,
78 #[serde(default)]
79 pub pages: Vec<Page>,
80 #[serde(default)]
81 pub description: Option<String>,
82 pub config: SiteConfig,
83}
84
85#[derive(Debug, Deserialize)]
86pub struct Album {
87 pub path: String,
88 pub title: String,
89 pub description: Option<String>,
90 pub thumbnail: String,
91 pub images: Vec<Image>,
92 pub in_nav: bool,
93 #[allow(dead_code)]
95 pub config: SiteConfig,
96 #[serde(default)]
97 #[allow(dead_code)]
98 pub support_files: Vec<String>,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct Image {
103 pub number: u32,
104 #[allow(dead_code)]
105 pub source_path: String,
106 #[serde(default)]
107 pub title: Option<String>,
108 #[serde(default)]
109 pub description: Option<String>,
110 pub dimensions: (u32, u32),
111 pub generated: BTreeMap<String, GeneratedVariant>,
112 pub thumbnail: String,
113}
114
115#[derive(Debug, Deserialize)]
116pub struct GeneratedVariant {
117 pub avif: String,
118 #[allow(dead_code)]
119 pub width: u32,
120 #[allow(dead_code)]
121 pub height: u32,
122}
123
124const CSS_STATIC: &str = include_str!("../static/style.css");
125const JS: &str = include_str!("../static/nav.js");
126const SW_JS_TEMPLATE: &str = include_str!("../static/sw.js");
127const ICON_192: &[u8] = include_bytes!("../static/icon-192.png");
130const ICON_512: &[u8] = include_bytes!("../static/icon-512.png");
131const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../static/apple-touch-icon.png");
132const FAVICON_PNG: &[u8] = include_bytes!("../static/favicon.png");
133
134const IMAGE_SIZES: &str = "(max-width: 800px) 100vw, 80vw";
135
136#[derive(Debug, Default)]
143struct CustomSnippets {
144 has_custom_css: bool,
146 head_html: Option<String>,
148 body_end_html: Option<String>,
150}
151
152fn detect_custom_snippets(output_dir: &Path) -> CustomSnippets {
156 CustomSnippets {
157 has_custom_css: output_dir.join("custom.css").exists(),
158 head_html: fs::read_to_string(output_dir.join("head.html")).ok(),
159 body_end_html: fs::read_to_string(output_dir.join("body-end.html")).ok(),
160 }
161}
162
163pub(crate) fn index_width(total: usize) -> usize {
165 match total {
166 0..=9 => 1,
167 10..=99 => 2,
168 100..=999 => 3,
169 _ => 4,
170 }
171}
172
173pub(crate) fn image_page_url(position: usize, total: usize, title: Option<&str>) -> String {
182 let width = index_width(total);
183 match title {
184 Some(t) => {
185 let escaped = escape_for_url(t);
186 format!("{:0>width$}-{}/", position, escaped)
187 }
188 None => format!("{:0>width$}/", position),
189 }
190}
191
192fn escape_for_url(title: &str) -> String {
196 let mut result = String::with_capacity(title.len());
197 let mut prev_dash = false;
198 for c in title.chars() {
199 if c == ' ' || c == '.' {
200 if !prev_dash {
201 result.push('-');
202 }
203 prev_dash = true;
204 } else {
205 result.push(c);
206 prev_dash = false;
207 }
208 }
209 result.trim_matches('-').to_string()
210}
211
212const SHORT_CAPTION_MAX_LEN: usize = 160;
213
214fn is_short_caption(text: &str) -> bool {
220 text.len() <= SHORT_CAPTION_MAX_LEN && !text.contains('\n')
221}
222
223pub fn generate(
224 manifest_path: &Path,
225 processed_dir: &Path,
226 output_dir: &Path,
227 source_dir: &Path,
228) -> Result<(), GenerateError> {
229 let manifest_content = fs::read_to_string(manifest_path)?;
230 let manifest: Manifest = serde_json::from_str(&manifest_content)?;
231
232 let font_url = manifest.config.font.stylesheet_url();
253 let color_css = config::generate_color_css(&manifest.config.colors);
254 let theme_css = config::generate_theme_css(&manifest.config.theme);
255 let font_css = config::generate_font_css(&manifest.config.font);
256 let css = format!(
257 "{}\n\n{}\n\n{}\n\n{}",
258 color_css, theme_css, font_css, CSS_STATIC
259 );
260
261 fs::create_dir_all(output_dir)?;
262
263 let manifest_json = serde_json::json!({
277 "name": manifest.config.site_title,
278 "short_name": manifest.config.site_title,
279 "icons": [
280 {
281 "src": "/icon-192.png",
282 "sizes": "192x192",
283 "type": "image/png"
284 },
285 {
286 "src": "/icon-512.png",
287 "sizes": "512x512",
288 "type": "image/png"
289 }
290 ],
291 "theme_color": "#ffffff",
292 "background_color": "#ffffff",
293 "display": "standalone",
294 "scope": "/",
295 "start_url": "/"
296 });
297 fs::write(
298 output_dir.join("site.webmanifest"),
299 serde_json::to_string_pretty(&manifest_json)?,
300 )?;
301
302 let version = env!("CARGO_PKG_VERSION");
305 let sw_content = SW_JS_TEMPLATE.replace(
306 "const CACHE_NAME = 'simple-gal-v1';",
307 &format!("const CACHE_NAME = 'simple-gal-v{}';", version),
308 );
309 fs::write(output_dir.join("sw.js"), sw_content)?;
310
311 fs::write(output_dir.join("icon-192.png"), ICON_192)?;
312 fs::write(output_dir.join("icon-512.png"), ICON_512)?;
313 fs::write(output_dir.join("apple-touch-icon.png"), APPLE_TOUCH_ICON)?;
314 fs::write(output_dir.join("favicon.png"), FAVICON_PNG)?;
315
316 let assets_path = source_dir.join(&manifest.config.assets_dir);
318 if assets_path.is_dir() {
319 copy_dir_recursive(&assets_path, output_dir)?;
320 }
321
322 copy_dir_recursive(processed_dir, output_dir)?;
324
325 let favicon_href = detect_favicon(output_dir);
327
328 let snippets = detect_custom_snippets(output_dir);
330
331 let index_html = render_index(
333 &manifest,
334 &css,
335 font_url.as_deref(),
336 favicon_href.as_deref(),
337 &snippets,
338 );
339 fs::write(output_dir.join("index.html"), index_html.into_string())?;
340
341 let offline_html = render_offline(&css, favicon_href.as_deref(), &snippets);
343 fs::write(output_dir.join("offline.html"), offline_html.into_string())?;
344
345 for page in manifest.pages.iter().filter(|p| !p.is_link) {
347 let page_html = render_page(
348 page,
349 &manifest.navigation,
350 &manifest.pages,
351 &css,
352 font_url.as_deref(),
353 &manifest.config.site_title,
354 favicon_href.as_deref(),
355 &snippets,
356 );
357 let filename = format!("{}.html", page.slug);
358 fs::write(output_dir.join(&filename), page_html.into_string())?;
359 }
360
361 for album in &manifest.albums {
363 let album_dir = output_dir.join(&album.path);
364 fs::create_dir_all(&album_dir)?;
365
366 let album_html = render_album_page(
367 album,
368 &manifest.navigation,
369 &manifest.pages,
370 &css,
371 font_url.as_deref(),
372 &manifest.config.site_title,
373 favicon_href.as_deref(),
374 &snippets,
375 );
376 fs::write(album_dir.join("index.html"), album_html.into_string())?;
377
378 for (idx, image) in album.images.iter().enumerate() {
380 let prev = if idx > 0 {
381 Some(&album.images[idx - 1])
382 } else {
383 None
384 };
385 let next = album.images.get(idx + 1);
386
387 let image_html = render_image_page(
388 album,
389 image,
390 prev,
391 next,
392 &manifest.navigation,
393 &manifest.pages,
394 &css,
395 font_url.as_deref(),
396 &manifest.config.site_title,
397 favicon_href.as_deref(),
398 &snippets,
399 );
400 let image_dir_name =
401 image_page_url(idx + 1, album.images.len(), image.title.as_deref());
402 let image_dir = album_dir.join(&image_dir_name);
403 fs::create_dir_all(&image_dir)?;
404 fs::write(image_dir.join("index.html"), image_html.into_string())?;
405 }
406 }
407
408 Ok(())
409}
410
411fn detect_favicon(output_dir: &Path) -> Option<String> {
413 for (filename, _mime) in &[
414 ("favicon.svg", "image/svg+xml"),
415 ("favicon.ico", "image/x-icon"),
416 ("favicon.png", "image/png"),
417 ] {
418 if output_dir.join(filename).exists() {
419 return Some(format!("/{}", filename));
420 }
421 }
422 None
423}
424
425fn favicon_type(href: &str) -> &'static str {
427 if href.ends_with(".svg") {
428 "image/svg+xml"
429 } else if href.ends_with(".png") {
430 "image/png"
431 } else {
432 "image/x-icon"
433 }
434}
435
436fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
437 for entry in fs::read_dir(src)? {
438 let entry = entry?;
439 let src_path = entry.path();
440 let dst_path = dst.join(entry.file_name());
441
442 if src_path.is_dir() {
443 fs::create_dir_all(&dst_path)?;
444 copy_dir_recursive(&src_path, &dst_path)?;
445 } else if src_path.extension().map(|e| e != "json").unwrap_or(true) {
446 fs::copy(&src_path, &dst_path)?;
448 }
449 }
450 Ok(())
451}
452
453#[allow(clippy::too_many_arguments)]
464fn base_document(
465 title: &str,
466 css: &str,
467 font_url: Option<&str>,
468 body_class: Option<&str>,
469 head_extra: Option<Markup>,
470 favicon_href: Option<&str>,
471 snippets: &CustomSnippets,
472 content: Markup,
473) -> Markup {
474 html! {
475 (DOCTYPE)
476 html lang="en" {
477 head {
478 meta charset="UTF-8";
479 meta name="viewport" content="width=device-width, initial-scale=1.0";
480 title { (title) }
481 link rel="manifest" href="/site.webmanifest";
483 link rel="apple-touch-icon" href="/apple-touch-icon.png";
484 @if let Some(href) = favicon_href {
485 link rel="icon" type=(favicon_type(href)) href=(href);
486 }
487 @if let Some(url) = font_url {
489 link rel="preconnect" href="https://fonts.googleapis.com";
490 link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="";
491 link rel="stylesheet" href=(url);
492 }
493 style { (PreEscaped(css)) }
494 @if snippets.has_custom_css {
496 link rel="stylesheet" href="/custom.css";
497 }
498 @if let Some(extra) = head_extra {
499 (extra)
500 }
501 script {
502 (PreEscaped(r#"
503 if ('serviceWorker' in navigator && location.protocol !== 'file:') {
504 window.addEventListener('load', () => {
505 navigator.serviceWorker.register('/sw.js');
506 });
507 }
508 window.addEventListener('beforeinstallprompt', e => e.preventDefault());
509 "#))
510 }
511 @if let Some(ref html) = snippets.head_html {
512 (PreEscaped(html))
513 }
514 }
515 body class=[body_class] {
516 (content)
517 @if let Some(ref html) = snippets.body_end_html {
518 (PreEscaped(html))
519 }
520 }
521 }
522 }
523}
524
525fn site_header(breadcrumb: Markup, nav: Markup) -> Markup {
527 html! {
528 header.site-header {
529 nav.breadcrumb {
530 (breadcrumb)
531 }
532 nav.site-nav {
533 (nav)
534 }
535 }
536 }
537}
538
539pub fn render_nav(items: &[NavItem], current_path: &str, pages: &[Page]) -> Markup {
544 let nav_pages: Vec<&Page> = pages.iter().filter(|p| p.in_nav).collect();
545
546 html! {
547 input.nav-toggle type="checkbox" id="nav-toggle";
548 label.nav-hamburger for="nav-toggle" {
549 span.hamburger-line {}
550 span.hamburger-line {}
551 span.hamburger-line {}
552 }
553 div.nav-panel {
554 label.nav-close for="nav-toggle" { "×" }
555 ul {
556 @for item in items {
557 (render_nav_item(item, current_path))
558 }
559 @if !nav_pages.is_empty() {
560 li.nav-separator role="separator" {}
561 @for page in &nav_pages {
562 @if page.is_link {
563 li {
564 a href=(page.body.trim()) target="_blank" rel="noopener" {
565 (page.link_title)
566 }
567 }
568 } @else {
569 @let is_current = current_path == page.slug;
570 li class=[is_current.then_some("current")] {
571 a href={ "/" (page.slug) ".html" } { (page.link_title) }
572 }
573 }
574 }
575 }
576 }
577 }
578 }
579}
580
581fn render_nav_item(item: &NavItem, current_path: &str) -> Markup {
583 let is_current =
584 item.path == current_path || current_path.starts_with(&format!("{}/", item.path));
585
586 html! {
587 li class=[is_current.then_some("current")] {
588 @if item.children.is_empty() {
589 a href={ "/" (item.path) "/" } { (item.title) }
590 } @else {
591 span.nav-group { (item.title) }
592 ul {
593 @for child in &item.children {
594 (render_nav_item(child, current_path))
595 }
596 }
597 }
598 }
599 }
600}
601
602fn render_index(
608 manifest: &Manifest,
609 css: &str,
610 font_url: Option<&str>,
611 favicon_href: Option<&str>,
612 snippets: &CustomSnippets,
613) -> Markup {
614 let nav = render_nav(&manifest.navigation, "", &manifest.pages);
615
616 let breadcrumb = html! {
617 a href="/" { (&manifest.config.site_title) }
618 };
619
620 let main_class = match manifest.description {
621 Some(_) => "index-page has-description",
622 None => "index-page",
623 };
624 let content = html! {
625 (site_header(breadcrumb, nav))
626 main class=(main_class) {
627 @if let Some(desc) = &manifest.description {
628 header.index-header {
629 h1 { (&manifest.config.site_title) }
630 input.desc-toggle type="checkbox" id="desc-toggle";
631 div.album-description { (PreEscaped(desc)) }
632 label.desc-expand for="desc-toggle" {
633 span.expand-more { "Read more" }
634 span.expand-less { "Show less" }
635 }
636 }
637 }
638 div.album-grid {
639 @for album in manifest.albums.iter().filter(|a| a.in_nav) {
640 a.album-card href={ (album.path) "/" } {
641 img src=(album.thumbnail) alt=(album.title) loading="lazy";
642 span.album-title { (album.title) }
643 }
644 }
645 }
646 }
647 };
648
649 base_document(
650 &manifest.config.site_title,
651 css,
652 font_url,
653 None,
654 None,
655 favicon_href,
656 snippets,
657 content,
658 )
659}
660
661#[allow(clippy::too_many_arguments)]
663fn render_album_page(
664 album: &Album,
665 navigation: &[NavItem],
666 pages: &[Page],
667 css: &str,
668 font_url: Option<&str>,
669 site_title: &str,
670 favicon_href: Option<&str>,
671 snippets: &CustomSnippets,
672) -> Markup {
673 let nav = render_nav(navigation, &album.path, pages);
674
675 let breadcrumb = html! {
676 a href="/" { (site_title) }
677 " › "
678 (album.title)
679 };
680
681 let strip_prefix = |path: &str| -> String {
683 path.strip_prefix(&album.path)
684 .and_then(|p| p.strip_prefix('/'))
685 .unwrap_or(path)
686 .to_string()
687 };
688
689 let content = html! {
690 (site_header(breadcrumb, nav))
691 main.album-page {
692 header.album-header {
693 h1 { (album.title) }
694 @if let Some(desc) = &album.description {
695 input.desc-toggle type="checkbox" id="desc-toggle";
696 div.album-description { (PreEscaped(desc)) }
697 label.desc-expand for="desc-toggle" {
698 span.expand-more { "Read more" }
699 span.expand-less { "Show less" }
700 }
701 }
702 }
703 div.thumbnail-grid {
704 @for (idx, image) in album.images.iter().enumerate() {
705 a.thumb-link href=(image_page_url(idx + 1, album.images.len(), image.title.as_deref())) {
706 img src=(strip_prefix(&image.thumbnail)) alt={ "Image " (idx + 1) } loading="lazy";
707 }
708 }
709 }
710 }
711 };
712
713 base_document(
714 &album.title,
715 css,
716 font_url,
717 None,
718 None,
719 favicon_href,
720 snippets,
721 content,
722 )
723}
724
725fn format_image_label(position: usize, total: usize, title: Option<&str>) -> String {
739 let width = index_width(total);
740 match title {
741 Some(t) => format!("{:0>width$}. {}", position, t),
742 None => format!("{:0>width$}", position),
743 }
744}
745
746#[allow(clippy::too_many_arguments)]
748fn render_image_page(
749 album: &Album,
750 image: &Image,
751 prev: Option<&Image>,
752 next: Option<&Image>,
753 navigation: &[NavItem],
754 pages: &[Page],
755 css: &str,
756 font_url: Option<&str>,
757 site_title: &str,
758 favicon_href: Option<&str>,
759 snippets: &CustomSnippets,
760) -> Markup {
761 let nav = render_nav(navigation, &album.path, pages);
762
763 let strip_prefix = |path: &str| -> String {
765 let relative = path
766 .strip_prefix(&album.path)
767 .and_then(|p| p.strip_prefix('/'))
768 .unwrap_or(path);
769 format!("../{}", relative)
770 };
771
772 let avif_srcset_for = |img: &Image| -> String {
774 img.generated
775 .iter()
776 .map(|(size, variant)| format!("{} {}w", strip_prefix(&variant.avif), size))
777 .collect::<Vec<_>>()
778 .join(", ")
779 };
780
781 let sizes: Vec<_> = image.generated.iter().collect();
783
784 let srcset_avif: String = avif_srcset_for(image);
785
786 let default_src = sizes
788 .get(sizes.len() / 2)
789 .map(|(_, v)| strip_prefix(&v.avif))
790 .unwrap_or_default();
791
792 let mid_avif = |img: &Image| -> String {
794 let sizes: Vec<_> = img.generated.iter().collect();
795 sizes
796 .get(sizes.len() / 2)
797 .map(|(_, v)| strip_prefix(&v.avif))
798 .unwrap_or_default()
799 };
800 let prev_prefetch = prev.map(&mid_avif);
801 let next_prefetch = next.map(&mid_avif);
802
803 let (width, height) = image.dimensions;
805 let aspect_ratio = width as f64 / height as f64;
806
807 let image_idx = album
809 .images
810 .iter()
811 .position(|i| i.number == image.number)
812 .unwrap();
813
814 let total = album.images.len();
815 let prev_url = match prev {
816 Some(p) => format!(
817 "../{}",
818 image_page_url(image_idx, total, p.title.as_deref())
819 ), None => "../".to_string(),
821 };
822
823 let next_url = match next {
824 Some(n) => format!(
825 "../{}",
826 image_page_url(image_idx + 2, total, n.title.as_deref())
827 ),
828 None => "../".to_string(),
829 };
830
831 let display_idx = image_idx + 1;
832 let image_label = format_image_label(display_idx, album.images.len(), image.title.as_deref());
833 let page_title = format!("{} - {}", album.title, image_label);
834
835 let breadcrumb = html! {
836 a href="/" { (site_title) }
837 " › "
838 a href="../" { (album.title) }
839 " › "
840 (image_label)
841 };
842
843 let aspect_style = format!("--aspect-ratio: {};", aspect_ratio);
844 let alt_text = match &image.title {
845 Some(t) => format!("{} - {}", album.title, t),
846 None => format!("{} - Image {}", album.title, display_idx),
847 };
848
849 let nav_dots: Vec<String> = album
851 .images
852 .iter()
853 .enumerate()
854 .map(|(idx, img)| {
855 format!(
856 "../{}",
857 image_page_url(idx + 1, total, img.title.as_deref())
858 )
859 })
860 .collect();
861
862 let description = image.description.as_deref().filter(|d| !d.is_empty());
863 let caption_text = description.filter(|d| is_short_caption(d));
864 let description_text = description.filter(|d| !is_short_caption(d));
865
866 let body_class = match description {
867 Some(desc) if is_short_caption(desc) => "image-view has-caption",
868 Some(_) => "image-view has-description",
869 None => "image-view",
870 };
871
872 let head_extra = html! {
874 link rel="expect" href="#main-image" blocking="render";
875 @if let Some(ref href) = prev_prefetch {
876 link rel="prefetch" as="image" href=(href);
877 }
878 @if let Some(ref href) = next_prefetch {
879 link rel="prefetch" as="image" href=(href);
880 }
881 };
882
883 let content = html! {
884 (site_header(breadcrumb, nav))
885 main style=(aspect_style) {
886 div.image-page {
887 figure.image-frame {
888 img #main-image src=(default_src) srcset=(srcset_avif) sizes=(IMAGE_SIZES) alt=(alt_text);
889 }
890 p.print-credit {
891 (album.title) " › " (image_label)
892 }
893 @if let Some(text) = caption_text {
894 p.image-caption { (text) }
895 }
896 }
897 @if let Some(text) = description_text {
898 div.image-description {
899 p { (text) }
900 }
901 }
902 nav.image-nav {
903 @for (idx, url) in nav_dots.iter().enumerate() {
904 @if idx == image_idx {
905 a href=(url) aria-current="true" {}
906 } @else {
907 a href=(url) {}
908 }
909 }
910 }
911 a.nav-prev href=(prev_url) aria-label="Previous image" {}
912 a.nav-next href=(next_url) aria-label="Next image" {}
913 }
914 script { (PreEscaped(JS)) }
915 };
916
917 base_document(
918 &page_title,
919 css,
920 font_url,
921 Some(body_class),
922 Some(head_extra),
923 favicon_href,
924 snippets,
925 content,
926 )
927}
928
929#[allow(clippy::too_many_arguments)]
931fn render_page(
932 page: &Page,
933 navigation: &[NavItem],
934 pages: &[Page],
935 css: &str,
936 font_url: Option<&str>,
937 site_title: &str,
938 favicon_href: Option<&str>,
939 snippets: &CustomSnippets,
940) -> Markup {
941 let nav = render_nav(navigation, &page.slug, pages);
942
943 let parser = Parser::new(&page.body);
945 let mut body_html = String::new();
946 md_html::push_html(&mut body_html, parser);
947
948 let breadcrumb = html! {
949 a href="/" { (site_title) }
950 " › "
951 (page.title)
952 };
953
954 let content = html! {
955 (site_header(breadcrumb, nav))
956 main.page {
957 article.page-content {
958 (PreEscaped(body_html))
959 }
960 }
961 };
962
963 base_document(
964 &page.title,
965 css,
966 font_url,
967 None,
968 None,
969 favicon_href,
970 snippets,
971 content,
972 )
973}
974
975fn render_offline(css: &str, favicon_href: Option<&str>, snippets: &CustomSnippets) -> Markup {
982 let content = html! {
983 main.page {
984 article.page-content {
985 h1 { "You're offline" }
986 p { "This page hasn't been cached yet. Connect to the internet or go back to a page you've already visited." }
987 p { a href="/" { "Go to the gallery" } }
988 }
989 }
990 };
991
992 base_document(
993 "Offline",
994 css,
995 None,
996 None,
997 None,
998 favicon_href,
999 snippets,
1000 content,
1001 )
1002}
1003
1004#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 fn no_snippets() -> CustomSnippets {
1013 CustomSnippets::default()
1014 }
1015
1016 fn make_page(slug: &str, link_title: &str, in_nav: bool, is_link: bool) -> Page {
1017 Page {
1018 title: link_title.to_string(),
1019 link_title: link_title.to_string(),
1020 slug: slug.to_string(),
1021 body: if is_link {
1022 "https://example.com".to_string()
1023 } else {
1024 format!("# {}\n\nContent.", link_title)
1025 },
1026 in_nav,
1027 sort_key: if in_nav { 40 } else { u32::MAX },
1028 is_link,
1029 }
1030 }
1031
1032 #[test]
1033 fn nav_renders_items() {
1034 let items = vec![NavItem {
1035 title: "Album One".to_string(),
1036 path: "010-one".to_string(),
1037 source_dir: String::new(),
1038 children: vec![],
1039 }];
1040 let html = render_nav(&items, "", &[]).into_string();
1041 assert!(html.contains("Album One"));
1042 assert!(html.contains("/010-one/"));
1043 }
1044
1045 #[test]
1046 fn nav_includes_pages() {
1047 let pages = vec![make_page("about", "About", true, false)];
1048 let html = render_nav(&[], "", &pages).into_string();
1049 assert!(html.contains("About"));
1050 assert!(html.contains("/about.html"));
1051 }
1052
1053 #[test]
1054 fn nav_hides_unnumbered_pages() {
1055 let pages = vec![make_page("notes", "Notes", false, false)];
1056 let html = render_nav(&[], "", &pages).into_string();
1057 assert!(!html.contains("Notes"));
1058 assert!(!html.contains("nav-separator"));
1060 }
1061
1062 #[test]
1063 fn nav_renders_link_page_as_external() {
1064 let pages = vec![make_page("github", "GitHub", true, true)];
1065 let html = render_nav(&[], "", &pages).into_string();
1066 assert!(html.contains("GitHub"));
1067 assert!(html.contains("https://example.com"));
1068 assert!(html.contains("target=\"_blank\""));
1069 }
1070
1071 #[test]
1072 fn nav_marks_current_item() {
1073 let items = vec![
1074 NavItem {
1075 title: "First".to_string(),
1076 path: "010-first".to_string(),
1077 source_dir: String::new(),
1078 children: vec![],
1079 },
1080 NavItem {
1081 title: "Second".to_string(),
1082 path: "020-second".to_string(),
1083 source_dir: String::new(),
1084 children: vec![],
1085 },
1086 ];
1087 let html = render_nav(&items, "020-second", &[]).into_string();
1088 assert!(html.contains(r#"class="current"#));
1090 }
1091
1092 #[test]
1093 fn nav_marks_current_page() {
1094 let pages = vec![make_page("about", "About", true, false)];
1095 let html = render_nav(&[], "about", &pages).into_string();
1096 assert!(html.contains(r#"class="current"#));
1097 }
1098
1099 #[test]
1100 fn nav_renders_nested_children() {
1101 let items = vec![NavItem {
1102 title: "Parent".to_string(),
1103 path: "010-parent".to_string(),
1104 source_dir: String::new(),
1105 children: vec![NavItem {
1106 title: "Child".to_string(),
1107 path: "010-parent/010-child".to_string(),
1108 source_dir: String::new(),
1109 children: vec![],
1110 }],
1111 }];
1112 let html = render_nav(&items, "", &[]).into_string();
1113 assert!(html.contains("Parent"));
1114 assert!(html.contains("Child"));
1115 assert!(html.contains("nav-group")); }
1117
1118 #[test]
1119 fn nav_separator_only_when_pages() {
1120 let html_no_pages = render_nav(&[], "", &[]).into_string();
1122 assert!(!html_no_pages.contains("nav-separator"));
1123
1124 let pages = vec![make_page("about", "About", true, false)];
1126 let html_with_pages = render_nav(&[], "", &pages).into_string();
1127 assert!(html_with_pages.contains("nav-separator"));
1128 }
1129
1130 #[test]
1131 fn base_document_includes_doctype() {
1132 let content = html! { p { "test" } };
1133 let doc = base_document(
1134 "Test",
1135 "body {}",
1136 None,
1137 None,
1138 None,
1139 None,
1140 &no_snippets(),
1141 content,
1142 )
1143 .into_string();
1144 assert!(doc.starts_with("<!DOCTYPE html>"));
1145 }
1146
1147 #[test]
1148 fn base_document_applies_body_class() {
1149 let content = html! { p { "test" } };
1150 let doc = base_document(
1151 "Test",
1152 "",
1153 None,
1154 Some("image-view"),
1155 None,
1156 None,
1157 &no_snippets(),
1158 content,
1159 )
1160 .into_string();
1161 assert!(html_contains_body_class(&doc, "image-view"));
1162 }
1163
1164 #[test]
1165 fn site_header_structure() {
1166 let breadcrumb = html! { a href="/" { "Home" } };
1167 let nav = html! { ul { li { "Item" } } };
1168 let header = site_header(breadcrumb, nav).into_string();
1169
1170 assert!(header.contains("site-header"));
1171 assert!(header.contains("breadcrumb"));
1172 assert!(header.contains("site-nav"));
1173 assert!(header.contains("Home"));
1174 }
1175
1176 fn html_contains_body_class(html: &str, class: &str) -> bool {
1178 html.contains(&format!(r#"class="{}""#, class))
1180 }
1181
1182 fn create_test_album() -> Album {
1187 Album {
1188 path: "test".to_string(),
1189 title: "Test Album".to_string(),
1190 description: Some("<p>A test album description</p>".to_string()),
1191 thumbnail: "test/001-image-thumb.avif".to_string(),
1192 images: vec![
1193 Image {
1194 number: 1,
1195 source_path: "test/001-dawn.jpg".to_string(),
1196 title: Some("Dawn".to_string()),
1197 description: None,
1198 dimensions: (1600, 1200),
1199 generated: {
1200 let mut map = BTreeMap::new();
1201 map.insert(
1202 "800".to_string(),
1203 GeneratedVariant {
1204 avif: "test/001-dawn-800.avif".to_string(),
1205 width: 800,
1206 height: 600,
1207 },
1208 );
1209 map.insert(
1210 "1400".to_string(),
1211 GeneratedVariant {
1212 avif: "test/001-dawn-1400.avif".to_string(),
1213 width: 1400,
1214 height: 1050,
1215 },
1216 );
1217 map
1218 },
1219 thumbnail: "test/001-dawn-thumb.avif".to_string(),
1220 },
1221 Image {
1222 number: 2,
1223 source_path: "test/002-night.jpg".to_string(),
1224 title: None,
1225 description: None,
1226 dimensions: (1200, 1600),
1227 generated: {
1228 let mut map = BTreeMap::new();
1229 map.insert(
1230 "800".to_string(),
1231 GeneratedVariant {
1232 avif: "test/002-night-800.avif".to_string(),
1233 width: 600,
1234 height: 800,
1235 },
1236 );
1237 map
1238 },
1239 thumbnail: "test/002-night-thumb.avif".to_string(),
1240 },
1241 ],
1242 in_nav: true,
1243 config: SiteConfig::default(),
1244 support_files: vec![],
1245 }
1246 }
1247
1248 #[test]
1249 fn render_album_page_includes_title() {
1250 let album = create_test_album();
1251 let nav = vec![];
1252 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1253 .into_string();
1254
1255 assert!(html.contains("Test Album"));
1256 assert!(html.contains("<h1>"));
1257 }
1258
1259 #[test]
1260 fn render_album_page_includes_description() {
1261 let album = create_test_album();
1262 let nav = vec![];
1263 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1264 .into_string();
1265
1266 assert!(html.contains("A test album description"));
1267 assert!(html.contains("album-description"));
1268 }
1269
1270 #[test]
1271 fn render_album_page_thumbnail_links() {
1272 let album = create_test_album();
1273 let nav = vec![];
1274 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1275 .into_string();
1276
1277 assert!(html.contains("1-Dawn/"));
1279 assert!(html.contains("2/"));
1280 assert!(html.contains("001-dawn-thumb.avif"));
1282 }
1283
1284 #[test]
1285 fn render_album_page_breadcrumb() {
1286 let album = create_test_album();
1287 let nav = vec![];
1288 let html = render_album_page(&album, &nav, &[], "", None, "Gallery", None, &no_snippets())
1289 .into_string();
1290
1291 assert!(html.contains(r#"href="/""#));
1293 assert!(html.contains("Gallery"));
1294 }
1295
1296 #[test]
1297 fn render_image_page_includes_img_with_srcset() {
1298 let album = create_test_album();
1299 let image = &album.images[0];
1300 let nav = vec![];
1301 let html = render_image_page(
1302 &album,
1303 image,
1304 None,
1305 Some(&album.images[1]),
1306 &nav,
1307 &[],
1308 "",
1309 None,
1310 "Gallery",
1311 None,
1312 &no_snippets(),
1313 )
1314 .into_string();
1315
1316 assert!(html.contains("<img"));
1317 assert!(html.contains("srcset="));
1318 assert!(html.contains(".avif"));
1319 assert!(!html.contains("<picture>"));
1320 }
1321
1322 #[test]
1323 fn render_image_page_srcset() {
1324 let album = create_test_album();
1325 let image = &album.images[0];
1326 let nav = vec![];
1327 let html = render_image_page(
1328 &album,
1329 image,
1330 None,
1331 Some(&album.images[1]),
1332 &nav,
1333 &[],
1334 "",
1335 None,
1336 "Gallery",
1337 None,
1338 &no_snippets(),
1339 )
1340 .into_string();
1341
1342 assert!(html.contains("srcset="));
1344 assert!(html.contains("800w"));
1345 assert!(html.contains("1400w"));
1346 }
1347
1348 #[test]
1349 fn render_image_page_nav_links() {
1350 let album = create_test_album();
1351 let image = &album.images[0];
1352 let nav = vec![];
1353 let html = render_image_page(
1354 &album,
1355 image,
1356 None,
1357 Some(&album.images[1]),
1358 &nav,
1359 &[],
1360 "",
1361 None,
1362 "Gallery",
1363 None,
1364 &no_snippets(),
1365 )
1366 .into_string();
1367
1368 assert!(html.contains("nav-prev"));
1369 assert!(html.contains("nav-next"));
1370 assert!(html.contains(r#"aria-label="Previous image""#));
1371 assert!(html.contains(r#"aria-label="Next image""#));
1372 }
1373
1374 #[test]
1375 fn render_image_page_prev_next_urls() {
1376 let album = create_test_album();
1377 let nav = vec![];
1378
1379 let html1 = render_image_page(
1381 &album,
1382 &album.images[0],
1383 None,
1384 Some(&album.images[1]),
1385 &nav,
1386 &[],
1387 "",
1388 None,
1389 "Gallery",
1390 None,
1391 &no_snippets(),
1392 )
1393 .into_string();
1394 assert!(html1.contains(r#"class="nav-prev" href="../""#));
1395 assert!(html1.contains(r#"class="nav-next" href="../2/""#));
1396
1397 let html2 = render_image_page(
1399 &album,
1400 &album.images[1],
1401 Some(&album.images[0]),
1402 None,
1403 &nav,
1404 &[],
1405 "",
1406 None,
1407 "Gallery",
1408 None,
1409 &no_snippets(),
1410 )
1411 .into_string();
1412 assert!(html2.contains(r#"class="nav-prev" href="../1-Dawn/""#));
1413 assert!(html2.contains(r#"class="nav-next" href="../""#));
1414 }
1415
1416 #[test]
1417 fn render_image_page_aspect_ratio() {
1418 let album = create_test_album();
1419 let image = &album.images[0]; let nav = vec![];
1421 let html = render_image_page(
1422 &album,
1423 image,
1424 None,
1425 None,
1426 &nav,
1427 &[],
1428 "",
1429 None,
1430 "Gallery",
1431 None,
1432 &no_snippets(),
1433 )
1434 .into_string();
1435
1436 assert!(html.contains("--aspect-ratio:"));
1438 }
1439
1440 #[test]
1441 fn render_page_converts_markdown() {
1442 let page = Page {
1443 title: "About Me".to_string(),
1444 link_title: "about".to_string(),
1445 slug: "about".to_string(),
1446 body: "# About Me\n\nThis is **bold** and *italic*.".to_string(),
1447 in_nav: true,
1448 sort_key: 40,
1449 is_link: false,
1450 };
1451 let html =
1452 render_page(&page, &[], &[], "", None, "Gallery", None, &no_snippets()).into_string();
1453
1454 assert!(html.contains("<strong>bold</strong>"));
1456 assert!(html.contains("<em>italic</em>"));
1457 }
1458
1459 #[test]
1460 fn render_page_includes_title() {
1461 let page = Page {
1462 title: "About Me".to_string(),
1463 link_title: "about me".to_string(),
1464 slug: "about".to_string(),
1465 body: "Content here".to_string(),
1466 in_nav: true,
1467 sort_key: 40,
1468 is_link: false,
1469 };
1470 let html =
1471 render_page(&page, &[], &[], "", None, "Gallery", None, &no_snippets()).into_string();
1472
1473 assert!(html.contains("<title>About Me</title>"));
1474 assert!(html.contains("class=\"page\""));
1475 }
1476
1477 #[test]
1482 fn format_label_with_title() {
1483 assert_eq!(format_image_label(1, 5, Some("Museum")), "1. Museum");
1484 }
1485
1486 #[test]
1487 fn format_label_without_title() {
1488 assert_eq!(format_image_label(1, 5, None), "1");
1489 }
1490
1491 #[test]
1492 fn format_label_zero_pads_for_10_plus() {
1493 assert_eq!(format_image_label(3, 15, Some("Dawn")), "03. Dawn");
1494 assert_eq!(format_image_label(3, 15, None), "03");
1495 }
1496
1497 #[test]
1498 fn format_label_zero_pads_for_100_plus() {
1499 assert_eq!(format_image_label(7, 120, Some("X")), "007. X");
1500 assert_eq!(format_image_label(7, 120, None), "007");
1501 }
1502
1503 #[test]
1504 fn format_label_no_padding_under_10() {
1505 assert_eq!(format_image_label(3, 9, Some("Y")), "3. Y");
1506 }
1507
1508 #[test]
1509 fn image_breadcrumb_includes_title() {
1510 let album = create_test_album();
1511 let image = &album.images[0]; let nav = vec![];
1513 let html = render_image_page(
1514 &album,
1515 image,
1516 None,
1517 Some(&album.images[1]),
1518 &nav,
1519 &[],
1520 "",
1521 None,
1522 "Gallery",
1523 None,
1524 &no_snippets(),
1525 )
1526 .into_string();
1527
1528 assert!(html.contains("1. Dawn"));
1530 assert!(html.contains("Test Album"));
1531 }
1532
1533 #[test]
1534 fn image_breadcrumb_without_title() {
1535 let album = create_test_album();
1536 let image = &album.images[1]; let nav = vec![];
1538 let html = render_image_page(
1539 &album,
1540 image,
1541 Some(&album.images[0]),
1542 None,
1543 &nav,
1544 &[],
1545 "",
1546 None,
1547 "Gallery",
1548 None,
1549 &no_snippets(),
1550 )
1551 .into_string();
1552
1553 assert!(html.contains("Test Album"));
1555 assert!(html.contains(" › 2<"));
1557 }
1558
1559 #[test]
1560 fn image_page_title_includes_label() {
1561 let album = create_test_album();
1562 let image = &album.images[0];
1563 let nav = vec![];
1564 let html = render_image_page(
1565 &album,
1566 image,
1567 None,
1568 Some(&album.images[1]),
1569 &nav,
1570 &[],
1571 "",
1572 None,
1573 "Gallery",
1574 None,
1575 &no_snippets(),
1576 )
1577 .into_string();
1578
1579 assert!(html.contains("<title>Test Album - 1. Dawn</title>"));
1580 }
1581
1582 #[test]
1583 fn image_alt_text_uses_title() {
1584 let album = create_test_album();
1585 let image = &album.images[0]; let nav = vec![];
1587 let html = render_image_page(
1588 &album,
1589 image,
1590 None,
1591 Some(&album.images[1]),
1592 &nav,
1593 &[],
1594 "",
1595 None,
1596 "Gallery",
1597 None,
1598 &no_snippets(),
1599 )
1600 .into_string();
1601
1602 assert!(html.contains("Test Album - Dawn"));
1603 }
1604
1605 #[test]
1610 fn is_short_caption_short_text() {
1611 assert!(is_short_caption("A beautiful sunset"));
1612 }
1613
1614 #[test]
1615 fn is_short_caption_exactly_at_limit() {
1616 let text = "a".repeat(SHORT_CAPTION_MAX_LEN);
1617 assert!(is_short_caption(&text));
1618 }
1619
1620 #[test]
1621 fn is_short_caption_over_limit() {
1622 let text = "a".repeat(SHORT_CAPTION_MAX_LEN + 1);
1623 assert!(!is_short_caption(&text));
1624 }
1625
1626 #[test]
1627 fn is_short_caption_with_newline() {
1628 assert!(!is_short_caption("Line one\nLine two"));
1629 }
1630
1631 #[test]
1632 fn is_short_caption_empty_string() {
1633 assert!(is_short_caption(""));
1634 }
1635
1636 #[test]
1637 fn render_image_page_short_caption() {
1638 let mut album = create_test_album();
1639 album.images[0].description = Some("A beautiful sunrise over the mountains".to_string());
1640 let image = &album.images[0];
1641 let html = render_image_page(
1642 &album,
1643 image,
1644 None,
1645 Some(&album.images[1]),
1646 &[],
1647 &[],
1648 "",
1649 None,
1650 "Gallery",
1651 None,
1652 &no_snippets(),
1653 )
1654 .into_string();
1655
1656 assert!(html.contains("image-caption"));
1657 assert!(html.contains("A beautiful sunrise over the mountains"));
1658 assert!(html_contains_body_class(&html, "image-view has-caption"));
1659 }
1660
1661 #[test]
1662 fn render_image_page_long_description() {
1663 let mut album = create_test_album();
1664 let long_text = "a".repeat(200);
1665 album.images[0].description = Some(long_text.clone());
1666 let image = &album.images[0];
1667 let html = render_image_page(
1668 &album,
1669 image,
1670 None,
1671 Some(&album.images[1]),
1672 &[],
1673 &[],
1674 "",
1675 None,
1676 "Gallery",
1677 None,
1678 &no_snippets(),
1679 )
1680 .into_string();
1681
1682 assert!(html.contains("image-description"));
1683 assert!(!html.contains("image-caption"));
1684 assert!(html_contains_body_class(
1685 &html,
1686 "image-view has-description"
1687 ));
1688 }
1689
1690 #[test]
1691 fn render_image_page_multiline_is_long_description() {
1692 let mut album = create_test_album();
1693 album.images[0].description = Some("Line one\nLine two".to_string());
1694 let image = &album.images[0];
1695 let html = render_image_page(
1696 &album,
1697 image,
1698 None,
1699 Some(&album.images[1]),
1700 &[],
1701 &[],
1702 "",
1703 None,
1704 "Gallery",
1705 None,
1706 &no_snippets(),
1707 )
1708 .into_string();
1709
1710 assert!(html.contains("image-description"));
1711 assert!(!html.contains("image-caption"));
1712 assert!(html_contains_body_class(
1713 &html,
1714 "image-view has-description"
1715 ));
1716 }
1717
1718 #[test]
1719 fn render_image_page_no_description_no_caption() {
1720 let album = create_test_album();
1721 let image = &album.images[1]; let html = render_image_page(
1723 &album,
1724 image,
1725 Some(&album.images[0]),
1726 None,
1727 &[],
1728 &[],
1729 "",
1730 None,
1731 "Gallery",
1732 None,
1733 &no_snippets(),
1734 )
1735 .into_string();
1736
1737 assert!(!html.contains("image-caption"));
1738 assert!(!html.contains("image-description"));
1739 assert!(html_contains_body_class(&html, "image-view"));
1740 }
1741
1742 #[test]
1743 fn render_image_page_caption_width_matches_frame() {
1744 let mut album = create_test_album();
1745 album.images[0].description = Some("Short caption".to_string());
1746 let image = &album.images[0];
1747 let html = render_image_page(
1748 &album,
1749 image,
1750 None,
1751 Some(&album.images[1]),
1752 &[],
1753 &[],
1754 "",
1755 None,
1756 "Gallery",
1757 None,
1758 &no_snippets(),
1759 )
1760 .into_string();
1761
1762 assert!(html.contains("image-frame"));
1764 assert!(html.contains("image-caption"));
1765 assert!(html.contains("image-page"));
1767 }
1768
1769 #[test]
1770 fn html_escape_in_maud() {
1771 let items = vec![NavItem {
1773 title: "<script>alert('xss')</script>".to_string(),
1774 path: "test".to_string(),
1775 source_dir: String::new(),
1776 children: vec![],
1777 }];
1778 let html = render_nav(&items, "", &[]).into_string();
1779
1780 assert!(!html.contains("<script>alert"));
1782 assert!(html.contains("<script>"));
1783 }
1784
1785 #[test]
1790 fn escape_for_url_spaces_become_dashes() {
1791 assert_eq!(escape_for_url("My Title"), "My-Title");
1792 }
1793
1794 #[test]
1795 fn escape_for_url_dots_become_dashes() {
1796 assert_eq!(escape_for_url("St. Louis"), "St-Louis");
1797 }
1798
1799 #[test]
1800 fn escape_for_url_collapses_consecutive() {
1801 assert_eq!(escape_for_url("A. B"), "A-B");
1802 }
1803
1804 #[test]
1805 fn escape_for_url_strips_leading_trailing() {
1806 assert_eq!(escape_for_url(". Title ."), "Title");
1807 }
1808
1809 #[test]
1810 fn escape_for_url_preserves_dashes() {
1811 assert_eq!(escape_for_url("My-Title"), "My-Title");
1812 }
1813
1814 #[test]
1815 fn image_page_url_with_title() {
1816 assert_eq!(image_page_url(3, 15, Some("Dawn")), "03-Dawn/");
1817 }
1818
1819 #[test]
1820 fn image_page_url_without_title() {
1821 assert_eq!(image_page_url(3, 15, None), "03/");
1822 }
1823
1824 #[test]
1825 fn image_page_url_title_with_spaces() {
1826 assert_eq!(image_page_url(1, 5, Some("My Museum")), "1-My-Museum/");
1827 }
1828
1829 #[test]
1830 fn image_page_url_title_with_dot() {
1831 assert_eq!(image_page_url(1, 5, Some("St. Louis")), "1-St-Louis/");
1832 }
1833
1834 #[test]
1839 fn render_image_page_has_main_image_id() {
1840 let album = create_test_album();
1841 let image = &album.images[0];
1842 let html = render_image_page(
1843 &album,
1844 image,
1845 None,
1846 Some(&album.images[1]),
1847 &[],
1848 &[],
1849 "",
1850 None,
1851 "Gallery",
1852 None,
1853 &no_snippets(),
1854 )
1855 .into_string();
1856
1857 assert!(html.contains(r#"id="main-image""#));
1858 }
1859
1860 #[test]
1861 fn render_image_page_has_render_blocking_link() {
1862 let album = create_test_album();
1863 let image = &album.images[0];
1864 let html = render_image_page(
1865 &album,
1866 image,
1867 None,
1868 Some(&album.images[1]),
1869 &[],
1870 &[],
1871 "",
1872 None,
1873 "Gallery",
1874 None,
1875 &no_snippets(),
1876 )
1877 .into_string();
1878
1879 assert!(html.contains(r#"rel="expect""#));
1880 assert!(html.contains(r##"href="#main-image""##));
1881 assert!(html.contains(r#"blocking="render""#));
1882 }
1883
1884 #[test]
1885 fn render_image_page_prefetches_next_image() {
1886 let album = create_test_album();
1887 let image = &album.images[0];
1888 let html = render_image_page(
1889 &album,
1890 image,
1891 None,
1892 Some(&album.images[1]),
1893 &[],
1894 &[],
1895 "",
1896 None,
1897 "Gallery",
1898 None,
1899 &no_snippets(),
1900 )
1901 .into_string();
1902
1903 assert!(html.contains(r#"rel="prefetch""#));
1905 assert!(html.contains(r#"as="image""#));
1906 assert!(html.contains("002-night-800.avif"));
1907 }
1908
1909 #[test]
1910 fn render_image_page_prefetches_prev_image() {
1911 let album = create_test_album();
1912 let image = &album.images[1];
1913 let html = render_image_page(
1914 &album,
1915 image,
1916 Some(&album.images[0]),
1917 None,
1918 &[],
1919 &[],
1920 "",
1921 None,
1922 "Gallery",
1923 None,
1924 &no_snippets(),
1925 )
1926 .into_string();
1927
1928 assert!(html.contains(r#"rel="prefetch""#));
1930 assert!(html.contains("001-dawn-800.avif"));
1931 assert!(!html.contains("001-dawn-1400.avif"));
1933 }
1934
1935 #[test]
1936 fn render_image_page_no_prefetch_without_adjacent() {
1937 let album = create_test_album();
1938 let image = &album.images[0];
1939 let html = render_image_page(
1941 &album,
1942 image,
1943 None,
1944 None,
1945 &[],
1946 &[],
1947 "",
1948 None,
1949 "Gallery",
1950 None,
1951 &no_snippets(),
1952 )
1953 .into_string();
1954
1955 assert!(html.contains(r#"rel="expect""#));
1957 assert!(!html.contains(r#"rel="prefetch""#));
1959 }
1960
1961 #[test]
1966 fn rendered_html_contains_color_css_variables() {
1967 let mut config = SiteConfig::default();
1968 config.colors.light.background = "#fafafa".to_string();
1969 config.colors.dark.background = "#111111".to_string();
1970
1971 let color_css = crate::config::generate_color_css(&config.colors);
1972 let theme_css = crate::config::generate_theme_css(&config.theme);
1973 let font_css = crate::config::generate_font_css(&config.font);
1974 let css = format!("{}\n{}\n{}", color_css, theme_css, font_css);
1975
1976 let album = create_test_album();
1977 let html = render_album_page(
1978 &album,
1979 &[],
1980 &[],
1981 &css,
1982 None,
1983 "Gallery",
1984 None,
1985 &no_snippets(),
1986 )
1987 .into_string();
1988
1989 assert!(html.contains("--color-bg: #fafafa"));
1990 assert!(html.contains("--color-bg: #111111"));
1991 assert!(html.contains("--color-text:"));
1992 assert!(html.contains("--color-text-muted:"));
1993 assert!(html.contains("--color-border:"));
1994 assert!(html.contains("--color-link:"));
1995 assert!(html.contains("--color-link-hover:"));
1996 }
1997
1998 #[test]
1999 fn rendered_html_contains_theme_css_variables() {
2000 let mut config = SiteConfig::default();
2001 config.theme.thumbnail_gap = "0.5rem".to_string();
2002 config.theme.mat_x.size = "5vw".to_string();
2003
2004 let theme_css = crate::config::generate_theme_css(&config.theme);
2005 let album = create_test_album();
2006 let html = render_album_page(
2007 &album,
2008 &[],
2009 &[],
2010 &theme_css,
2011 None,
2012 "Gallery",
2013 None,
2014 &no_snippets(),
2015 )
2016 .into_string();
2017
2018 assert!(html.contains("--thumbnail-gap: 0.5rem"));
2019 assert!(html.contains("--mat-x: clamp(1rem, 5vw, 2.5rem)"));
2020 assert!(html.contains("--mat-y:"));
2021 assert!(html.contains("--grid-padding:"));
2022 }
2023
2024 #[test]
2025 fn rendered_html_contains_font_css_variables() {
2026 let mut config = SiteConfig::default();
2027 config.font.font = "Lora".to_string();
2028 config.font.weight = "300".to_string();
2029 config.font.font_type = crate::config::FontType::Serif;
2030
2031 let font_css = crate::config::generate_font_css(&config.font);
2032 let font_url = config.font.stylesheet_url();
2033
2034 let album = create_test_album();
2035 let html = render_album_page(
2036 &album,
2037 &[],
2038 &[],
2039 &font_css,
2040 font_url.as_deref(),
2041 "Gallery",
2042 None,
2043 &no_snippets(),
2044 )
2045 .into_string();
2046
2047 assert!(html.contains("--font-family:"));
2048 assert!(html.contains("--font-weight: 300"));
2049 assert!(html.contains("fonts.googleapis.com"));
2050 assert!(html.contains("Lora"));
2051 }
2052
2053 #[test]
2058 fn index_page_excludes_non_nav_albums() {
2059 let manifest = Manifest {
2060 navigation: vec![],
2061 albums: vec![
2062 Album {
2063 path: "visible".to_string(),
2064 title: "Visible".to_string(),
2065 description: None,
2066 thumbnail: "visible/thumb.avif".to_string(),
2067 images: vec![],
2068 in_nav: true,
2069 config: SiteConfig::default(),
2070 support_files: vec![],
2071 },
2072 Album {
2073 path: "hidden".to_string(),
2074 title: "Hidden".to_string(),
2075 description: None,
2076 thumbnail: "hidden/thumb.avif".to_string(),
2077 images: vec![],
2078 in_nav: false,
2079 config: SiteConfig::default(),
2080 support_files: vec![],
2081 },
2082 ],
2083 pages: vec![],
2084 description: None,
2085 config: SiteConfig::default(),
2086 };
2087
2088 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2089
2090 assert!(html.contains("Visible"));
2091 assert!(!html.contains("Hidden"));
2092 }
2093
2094 #[test]
2095 fn index_page_with_no_albums() {
2096 let manifest = Manifest {
2097 navigation: vec![],
2098 albums: vec![],
2099 pages: vec![],
2100 description: None,
2101 config: SiteConfig::default(),
2102 };
2103
2104 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2105
2106 assert!(html.contains("album-grid"));
2107 assert!(html.contains("Gallery"));
2108 }
2109
2110 #[test]
2111 fn index_page_with_description() {
2112 let manifest = Manifest {
2113 navigation: vec![],
2114 albums: vec![],
2115 pages: vec![],
2116 description: Some("<p>Welcome to the gallery.</p>".to_string()),
2117 config: SiteConfig::default(),
2118 };
2119
2120 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2121
2122 assert!(html.contains("has-description"));
2123 assert!(html.contains("index-header"));
2124 assert!(html.contains("album-description"));
2125 assert!(html.contains("Welcome to the gallery."));
2126 assert!(html.contains("desc-toggle"));
2127 assert!(html.contains("Read more"));
2128 assert!(html.contains("Show less"));
2129 assert!(html.contains("<h1>Gallery</h1>"));
2131 }
2132
2133 #[test]
2134 fn index_page_no_description_no_header() {
2135 let manifest = Manifest {
2136 navigation: vec![],
2137 albums: vec![],
2138 pages: vec![],
2139 description: None,
2140 config: SiteConfig::default(),
2141 };
2142
2143 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2144
2145 assert!(!html.contains("has-description"));
2146 assert!(!html.contains("index-header"));
2147 assert!(!html.contains("album-description"));
2148 }
2149
2150 #[test]
2155 fn single_image_album_no_prev_next() {
2156 let album = Album {
2157 path: "solo".to_string(),
2158 title: "Solo Album".to_string(),
2159 description: None,
2160 thumbnail: "solo/001-thumb.avif".to_string(),
2161 images: vec![Image {
2162 number: 1,
2163 source_path: "solo/001-photo.jpg".to_string(),
2164 title: Some("Photo".to_string()),
2165 description: None,
2166 dimensions: (1600, 1200),
2167 generated: {
2168 let mut map = BTreeMap::new();
2169 map.insert(
2170 "800".to_string(),
2171 GeneratedVariant {
2172 avif: "solo/001-photo-800.avif".to_string(),
2173 width: 800,
2174 height: 600,
2175 },
2176 );
2177 map
2178 },
2179 thumbnail: "solo/001-photo-thumb.avif".to_string(),
2180 }],
2181 in_nav: true,
2182 config: SiteConfig::default(),
2183 support_files: vec![],
2184 };
2185
2186 let image = &album.images[0];
2187 let html = render_image_page(
2188 &album,
2189 image,
2190 None,
2191 None,
2192 &[],
2193 &[],
2194 "",
2195 None,
2196 "Gallery",
2197 None,
2198 &no_snippets(),
2199 )
2200 .into_string();
2201
2202 assert!(html.contains(r#"class="nav-prev" href="../""#));
2204 assert!(html.contains(r#"class="nav-next" href="../""#));
2205 }
2206
2207 #[test]
2208 fn album_page_no_description() {
2209 let mut album = create_test_album();
2210 album.description = None;
2211 let html = render_album_page(&album, &[], &[], "", None, "Gallery", None, &no_snippets())
2212 .into_string();
2213
2214 assert!(!html.contains("album-description"));
2215 assert!(html.contains("Test Album"));
2216 }
2217
2218 #[test]
2219 fn render_image_page_nav_dots() {
2220 let album = create_test_album();
2221 let image = &album.images[0];
2222 let html = render_image_page(
2223 &album,
2224 image,
2225 None,
2226 Some(&album.images[1]),
2227 &[],
2228 &[],
2229 "",
2230 None,
2231 "Gallery",
2232 None,
2233 &no_snippets(),
2234 )
2235 .into_string();
2236
2237 assert!(html.contains("image-nav"));
2239 assert!(html.contains(r#"aria-current="true""#));
2241 assert!(html.contains(r#"href="../1-Dawn/""#));
2243 assert!(html.contains(r#"href="../2/""#));
2244 }
2245
2246 #[test]
2247 fn render_image_page_nav_dots_marks_correct_current() {
2248 let album = create_test_album();
2249 let html = render_image_page(
2251 &album,
2252 &album.images[1],
2253 Some(&album.images[0]),
2254 None,
2255 &[],
2256 &[],
2257 "",
2258 None,
2259 "Gallery",
2260 None,
2261 &no_snippets(),
2262 )
2263 .into_string();
2264
2265 assert!(html.contains(r#"<a href="../2/" aria-current="true">"#));
2268 assert!(html.contains(r#"<a href="../1-Dawn/">"#));
2269 assert!(!html.contains(r#"<a href="../1-Dawn/" aria-current"#));
2271 }
2272
2273 #[test]
2278 fn index_page_uses_custom_site_title() {
2279 let mut config = SiteConfig::default();
2280 config.site_title = "My Portfolio".to_string();
2281 let manifest = Manifest {
2282 navigation: vec![],
2283 albums: vec![],
2284 pages: vec![],
2285 description: None,
2286 config,
2287 };
2288
2289 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2290
2291 assert!(html.contains("My Portfolio"));
2292 assert!(!html.contains("Gallery"));
2293 assert!(html.contains("<title>My Portfolio</title>"));
2294 }
2295
2296 #[test]
2297 fn album_page_breadcrumb_uses_custom_site_title() {
2298 let album = create_test_album();
2299 let html = render_album_page(
2300 &album,
2301 &[],
2302 &[],
2303 "",
2304 None,
2305 "My Portfolio",
2306 None,
2307 &no_snippets(),
2308 )
2309 .into_string();
2310
2311 assert!(html.contains("My Portfolio"));
2312 assert!(!html.contains("Gallery"));
2313 }
2314
2315 #[test]
2316 fn image_page_breadcrumb_uses_custom_site_title() {
2317 let album = create_test_album();
2318 let image = &album.images[0];
2319 let html = render_image_page(
2320 &album,
2321 image,
2322 None,
2323 Some(&album.images[1]),
2324 &[],
2325 &[],
2326 "",
2327 None,
2328 "My Portfolio",
2329 None,
2330 &no_snippets(),
2331 )
2332 .into_string();
2333
2334 assert!(html.contains("My Portfolio"));
2335 assert!(!html.contains("Gallery"));
2336 }
2337
2338 #[test]
2339 fn content_page_breadcrumb_uses_custom_site_title() {
2340 let page = Page {
2341 title: "About".to_string(),
2342 link_title: "About".to_string(),
2343 slug: "about".to_string(),
2344 body: "# About\n\nContent.".to_string(),
2345 in_nav: true,
2346 sort_key: 40,
2347 is_link: false,
2348 };
2349 let html = render_page(
2350 &page,
2351 &[],
2352 &[],
2353 "",
2354 None,
2355 "My Portfolio",
2356 None,
2357 &no_snippets(),
2358 )
2359 .into_string();
2360
2361 assert!(html.contains("My Portfolio"));
2362 assert!(!html.contains("Gallery"));
2363 }
2364
2365 #[test]
2366 fn pwa_assets_present() {
2367 let manifest = Manifest {
2368 navigation: vec![],
2369 albums: vec![],
2370 pages: vec![],
2371 description: None,
2372 config: SiteConfig::default(),
2373 };
2374
2375 let html = render_index(&manifest, "", None, None, &no_snippets()).into_string();
2376
2377 assert!(html.contains(r#"<link rel="manifest" href="/site.webmanifest">"#));
2378 assert!(html.contains(r#"<link rel="apple-touch-icon" href="/apple-touch-icon.png">"#));
2379 assert!(html.contains("navigator.serviceWorker.register('/sw.js');"));
2380 assert!(html.contains("beforeinstallprompt"));
2381 }
2382
2383 #[test]
2388 fn no_custom_css_link_by_default() {
2389 let content = html! { p { "test" } };
2390 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
2391 .into_string();
2392 assert!(!doc.contains("custom.css"));
2393 }
2394
2395 #[test]
2396 fn custom_css_link_injected_when_present() {
2397 let snippets = CustomSnippets {
2398 has_custom_css: true,
2399 ..Default::default()
2400 };
2401 let content = html! { p { "test" } };
2402 let doc =
2403 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2404 assert!(doc.contains(r#"<link rel="stylesheet" href="/custom.css">"#));
2405 }
2406
2407 #[test]
2408 fn custom_css_link_after_main_style() {
2409 let snippets = CustomSnippets {
2410 has_custom_css: true,
2411 ..Default::default()
2412 };
2413 let content = html! { p { "test" } };
2414 let doc = base_document("Test", "body{}", None, None, None, None, &snippets, content)
2415 .into_string();
2416 let style_pos = doc.find("</style>").unwrap();
2417 let link_pos = doc.find(r#"href="/custom.css""#).unwrap();
2418 assert!(
2419 link_pos > style_pos,
2420 "custom.css link should appear after main <style>"
2421 );
2422 }
2423
2424 #[test]
2425 fn head_html_injected_when_present() {
2426 let snippets = CustomSnippets {
2427 head_html: Some(r#"<script>console.log("analytics")</script>"#.to_string()),
2428 ..Default::default()
2429 };
2430 let content = html! { p { "test" } };
2431 let doc =
2432 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2433 assert!(doc.contains(r#"<script>console.log("analytics")</script>"#));
2434 }
2435
2436 #[test]
2437 fn head_html_inside_head_element() {
2438 let snippets = CustomSnippets {
2439 head_html: Some("<!-- custom head -->".to_string()),
2440 ..Default::default()
2441 };
2442 let content = html! { p { "test" } };
2443 let doc =
2444 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2445 let head_end = doc.find("</head>").unwrap();
2446 let snippet_pos = doc.find("<!-- custom head -->").unwrap();
2447 assert!(
2448 snippet_pos < head_end,
2449 "head.html should appear inside <head>"
2450 );
2451 }
2452
2453 #[test]
2454 fn no_head_html_by_default() {
2455 let content = html! { p { "test" } };
2456 let doc = base_document("Test", "", None, None, None, None, &no_snippets(), content)
2457 .into_string();
2458 assert!(!doc.contains("<!-- custom"));
2460 }
2461
2462 #[test]
2463 fn body_end_html_injected_when_present() {
2464 let snippets = CustomSnippets {
2465 body_end_html: Some(r#"<script src="/tracking.js"></script>"#.to_string()),
2466 ..Default::default()
2467 };
2468 let content = html! { p { "test" } };
2469 let doc =
2470 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2471 assert!(doc.contains(r#"<script src="/tracking.js"></script>"#));
2472 }
2473
2474 #[test]
2475 fn body_end_html_inside_body_before_close() {
2476 let snippets = CustomSnippets {
2477 body_end_html: Some("<!-- body end -->".to_string()),
2478 ..Default::default()
2479 };
2480 let content = html! { p { "test" } };
2481 let doc =
2482 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2483 let body_end = doc.find("</body>").unwrap();
2484 let snippet_pos = doc.find("<!-- body end -->").unwrap();
2485 assert!(
2486 snippet_pos < body_end,
2487 "body-end.html should appear before </body>"
2488 );
2489 }
2490
2491 #[test]
2492 fn body_end_html_after_content() {
2493 let snippets = CustomSnippets {
2494 body_end_html: Some("<!-- body end -->".to_string()),
2495 ..Default::default()
2496 };
2497 let content = html! { p { "main content" } };
2498 let doc =
2499 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2500 let content_pos = doc.find("main content").unwrap();
2501 let snippet_pos = doc.find("<!-- body end -->").unwrap();
2502 assert!(
2503 snippet_pos > content_pos,
2504 "body-end.html should appear after main content"
2505 );
2506 }
2507
2508 #[test]
2509 fn all_snippets_injected_together() {
2510 let snippets = CustomSnippets {
2511 has_custom_css: true,
2512 head_html: Some("<!-- head snippet -->".to_string()),
2513 body_end_html: Some("<!-- body snippet -->".to_string()),
2514 };
2515 let content = html! { p { "test" } };
2516 let doc =
2517 base_document("Test", "", None, None, None, None, &snippets, content).into_string();
2518 assert!(doc.contains(r#"href="/custom.css""#));
2519 assert!(doc.contains("<!-- head snippet -->"));
2520 assert!(doc.contains("<!-- body snippet -->"));
2521 }
2522
2523 #[test]
2524 fn snippets_appear_in_all_page_types() {
2525 let snippets = CustomSnippets {
2526 has_custom_css: true,
2527 head_html: Some("<!-- head -->".to_string()),
2528 body_end_html: Some("<!-- body -->".to_string()),
2529 };
2530
2531 let manifest = Manifest {
2533 navigation: vec![],
2534 albums: vec![],
2535 pages: vec![],
2536 description: None,
2537 config: SiteConfig::default(),
2538 };
2539 let html = render_index(&manifest, "", None, None, &snippets).into_string();
2540 assert!(html.contains("custom.css"));
2541 assert!(html.contains("<!-- head -->"));
2542 assert!(html.contains("<!-- body -->"));
2543
2544 let album = create_test_album();
2546 let html =
2547 render_album_page(&album, &[], &[], "", None, "Gallery", None, &snippets).into_string();
2548 assert!(html.contains("custom.css"));
2549 assert!(html.contains("<!-- head -->"));
2550 assert!(html.contains("<!-- body -->"));
2551
2552 let page = make_page("about", "About", true, false);
2554 let html = render_page(&page, &[], &[], "", None, "Gallery", None, &snippets).into_string();
2555 assert!(html.contains("custom.css"));
2556 assert!(html.contains("<!-- head -->"));
2557 assert!(html.contains("<!-- body -->"));
2558
2559 let html = render_image_page(
2561 &album,
2562 &album.images[0],
2563 None,
2564 Some(&album.images[1]),
2565 &[],
2566 &[],
2567 "",
2568 None,
2569 "Gallery",
2570 None,
2571 &snippets,
2572 )
2573 .into_string();
2574 assert!(html.contains("custom.css"));
2575 assert!(html.contains("<!-- head -->"));
2576 assert!(html.contains("<!-- body -->"));
2577 }
2578
2579 #[test]
2580 fn detect_custom_snippets_finds_files() {
2581 let tmp = tempfile::TempDir::new().unwrap();
2582
2583 let snippets = detect_custom_snippets(tmp.path());
2585 assert!(!snippets.has_custom_css);
2586 assert!(snippets.head_html.is_none());
2587 assert!(snippets.body_end_html.is_none());
2588
2589 fs::write(tmp.path().join("custom.css"), "body { color: red; }").unwrap();
2591 let snippets = detect_custom_snippets(tmp.path());
2592 assert!(snippets.has_custom_css);
2593 assert!(snippets.head_html.is_none());
2594
2595 fs::write(tmp.path().join("head.html"), "<meta name=\"test\">").unwrap();
2597 let snippets = detect_custom_snippets(tmp.path());
2598 assert!(snippets.has_custom_css);
2599 assert_eq!(snippets.head_html.as_deref(), Some("<meta name=\"test\">"));
2600
2601 fs::write(
2603 tmp.path().join("body-end.html"),
2604 "<script>alert(1)</script>",
2605 )
2606 .unwrap();
2607 let snippets = detect_custom_snippets(tmp.path());
2608 assert!(snippets.has_custom_css);
2609 assert!(snippets.head_html.is_some());
2610 assert_eq!(
2611 snippets.body_end_html.as_deref(),
2612 Some("<script>alert(1)</script>")
2613 );
2614 }
2615}