use std::ffi::OsStr; use std::path::PathBuf; use include_dir::include_dir; use include_dir::Dir; use tokio::fs::DirEntry; use tokio::task::JoinHandle; use crate::config::Config; use crate::context::DependencyManager; use crate::context::RenderBlogPostPage; use crate::context::RenderBlogPostPageInput; use crate::context::RenderBlogStream; use crate::context::RenderBlogStreamInput; use crate::context::RenderContext; use crate::context::RenderPage; use crate::error::CustomError; use crate::intermediate::get_web_path; use crate::intermediate::BlogPost; use crate::intermediate::IPage; use crate::intermediate::PublishStatus; use crate::render::DusterRenderer; use crate::render::RendererIntegration; use crate::walk_fs::walk_fs; use crate::walk_fs::WalkAction; use crate::walk_fs::WalkFsFilterResult; use super::stylesheet::Stylesheet; static MAIN_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/default_environment/templates/html"); pub(crate) struct SiteRenderer { output_directory: PathBuf, blog_posts: Vec, stylesheets: Vec, pages: Vec, } impl SiteRenderer { pub(crate) fn new>( output_directory: P, blog_posts: Vec, stylesheets: Vec, pages: Vec, ) -> SiteRenderer { SiteRenderer { output_directory: output_directory.into(), blog_posts, stylesheets, pages, } } fn init_renderer_integration(&self) -> Result, CustomError> { let mut renderer_integration = DusterRenderer::new(); let sources: Vec<_> = MAIN_TEMPLATES .files() .filter(|f| f.path().extension() == Some(OsStr::new("dust"))) .collect(); if sources .iter() .filter(|f| f.path().file_stem() == Some(OsStr::new("main"))) .count() != 1 { return Err("Expect exactly 1 main.dust template file.".into()); } let decoded_templates = { let mut decoded_templates = Vec::with_capacity(sources.len()); for entry in sources { decoded_templates.push(build_name_contents_pairs(entry)?); } decoded_templates }; for (name, contents) in decoded_templates { renderer_integration.load_template(name, contents)?; } Ok(renderer_integration) } pub(crate) async fn render_pages(&self, config: &Config) -> Result<(), CustomError> { let renderer_integration = self.init_renderer_integration()?; for page in self.pages.iter().filter(|page| match page.natter_publish { PublishStatus::Full => true, PublishStatus::Unlisted => true, PublishStatus::Unpublished => false, }) { let output_path = self.output_directory.join(page.get_output_path()); let dependency_manager = std::sync::Arc::new(std::sync::Mutex::new(DependencyManager::new())); let render_context = RenderContext::new( config, self.output_directory.as_path(), output_path.as_path(), None, dependency_manager.clone(), )?; let dust_context = RenderPage::new(render_context.clone(), page)?; let rendered_output = renderer_integration.render(dust_context)?; let parent_directory = output_path .parent() .ok_or("Output file should have a containing directory.")?; tokio::fs::create_dir_all(parent_directory).await?; tokio::fs::write(&output_path, rendered_output).await?; let dependencies = dependency_manager.lock().unwrap().take_dependencies(); for dependency in dependencies { dependency.perform(render_context.clone()).await?; } } Ok(()) } pub(crate) async fn render_blog_posts(&self, config: &Config) -> Result<(), CustomError> { let renderer_integration = self.init_renderer_integration()?; for blog_post in self.blog_posts.iter().filter(|blog_post| { match blog_post .get_index_page() .expect("Blog posts should have an index page.") .natter_publish { PublishStatus::Full => true, PublishStatus::Unlisted => true, PublishStatus::Unpublished => false, } }) { for blog_post_page in &blog_post.pages { let output_path = self .output_directory .join(config.get_relative_path_to_post(&blog_post.id)) .join(blog_post_page.get_output_path()); let dependency_manager = std::sync::Arc::new(std::sync::Mutex::new(DependencyManager::new())); let convert_input = RenderBlogPostPageInput::new(blog_post, blog_post_page); let render_context = RenderContext::new( config, self.output_directory.as_path(), output_path.as_path(), None, dependency_manager.clone(), )?; let dust_context = RenderBlogPostPage::new(render_context.clone(), &convert_input)?; let rendered_output = renderer_integration.render(dust_context)?; let parent_directory = output_path .parent() .ok_or("Output file should have a containing directory.")?; tokio::fs::create_dir_all(parent_directory).await?; tokio::fs::write(&output_path, rendered_output).await?; let dependencies = dependency_manager.lock().unwrap().take_dependencies(); for dependency in dependencies { dependency.perform(render_context.clone()).await?; } } } Ok(()) } pub(crate) async fn render_blog_stream(&self, config: &Config) -> Result<(), CustomError> { let renderer_integration = self.init_renderer_integration()?; // Sort blog posts by date, newest first. let sorted_blog_posts = { let mut sorted_blog_posts: Vec<_> = self .blog_posts .iter() .filter(|blog_post| { match blog_post .get_index_page() .expect("Blog posts should have an index page.") .natter_publish { PublishStatus::Full => true, PublishStatus::Unlisted => false, PublishStatus::Unpublished => false, } }) .collect(); sorted_blog_posts .sort_by_key(|blog_post| (blog_post.get_date(), blog_post.id.as_str())); sorted_blog_posts.reverse(); sorted_blog_posts }; for blog_post in &sorted_blog_posts { if blog_post.get_date().is_none() { return Err(format!("Blog post {} does not have a date.", blog_post.id).into()); } } // Group blog posts based on # of posts per page. let stream_chunks: Vec<_> = sorted_blog_posts .chunks(config.get_stream_entries_per_page()) .collect(); // For each group, create a RenderBlogStream. let num_stream_pages = stream_chunks.len(); for (page_num, chunk) in stream_chunks.into_iter().enumerate() { let output_file = if page_num == 0 { self.output_directory.join("index.html") } else { self.output_directory .join("stream") .join(format!("{}.html", page_num)) }; let newer_link = if page_num == 0 { None } else if page_num == 1 { Some(get_web_path( config, &self.output_directory, &output_file, "index.html", )?) } else { Some(get_web_path( config, &self.output_directory, &output_file, format!("stream/{}.html", page_num - 1), )?) }; let older_link = if page_num == (num_stream_pages - 1) { None } else { Some(get_web_path( config, &self.output_directory, &output_file, format!("stream/{}.html", page_num + 1), )?) }; let dependency_manager = std::sync::Arc::new(std::sync::Mutex::new(DependencyManager::new())); let convert_input = RenderBlogStreamInput::new(chunk, older_link, newer_link); let render_context = RenderContext::new( config, self.output_directory.as_path(), output_file.as_path(), None, dependency_manager.clone(), )?; let blog_stream = RenderBlogStream::new(render_context.clone(), &convert_input)?; // Pass each RenderBlogStream to dust as the context to render index.html and any additional stream pages. let rendered_output = renderer_integration.render(blog_stream)?; let parent_directory = output_file .parent() .ok_or("Output file should have a containing directory.")?; tokio::fs::create_dir_all(parent_directory).await?; tokio::fs::write(&output_file, rendered_output).await?; let dependencies = dependency_manager.lock().unwrap().take_dependencies(); for dependency in dependencies { dependency.perform(render_context.clone()).await?; } } Ok(()) } pub(crate) async fn render_stylesheets(&self) -> Result<(), CustomError> { let stylesheet_output_directory = self.output_directory.join("stylesheet"); if !stylesheet_output_directory.exists() { tokio::fs::create_dir(&stylesheet_output_directory).await?; } for stylesheet in &self.stylesheets { let file_output_path = stylesheet_output_directory.join(&stylesheet.path); let parent_directory = file_output_path .parent() .ok_or("Output file should have a containing directory.")?; tokio::fs::create_dir_all(parent_directory).await?; tokio::fs::write(file_output_path, stylesheet.contents.as_bytes()).await?; } Ok(()) } pub(crate) async fn copy_static_files(&self, config: &Config) -> Result<(), CustomError> { let static_files_directory = config .get_root_directory() .join(config.get_relative_path_to_static_files()); if !static_files_directory.exists() { return Ok(()); } let static_files = get_all_files(&static_files_directory).await?; for entry in static_files { let (path, contents) = entry.await??; let relative_path = path.strip_prefix(&static_files_directory)?; let output_path = self.output_directory.join(relative_path); let parent_directory = output_path .parent() .ok_or("Output file should have a containing directory.")?; tokio::fs::create_dir_all(parent_directory).await?; tokio::fs::write(output_path, contents).await?; } Ok(()) } } fn build_name_contents_pairs<'a>( entry: &'a include_dir::File<'_>, ) -> Result<(&'a str, &'a str), CustomError> { let path = entry.path(); let name = path .file_stem() .ok_or("All templates should have a stem.")? .to_str() .ok_or("All template filenames should be valid utf-8.")?; let contents = std::str::from_utf8(entry.contents())?; Ok((name, contents)) } type ReadFileResult = std::io::Result<(PathBuf, Vec)>; async fn filter_to_files(entry: &DirEntry) -> WalkFsFilterResult { let file_type = entry.file_type().await?; if file_type.is_dir() { return Ok(WalkAction::Recurse); } if file_type.is_file() { return Ok(WalkAction::HaltAndCapture); } unreachable!("Unhandled file type."); } async fn get_all_files>( root_dir: P, ) -> Result>, CustomError> { let files = walk_fs(root_dir, filter_to_files).await?; let files_and_content = files .into_iter() .map(|entry| tokio::spawn(read_file(entry.path()))); Ok(files_and_content) } async fn read_file(path: PathBuf) -> ReadFileResult { let contents = tokio::fs::read(&path).await?; Ok((path, contents)) }