diff --git a/site/templates/images.hbs b/site/templates/images.hbs index a67f46f..4393b70 100644 --- a/site/templates/images.hbs +++ b/site/templates/images.hbs @@ -14,7 +14,7 @@ Next page {{/if}}
- {{#each images}} + {{#each resources}} {{alt}} {{title}} diff --git a/src/builder.rs b/src/builder.rs index 7dd9b0a..9c1b4e3 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -10,7 +10,7 @@ use pulldown_cmark::{Options, Parser}; use serde::Serialize; use url::Url; -use crate::{images::ImageMetadata, util, PageMetadata, Site, ROOT_PATH, SASS_PATH}; +use crate::{util, PageMetadata, Site, ROOT_PATH, SASS_PATH}; /// URLs which need to have a "me" rel attribute. const ME_URLS: &[&str] = &["https://mas.to/@zyl"]; @@ -240,11 +240,6 @@ impl<'a> SiteBuilder<'a> { /// Builds the site's various image pages. pub fn build_images(&self) -> anyhow::Result<()> { - let images = ImageMetadata::load_all(&self.site.site_path)?; - ImageMetadata::build_lists(self, &images)?; - for (id, image) in images { - image.build(self, id)?; - } - Ok(()) + crate::images::build_images(self) } } diff --git a/src/images.rs b/src/images.rs index 288d59b..d4de8ef 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,312 +1,75 @@ -use std::{ - collections::BTreeMap, - path::{Path, PathBuf}, -}; - -use anyhow::Context; -use itertools::Itertools; -use rss::{validation::Validate, ChannelBuilder, ItemBuilder}; -use serde::{Deserialize, Serialize, Serializer}; -use time::{format_description::well_known::Rfc2822, OffsetDateTime}; +use serde::{Deserialize, Serialize}; use url::Url; -use crate::{builder::SiteBuilder, link_list::Link, PageMetadata, SiteConfig}; +use crate::{ + builder::SiteBuilder, + resource::{ResourceBuilder, ResourceBuilderConfig, ResourceMetadata, ResourceMethods}, + SiteConfig, +}; pub(crate) const IMAGES_PATH: &str = "images"; pub(crate) const IMAGES_OUT_PATH: &str = "i"; +pub fn build_images(site_builder: &SiteBuilder) -> anyhow::Result<()> { + let config = ResourceBuilderConfig { + source_path: IMAGES_PATH.to_string(), + output_path_short: IMAGES_OUT_PATH.to_string(), + output_path_long: "images".to_string(), + resource_template: "image".to_string(), + resource_list_template: "images".to_string(), + rss_template: "rss/image".to_string(), + rss_title: "Zyllian's images".to_string(), + rss_description: "Feed of newly uploaded images from Zyllian's website.".to_string(), + list_title: "Images".to_string(), + tag_list_title: "Image Tags".to_string(), + resource_name_plural: "Images".to_string(), + resources_per_page: site_builder.site.config.images_per_page, + }; + + let mut builder = ResourceBuilder::::new(config); + builder.load_all(&site_builder.site.site_path)?; + builder.build_all(site_builder)?; + + Ok(()) +} + /// Definition for a remote image. #[derive(Debug, Deserialize, Serialize)] pub struct ImageMetadata { - /// The image's title. - pub title: String, - /// The image's timestamp. - #[serde(with = "time::serde::rfc3339")] - pub timestamp: OffsetDateTime, /// The image's alt text. pub alt: String, /// The image's extra description, if any. pub desc: Option, /// The image's file path. pub file: String, - /// The image's tags. - pub tags: Vec, } impl ImageMetadata { - /// Gets an image's ID from its path. - pub fn get_id(path: &Path) -> String { - path.with_extension("") - .file_name() - .expect("Should never fail") - .to_string_lossy() - .into_owned() - } - - /// Loads an image's ID and metadata. - pub fn load(path: &Path) -> anyhow::Result<(String, Self)> { - let id = Self::get_id(path); - let metadata: ImageMetadata = serde_yaml::from_str(&std::fs::read_to_string(path)?)?; - Ok((id, metadata)) - } - - /// Loads all available images. - pub fn load_all(site_path: &Path) -> anyhow::Result> { - let images_path = site_path.join(IMAGES_PATH); - let mut images = Vec::new(); - for e in images_path.read_dir()? { - let p = e?.path(); - if let Some(ext) = p.extension() { - if ext == "yml" { - let (id, metadata) = Self::load(&p)?; - images.push((id, metadata)); - } - } - } - images.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp).reverse()); - Ok(images) - } - - /// Builds an image's page. - pub fn build(self, builder: &SiteBuilder, id: String) -> anyhow::Result<()> { - let out = { - let data = ImageTemplateData { - image: &self, - src: self.cdn_url(&builder.site.config)?.to_string(), - id: &id, - timestamp: self.timestamp, - }; - builder.reg.render("image", &data)? - }; - let out = builder.build_page_raw( - PageMetadata { - title: Some(self.title), - ..Default::default() - }, - &out, - )?; - let out_path = Self::build_path(&builder.build_path, &id); - std::fs::write(out_path, out)?; - Ok(()) - } - /// Gets an image's CDN url. pub fn cdn_url(&self, config: &SiteConfig) -> anyhow::Result { Ok(config.cdn_url.join(&config.s3_prefix)?.join(&self.file)?) } - - /// Gets an image's build path. - pub fn build_path(build_path: &Path, id: &str) -> PathBuf { - build_path - .join(IMAGES_OUT_PATH) - .join(id) - .with_extension("html") - } - - /// Builds the various list pages for images. - pub fn build_lists(builder: &SiteBuilder, metadata: &[(String, Self)]) -> anyhow::Result<()> { - let mut data = Vec::with_capacity(metadata.len()); - for (id, metadata) in metadata { - data.push(ImageTemplateData { - image: metadata, - src: metadata.cdn_url(&builder.site.config)?.to_string(), - id, - timestamp: metadata.timestamp, - }); - } - - fn build_list( - builder: &SiteBuilder, - list: Vec<&ImageTemplateData>, - title: &str, - tag: Option<&str>, - out_path: &Path, - items_per_page: usize, - ) -> anyhow::Result<()> { - if !out_path.exists() { - std::fs::create_dir_all(out_path)?; - } - - let page_max = list.len() / items_per_page - + if list.len() % items_per_page == 0 { - 0 - } else { - 1 - }; - let mut previous = None; - let mut next; - for (page, iter) in list.iter().chunks(items_per_page).into_iter().enumerate() { - next = (page + 1 != page_max).then_some(page + 2); - let data = ImageListTemplateData { - images: iter.copied().collect(), - tag, - page: page + 1, - page_max, - previous, - next, - }; - let out = builder.reg.render("images", &data)?; - let out = builder.build_page_raw( - PageMetadata { - title: Some(title.to_owned()), - ..Default::default() - }, - &out, - )?; - if page == 0 { - std::fs::write(out_path.join("index.html"), &out)?; - } - std::fs::write( - out_path.join(out_path.join((page + 1).to_string()).with_extension("html")), - out, - )?; - previous = Some(page + 1); - } - - Ok(()) - } - - build_list( - builder, - data.iter().collect(), - "Images", - None, - &builder.build_path.join("images"), - builder.site.config.images_per_page, - )?; - - let mut tags: BTreeMap> = BTreeMap::new(); - for image in &data { - for tag in image.image.tags.iter().cloned() { - tags.entry(tag).or_default().push(image); - } - } - - { - let links = tags - .iter() - .map(|(tag, data)| { - let count = data.len(); - ( - Link::new( - format!("/{IMAGES_OUT_PATH}/tag/{tag}/"), - format!("{tag} ({count})"), - ), - count, - ) - }) - .sorted_by(|(_, a), (_, b)| a.cmp(b).reverse()) - .map(|(l, _)| l) - .collect(); - let out = crate::link_list::render_basic_link_list(builder, links, "Image Tags")?; - std::fs::write( - builder.build_path.join(IMAGES_OUT_PATH).join("tags.html"), - out, - )?; - } - - for (tag, data) in tags { - build_list( - builder, - data, - &format!("Images tagged {tag}"), - Some(tag.as_str()), - &builder - .build_path - .join(IMAGES_OUT_PATH) - .join("tag") - .join(&tag), - builder.site.config.images_per_page, - )?; - } - - let mut items = Vec::with_capacity(data.len()); - for image in data { - items.push( - ItemBuilder::default() - .title(Some(image.image.title.to_owned())) - .link(Some( - builder - .site - .config - .base_url - .join(&format!("{IMAGES_OUT_PATH}/{}", image.id))? - .to_string(), - )) - .description(image.image.desc.to_owned()) - .pub_date(Some(image.image.timestamp.format(&Rfc2822)?)) - .content(Some(builder.reg.render("rss/image", &image)?)) - .build(), - ); - } - - // Build RSS feed - let channel = ChannelBuilder::default() - .title("Zyllian's images".to_string()) - .link( - builder - .site - .config - .base_url - .join("images/") - .expect("Should never fail"), - ) - .description("Feed of newly uploaded images from Zyllian's website.".to_string()) - .last_build_date(Some(OffsetDateTime::now_utc().format(&Rfc2822)?)) - .items(items) - .build(); - channel.validate().context("Failed to validate RSS feed")?; - let out = channel.to_string(); - std::fs::write(builder.build_path.join("images").join("rss.xml"), out)?; - - Ok(()) - } } /// Template data for a specific image. #[derive(Debug, Serialize)] -struct ImageTemplateData<'i> { - /// The image's regular metadata. - #[serde(flatten)] - image: &'i ImageMetadata, +struct ImageTemplateData { /// Direct URL to the image's CDN location. /// TODO: link to smaller versions on list pages src: String, - /// The image's ID. - id: &'i str, - /// The image's timestamp. (Duplicated to change the serialization method.) - #[serde(serialize_with = "ImageTemplateData::timestamp_formatter")] - timestamp: OffsetDateTime, } -impl<'i> ImageTemplateData<'i> { - fn timestamp_formatter(timestamp: &OffsetDateTime, serializer: S) -> Result - where - S: Serializer, - { - let out = timestamp - .format( - &time::format_description::parse("[weekday], [month repr:long] [day], [year]") - .expect("Should never fail"), - ) - .expect("Should never fail"); - serializer.serialize_str(&out) +impl ResourceMethods for ResourceMetadata { + fn get_short_desc(&self) -> String { + self.inner.desc.clone().unwrap_or_default() + } + + fn get_extra_resource_template_data( + &self, + site_config: &SiteConfig, + ) -> anyhow::Result { + Ok(ImageTemplateData { + src: self.inner.cdn_url(site_config)?.to_string(), + }) } } - -/// Template data for image lists. -#[derive(Debug, Serialize)] -struct ImageListTemplateData<'i> { - /// The list of images to display. - images: Vec<&'i ImageTemplateData<'i>>, - /// The current tag, if any. - tag: Option<&'i str>, - /// The current page. - page: usize, - /// The total number of pages. - page_max: usize, - /// The previous page, if any. - previous: Option, - /// The next page, if any. - next: Option, -} diff --git a/src/lib.rs b/src/lib.rs index ebf7343..1ba085c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod builder; mod images; mod link_list; +mod resource; #[cfg(feature = "serve")] pub mod serving; mod util; diff --git a/src/resource.rs b/src/resource.rs new file mode 100644 index 0000000..75d81a3 --- /dev/null +++ b/src/resource.rs @@ -0,0 +1,380 @@ +use std::{ + collections::BTreeMap, + marker::PhantomData, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use itertools::Itertools; +use rss::{validation::Validate, ChannelBuilder, ItemBuilder}; +use serde::{de::DeserializeOwned, Deserialize, Serialize, Serializer}; +use time::{format_description::well_known::Rfc2822, OffsetDateTime}; + +use crate::{builder::SiteBuilder, link_list::Link, PageMetadata, SiteConfig}; + +/// Metadata for resources. +#[derive(Debug, Deserialize, Serialize)] +pub struct ResourceMetadata { + /// The resource's title. + pub title: String, + /// The resource's timestamp. + #[serde(with = "time::serde::rfc3339")] + pub timestamp: OffsetDateTime, + /// The resource's tags. + pub tags: Vec, + /// Extra resource data not included. + #[serde(flatten)] + pub inner: T, +} + +#[derive(Debug, Serialize)] +pub struct ResourceTemplateData<'r, M, E> { + /// The resource's metadata. + #[serde(flatten)] + resource: &'r ResourceMetadata, + /// The resource's ID. + id: String, + /// Extra data to be passed to the template. + #[serde(flatten)] + extra: E, + /// The resource's timestamp. Duplicated to change serialization method. + #[serde(serialize_with = "ResourceTemplateData::::timestamp_formatter")] + timestamp: OffsetDateTime, +} + +impl<'r, M, E> ResourceTemplateData<'r, M, E> { + fn timestamp_formatter(timestamp: &OffsetDateTime, serializer: S) -> Result + where + S: Serializer, + { + let out = timestamp + .format( + &time::format_description::parse("[weekday], [month repr:long] [day], [year]") + .expect("Should never fail"), + ) + .expect("Should never fail"); + serializer.serialize_str(&out) + } +} + +/// Trait for getting extra template data from resource metadata. +pub trait ResourceMethods +where + E: Serialize, +{ + fn get_short_desc(&self) -> String; + + fn get_extra_resource_template_data(&self, site_config: &SiteConfig) -> anyhow::Result; +} + +#[derive(Debug, Serialize)] +struct ResourceListTemplateData<'r, M, E> { + resources: Vec<&'r ResourceTemplateData<'r, M, E>>, + tag: Option<&'r str>, + page: usize, + page_max: usize, + previous: Option, + next: Option, +} + +/// Config for the resource builder. +#[derive(Debug)] +pub struct ResourceBuilderConfig { + /// Path to where the resources should be loaded from. + pub source_path: String, + /// Path to where the resource pages should be written to. + pub output_path_short: String, + /// Path to where the main list should be written to. + pub output_path_long: String, + /// The template used to render a single resource. + pub resource_template: String, + /// The template used to render a list of resources. + pub resource_list_template: String, + /// Template used when rendering the RSS feed. + pub rss_template: String, + /// The RSS feed's title. + pub rss_title: String, + /// The description for the RSS feed. + pub rss_description: String, + /// Title for the main list of resources. + pub list_title: String, + /// Title for the page containing a list of tags. + pub tag_list_title: String, + /// Name for the resource type in plural. + pub resource_name_plural: String, + /// The number of resources to display on a single page. + pub resources_per_page: usize, +} + +/// Helper to genericize resource building. +#[derive(Debug)] +pub struct ResourceBuilder { + /// The builder's config. + config: ResourceBuilderConfig, + /// The currently loaded resource metadata. + loaded_metadata: Vec<(String, ResourceMetadata)>, + _extra: PhantomData, +} + +impl ResourceBuilder +where + M: Serialize + DeserializeOwned, + E: Serialize, + ResourceMetadata: ResourceMethods, +{ + /// Creates a new resource builder. + pub fn new(config: ResourceBuilderConfig) -> Self { + Self { + config, + loaded_metadata: Default::default(), + _extra: Default::default(), + } + } + + /// Gets a resource's ID from its path. + fn get_id(path: &Path) -> String { + path.with_extension("") + .file_name() + .expect("Should never fail") + .to_string_lossy() + .into_owned() + } + + /// Loads resource metadata from the given path. + fn load(path: &Path) -> anyhow::Result<(String, ResourceMetadata)> { + let id = Self::get_id(path); + let metadata = serde_yaml::from_str(&std::fs::read_to_string(path)?)?; + Ok((id, metadata)) + } + + /// Loads all resource metadata from the given config. + pub fn load_all(&mut self, site_path: &Path) -> anyhow::Result<()> { + self.loaded_metadata.clear(); + for e in site_path.join(&self.config.source_path).read_dir()? { + let p = e?.path(); + if let Some("yml") = p.extension().and_then(|e| e.to_str()) { + let (id, metadata) = Self::load(&p)?; + self.loaded_metadata.push((id, metadata)); + } + } + self.loaded_metadata + .sort_by(|a, b| b.1.timestamp.cmp(&a.1.timestamp)); + Ok(()) + } + + /// Gets a resource's build path. + fn build_path(&self, base_path: &Path, id: &str) -> PathBuf { + base_path + .join(&self.config.output_path_short) + .join(id) + .with_extension("html") + } + + /// Builds a single resource page. + fn build( + &self, + builder: &SiteBuilder, + id: String, + resource: &ResourceMetadata, + ) -> anyhow::Result<()> { + let out_path = self.build_path(&builder.build_path, &id); + let out = { + let data = ResourceTemplateData { + resource, + extra: resource.get_extra_resource_template_data(&builder.site.config)?, + id, + timestamp: resource.timestamp, + }; + builder.reg.render(&self.config.resource_template, &data)? + }; + + let out = builder.build_page_raw( + PageMetadata { + title: Some(resource.title.clone()), + ..Default::default() + }, + &out, + )?; + std::fs::write(out_path, out)?; + + Ok(()) + } + + pub fn build_all(&self, builder: &SiteBuilder) -> anyhow::Result<()> { + for (id, resource) in &self.loaded_metadata { + self.build(builder, id.clone(), resource)?; + } + + let mut data = Vec::with_capacity(self.loaded_metadata.len()); + for (id, resource) in &self.loaded_metadata { + let extra = resource.get_extra_resource_template_data(&builder.site.config)?; + data.push(ResourceTemplateData { + resource, + extra, + id: id.clone(), + timestamp: resource.timestamp, + }); + } + + fn build_list( + builder: &SiteBuilder, + config: &ResourceBuilderConfig, + list: Vec<&ResourceTemplateData>, + title: &str, + tag: Option<&str>, + out_path: &Path, + items_per_page: usize, + ) -> anyhow::Result<()> + where + M: Serialize, + E: Serialize, + { + if !out_path.exists() { + std::fs::create_dir_all(out_path)?; + } + + let page_max = list.len() / items_per_page + (list.len() % items_per_page).min(1); + let mut previous = None; + let mut next; + for (page, iter) in list.iter().chunks(items_per_page).into_iter().enumerate() { + next = (page + 1 != page_max).then_some(page + 2); + let data = ResourceListTemplateData { + resources: iter.copied().collect(), + tag, + page: page + 1, + page_max, + previous, + next, + }; + let out = builder.reg.render(&config.resource_list_template, &data)?; + let out = builder.build_page_raw( + PageMetadata { + title: Some(title.to_owned()), + ..Default::default() + }, + &out, + )?; + if page == 0 { + std::fs::write(out_path.join("index.html"), &out)?; + } + std::fs::write( + out_path.join((page + 1).to_string()).with_extension("html"), + out, + )?; + previous = Some(page + 1); + } + + Ok(()) + } + + let out_path = builder.build_path.join(&self.config.output_path_short); + + // Build main list of resources + build_list( + builder, + &self.config, + data.iter().collect(), + &self.config.list_title, + None, + &builder.build_path.join(&self.config.output_path_long), + self.config.resources_per_page, + )?; + + // Build resource lists by tag + let mut tags: BTreeMap>> = BTreeMap::new(); + for resource in &data { + for tag in resource.resource.tags.iter().cloned() { + tags.entry(tag).or_default().push(resource); + } + } + + // Build list of tags + { + let links = tags + .iter() + .map(|(tag, data)| { + let count = data.len(); + ( + Link::new( + format!("/{}/tag/{tag}/", self.config.output_path_short), + format!("{tag} ({count})"), + ), + count, + ) + }) + .sorted_by(|(_, a), (_, b)| b.cmp(a)) + .map(|(l, _)| l) + .collect(); + let out = crate::link_list::render_basic_link_list( + builder, + links, + &self.config.tag_list_title, + )?; + std::fs::write(out_path.join("tags.html"), out)?; + } + + for (tag, data) in tags { + build_list( + builder, + &self.config, + data, + &format!("{} tagged {tag}", self.config.resource_name_plural), + Some(tag.as_str()), + &out_path.join("tag").join(&tag), + self.config.resources_per_page, + )?; + } + + // Build RSS feed + let mut items = Vec::with_capacity(data.len()); + for resource in data { + items.push( + ItemBuilder::default() + .title(Some(resource.resource.title.to_owned())) + .link(Some( + builder + .site + .config + .base_url + .join(&format!( + "{}/{}", + self.config.output_path_short, resource.id + ))? + .to_string(), + )) + .description(Some(resource.resource.get_short_desc())) + .pub_date(Some(resource.timestamp.format(&Rfc2822)?)) + .content(Some( + builder.reg.render(&self.config.rss_template, &resource)?, + )) + .build(), + ) + } + + let channel = ChannelBuilder::default() + .title(self.config.rss_title.clone()) + .link( + builder + .site + .config + .base_url + .join(&format!("{}/", self.config.output_path_long)) + .expect("Should never fail"), + ) + .description(self.config.rss_description.clone()) + .last_build_date(Some(OffsetDateTime::now_utc().format(&Rfc2822)?)) + .items(items) + .build(); + channel.validate().context("Failed to validate RSS feed")?; + let out = channel.to_string(); + std::fs::write( + builder + .build_path + .join(&self.config.output_path_long) + .join("rss.xml"), + out, + )?; + + Ok(()) + } +} diff --git a/src/serving.rs b/src/serving.rs index 0d4522c..1f913cd 100644 --- a/src/serving.rs +++ b/src/serving.rs @@ -18,9 +18,7 @@ use warp::{ Filter, }; -use crate::{ - images::ImageMetadata, Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH, TEMPLATES_PATH, -}; +use crate::{Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH, TEMPLATES_PATH}; fn with_build_path( build_path: PathBuf, @@ -109,8 +107,6 @@ fn remove(builder: &mut SiteBuilder, path: &Path, relative_path: &Path) -> anyho } else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) { std::fs::remove_file(builder.build_path.join(root_path))?; } else if let Ok(_image_path) = relative_path.strip_prefix(crate::images::IMAGES_PATH) { - let p = ImageMetadata::build_path(&builder.build_path, &ImageMetadata::get_id(path)); - std::fs::remove_file(p)?; // HACK: same as in `create` builder.build_images()?; }