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, alternates: &[(&str, &str)]) -> 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, alternates)?;
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 .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 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 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 .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 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 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 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 fn build_base_context(
214 &self,
215 page: &Page,
216 inner_html: &str,
217 alternates: &[(&str, &str)],
218 ) -> Result<TemplateContext> {
219 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 .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 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 if let Some(author) = &self.config.site.author {
256 ctx.insert("author", author);
257 }
258
259 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 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 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 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 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 if canonical_id.is_empty() {
319 "/".to_string()
320 } else {
321 format!("/{canonical_id}")
322 }
323 } else {
324 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 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 #[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 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())); 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 .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 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 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)); 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 .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 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 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 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 for pages in by_year.values_mut() {
514 pages.sort_by(|a, b| b.date.cmp(&a.date));
515 }
516
517 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 .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 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 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 let title = section
593 .chars()
594 .next()
595 .map(|c| c.to_uppercase().collect::<String>() + §ion[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 .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 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
643fn 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
655pub 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
687pub 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 assert!(pagination_html(1, 1, "/blog").is_none());
820
821 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 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 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 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}