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::{Config, Page};
11
12use crate::template::{Template, TemplateContext, TemplateError, TemplateRegistry};
13
14/// HTML generation errors.
15#[derive(Debug, Error)]
16pub enum HtmlError {
17    /// Template error.
18    #[error("template error: {0}")]
19    Template(#[from] TemplateError),
20
21    /// IO error.
22    #[error("IO error: {0}")]
23    Io(#[from] std::io::Error),
24
25    /// Invalid page data.
26    #[error("invalid page data: {0}")]
27    InvalidPage(String),
28}
29
30/// Result type for HTML generation.
31pub type Result<T> = std::result::Result<T, HtmlError>;
32
33/// HTML page generator.
34#[derive(Debug)]
35pub struct HtmlGenerator {
36    templates: TemplateRegistry,
37    config: Config,
38}
39
40impl HtmlGenerator {
41    /// Create a new HTML generator with the given configuration.
42    #[must_use]
43    pub fn new(config: Config) -> Self {
44        Self {
45            templates: TemplateRegistry::new(),
46            config,
47        }
48    }
49
50    /// Create a generator with custom templates.
51    #[must_use]
52    pub fn with_templates(config: Config, templates: TemplateRegistry) -> Self {
53        Self { templates, config }
54    }
55
56    /// Register a custom template.
57    pub fn register_template(&mut self, template: Template) {
58        self.templates.register(template);
59    }
60
61    /// Generate HTML for a page.
62    pub fn generate_page(&self, page: &Page, alternates: &[(&str, &str)]) -> Result<String> {
63        debug!(url = %page.url, "generating HTML for page");
64
65        // Determine which template to use
66        let template_name =
67            page.template
68                .as_deref()
69                .unwrap_or(if page.date.is_some() { "post" } else { "page" });
70
71        // Build inner content context
72        let inner_ctx = self.build_page_context(page)?;
73        let inner_html = self.templates.render(template_name, &inner_ctx)?;
74
75        // Build outer (base) context
76        let base_ctx = self.build_base_context(page, &inner_html, alternates)?;
77        Ok(self.templates.render("base", &base_ctx)?)
78    }
79
80    /// Generate redirect HTML for URL aliases.
81    pub fn generate_redirect(&self, redirect_url: &str) -> Result<String> {
82        let ctx = TemplateContext::new().with_var("redirect_url", redirect_url);
83        self.templates
84            .render("redirect", &ctx)
85            .map_err(HtmlError::from)
86    }
87
88    /// Generate a list page HTML.
89    pub fn generate_list_page(
90        &self,
91        title: &str,
92        items_html: &str,
93        pagination_html: Option<&str>,
94    ) -> Result<String> {
95        let mut ctx = TemplateContext::new()
96            .with_var("title", title)
97            .with_var("items", items_html);
98
99        if let Some(pagination) = pagination_html {
100            ctx.insert("pagination", pagination);
101        }
102
103        let inner_html = self.templates.render("list", &ctx)?;
104
105        // Wrap in base template
106        let base_ctx = TemplateContext::new()
107            .with_var("lang", &self.config.site.default_language)
108            .with_var("title", title)
109            .with_var(
110                "site_title_suffix",
111                format!(" | {}", self.config.site.title),
112            )
113            .with_var("canonical_url", &self.config.site.base_url)
114            .with_var("content", &inner_html)
115            .with_var("site_title", &self.config.site.title)
116            .with_var("year", Utc::now().year().to_string())
117            // Navigation URLs
118            .with_var("nav_home_url", "/")
119            .with_var("nav_posts_url", "/posts")
120            .with_var("nav_archives_url", "/archives")
121            .with_var("nav_tags_url", "/tags")
122            .with_var("nav_about_url", "/about");
123
124        Ok(self.templates.render("base", &base_ctx)?)
125    }
126
127    /// Generate a taxonomy term page HTML.
128    pub fn generate_taxonomy_page(
129        &self,
130        taxonomy_name: &str,
131        term: &str,
132        items_html: &str,
133        pagination_html: Option<&str>,
134    ) -> Result<String> {
135        let mut ctx = TemplateContext::new()
136            .with_var("taxonomy_name", taxonomy_name)
137            .with_var("term", term)
138            .with_var("items", items_html);
139
140        if let Some(pagination) = pagination_html {
141            ctx.insert("pagination", pagination);
142        }
143
144        let inner_html = self.templates.render("taxonomy", &ctx)?;
145        let title = format!("{taxonomy_name}: {term}");
146
147        // Wrap in base template
148        let base_ctx = TemplateContext::new()
149            .with_var("lang", &self.config.site.default_language)
150            .with_var("title", &title)
151            .with_var(
152                "site_title_suffix",
153                format!(" | {}", self.config.site.title),
154            )
155            .with_var(
156                "canonical_url",
157                format!(
158                    "{}/{}/{}",
159                    self.config.site.base_url,
160                    taxonomy_name.to_lowercase(),
161                    term
162                ),
163            )
164            .with_var("content", &inner_html)
165            .with_var("site_title", &self.config.site.title)
166            .with_var("year", Utc::now().year().to_string())
167            // Navigation URLs
168            .with_var("nav_home_url", "/")
169            .with_var("nav_posts_url", "/posts")
170            .with_var("nav_archives_url", "/archives")
171            .with_var("nav_tags_url", "/tags")
172            .with_var("nav_about_url", "/about");
173
174        Ok(self.templates.render("base", &base_ctx)?)
175    }
176
177    /// Build template context for page content.
178    fn build_page_context(&self, page: &Page) -> Result<TemplateContext> {
179        let mut ctx = TemplateContext::new()
180            .with_var("title", &page.title)
181            .with_var("content", &page.content);
182
183        // Add date if present
184        if let Some(date) = page.date {
185            ctx.insert("date_iso", date.format("%Y-%m-%d").to_string());
186            ctx.insert("date_formatted", date.format("%B %d, %Y").to_string());
187        }
188
189        // Add tags HTML if present
190        if !page.tags.is_empty() {
191            let tags_html = page
192                .tags
193                .iter()
194                .map(|tag| {
195                    format!(
196                        r#"<a href="/tags/{}" rel="tag">{}</a>"#,
197                        slug_from_str(tag),
198                        tag
199                    )
200                })
201                .collect::<Vec<_>>()
202                .join(" ");
203            ctx.insert(
204                "tags_html",
205                format!(r#"<div class="tags">{tags_html}</div>"#),
206            );
207        }
208
209        Ok(ctx)
210    }
211
212    /// Build template context for base HTML wrapper.
213    fn build_base_context(
214        &self,
215        page: &Page,
216        inner_html: &str,
217        alternates: &[(&str, &str)],
218    ) -> Result<TemplateContext> {
219        // Determine language prefix for URLs
220        let lang_prefix = if page.is_default_lang {
221            String::new()
222        } else {
223            format!("/{}", page.lang)
224        };
225
226        let mut ctx = TemplateContext::new()
227            .with_var("lang", &page.lang)
228            .with_var("title", &page.title)
229            .with_var(
230                "site_title_suffix",
231                format!(" | {}", self.config.title_for_language(&page.lang)),
232            )
233            .with_var(
234                "canonical_url",
235                format!("{}{}", self.config.site.base_url, page.url),
236            )
237            .with_var("content", inner_html)
238            .with_var("site_title", self.config.title_for_language(&page.lang))
239            .with_var("year", Utc::now().year().to_string())
240            // Navigation URLs with language prefix
241            .with_var("nav_home_url", format!("{lang_prefix}/"))
242            .with_var("nav_posts_url", format!("{lang_prefix}/posts"))
243            .with_var("nav_archives_url", format!("{lang_prefix}/archives"))
244            .with_var("nav_tags_url", format!("{lang_prefix}/tags"))
245            .with_var("nav_about_url", format!("{lang_prefix}/about"));
246
247        // Add description if present
248        if let Some(desc) = &page.description {
249            ctx.insert("description", desc);
250        } else if let Some(site_desc) = self.config.description_for_language(&page.lang) {
251            ctx.insert("description", site_desc);
252        }
253
254        // Add author if present
255        if let Some(author) = &self.config.site.author {
256            ctx.insert("author", author);
257        }
258
259        // Add custom CSS
260        if !page.custom_css.is_empty() {
261            let css_links = page
262                .custom_css
263                .iter()
264                .map(|href| format!(r#"<link rel="stylesheet" href="{href}">"#))
265                .collect::<Vec<_>>()
266                .join("\n");
267            ctx.insert("custom_css", css_links);
268        }
269
270        // Add custom JS
271        if !page.custom_js.is_empty() {
272            let js_scripts = page
273                .custom_js
274                .iter()
275                .map(|src| format!(r#"<script src="{src}"></script>"#))
276                .collect::<Vec<_>>()
277                .join("\n");
278            ctx.insert("custom_js", js_scripts);
279        }
280
281        // Generate language switcher HTML
282        let lang_switcher = self.generate_lang_switcher(&page.lang, &page.canonical_id);
283        if !lang_switcher.is_empty() {
284            ctx.insert("lang_switcher", lang_switcher);
285        }
286
287        // Add hreflang tags
288        if !alternates.is_empty() {
289            let hreflang = alternates
290                .iter()
291                .map(|(lang, url)| {
292                    format!(
293                        r#"<link rel="alternate" hreflang="{}" href="{}{}" />"#,
294                        lang, self.config.site.base_url, url
295                    )
296                })
297                .collect::<Vec<_>>()
298                .join("\n");
299            ctx.insert("hreflang", hreflang);
300        }
301
302        Ok(ctx)
303    }
304
305    /// Generate language switcher HTML dropdown.
306    fn generate_lang_switcher(&self, current_lang: &str, canonical_id: &str) -> String {
307        let all_langs = self.config.all_languages();
308        if all_langs.len() <= 1 {
309            return String::new();
310        }
311
312        let mut options = Vec::new();
313
314        for lang in &all_langs {
315            let name = self.config.language_name(lang);
316            let url = if *lang == self.config.site.default_language {
317                // Default language: no prefix
318                if canonical_id.is_empty() {
319                    "/".to_string()
320                } else {
321                    format!("/{canonical_id}")
322                }
323            } else {
324                // Non-default language: add prefix
325                if canonical_id.is_empty() {
326                    format!("/{lang}/")
327                } else {
328                    format!("/{lang}/{canonical_id}")
329                }
330            };
331
332            let selected_class = if *lang == current_lang { " active" } else { "" };
333            options.push(format!(
334                r#"<a href="{url}" class="lang-option{selected_class}">{name}</a>"#,
335            ));
336        }
337
338        // Get the language code for display (uppercase, max 2 chars)
339        let display_code = current_lang
340            .chars()
341            .take(2)
342            .collect::<String>()
343            .to_uppercase();
344
345        format!(
346            r#"<div class="lang-switcher" tabindex="0" role="button" aria-label="Switch language" aria-haspopup="true">
347    <span class="lang-code">{}</span>
348    <div class="lang-dropdown">{}</div>
349</div>"#,
350            display_code,
351            options.join("\n        ")
352        )
353    }
354
355    /// Get the output path for a page.
356    #[must_use]
357    pub fn output_path(&self, page: &Page, output_dir: &Path) -> PathBuf {
358        let relative = page.url.trim_start_matches('/');
359
360        if relative.is_empty() {
361            output_dir.join("index.html")
362        } else {
363            output_dir.join(relative).join("index.html")
364        }
365    }
366
367    /// Generate a tags index page listing all tags with their counts.
368    pub fn generate_tags_index_page(
369        &self,
370        tags: &std::collections::HashMap<String, Vec<String>>,
371        lang: &str,
372    ) -> Result<String> {
373        let is_default_lang = lang == self.config.site.default_language;
374        let lang_prefix = if is_default_lang {
375            String::new()
376        } else {
377            format!("/{lang}")
378        };
379
380        let mut items: Vec<_> = tags.iter().collect();
381        items.sort_by(|a, b| b.1.len().cmp(&a.1.len())); // Sort by count descending
382
383        let items_html: String = items
384            .iter()
385            .map(|(tag, pages)| {
386                format!(
387                    r#"<a href="{}/tags/{}" class="tag-item"><span class="tag-name">{}</span><span class="tag-count">{}</span></a>"#,
388                    lang_prefix,
389                    slug_from_str(tag),
390                    tag,
391                    pages.len()
392                )
393            })
394            .collect::<Vec<_>>()
395            .join("\n");
396
397        let ctx = TemplateContext::new().with_var("items", &items_html);
398        let inner_html = self.templates.render("tags_index", &ctx)?;
399
400        let mut base_ctx = TemplateContext::new()
401            .with_var("lang", lang)
402            .with_var("title", "Tags")
403            .with_var(
404                "site_title_suffix",
405                format!(" | {}", self.config.title_for_language(lang)),
406            )
407            .with_var(
408                "canonical_url",
409                format!("{}{}/tags", self.config.site.base_url, lang_prefix),
410            )
411            .with_var("content", &inner_html)
412            .with_var("site_title", self.config.title_for_language(lang))
413            .with_var("year", Utc::now().year().to_string())
414            // Navigation URLs
415            .with_var("nav_home_url", format!("{lang_prefix}/"))
416            .with_var("nav_posts_url", format!("{lang_prefix}/posts"))
417            .with_var("nav_archives_url", format!("{lang_prefix}/archives"))
418            .with_var("nav_tags_url", format!("{lang_prefix}/tags"))
419            .with_var("nav_about_url", format!("{lang_prefix}/about"));
420
421        // Generate language switcher
422        let lang_switcher = self.generate_lang_switcher(lang, "tags");
423        if !lang_switcher.is_empty() {
424            base_ctx.insert("lang_switcher", lang_switcher);
425        }
426
427        Ok(self.templates.render("base", &base_ctx)?)
428    }
429
430    /// Generate a categories index page listing all categories with their counts.
431    pub fn generate_categories_index_page(
432        &self,
433        categories: &std::collections::HashMap<String, Vec<String>>,
434        lang: &str,
435    ) -> Result<String> {
436        let is_default_lang = lang == self.config.site.default_language;
437        let lang_prefix = if is_default_lang {
438            String::new()
439        } else {
440            format!("/{lang}")
441        };
442
443        let mut items: Vec<_> = categories.iter().collect();
444        items.sort_by(|a, b| a.0.cmp(b.0)); // Sort alphabetically
445
446        let items_html: String = items
447            .iter()
448            .map(|(category, pages)| {
449                format!(
450                    r#"<li><a href="{}/categories/{}">{}</a> <span class="count">({})</span></li>"#,
451                    lang_prefix,
452                    slug_from_str(category),
453                    category,
454                    pages.len()
455                )
456            })
457            .collect::<Vec<_>>()
458            .join("\n");
459
460        let ctx = TemplateContext::new().with_var("items", &items_html);
461        let inner_html = self.templates.render("categories_index", &ctx)?;
462
463        let mut base_ctx = TemplateContext::new()
464            .with_var("lang", lang)
465            .with_var("title", "Categories")
466            .with_var(
467                "site_title_suffix",
468                format!(" | {}", self.config.title_for_language(lang)),
469            )
470            .with_var(
471                "canonical_url",
472                format!("{}{}/categories", self.config.site.base_url, lang_prefix),
473            )
474            .with_var("content", &inner_html)
475            .with_var("site_title", self.config.title_for_language(lang))
476            .with_var("year", Utc::now().year().to_string())
477            // Navigation URLs
478            .with_var("nav_home_url", format!("{lang_prefix}/"))
479            .with_var("nav_posts_url", format!("{lang_prefix}/posts"))
480            .with_var("nav_archives_url", format!("{lang_prefix}/archives"))
481            .with_var("nav_tags_url", format!("{lang_prefix}/tags"))
482            .with_var("nav_about_url", format!("{lang_prefix}/about"));
483
484        // Generate language switcher
485        let lang_switcher = self.generate_lang_switcher(lang, "categories");
486        if !lang_switcher.is_empty() {
487            base_ctx.insert("lang_switcher", lang_switcher);
488        }
489
490        Ok(self.templates.render("base", &base_ctx)?)
491    }
492
493    /// Generate an archives page listing all posts grouped by year.
494    pub fn generate_archives_page(&self, pages: &[&Page], lang: &str) -> Result<String> {
495        use std::collections::BTreeMap;
496
497        let is_default_lang = lang == self.config.site.default_language;
498        let lang_prefix = if is_default_lang {
499            String::new()
500        } else {
501            format!("/{lang}")
502        };
503
504        // Group pages by year
505        let mut by_year: BTreeMap<i32, Vec<&Page>> = BTreeMap::new();
506        for page in pages {
507            if let Some(date) = page.date {
508                by_year.entry(date.year()).or_default().push(page);
509            }
510        }
511
512        // Sort pages within each year by date (newest first)
513        for pages in by_year.values_mut() {
514            pages.sort_by(|a, b| b.date.cmp(&a.date));
515        }
516
517        // Generate HTML (years in descending order)
518        let items_html: String = by_year
519            .iter()
520            .rev()
521            .map(|(year, year_pages)| {
522                let posts_html: String = year_pages
523                    .iter()
524                    .map(|p| {
525                        let date_str = p
526                            .date
527                            .map(|d| d.format("%m-%d").to_string())
528                            .unwrap_or_default();
529                        format!(
530                            r#"<li><span class="archive-date">{}</span><a href="{}">{}</a></li>"#,
531                            date_str, p.url, p.title
532                        )
533                    })
534                    .collect::<Vec<_>>()
535                    .join("\n");
536
537                format!(r#"<div class="archive-year"><h2>{year}</h2><ul>{posts_html}</ul></div>"#,)
538            })
539            .collect::<Vec<_>>()
540            .join("\n");
541
542        let ctx = TemplateContext::new().with_var("items", &items_html);
543        let inner_html = self.templates.render("archives", &ctx)?;
544
545        let mut base_ctx = TemplateContext::new()
546            .with_var("lang", lang)
547            .with_var("title", "Archives")
548            .with_var(
549                "site_title_suffix",
550                format!(" | {}", self.config.title_for_language(lang)),
551            )
552            .with_var(
553                "canonical_url",
554                format!("{}{}/archives", self.config.site.base_url, lang_prefix),
555            )
556            .with_var("content", &inner_html)
557            .with_var("site_title", self.config.title_for_language(lang))
558            .with_var("year", Utc::now().year().to_string())
559            // Navigation URLs
560            .with_var("nav_home_url", format!("{lang_prefix}/"))
561            .with_var("nav_posts_url", format!("{lang_prefix}/posts"))
562            .with_var("nav_archives_url", format!("{lang_prefix}/archives"))
563            .with_var("nav_tags_url", format!("{lang_prefix}/tags"))
564            .with_var("nav_about_url", format!("{lang_prefix}/about"));
565
566        // Generate language switcher
567        let lang_switcher = self.generate_lang_switcher(lang, "archives");
568        if !lang_switcher.is_empty() {
569            base_ctx.insert("lang_switcher", lang_switcher);
570        }
571
572        Ok(self.templates.render("base", &base_ctx)?)
573    }
574
575    /// Generate a section index page (e.g., /posts/).
576    pub fn generate_section_page(
577        &self,
578        section: &str,
579        description: Option<&str>,
580        items_html: &str,
581        pagination_html: Option<&str>,
582        lang: &str,
583    ) -> Result<String> {
584        let is_default_lang = lang == self.config.site.default_language;
585        let lang_prefix = if is_default_lang {
586            String::new()
587        } else {
588            format!("/{lang}")
589        };
590
591        // Convert section name to title case
592        let title = section
593            .chars()
594            .next()
595            .map(|c| c.to_uppercase().collect::<String>() + &section[1..])
596            .unwrap_or_else(|| section.to_string());
597
598        let mut ctx = TemplateContext::new()
599            .with_var("title", &title)
600            .with_var("items", items_html);
601
602        if let Some(desc) = description {
603            ctx.insert("description", desc);
604        }
605
606        if let Some(pagination) = pagination_html {
607            ctx.insert("pagination", pagination);
608        }
609
610        let inner_html = self.templates.render("section", &ctx)?;
611
612        let mut base_ctx = TemplateContext::new()
613            .with_var("lang", lang)
614            .with_var("title", &title)
615            .with_var(
616                "site_title_suffix",
617                format!(" | {}", self.config.title_for_language(lang)),
618            )
619            .with_var(
620                "canonical_url",
621                format!("{}{}/{}", self.config.site.base_url, lang_prefix, section),
622            )
623            .with_var("content", &inner_html)
624            .with_var("site_title", self.config.title_for_language(lang))
625            .with_var("year", Utc::now().year().to_string())
626            // Navigation URLs
627            .with_var("nav_home_url", format!("{lang_prefix}/"))
628            .with_var("nav_posts_url", format!("{lang_prefix}/posts"))
629            .with_var("nav_archives_url", format!("{lang_prefix}/archives"))
630            .with_var("nav_tags_url", format!("{lang_prefix}/tags"))
631            .with_var("nav_about_url", format!("{lang_prefix}/about"));
632
633        // Generate language switcher
634        let lang_switcher = self.generate_lang_switcher(lang, section);
635        if !lang_switcher.is_empty() {
636            base_ctx.insert("lang_switcher", lang_switcher);
637        }
638
639        Ok(self.templates.render("base", &base_ctx)?)
640    }
641}
642
643/// Generate a URL-safe slug from a string.
644fn slug_from_str(s: &str) -> String {
645    s.to_lowercase()
646        .chars()
647        .map(|c| if c.is_alphanumeric() { c } else { '-' })
648        .collect::<String>()
649        .split('-')
650        .filter(|s| !s.is_empty())
651        .collect::<Vec<_>>()
652        .join("-")
653}
654
655/// Generate HTML for a list item (used in list pages).
656pub fn list_item_html(page: &Page) -> String {
657    let date_html = page
658        .date
659        .map(|d| {
660            format!(
661                r#"<time datetime="{}">{}</time>"#,
662                d.format("%Y-%m-%d"),
663                d.format("%Y-%m-%d")
664            )
665        })
666        .unwrap_or_default();
667
668    let description_html = page
669        .description
670        .as_ref()
671        .filter(|d| !d.is_empty())
672        .map(|d| format!(r#"<p class="post-description">{d}</p>"#))
673        .unwrap_or_default();
674
675    format!(
676        r#"<li class="post-item">
677    <div class="post-item-header">
678        <a href="{}" class="post-title">{}</a>
679        {}
680    </div>
681    {}
682</li>"#,
683        page.url, page.title, date_html, description_html
684    )
685}
686
687/// Generate pagination HTML.
688pub fn pagination_html(current: usize, total: usize, base_url: &str) -> Option<String> {
689    if total <= 1 {
690        return None;
691    }
692
693    let mut parts = Vec::new();
694
695    if current > 1 {
696        let prev_url = if current == 2 {
697            base_url.to_string()
698        } else {
699            format!("{}/page/{}", base_url, current - 1)
700        };
701        parts.push(format!(r#"<a href="{prev_url}" rel="prev">← Previous</a>"#));
702    }
703
704    parts.push(format!("Page {current} of {total}"));
705
706    if current < total {
707        parts.push(format!(
708            r#"<a href="{}/page/{}" rel="next">Next →</a>"#,
709            base_url,
710            current + 1
711        ));
712    }
713
714    Some(format!(
715        r#"<nav class="pagination">{}</nav>"#,
716        parts.join(" ")
717    ))
718}
719
720#[cfg(test)]
721mod tests {
722    use std::collections::HashMap;
723
724    use super::*;
725
726    fn test_config() -> Config {
727        Config {
728            site: typstify_core::config::SiteConfig {
729                title: "Test Site".to_string(),
730                base_url: "https://example.com".to_string(),
731                default_language: "en".to_string(),
732                description: Some("A test site".to_string()),
733                author: Some("Test Author".to_string()),
734            },
735            languages: HashMap::new(),
736            build: typstify_core::config::BuildConfig::default(),
737            search: typstify_core::config::SearchConfig::default(),
738            rss: typstify_core::config::RssConfig::default(),
739            robots: typstify_core::config::RobotsConfig::default(),
740            taxonomies: typstify_core::config::TaxonomyConfig::default(),
741        }
742    }
743
744    fn test_page() -> Page {
745        Page {
746            url: "/test-page".to_string(),
747            title: "Test Page".to_string(),
748            description: Some("A test page".to_string()),
749            date: None,
750            updated: None,
751            draft: false,
752            lang: "en".to_string(),
753            is_default_lang: true,
754            canonical_id: "test-page".to_string(),
755            tags: vec![],
756            categories: vec![],
757            content: "<p>Hello, World!</p>".to_string(),
758            summary: None,
759            reading_time: None,
760            word_count: None,
761            toc: vec![],
762            custom_js: vec![],
763            custom_css: vec![],
764            aliases: vec![],
765            template: None,
766            weight: 0,
767            source_path: Some(PathBuf::from("test-page.md")),
768        }
769    }
770
771    #[test]
772    fn test_generate_page() {
773        let generator = HtmlGenerator::new(test_config());
774        let page = test_page();
775
776        let html = generator.generate_page(&page, &[]).unwrap();
777
778        assert!(html.contains("<!DOCTYPE html>"));
779        assert!(html.contains("<title>Test Page | Test Site</title>"));
780        assert!(html.contains("<p>Hello, World!</p>"));
781        assert!(html.contains("Test Site"));
782    }
783
784    #[test]
785    fn test_generate_redirect() {
786        let generator = HtmlGenerator::new(test_config());
787
788        let html = generator
789            .generate_redirect("https://example.com/new-url")
790            .unwrap();
791
792        assert!(html.contains("Redirecting"));
793        assert!(html.contains("https://example.com/new-url"));
794        assert!(html.contains(r#"http-equiv="refresh""#));
795    }
796
797    #[test]
798    fn test_slug_from_str() {
799        assert_eq!(slug_from_str("Hello World"), "hello-world");
800        assert_eq!(slug_from_str("Rust & Go"), "rust-go");
801        assert_eq!(slug_from_str("  multiple   spaces  "), "multiple-spaces");
802        assert_eq!(slug_from_str("CamelCase"), "camelcase");
803    }
804
805    #[test]
806    fn test_list_item_html() {
807        let page = test_page();
808        let html = list_item_html(&page);
809
810        assert!(html.contains(r#"<li class="post-item">"#));
811        assert!(html.contains("post-title"));
812        assert!(html.contains("Test Page"));
813        assert!(html.contains("/test-page"));
814    }
815
816    #[test]
817    fn test_pagination_html() {
818        // Single page - no pagination
819        assert!(pagination_html(1, 1, "/blog").is_none());
820
821        // First page of many
822        let html = pagination_html(1, 5, "/blog").unwrap();
823        assert!(html.contains("Page 1 of 5"));
824        assert!(html.contains("Next →"));
825        assert!(!html.contains("Previous"));
826
827        // Middle page
828        let html = pagination_html(3, 5, "/blog").unwrap();
829        assert!(html.contains("Page 3 of 5"));
830        assert!(html.contains("Previous"));
831        assert!(html.contains("Next →"));
832
833        // Last page
834        let html = pagination_html(5, 5, "/blog").unwrap();
835        assert!(html.contains("Page 5 of 5"));
836        assert!(html.contains("Previous"));
837        assert!(!html.contains("Next →"));
838    }
839
840    #[test]
841    fn test_output_path() {
842        let generator = HtmlGenerator::new(test_config());
843        let output_dir = Path::new("public");
844
845        let page = test_page();
846        let path = generator.output_path(&page, output_dir);
847        assert_eq!(path, PathBuf::from("public/test-page/index.html"));
848
849        // Root page
850        let mut root_page = test_page();
851        root_page.url = "/".to_string();
852        let path = generator.output_path(&root_page, output_dir);
853        assert_eq!(path, PathBuf::from("public/index.html"));
854    }
855}