diff --git a/Cargo.lock b/Cargo.lock index 5bc05a7..33509dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,50 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -117,6 +161,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "basic-toml" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -348,6 +401,15 @@ dependencies = [ "syn", ] +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.1.0" @@ -554,6 +616,12 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + [[package]] name = "litemap" version = "0.7.4" @@ -614,6 +682,28 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.2" @@ -640,6 +730,16 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1208,6 +1308,7 @@ name = "v7" version = "0.1.0" dependencies = [ "anyhow", + "askama", "chrono", "clap", "compute_graph", diff --git a/Cargo.toml b/Cargo.toml index 5e90573..d7f43f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde_json = "1.0" [dependencies] anyhow = "1.0.95" +askama = "0.12.1" chrono = { version = "0.4.39", features = ["serde"] } clap = { version = "4.5.23", features = ["cargo"] } compute_graph = { path = "crates/compute_graph" } diff --git a/askama.toml b/askama.toml index 56397bd..7efad7e 100644 --- a/askama.toml +++ b/askama.toml @@ -1,2 +1,2 @@ [general] -dirs = ["site"] +dirs = ["site_test"] diff --git a/crates/compute_graph/src/builder.rs b/crates/compute_graph/src/builder.rs index 5fbd43e..c199ea1 100644 --- a/crates/compute_graph/src/builder.rs +++ b/crates/compute_graph/src/builder.rs @@ -57,6 +57,11 @@ impl GraphBuilder { self.output = Some(input); } + /// Sets an existing node represented by the given input to be the output node. + pub fn set_existing_output(&mut self, input: Input) { + self.output = Some(input); + } + /// Replaces the current output rule with a new one, changing the output value type. pub fn with_output(mut self, rule: R) -> GraphBuilder { let input = self.add_rule(rule); diff --git a/crates/compute_graph/src/lib.rs b/crates/compute_graph/src/lib.rs index a0a7f0a..12226c4 100644 --- a/crates/compute_graph/src/lib.rs +++ b/crates/compute_graph/src/lib.rs @@ -26,7 +26,8 @@ //! ``` //! //! Here, `a` and `b` are placeholders representing the values of the two constant nodes in the graph. -//! The `Add` struct implements the [`Rule`] trait and defines how to combine those two values by addition. +//! The `Add` struct implements the [`Rule`](`crate::rule::Rule`) trait and defines how to combine +//! those two values by addition. //! The `Add` rule is implemented as follows: //! //! ```rust @@ -165,7 +166,7 @@ impl Graph { let target = graph.to_index(edge.target()); if !old_edges .get(&source) - .map_or(false, |old| !old.contains(&target)) + .map_or(false, |old| old.contains(&target)) { to_invalidate.push_back(edge.target()); } @@ -926,5 +927,6 @@ mod tests { assert_eq!(*graph.evaluate_async().await, 3); set_count.set_value(4); assert_eq!(*graph.evaluate_async().await, 10); + println!("{}", graph.as_dot_string()); } } diff --git a/crates/compute_graph/src/node.rs b/crates/compute_graph/src/node.rs index f868ae4..8cf6cb4 100644 --- a/crates/compute_graph/src/node.rs +++ b/crates/compute_graph/src/node.rs @@ -260,6 +260,8 @@ fn visit_inputs(visitable: &V, visitor: &mut dyn FnMut(NodeId // And visit all the nodes it produces let maybe_dynamic_output = input.input.value.borrow(); if let Some(dynamic_output) = maybe_dynamic_output.as_ref() { + // This might be slightly overzealous: it is possible for a node to only depend on the + // dynamic node itself, and not directly depend on any of the nodes the dynamic node produces. for input in dynamic_output.inputs.iter() { self.visit(input); } @@ -686,8 +688,9 @@ impl std::fmt::Debug for AsyncDynamicRuleNode() -> String { - let s = std::any::type_name::(); - let ty = syn::parse_str::(s).unwrap(); + // idk where the {{closure}} comes from in one of the tests, just do this to avoid panicking + let s = std::any::type_name::().replace("{{closure}}", "__closure__"); + let ty = syn::parse_str::(&s).unwrap(); pretty_type_name_type(ty) } diff --git a/crates/compute_graph/src/rule.rs b/crates/compute_graph/src/rule.rs index 9bd3d1f..a23691c 100644 --- a/crates/compute_graph/src/rule.rs +++ b/crates/compute_graph/src/rule.rs @@ -2,7 +2,9 @@ use crate::node::{DynamicRuleOutput, NodeValue}; use crate::NodeId; pub use compute_graph_macros::InputVisitable; use std::cell::{Cell, Ref, RefCell}; +use std::collections::{HashMap, HashSet}; use std::future::Future; +use std::hash::Hash; use std::ops::Deref; use std::rc::Rc; @@ -88,6 +90,8 @@ pub trait DynamicRule: InputVisitable + 'static { /// Evaluates this rule, producing additional nodes. /// /// Use the methods on [`DynamicRuleContext`] to add or remove nodes from the graph. + /// Or, use [`DynamicNodeFactory`] to always register all nodes and allow that type to track + /// the specific additions/removals. fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec>; #[allow(unused_variables)] @@ -114,6 +118,79 @@ pub trait DynamicRuleContext { R: Rule; } +/// Helper type for working with [`DynamicRule`]s. +/// +/// When implementing [`DynamicRule::evaluate`], call the [`DynamicNodeFactory::add_rule`] method for +/// each of the nodes that should be in the output. Then, call [`DynamicNodeFactory::all_nodes`] to produce +/// the output (i.e., `Vec` of [`Input`]s) of the dynamic node. +/// +/// This type keeps track of which nodes need to be added and removed from the [`DynamicRuleContext`]. +pub struct DynamicNodeFactory { + existing_nodes: HashMap>, + ids_added_this_evaluation: HashSet, +} + +impl DynamicNodeFactory { + /// Constructs a new dynamic node factory. + pub fn new() -> Self { + Self { + existing_nodes: HashMap::new(), + ids_added_this_evaluation: HashSet::new(), + } + } + + /// Registers a node that is part of the output. + /// + /// This method must be called for every node that is part of the output. The `build` function + /// will only be called for nodes that have not previously been built. + pub fn add_rule(&mut self, ctx: &mut impl DynamicRuleContext, id: ID, build: F) + where + F: FnOnce() -> R, + R: Rule, + { + if !self.existing_nodes.contains_key(&id) { + let input = ctx.add_rule(build()); + self.existing_nodes.insert(id.clone(), input); + } + self.ids_added_this_evaluation.insert(id); + } + + /// Registers a node that is part of the output. + /// + /// See [`DynamicNodeFactory::add_rule`]. + pub fn add_async_rule(&mut self, ctx: &mut impl AsyncDynamicRuleContext, id: ID, build: F) + where + F: FnOnce() -> R, + R: AsyncRule, + { + if !self.existing_nodes.contains_key(&id) { + let input = ctx.add_async_rule(build()); + self.existing_nodes.insert(id.clone(), input); + } + self.ids_added_this_evaluation.insert(id); + } + + /// Builds the final list of all nodes currently present in the output. + /// + /// Removes any nodes that were previously output but which have not had [`DynamicNodeFactory::add_rule`] + /// called during this evaluation. + pub fn all_nodes(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { + // collect everything up front so we can mutably borrow existing_nodes + let to_remove = self + .existing_nodes + .keys() + .filter(|k| !self.ids_added_this_evaluation.contains(*k)) + .cloned() + .collect::>(); + for key in to_remove { + let input = self.existing_nodes.remove(&key).unwrap(); + ctx.remove_node(input.node_id()); + } + self.ids_added_this_evaluation.clear(); + self.existing_nodes.values().cloned().collect() + } +} + /// An asynchronous rule whose output is further nodes in the graph. /// /// See [`DynamicRule`]. @@ -124,6 +201,8 @@ pub trait AsyncDynamicRule: InputVisitable + 'static { /// Evaluates this rule asynchronously, producing additional nodes. /// /// Use the methods on [`AsyncDynamicRuleContext`] to add or remove nodes from the graph. + /// Or, use [`DynamicNodeFactory`] to always register all nodes and allow that type to track + /// the specific additions/removals. fn evaluate<'a>( &'a mut self, ctx: &'a mut impl AsyncDynamicRuleContext, diff --git a/site_test/archive.html b/site_test/archive.html new file mode 100644 index 0000000..bf20e19 --- /dev/null +++ b/site_test/archive.html @@ -0,0 +1,20 @@ +{% extends "layout/default.html" %} + +{% block title %}Archive{% endblock %} + +{% block content -%} + +{% for year in self.years() %} +

{{ year }}

+ +{% endfor %} + +{%- endblock %} diff --git a/site_test/layout/article.html b/site_test/layout/article.html new file mode 100644 index 0000000..dd8ed75 --- /dev/null +++ b/site_test/layout/article.html @@ -0,0 +1,30 @@ +{% extends "layout/default.html" %} + +{% block head -%} + + +{% match post.metadata.short_desc %} + {% when Some with (val) %} + + {% when None %} + +{% endmatch %} + +{%- endblock %} + +{% block image %} +{% match post.metadata.card_image_path %} + {% when Some with (path) %} + + + {% when None %} + + +{% endmatch %} +{% endblock %} + +{% block title %}{{ post.metadata.title }}{% endblock %} + +
+ +
diff --git a/site_test/layout/default.html b/site_test/layout/default.html new file mode 100644 index 0000000..e6ef771 --- /dev/null +++ b/site_test/layout/default.html @@ -0,0 +1,37 @@ + + + + + + + + {% block title %}Shadowfacts{% endblock %} + + + + + + + + + + + + {% block image %} + + + {% endblock %} + + + + {% block head %}{% endblock %} + + + + + + {% block content %}{% endblock %} + + + + diff --git a/site_test/tag.html b/site_test/tag.html new file mode 100644 index 0000000..2941728 --- /dev/null +++ b/site_test/tag.html @@ -0,0 +1,9 @@ +{% extends "layout/default.html" %} + +{% block title %}{{ tag.name }} posts{% endblock %} + +{% block content -%} + +

{{ tag.name }} posts

+ +{%- endblock %} diff --git a/src/generator/archive.rs b/src/generator/archive.rs new file mode 100644 index 0000000..6c72385 --- /dev/null +++ b/src/generator/archive.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +use askama::Template; +use chrono::Datelike; +use compute_graph::{ + builder::GraphBuilder, + rule::{Input, InputVisitable, Rule}, + synchronicity::Asynchronous, +}; + +use super::{ + posts::content::{HtmlContent, Post}, + util::{output_rendered_template, templates::TemplateCommon}, +}; + +pub fn make_graph( + builder: &mut GraphBuilder<(), Asynchronous>, + posts: Input>>, +) -> Input<()> { + let entries = builder.add_rule(Entries(posts)); + let posts_by_year = builder.add_rule(PostsByYear(entries)); + builder.add_rule(Archive(posts_by_year)) +} + +#[derive(InputVisitable)] +struct Entries(Input>>); +impl Rule for Entries { + type Output = Vec; + fn evaluate(&mut self) -> Self::Output { + self.input_0() + .iter() + .map(|post| Entry { + permalink: post.permalink(), + title: post.metadata.title.clone(), + year: post.metadata.date.year(), + }) + .collect() + } +} + +#[derive(InputVisitable)] +struct PostsByYear(Input>); +impl Rule for PostsByYear { + type Output = PostsYearMap; + fn evaluate(&mut self) -> Self::Output { + let mut map = HashMap::new(); + for entry in self.input_0().iter().cloned() { + map.entry(entry.year).or_insert(vec![]).push(entry); + } + PostsYearMap(map) + } +} + +#[derive(PartialEq)] +struct PostsYearMap(HashMap>); + +#[derive(PartialEq, Clone)] +struct Entry { + permalink: String, + title: String, + year: i32, +} + +#[derive(InputVisitable)] +struct Archive(Input); +impl Rule for Archive { + type Output = (); + fn evaluate(&mut self) -> Self::Output { + output_rendered_template( + &ArchiveTemplate { + map: &*self.input_0(), + }, + "archive/index.html", + ) + .expect("writing archive") + } +} + +#[derive(Template)] +#[template(path = "archive.html")] +struct ArchiveTemplate<'a> { + map: &'a PostsYearMap, +} + +impl<'a> TemplateCommon for ArchiveTemplate<'a> {} + +impl<'a> ArchiveTemplate<'a> { + fn permalink(&self) -> &'static str { + "/archive/" + } + + fn years(&self) -> Vec { + let mut years = self.map.0.keys().cloned().collect::>(); + years.sort(); + years.reverse(); + years + } + + fn posts_for_year(&self, year: &i32) -> &[Entry] { + self.map + .0 + .get(year) + .map(|vec| vec.as_slice()) + .unwrap_or(&[]) + } +} diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 2dc71cf..a0781f1 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -1,13 +1,11 @@ +mod archive; mod markdown; mod posts; +mod tags; mod util; -use compute_graph::{ - AsyncGraph, - builder::GraphBuilder, - rule::{Input, InputVisitable, Rule}, -}; -use util::MapToVoid; +use compute_graph::{AsyncGraph, builder::GraphBuilder}; +use util::{Combine, MapToVoid}; pub async fn generate() -> anyhow::Result<()> { std::fs::create_dir_all("out").expect("creating output dir"); @@ -26,20 +24,29 @@ pub async fn generate() -> anyhow::Result<()> { fn make_graph() -> anyhow::Result> { let mut builder = GraphBuilder::new_async(); - let (posts, post_metadatas) = posts::make_graph(&mut builder); + let (void_outputs, posts, all_posts, post_metadatas) = posts::make_graph(&mut builder); - let posts_output = builder.add_rule(MapToVoid(post_metadatas)); - builder.set_output(Output { - posts: posts_output, - }); + let archive = archive::make_graph(&mut builder, all_posts); + + let tag_output = tags::make_graph(&mut builder, posts); + + let post_metadatas_voided = builder.add_rule(MapToVoid(post_metadatas)); + let output = Combine::make(&mut builder, &[ + void_outputs, + archive, + tag_output, + post_metadatas_voided, + ]); + builder.set_existing_output(output); Ok(builder.build()?) } -#[derive(InputVisitable)] -struct Output { - posts: Input<()>, -} -impl Rule for Output { - type Output = (); - fn evaluate(&mut self) -> Self::Output {} -} +// #[derive(InputVisitable)] +// struct Output { +// archive: Input<()>, +// posts: Input<()>, +// } +// impl Rule for Output { +// type Output = (); +// fn evaluate(&mut self) -> Self::Output {} +// } diff --git a/src/generator/posts.rs b/src/generator/posts.rs index d0b14c6..399dcc2 100644 --- a/src/generator/posts.rs +++ b/src/generator/posts.rs @@ -1,29 +1,44 @@ -mod content; -mod metadata; +pub mod content; +pub mod metadata; -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; +use askama::Template; use compute_graph::{ builder::GraphBuilder, - rule::{DynamicInput, DynamicRule, DynamicRuleContext, Input, InputVisitable, Rule}, + rule::{ + DynamicInput, DynamicNodeFactory, DynamicRule, DynamicRuleContext, Input, InputVisitable, + Rule, + }, synchronicity::Asynchronous, }; use content::{HtmlContent, Post}; use log::error; use metadata::PostMetadata; -use super::util::content_path; +use crate::generator::util::output_rendered_template; + +use super::util::{MapDynamicToVoid, content_path, templates::TemplateCommon}; pub fn make_graph( builder: &mut GraphBuilder<(), Asynchronous>, -) -> (Input>>, Input>) { +) -> ( + Input<()>, + DynamicInput, + Input>>, + Input>, +) { // todo: make this invalidatable, watch files let post_files = builder.add_rule(ListPostFiles); let posts = builder.add_dynamic_rule(MakeReadNodes::new(post_files)); let extract_metadatas = builder.add_dynamic_rule(MakeExtractMetadatas::new(posts.clone())); + let write_posts = builder.add_dynamic_rule(MakeWritePosts::new(posts.clone())); + ( + builder.add_rule(MapDynamicToVoid(write_posts)), + posts.clone(), builder.add_rule(AllPosts(posts)), builder.add_rule(AllMetadatas(extract_metadatas)), ) @@ -65,13 +80,13 @@ fn find_index(path: PathBuf) -> Option { #[derive(InputVisitable)] struct MakeReadNodes { files: Input>, - existing_nodes: HashMap>, + node_factory: DynamicNodeFactory, } impl MakeReadNodes { fn new(files: Input>) -> Self { Self { files, - existing_nodes: HashMap::new(), + node_factory: DynamicNodeFactory::new(), } } } @@ -79,27 +94,14 @@ impl DynamicRule for MakeReadNodes { type ChildOutput = ReadPostOutput; fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { for file in self.files.value().iter() { - if !self.existing_nodes.contains_key(file) { - let input = ctx.add_rule(ReadPost { path: file.clone() }); - self.existing_nodes.insert(file.clone(), input); - } + self.node_factory + .add_rule(ctx, file.clone(), || ReadPost { path: file.clone() }); } - // collect everything up front so we can mutably borrow existing_nodes - let to_remove = self - .existing_nodes - .keys() - .filter(|key| !self.files.value().contains(key)) - .cloned() - .collect::>(); - for key in to_remove { - ctx.remove_node(self.existing_nodes[&key].node_id()); - self.existing_nodes.remove(&key); - } - self.existing_nodes.values().cloned().collect() + self.node_factory.all_nodes(ctx) } } -type ReadPostOutput = Option>; +pub type ReadPostOutput = Option>; #[derive(InputVisitable)] struct ReadPost { @@ -136,41 +138,27 @@ impl Rule for ReadPost { #[derive(InputVisitable)] struct MakeExtractMetadatas { posts: DynamicInput, - existing_nodes: HashMap>, + node_factory: DynamicNodeFactory, } impl MakeExtractMetadatas { fn new(posts: DynamicInput) -> Self { Self { posts, - existing_nodes: HashMap::new(), + node_factory: DynamicNodeFactory::new(), } } } impl DynamicRule for MakeExtractMetadatas { type ChildOutput = ExtractMetadataOutput; fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { - let mut all_posts = vec![]; for post_input in self.posts.value().inputs.iter() { let post_ = post_input.value(); let post = post_.as_ref().unwrap(); - all_posts.push(post.path.clone()); - - if !self.existing_nodes.contains_key(&post.path) { - let input = ctx.add_rule(ExtractMetadata(post_input.clone())); - self.existing_nodes.insert(post.path.clone(), input); - } + self.node_factory.add_rule(ctx, post.path.clone(), || { + ExtractMetadata(post_input.clone()) + }); } - let keys = self - .existing_nodes - .keys() - .filter(|key| !all_posts.contains(key)) - .cloned() - .collect::>(); - for key in keys { - ctx.remove_node(self.existing_nodes[&key].node_id()); - self.existing_nodes.remove(&key); - } - self.existing_nodes.values().cloned().collect() + self.node_factory.all_nodes(ctx) } } @@ -194,6 +182,64 @@ impl Rule for ExtractMetadata { } } +#[derive(InputVisitable)] +struct MakeWritePosts { + posts: DynamicInput, + node_factory: DynamicNodeFactory, +} +impl MakeWritePosts { + fn new(posts: DynamicInput) -> Self { + Self { + posts, + node_factory: DynamicNodeFactory::new(), + } + } +} +impl DynamicRule for MakeWritePosts { + type ChildOutput = (); + fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { + for post_input in self.posts.value().inputs.iter() { + if let Some(post) = post_input.value().as_ref() { + self.node_factory + .add_rule(ctx, post.path.clone(), || WritePost(post_input.clone())); + } + } + self.node_factory.all_nodes(ctx) + } +} + +#[derive(InputVisitable)] +struct WritePost(Input); +impl Rule for WritePost { + type Output = (); + + fn evaluate(&mut self) -> Self::Output { + let post_ = &self.input_0(); + let post = post_.as_ref().unwrap(); + let mut path = PathBuf::from(post.permalink()); + path.push("index.html"); + output_rendered_template(&ArticleTemplate { post }, path).expect("writing post"); + } + + fn node_label(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.input_0().as_ref().unwrap().slug) + } +} + +#[derive(Template)] +#[template(path = "layout/article.html")] +struct ArticleTemplate<'a> { + post: &'a Post, +} + +impl<'a> TemplateCommon for ArticleTemplate<'a> {} + +impl<'a> ArticleTemplate<'a> { + fn permalink(&self) -> String { + self.post.permalink() + } +} + /// Flattens Vec>> into Vec> #[derive(InputVisitable)] struct AllPosts(DynamicInput); diff --git a/src/generator/posts/content.rs b/src/generator/posts/content.rs index d6e1491..3d7cfd0 100644 --- a/src/generator/posts/content.rs +++ b/src/generator/posts/content.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use anyhow::anyhow; +use chrono::Datelike; use crate::generator::markdown; use crate::generator::posts::metadata::PostMetadata; @@ -58,6 +59,10 @@ impl Post { content: self.content.to_html(), } } + + pub fn permalink(&self) -> String { + format!("/{}/{}/", self.metadata.date.year(), self.slug) + } } #[derive(Debug, PartialEq, Clone)] diff --git a/src/generator/tags.rs b/src/generator/tags.rs new file mode 100644 index 0000000..439363f --- /dev/null +++ b/src/generator/tags.rs @@ -0,0 +1,181 @@ +use std::{collections::HashMap, path::PathBuf}; + +use askama::Template; +use compute_graph::{ + builder::GraphBuilder, + rule::{DynamicInput, DynamicNodeFactory, DynamicRule, Input, InputVisitable, Rule}, + synchronicity::Asynchronous, +}; + +use super::{ + posts::{ + ReadPostOutput, + metadata::{PostMetadata, Tag}, + }, + util::{MapDynamicToVoid, output_rendered_template, templates::TemplateCommon}, +}; + +pub fn make_graph( + builder: &mut GraphBuilder<(), Asynchronous>, + posts: DynamicInput, +) -> Input<()> { + let by_tags = builder.add_dynamic_rule(MakePostsByTags::new(posts)); + let write_tags = builder.add_dynamic_rule(MakeWriteTagPages::new(by_tags)); + builder.add_rule(MapDynamicToVoid(write_tags)) +} + +#[derive(InputVisitable)] +struct MakePostsByTags { + posts: DynamicInput, + node_factory: DynamicNodeFactory, +} +impl MakePostsByTags { + fn new(posts: DynamicInput) -> Self { + Self { + posts, + node_factory: DynamicNodeFactory::new(), + } + } +} +impl DynamicRule for MakePostsByTags { + type ChildOutput = TagAndPosts; + fn evaluate( + &mut self, + ctx: &mut impl compute_graph::rule::DynamicRuleContext, + ) -> Vec> { + let mut all_tags = HashMap::new(); + for post_input in self.posts().inputs.iter() { + let post = post_input.value(); + for tag in post.as_ref().unwrap().metadata.tags.iter().flatten() { + all_tags.insert(tag.slug.clone(), tag.name.clone()); + } + } + for (slug, name) in all_tags { + self.node_factory + .add_rule(ctx, slug.clone(), || PostsByTag { + posts: self.posts.clone(), + tag: Tag { slug, name }, + }); + } + self.node_factory.all_nodes(ctx) + } +} + +#[derive(InputVisitable)] +struct PostsByTag { + posts: DynamicInput, + tag: Tag, +} +impl Rule for PostsByTag { + type Output = TagAndPosts; + + fn evaluate(&mut self) -> Self::Output { + let entries = self + .posts() + .inputs + .iter() + .flat_map(|post_input| { + let post_ = post_input.value(); + let post = post_.as_ref().unwrap(); + let mut tags = post.metadata.tags.iter().flatten(); + if tags.any(|t| t.slug == self.tag.slug) { + Some(Entry { + permalink: post.permalink(), + metadata: post.metadata.clone(), + }) + } else { + None + } + }) + .collect(); + TagAndPosts { + tag: self.tag.clone(), + entries, + } + } + + fn node_label(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.tag.slug) + } +} + +#[derive(PartialEq, Clone)] +struct TagAndPosts { + tag: Tag, + entries: Vec, +} + +#[derive(PartialEq, Clone)] +struct Entry { + permalink: String, + metadata: PostMetadata, +} + +#[derive(InputVisitable)] +struct MakeWriteTagPages { + tags: DynamicInput, + node_factory: DynamicNodeFactory, +} +impl MakeWriteTagPages { + fn new(tags: DynamicInput) -> Self { + Self { + tags, + node_factory: DynamicNodeFactory::new(), + } + } +} +impl DynamicRule for MakeWriteTagPages { + type ChildOutput = (); + fn evaluate( + &mut self, + ctx: &mut impl compute_graph::rule::DynamicRuleContext, + ) -> Vec> { + for tag_input in self.tags.value().inputs.iter() { + let tag_and_posts = tag_input.value(); + self.node_factory + .add_rule(ctx, tag_and_posts.tag.slug.clone(), || { + WriteTag(tag_input.clone()) + }); + } + self.node_factory.all_nodes(ctx) + } +} + +#[derive(InputVisitable)] +struct WriteTag(Input); +impl Rule for WriteTag { + type Output = (); + + fn evaluate(&mut self) -> Self::Output { + let tag_and_posts = self.input_0(); + let mut path = PathBuf::from(&tag_and_posts.tag.slug); + path.push("index.html"); + output_rendered_template( + &TagTemplate { + tag: &tag_and_posts.tag, + posts: &tag_and_posts.entries, + }, + path, + ) + .expect("writing tag page"); + } + + fn node_label(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.input_0().tag.slug) + } +} + +#[derive(Template)] +#[template(path = "tag.html")] +struct TagTemplate<'a> { + tag: &'a Tag, + posts: &'a [Entry], +} + +impl<'a> TemplateCommon for TagTemplate<'a> {} + +impl<'a> TagTemplate<'a> { + fn permalink(&self) -> String { + format!("/{}/", self.tag.slug) + } +} diff --git a/src/generator/util/mod.rs b/src/generator/util/mod.rs index 6a0f1a9..b7e5a40 100644 --- a/src/generator/util/mod.rs +++ b/src/generator/util/mod.rs @@ -1,13 +1,59 @@ pub mod one_more; pub mod slugify; +pub mod templates; pub mod word_count; +use std::io::{BufWriter, Write}; use std::path::{Component, Path, PathBuf}; use anyhow::anyhow; +use askama::Template; +use compute_graph::builder::GraphBuilder; use compute_graph::rule::{DynamicInput, Input, InputVisitable, Rule}; +use compute_graph::synchronicity::Synchronicity; use serde::de::DeserializeOwned; +pub fn output_writer(path: impl AsRef) -> Result { + let path = output_path(path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::File::create(path)?; + Ok(BufWriter::new(file)) +} + +pub fn output_rendered_template( + template: &impl Template, + file: impl AsRef, +) -> Result<(), std::io::Error> { + let path = file.as_ref(); + let writer = output_writer(path)?; + template.render_into(&mut FmtWriter(writer)).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("writing template {}: {}", path.display(), e), + ) + }) +} + +struct FmtWriter(W); + +impl std::fmt::Write for FmtWriter { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.write_all(s.as_bytes()).map_err(|_| std::fmt::Error) + } + + fn write_char(&mut self, c: char) -> std::fmt::Result { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf); + self.0.write_all(&buf).map_err(|_| std::fmt::Error) + } + + fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::fmt::Result { + self.0.write_fmt(args).map_err(|_| std::fmt::Error) + } +} + pub fn from_frontmatter(contents: &str) -> anyhow::Result<(D, &str)> { let mut chars = contents.char_indices(); for i in 0..=2 { @@ -74,6 +120,40 @@ impl Rule for MapDynamicToVoid { fn evaluate(&mut self) -> Self::Output {} } +#[derive(InputVisitable)] +pub struct Combine(pub Input<()>, pub Input<()>); +impl Combine { + pub fn make( + builder: &mut GraphBuilder, + inputs: &[Input<()>], + ) -> Input<()> { + if inputs.is_empty() { + panic!("can only create a combine rule with one or more inputs") + } else if inputs.len() == 1 { + inputs[0].clone() + } else { + let input = builder.add_rule(Combine(inputs[0].clone(), inputs[1].clone())); + Self::make_(builder, input, &inputs[2..]) + } + } + fn make_( + builder: &mut GraphBuilder, + acc: Input<()>, + rest: &[Input<()>], + ) -> Input<()> { + if rest.is_empty() { + acc + } else { + let input = builder.add_rule(Combine(acc, rest[0].clone())); + Self::make_(builder, input, &rest[1..]) + } + } +} +impl Rule for Combine { + type Output = (); + fn evaluate(&mut self) -> Self::Output {} +} + #[cfg(test)] mod tests { use super::join_abs; diff --git a/src.bak/generator/util/templates.rs b/src/generator/util/templates.rs similarity index 80% rename from src.bak/generator/util/templates.rs rename to src/generator/util/templates.rs index 082dca9..7bd7260 100644 --- a/src.bak/generator/util/templates.rs +++ b/src/generator/util/templates.rs @@ -1,8 +1,10 @@ -use crate::activitypub::DOMAIN; use chrono::{DateTime, Local}; use once_cell::sync::Lazy; use std::time::SystemTime; +static DOMAIN: Lazy = + Lazy::new(|| std::env::var("DOMAIN").unwrap_or("shadowfacts.net".to_owned())); + static CB: Lazy = Lazy::new(|| { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -21,17 +23,6 @@ pub trait TemplateCommon { *CB } - fn fancy_link(text: &str, href: &str, meta: Option<&str>) -> String { - format!( - r#" - {} - "#, - href, - meta.unwrap_or(""), - text - ) - } - fn generated_at() -> &'static DateTime { &*GENERATED_AT } @@ -43,9 +34,11 @@ pub mod filters { use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; pub fn iso_date(date: &NaiveDate) -> askama::Result { - Ok(DateTime::::from_utc(date.and_hms(12, 0, 0), Utc) - .format("%+:0") - .to_string()) + Ok( + Utc::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()) + .format("%+:0") + .to_string(), + ) } pub fn iso_datetime(datetime: &DateTime) -> askama::Result