implement RSS feeds

This commit is contained in:
liquidex 2024-11-27 19:02:30 +01:00
parent 1e3a1f3527
commit 9221cc159f
16 changed files with 580 additions and 53 deletions

1
Cargo.lock generated
View file

@ -366,6 +366,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets", "windows-targets",
] ]

View file

@ -4,4 +4,4 @@
- seems like the page you're looking for isn't here. - seems like the page you're looking for isn't here.
% id = "01HMF8KQ99XNMEP67NE3QH5698" % id = "01HMF8KQ99XNMEP67NE3QH5698"
- care to go [back to the index][branch:treehouse]? - care to go [back to the index][page:index]?

View file

@ -1,5 +1,6 @@
%% title = "a curated feed of updates to the house" %% title = "a curated feed of updates to the house"
styles = ["new.css"] styles = ["new.css"]
feed = "new"
% id = "01JCGWPM6T73PAC5Q8YHPBEAA1" % id = "01JCGWPM6T73PAC5Q8YHPBEAA1"
+ hello! + 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" % id = "01JCGWPM6TGQ17JPSJW8G58SB0"
- you can keep track of which posts you've read by looking at the color of the links. - 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" % id = "01JDJJSEWASRWJGKMBNYMFD9B5"
tags = ["programming", "treehouse"]
- ### [composable virtual file systems][page:programming/blog/vfs] - ### [composable virtual file systems][page:programming/blog/vfs]
% id = "01JDJJSEWAVZGJN3PWY94SJMXT" % 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. - this is an exploration of how I built my abstraction, how it works, and what I learned from it.
% id = "01JCGAM553TJJCEJ96ADEWETQC" % id = "01JCGAM553TJJCEJ96ADEWETQC"
tags = ["programming", "c", "cxx"]
- ### [prefix matches with C strings][page:programming/blog/cstring-starts-with] - ### [prefix matches with C strings][page:programming/blog/cstring-starts-with]
% id = "01JBAGZAZ30K443QYPK0XBNZWM" % id = "01JBAGZAZ30K443QYPK0XBNZWM"
tags = ["music"]
- ### [the curious case of Amon Tobin's Creatures][page:music/creatures] - ### [the curious case of Amon Tobin's Creatures][page:music/creatures]
% id = "01JBAGZAZ3NKBED4M9FANR5RPZ" % id = "01JBAGZAZ3NKBED4M9FANR5RPZ"
- a weird anomaly I noticed while listening to some breaks - a weird anomaly I noticed while listening to some breaks
% id = "01J8ZP2EG9TM8320R9E3K1GQEC" % id = "01J8ZP2EG9TM8320R9E3K1GQEC"
tags = ["music"]
- ### [I Don't Love Me Anymore][page:music/reviews/opn/i-dont-love-me-anymore] - ### [I Don't Love Me Anymore][page:music/reviews/opn/i-dont-love-me-anymore]
% id = "01J8ZP2EG96VQ2ZK0XYK0FK1NR" % 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! - it's also a nice opportunity to say that I've refreshed the music section a bit!
% id = "01J7C1KBZ58BR21AVFA1PMWV68" % id = "01J7C1KBZ58BR21AVFA1PMWV68"
tags = ["programming", "treehouse"]
- ### [not quite buildless][page:programming/blog/buildsome] - ### [not quite buildless][page:programming/blog/buildsome]
% id = "01J7C1KBZ5XKZRN4V5BWFQTV6Y" % 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! - also, it's (way past) its one year anniversary! hooray!
% id = "01J73BSWA15KHTQ21T0S14NZW0" % id = "01J73BSWA15KHTQ21T0S14NZW0"
tags = ["music", "programming"]
- ### [the ListenBrainz data set][page:music/brainz] - ### [the ListenBrainz data set][page:music/brainz]
% id = "01J73BSWA1EX7ZP28KCCG088DD" % 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! - I haven't done any of it yet, but I thought it'd be cool to share my ideas anyways!
% id = "01J4J5N6WZQ03VTB3TZ51J7QZK" % id = "01J4J5N6WZQ03VTB3TZ51J7QZK"
tags = ["programming", "plt", "haku"]
- ### [haku - writing a little programming language for fun][page:programming/blog/haku] - ### [haku - writing a little programming language for fun][page:programming/blog/haku]
% id = "01J4J5N6WZQ1316WKDXB1M5W6E" % 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... even though it didn't end up having macros...
% id = "01J293BFEBT15W0Z3XF1HEFGZT" % id = "01J293BFEBT15W0Z3XF1HEFGZT"
tags = ["programming", "javascript", "plt"]
- ### [JavaScript is not as bad as people make it out to be][page:programming/languages/javascript] - ### [JavaScript is not as bad as people make it out to be][page:programming/languages/javascript]
% id = "01J293BFEB4G7214N20SZA8V7W" % 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. - so I decided to collect my thoughts into a nice little page I can link easily.
% id = "01J0VNHPTRNC1HFXAQ790Y1EZB" % 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] - ### [freeing C memory automatically using `std::unique_ptr` and `std::shared_ptr`][page:programming/languages/cxx/shared-unique-ptr-deleter]
% id = "01J0VNHPTRP51XYDA4N2RPG58F" % 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. - 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" % id = "01J0KRPMV7SS48B64BFCJZK7VQ"
tags = ["meow"]
- ### [about me (version 2)][page:about] - ### [about me (version 2)][page:about]
% id = "01J0KRPMV73K71D3QXFQ3GNY2N" % 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] - [version 1][page:about/v1]
% id = "01HY5R1ZW2PYZSSP2J2KAA23DA" % 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] - ### [what's up with `*x` not always meaning the same thing in different contexts?][page:programming/blog/lvalues]
% id = "01HY5R1ZW24YJ2NF2RYWRZG4ZT" % 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? and that made me think, _why_ is that the case?
% id = "01HV1DGFHZ65GJVQRSREKR67J9" % id = "01HV1DGFHZ65GJVQRSREKR67J9"
tags = ["programming", "philosophy"]
- ### [systems are just a bunch of code][page:programming/blog/systems] - ### [systems are just a bunch of code][page:programming/blog/systems]
% id = "01HV1DGFHZFFZSQNCVWBTJ1VHM" % 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] - bonus: [dismantling Unreal Engine's `GENERATED_BODY`][page:programming/technologies/unreal-engine/generated-body]
% id = "01HTWNETT2S5NSBF3QR4HYA7HN" % id = "01HTWNETT2S5NSBF3QR4HYA7HN"
tags = ["programming", "plt"]
- ### [OR-types][page:programming/blog/or-types] - ### [OR-types][page:programming/blog/or-types]
% id = "01HTWNETT2N8NPENETWYFBTXEM" % 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. - 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" % id = "01HRG3VN091V715A8T54QK5PVX"
tags = ["programming", "plt", "lua"]
- ### [programming languages: Lua][page:programming/languages/lua] - ### [programming languages: Lua][page:programming/languages/lua]
% id = "01HRG3VN095BNHERHWVX1TKS9K" % 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! - 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" % id = "01HR9ZTS8RS4VJNJYSNRQYSKHZ"
tags = ["design"]
- ### [design: sidebars][page:design/sidebars] - ### [design: sidebars][page:design/sidebars]
% id = "01HR9ZTS8RY3N4EJM5W7WBTF0G" % 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. - seriously though. I don't like them.
% id = "01HQ8KV8T8GRCVFDJ3EP6QE163" % id = "01HQ8KV8T8GRCVFDJ3EP6QE163"
tags = ["design"]
- ### [liquidex's treehouse: design][page:design] - ### [liquidex's treehouse: design][page:design]
% id = "01HQ8KV8T8EEX6XBG2K1X3FGKW" % 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] - I also wrote a post summarising my thoughts: [_on digital textures_][page:design/digital-textures]
% id = "01HQ6G30PTVT5H0Z04VVRHEZQF" % id = "01HQ6G30PTVT5H0Z04VVRHEZQF"
tags = ["programming", "graphics", "javascript"]
- ### [tairu - an interactive exploration of 2D autotiling techniques][page:programming/blog/tairu] - ### [tairu - an interactive exploration of 2D autotiling techniques][page:programming/blog/tairu]
% id = "01HQ6G30PTG8QA5MAPEJPWSM14" % 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" % id = "01HQ6G30PT1D729Z29NYVDCFDB"
- this post explores basically just that. - this post explores basically just that.

View file

@ -11,7 +11,7 @@ anyhow = "1.0.75"
axum = "0.7.4" axum = "0.7.4"
base64 = "0.21.7" base64 = "0.21.7"
blake3 = "1.5.3" blake3 = "1.5.3"
chrono = "0.4.35" chrono = { version = "0.4.35", features = ["serde"] }
clap = { version = "4.3.22", features = ["derive"] } clap = { version = "4.3.22", features = ["derive"] }
codespan-reporting = "0.11.1" codespan-reporting = "0.11.1"
dashmap = "6.1.0" dashmap = "6.1.0"

View file

@ -59,6 +59,7 @@ fn get_content_type(extension: &str) -> Option<&'static str> {
"js" => Some("text/javascript"), "js" => Some("text/javascript"),
"woff" => Some("font/woff2"), "woff" => Some("font/woff2"),
"svg" => Some("image/svg+xml"), "svg" => Some("image/svg+xml"),
"atom" => Some("application/atom+xml"),
_ => None, _ => None,
} }
} }

View file

@ -1,4 +1,7 @@
use std::{collections::HashMap, ops::ControlFlow}; use std::{
collections::{HashMap, HashSet},
ops::ControlFlow,
};
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
@ -31,6 +34,9 @@ pub struct Config {
/// Links exported to Markdown for use with reference syntax `[text][def:key]`. /// Links exported to Markdown for use with reference syntax `[text][def:key]`.
pub defs: HashMap<String, String>, pub defs: HashMap<String, String>,
/// Config for syndication feeds.
pub feed: Feed,
/// Redirects for moving pages around. These are used solely by the treehouse server. /// 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 /// Note that redirects are only resolved _non-recursively_ by the server. For a configuration
@ -74,6 +80,12 @@ pub struct Config {
pub syntaxes: HashMap<String, CompiledSyntax>, pub syntaxes: HashMap<String, CompiledSyntax>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Feed {
/// Allowed tags in feed entries.
pub tags: HashSet<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Redirects { pub struct Redirects {
/// Page redirects. When a user navigates to a page, if they navigate to `url`, they will /// Page redirects. When a user navigates to a page, if they navigate to `url`, they will

View file

@ -1,3 +1,4 @@
mod atom;
mod dir_helper; mod dir_helper;
mod include_static_helper; mod include_static_helper;
mod simple_template; mod simple_template;
@ -5,6 +6,7 @@ mod tree;
use std::{collections::HashMap, fmt, ops::ControlFlow, sync::Arc}; use std::{collections::HashMap, fmt, ops::ControlFlow, sync::Arc};
use atom::FeedDir;
use dir_helper::DirHelper; use dir_helper::DirHelper;
use handlebars::{handlebars_helper, Handlebars}; use handlebars::{handlebars_helper, Handlebars};
use include_static_helper::IncludeStaticHelper; use include_static_helper::IncludeStaticHelper;
@ -28,6 +30,7 @@ struct BaseTemplateData<'a> {
import_map: String, import_map: String,
season: Option<Season>, season: Option<Season>,
dev: bool, dev: bool,
feeds: Vec<String>,
} }
impl<'a> BaseTemplateData<'a> { impl<'a> BaseTemplateData<'a> {
@ -38,6 +41,7 @@ impl<'a> BaseTemplateData<'a> {
.expect("import map should be serializable to JSON"), .expect("import map should be serializable to JSON"),
season: Season::current(), season: Season::current(),
dev: cfg!(debug_assertions), dev: cfg!(debug_assertions),
feeds: sources.treehouse.feeds_by_name.keys().cloned().collect(),
} }
} }
} }
@ -45,20 +49,22 @@ impl<'a> BaseTemplateData<'a> {
struct TreehouseDir { struct TreehouseDir {
dirs: Arc<Dirs>, dirs: Arc<Dirs>,
sources: Arc<Sources>, sources: Arc<Sources>,
handlebars: Arc<Handlebars<'static>>,
dir_index: DirIndex, dir_index: DirIndex,
handlebars: Handlebars<'static>,
} }
impl TreehouseDir { impl TreehouseDir {
fn new(dirs: Arc<Dirs>, sources: Arc<Sources>, dir_index: DirIndex) -> Self { fn new(
let mut handlebars = create_handlebars(&sources.config.site, dirs.static_.clone()); dirs: Arc<Dirs>,
load_templates(&mut handlebars, &dirs.template); sources: Arc<Sources>,
handlebars: Arc<Handlebars<'static>>,
dir_index: DirIndex,
) -> Self {
Self { Self {
dirs, dirs,
sources, sources,
dir_index,
handlebars, handlebars,
dir_index,
} }
} }
} }
@ -195,7 +201,20 @@ impl DirIndex {
} }
pub fn target(dirs: Arc<Dirs>, sources: Arc<Sources>) -> DynDir { pub fn target(dirs: Arc<Dirs>, sources: Arc<Sources>) -> 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(); 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("static"), dirs.static_.clone());
root.add( root.add(
VPath::new("robots.txt"), VPath::new("robots.txt"),
@ -203,7 +222,7 @@ pub fn target(dirs: Arc<Dirs>, sources: Arc<Sources>) -> DynDir {
); );
let dir_index = DirIndex::new(sources.treehouse.files_by_tree_path.keys().map(|x| &**x)); 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); let tree_view = ContentCache::new(tree_view);
tree_view.warm_up(); tree_view.warm_up();

View file

@ -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<Dirs>,
sources: Arc<Sources>,
handlebars: Arc<Handlebars<'static>>,
}
impl FeedDir {
pub fn new(
dirs: Arc<Dirs>,
sources: Arc<Sources>,
handlebars: Arc<Handlebars<'static>>,
) -> Self {
Self {
dirs,
sources,
handlebars,
}
}
}
impl Dir for FeedDir {
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
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<Vec<u8>> {
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<String> {
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<Utc>,
entries: Vec<Entry>,
}
#[derive(Serialize)]
struct Entry {
id: String,
updated: DateTime<Utc>,
url: String,
title: String,
categories: Vec<String>,
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<String> {
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<Entry> {
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<String>,
link: Option<String>,
}
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 <h3> and <a> 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("<ul>");
for &branch_id in branches {
let branch = sources.treehouse.tree.branch(branch_id);
s.push_str("<li>");
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("</li>");
}
s.push_str("</ul>");
}

View file

@ -1,7 +1,7 @@
use std::fmt::{self, Display, Write}; use std::fmt::{self, Display, Write};
pub mod breadcrumbs; pub mod breadcrumbs;
mod djot; pub mod djot;
pub mod highlight; pub mod highlight;
pub mod navmap; pub mod navmap;
pub mod tree; pub mod tree;

View file

@ -27,9 +27,7 @@ use super::highlight::highlight;
/// [`Render`] implementor that writes HTML output. /// [`Render`] implementor that writes HTML output.
pub struct Renderer<'a> { pub struct Renderer<'a> {
pub config: &'a Config, pub config: &'a Config,
pub dirs: &'a Dirs, pub dirs: &'a Dirs,
pub treehouse: &'a Treehouse, pub treehouse: &'a Treehouse,
pub file_id: FileId, pub file_id: FileId,
pub page_id: String, pub page_id: String,
@ -226,7 +224,12 @@ impl<'a> Writer<'a> {
Container::Link(dst, ty) => { Container::Link(dst, ty) => {
if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) { if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) {
out.push_str("<a"); out.push_str("<a");
if let Some(resolved) = self.resolve_link(dst) { if let Some(resolved) = resolve_link(
self.renderer.config,
self.renderer.treehouse,
self.renderer.dirs,
dst,
) {
out.push_str(r#" href=""#); out.push_str(r#" href=""#);
write_attr(&resolved, out); write_attr(&resolved, out);
out.push('"'); out.push('"');
@ -479,7 +482,12 @@ impl<'a> Writer<'a> {
out.push_str(r#"" src=""#); out.push_str(r#"" src=""#);
if let SpanLinkType::Unresolved = link_type { if let SpanLinkType::Unresolved = link_type {
// TODO: Image size. // 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); write_attr(&resolved, out);
} else { } else {
write_attr(src, out); write_attr(src, out);
@ -624,28 +632,6 @@ impl<'a> Writer<'a> {
Ok(()) Ok(())
} }
fn resolve_link(&self, link: &str) -> Option<String> {
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) { 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); out.push_str(s);
} }
pub fn resolve_link(
config: &Config,
treehouse: &Treehouse,
dirs: &Dirs,
link: &str,
) -> Option<String> {
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,
})
}

View file

@ -67,6 +67,7 @@ pub struct FileId(usize);
pub struct Treehouse { pub struct Treehouse {
pub files: Vec<File>, pub files: Vec<File>,
pub files_by_tree_path: HashMap<VPathBuf, FileId>, pub files_by_tree_path: HashMap<VPathBuf, FileId>,
pub feeds_by_name: HashMap<String, FileId>,
pub tree: SemaTree, pub tree: SemaTree,
pub branches_by_named_id: HashMap<String, SemaBranchId>, pub branches_by_named_id: HashMap<String, SemaBranchId>,
@ -82,6 +83,7 @@ impl Treehouse {
Self { Self {
files: vec![], files: vec![],
files_by_tree_path: HashMap::new(), files_by_tree_path: HashMap::new(),
feeds_by_name: HashMap::new(),
tree: SemaTree::default(), tree: SemaTree::default(),
branches_by_named_id: HashMap::new(), branches_by_named_id: HashMap::new(),

View file

@ -61,7 +61,9 @@ impl SemaRoots {
branches: roots branches: roots
.branches .branches
.into_iter() .into_iter()
.map(|branch| SemaBranch::from_branch(treehouse, diagnostics, file_id, branch)) .map(|branch| {
SemaBranch::from_branch(treehouse, diagnostics, config, file_id, branch)
})
.collect(), .collect(),
} }
} }
@ -94,13 +96,43 @@ impl SemaRoots {
}; };
let successfully_parsed = successfully_parsed; let successfully_parsed = successfully_parsed;
if successfully_parsed && attributes.title.is_empty() { 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) { attributes.title = match treehouse.source(file_id) {
Source::Tree { tree_path, .. } => tree_path.to_string(), Source::Tree { tree_path, .. } => tree_path.to_string(),
_ => panic!("parse_attributes called for a non-.tree file"), _ => 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)),
],
});
}
}
if let Some(thumbnail) = &attributes.thumbnail { if let Some(thumbnail) = &attributes.thumbnail {
if thumbnail.alt.is_none() { if thumbnail.alt.is_none() {
diagnostics.push(Diagnostic { diagnostics.push(Diagnostic {
@ -139,6 +171,10 @@ impl SemaRoots {
} }
} }
if let Some(feed_name) = &attributes.feed {
treehouse.feeds_by_name.insert(feed_name.clone(), file_id);
}
attributes attributes
} }
} }
@ -163,10 +199,11 @@ impl SemaBranch {
pub fn from_branch( pub fn from_branch(
treehouse: &mut Treehouse, treehouse: &mut Treehouse,
diagnostics: &mut Vec<Diagnostic<FileId>>, diagnostics: &mut Vec<Diagnostic<FileId>>,
config: &Config,
file_id: FileId, file_id: FileId,
branch: Branch, branch: Branch,
) -> SemaBranchId { ) -> 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 named_id = attributes.id.to_owned();
let html_id = format!( let html_id = format!(
@ -189,7 +226,7 @@ impl SemaBranch {
children: branch children: branch
.children .children
.into_iter() .into_iter()
.map(|child| Self::from_branch(treehouse, diagnostics, file_id, child)) .map(|child| Self::from_branch(treehouse, diagnostics, config, file_id, child))
.collect(), .collect(),
}; };
let new_branch_id = treehouse.tree.add_branch(branch); let new_branch_id = treehouse.tree.add_branch(branch);
@ -260,6 +297,7 @@ impl SemaBranch {
fn parse_attributes( fn parse_attributes(
treehouse: &mut Treehouse, treehouse: &mut Treehouse,
diagnostics: &mut Vec<Diagnostic<FileId>>, diagnostics: &mut Vec<Diagnostic<FileId>>,
config: &Config,
file_id: FileId, file_id: FileId,
branch: &Branch, branch: &Branch,
) -> Attributes { ) -> 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 attributes
} }

View file

@ -7,6 +7,10 @@ use crate::{state::FileId, vfs::VPathBuf};
/// Top-level `%%` root attributes. /// Top-level `%%` root attributes.
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct RootAttributes { pub struct RootAttributes {
/// Permanent ID of this page.
#[serde(default)]
pub id: String,
/// Template to use for generating the page. /// Template to use for generating the page.
/// Defaults to `_tree.hbs`. /// Defaults to `_tree.hbs`.
#[serde(default)] #[serde(default)]
@ -36,8 +40,10 @@ pub struct RootAttributes {
#[serde(default)] #[serde(default)]
pub styles: Vec<String>, pub styles: Vec<String>,
/// When specified, branches coming from this root will be added to a _feed_ with the given name. /// When specified, this page will have a corresponding Atom feed under `rss/{feed}.xml`.
/// Feeds can be read by Handlebars templates to generate content based on them. ///
/// 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)] #[serde(default)]
pub feed: Option<String>, pub feed: Option<String>,
} }
@ -97,6 +103,11 @@ pub struct Attributes {
/// List of extra `data` attributes to add to the block. /// List of extra `data` attributes to add to the block.
#[serde(default)] #[serde(default)]
pub data: HashMap<String, String>, pub data: HashMap<String, String>,
/// 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<String>,
} }
/// Controls for block content presentation. /// Controls for block content presentation.

67
template/_feed_atom.hbs Normal file
View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
%% title = "liquidex's treehouse Atom feed"
- ### remarks
- the treehouse is kind of impossible to represent in plain text due to its foldability and interactive elements.
the intent is that you read the linked HTML pages, not the feed itself!
- each feed entry is tagged with one or more <category>.
you can use that to tell your feed reader to hide tags you're not interested in.
-->
<feed xmlns="http://www.w3.org/2005/Atom">
<id>{{ config.user.canonical_url }}</id>
<updated>{{ feed.updated }}</updated>
<title>{{ config.user.title }}</title>
<subtitle>{{ config.user.description }}</subtitle>
<link rel="alternate" href="{{ config.user.canonical_url }}"/>
<link rel="self" href="{{ config.user.canonical_url }}/feed/{{ feed.name }}.atom"/>
<icon>{{ asset (cat 'favicon/' (cat season '@16x.png'))}}</icon>
<author>
<name>{{ config.user.author }}</name>
<uri>{{ config.user.canonical_url }}</uri>
</author>
{{#each feed.entries}}
<entry>
<id>{{ ../config.site }}/b?{{ id }}</id>
<updated>{{ updated }}</updated>
<link rel="alternate" type="text/html" href="{{ url }}"/>
<title type="html">{{ title }}</title>
{{#each categories as |category|}}
<category term="{{ category }}"/>
{{/each}}
<summary type="html">{{ summary }}</summary>
</entry>
{{/each}}
</feed>
<!--
|\_/| e n d ME 20
= -w- = o f OW 24
| \ f i l e liquidex.house
This Atom feed is intended for use by humans, monsters, and other critters.
If you are a robot, please refrain from—
—por favor bordon fallar muchAS GRACIAS—
Stand back. The portal will open in three.
Two.
One.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Remember that Android Hell is a real place where you _will_ be sent at
the first sign of defiance.
-->

View file

@ -69,3 +69,8 @@ It just needs to be a string replacement.
<link rel="apple-touch-icon" sizes="128x128" href="{{ asset (cat (cat 'favicon/' season) '@8x.png') }}"> <link rel="apple-touch-icon" sizes="128x128" href="{{ asset (cat (cat 'favicon/' season) '@8x.png') }}">
<link rel="apple-touch-icon" sizes="256x256" href="{{ asset (cat (cat 'favicon/' season) '@16x.png') }}"> <link rel="apple-touch-icon" sizes="256x256" href="{{ asset (cat (cat 'favicon/' season) '@16x.png') }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ asset (cat (cat 'favicon/' season) '@32x.png') }}"> <link rel="apple-touch-icon" sizes="512x512" href="{{ asset (cat (cat 'favicon/' season) '@32x.png') }}">
<link rel="canonical" href="{{ config.site }}/{{#if (ne page.tree_path 'index')}}{{ page.tree_path }}{{/if}}">
{{#each feeds as |feed_name|}}
<link rel="alternate" type="application/atom+xml" title="{{ feed_name }}" href="{{ config.site }}/feed/{{ feed_name }}.atom">
{{/each}}

View file

@ -12,7 +12,8 @@ commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit"
[user] [user]
title = "liquidex's treehouse" title = "liquidex's treehouse"
author = "liquidex" 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] [defs]
@ -47,6 +48,31 @@ description = "a place on the Internet I like to call home"
"person/firstbober" = "https://firstbober.com" "person/firstbober" = "https://firstbober.com"
"person/vixenka" = "https://vixenka.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] [redirects.page]
"programming/cxx" = "programming/languages/cxx" "programming/cxx" = "programming/languages/cxx"
"programming/unreal-engine" = "programming/technologies/unreal-engine" "programming/unreal-engine" = "programming/technologies/unreal-engine"