From 9221cc159f3c4674da6d4772bc52822590591cab Mon Sep 17 00:00:00 2001 From: liquidev Date: Wed, 27 Nov 2024 19:02:30 +0100 Subject: [PATCH] implement RSS feeds --- Cargo.lock | 1 + content/_treehouse/404.tree | 2 +- content/treehouse/new.tree | 25 +- crates/treehouse/Cargo.toml | 2 +- crates/treehouse/src/cli/serve.rs | 1 + crates/treehouse/src/config.rs | 14 +- crates/treehouse/src/generate.rs | 33 ++- crates/treehouse/src/generate/atom.rs | 302 ++++++++++++++++++++++++ crates/treehouse/src/html.rs | 2 +- crates/treehouse/src/html/djot.rs | 62 +++-- crates/treehouse/src/state.rs | 2 + crates/treehouse/src/tree.rs | 72 +++++- crates/treehouse/src/tree/attributes.rs | 15 +- template/_feed_atom.hbs | 67 ++++++ template/components/_head.hbs | 5 + treehouse.toml | 28 ++- 16 files changed, 580 insertions(+), 53 deletions(-) create mode 100644 crates/treehouse/src/generate/atom.rs create mode 100644 template/_feed_atom.hbs diff --git a/Cargo.lock b/Cargo.lock index 9ba32b1..4305391 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets", ] diff --git a/content/_treehouse/404.tree b/content/_treehouse/404.tree index d424887..01c7d15 100644 --- a/content/_treehouse/404.tree +++ b/content/_treehouse/404.tree @@ -4,4 +4,4 @@ - seems like the page you're looking for isn't here. % id = "01HMF8KQ99XNMEP67NE3QH5698" -- care to go [back to the index][branch:treehouse]? +- care to go [back to the index][page:index]? diff --git a/content/treehouse/new.tree b/content/treehouse/new.tree index 072c96c..02b4099 100644 --- a/content/treehouse/new.tree +++ b/content/treehouse/new.tree @@ -1,5 +1,6 @@ %% title = "a curated feed of updates to the house" styles = ["new.css"] +feed = "new" % id = "01JCGWPM6T73PAC5Q8YHPBEAA1" + hello! @@ -11,10 +12,8 @@ if you've been wondering what I've been up to, you've come to the right place. % id = "01JCGWPM6TGQ17JPSJW8G58SB0" - you can keep track of which posts you've read by looking at the color of the links. - % id = "01JCGWPM6TMAJT0B50GQSA4BDW" - - there is currently no RSS or Atom feed for this page, sorry! - % id = "01JDJJSEWASRWJGKMBNYMFD9B5" +tags = ["programming", "treehouse"] - ### [composable virtual file systems][page:programming/blog/vfs] % id = "01JDJJSEWAVZGJN3PWY94SJMXT" @@ -24,15 +23,18 @@ if you've been wondering what I've been up to, you've come to the right place. - this is an exploration of how I built my abstraction, how it works, and what I learned from it. % id = "01JCGAM553TJJCEJ96ADEWETQC" +tags = ["programming", "c", "cxx"] - ### [prefix matches with C strings][page:programming/blog/cstring-starts-with] % id = "01JBAGZAZ30K443QYPK0XBNZWM" +tags = ["music"] - ### [the curious case of Amon Tobin's Creatures][page:music/creatures] % id = "01JBAGZAZ3NKBED4M9FANR5RPZ" - a weird anomaly I noticed while listening to some breaks % id = "01J8ZP2EG9TM8320R9E3K1GQEC" +tags = ["music"] - ### [I Don't Love Me Anymore][page:music/reviews/opn/i-dont-love-me-anymore] % id = "01J8ZP2EG96VQ2ZK0XYK0FK1NR" @@ -42,6 +44,7 @@ if you've been wondering what I've been up to, you've come to the right place. - it's also a nice opportunity to say that I've refreshed the music section a bit! % id = "01J7C1KBZ58BR21AVFA1PMWV68" +tags = ["programming", "treehouse"] - ### [not quite buildless][page:programming/blog/buildsome] % id = "01J7C1KBZ5XKZRN4V5BWFQTV6Y" @@ -57,6 +60,7 @@ if you've been wondering what I've been up to, you've come to the right place. - also, it's (way past) its one year anniversary! hooray! % id = "01J73BSWA15KHTQ21T0S14NZW0" +tags = ["music", "programming"] - ### [the ListenBrainz data set][page:music/brainz] % id = "01J73BSWA1EX7ZP28KCCG088DD" @@ -66,6 +70,7 @@ if you've been wondering what I've been up to, you've come to the right place. - I haven't done any of it yet, but I thought it'd be cool to share my ideas anyways! % id = "01J4J5N6WZQ03VTB3TZ51J7QZK" +tags = ["programming", "plt", "haku"] - ### [haku - writing a little programming language for fun][page:programming/blog/haku] % id = "01J4J5N6WZQ1316WKDXB1M5W6E" @@ -79,6 +84,7 @@ if you've been wondering what I've been up to, you've come to the right place. even though it didn't end up having macros... % id = "01J293BFEBT15W0Z3XF1HEFGZT" +tags = ["programming", "javascript", "plt"] - ### [JavaScript is not as bad as people make it out to be][page:programming/languages/javascript] % id = "01J293BFEB4G7214N20SZA8V7W" @@ -88,6 +94,7 @@ if you've been wondering what I've been up to, you've come to the right place. - so I decided to collect my thoughts into a nice little page I can link easily. % id = "01J0VNHPTRNC1HFXAQ790Y1EZB" +tags = ["programming", "cxx"] - ### [freeing C memory automatically using `std::unique_ptr` and `std::shared_ptr`][page:programming/languages/cxx/shared-unique-ptr-deleter] % id = "01J0VNHPTRP51XYDA4N2RPG58F" @@ -100,6 +107,7 @@ if you've been wondering what I've been up to, you've come to the right place. - on another note, I did read a blog post about this once somewhere, but couldn't be bothered to find it. so there you go! I made a post about this too. % id = "01J0KRPMV7SS48B64BFCJZK7VQ" +tags = ["meow"] - ### [about me (version 2)][page:about] % id = "01J0KRPMV73K71D3QXFQ3GNY2N" @@ -110,13 +118,15 @@ if you've been wondering what I've been up to, you've come to the right place. - [version 1][page:about/v1] % id = "01HY5R1ZW2PYZSSP2J2KAA23DA" +tags = ["programming", "c", "cxx", "plt"] - ### [what's up with `*x` not always meaning the same thing in different contexts?][page:programming/blog/lvalues] % id = "01HY5R1ZW24YJ2NF2RYWRZG4ZT" - - I recently got a question from my someone telling me they doesn't understand why `*x` does not read from the pointer `x` when on the left-hand side of an assignment. + - I recently got a question from my someone telling me they don't understand why `*x` does not read from the pointer `x` when on the left-hand side of an assignment. and that made me think, _why_ is that the case? % id = "01HV1DGFHZ65GJVQRSREKR67J9" +tags = ["programming", "philosophy"] - ### [systems are just a bunch of code][page:programming/blog/systems] % id = "01HV1DGFHZFFZSQNCVWBTJ1VHM" @@ -129,18 +139,21 @@ if you've been wondering what I've been up to, you've come to the right place. - bonus: [dismantling Unreal Engine's `GENERATED_BODY`][page:programming/technologies/unreal-engine/generated-body] % id = "01HTWNETT2S5NSBF3QR4HYA7HN" +tags = ["programming", "plt"] - ### [OR-types][page:programming/blog/or-types] % id = "01HTWNETT2N8NPENETWYFBTXEM" - last night I couldn't sleep because of type theory. in the process of trying to write down my thoughts, I ended up discovering a class of types which, to my knowledge, no language implements. % id = "01HRG3VN091V715A8T54QK5PVX" +tags = ["programming", "plt", "lua"] - ### [programming languages: Lua][page:programming/languages/lua] % id = "01HRG3VN095BNHERHWVX1TKS9K" - I really like Lua, did you know that? but I get kind of tired of explaining why a thousand times to people who don't know the language, so here's a page with my thoughts! % id = "01HR9ZTS8RS4VJNJYSNRQYSKHZ" +tags = ["design"] - ### [design: sidebars][page:design/sidebars] % id = "01HR9ZTS8RY3N4EJM5W7WBTF0G" @@ -150,6 +163,7 @@ if you've been wondering what I've been up to, you've come to the right place. - seriously though. I don't like them. % id = "01HQ8KV8T8GRCVFDJ3EP6QE163" +tags = ["design"] - ### [liquidex's treehouse: design][page:design] % id = "01HQ8KV8T8EEX6XBG2K1X3FGKW" @@ -161,6 +175,7 @@ if you've been wondering what I've been up to, you've come to the right place. - I also wrote a post summarising my thoughts: [_on digital textures_][page:design/digital-textures] % id = "01HQ6G30PTVT5H0Z04VVRHEZQF" +tags = ["programming", "graphics", "javascript"] - ### [tairu - an interactive exploration of 2D autotiling techniques][page:programming/blog/tairu] % id = "01HQ6G30PTG8QA5MAPEJPWSM14" @@ -168,5 +183,3 @@ if you've been wondering what I've been up to, you've come to the right place. % id = "01HQ6G30PT1D729Z29NYVDCFDB" - this post explores basically just that. - - diff --git a/crates/treehouse/Cargo.toml b/crates/treehouse/Cargo.toml index 8b53f86..d58ae13 100644 --- a/crates/treehouse/Cargo.toml +++ b/crates/treehouse/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1.0.75" axum = "0.7.4" base64 = "0.21.7" blake3 = "1.5.3" -chrono = "0.4.35" +chrono = { version = "0.4.35", features = ["serde"] } clap = { version = "4.3.22", features = ["derive"] } codespan-reporting = "0.11.1" dashmap = "6.1.0" diff --git a/crates/treehouse/src/cli/serve.rs b/crates/treehouse/src/cli/serve.rs index 7dff90b..5a5b8ec 100644 --- a/crates/treehouse/src/cli/serve.rs +++ b/crates/treehouse/src/cli/serve.rs @@ -59,6 +59,7 @@ fn get_content_type(extension: &str) -> Option<&'static str> { "js" => Some("text/javascript"), "woff" => Some("font/woff2"), "svg" => Some("image/svg+xml"), + "atom" => Some("application/atom+xml"), _ => None, } } diff --git a/crates/treehouse/src/config.rs b/crates/treehouse/src/config.rs index aa425b6..885d6ec 100644 --- a/crates/treehouse/src/config.rs +++ b/crates/treehouse/src/config.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, ops::ControlFlow}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; use anyhow::{anyhow, Context}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; @@ -31,6 +34,9 @@ pub struct Config { /// Links exported to Markdown for use with reference syntax `[text][def:key]`. pub defs: HashMap, + /// Config for syndication feeds. + pub feed: Feed, + /// Redirects for moving pages around. These are used solely by the treehouse server. /// /// Note that redirects are only resolved _non-recursively_ by the server. For a configuration @@ -74,6 +80,12 @@ pub struct Config { pub syntaxes: HashMap, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Feed { + /// Allowed tags in feed entries. + pub tags: HashSet, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Redirects { /// Page redirects. When a user navigates to a page, if they navigate to `url`, they will diff --git a/crates/treehouse/src/generate.rs b/crates/treehouse/src/generate.rs index 0f32ccc..fc8d1c0 100644 --- a/crates/treehouse/src/generate.rs +++ b/crates/treehouse/src/generate.rs @@ -1,3 +1,4 @@ +mod atom; mod dir_helper; mod include_static_helper; mod simple_template; @@ -5,6 +6,7 @@ mod tree; use std::{collections::HashMap, fmt, ops::ControlFlow, sync::Arc}; +use atom::FeedDir; use dir_helper::DirHelper; use handlebars::{handlebars_helper, Handlebars}; use include_static_helper::IncludeStaticHelper; @@ -28,6 +30,7 @@ struct BaseTemplateData<'a> { import_map: String, season: Option, dev: bool, + feeds: Vec, } impl<'a> BaseTemplateData<'a> { @@ -38,6 +41,7 @@ impl<'a> BaseTemplateData<'a> { .expect("import map should be serializable to JSON"), season: Season::current(), dev: cfg!(debug_assertions), + feeds: sources.treehouse.feeds_by_name.keys().cloned().collect(), } } } @@ -45,20 +49,22 @@ impl<'a> BaseTemplateData<'a> { struct TreehouseDir { dirs: Arc, sources: Arc, + handlebars: Arc>, dir_index: DirIndex, - handlebars: Handlebars<'static>, } impl TreehouseDir { - fn new(dirs: Arc, sources: Arc, dir_index: DirIndex) -> Self { - let mut handlebars = create_handlebars(&sources.config.site, dirs.static_.clone()); - load_templates(&mut handlebars, &dirs.template); - + fn new( + dirs: Arc, + sources: Arc, + handlebars: Arc>, + dir_index: DirIndex, + ) -> Self { Self { dirs, sources, - dir_index, handlebars, + dir_index, } } } @@ -195,7 +201,20 @@ impl DirIndex { } pub fn target(dirs: Arc, sources: Arc) -> DynDir { + let mut handlebars = create_handlebars(&sources.config.site, dirs.static_.clone()); + load_templates(&mut handlebars, &dirs.template); + let handlebars = Arc::new(handlebars); + let mut root = MemDir::new(); + root.add( + VPath::new("feed"), + ContentCache::new(FeedDir::new( + dirs.clone(), + sources.clone(), + handlebars.clone(), + )) + .to_dyn(), + ); root.add(VPath::new("static"), dirs.static_.clone()); root.add( VPath::new("robots.txt"), @@ -203,7 +222,7 @@ pub fn target(dirs: Arc, sources: Arc) -> DynDir { ); let dir_index = DirIndex::new(sources.treehouse.files_by_tree_path.keys().map(|x| &**x)); - let tree_view = TreehouseDir::new(dirs, sources, dir_index); + let tree_view = TreehouseDir::new(dirs, sources, handlebars, dir_index); let tree_view = ContentCache::new(tree_view); tree_view.warm_up(); diff --git a/crates/treehouse/src/generate/atom.rs b/crates/treehouse/src/generate/atom.rs new file mode 100644 index 0000000..0b3d16c --- /dev/null +++ b/crates/treehouse/src/generate/atom.rs @@ -0,0 +1,302 @@ +use std::{fmt, sync::Arc}; + +use anyhow::Context; +use chrono::{DateTime, Utc}; +use handlebars::Handlebars; +use serde::Serialize; +use tracing::{info, info_span, instrument}; +use ulid::Ulid; + +use crate::{ + dirs::Dirs, + html::djot::{self, resolve_link}, + sources::Sources, + state::FileId, + tree::SemaBranchId, + vfs::{Dir, DirEntry, VPath, VPathBuf}, +}; + +use super::BaseTemplateData; + +pub struct FeedDir { + dirs: Arc, + sources: Arc, + handlebars: Arc>, +} + +impl FeedDir { + pub fn new( + dirs: Arc, + sources: Arc, + handlebars: Arc>, + ) -> Self { + Self { + dirs, + sources, + handlebars, + } + } +} + +impl Dir for FeedDir { + fn dir(&self, path: &VPath) -> Vec { + if path == VPath::ROOT { + self.sources + .treehouse + .feeds_by_name + .keys() + .map(|name| DirEntry { + path: VPathBuf::new(format!("{name}.atom")), + }) + .collect() + } else { + vec![] + } + } + + fn content(&self, path: &VPath) -> Option> { + info!("{path}"); + if path.extension() == Some("atom") { + let feed_name = path.with_extension("").to_string(); + self.sources + .treehouse + .feeds_by_name + .get(&feed_name) + .map(|file_id| { + generate_or_error(&self.sources, &self.dirs, &self.handlebars, *file_id).into() + }) + } else { + None + } + } + + fn content_version(&self, _path: &VPath) -> Option { + None + } +} + +impl fmt::Debug for FeedDir { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("FeedDir") + } +} + +#[derive(Serialize)] +struct Feed { + name: String, + updated: DateTime, + entries: Vec, +} + +#[derive(Serialize)] +struct Entry { + id: String, + updated: DateTime, + url: String, + title: String, + categories: Vec, + summary: String, +} + +#[derive(Serialize)] +struct AtomTemplateData<'a> { + #[serde(flatten)] + base: &'a BaseTemplateData<'a>, + feed: Feed, +} + +#[instrument(name = "atom::generate", skip(sources, handlebars))] +pub fn generate( + sources: &Sources, + dirs: &Dirs, + handlebars: &Handlebars, + file_id: FileId, +) -> anyhow::Result { + let roots = &sources.treehouse.roots[&file_id]; + let feed_name = roots.attributes.feed.clone().expect("page must be a feed"); + + let template_data = AtomTemplateData { + base: &BaseTemplateData::new(sources), + feed: Feed { + name: feed_name, + // The content cache layer should take care of sampling the current time only once, + // and then preserving it until the treehouse is deployed again. + updated: Utc::now(), + entries: extract_entries(sources, dirs, file_id), + }, + }; + + let _span = info_span!("handlebars::render").entered(); + handlebars + .render("_feed_atom.hbs", &template_data) + .context("template rendering failed") +} + +pub fn generate_or_error( + sources: &Sources, + dirs: &Dirs, + handlebars: &Handlebars, + file_id: FileId, +) -> String { + match generate(sources, dirs, handlebars, file_id) { + Ok(html) => html, + Err(error) => format!("error: {error:?}"), + } +} + +fn extract_entries(sources: &Sources, dirs: &Dirs, file_id: FileId) -> Vec { + let roots = &sources.treehouse.roots[&file_id]; + + roots + .branches + .iter() + .flat_map(|&branch_id| { + let branch = sources.treehouse.tree.branch(branch_id); + + let text = &sources.treehouse.source(file_id).input()[branch.content.clone()]; + let parsed = parse_entry(sources, dirs, file_id, jotdown::Parser::new(text)); + + let mut summary = String::new(); + branches_to_html_simple(&mut summary, sources, dirs, file_id, &branch.children); + + let updated = Ulid::from_string(&branch.attributes.id) + .ok() + .and_then(|ulid| DateTime::from_timestamp_millis(ulid.timestamp_ms() as i64)) + .unwrap_or(DateTime::UNIX_EPOCH); // if you see the Unix epoch... oops + + parsed.link.map(|url| Entry { + id: branch.attributes.id.clone(), + updated, + url, + title: parsed.title.unwrap_or_else(|| "untitled".into()), + categories: branch.attributes.tags.clone(), + summary, + }) + }) + .collect() +} + +#[derive(Debug, Clone)] +struct ParsedEntry { + title: Option, + link: Option, +} + +fn parse_entry( + sources: &Sources, + dirs: &Dirs, + file_id: FileId, + parser: jotdown::Parser, +) -> ParsedEntry { + let mut parser = parser.into_offset_iter(); + while let Some((event, span)) = parser.next() { + if let jotdown::Event::Start(jotdown::Container::Heading { .. }, _attrs) = &event { + let mut events = vec![(event, span)]; + for (event, span) in parser.by_ref() { + // To my knowledge headings cannot nest, so it's okay not keeping a stack here. + let is_heading = matches!( + event, + jotdown::Event::End(jotdown::Container::Heading { .. }) + ); + events.push((event, span)); + if is_heading { + break; + } + } + + let title_events: Vec<_> = events + .iter() + .filter(|(event, _)| { + !matches!( + event, + // A little repetitive, but I don't mind. + // The point of this is not to include extra

and in the link text, + // but preserve other formatting such as bold, italic, code, etc. + jotdown::Event::Start( + jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. }, + _ + ) | jotdown::Event::End( + jotdown::Container::Link(_, _) | jotdown::Container::Heading { .. } + ) + ) + }) + .cloned() + .collect(); + let mut title = String::new(); + let _render_diagnostics = djot::Renderer { + config: &sources.config, + dirs, + treehouse: &sources.treehouse, + file_id, + + // How. Just, stop. + page_id: "liquidex-you-reeeeeal-dummy".into(), + } + .render(&title_events, &mut title); + + let link = events.iter().find_map(|(event, _)| { + if let jotdown::Event::Start(jotdown::Container::Link(link, link_type), _) = event { + Some(link_url(sources, dirs, link, *link_type)) + } else { + None + } + }); + + return ParsedEntry { + title: (!title.is_empty()).then_some(title), + link, + }; + } + } + + ParsedEntry { + title: None, + link: None, + } +} + +fn link_url(sources: &Sources, dirs: &Dirs, url: &str, link_type: jotdown::LinkType) -> String { + if let jotdown::LinkType::Span(jotdown::SpanLinkType::Unresolved) = link_type { + if let Some(url) = resolve_link(&sources.config, &sources.treehouse, dirs, url) { + return url; + } + } + url.to_owned() +} + +/// Extremely simple HTML renderer without the treehouse's fancy branch folding and linking features. +fn branches_to_html_simple( + s: &mut String, + sources: &Sources, + dirs: &Dirs, + file_id: FileId, + branches: &[SemaBranchId], +) { + s.push_str("
    "); + for &branch_id in branches { + let branch = sources.treehouse.tree.branch(branch_id); + + s.push_str("
  • "); + + let text = &sources.treehouse.source(file_id).input()[branch.content.clone()]; + let events: Vec<_> = jotdown::Parser::new(text).into_offset_iter().collect(); + // Ignore render diagnostics. Those should be reported by the main HTML generator. + let _render_diagnostics = djot::Renderer { + config: &sources.config, + dirs, + treehouse: &sources.treehouse, + file_id, + + // Yeah, maybe don't include literate code in summaries... + page_id: "liquidex-is-a-dummy".into(), + } + .render(&events, s); + + if !branch.children.is_empty() { + branches_to_html_simple(s, sources, dirs, file_id, &branch.children); + } + + s.push_str("
  • "); + } + s.push_str("
"); +} diff --git a/crates/treehouse/src/html.rs b/crates/treehouse/src/html.rs index ab6feed..33402d9 100644 --- a/crates/treehouse/src/html.rs +++ b/crates/treehouse/src/html.rs @@ -1,7 +1,7 @@ use std::fmt::{self, Display, Write}; pub mod breadcrumbs; -mod djot; +pub mod djot; pub mod highlight; pub mod navmap; pub mod tree; diff --git a/crates/treehouse/src/html/djot.rs b/crates/treehouse/src/html/djot.rs index efbca09..94bedd2 100644 --- a/crates/treehouse/src/html/djot.rs +++ b/crates/treehouse/src/html/djot.rs @@ -27,9 +27,7 @@ use super::highlight::highlight; /// [`Render`] implementor that writes HTML output. pub struct Renderer<'a> { pub config: &'a Config, - pub dirs: &'a Dirs, - pub treehouse: &'a Treehouse, pub file_id: FileId, pub page_id: String, @@ -226,7 +224,12 @@ impl<'a> Writer<'a> { Container::Link(dst, ty) => { if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) { out.push_str(" Writer<'a> { out.push_str(r#"" src=""#); if let SpanLinkType::Unresolved = link_type { // TODO: Image size. - if let Some(resolved) = self.resolve_link(src) { + if let Some(resolved) = resolve_link( + self.renderer.config, + self.renderer.treehouse, + self.renderer.dirs, + src, + ) { write_attr(&resolved, out); } else { write_attr(src, out); @@ -624,28 +632,6 @@ impl<'a> Writer<'a> { Ok(()) } - - fn resolve_link(&self, link: &str) -> Option { - let Renderer { - config, treehouse, .. - } = &self.renderer; - link.split_once(':').and_then(|(kind, linked)| match kind { - "def" => config.defs.get(linked).cloned(), - "branch" => treehouse - .branches_by_named_id - .get(linked) - .map(|&branch_id| { - format!( - "{}/b?{}", - config.site, - treehouse.tree.branch(branch_id).attributes.id - ) - }), - "page" => Some(config.page_url(linked)), - "pic" => Some(config.pic_url(&*self.renderer.dirs.pic, linked)), - _ => None, - }) - } } fn write_text(s: &str, out: &mut String) { @@ -677,3 +663,27 @@ fn write_escape(mut s: &str, escape_quotes: bool, out: &mut String) { } out.push_str(s); } + +pub fn resolve_link( + config: &Config, + treehouse: &Treehouse, + dirs: &Dirs, + link: &str, +) -> Option { + link.split_once(':').and_then(|(kind, linked)| match kind { + "def" => config.defs.get(linked).cloned(), + "branch" => treehouse + .branches_by_named_id + .get(linked) + .map(|&branch_id| { + format!( + "{}/b?{}", + config.site, + treehouse.tree.branch(branch_id).attributes.id + ) + }), + "page" => Some(config.page_url(linked)), + "pic" => Some(config.pic_url(&*dirs.pic, linked)), + _ => None, + }) +} diff --git a/crates/treehouse/src/state.rs b/crates/treehouse/src/state.rs index 9ce0448..9a592b9 100644 --- a/crates/treehouse/src/state.rs +++ b/crates/treehouse/src/state.rs @@ -67,6 +67,7 @@ pub struct FileId(usize); pub struct Treehouse { pub files: Vec, pub files_by_tree_path: HashMap, + pub feeds_by_name: HashMap, pub tree: SemaTree, pub branches_by_named_id: HashMap, @@ -82,6 +83,7 @@ impl Treehouse { Self { files: vec![], files_by_tree_path: HashMap::new(), + feeds_by_name: HashMap::new(), tree: SemaTree::default(), branches_by_named_id: HashMap::new(), diff --git a/crates/treehouse/src/tree.rs b/crates/treehouse/src/tree.rs index ac0890e..00a5bb7 100644 --- a/crates/treehouse/src/tree.rs +++ b/crates/treehouse/src/tree.rs @@ -61,7 +61,9 @@ impl SemaRoots { branches: roots .branches .into_iter() - .map(|branch| SemaBranch::from_branch(treehouse, diagnostics, file_id, branch)) + .map(|branch| { + SemaBranch::from_branch(treehouse, diagnostics, config, file_id, branch) + }) .collect(), } } @@ -94,10 +96,40 @@ impl SemaRoots { }; let successfully_parsed = successfully_parsed; - if successfully_parsed && attributes.title.is_empty() { - attributes.title = match treehouse.source(file_id) { - Source::Tree { tree_path, .. } => tree_path.to_string(), - _ => panic!("parse_attributes called for a non-.tree file"), + if successfully_parsed { + let attribute_warning_span = roots + .attributes + .as_ref() + .map(|attributes| attributes.percent.clone()) + .unwrap_or(0..1); + + if attributes.title.is_empty() { + attributes.title = match treehouse.source(file_id) { + Source::Tree { tree_path, .. } => tree_path.to_string(), + _ => panic!("parse_attributes called for a non-.tree file"), + } + } + + if attributes.id.is_empty() { + attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); + diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: Some("attr".into()), + message: "page does not have an `id` attribute".into(), + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: attribute_warning_span.clone(), + message: String::new(), + }], + notes: vec![ + format!( + "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations", + attributes.id + ), + format!("help: run `treehouse fix {}` to add missing ids to pages", treehouse.path(file_id)), + ], + }); } } @@ -139,6 +171,10 @@ impl SemaRoots { } } + if let Some(feed_name) = &attributes.feed { + treehouse.feeds_by_name.insert(feed_name.clone(), file_id); + } + attributes } } @@ -163,10 +199,11 @@ impl SemaBranch { pub fn from_branch( treehouse: &mut Treehouse, diagnostics: &mut Vec>, + config: &Config, file_id: FileId, branch: Branch, ) -> SemaBranchId { - let attributes = Self::parse_attributes(treehouse, diagnostics, file_id, &branch); + let attributes = Self::parse_attributes(treehouse, diagnostics, config, file_id, &branch); let named_id = attributes.id.to_owned(); let html_id = format!( @@ -189,7 +226,7 @@ impl SemaBranch { children: branch .children .into_iter() - .map(|child| Self::from_branch(treehouse, diagnostics, file_id, child)) + .map(|child| Self::from_branch(treehouse, diagnostics, config, file_id, child)) .collect(), }; let new_branch_id = treehouse.tree.add_branch(branch); @@ -260,6 +297,7 @@ impl SemaBranch { fn parse_attributes( treehouse: &mut Treehouse, diagnostics: &mut Vec>, + config: &Config, file_id: FileId, branch: &Branch, ) -> Attributes { @@ -354,6 +392,26 @@ impl SemaBranch { }) } } + + // Check that each tag belongs to the allowed set. + for tag in &attributes.tags { + if !config.feed.tags.contains(tag) { + diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: Some("attr".into()), + message: format!("tag `{tag}` is not within the set of allowed tags"), + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: attribute_warning_span.clone(), + message: "".into(), + }], + notes: vec![ + "note: tag should be one from the set defined in `feed.tags` in treehouse.toml".into(), + ], + }) + } + } } attributes } diff --git a/crates/treehouse/src/tree/attributes.rs b/crates/treehouse/src/tree/attributes.rs index ec7f4a7..6a5a654 100644 --- a/crates/treehouse/src/tree/attributes.rs +++ b/crates/treehouse/src/tree/attributes.rs @@ -7,6 +7,10 @@ use crate::{state::FileId, vfs::VPathBuf}; /// Top-level `%%` root attributes. #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct RootAttributes { + /// Permanent ID of this page. + #[serde(default)] + pub id: String, + /// Template to use for generating the page. /// Defaults to `_tree.hbs`. #[serde(default)] @@ -36,8 +40,10 @@ pub struct RootAttributes { #[serde(default)] pub styles: Vec, - /// When specified, branches coming from this root will be added to a _feed_ with the given name. - /// Feeds can be read by Handlebars templates to generate content based on them. + /// When specified, this page will have a corresponding Atom feed under `rss/{feed}.xml`. + /// + /// In feeds, top-level branches are expected to have a single heading containing the post title. + /// Their children are turned into the post description #[serde(default)] pub feed: Option, } @@ -97,6 +103,11 @@ pub struct Attributes { /// List of extra `data` attributes to add to the block. #[serde(default)] pub data: HashMap, + + /// In feeds, specifies the list of tags to attach to an entry. + /// This only has an effect on top-level branches. + #[serde(default)] + pub tags: Vec, } /// Controls for block content presentation. diff --git a/template/_feed_atom.hbs b/template/_feed_atom.hbs new file mode 100644 index 0000000..b77d7cf --- /dev/null +++ b/template/_feed_atom.hbs @@ -0,0 +1,67 @@ + + + + + + {{ config.user.canonical_url }} + {{ feed.updated }} + + {{ config.user.title }} + {{ config.user.description }} + + + {{ asset (cat 'favicon/' (cat season '@16x.png'))}} + + + {{ config.user.author }} + {{ config.user.canonical_url }} + + + {{#each feed.entries}} + + {{ ../config.site }}/b?{{ id }} + {{ updated }} + + + {{ title }} + {{#each categories as |category|}} + + {{/each}} + {{ summary }} + + {{/each}} + + + diff --git a/template/components/_head.hbs b/template/components/_head.hbs index 3c31fa2..0414289 100644 --- a/template/components/_head.hbs +++ b/template/components/_head.hbs @@ -69,3 +69,8 @@ It just needs to be a string replacement. + + +{{#each feeds as |feed_name|}} + +{{/each}} diff --git a/treehouse.toml b/treehouse.toml index c28ea61..3bfbac3 100644 --- a/treehouse.toml +++ b/treehouse.toml @@ -12,7 +12,8 @@ commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit" [user] title = "liquidex's treehouse" author = "liquidex" -description = "a place on the Internet I like to call home" +description = "a fluffy ragdoll's house on a tree = —w— =" +canonical_url = "https://liquidex.house" [defs] @@ -47,6 +48,31 @@ description = "a place on the Internet I like to call home" "person/firstbober" = "https://firstbober.com" "person/vixenka" = "https://vixenka.com" +[feed] +tags = [ + # Hobby corners + "meow", + "programming", + "design", + "music", + "games", + "philosophy", + + # Programming fields + "graphics", + "plt", + + # Programming languages + "c", + "cxx", + "lua", + "javascript", + + # Projects + "treehouse", + "haku", +] + [redirects.page] "programming/cxx" = "programming/languages/cxx" "programming/unreal-engine" = "programming/technologies/unreal-engine"