natter/src/command/build/render.rs
Tom Alexander fa8753077a
All checks were successful
format Build format has succeeded
clippy Build clippy has succeeded
rust-test Build rust-test has succeeded
Add support for unlisted posts.
2025-02-23 12:02:14 -05:00

336 lines
12 KiB
Rust

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<BlogPost>,
stylesheets: Vec<Stylesheet>,
pages: Vec<IPage>,
}
impl SiteRenderer {
pub(crate) fn new<P: Into<PathBuf>>(
output_directory: P,
blog_posts: Vec<BlogPost>,
stylesheets: Vec<Stylesheet>,
pages: Vec<IPage>,
) -> SiteRenderer {
SiteRenderer {
output_directory: output_directory.into(),
blog_posts,
stylesheets,
pages,
}
}
fn init_renderer_integration(&self) -> Result<DusterRenderer<'_>, 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 {
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 {
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<u8>)>;
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<P: Into<PathBuf>>(
root_dir: P,
) -> Result<impl Iterator<Item = JoinHandle<ReadFileResult>>, 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))
}