use std::path::{Path, PathBuf};
use chrono::{Datelike, Utc};
use thiserror::Error;
use tracing::debug;
use typstify_core::{
Config, Page,
utils::{html_escape, slugify},
};
use crate::template::{Template, TemplateContext, TemplateError, TemplateRegistry};
#[derive(Debug, Error)]
pub enum HtmlError {
#[error("template error: {0}")]
Template(#[from] TemplateError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid page data: {0}")]
InvalidPage(String),
}
pub type Result<T> = std::result::Result<T, HtmlError>;
#[derive(Debug)]
pub struct HtmlGenerator<'a> {
templates: TemplateRegistry,
config: &'a Config,
sections: Vec<String>,
}
impl<'a> HtmlGenerator<'a> {
#[must_use]
pub fn new(config: &'a Config) -> Self {
Self {
templates: TemplateRegistry::new(),
config,
sections: Vec::new(),
}
}
#[must_use]
pub fn with_templates(config: &'a Config, templates: TemplateRegistry) -> Self {
Self {
templates,
config,
sections: Vec::new(),
}
}
#[must_use]
pub fn with_sections(mut self, sections: Vec<String>) -> Self {
self.sections = sections;
self
}
fn generate_section_nav(&self, base_path: &str, lang_prefix: &str) -> String {
if self.sections.is_empty() {
return format!(r#"<a href="{base_path}{lang_prefix}/posts">Posts</a>"#);
}
let excluded_sections = ["about", "index"];
let filtered_sections: Vec<_> = self
.sections
.iter()
.filter(|s| {
if s.len() <= 3 && s.chars().all(|c| c.is_ascii_lowercase()) {
return false;
}
!excluded_sections.contains(&s.as_str())
})
.collect();
if filtered_sections.is_empty() {
return format!(r#"<a href="{base_path}{lang_prefix}/posts">Posts</a>"#);
}
filtered_sections
.iter()
.map(|section| {
let title = section
.chars()
.next()
.map(|c| c.to_uppercase().collect::<String>() + §ion[c.len_utf8()..])
.unwrap_or_else(|| (*section).clone());
format!(
r#"<a href="{base_path}{lang_prefix}/{section}">{}</a>"#,
html_escape(&title)
)
})
.collect::<Vec<_>>()
.join("\n ")
}
pub fn register_template(&mut self, template: Template) {
self.templates.register(template);
}
pub fn generate_page(&self, page: &Page, alternates: &[(&str, &str)]) -> Result<String> {
debug!(url = %page.url, "generating HTML for page");
let template_name = page.template.as_deref().map_or_else(
|| {
if page.date.is_some() { "post" } else { "page" }
},
|t| {
if t == "shorts" { "short" } else { t }
},
);
let inner_ctx = self.build_page_context(page)?;
let inner_html = self.templates.render(template_name, &inner_ctx)?;
let base_ctx = self.build_base_context(page, &inner_html, alternates)?;
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_redirect(&self, redirect_url: &str) -> Result<String> {
let ctx = TemplateContext::new().with_var("redirect_url", redirect_url);
self.templates
.render("redirect", &ctx)
.map_err(HtmlError::from)
}
pub fn generate_list_page(
&self,
title: &str,
items_html: &str,
pagination_html: Option<&str>,
) -> Result<String> {
let mut ctx = TemplateContext::new()
.with_var("title", title)
.with_var("items", items_html);
if let Some(pagination) = pagination_html {
ctx.insert("pagination", pagination);
}
let inner_html = self.templates.render("list", &ctx)?;
let base_url = self.config.base_url();
let base_ctx = self.build_shared_base_ctx(
&self.config.site.default_language,
title,
base_url,
&inner_html,
"",
);
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_taxonomy_page(
&self,
taxonomy_name: &str,
term: &str,
items_html: &str,
pagination_html: Option<&str>,
) -> Result<String> {
let mut ctx = TemplateContext::new()
.with_var("taxonomy_name", taxonomy_name)
.with_var("term", term)
.with_var("items", items_html);
if let Some(pagination) = pagination_html {
ctx.insert("pagination", pagination);
}
let inner_html = self.templates.render("taxonomy", &ctx)?;
let title = format!("{taxonomy_name}: {term}");
let base_url = self.config.base_url();
let canonical_url = format!("{}/{}/{}", base_url, taxonomy_name.to_lowercase(), term);
let base_ctx = self.build_shared_base_ctx(
&self.config.site.default_language,
&title,
&canonical_url,
&inner_html,
"",
);
Ok(self.templates.render("base", &base_ctx)?)
}
fn build_shared_base_ctx(
&self,
lang: &str,
title: &str,
canonical_url: &str,
content: &str,
lang_prefix: &str,
) -> TemplateContext {
let base_path = self.config.base_path();
TemplateContext::new()
.with_var("lang", lang)
.with_var("title", title)
.with_var("base_path", base_path)
.with_var(
"site_title_suffix",
format!(" | {}", self.config.title_for_language(lang)),
)
.with_var("canonical_url", canonical_url)
.with_var("content", content)
.with_var("site_title", self.config.title_for_language(lang))
.with_var("year", Utc::now().year().to_string())
.with_var("nav_home_url", format!("{base_path}{lang_prefix}/"))
.with_var(
"nav_archives_url",
format!("{base_path}{lang_prefix}/archives"),
)
.with_var("nav_tags_url", format!("{base_path}{lang_prefix}/tags"))
.with_var("nav_about_url", format!("{base_path}{lang_prefix}/about"))
.with_var(
"section_nav",
self.generate_section_nav(base_path, lang_prefix),
)
}
fn build_page_context(&self, page: &Page) -> Result<TemplateContext> {
let mut ctx = TemplateContext::new()
.with_var("title", &page.title)
.with_var("content", &page.content);
if let Some(date) = page.date {
ctx.insert("date_iso", date.format("%Y-%m-%d").to_string());
ctx.insert("date_formatted", date.format("%B %d, %Y").to_string());
}
let author = self.config.site.author.as_deref().unwrap_or("Author");
ctx.insert("author", author);
let initials: String = author
.split_whitespace()
.filter_map(|w| w.chars().next())
.take(2)
.collect::<String>()
.to_uppercase();
ctx.insert("author_initials", initials);
if !page.tags.is_empty() {
let base_path = self.config.base_path();
let lang_prefix = if page.is_default_lang {
String::new()
} else {
format!("/{}", page.lang)
};
let tags_html = page
.tags
.iter()
.map(|tag| {
format!(
r#"<a href="{base_path}{lang_prefix}/tags/{}" rel="tag">{}</a>"#,
slugify(tag),
tag
)
})
.collect::<Vec<_>>()
.join(" ");
ctx.insert(
"tags_html",
format!(r#"<div class="tags">{tags_html}</div>"#),
);
}
Ok(ctx)
}
fn build_base_context(
&self,
page: &Page,
inner_html: &str,
alternates: &[(&str, &str)],
) -> Result<TemplateContext> {
let lang_prefix = if page.is_default_lang {
String::new()
} else {
format!("/{}", page.lang)
};
let canonical_url = format!("{}{}", self.config.base_url(), page.url);
let mut ctx = self.build_shared_base_ctx(
&page.lang,
&page.title,
&canonical_url,
inner_html,
&lang_prefix,
);
if let Some(desc) = &page.description {
ctx.insert("description", desc);
} else if let Some(site_desc) = self.config.description_for_language(&page.lang) {
ctx.insert("description", site_desc);
}
if let Some(author) = &self.config.site.author {
ctx.insert("author", author);
}
if !page.custom_css.is_empty() {
let css_links = page
.custom_css
.iter()
.map(|href| format!(r#"<link rel="stylesheet" href="{href}">"#))
.collect::<Vec<_>>()
.join("\n");
ctx.insert("custom_css", css_links);
}
if !page.custom_js.is_empty() {
let js_scripts = page
.custom_js
.iter()
.map(|src| format!(r#"<script src="{src}"></script>"#))
.collect::<Vec<_>>()
.join("\n");
ctx.insert("custom_js", js_scripts);
}
let lang_switcher = self.generate_lang_switcher(&page.lang, &page.canonical_id);
if !lang_switcher.is_empty() {
ctx.insert("lang_switcher", lang_switcher);
}
if !alternates.is_empty() {
let hreflang = alternates
.iter()
.map(|(lang, url)| {
format!(
r#"<link rel="alternate" hreflang="{}" href="{}{}" />"#,
lang,
self.config.base_url(),
url
)
})
.collect::<Vec<_>>()
.join("\n");
ctx.insert("hreflang", hreflang);
}
Ok(ctx)
}
fn generate_lang_switcher(&self, current_lang: &str, canonical_id: &str) -> String {
let all_langs = self.config.all_languages();
if all_langs.len() <= 1 {
return String::new();
}
let base_path = self.config.base_path();
let mut options = Vec::new();
for lang in &all_langs {
let name = self.config.language_name(lang);
let url = if *lang == self.config.site.default_language {
if canonical_id.is_empty() {
format!("{base_path}/")
} else {
format!("{base_path}/{canonical_id}")
}
} else {
if canonical_id.is_empty() {
format!("{base_path}/{lang}/")
} else {
format!("{base_path}/{lang}/{canonical_id}")
}
};
let selected_class = if *lang == current_lang { " active" } else { "" };
options.push(format!(
r#"<a href="{url}" class="lang-option{selected_class}">{name}</a>"#,
));
}
let display_code = current_lang
.chars()
.take(2)
.collect::<String>()
.to_uppercase();
format!(
r#"<div class="lang-switcher" tabindex="0" role="button" aria-label="Switch language" aria-haspopup="true">
<span class="lang-code">{}</span>
<div class="lang-dropdown">{}</div>
</div>"#,
display_code,
options.join("\n ")
)
}
#[must_use]
pub fn output_path(&self, page: &Page, output_dir: &Path) -> PathBuf {
let relative = page.url.trim_start_matches('/');
if relative.is_empty() {
output_dir.join("index.html")
} else {
output_dir.join(relative).join("index.html")
}
}
pub fn generate_tags_index_page(
&self,
tags: &std::collections::HashMap<String, Vec<String>>,
lang: &str,
) -> Result<String> {
let is_default_lang = lang == self.config.site.default_language;
let lang_prefix = if is_default_lang {
String::new()
} else {
format!("/{lang}")
};
let base_path = self.config.base_path();
let mut items: Vec<_> = tags.iter().collect();
items.sort_by_key(|b| std::cmp::Reverse(b.1.len()));
let items_html: String = items
.iter()
.map(|(tag, pages)| {
format!(
r#"<a href="{base_path}{lang_prefix}/tags/{}" class="tag-item"><span class="tag-name">{}</span><span class="tag-count">{}</span></a>"#,
slugify(tag),
html_escape(tag),
pages.len()
)
})
.collect::<Vec<_>>()
.join("\n");
let ctx = TemplateContext::new().with_var("items", &items_html);
let inner_html = self.templates.render("tags_index", &ctx)?;
let canonical_url = format!("{}{}/tags", self.config.base_url(), lang_prefix);
let mut base_ctx =
self.build_shared_base_ctx(lang, "Tags", &canonical_url, &inner_html, &lang_prefix);
let lang_switcher = self.generate_lang_switcher(lang, "tags");
if !lang_switcher.is_empty() {
base_ctx.insert("lang_switcher", lang_switcher);
}
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_categories_index_page(
&self,
categories: &std::collections::HashMap<String, Vec<String>>,
lang: &str,
) -> Result<String> {
let is_default_lang = lang == self.config.site.default_language;
let lang_prefix = if is_default_lang {
String::new()
} else {
format!("/{lang}")
};
let base_path = self.config.base_path();
let mut items: Vec<_> = categories.iter().collect();
items.sort_by(|a, b| a.0.cmp(b.0));
let items_html: String = items
.iter()
.map(|(category, pages)| {
format!(
r#"<li><a href="{base_path}{lang_prefix}/categories/{}">{}</a> <span class="count">({})</span></li>"#,
slugify(category),
html_escape(category),
pages.len()
)
})
.collect::<Vec<_>>()
.join("\n");
let ctx = TemplateContext::new().with_var("items", &items_html);
let inner_html = self.templates.render("categories_index", &ctx)?;
let canonical_url = format!("{}{}/categories", self.config.base_url(), lang_prefix);
let mut base_ctx = self.build_shared_base_ctx(
lang,
"Categories",
&canonical_url,
&inner_html,
&lang_prefix,
);
let lang_switcher = self.generate_lang_switcher(lang, "categories");
if !lang_switcher.is_empty() {
base_ctx.insert("lang_switcher", lang_switcher);
}
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_archives_page(&self, pages: &[&Page], lang: &str) -> Result<String> {
use std::collections::BTreeMap;
let is_default_lang = lang == self.config.site.default_language;
let lang_prefix = if is_default_lang {
String::new()
} else {
format!("/{lang}")
};
let mut by_year: BTreeMap<i32, Vec<&Page>> = BTreeMap::new();
for page in pages {
if let Some(date) = page.date {
by_year.entry(date.year()).or_default().push(page);
}
}
for pages in by_year.values_mut() {
pages.sort_by_key(|b| std::cmp::Reverse(b.date));
}
let items_html: String = by_year
.iter()
.rev()
.map(|(year, year_pages)| {
let posts_html: String = year_pages
.iter()
.map(|p| {
let date_str = p
.date
.map(|d| d.format("%m-%d").to_string())
.unwrap_or_default();
let template_type = p.template.as_deref().unwrap_or("post");
let badge_class = match template_type {
"short" | "shorts" => "badge-short",
_ => "badge-post",
};
let badge_label = match template_type {
"short" | "shorts" => "short",
_ => "post",
};
format!(
r#"<li><span class="archive-date">{}</span><span class="archive-badge {}">{}</span><a href="{}">{}</a></li>"#,
date_str, badge_class, badge_label, html_escape(&p.url), html_escape(&p.title)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(r#"<div class="archive-year"><h2>{year}</h2><ul>{posts_html}</ul></div>"#,)
})
.collect::<Vec<_>>()
.join("\n");
let ctx = TemplateContext::new().with_var("items", &items_html);
let inner_html = self.templates.render("archives", &ctx)?;
let canonical_url = format!("{}{}/archives", self.config.base_url(), lang_prefix);
let mut base_ctx =
self.build_shared_base_ctx(lang, "Archives", &canonical_url, &inner_html, &lang_prefix);
let lang_switcher = self.generate_lang_switcher(lang, "archives");
if !lang_switcher.is_empty() {
base_ctx.insert("lang_switcher", lang_switcher);
}
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_section_page(
&self,
section: &str,
description: Option<&str>,
items_html: &str,
pagination_html: Option<&str>,
lang: &str,
) -> Result<String> {
let is_default_lang = lang == self.config.site.default_language;
let lang_prefix = if is_default_lang {
String::new()
} else {
format!("/{lang}")
};
let title = section
.chars()
.next()
.map(|c| c.to_uppercase().collect::<String>() + §ion[1..])
.unwrap_or_else(|| section.to_string());
let mut ctx = TemplateContext::new()
.with_var("title", &title)
.with_var("items", items_html);
if let Some(desc) = description {
ctx.insert("description", desc);
}
if let Some(pagination) = pagination_html {
ctx.insert("pagination", pagination);
}
let inner_html = self.templates.render("section", &ctx)?;
let canonical_url = format!("{}{}/{}", self.config.base_url(), lang_prefix, section);
let mut base_ctx =
self.build_shared_base_ctx(lang, &title, &canonical_url, &inner_html, &lang_prefix);
let lang_switcher = self.generate_lang_switcher(lang, section);
if !lang_switcher.is_empty() {
base_ctx.insert("lang_switcher", lang_switcher);
}
Ok(self.templates.render("base", &base_ctx)?)
}
pub fn generate_shorts_page(
&self,
section: &str,
description: Option<&str>,
items_html: &str,
pagination_html: Option<&str>,
lang: &str,
) -> Result<String> {
let is_default_lang = lang == self.config.site.default_language;
let lang_prefix = if is_default_lang {
String::new()
} else {
format!("/{lang}")
};
let title = section
.chars()
.next()
.map(|c| c.to_uppercase().collect::<String>() + §ion[1..])
.unwrap_or_else(|| section.to_string());
let mut ctx = TemplateContext::new()
.with_var("title", &title)
.with_var("items", items_html);
if let Some(desc) = description {
ctx.insert("description", desc);
}
if let Some(pagination) = pagination_html {
ctx.insert("pagination", pagination);
}
let inner_html = self.templates.render("shorts", &ctx)?;
let canonical_url = format!("{}{}/{}", self.config.base_url(), lang_prefix, section);
let mut base_ctx =
self.build_shared_base_ctx(lang, &title, &canonical_url, &inner_html, &lang_prefix);
let lang_switcher = self.generate_lang_switcher(lang, section);
if !lang_switcher.is_empty() {
base_ctx.insert("lang_switcher", lang_switcher);
}
Ok(self.templates.render("base", &base_ctx)?)
}
}
pub fn list_item_html(page: &Page) -> String {
let date_html = page
.date
.map(|d| {
format!(
r#"<time datetime="{}">{}</time>"#,
d.format("%Y-%m-%d"),
d.format("%Y-%m-%d")
)
})
.unwrap_or_default();
let description_html = page
.description
.as_ref()
.filter(|d| !d.is_empty())
.map(|d| format!(r#"<p class="post-description">{}</p>"#, html_escape(d)))
.unwrap_or_default();
format!(
r#"<li class="post-item">
<div class="post-item-header">
<a href="{}" class="post-title">{}</a>
{}
</div>
{}
</li>"#,
html_escape(&page.url),
html_escape(&page.title),
date_html,
description_html
)
}
pub fn short_item_html(page: &Page, _author: &str) -> String {
let date_html = page
.date
.map(|d| {
format!(
r#"<time class="short-date" datetime="{}">{}</time>"#,
d.format("%Y-%m-%d"),
d.format("%b %d, %Y")
)
})
.unwrap_or_default();
let content_html = &page.content;
format!(
r#"<div class="short-item">
{date_html}
<div class="short-content">
{content_html}
</div>
</div>"#
)
}
pub fn shorts_with_separators_html(pages: &[&Page], author: &str) -> String {
let mut result = String::new();
let mut last_date: Option<chrono::NaiveDate> = None;
for page in pages {
if let Some(date) = page.date {
let current_date = date.date_naive();
if let Some(prev_date) = last_date
&& current_date != prev_date
{
result.push_str(r#"<hr class="date-separator">"#);
}
last_date = Some(current_date);
}
result.push_str(&short_item_html(page, author));
}
result
}
pub fn pagination_html(current: usize, total: usize, base_url: &str) -> Option<String> {
if total <= 1 {
return None;
}
let mut parts = Vec::new();
if current > 1 {
let prev_url = if current == 2 {
base_url.to_string()
} else {
format!("{}/page/{}", base_url, current - 1)
};
parts.push(format!(r#"<a href="{prev_url}" rel="prev">← Previous</a>"#));
}
parts.push(format!("Page {current} of {total}"));
if current < total {
parts.push(format!(
r#"<a href="{}/page/{}" rel="next">Next →</a>"#,
base_url,
current + 1
));
}
Some(format!(
r#"<nav class="pagination">{}</nav>"#,
parts.join(" ")
))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use typstify_core::test_fixtures::{test_config, test_page};
use super::*;
#[test]
fn test_generate_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let page = test_page();
let html = generator.generate_page(&page, &[]).unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<title>Test Page | Test Site</title>"));
assert!(html.contains("<p>Hello, World!</p>"));
assert!(html.contains("Test Site"));
}
#[test]
fn test_generate_redirect() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator
.generate_redirect("https://example.com/new-url")
.unwrap();
assert!(html.contains("Redirecting"));
assert!(html.contains("https://example.com/new-url"));
assert!(html.contains(r#"http-equiv="refresh""#));
}
#[test]
fn test_list_item_html() {
let page = test_page();
let html = list_item_html(&page);
assert!(html.contains(r#"<li class="post-item">"#));
assert!(html.contains("post-title"));
assert!(html.contains("Test Page"));
assert!(html.contains("/test-page"));
}
#[test]
fn test_pagination_html() {
assert!(pagination_html(1, 1, "/blog").is_none());
let html = pagination_html(1, 5, "/blog").unwrap();
assert!(html.contains("Page 1 of 5"));
assert!(html.contains("Next →"));
assert!(!html.contains("Previous"));
let html = pagination_html(3, 5, "/blog").unwrap();
assert!(html.contains("Page 3 of 5"));
assert!(html.contains("Previous"));
assert!(html.contains("Next →"));
let html = pagination_html(5, 5, "/blog").unwrap();
assert!(html.contains("Page 5 of 5"));
assert!(html.contains("Previous"));
assert!(!html.contains("Next →"));
}
#[test]
fn test_output_path() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let output_dir = Path::new("public");
let page = test_page();
let path = generator.output_path(&page, output_dir);
assert_eq!(path, PathBuf::from("public/test-page/index.html"));
let mut root_page = test_page();
root_page.url = "/".to_string();
let path = generator.output_path(&root_page, output_dir);
assert_eq!(path, PathBuf::from("public/index.html"));
}
#[test]
fn test_generate_list_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator
.generate_list_page("My Posts", "<li>Post 1</li>", None)
.unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("My Posts"));
assert!(html.contains("<li>Post 1</li>"));
assert!(html.contains("post-list"));
}
#[test]
fn test_generate_list_page_with_pagination() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let pagination = r#"<nav class="pagination">Page 1 of 3</nav>"#;
let html = generator
.generate_list_page("Blog", "<li>Item</li>", Some(pagination))
.unwrap();
assert!(html.contains("Blog"));
assert!(html.contains(r#"<nav class="pagination">"#));
assert!(html.contains("Page 1 of 3"));
}
#[test]
fn test_generate_taxonomy_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator
.generate_taxonomy_page("Tags", "rust", "<li>Rust Post</li>", None)
.unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Tags"));
assert!(html.contains("rust"));
assert!(html.contains("<li>Rust Post</li>"));
assert!(html.contains("taxonomy"));
}
#[test]
fn test_generate_taxonomy_page_with_pagination() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let pagination = r#"<nav class="pagination">Page 2 of 5</nav>"#;
let html = generator
.generate_taxonomy_page(
"Categories",
"tutorial",
"<li>Tutorial Post</li>",
Some(pagination),
)
.unwrap();
assert!(html.contains("Categories"));
assert!(html.contains("tutorial"));
assert!(html.contains("Page 2 of 5"));
}
#[test]
fn test_generate_tags_index_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let mut tags = HashMap::new();
tags.insert(
"rust".to_string(),
vec!["page1".to_string(), "page2".to_string()],
);
tags.insert("web".to_string(), vec!["page3".to_string()]);
let html = generator.generate_tags_index_page(&tags, "en").unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Tags"));
assert!(html.contains("tag-item"));
assert!(html.contains("rust"));
assert!(html.contains("web"));
assert!(html.contains("tag-count"));
assert!(html.contains("tag-name"));
}
#[test]
fn test_generate_categories_index_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let mut categories = HashMap::new();
categories.insert(
"programming".to_string(),
vec!["p1".to_string(), "p2".to_string()],
);
categories.insert("design".to_string(), vec!["p3".to_string()]);
let html = generator
.generate_categories_index_page(&categories, "en")
.unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Categories"));
assert!(html.contains("programming"));
assert!(html.contains("design"));
assert!(html.contains("categories-list"));
}
#[test]
fn test_generate_archives_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let mut page = test_page();
page.date = Some(
chrono::NaiveDateTime::parse_from_str("2024-01-15T10:00:00", "%Y-%m-%dT%H:%M:%S")
.unwrap()
.and_utc(),
);
page.title = "January Post".to_string();
let html = generator.generate_archives_page(&[&page], "en").unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Archives"));
assert!(html.contains("archive-year"));
assert!(html.contains("2024"));
assert!(html.contains("January Post"));
}
#[test]
fn test_generate_archives_page_empty() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator.generate_archives_page(&[], "en").unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Archives"));
}
#[test]
fn test_generate_section_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator
.generate_section_page(
"posts",
Some("All blog posts"),
"<li>Post A</li>",
None,
"en",
)
.unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Posts"));
assert!(html.contains("All blog posts"));
assert!(html.contains("<li>Post A</li>"));
assert!(html.contains("section-list"));
}
#[test]
fn test_generate_section_page_with_pagination() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let pagination = r#"<nav class="pagination">Page 1 of 2</nav>"#;
let html = generator
.generate_section_page("tutorials", None, "<li>Tut 1</li>", Some(pagination), "en")
.unwrap();
assert!(html.contains("Tutorials"));
assert!(html.contains("Page 1 of 2"));
}
#[test]
fn test_generate_shorts_page() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let html = generator
.generate_shorts_page(
"shorts",
Some("Short-form content"),
"<div class=\"short-item\">Short 1</div>",
None,
"en",
)
.unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Shorts"));
assert!(html.contains("Short-form content"));
assert!(html.contains("Short 1"));
assert!(html.contains("shorts-section"));
}
#[test]
fn test_generate_shorts_page_with_pagination() {
let config = test_config();
let generator = HtmlGenerator::new(&config);
let pagination = r#"<nav class="pagination">Page 1 of 4</nav>"#;
let html = generator
.generate_shorts_page(
"notes",
None,
"<div class=\"short-item\">Note 1</div>",
Some(pagination),
"en",
)
.unwrap();
assert!(html.contains("Notes"));
assert!(html.contains("Page 1 of 4"));
}
}