use std::{ collections::{HashMap, HashSet}, ops::ControlFlow, }; use anyhow::{Context, anyhow}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use tracing::{error, info_span, instrument}; use crate::{ html::highlight::{ Syntax, compiled::{CompiledSyntax, compile_syntax}, }, import_map::ImportRoot, vfs::{self, Content, Dir, DynDir, ImageSize, VPath, VPathBuf}, }; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct VfsConfig { /// Cache salt string. Passed to `Blake3ContentVersionCache` as a salt for content version hashes. /// Can be changed to bust cached assets for all clients. pub cache_salt: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { /// Website root; used when generating links. /// Can also be specified using the environment variable `$TREEHOUSE_SITE`. (this is the /// preferred way of setting this in production, so as not to clobber treehouse.toml.) pub site: String, /// This is used to generate a link in the footer that links to the page's source commit. /// The final URL is `{commit_base_url}/{commit}/content/{tree_path}.tree`. pub commit_base_url: String, /// User-defined keys. pub user: HashMap, /// 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 /// like: /// /// ```toml /// page.redirects.foo = "bar" /// page.redirects.bar = "baz" /// ``` /// /// the user will be redirected from `foo` to `bar`, then from `bar` to `baz`. This isn't /// optimal for UX and causes unnecessary latency. Therefore you should always make redirects /// point to the newest version of the page. /// /// ```toml /// page.redirects.foo = "baz" /// page.redirects.bar = "baz" /// ``` pub redirects: Redirects, /// How the treehouse should be built. pub build: Build, /// Overrides for emoji names. Useful for setting up aliases. /// /// Paths are anchored within `static/emoji` and must not contain parent directories. #[serde(default)] pub emoji: HashMap, /// Overrides for pic filenames. Useful for setting up aliases. /// /// On top of this, pics are autodiscovered by walking the `static/pic` directory. /// Only the part before the first dash is treated as the pic's id. pub pics: HashMap, /// Syntax definitions. /// /// These are not part of the config file, but are loaded as part of site configuration from /// `static/syntax`. #[serde(skip)] 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 { /// Path redirects. When a user requests a path, if they request `p`, they will be redirected /// to `path[p]` with a `301 Moved Permanently` status code. pub path: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Build { /// Configuration for how JavaScript is compiled. pub javascript: JavaScript, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct JavaScript { /// Import roots to generate in the project's import map. pub import_roots: Vec, } impl Config { #[instrument(name = "Config::autopopulate_emoji", skip(self))] pub fn autopopulate_emoji(&mut self, dir: &dyn Dir) -> anyhow::Result<()> { vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| { if path.extension().is_some_and(is_image_file) && let Some(emoji_name) = path.file_stem() && !self.emoji.contains_key(emoji_name) { self.emoji.insert(emoji_name.to_owned(), path.to_owned()); } ControlFlow::Continue(()) }); Ok(()) } #[instrument(name = "Config::autopopulate_pics", skip(self))] pub fn autopopulate_pics(&mut self, dir: &dyn Dir) -> anyhow::Result<()> { vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| { if path.extension().is_some_and(is_image_file) && let Some(pic_name) = path.file_stem() { let pic_id = pic_name .split_once('-') .map(|(before_dash, _after_dash)| before_dash) .unwrap_or(pic_name); if !self.pics.contains_key(pic_id) { self.pics.insert(pic_id.to_owned(), path.to_owned()); } } ControlFlow::Continue(()) }); Ok(()) } pub fn page_url(&self, page: &str) -> String { let (page, hash) = page.split_once('#').unwrap_or((page, "")); // We don't want .dj appearing in URLs, though it exists as a disambiguator in [page:] links. let page = page.strip_suffix(".dj").unwrap_or(page); if !hash.is_empty() { format!("{}/{page}#{hash}", self.site) } else { format!("{}/{page}", self.site) } } pub fn pic_url(&self, pics_dir: &dyn Dir, id: &str) -> String { vfs::url( &self.site, pics_dir, self.pics .get(id) .map(|x| &**x) .unwrap_or(VPath::new("404.png")), ) .expect("pics_dir is not anchored anywhere") } pub fn pic_size(&self, pics_dir: &dyn Dir, id: &str) -> Option { self.pics .get(id) .and_then(|path| vfs::query::(pics_dir, path)) } /// Loads all syntax definition files. #[instrument(name = "Config::load_syntaxes", skip(self))] pub fn load_syntaxes(&mut self, dir: DynDir) -> anyhow::Result<()> { let mut paths = vec![]; vfs::walk_dir_rec(&dir, VPath::ROOT, &mut |path| { if path.extension() == Some("json") { paths.push(path.to_owned()); } ControlFlow::Continue(()) }); let syntaxes: Vec<_> = paths .par_iter() .flat_map(|path| { let name = path .file_stem() .expect("syntax file name should have a stem due to the .json extension"); let result: Result = vfs::query::(&dir, path) .ok_or_else(|| anyhow!("syntax .json is not a file")) .and_then(|b| b.string().context("syntax .json contains invalid UTF-8")) .and_then(|s| { let _span = info_span!("Config::load_syntaxes::parse").entered(); serde_json::from_str(&s).context("could not deserialize syntax file") }); match result { Ok(syntax) => { let _span = info_span!("Config::load_syntaxes::compile", ?name).entered(); let compiled = compile_syntax(&syntax); Some((name.to_owned(), compiled)) } Err(err) => { error!("error while loading syntax file `{path}`: {err}"); None } } }) .collect(); for (name, compiled) in syntaxes { self.syntaxes.insert(name, compiled); } Ok(()) } } pub fn is_image_file(extension: &str) -> bool { matches!(extension, "png" | "svg" | "jpg" | "jpeg" | "webp") }