Implement image display

This commit is contained in:
Zoey 2022-12-14 21:39:19 -08:00
parent 97b9fbf46d
commit c0ed59b2cd
19 changed files with 730 additions and 25 deletions

View file

@ -11,7 +11,7 @@ use serde::Serialize;
use url::Url;
use walkdir::WalkDir;
use crate::{util, PageMetadata, Site, ROOT_PATH, SASS_PATH, STATIC_PATH};
use crate::{images::ImageMetadata, util, PageMetadata, Site, ROOT_PATH, SASS_PATH, STATIC_PATH};
/// Struct containing data to be sent to templates when rendering them.
#[derive(Debug, Serialize)]
@ -102,29 +102,23 @@ impl<'a> SiteBuilder<'a> {
.context("Failed to copy static directory")?;
}
let images_path = self.build_path.join(crate::images::IMAGES_OUT_PATH);
if !images_path.exists() {
std::fs::create_dir(images_path).context("Failed to create images path")?;
}
Ok(self)
}
/// Builds a page.
pub fn build_page(&self, page_name: &str) -> anyhow::Result<()> {
let page_path = self.site.page_index.get(page_name).unwrap();
let input = std::fs::read_to_string(page_path)
.with_context(|| format!("Failed to read page at {}", page_path.display()))?;
let page = self.matter.parse(&input);
let page_metadata = if let Some(data) = page.data {
data.deserialize()?
} else {
PageMetadata::default()
};
let parser = Parser::new_ext(&page.content, Options::all());
let mut page_html = String::new();
pulldown_cmark::html::push_html(&mut page_html, parser);
/// Helper to build a page without writing it to disk.
pub fn build_page_raw(
&self,
page_metadata: PageMetadata,
page_html: &str,
) -> anyhow::Result<String> {
let out = self.reg.render(
&page_metadata.template.unwrap_or_else(|| "base".to_string()),
&TemplateData { page: &page_html },
&TemplateData { page: page_html },
)?;
let title = match &page_metadata.title {
@ -184,6 +178,28 @@ impl<'a> SiteBuilder<'a> {
out = minifier::html::minify(&out);
}
Ok(out)
}
/// Builds a page.
pub fn build_page(&self, page_name: &str) -> anyhow::Result<()> {
let page_path = self.site.page_index.get(page_name).expect("Missing page");
let input = std::fs::read_to_string(page_path)
.with_context(|| format!("Failed to read page at {}", page_path.display()))?;
let page = self.matter.parse(&input);
let page_metadata = if let Some(data) = page.data {
data.deserialize()?
} else {
PageMetadata::default()
};
let parser = Parser::new_ext(&page.content, Options::all());
let mut page_html = String::new();
pulldown_cmark::html::push_html(&mut page_html, parser);
let out = self.build_page_raw(page_metadata, &page_html)?;
let out_path = self.build_path.join(page_name).with_extension("html");
std::fs::create_dir_all(out_path.parent().unwrap())
.with_context(|| format!("Failed to create directory for page {}", page_name))?;
@ -239,4 +255,14 @@ impl<'a> SiteBuilder<'a> {
Ok(())
}
/// 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(())
}
}

292
src/images.rs Normal file
View file

@ -0,0 +1,292 @@
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use anyhow::Context;
use itertools::Itertools;
use rss::{validation::Validate, ChannelBuilder, ItemBuilder};
use serde::{Deserialize, Serialize};
use time::{format_description::well_known::Rfc2822, OffsetDateTime};
use url::Url;
use crate::{builder::SiteBuilder, link_list::Link, PageMetadata, SiteConfig};
pub(crate) const IMAGES_PATH: &str = "images";
pub(crate) const IMAGES_OUT_PATH: &str = "i";
/// 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<String>,
/// The image's file path.
pub file: String,
/// The image's tags.
pub tags: Vec<String>,
}
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<Vec<(String, Self)>> {
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,
};
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<Url> {
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,
});
}
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<String, Vec<&ImageTemplateData>> = 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,
/// 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,
}
/// 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<usize>,
/// The next page, if any.
next: Option<usize>,
}

View file

@ -1,4 +1,6 @@
mod builder;
mod images;
mod link_list;
#[cfg(feature = "serve")]
pub mod serving;
mod util;
@ -10,6 +12,7 @@ use std::{
use anyhow::Context;
use serde::Deserialize;
use url::Url;
use walkdir::WalkDir;
use builder::SiteBuilder;
@ -24,7 +27,7 @@ const ROOT_PATH: &str = "root";
#[derive(Debug, Deserialize)]
pub struct SiteConfig {
/// The location the site is at.
pub base_url: String,
pub base_url: Url,
/// The site's title.
pub title: String,
/// The site's description? Not sure if this will actually be used or not
@ -33,6 +36,12 @@ pub struct SiteConfig {
pub build: Option<String>,
/// A list of Sass stylesheets that will be built.
pub sass_styles: Vec<PathBuf>,
/// The number of images to display on a single page of an image list.
pub images_per_page: usize,
/// URL to the CDN used for the site's images.
pub cdn_url: Url,
/// Prefix applied to all files uploaded to the site's S3 space.
pub s3_prefix: String,
}
/// Struct for the front matter in templates. (nothing here yet)
@ -124,6 +133,7 @@ impl Site {
builder.site.build_all_pages(&builder)?;
builder.build_sass()?;
builder.build_images()?;
Ok(())
}

50
src/link_list.rs Normal file
View file

@ -0,0 +1,50 @@
use std::borrow::Cow;
use serde::Serialize;
use crate::{builder::SiteBuilder, PageMetadata};
/// Helper for links.
#[derive(Debug, Serialize)]
pub struct Link<'l> {
/// The link's actual link.
pub link: Cow<'l, str>,
/// The link's title.
pub title: Cow<'l, str>,
}
impl<'l> Link<'l> {
/// Creates a new link.
pub fn new(link: impl Into<Cow<'l, str>>, title: impl Into<Cow<'l, str>>) -> Self {
let link = link.into();
let title = title.into();
Self { link, title }
}
}
/// Renders a basic list of links.
pub fn render_basic_link_list(
builder: &SiteBuilder,
links: Vec<Link>,
title: &str,
) -> anyhow::Result<String> {
let data = LinkTemplateData { links, title };
let out = builder.reg.render("basic-link-list", &data)?;
let out = builder.build_page_raw(
PageMetadata {
title: Some(title.to_owned()),
..Default::default()
},
&out,
)?;
Ok(out)
}
/// Template data for a list of links.
#[derive(Debug, Serialize)]
struct LinkTemplateData<'l> {
/// The actual links.
links: Vec<Link<'l>>,
/// The title for the page.
title: &'l str,
}

View file

@ -18,7 +18,10 @@ use warp::{
Filter,
};
use crate::{Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH, STATIC_PATH, TEMPLATES_PATH};
use crate::{
images::ImageMetadata, Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH, STATIC_PATH,
TEMPLATES_PATH,
};
fn with_build_path(
build_path: PathBuf,
@ -63,6 +66,7 @@ fn create(
builder.refresh_template(&template_name_str, path)?;
if build {
builder.site.build_all_pages(builder)?;
builder.build_images()?;
}
} else if let Ok(_static_path) = relative_path.strip_prefix(STATIC_PATH) {
std::fs::copy(path, builder.build_path.join(relative_path))?;
@ -76,6 +80,9 @@ fn create(
}
} else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) {
std::fs::copy(path, builder.build_path.join(root_path))?;
} else if let Ok(_image_path) = relative_path.strip_prefix(crate::images::IMAGES_PATH) {
// HACK: this could get very inefficient with a larger number of images. should definitely optimize
builder.build_images()?;
}
Ok(())
@ -108,6 +115,11 @@ fn remove(builder: &mut SiteBuilder, path: &Path, relative_path: &Path) -> anyho
builder.build_sass().context("Failed to rebuild Sass")?;
} 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()?;
}
Ok(())
@ -134,6 +146,9 @@ impl Site {
}
}
builder.build_sass().context("Failed to build Sass")?;
builder
.build_images()
.context("Failed to build image pages")?;
// Map of websocket connections
let peers: Arc<Mutex<HashMap<SocketAddr, WebSocket>>> =
@ -238,13 +253,16 @@ impl Site {
.and_then(move |path: FullPath, build_path: PathBuf| async move {
// Serve static files
let p = &path.as_str()[1..];
let p = percent_encoding::percent_decode_str(p)
.decode_utf8()
.expect("Failed to decode URL");
if p == "static/_dev.js" {
let res = Response::new(include_str!("./refresh_websocket.js").into());
return Ok(res);
}
let mut p = build_path.join(p);
let mut p = build_path.join(p.as_ref());
if !p.exists() {
p = p.with_extension("html");