Skip to main content

simple_gal/
generate.rs

1//! HTML site generation.
2//!
3//! Stage 3 of the Simple Gal build pipeline. Takes the processed manifest and
4//! generates the final static HTML site.
5//!
6//! ## Generated Pages
7//!
8//! - **Index page** (`/index.html`): Gallery list showing top-level album/group cards
9//! - **Gallery-list pages** (`/{group}/index.html`): Gallery list for a container directory, showing cards for each child album or sub-group
10//! - **Album pages** (`/{album}/index.html`): Thumbnail grid for an album
11//! - **Image pages** (`/{album}/{n}-{slug}.html`): Full-screen image viewer with navigation
12//! - **Content pages** (`/{slug}.html`): Markdown pages (e.g. about, contact)
13//!
14//! ## Features
15//!
16//! - **Responsive images**: Uses AVIF srcset for responsive images
17//! - **Collapsible navigation**: Details/summary for mobile-friendly nav
18//! - **Keyboard navigation**: Arrow keys and swipe gestures for image browsing
19//! - **View transitions**: Smooth page-to-page animations (where supported)
20//! - **Configurable colors**: CSS custom properties generated from config.toml
21//!
22//! ## Output Structure
23//!
24//! ```text
25//! dist/
26//! ├── index.html                 # Gallery list (top-level cards)
27//! ├── about.html                 # Content page (from 040-about.md)
28//! ├── Landscapes/
29//! │   ├── index.html             # Album page (thumbnail grid)
30//! │   ├── 1-dawn.html            # Image viewer pages
31//! │   ├── 2-sunset.html
32//! │   ├── 001-dawn-800.avif      # Processed images (copied)
33//! │   └── ...
34//! └── Travel/
35//!     ├── index.html             # Gallery-list page (child album cards)
36//!     ├── Japan/
37//!     │   ├── index.html         # Album page
38//!     │   └── ...
39//!     └── Italy/
40//!         └── ...
41//! ```
42//!
43//! ## CSS and JavaScript
44//!
45//! Static assets are embedded at compile time:
46//! - `static/style.css`: Base styles (colors injected from config)
47//! - `static/nav.js`: Keyboard and touch navigation
48//!
49//! ## Custom Snippets
50//!
51//! Users can inject custom content by placing convention files in `assets/`:
52//! - `custom.css`: Linked after the main `<style>` block for CSS overrides
53//! - `head.html`: Raw HTML injected at the end of `<head>` (analytics, meta tags)
54//! - `body-end.html`: Raw HTML injected before `</body>` (tracking scripts, widgets)
55//!
56//! ## HTML Generation
57//!
58//! Uses [maud](https://maud.lambda.xyz/) for compile-time HTML templating.
59//! Templates are type-safe Rust code with automatic XSS escaping.
60
61use 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/// Processed manifest from stage 2
80#[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    /// Resolved config for this album (available for future per-album theming).
100    #[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");
135// We embed default icons so every installation is a valid PWA out of the box.
136// Users can override these by placing files in their assets/ directory.
137const 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
142/// Compute the `sizes` attribute for a responsive image based on its aspect ratio
143/// and the maximum generated width. The image frame CSS constrains display to
144/// `min(container-width, container-height * aspect-ratio)`, so for portrait images
145/// the height constraint dominates and the displayed width is much less than 100vw.
146fn image_sizes_attr(aspect_ratio: f64, max_generated_width: u32) -> String {
147    // ~90vh accounts for header + mat; multiply by aspect ratio for the
148    // height-constrained case (portrait images on wide screens).
149    let vh_factor = 90.0 * aspect_ratio;
150    // Cap so the browser never requests more than our largest variant.
151    let cap = format!("{}px", max_generated_width);
152    if vh_factor >= 100.0 {
153        // Wide landscape: width-constrained, ~100vw on mobile, ~95vw on desktop
154        format!("(max-width: 800px) min(100vw, {cap}), min(95vw, {cap})")
155    } else {
156        // Portrait / square: height-constrained on desktop
157        format!("(max-width: 800px) min(100vw, {cap}), min({vh_factor:.1}vh, {cap})")
158    }
159}
160
161/// An entry in a gallery-list page (index or container page).
162struct GalleryEntry {
163    title: String,
164    path: String,
165    thumbnail: Option<String>,
166}
167
168/// Find a thumbnail for a nav item by walking into its first child recursively.
169fn find_nav_thumbnail(item: &NavItem, albums: &[Album]) -> Option<String> {
170    if item.children.is_empty() {
171        // Leaf: find the matching album
172        albums
173            .iter()
174            .find(|a| a.path == item.path)
175            .map(|a| a.thumbnail.clone())
176    } else {
177        // Container: recurse into first child
178        item.children
179            .first()
180            .and_then(|c| find_nav_thumbnail(c, albums))
181    }
182}
183
184/// Build gallery entries from nav children for a gallery-list page.
185fn 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
196/// Walk the navigation tree and find breadcrumb segments for a given path.
197///
198/// Returns a list of (title, path) pairs from root to the matching node (exclusive).
199fn 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/// User-provided snippets discovered via convention files in the assets directory.
229///
230/// Drop any of these files into your `assets/` directory to inject custom content:
231/// - `custom.css` → `<link rel="stylesheet">` after the main `<style>` block
232/// - `head.html` → raw HTML at the end of `<head>`
233/// - `body-end.html` → raw HTML before `</body>`
234#[derive(Debug, Default)]
235struct CustomSnippets {
236    /// Whether `custom.css` exists in the output directory.
237    has_custom_css: bool,
238    /// Raw HTML to inject at the end of `<head>`.
239    head_html: Option<String>,
240    /// Raw HTML to inject before `</body>`.
241    body_end_html: Option<String>,
242}
243
244/// Detect convention-based custom snippet files in the output directory.
245///
246/// Called after assets are copied so user files are already in place.
247fn 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
255/// Zero-padding width for image indices, based on album size.
256pub(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
265/// Build an image page directory name like `"02-My-Title/"` or `"02/"` (when no title).
266///
267/// The directory name mirrors the display label shown in the header/breadcrumb
268/// (`"02. My Title"`) but URL-escaped: dots and spaces become hyphens, consecutive
269/// hyphens are collapsed.
270///
271/// Image pages are directories with an `index.html` inside, so that static
272/// servers can serve them without requiring `.html` in the URL.
273pub(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
284/// Escape a display title for use in URL paths.
285///
286/// Lowercases, replaces spaces/dots/underscores with hyphens, and collapses consecutive hyphens.
287fn 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
306/// Whether a description is short enough to display as an inline caption.
307///
308/// Short captions (≤160 chars, single line) are rendered as centered text
309/// directly beneath the image. Longer or multi-line descriptions get a
310/// scrollable container instead.
311fn 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    // ── CSS assembly ──────────────────────────────────────────────────
325    // The final CSS is built from THREE sources, injected in two places:
326    //
327    //   1. Google Font <link>  → emitted in <head> BEFORE <style>
328    //      (see base_document() — font_url becomes a <link rel="stylesheet">)
329    //      DO NOT use @import inside <style>; browsers ignore/delay it.
330    //      For local fonts, this is skipped and @font-face is used instead.
331    //
332    //   2. Generated CSS vars  → config::generate_{color,theme,font}_css()
333    //      Produces :root { --color-*, --mat-*, --font-*, … }
334    //      For local fonts, also includes @font-face declaration.
335    //      Prepended to the <style> block so vars are defined before use.
336    //
337    //   3. Static CSS rules    → static/style.css (compiled in via include_str!)
338    //      References the vars above. MUST NOT redefine them — if a var
339    //      needs to come from config, generate it in (2) and consume it here.
340    //
341    // When adding new config-driven CSS: generate the variable in config.rs,
342    // wire it into this assembly, and reference it in static/style.css.
343    // ────────────────────────────────────────────────────────────────────
344    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    // ── PWA assets ────────────────────────────────────────────────────
356    // Written *before* copying user assets so the user can override any
357    // of them by placing files in their assets/ directory.
358    //
359    // IMPORTANT: All PWA paths are absolute from the domain root
360    // (/sw.js, /site.webmanifest, /icon-*.png, scope "/", start_url "/").
361    // The generated site MUST be deployed at the root of its domain.
362    // Subdirectory deployment (e.g. example.com/gallery/) is not supported
363    // because the service worker scope, manifest paths, and cached asset
364    // URLs would all need to be rewritten with the subpath prefix.
365    // ────────────────────────────────────────────────────────────────────
366
367    // 1. Dynamic Manifest (uses site title)
368    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    // 2. Dynamic Service Worker (uses package version for cache busting)
395    // We replace the default cache name with one including the build version.
396    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    // Copy static assets (favicon, fonts, etc.) to output root
409    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 processed images to output
415    copy_dir_recursive(processed_dir, output_dir)?;
416
417    // Detect favicon in output directory for <link rel="icon"> injection
418    let favicon_href = detect_favicon(output_dir);
419
420    // Detect convention-based custom snippets (custom.css, head.html, body-end.html)
421    let snippets = detect_custom_snippets(output_dir);
422
423    // Generate index page
424    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    // Generate pages (content pages only, not link pages)
436    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 for container directories
453    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    // Generate album pages
468    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        // Generate image pages
486        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    // Site-wide "All Photos" page (opt-in via [full_index] generates = true)
517    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
536/// Check the output directory for common favicon files and return the href if found.
537fn 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
550/// Determine the MIME type for a favicon based on its extension.
551fn 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            // Skip manifest.json, copy everything else
572            fs::copy(&src_path, &dst_path)?;
573        }
574    }
575    Ok(())
576}
577
578// ============================================================================
579// HTML Components
580// ============================================================================
581
582/// Renders the base HTML document structure.
583///
584/// Font loading: for Google Fonts, loaded via a `<link>` tag, NOT via
585/// `@import` inside `<style>`. Browsers ignore or delay `@import` in
586/// inline `<style>` blocks. For local fonts, `@font-face` is in the CSS
587/// and `font_url` is `None`. See the CSS assembly comment in `generate()`.
588#[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                // PWA links — absolute paths, requires root deployment (see PWA comment in generate())
607                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                // Google Font loaded as <link>, not @import — see generate().
613                @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                // Custom CSS loaded after main styles so overrides win at equal specificity.
620                @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
651/// Renders the site header with breadcrumb and navigation
652fn 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
665/// Renders the navigation menu (hamburger style, slides from right).
666///
667/// Albums are listed first, then a separator, then pages (numbered pages only).
668/// Link pages render as direct external links; content pages link to `/{slug}.html`.
669///
670/// When `show_all_photos` is true, an "All Photos" item is appended after the
671/// album list — it points at `/all-photos/` which is rendered only when
672/// `[full_index] generates = true`.
673pub 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
722/// Renders a single navigation item (may have children)
723fn 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
743// ============================================================================
744// Page Renderers
745// ============================================================================
746
747/// Renders the index/home page with album grid.
748///
749/// Delegates to `render_gallery_list_page` — the index is just a gallery-list
750/// of top-level navigation entries.
751fn 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
774/// Whether the nav menu should include the "All Photos" entry. Requires both
775/// `full_index.generates` and `full_index.show_link`, since a link without a
776/// generated target would be broken.
777fn show_all_photos_link(config: &SiteConfig) -> bool {
778    config.full_index.generates && config.full_index.show_link
779}
780
781/// Renders an album page with thumbnail grid
782#[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    // Strip album directory name prefix since album page is inside the album directory.
808    // Process-stage paths are relative to the parent dir (e.g. "Night/03-thumb.avif"
809    // for album "NY/Night"), so strip the last segment, not the full nested path.
810    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
855/// Format an image's display label for breadcrumbs and page titles.
856///
857/// The label is `<index>. <title>` when a title exists, or just `<index>` alone.
858///
859/// The index is the image's 1-based position in the album (not the sequence
860/// number from the filename — ordering can start at any number and be
861/// non-contiguous).
862///
863/// Zero-padding width adapts to the album size:
864/// - 1–9 images: no padding (1, 2, 3)
865/// - 10–99 images: 2 digits (01, 02, 03)
866/// - 100–999 images: 3 digits (001, 002, 003)
867/// - 1000+ images: 4 digits (0001, 0002, ...)
868fn 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/// Renders an image viewer page
877#[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    // Strip album directory name and add ../ since image pages are in subdirectories.
895    // Process-stage paths are relative to the parent dir, so strip the last segment.
896    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    // Collect variants sorted by width (BTreeMap keys are strings, so lexicographic
906    // order doesn't match numeric order — "1400" < "800").
907    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    // Build srcset for a given image's avif variants (ascending width order)
914    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    // Build srcset
923    let variants = sorted_variants(image);
924
925    let srcset_avif: String = avif_srcset_for(image);
926
927    // Use middle size as default
928    let default_src = variants
929        .get(variants.len() / 2)
930        .map(|v| strip_prefix(&v.avif))
931        .unwrap_or_default();
932
933    // Pick a single middle-size AVIF URL for adjacent image prefetch
934    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    // Calculate aspect ratio
944    let (width, height) = image.dimensions;
945    let aspect_ratio = width as f64 / height as f64;
946
947    // Navigation URLs
948    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        ), // image_idx is 0-based = prev's 1-based
960        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    // Build image navigation dot URLs
1003    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    // Build <head> extras: render-blocking link + adjacent image prefetches
1026    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/// Renders a content page from markdown
1082#[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    // Convert markdown to HTML
1097    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/// Renders a gallery-list page for a container directory (e.g. /NY/).
1129///
1130/// Structurally identical to the index page but parameterized: shows a grid
1131/// of album cards for the container's children.
1132#[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
1206/// Renders the site-wide "All Photos" page — a single thumbnail grid containing
1207/// every image from every public (numbered) album. Uses the full-index thumbnails
1208/// generated in the process stage, with gap and aspect ratio controlled by
1209/// `[full_index]` config. Each thumbnail links to the image's normal page.
1210fn 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    // Collect entries: every image from every in-nav album, in album order.
1235    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    // Inline CSS variables: custom gap, aspect ratio, and grid column width
1269    // just for this page, so a site can tune the full-index grid independently
1270    // from album grids.
1271    //
1272    // The displayed column width is derived from thumb_ratio + thumb_size so
1273    // the CSS grid cells match the pixel dimensions of the generated thumbnail.
1274    // Without this, the grid falls back to the album-grid minmax(200px, 1fr)
1275    // and a source shrunk to 100px (or stretched from 100px to 200px) would
1276    // look blurry regardless of thumb_size.
1277    //
1278    // thumb_size is the short-edge size; long edge scales by the ratio.
1279    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/// Walk the navigation tree and generate gallery-list pages for every container.
1320#[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            // Recurse into children
1356            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// ============================================================================
1375// Tests
1376// ============================================================================
1377
1378#[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        // No separator either when no nav pages
1430        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        // The second item should have the current class
1462        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")); // Parent should have nav-group class
1491    }
1492
1493    #[test]
1494    fn nav_separator_only_when_pages() {
1495        // No pages = no separator
1496        let html_no_pages = render_nav(&[], "", &[], false).into_string();
1497        assert!(!html_no_pages.contains("nav-separator"));
1498
1499        // With nav pages = separator
1500        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    // Helper to check if body has a specific class
1552    fn html_contains_body_class(html: &str, class: &str) -> bool {
1553        // Look for body tag with class attribute containing the class
1554        html.contains(&format!(r#"class="{}""#, class))
1555    }
1556
1557    // =========================================================================
1558    // Page renderer tests
1559    // =========================================================================
1560
1561    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    /// A nested album (e.g. NY/Night) with process-stage path conventions.
1626    ///
1627    /// The process stage outputs image paths relative to the album's parent
1628    /// directory, NOT the full nested path. So for album "NY/Night", thumbnail
1629    /// paths are "Night/..." not "NY/Night/...".
1630    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        // Thumbnail src must be relative to the album directory (no parent prefix).
1688        // "001-city-thumb.avif" is correct; "Night/001-city-thumb.avif" would break
1689        // because the page is already served from /NY/Night/.
1690        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        // Image page is at /NY/Night/1-City/, so srcset paths use ../ to reach
1715        // the album directory. Must NOT contain the album dir name again.
1716        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        // Should have links to image pages (1-dawn/, 2/)
1781        assert!(html.contains("1-dawn/"));
1782        assert!(html.contains("2/"));
1783        // Thumbnails should have paths relative to album dir
1784        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        // Breadcrumb should link to gallery root
1805        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        // Should have srcset with sizes
1858        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        // First image - no prev, has next
1896        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        // Second image - has prev, no next (image[1] has no title)
1915        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]; // 1600x1200 = 1.333...
1938        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        // Should have aspect ratio CSS variable
1956        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        // Markdown should be converted to HTML
1984        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    // =========================================================================
2017    // Image label and breadcrumb tests
2018    // =========================================================================
2019
2020    #[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]; // has title "Dawn"
2051        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        // Breadcrumb: Gallery › Test Album › 1. Dawn
2069        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]; // no title
2077        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        // Breadcrumb: Gallery › Test Album › 2
2095        assert!(html.contains("Test Album"));
2096        // Should contain just "2" without a period
2097        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]; // has title "Dawn"
2128        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    // =========================================================================
2149    // Description detection and rendering tests
2150    // =========================================================================
2151
2152    #[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]; // description: None
2268        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        // Caption should be a sibling of image-frame inside image-page
2311        assert!(html.contains("image-frame"));
2312        assert!(html.contains("image-caption"));
2313        // Both should be inside image-page (column flex ensures width matching via CSS)
2314        assert!(html.contains("image-page"));
2315    }
2316
2317    #[test]
2318    fn html_escape_in_maud() {
2319        // Maud should automatically escape HTML in content
2320        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        // Should be escaped, not raw script tag
2330        assert!(!html.contains("<script>alert"));
2331        assert!(html.contains("&lt;script&gt;"));
2332    }
2333
2334    // =========================================================================
2335    // escape_for_url tests
2336    // =========================================================================
2337
2338    #[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    // =========================================================================
2389    // View transition: render-blocking and image preload tests
2390    // =========================================================================
2391
2392    #[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        // Should have a prefetch link with the next image's middle-size avif
2461        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        // Should have a prefetch link with the prev image's middle-size avif
2487        // Variants sorted by width: [800, 1400], middle (index 1) = 1400
2488        assert!(html.contains(r#"rel="prefetch""#));
2489        assert!(html.contains("001-dawn-1400.avif"));
2490        // Single URL (href), not a srcset — should not contain both sizes
2491        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        // No prev, no next
2499        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        // Should still have the render-blocking link
2516        assert!(html.contains(r#"rel="expect""#));
2517        // Should NOT have any prefetch links
2518        assert!(!html.contains(r#"rel="prefetch""#));
2519    }
2520
2521    // =========================================================================
2522    // CSS variables from config in rendered HTML
2523    // =========================================================================
2524
2525    #[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    // =========================================================================
2617    // Index page edge cases
2618    // =========================================================================
2619
2620    #[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    // =========================================================================
2664    // Full-index ("All Photos") tests
2665    // =========================================================================
2666
2667    fn make_full_index_manifest() -> Manifest {
2668        // Two public albums with one image each. Each image has a full-index
2669        // thumbnail populated as if the process stage ran with generates=true.
2670        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        // Each thumbnail should link to the image's normal page.
2754        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        // Inline CSS vars on <main> let a site tune the grid independently.
2764        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        // thumb_ratio = [4, 4] (square), thumb_size = 1000 → col width = 1000px
2771        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        // Portrait [4, 5] thumb_size=400 → width = 400 (short edge)
2779        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        // Landscape [16, 9] thumb_size=400 → width = 400 * 16 / 9 ≈ 711
2789        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        // Add a hidden album whose image must NOT appear on the All Photos page.
2800        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        // show_link alone does not produce a link — the target must be generated.
2847        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        // Should still include the site title in the header
2896        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    // =========================================================================
2917    // Album page with single image
2918    // =========================================================================
2919
2920    #[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        // Both prev and next should go back to album
2971        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        // Should contain nav with image-nav class
3017        assert!(html.contains("image-nav"));
3018        // Current image dot should have aria-current
3019        assert!(html.contains(r#"aria-current="true""#));
3020        // Should have links to both image pages
3021        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        // Render second image page
3029        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        // The second dot (href="../2/") should have aria-current
3046        // The first dot (href="../1-Dawn/") should NOT
3047        assert!(html.contains(r#"<a href="../2/" aria-current="true">"#));
3048        assert!(html.contains(r#"<a href="../1-dawn/">"#));
3049        // Verify the first dot does NOT have aria-current
3050        assert!(!html.contains(r#"<a href="../1-dawn/" aria-current"#));
3051    }
3052
3053    // =========================================================================
3054    // Custom site_title tests
3055    // =========================================================================
3056
3057    #[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    // =========================================================================
3167    // Custom snippets tests
3168    // =========================================================================
3169
3170    #[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        // Only the standard head content should be present
3242        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        // Index page
3315        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        // Album page
3328        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        // Content page
3346        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        // Image page
3354        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        // No files → empty snippets
3379        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        // Create custom.css
3385        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        // Create head.html
3391        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        // Create body-end.html
3397        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    // =========================================================================
3412    // image_sizes_attr tests
3413    // =========================================================================
3414
3415    #[test]
3416    fn sizes_attr_landscape_uses_vw() {
3417        // 1600x1200 → aspect 1.333, 90*1.333 = 120 > 100 → landscape branch
3418        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        // 1200x1600 → aspect 0.75, 90*0.75 = 67.5 < 100 → portrait branch
3432        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        // 1:1 → aspect 1.0, 90*1.0 = 90 < 100 → portrait/square branch
3450        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        // Both mobile and desktop min() should reference the 900px cap
3473        assert_eq!(
3474            attr.matches("900px").count(),
3475            2,
3476            "should have px cap in both conditions: {attr}"
3477        );
3478    }
3479
3480    // =========================================================================
3481    // srcset w-descriptor correctness
3482    // =========================================================================
3483
3484    #[test]
3485    fn srcset_uses_actual_width_not_target_for_portrait() {
3486        let album = create_test_album();
3487        let image = &album.images[1]; // portrait 1200x1600, generated width=600
3488        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        // Portrait: target key is "800" (longer edge=height) but actual width is 600
3506        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]; // landscape 1600x1200, generated widths 800 and 1400
3520        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}