use crate::collections::Collection;
use crate::config::Config;
use crate::content::Site;
use crate::images::process_images;
use crate::layouts::LayoutEngine;
use crate::rss::generate_rss;
use crate::sitemap::generate_sitemap;
use crate::static_files::copy_static_files;
use anyhow::Result;
use rayon::prelude::*;
use std::path::Path;
const DEFAULT_BASE_URL: &str = "http://localhost:3000";
pub fn build_site(site: &Site, config: &Config) -> Result<()> {
std::fs::create_dir_all(&config.output_dir)?;
let layouts_dir = config.source_dir.join("layouts");
let layout_engine = LayoutEngine::load(&layouts_dir);
process_images(config)?;
copy_static_files(&config.source_dir, &config.output_dir)?;
let nav_html = &site.nav_html;
let render_results: Vec<Result<(), anyhow::Error>> = site
.pages
.par_iter()
.map(|page| {
let html = layout_engine.render_page(page, nav_html);
write_page(&page.url, &html, &config.output_dir)
})
.collect();
for result in render_results {
if let Err(e) = result {
tracing::warn!("Page render error: {}", e);
}
}
let snippet_results: Vec<Result<(), anyhow::Error>> = site
.snippets
.par_iter()
.map(|snippet| {
let html = layout_engine.render_snippet(snippet, nav_html);
write_page(&snippet.url, &html, &config.output_dir)
})
.collect();
for result in snippet_results {
if let Err(e) = result {
tracing::warn!("Snippet render error: {}", e);
}
}
generate_collection_indexes(site, &layout_engine, nav_html, &config.output_dir)?;
generate_rss(site, &config.output_dir, DEFAULT_BASE_URL)?;
generate_sitemap(site, &config.output_dir, DEFAULT_BASE_URL)?;
Ok(())
}
fn write_page(url: &str, html: &str, output_dir: &Path) -> Result<()> {
let path = url_to_path(url, output_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, html)?;
tracing::debug!("Wrote: {:?}", path);
Ok(())
}
fn url_to_path(url: &str, output_dir: &Path) -> std::path::PathBuf {
let url = url.trim_start_matches('/');
if url.is_empty() {
output_dir.join("index.html")
} else if url.ends_with('/') {
output_dir.join(url).join("index.html")
} else {
output_dir.join(format!("{}/index.html", url))
}
}
fn generate_collection_indexes(
site: &Site,
layout_engine: &LayoutEngine,
nav_html: &str,
output_dir: &Path,
) -> Result<()> {
let collections = [
Collection::Blog,
Collection::Notes,
Collection::Snippets,
Collection::Talks,
Collection::Projects,
Collection::Recipes,
Collection::Slides,
];
for collection in collections {
let index_url = collection.url_base();
let has_index = site.pages.iter().any(|p| p.url == index_url);
if has_index {
continue;
}
let pages: Vec<_> = site
.pages
.iter()
.filter(|p| p.collection == collection && !p.is_index)
.collect();
if pages.is_empty() {
continue;
}
let title = format!("{}", collection);
let mut html = format!(
"<h1>{}</h1>\n",
html_escape::encode_text(&title)
);
if collection.is_hierarchical() {
let mut child_dirs: std::collections::HashMap<String, Vec<&crate::content::Page>> = std::collections::HashMap::new();
let mut root_pages: Vec<&crate::content::Page> = Vec::new();
for page in &pages {
let suffix = page.url.strip_prefix(index_url).unwrap_or(&page.url);
if let Some(slash_pos) = suffix.trim_end_matches('/').find('/') {
let dir_name = &suffix[..slash_pos];
child_dirs.entry(dir_name.to_string()).or_default().push(page);
} else {
root_pages.push(page);
}
}
if !child_dirs.is_empty() {
let mut sorted_dirs: Vec<_> = child_dirs.into_iter().collect();
sorted_dirs.sort_by(|a, b| a.0.cmp(&b.0));
for (dir_name, mut dir_pages) in sorted_dirs {
let dir_url = format!("{}{}/", index_url, dir_name);
let display_name = dir_name.replace(['-', '_'], " ");
html.push_str(&format!(
"<h2><a href=\"{}\">{}/</a></h2>\n",
html_escape::encode_double_quoted_attribute(&dir_url),
html_escape::encode_text(&display_name)
));
dir_pages.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
html.push_str("<ul class=\"collection-list\">\n");
for page in dir_pages {
html.push_str(&format!(
" <li><a href=\"{}\">{}</a>",
html_escape::encode_double_quoted_attribute(&page.url),
html_escape::encode_text(&page.title)
));
if let Some(date) = page.date {
html.push_str(&format!(
" <time datetime=\"{}\">{}</time>",
date.format("%Y-%m-%d"),
date.format("%Y-%m-%d")
));
}
html.push_str("</li>\n");
}
html.push_str("</ul>\n");
}
}
if !root_pages.is_empty() {
html.push_str("<h2>Pages</h2>\n");
root_pages.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
html.push_str("<ul class=\"collection-list\">\n");
for page in root_pages {
html.push_str(&format!(
" <li><a href=\"{}\">{}</a>",
html_escape::encode_double_quoted_attribute(&page.url),
html_escape::encode_text(&page.title)
));
if let Some(date) = page.date {
html.push_str(&format!(
" <time datetime=\"{}\">{}</time>",
date.format("%Y-%m-%d"),
date.format("%Y-%m-%d")
));
}
html.push_str("</li>\n");
}
html.push_str("</ul>");
}
} else {
html.push_str("<ul class=\"collection-list\">\n");
let mut sorted_pages = pages;
if collection.is_date_sorted() {
sorted_pages.sort_by(|a, b| b.date.cmp(&a.date));
} else {
sorted_pages.sort_by(|a, b| a.title.cmp(&b.title));
}
for page in sorted_pages {
html.push_str(&format!(
" <li><a href=\"{}\">{}</a>",
html_escape::encode_double_quoted_attribute(&page.url),
html_escape::encode_text(&page.title)
));
if let Some(date) = page.date {
html.push_str(&format!(
" <time datetime=\"{}\">{}</time>",
date.format("%Y-%m-%d"),
date.format("%Y-%m-%d")
));
}
html.push_str("</li>\n");
}
html.push_str("</ul>");
}
let mut page = crate::content::Page::new(std::path::PathBuf::new(), collection);
page.url = index_url.to_string();
page.title = title;
page.html_content = html;
page.is_index = true;
page.is_auto_index = true;
let full_html = layout_engine.render_page(&page, nav_html);
write_page(&page.url, &full_html, output_dir)?;
}
if !site.snippets.is_empty() {
let has_index = site.pages.iter().any(|p| p.url == "/snippets/");
if !has_index {
let mut html = String::from("<h1>Code Snippets</h1>\n<ul class=\"collection-list\">\n");
let mut sorted_snippets: Vec<_> = site.snippets.iter().collect();
sorted_snippets.sort_by(|a, b| a.title.cmp(&b.title));
for snippet in sorted_snippets {
html.push_str(&format!(
" <li><a href=\"{}\">{}</a> <span class=\"language\">({})</span></li>\n",
html_escape::encode_double_quoted_attribute(&snippet.url),
html_escape::encode_text(&snippet.title),
html_escape::encode_text(&snippet.language)
));
}
html.push_str("</ul>");
let mut page =
crate::content::Page::new(std::path::PathBuf::new(), Collection::Snippets);
page.url = "/snippets/".to_string();
page.title = "Code Snippets".to_string();
page.html_content = html;
page.is_index = true;
page.is_auto_index = true;
let full_html = layout_engine.render_page(&page, nav_html);
write_page(&page.url, &full_html, output_dir)?;
}
}
generate_tags_index(site, layout_engine, nav_html, output_dir)?;
generate_contexts_index(site, layout_engine, nav_html, output_dir)?;
Ok(())
}
fn generate_tags_index(
site: &Site,
layout_engine: &LayoutEngine,
nav_html: &str,
output_dir: &Path,
) -> Result<()> {
let mut tag_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for page in &site.pages {
if page.collection == Collection::Tags {
continue; }
for tag in &page.tags {
*tag_counts.entry(tag.slug.clone()).or_default() += 1;
}
}
if tag_counts.is_empty() {
return Ok(());
}
let mut tags: Vec<_> = tag_counts.into_iter().collect();
tags.sort_by(|a, b| a.0.cmp(&b.0));
let mut html = String::from("<h1>Tags</h1>\n<ul class=\"tag-list\">\n");
for (slug, count) in tags {
let display_name = slug.replace('-', " ");
html.push_str(&format!(
" <li><a href=\"/tags/{}/\">#{}</a> <span class=\"count\">({})</span></li>\n",
html_escape::encode_double_quoted_attribute(&slug),
html_escape::encode_text(&display_name),
count
));
}
html.push_str("</ul>");
let mut page = crate::content::Page::new(std::path::PathBuf::new(), Collection::Tags);
page.url = "/tags/".to_string();
page.title = "Tags".to_string();
page.html_content = html;
page.is_index = true;
page.is_auto_index = true;
let full_html = layout_engine.render_page(&page, nav_html);
write_page(&page.url, &full_html, output_dir)?;
Ok(())
}
fn generate_contexts_index(
site: &Site,
layout_engine: &LayoutEngine,
nav_html: &str,
output_dir: &Path,
) -> Result<()> {
let mut context_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for page in &site.pages {
if page.collection == Collection::Contexts {
continue; }
for ctx in &page.contexts {
*context_counts.entry(ctx.slug.clone()).or_default() += 1;
}
}
if context_counts.is_empty() {
return Ok(());
}
let mut contexts: Vec<_> = context_counts.into_iter().collect();
contexts.sort_by(|a, b| a.0.cmp(&b.0));
let mut html = String::from("<h1>Contexts</h1>\n<ul class=\"context-list\">\n");
for (slug, count) in contexts {
let display_name = slug.replace('-', " ");
html.push_str(&format!(
" <li><a href=\"/contexts/{}/\">@{}</a> <span class=\"count\">({})</span></li>\n",
html_escape::encode_double_quoted_attribute(&slug),
html_escape::encode_text(&display_name),
count
));
}
html.push_str("</ul>");
let mut page = crate::content::Page::new(std::path::PathBuf::new(), Collection::Contexts);
page.url = "/contexts/".to_string();
page.title = "Contexts".to_string();
page.html_content = html;
page.is_index = true;
page.is_auto_index = true;
let full_html = layout_engine.render_page(&page, nav_html);
write_page(&page.url, &full_html, output_dir)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_to_path_root() {
let output = Path::new("/output");
assert_eq!(url_to_path("/", output), Path::new("/output/index.html"));
}
#[test]
fn test_url_to_path_blog() {
let output = Path::new("/output");
assert_eq!(
url_to_path("/blog/post/", output),
Path::new("/output/blog/post/index.html")
);
}
#[test]
fn test_url_to_path_no_trailing() {
let output = Path::new("/output");
assert_eq!(
url_to_path("/blog/post", output),
Path::new("/output/blog/post/index.html")
);
}
}