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