1use 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#[derive(Debug, Error)]
19pub enum HtmlError {
20 #[error("template error: {0}")]
22 Template(#[from] TemplateError),
23
24 #[error("IO error: {0}")]
26 Io(#[from] std::io::Error),
27
28 #[error("invalid page data: {0}")]
30 InvalidPage(String),
31}
32
33pub type Result<T> = std::result::Result<T, HtmlError>;
35
36#[derive(Debug)]
38pub struct HtmlGenerator<'a> {
39 templates: TemplateRegistry,
40 config: &'a Config,
41 sections: Vec<String>,
43}
44
45impl<'a> HtmlGenerator<'a> {
46 #[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 #[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 #[must_use]
68 pub fn with_sections(mut self, sections: Vec<String>) -> Self {
69 self.sections = sections;
70 self
71 }
72
73 fn generate_section_nav(&self, base_path: &str, lang_prefix: &str) -> String {
75 if self.sections.is_empty() {
76 return format!(r#"<a href="{base_path}{lang_prefix}/posts">Posts</a>"#);
78 }
79
80 let excluded_sections = ["about", "index"];
82 let filtered_sections: Vec<_> = self
83 .sections
84 .iter()
85 .filter(|s| {
86 if s.len() <= 3 && s.chars().all(|c| c.is_ascii_lowercase()) {
88 return false;
89 }
90 !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 let title = section
104 .chars()
105 .next()
106 .map(|c| c.to_uppercase().collect::<String>() + §ion[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 pub fn register_template(&mut self, template: Template) {
119 self.templates.register(template);
120 }
121
122 pub fn generate_page(&self, page: &Page, alternates: &[(&str, &str)]) -> Result<String> {
124 debug!(url = %page.url, "generating HTML for page");
125
126 let template_name = page.template.as_deref().map_or_else(
128 || {
129 if page.date.is_some() { "post" } else { "page" }
130 },
131 |t| {
132 if t == "shorts" { "short" } else { t }
134 },
135 );
136
137 let inner_ctx = self.build_page_context(page)?;
139 let inner_html = self.templates.render(template_name, &inner_ctx)?;
140
141 let base_ctx = self.build_base_context(page, &inner_html, alternates)?;
143 Ok(self.templates.render("base", &base_ctx)?)
144 }
145
146 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 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 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 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 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 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 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 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 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 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 if let Some(author) = &self.config.site.author {
336 ctx.insert("author", author);
337 }
338
339 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 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 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 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 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 if canonical_id.is_empty() {
402 format!("{base_path}/")
403 } else {
404 format!("{base_path}/{canonical_id}")
405 }
406 } else {
407 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 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 #[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 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 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())); 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 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 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 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)); 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 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 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 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 for pages in by_year.values_mut() {
574 pages.sort_by_key(|b| std::cmp::Reverse(b.date));
575 }
576
577 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 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 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 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 let title = section
647 .chars()
648 .next()
649 .map(|c| c.to_uppercase().collect::<String>() + §ion[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 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 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 let title = section
698 .chars()
699 .next()
700 .map(|c| c.to_uppercase().collect::<String>() + §ion[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 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 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
733pub 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
768pub 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 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
794pub 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 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
819pub 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 assert!(pagination_html(1, 1, "/blog").is_none());
903
904 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 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 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 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}