Skip to main content

typstify_generator/
html.rs

1//! HTML generation from parsed content.
2//!
3//! Converts parsed content into final HTML pages using templates.
4
5use std::path::{Path, PathBuf};
6
7use chrono::{Datelike, Utc};
8use thiserror::Error;
9use tracing::debug;
10use typstify_core::{
11    Config, Page,
12    utils::{html_escape, slugify},
13};
14
15use crate::template::{Template, TemplateContext, TemplateError, TemplateRegistry};
16
17/// HTML generation errors.
18#[derive(Debug, Error)]
19pub enum HtmlError {
20    /// Template error.
21    #[error("template error: {0}")]
22    Template(#[from] TemplateError),
23
24    /// IO error.
25    #[error("IO error: {0}")]
26    Io(#[from] std::io::Error),
27
28    /// Invalid page data.
29    #[error("invalid page data: {0}")]
30    InvalidPage(String),
31}
32
33/// Result type for HTML generation.
34pub type Result<T> = std::result::Result<T, HtmlError>;
35
36/// HTML page generator.
37#[derive(Debug)]
38pub struct HtmlGenerator<'a> {
39    templates: TemplateRegistry,
40    config: &'a Config,
41    /// Content sections for dynamic navigation (e.g., "posts", "shorts").
42    sections: Vec<String>,
43}
44
45impl<'a> HtmlGenerator<'a> {
46    /// Create a new HTML generator with the given configuration.
47    #[must_use]
48    pub fn new(config: &'a Config) -> Self {
49        Self {
50            templates: TemplateRegistry::new(),
51            config,
52            sections: Vec::new(),
53        }
54    }
55
56    /// Create a generator with custom templates.
57    #[must_use]
58    pub fn with_templates(config: &'a Config, templates: TemplateRegistry) -> Self {
59        Self {
60            templates,
61            config,
62            sections: Vec::new(),
63        }
64    }
65
66    /// Set content sections for dynamic navigation.
67    #[must_use]
68    pub fn with_sections(mut self, sections: Vec<String>) -> Self {
69        self.sections = sections;
70        self
71    }
72
73    /// Generate navigation HTML for content sections.
74    fn generate_section_nav(&self, base_path: &str, lang_prefix: &str) -> String {
75        if self.sections.is_empty() {
76            // Default to "Posts" if no sections configured
77            return format!(r#"<a href="{base_path}{lang_prefix}/posts">Posts</a>"#);
78        }
79
80        // Filter out language codes (2-3 letter codes) and standalone pages like "about"
81        let excluded_sections = ["about", "index"];
82        let filtered_sections: Vec<_> = self
83            .sections
84            .iter()
85            .filter(|s| {
86                // Exclude 2-3 letter sections (likely language codes like "zh", "en")
87                if s.len() <= 3 && s.chars().all(|c| c.is_ascii_lowercase()) {
88                    return false;
89                }
90                // Exclude known standalone pages
91                !excluded_sections.contains(&s.as_str())
92            })
93            .collect();
94
95        if filtered_sections.is_empty() {
96            return format!(r#"<a href="{base_path}{lang_prefix}/posts">Posts</a>"#);
97        }
98
99        filtered_sections
100            .iter()
101            .map(|section| {
102                // Capitalize first letter for display
103                let title = section
104                    .chars()
105                    .next()
106                    .map(|c| c.to_uppercase().collect::<String>() + &section[c.len_utf8()..])
107                    .unwrap_or_else(|| (*section).clone());
108                format!(
109                    r#"<a href="{base_path}{lang_prefix}/{section}">{}</a>"#,
110                    html_escape(&title)
111                )
112            })
113            .collect::<Vec<_>>()
114            .join("\n                    ")
115    }
116
117    /// Register a custom template.
118    pub fn register_template(&mut self, template: Template) {
119        self.templates.register(template);
120    }
121
122    /// Generate HTML for a page.
123    pub fn generate_page(&self, page: &Page, alternates: &[(&str, &str)]) -> Result<String> {
124        debug!(url = %page.url, "generating HTML for page");
125
126        // Determine which template to use
127        let template_name = page.template.as_deref().map_or_else(
128            || {
129                if page.date.is_some() { "post" } else { "page" }
130            },
131            |t| {
132                // Normalize "shorts" to "short" for individual pages
133                if t == "shorts" { "short" } else { t }
134            },
135        );
136
137        // Build inner content context
138        let inner_ctx = self.build_page_context(page)?;
139        let inner_html = self.templates.render(template_name, &inner_ctx)?;
140
141        // Build outer (base) context
142        let base_ctx = self.build_base_context(page, &inner_html, alternates)?;
143        Ok(self.templates.render("base", &base_ctx)?)
144    }
145
146    /// Generate redirect HTML for URL aliases.
147    pub fn generate_redirect(&self, redirect_url: &str) -> Result<String> {
148        let ctx = TemplateContext::new().with_var("redirect_url", redirect_url);
149        self.templates
150            .render("redirect", &ctx)
151            .map_err(HtmlError::from)
152    }
153
154    /// Generate a list page HTML.
155    pub fn generate_list_page(
156        &self,
157        title: &str,
158        items_html: &str,
159        pagination_html: Option<&str>,
160    ) -> Result<String> {
161        let mut ctx = TemplateContext::new()
162            .with_var("title", title)
163            .with_var("items", items_html);
164
165        if let Some(pagination) = pagination_html {
166            ctx.insert("pagination", pagination);
167        }
168
169        let inner_html = self.templates.render("list", &ctx)?;
170
171        let base_url = self.config.base_url();
172        let base_ctx = self.build_shared_base_ctx(
173            &self.config.site.default_language,
174            title,
175            base_url,
176            &inner_html,
177            "",
178        );
179
180        Ok(self.templates.render("base", &base_ctx)?)
181    }
182
183    /// Generate a taxonomy term page HTML.
184    pub fn generate_taxonomy_page(
185        &self,
186        taxonomy_name: &str,
187        term: &str,
188        items_html: &str,
189        pagination_html: Option<&str>,
190    ) -> Result<String> {
191        let mut ctx = TemplateContext::new()
192            .with_var("taxonomy_name", taxonomy_name)
193            .with_var("term", term)
194            .with_var("items", items_html);
195
196        if let Some(pagination) = pagination_html {
197            ctx.insert("pagination", pagination);
198        }
199
200        let inner_html = self.templates.render("taxonomy", &ctx)?;
201        let title = format!("{taxonomy_name}: {term}");
202
203        let base_url = self.config.base_url();
204        let canonical_url = format!("{}/{}/{}", base_url, taxonomy_name.to_lowercase(), term);
205
206        let base_ctx = self.build_shared_base_ctx(
207            &self.config.site.default_language,
208            &title,
209            &canonical_url,
210            &inner_html,
211            "",
212        );
213
214        Ok(self.templates.render("base", &base_ctx)?)
215    }
216
217    /// Build shared base template context with common navigation variables.
218    fn build_shared_base_ctx(
219        &self,
220        lang: &str,
221        title: &str,
222        canonical_url: &str,
223        content: &str,
224        lang_prefix: &str,
225    ) -> TemplateContext {
226        let base_path = self.config.base_path();
227        TemplateContext::new()
228            .with_var("lang", lang)
229            .with_var("title", title)
230            .with_var("base_path", base_path)
231            .with_var(
232                "site_title_suffix",
233                format!(" | {}", self.config.title_for_language(lang)),
234            )
235            .with_var("canonical_url", canonical_url)
236            .with_var("content", content)
237            .with_var("site_title", self.config.title_for_language(lang))
238            .with_var("year", Utc::now().year().to_string())
239            .with_var("nav_home_url", format!("{base_path}{lang_prefix}/"))
240            .with_var(
241                "nav_archives_url",
242                format!("{base_path}{lang_prefix}/archives"),
243            )
244            .with_var("nav_tags_url", format!("{base_path}{lang_prefix}/tags"))
245            .with_var("nav_about_url", format!("{base_path}{lang_prefix}/about"))
246            .with_var(
247                "section_nav",
248                self.generate_section_nav(base_path, lang_prefix),
249            )
250    }
251
252    /// Build template context for page content.
253    fn build_page_context(&self, page: &Page) -> Result<TemplateContext> {
254        let mut ctx = TemplateContext::new()
255            .with_var("title", &page.title)
256            .with_var("content", &page.content);
257
258        // Add date if present
259        if let Some(date) = page.date {
260            ctx.insert("date_iso", date.format("%Y-%m-%d").to_string());
261            ctx.insert("date_formatted", date.format("%B %d, %Y").to_string());
262        }
263
264        // Add author info for short templates
265        let author = self.config.site.author.as_deref().unwrap_or("Author");
266        ctx.insert("author", author);
267        let initials: String = author
268            .split_whitespace()
269            .filter_map(|w| w.chars().next())
270            .take(2)
271            .collect::<String>()
272            .to_uppercase();
273        ctx.insert("author_initials", initials);
274
275        // Add tags HTML if present
276        if !page.tags.is_empty() {
277            let base_path = self.config.base_path();
278            let lang_prefix = if page.is_default_lang {
279                String::new()
280            } else {
281                format!("/{}", page.lang)
282            };
283            let tags_html = page
284                .tags
285                .iter()
286                .map(|tag| {
287                    format!(
288                        r#"<a href="{base_path}{lang_prefix}/tags/{}" rel="tag">{}</a>"#,
289                        slugify(tag),
290                        tag
291                    )
292                })
293                .collect::<Vec<_>>()
294                .join(" ");
295            ctx.insert(
296                "tags_html",
297                format!(r#"<div class="tags">{tags_html}</div>"#),
298            );
299        }
300
301        Ok(ctx)
302    }
303
304    /// Build template context for base HTML wrapper.
305    fn build_base_context(
306        &self,
307        page: &Page,
308        inner_html: &str,
309        alternates: &[(&str, &str)],
310    ) -> Result<TemplateContext> {
311        let lang_prefix = if page.is_default_lang {
312            String::new()
313        } else {
314            format!("/{}", page.lang)
315        };
316
317        let canonical_url = format!("{}{}", self.config.base_url(), page.url);
318
319        let mut ctx = self.build_shared_base_ctx(
320            &page.lang,
321            &page.title,
322            &canonical_url,
323            inner_html,
324            &lang_prefix,
325        );
326
327        // Add description if present
328        if let Some(desc) = &page.description {
329            ctx.insert("description", desc);
330        } else if let Some(site_desc) = self.config.description_for_language(&page.lang) {
331            ctx.insert("description", site_desc);
332        }
333
334        // Add author if present
335        if let Some(author) = &self.config.site.author {
336            ctx.insert("author", author);
337        }
338
339        // Add custom CSS
340        if !page.custom_css.is_empty() {
341            let css_links = page
342                .custom_css
343                .iter()
344                .map(|href| format!(r#"<link rel="stylesheet" href="{href}">"#))
345                .collect::<Vec<_>>()
346                .join("\n");
347            ctx.insert("custom_css", css_links);
348        }
349
350        // Add custom JS
351        if !page.custom_js.is_empty() {
352            let js_scripts = page
353                .custom_js
354                .iter()
355                .map(|src| format!(r#"<script src="{src}"></script>"#))
356                .collect::<Vec<_>>()
357                .join("\n");
358            ctx.insert("custom_js", js_scripts);
359        }
360
361        // Generate language switcher HTML
362        let lang_switcher = self.generate_lang_switcher(&page.lang, &page.canonical_id);
363        if !lang_switcher.is_empty() {
364            ctx.insert("lang_switcher", lang_switcher);
365        }
366
367        // Add hreflang tags
368        if !alternates.is_empty() {
369            let hreflang = alternates
370                .iter()
371                .map(|(lang, url)| {
372                    format!(
373                        r#"<link rel="alternate" hreflang="{}" href="{}{}" />"#,
374                        lang,
375                        self.config.base_url(),
376                        url
377                    )
378                })
379                .collect::<Vec<_>>()
380                .join("\n");
381            ctx.insert("hreflang", hreflang);
382        }
383
384        Ok(ctx)
385    }
386
387    /// Generate language switcher HTML dropdown.
388    fn generate_lang_switcher(&self, current_lang: &str, canonical_id: &str) -> String {
389        let all_langs = self.config.all_languages();
390        if all_langs.len() <= 1 {
391            return String::new();
392        }
393
394        let base_path = self.config.base_path();
395        let mut options = Vec::new();
396
397        for lang in &all_langs {
398            let name = self.config.language_name(lang);
399            let url = if *lang == self.config.site.default_language {
400                // Default language: no prefix
401                if canonical_id.is_empty() {
402                    format!("{base_path}/")
403                } else {
404                    format!("{base_path}/{canonical_id}")
405                }
406            } else {
407                // Non-default language: add prefix
408                if canonical_id.is_empty() {
409                    format!("{base_path}/{lang}/")
410                } else {
411                    format!("{base_path}/{lang}/{canonical_id}")
412                }
413            };
414
415            let selected_class = if *lang == current_lang { " active" } else { "" };
416            options.push(format!(
417                r#"<a href="{url}" class="lang-option{selected_class}">{name}</a>"#,
418            ));
419        }
420
421        // Get the language code for display (uppercase, max 2 chars)
422        let display_code = current_lang
423            .chars()
424            .take(2)
425            .collect::<String>()
426            .to_uppercase();
427
428        format!(
429            r#"<div class="lang-switcher" tabindex="0" role="button" aria-label="Switch language" aria-haspopup="true">
430    <span class="lang-code">{}</span>
431    <div class="lang-dropdown">{}</div>
432</div>"#,
433            display_code,
434            options.join("\n        ")
435        )
436    }
437
438    /// Get the output path for a page.
439    #[must_use]
440    pub fn output_path(&self, page: &Page, output_dir: &Path) -> PathBuf {
441        let relative = page.url.trim_start_matches('/');
442
443        if relative.is_empty() {
444            output_dir.join("index.html")
445        } else {
446            output_dir.join(relative).join("index.html")
447        }
448    }
449
450    /// Generate a tags index page listing all tags with their counts.
451    pub fn generate_tags_index_page(
452        &self,
453        tags: &std::collections::HashMap<String, Vec<String>>,
454        lang: &str,
455    ) -> Result<String> {
456        let is_default_lang = lang == self.config.site.default_language;
457        let lang_prefix = if is_default_lang {
458            String::new()
459        } else {
460            format!("/{lang}")
461        };
462
463        // Get the base path for subdirectory deployments
464        let base_path = self.config.base_path();
465
466        let mut items: Vec<_> = tags.iter().collect();
467        items.sort_by_key(|b| std::cmp::Reverse(b.1.len())); // Sort by count descending
468
469        let items_html: String = items
470            .iter()
471            .map(|(tag, pages)| {
472                format!(
473                    r#"<a href="{base_path}{lang_prefix}/tags/{}" class="tag-item"><span class="tag-name">{}</span><span class="tag-count">{}</span></a>"#,
474                    slugify(tag),
475                    html_escape(tag),
476                    pages.len()
477                )
478            })
479            .collect::<Vec<_>>()
480            .join("\n");
481
482        let ctx = TemplateContext::new().with_var("items", &items_html);
483        let inner_html = self.templates.render("tags_index", &ctx)?;
484
485        let canonical_url = format!("{}{}/tags", self.config.base_url(), lang_prefix);
486
487        let mut base_ctx =
488            self.build_shared_base_ctx(lang, "Tags", &canonical_url, &inner_html, &lang_prefix);
489
490        // Generate language switcher
491        let lang_switcher = self.generate_lang_switcher(lang, "tags");
492        if !lang_switcher.is_empty() {
493            base_ctx.insert("lang_switcher", lang_switcher);
494        }
495
496        Ok(self.templates.render("base", &base_ctx)?)
497    }
498
499    /// Generate a categories index page listing all categories with their counts.
500    pub fn generate_categories_index_page(
501        &self,
502        categories: &std::collections::HashMap<String, Vec<String>>,
503        lang: &str,
504    ) -> Result<String> {
505        let is_default_lang = lang == self.config.site.default_language;
506        let lang_prefix = if is_default_lang {
507            String::new()
508        } else {
509            format!("/{lang}")
510        };
511
512        // Get the base path for subdirectory deployments
513        let base_path = self.config.base_path();
514
515        let mut items: Vec<_> = categories.iter().collect();
516        items.sort_by(|a, b| a.0.cmp(b.0)); // Sort alphabetically
517
518        let items_html: String = items
519            .iter()
520            .map(|(category, pages)| {
521                format!(
522                    r#"<li><a href="{base_path}{lang_prefix}/categories/{}">{}</a> <span class="count">({})</span></li>"#,
523                    slugify(category),
524                    html_escape(category),
525                    pages.len()
526                )
527            })
528            .collect::<Vec<_>>()
529            .join("\n");
530
531        let ctx = TemplateContext::new().with_var("items", &items_html);
532        let inner_html = self.templates.render("categories_index", &ctx)?;
533
534        let canonical_url = format!("{}{}/categories", self.config.base_url(), lang_prefix);
535
536        let mut base_ctx = self.build_shared_base_ctx(
537            lang,
538            "Categories",
539            &canonical_url,
540            &inner_html,
541            &lang_prefix,
542        );
543
544        // Generate language switcher
545        let lang_switcher = self.generate_lang_switcher(lang, "categories");
546        if !lang_switcher.is_empty() {
547            base_ctx.insert("lang_switcher", lang_switcher);
548        }
549
550        Ok(self.templates.render("base", &base_ctx)?)
551    }
552
553    /// Generate an archives page listing all posts grouped by year.
554    pub fn generate_archives_page(&self, pages: &[&Page], lang: &str) -> Result<String> {
555        use std::collections::BTreeMap;
556
557        let is_default_lang = lang == self.config.site.default_language;
558        let lang_prefix = if is_default_lang {
559            String::new()
560        } else {
561            format!("/{lang}")
562        };
563
564        // Group pages by year
565        let mut by_year: BTreeMap<i32, Vec<&Page>> = BTreeMap::new();
566        for page in pages {
567            if let Some(date) = page.date {
568                by_year.entry(date.year()).or_default().push(page);
569            }
570        }
571
572        // Sort pages within each year by date (newest first)
573        for pages in by_year.values_mut() {
574            pages.sort_by_key(|b| std::cmp::Reverse(b.date));
575        }
576
577        // Generate HTML (years in descending order)
578        let items_html: String = by_year
579            .iter()
580            .rev()
581            .map(|(year, year_pages)| {
582                let posts_html: String = year_pages
583                    .iter()
584                    .map(|p| {
585                        let date_str = p
586                            .date
587                            .map(|d| d.format("%m-%d").to_string())
588                            .unwrap_or_default();
589                        // Determine template type for badge
590                        let template_type = p.template.as_deref().unwrap_or("post");
591                        let badge_class = match template_type {
592                            "short" | "shorts" => "badge-short",
593                            _ => "badge-post",
594                        };
595                        let badge_label = match template_type {
596                            "short" | "shorts" => "short",
597                            _ => "post",
598                        };
599                        format!(
600                            r#"<li><span class="archive-date">{}</span><span class="archive-badge {}">{}</span><a href="{}">{}</a></li>"#,
601                            date_str, badge_class, badge_label, html_escape(&p.url), html_escape(&p.title)
602                        )
603                    })
604                    .collect::<Vec<_>>()
605                    .join("\n");
606
607                format!(r#"<div class="archive-year"><h2>{year}</h2><ul>{posts_html}</ul></div>"#,)
608            })
609            .collect::<Vec<_>>()
610            .join("\n");
611
612        let ctx = TemplateContext::new().with_var("items", &items_html);
613        let inner_html = self.templates.render("archives", &ctx)?;
614
615        let canonical_url = format!("{}{}/archives", self.config.base_url(), lang_prefix);
616
617        let mut base_ctx =
618            self.build_shared_base_ctx(lang, "Archives", &canonical_url, &inner_html, &lang_prefix);
619
620        // Generate language switcher
621        let lang_switcher = self.generate_lang_switcher(lang, "archives");
622        if !lang_switcher.is_empty() {
623            base_ctx.insert("lang_switcher", lang_switcher);
624        }
625
626        Ok(self.templates.render("base", &base_ctx)?)
627    }
628
629    /// Generate a section index page (e.g., /posts/).
630    pub fn generate_section_page(
631        &self,
632        section: &str,
633        description: Option<&str>,
634        items_html: &str,
635        pagination_html: Option<&str>,
636        lang: &str,
637    ) -> Result<String> {
638        let is_default_lang = lang == self.config.site.default_language;
639        let lang_prefix = if is_default_lang {
640            String::new()
641        } else {
642            format!("/{lang}")
643        };
644
645        // Convert section name to title case
646        let title = section
647            .chars()
648            .next()
649            .map(|c| c.to_uppercase().collect::<String>() + &section[1..])
650            .unwrap_or_else(|| section.to_string());
651
652        let mut ctx = TemplateContext::new()
653            .with_var("title", &title)
654            .with_var("items", items_html);
655
656        if let Some(desc) = description {
657            ctx.insert("description", desc);
658        }
659
660        if let Some(pagination) = pagination_html {
661            ctx.insert("pagination", pagination);
662        }
663
664        let inner_html = self.templates.render("section", &ctx)?;
665
666        let canonical_url = format!("{}{}/{}", self.config.base_url(), lang_prefix, section);
667
668        let mut base_ctx =
669            self.build_shared_base_ctx(lang, &title, &canonical_url, &inner_html, &lang_prefix);
670
671        // Generate language switcher
672        let lang_switcher = self.generate_lang_switcher(lang, section);
673        if !lang_switcher.is_empty() {
674            base_ctx.insert("lang_switcher", lang_switcher);
675        }
676
677        Ok(self.templates.render("base", &base_ctx)?)
678    }
679
680    /// Generate a shorts section index page (uses shorts-specific template).
681    pub fn generate_shorts_page(
682        &self,
683        section: &str,
684        description: Option<&str>,
685        items_html: &str,
686        pagination_html: Option<&str>,
687        lang: &str,
688    ) -> Result<String> {
689        let is_default_lang = lang == self.config.site.default_language;
690        let lang_prefix = if is_default_lang {
691            String::new()
692        } else {
693            format!("/{lang}")
694        };
695
696        // Convert section name to title case
697        let title = section
698            .chars()
699            .next()
700            .map(|c| c.to_uppercase().collect::<String>() + &section[1..])
701            .unwrap_or_else(|| section.to_string());
702
703        let mut ctx = TemplateContext::new()
704            .with_var("title", &title)
705            .with_var("items", items_html);
706
707        if let Some(desc) = description {
708            ctx.insert("description", desc);
709        }
710
711        if let Some(pagination) = pagination_html {
712            ctx.insert("pagination", pagination);
713        }
714
715        // Use shorts template
716        let inner_html = self.templates.render("shorts", &ctx)?;
717
718        let canonical_url = format!("{}{}/{}", self.config.base_url(), lang_prefix, section);
719
720        let mut base_ctx =
721            self.build_shared_base_ctx(lang, &title, &canonical_url, &inner_html, &lang_prefix);
722
723        // Generate language switcher
724        let lang_switcher = self.generate_lang_switcher(lang, section);
725        if !lang_switcher.is_empty() {
726            base_ctx.insert("lang_switcher", lang_switcher);
727        }
728
729        Ok(self.templates.render("base", &base_ctx)?)
730    }
731}
732
733/// Generate HTML for a list item (used in list pages).
734pub fn list_item_html(page: &Page) -> String {
735    let date_html = page
736        .date
737        .map(|d| {
738            format!(
739                r#"<time datetime="{}">{}</time>"#,
740                d.format("%Y-%m-%d"),
741                d.format("%Y-%m-%d")
742            )
743        })
744        .unwrap_or_default();
745
746    let description_html = page
747        .description
748        .as_ref()
749        .filter(|d| !d.is_empty())
750        .map(|d| format!(r#"<p class="post-description">{}</p>"#, html_escape(d)))
751        .unwrap_or_default();
752
753    format!(
754        r#"<li class="post-item">
755    <div class="post-item-header">
756        <a href="{}" class="post-title">{}</a>
757        {}
758    </div>
759    {}
760</li>"#,
761        html_escape(&page.url),
762        html_escape(&page.title),
763        date_html,
764        description_html
765    )
766}
767
768/// Generate HTML for a short item (minimalist layout).
769pub fn short_item_html(page: &Page, _author: &str) -> String {
770    let date_html = page
771        .date
772        .map(|d| {
773            format!(
774                r#"<time class="short-date" datetime="{}">{}</time>"#,
775                d.format("%Y-%m-%d"),
776                d.format("%b %d, %Y")
777            )
778        })
779        .unwrap_or_default();
780
781    // Use actual content for shorts display
782    let content_html = &page.content;
783
784    format!(
785        r#"<div class="short-item">
786    {date_html}
787    <div class="short-content">
788        {content_html}
789    </div>
790</div>"#
791    )
792}
793
794/// Generate HTML for shorts with date separators.
795pub fn shorts_with_separators_html(pages: &[&Page], author: &str) -> String {
796    let mut result = String::new();
797    let mut last_date: Option<chrono::NaiveDate> = None;
798
799    for page in pages {
800        if let Some(date) = page.date {
801            let current_date = date.date_naive();
802
803            // Add separator if date changes
804            if let Some(prev_date) = last_date
805                && current_date != prev_date
806            {
807                result.push_str(r#"<hr class="date-separator">"#);
808            }
809
810            last_date = Some(current_date);
811        }
812
813        result.push_str(&short_item_html(page, author));
814    }
815
816    result
817}
818
819/// Generate pagination HTML.
820pub fn pagination_html(current: usize, total: usize, base_url: &str) -> Option<String> {
821    if total <= 1 {
822        return None;
823    }
824
825    let mut parts = Vec::new();
826
827    if current > 1 {
828        let prev_url = if current == 2 {
829            base_url.to_string()
830        } else {
831            format!("{}/page/{}", base_url, current - 1)
832        };
833        parts.push(format!(r#"<a href="{prev_url}" rel="prev">← Previous</a>"#));
834    }
835
836    parts.push(format!("Page {current} of {total}"));
837
838    if current < total {
839        parts.push(format!(
840            r#"<a href="{}/page/{}" rel="next">Next →</a>"#,
841            base_url,
842            current + 1
843        ));
844    }
845
846    Some(format!(
847        r#"<nav class="pagination">{}</nav>"#,
848        parts.join(" ")
849    ))
850}
851
852#[cfg(test)]
853mod tests {
854    use std::collections::HashMap;
855
856    use typstify_core::test_fixtures::{test_config, test_page};
857
858    use super::*;
859
860    #[test]
861    fn test_generate_page() {
862        let config = test_config();
863        let generator = HtmlGenerator::new(&config);
864        let page = test_page();
865
866        let html = generator.generate_page(&page, &[]).unwrap();
867
868        assert!(html.contains("<!DOCTYPE html>"));
869        assert!(html.contains("<title>Test Page | Test Site</title>"));
870        assert!(html.contains("<p>Hello, World!</p>"));
871        assert!(html.contains("Test Site"));
872    }
873
874    #[test]
875    fn test_generate_redirect() {
876        let config = test_config();
877        let generator = HtmlGenerator::new(&config);
878
879        let html = generator
880            .generate_redirect("https://example.com/new-url")
881            .unwrap();
882
883        assert!(html.contains("Redirecting"));
884        assert!(html.contains("https://example.com/new-url"));
885        assert!(html.contains(r#"http-equiv="refresh""#));
886    }
887
888    #[test]
889    fn test_list_item_html() {
890        let page = test_page();
891        let html = list_item_html(&page);
892
893        assert!(html.contains(r#"<li class="post-item">"#));
894        assert!(html.contains("post-title"));
895        assert!(html.contains("Test Page"));
896        assert!(html.contains("/test-page"));
897    }
898
899    #[test]
900    fn test_pagination_html() {
901        // Single page - no pagination
902        assert!(pagination_html(1, 1, "/blog").is_none());
903
904        // First page of many
905        let html = pagination_html(1, 5, "/blog").unwrap();
906        assert!(html.contains("Page 1 of 5"));
907        assert!(html.contains("Next →"));
908        assert!(!html.contains("Previous"));
909
910        // Middle page
911        let html = pagination_html(3, 5, "/blog").unwrap();
912        assert!(html.contains("Page 3 of 5"));
913        assert!(html.contains("Previous"));
914        assert!(html.contains("Next →"));
915
916        // Last page
917        let html = pagination_html(5, 5, "/blog").unwrap();
918        assert!(html.contains("Page 5 of 5"));
919        assert!(html.contains("Previous"));
920        assert!(!html.contains("Next →"));
921    }
922
923    #[test]
924    fn test_output_path() {
925        let config = test_config();
926        let generator = HtmlGenerator::new(&config);
927        let output_dir = Path::new("public");
928
929        let page = test_page();
930        let path = generator.output_path(&page, output_dir);
931        assert_eq!(path, PathBuf::from("public/test-page/index.html"));
932
933        // Root page
934        let mut root_page = test_page();
935        root_page.url = "/".to_string();
936        let path = generator.output_path(&root_page, output_dir);
937        assert_eq!(path, PathBuf::from("public/index.html"));
938    }
939
940    #[test]
941    fn test_generate_list_page() {
942        let config = test_config();
943        let generator = HtmlGenerator::new(&config);
944
945        let html = generator
946            .generate_list_page("My Posts", "<li>Post 1</li>", None)
947            .unwrap();
948
949        assert!(html.contains("<!DOCTYPE html>"));
950        assert!(html.contains("My Posts"));
951        assert!(html.contains("<li>Post 1</li>"));
952        assert!(html.contains("post-list"));
953    }
954
955    #[test]
956    fn test_generate_list_page_with_pagination() {
957        let config = test_config();
958        let generator = HtmlGenerator::new(&config);
959
960        let pagination = r#"<nav class="pagination">Page 1 of 3</nav>"#;
961        let html = generator
962            .generate_list_page("Blog", "<li>Item</li>", Some(pagination))
963            .unwrap();
964
965        assert!(html.contains("Blog"));
966        assert!(html.contains(r#"<nav class="pagination">"#));
967        assert!(html.contains("Page 1 of 3"));
968    }
969
970    #[test]
971    fn test_generate_taxonomy_page() {
972        let config = test_config();
973        let generator = HtmlGenerator::new(&config);
974
975        let html = generator
976            .generate_taxonomy_page("Tags", "rust", "<li>Rust Post</li>", None)
977            .unwrap();
978
979        assert!(html.contains("<!DOCTYPE html>"));
980        assert!(html.contains("Tags"));
981        assert!(html.contains("rust"));
982        assert!(html.contains("<li>Rust Post</li>"));
983        assert!(html.contains("taxonomy"));
984    }
985
986    #[test]
987    fn test_generate_taxonomy_page_with_pagination() {
988        let config = test_config();
989        let generator = HtmlGenerator::new(&config);
990
991        let pagination = r#"<nav class="pagination">Page 2 of 5</nav>"#;
992        let html = generator
993            .generate_taxonomy_page(
994                "Categories",
995                "tutorial",
996                "<li>Tutorial Post</li>",
997                Some(pagination),
998            )
999            .unwrap();
1000
1001        assert!(html.contains("Categories"));
1002        assert!(html.contains("tutorial"));
1003        assert!(html.contains("Page 2 of 5"));
1004    }
1005
1006    #[test]
1007    fn test_generate_tags_index_page() {
1008        let config = test_config();
1009        let generator = HtmlGenerator::new(&config);
1010
1011        let mut tags = HashMap::new();
1012        tags.insert(
1013            "rust".to_string(),
1014            vec!["page1".to_string(), "page2".to_string()],
1015        );
1016        tags.insert("web".to_string(), vec!["page3".to_string()]);
1017
1018        let html = generator.generate_tags_index_page(&tags, "en").unwrap();
1019
1020        assert!(html.contains("<!DOCTYPE html>"));
1021        assert!(html.contains("Tags"));
1022        assert!(html.contains("tag-item"));
1023        assert!(html.contains("rust"));
1024        assert!(html.contains("web"));
1025        assert!(html.contains("tag-count"));
1026        assert!(html.contains("tag-name"));
1027    }
1028
1029    #[test]
1030    fn test_generate_categories_index_page() {
1031        let config = test_config();
1032        let generator = HtmlGenerator::new(&config);
1033
1034        let mut categories = HashMap::new();
1035        categories.insert(
1036            "programming".to_string(),
1037            vec!["p1".to_string(), "p2".to_string()],
1038        );
1039        categories.insert("design".to_string(), vec!["p3".to_string()]);
1040
1041        let html = generator
1042            .generate_categories_index_page(&categories, "en")
1043            .unwrap();
1044
1045        assert!(html.contains("<!DOCTYPE html>"));
1046        assert!(html.contains("Categories"));
1047        assert!(html.contains("programming"));
1048        assert!(html.contains("design"));
1049        assert!(html.contains("categories-list"));
1050    }
1051
1052    #[test]
1053    fn test_generate_archives_page() {
1054        let config = test_config();
1055        let generator = HtmlGenerator::new(&config);
1056
1057        let mut page = test_page();
1058        page.date = Some(
1059            chrono::NaiveDateTime::parse_from_str("2024-01-15T10:00:00", "%Y-%m-%dT%H:%M:%S")
1060                .unwrap()
1061                .and_utc(),
1062        );
1063        page.title = "January Post".to_string();
1064
1065        let html = generator.generate_archives_page(&[&page], "en").unwrap();
1066
1067        assert!(html.contains("<!DOCTYPE html>"));
1068        assert!(html.contains("Archives"));
1069        assert!(html.contains("archive-year"));
1070        assert!(html.contains("2024"));
1071        assert!(html.contains("January Post"));
1072    }
1073
1074    #[test]
1075    fn test_generate_archives_page_empty() {
1076        let config = test_config();
1077        let generator = HtmlGenerator::new(&config);
1078
1079        let html = generator.generate_archives_page(&[], "en").unwrap();
1080
1081        assert!(html.contains("<!DOCTYPE html>"));
1082        assert!(html.contains("Archives"));
1083    }
1084
1085    #[test]
1086    fn test_generate_section_page() {
1087        let config = test_config();
1088        let generator = HtmlGenerator::new(&config);
1089
1090        let html = generator
1091            .generate_section_page(
1092                "posts",
1093                Some("All blog posts"),
1094                "<li>Post A</li>",
1095                None,
1096                "en",
1097            )
1098            .unwrap();
1099
1100        assert!(html.contains("<!DOCTYPE html>"));
1101        assert!(html.contains("Posts"));
1102        assert!(html.contains("All blog posts"));
1103        assert!(html.contains("<li>Post A</li>"));
1104        assert!(html.contains("section-list"));
1105    }
1106
1107    #[test]
1108    fn test_generate_section_page_with_pagination() {
1109        let config = test_config();
1110        let generator = HtmlGenerator::new(&config);
1111
1112        let pagination = r#"<nav class="pagination">Page 1 of 2</nav>"#;
1113        let html = generator
1114            .generate_section_page("tutorials", None, "<li>Tut 1</li>", Some(pagination), "en")
1115            .unwrap();
1116
1117        assert!(html.contains("Tutorials"));
1118        assert!(html.contains("Page 1 of 2"));
1119    }
1120
1121    #[test]
1122    fn test_generate_shorts_page() {
1123        let config = test_config();
1124        let generator = HtmlGenerator::new(&config);
1125
1126        let html = generator
1127            .generate_shorts_page(
1128                "shorts",
1129                Some("Short-form content"),
1130                "<div class=\"short-item\">Short 1</div>",
1131                None,
1132                "en",
1133            )
1134            .unwrap();
1135
1136        assert!(html.contains("<!DOCTYPE html>"));
1137        assert!(html.contains("Shorts"));
1138        assert!(html.contains("Short-form content"));
1139        assert!(html.contains("Short 1"));
1140        assert!(html.contains("shorts-section"));
1141    }
1142
1143    #[test]
1144    fn test_generate_shorts_page_with_pagination() {
1145        let config = test_config();
1146        let generator = HtmlGenerator::new(&config);
1147
1148        let pagination = r#"<nav class="pagination">Page 1 of 4</nav>"#;
1149        let html = generator
1150            .generate_shorts_page(
1151                "notes",
1152                None,
1153                "<div class=\"short-item\">Note 1</div>",
1154                Some(pagination),
1155                "en",
1156            )
1157            .unwrap();
1158
1159        assert!(html.contains("Notes"));
1160        assert!(html.contains("Page 1 of 4"));
1161    }
1162}