1use 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#[derive(Debug, Error)]
16pub enum HtmlError {
17 #[error("template error: {0}")]
19 Template(#[from] TemplateError),
20
21 #[error("IO error: {0}")]
23 Io(#[from] std::io::Error),
24
25 #[error("invalid page data: {0}")]
27 InvalidPage(String),
28}
29
30pub type Result<T> = std::result::Result<T, HtmlError>;
32
33#[derive(Debug)]
35pub struct HtmlGenerator {
36 templates: TemplateRegistry,
37 config: Config,
38}
39
40impl HtmlGenerator {
41 #[must_use]
43 pub fn new(config: Config) -> Self {
44 Self {
45 templates: TemplateRegistry::new(),
46 config,
47 }
48 }
49
50 #[must_use]
52 pub fn with_templates(config: Config, templates: TemplateRegistry) -> Self {
53 Self { templates, config }
54 }
55
56 pub fn register_template(&mut self, template: Template) {
58 self.templates.register(template);
59 }
60
61 pub fn generate_page(&self, page: &Page) -> Result<String> {
63 debug!(url = %page.url, "generating HTML for page");
64
65 let template_name =
67 page.template
68 .as_deref()
69 .unwrap_or(if page.date.is_some() { "post" } else { "page" });
70
71 let inner_ctx = self.build_page_context(page)?;
73 let inner_html = self.templates.render(template_name, &inner_ctx)?;
74
75 let base_ctx = self.build_base_context(page, &inner_html)?;
77 Ok(self.templates.render("base", &base_ctx)?)
78 }
79
80 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 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 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 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 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 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 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 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 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 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 if let Some(author) = &self.config.site.author {
231 ctx.insert("author", author);
232 }
233
234 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 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 #[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
272fn 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
284pub 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
303pub 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 assert!(pagination_html(1, 1, "/blog").is_none());
430
431 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 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 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 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}