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) -> 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)?;
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
118        Ok(self.templates.render("base", &base_ctx)?)
119    }
120
121    /// Generate a taxonomy term page HTML.
122    pub fn generate_taxonomy_page(
123        &self,
124        taxonomy_name: &str,
125        term: &str,
126        items_html: &str,
127        pagination_html: Option<&str>,
128    ) -> Result<String> {
129        let mut ctx = TemplateContext::new()
130            .with_var("taxonomy_name", taxonomy_name)
131            .with_var("term", term)
132            .with_var("items", items_html);
133
134        if let Some(pagination) = pagination_html {
135            ctx.insert("pagination", pagination);
136        }
137
138        let inner_html = self.templates.render("taxonomy", &ctx)?;
139        let title = format!("{taxonomy_name}: {term}");
140
141        // Wrap in base template
142        let base_ctx = TemplateContext::new()
143            .with_var("lang", &self.config.site.default_language)
144            .with_var("title", &title)
145            .with_var(
146                "site_title_suffix",
147                format!(" | {}", self.config.site.title),
148            )
149            .with_var(
150                "canonical_url",
151                format!(
152                    "{}/{}/{}",
153                    self.config.site.base_url,
154                    taxonomy_name.to_lowercase(),
155                    term
156                ),
157            )
158            .with_var("content", &inner_html)
159            .with_var("site_title", &self.config.site.title)
160            .with_var("year", Utc::now().year().to_string());
161
162        Ok(self.templates.render("base", &base_ctx)?)
163    }
164
165    /// Build template context for page content.
166    fn build_page_context(&self, page: &Page) -> Result<TemplateContext> {
167        let mut ctx = TemplateContext::new()
168            .with_var("title", &page.title)
169            .with_var("content", &page.content);
170
171        // Add date if present
172        if let Some(date) = page.date {
173            ctx.insert("date_iso", date.format("%Y-%m-%d").to_string());
174            ctx.insert("date_formatted", date.format("%B %d, %Y").to_string());
175        }
176
177        // Add tags HTML if present
178        if !page.tags.is_empty() {
179            let tags_html = page
180                .tags
181                .iter()
182                .map(|tag| {
183                    format!(
184                        r#"<a href="/tags/{}" rel="tag">{}</a>"#,
185                        slug_from_str(tag),
186                        tag
187                    )
188                })
189                .collect::<Vec<_>>()
190                .join(" ");
191            ctx.insert(
192                "tags_html",
193                format!(r#"<div class="tags">{tags_html}</div>"#),
194            );
195        }
196
197        Ok(ctx)
198    }
199
200    /// Build template context for base HTML wrapper.
201    fn build_base_context(&self, page: &Page, inner_html: &str) -> Result<TemplateContext> {
202        let lang = page
203            .lang
204            .as_deref()
205            .unwrap_or(&self.config.site.default_language);
206
207        let mut ctx = TemplateContext::new()
208            .with_var("lang", lang)
209            .with_var("title", &page.title)
210            .with_var(
211                "site_title_suffix",
212                format!(" | {}", self.config.site.title),
213            )
214            .with_var(
215                "canonical_url",
216                format!("{}{}", self.config.site.base_url, page.url),
217            )
218            .with_var("content", inner_html)
219            .with_var("site_title", &self.config.site.title)
220            .with_var("year", Utc::now().year().to_string());
221
222        // Add description if present
223        if let Some(desc) = &page.description {
224            ctx.insert("description", desc);
225        } else if let Some(site_desc) = &self.config.site.description {
226            ctx.insert("description", site_desc);
227        }
228
229        // Add author if present
230        if let Some(author) = &self.config.site.author {
231            ctx.insert("author", author);
232        }
233
234        // Add custom CSS
235        if !page.custom_css.is_empty() {
236            let css_links = page
237                .custom_css
238                .iter()
239                .map(|href| format!(r#"<link rel="stylesheet" href="{href}">"#))
240                .collect::<Vec<_>>()
241                .join("\n");
242            ctx.insert("custom_css", css_links);
243        }
244
245        // Add custom JS
246        if !page.custom_js.is_empty() {
247            let js_scripts = page
248                .custom_js
249                .iter()
250                .map(|src| format!(r#"<script src="{src}"></script>"#))
251                .collect::<Vec<_>>()
252                .join("\n");
253            ctx.insert("custom_js", js_scripts);
254        }
255
256        Ok(ctx)
257    }
258
259    /// Get the output path for a page.
260    #[must_use]
261    pub fn output_path(&self, page: &Page, output_dir: &Path) -> PathBuf {
262        let relative = page.url.trim_start_matches('/');
263
264        if relative.is_empty() {
265            output_dir.join("index.html")
266        } else {
267            output_dir.join(relative).join("index.html")
268        }
269    }
270}
271
272/// Generate a URL-safe slug from a string.
273fn slug_from_str(s: &str) -> String {
274    s.to_lowercase()
275        .chars()
276        .map(|c| if c.is_alphanumeric() { c } else { '-' })
277        .collect::<String>()
278        .split('-')
279        .filter(|s| !s.is_empty())
280        .collect::<Vec<_>>()
281        .join("-")
282}
283
284/// Generate HTML for a list item (used in list pages).
285pub fn list_item_html(page: &Page) -> String {
286    let date_html = page
287        .date
288        .map(|d| {
289            format!(
290                r#"<time datetime="{}">{}</time>"#,
291                d.format("%Y-%m-%d"),
292                d.format("%Y-%m-%d")
293            )
294        })
295        .unwrap_or_default();
296
297    format!(
298        r#"<li><a href="{}">{}</a> {}</li>"#,
299        page.url, page.title, date_html
300    )
301}
302
303/// Generate pagination HTML.
304pub fn pagination_html(current: usize, total: usize, base_url: &str) -> Option<String> {
305    if total <= 1 {
306        return None;
307    }
308
309    let mut parts = Vec::new();
310
311    if current > 1 {
312        let prev_url = if current == 2 {
313            base_url.to_string()
314        } else {
315            format!("{}/page/{}", base_url, current - 1)
316        };
317        parts.push(format!(r#"<a href="{prev_url}" rel="prev">← Previous</a>"#));
318    }
319
320    parts.push(format!("Page {current} of {total}"));
321
322    if current < total {
323        parts.push(format!(
324            r#"<a href="{}/page/{}" rel="next">Next →</a>"#,
325            base_url,
326            current + 1
327        ));
328    }
329
330    Some(format!(
331        r#"<nav class="pagination">{}</nav>"#,
332        parts.join(" ")
333    ))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn test_config() -> Config {
341        Config {
342            site: typstify_core::config::SiteConfig {
343                title: "Test Site".to_string(),
344                base_url: "https://example.com".to_string(),
345                default_language: "en".to_string(),
346                languages: vec!["en".to_string()],
347                description: Some("A test site".to_string()),
348                author: Some("Test Author".to_string()),
349            },
350            build: typstify_core::config::BuildConfig::default(),
351            search: typstify_core::config::SearchConfig::default(),
352            rss: typstify_core::config::RssConfig::default(),
353            taxonomies: typstify_core::config::TaxonomyConfig::default(),
354        }
355    }
356
357    fn test_page() -> Page {
358        Page {
359            url: "/test-page".to_string(),
360            title: "Test Page".to_string(),
361            description: Some("A test page".to_string()),
362            date: None,
363            updated: None,
364            draft: false,
365            lang: None,
366            tags: vec![],
367            categories: vec![],
368            content: "<p>Hello, World!</p>".to_string(),
369            summary: None,
370            reading_time: None,
371            word_count: None,
372            toc: vec![],
373            custom_js: vec![],
374            custom_css: vec![],
375            aliases: vec![],
376            template: None,
377            weight: 0,
378            source_path: Some(PathBuf::from("test-page.md")),
379        }
380    }
381
382    #[test]
383    fn test_generate_page() {
384        let generator = HtmlGenerator::new(test_config());
385        let page = test_page();
386
387        let html = generator.generate_page(&page).unwrap();
388
389        assert!(html.contains("<!DOCTYPE html>"));
390        assert!(html.contains("<title>Test Page | Test Site</title>"));
391        assert!(html.contains("<p>Hello, World!</p>"));
392        assert!(html.contains("Test Site"));
393    }
394
395    #[test]
396    fn test_generate_redirect() {
397        let generator = HtmlGenerator::new(test_config());
398
399        let html = generator
400            .generate_redirect("https://example.com/new-url")
401            .unwrap();
402
403        assert!(html.contains("Redirecting"));
404        assert!(html.contains("https://example.com/new-url"));
405        assert!(html.contains(r#"http-equiv="refresh""#));
406    }
407
408    #[test]
409    fn test_slug_from_str() {
410        assert_eq!(slug_from_str("Hello World"), "hello-world");
411        assert_eq!(slug_from_str("Rust & Go"), "rust-go");
412        assert_eq!(slug_from_str("  multiple   spaces  "), "multiple-spaces");
413        assert_eq!(slug_from_str("CamelCase"), "camelcase");
414    }
415
416    #[test]
417    fn test_list_item_html() {
418        let page = test_page();
419        let html = list_item_html(&page);
420
421        assert!(html.contains("<li>"));
422        assert!(html.contains("Test Page"));
423        assert!(html.contains("/test-page"));
424    }
425
426    #[test]
427    fn test_pagination_html() {
428        // Single page - no pagination
429        assert!(pagination_html(1, 1, "/blog").is_none());
430
431        // First page of many
432        let html = pagination_html(1, 5, "/blog").unwrap();
433        assert!(html.contains("Page 1 of 5"));
434        assert!(html.contains("Next →"));
435        assert!(!html.contains("Previous"));
436
437        // Middle page
438        let html = pagination_html(3, 5, "/blog").unwrap();
439        assert!(html.contains("Page 3 of 5"));
440        assert!(html.contains("Previous"));
441        assert!(html.contains("Next →"));
442
443        // Last page
444        let html = pagination_html(5, 5, "/blog").unwrap();
445        assert!(html.contains("Page 5 of 5"));
446        assert!(html.contains("Previous"));
447        assert!(!html.contains("Next →"));
448    }
449
450    #[test]
451    fn test_output_path() {
452        let generator = HtmlGenerator::new(test_config());
453        let output_dir = Path::new("public");
454
455        let page = test_page();
456        let path = generator.output_path(&page, output_dir);
457        assert_eq!(path, PathBuf::from("public/test-page/index.html"));
458
459        // Root page
460        let mut root_page = test_page();
461        root_page.url = "/".to_string();
462        let path = generator.output_path(&root_page, output_dir);
463        assert_eq!(path, PathBuf::from("public/index.html"));
464    }
465}