From 009e9cfcb113272a924b65b535f20fa16223612e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 13 Jan 2025 14:21:04 -0500 Subject: [PATCH] Add tv --- site/tv/doctor-who-2005.md | 3 +- site_test/css/main.scss | 10 ++ site_test/show.html | 41 +++++ site_test/tv.html | 33 ++++ src/generator/mod.rs | 6 +- src/generator/templates.rs | 11 +- src/generator/tv.rs | 351 +++++++++++++++++++++++++++++++++++++ 7 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 site_test/show.html create mode 100644 site_test/tv.html create mode 100644 src/generator/tv.rs diff --git a/site/tv/doctor-who-2005.md b/site/tv/doctor-who-2005.md index 5056c25..9ff4d52 100644 --- a/site/tv/doctor-who-2005.md +++ b/site/tv/doctor-who-2005.md @@ -27,7 +27,7 @@ The inside of the TARDIS still seems cramped right around the console, but the l Sure, the episode was a bit cheesy. But in fun, campy way. - + In some ways it was good, in some ways it was bad. Jodie Whittaker and Sacha Dhawan are both great. @@ -172,4 +172,3 @@ Honestly, I would be entirely fine with this series being never spoken of again I like the idea of this series, doing a serial again, but this was just not good enough. There were way too many distinct threads and they didn’t manage to come together nicely. And the overall concept of the arc was confusing. Like, the entire season could have been just about the end of the universe or just about the Doctor trying to find the Division and the plot would have been far less confusing and still perfectly entertaining. I’m really sad. I really like Jodie Whittaker as The Doctor; she’s excellent when she gets to actually do things rather than standing around being expositioned at. She deserves so much better than Chibnall’s awful writing. - diff --git a/site_test/css/main.scss b/site_test/css/main.scss index 7746e82..d34b842 100644 --- a/site_test/css/main.scss +++ b/site_test/css/main.scss @@ -246,6 +246,16 @@ aside:not(.inline) { } } +.tv-show-entry { + margin-bottom: 1rem; + + > summary { + > h2 { + display: inline; + } + } +} + .footnote { display: none; flex-direction: row; diff --git a/site_test/show.html b/site_test/show.html new file mode 100644 index 0000000..12cde3a --- /dev/null +++ b/site_test/show.html @@ -0,0 +1,41 @@ +{% extends "default" %} + +{% block titlevariable %} +{% set title = show.metadata.title %} +{% endblock %} + +{% block content -%} + +

{{ show.metadata.title }}

+

+ {{ show.episodes | length }} + entr{% if show.episodes | length == 1 %}y{% else %}ies{% endif %}. + Last updated on + +

+ + + + +{% for episode in show.episodes %} +
+ +

{{ episode.title }}

+ +
+
+ {{ episode.content }} +
+
+{% endfor %} + +{%- endblock %} diff --git a/site_test/tv.html b/site_test/tv.html new file mode 100644 index 0000000..52ad59a --- /dev/null +++ b/site_test/tv.html @@ -0,0 +1,33 @@ +{% extends "default" %} + +{% block titlevariable %} +{% set title = "TV" %} +{% endblock %} + +{% block content -%} + +

TV Commentary

+ +
+

+ Sometimes when I'm watching TV shows (mostly sci-fi ones) I write commentary on them (don't expect anything too insightful). I generally post these on the fediverse and publish them here at the end of a season. Some of the series below are incomplete, for various reasons. Spoilers abound, obviously. +

+
+ +{% for entry in index_entries %} +
+

+ {{ entry.title }} +

+ +
+{% endfor %} + +{%- endblock %} diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 23de345..72fe378 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -7,6 +7,7 @@ mod rss; mod static_files; mod tags; mod templates; +mod tv; mod util; use std::cell::RefCell; @@ -53,7 +54,7 @@ fn make_graph(watcher: Rc>) -> anyhow::Result>) -> anyhow::Result>) -> anyhow::Result, ); impl AddTemplate { - pub fn new(name: &str, path: PathBuf, base: Input) -> Self { - Self(name.into(), path, base) + pub fn new(name: &'static str, path: PathBuf, base: Input) -> Self { + Self(name, path, base) } } impl Rule for AddTemplate { type Output = Templates; + fn evaluate(&mut self) -> Self::Output { let mut templates = self.input_2().clone(); let content = std::fs::read_to_string(&self.1).expect("reading template"); @@ -65,6 +66,10 @@ impl Rule for AddTemplate { templates.templates.push(content); templates } + + fn node_label(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } } #[derive(Default, Clone)] diff --git a/src/generator/tv.rs b/src/generator/tv.rs new file mode 100644 index 0000000..50ef343 --- /dev/null +++ b/src/generator/tv.rs @@ -0,0 +1,351 @@ +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, +}; + +use anyhow::anyhow; +use chrono::{DateTime, Local, NaiveDate}; +use compute_graph::{ + builder::GraphBuilder, + input::{DynamicInput, Input, InputVisitable}, + rule::{DynamicNodeFactory, DynamicRule, DynamicRuleContext, Rule}, + synchronicity::Asynchronous, +}; +use log::error; +use once_cell::sync::Lazy; +use pulldown_cmark::Event; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use tera::Context; + +use crate::generator::{ + templates::AddTemplate, + util::{content_path, from_frontmatter}, +}; + +use super::{ + FileWatcher, markdown, + templates::{BuildTemplateContext, RenderTemplate, Templates}, + util::{Combine, MapDynamicToVoid}, +}; + +pub fn make_graph( + builder: &mut GraphBuilder<(), Asynchronous>, + default_template: Input, + watcher: Rc>, +) -> Input<()> { + let tv_path = content_path("tv/"); + let (shows, invalidate) = builder + .add_invalidatable_dynamic_rule(MakeReadShows::new(tv_path.clone(), Rc::clone(&watcher))); + watcher + .borrow_mut() + .watch(tv_path, move || invalidate.invalidate()); + + let show_path = content_path("show.html"); + let (show_template, invalidate) = builder.add_invalidatable_rule(AddTemplate::new( + "show", + show_path.clone(), + default_template.clone(), + )); + watcher + .borrow_mut() + .watch(show_path, move || invalidate.invalidate()); + + let render_shows = builder.add_dynamic_rule(MakeRenderShows::new(shows.clone(), show_template)); + let void_outputs = builder.add_rule(MapDynamicToVoid(render_shows)); + + let tv_path = content_path("tv.html"); + let (index_template, invalidate) = + builder.add_invalidatable_rule(AddTemplate::new("tv", tv_path.clone(), default_template)); + watcher + .borrow_mut() + .watch(tv_path, move || invalidate.invalidate()); + + let index = builder.add_rule(ShowIndex(shows)); + let index_context = builder.add_rule(BuildTemplateContext::new( + "/tv/".into(), + index, + |entries, context| { + context.insert("index_entries", entries); + }, + )); + + let render_index = builder.add_rule(RenderTemplate { + name: "tv", + output_path: "tv/index.html".into(), + templates: index_template, + context: index_context, + }); + + builder.add_rule(Combine(void_outputs, render_index)) +} + +#[derive(InputVisitable)] +struct MakeReadShows { + #[skip_visit] + path: PathBuf, + #[skip_visit] + watcher: Rc>, + factory: DynamicNodeFactory>, +} +impl MakeReadShows { + fn new(path: PathBuf, watcher: Rc>) -> Self { + Self { + path, + watcher, + factory: DynamicNodeFactory::new(), + } + } + + fn read_shows(&mut self, ctx: &mut impl DynamicRuleContext) -> anyhow::Result<()> { + let entries = std::fs::read_dir(&self.path)?; + for ent in entries { + let ent = ent?; + let ft = ent.file_type()?; + if !ft.is_file() { + return Err(anyhow!("Unexpected tv file type: {ft:?}")); + } + self.factory.add_node(ctx, ent.path(), |ctx| { + let (input, invalidate) = ctx.add_invalidatable_rule(ReadShow(ent.path())); + self.watcher + .borrow_mut() + .watch(ent.path(), move || invalidate.invalidate()); + input + }); + } + Ok(()) + } +} +impl DynamicRule for MakeReadShows { + type ChildOutput = Option; + fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { + let result = self.read_shows(ctx); + if let Err(e) = result { + error!("Error reading tv shows: {e:?}"); + } + self.factory.all_nodes(ctx) + } +} + +#[derive(InputVisitable)] +struct ReadShow(#[skip_visit] PathBuf); +impl Rule for ReadShow { + type Output = Option; + fn evaluate(&mut self) -> Self::Output { + let contents = std::fs::read_to_string(&self.0); + match contents { + Ok(s) => match Show::new(s, &self.0) { + Ok(show) => Some(show), + Err(e) => { + error!("Error parsing tv show {:?}: {e:?}", self.0); + None + } + }, + Err(e) => { + error!("Error reading tv show {:?}: {e:?}", self.0); + None + } + } + } +} + +#[derive(PartialEq, Clone, Serialize)] +struct Show { + metadata: ShowMetadata, + slug: String, + last_updated: Option>, + episodes: Vec, +} + +impl Show { + fn new(contents: String, path: &Path) -> anyhow::Result { + let (metadata, rest) = from_frontmatter::(&contents)?; + let slug = path.file_stem().unwrap().to_str().unwrap().to_owned(); + let episodes = parse_episodes(rest); + let last_updated = episodes.iter().map(|ep| ep.date).max(); + Ok(Self { + metadata, + slug, + last_updated, + episodes, + }) + } +} + +fn parse_episodes(contents: &str) -> Vec { + Episodes { + inner: markdown::parse(contents), + buf: vec![], + cur_episode_meta: None, + finished: false, + } + .collect() +} + +#[derive(PartialEq, Clone, Deserialize, Serialize)] +struct ShowMetadata { + title: String, +} + +#[derive(PartialEq, Clone, Serialize)] +struct ShowEpisode { + title: String, + date: DateTime, + content: String, +} + +struct Episodes<'a, I: Iterator>> { + inner: I, + buf: Vec>, + cur_episode_meta: Option<(String, DateTime)>, + finished: bool, +} + +impl<'a, I: Iterator>> Iterator for Episodes<'a, I> { + type Item = ShowEpisode; + + fn next(&mut self) -> Option { + loop { + match self.inner.next() { + Some(Event::Html(ref s)) if is_ep_comment(s) => { + if self.cur_episode_meta.is_some() { + let ep = self.create_episode(); + self.cur_episode_meta = Some(parse_meta(s)); + return Some(ep); + } else { + self.cur_episode_meta = Some(parse_meta(s)); + } + } + Some(e) => { + self.buf.push(e); + } + None => { + if self.finished { + return None; + } else { + self.finished = true; + return Some(self.create_episode()); + } + } + } + } + } +} + +impl<'a, I: Iterator>> Episodes<'a, I> { + fn create_episode(&mut self) -> ShowEpisode { + let meta = self.cur_episode_meta.take().unwrap(); + + let mut content = String::new(); + let events = std::mem::take(&mut self.buf); + pulldown_cmark::html::push_html(&mut content, events.into_iter()); + ShowEpisode { + title: meta.0, + date: meta.1, + content, + } + } +} + +static EPISODE_SEPARATOR: Lazy = + Lazy::new(|| Regex::new(r#"^\s*\s*$"#).unwrap()); + +fn is_ep_comment(s: impl AsRef) -> bool { + EPISODE_SEPARATOR.is_match(s.as_ref()) +} + +fn parse_meta(s: impl AsRef) -> (String, DateTime) { + let captures = EPISODE_SEPARATOR.captures(s.as_ref()).unwrap(); + let title = &captures[1]; + let date_str = &captures[2].trim(); + let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .expect("episode entry date") + .and_hms_opt(12, 0, 0) + .unwrap() + .and_local_timezone(Local) + .unwrap(); + (title.into(), date) +} + +#[derive(InputVisitable)] +struct MakeRenderShows { + shows: DynamicInput>, + #[skip_visit] + templates: Input, + build_context_factory: DynamicNodeFactory, + render_factory: DynamicNodeFactory, +} +impl MakeRenderShows { + fn new(shows: DynamicInput>, templates: Input) -> Self { + Self { + shows, + templates, + build_context_factory: DynamicNodeFactory::new(), + render_factory: DynamicNodeFactory::new(), + } + } +} +impl DynamicRule for MakeRenderShows { + type ChildOutput = (); + fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec> { + for show_input in self.shows.value().inputs.iter() { + if let Some(show) = show_input.value().as_ref() { + let context = self + .build_context_factory + .add_node(ctx, show.slug.clone(), |ctx| { + ctx.add_rule(BuildTemplateContext::new( + format!("/tv/{}/", show.slug).into(), + show_input.clone(), + |show, context| { + if let Some(show) = show { + context.insert("show", show); + } + }, + )) + }); + self.render_factory.add_node(ctx, show.slug.clone(), |ctx| { + ctx.add_rule(RenderTemplate { + name: "show", + output_path: format!("/tv/{}/index.html", show.slug).into(), + templates: self.templates.clone(), + context, + }) + }); + } + } + self.build_context_factory.finalize_nodes(ctx); + self.render_factory.all_nodes(ctx) + } +} + +#[derive(InputVisitable)] +struct ShowIndex(DynamicInput>); +impl Rule for ShowIndex { + type Output = Vec; + fn evaluate(&mut self) -> Self::Output { + self.input_0() + .inputs + .iter() + .flat_map(|inp| inp.value().as_ref().map(ShowIndexEntry::new)) + .collect() + } +} + +#[derive(PartialEq, Serialize)] +struct ShowIndexEntry { + title: String, + slug: String, + last_updated: Option>, + episode_count: usize, +} +impl ShowIndexEntry { + fn new(show: &Show) -> Self { + Self { + title: show.metadata.title.clone(), + slug: show.slug.clone(), + last_updated: show.last_updated, + episode_count: show.episodes.len(), + } + } +}