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