diff --git a/Cargo.lock b/Cargo.lock index 6da91ed..8b76bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,9 +267,14 @@ checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "cc" -version = "1.0.97" +version = "1.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" +checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +dependencies = [ + "jobserver", + "libc", + "shlex", +] [[package]] name = "cfg-if" @@ -599,6 +604,21 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags 2.5.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "half" version = "2.4.1" @@ -813,6 +833,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "jotdown" version = "0.4.1" @@ -849,6 +878,46 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.12" @@ -938,6 +1007,24 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.2" @@ -1044,6 +1131,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + [[package]] name = "png" version = "0.17.13" @@ -1295,6 +1388,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1546,6 +1645,7 @@ dependencies = [ "codespan-reporting", "copy_dir", "env_logger", + "git2", "handlebars", "http-body", "image", @@ -1649,6 +1749,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/admin/daemon.bash b/admin/daemon.bash index 19b706d..5c6ce28 100755 --- a/admin/daemon.bash +++ b/admin/daemon.bash @@ -11,8 +11,9 @@ mkfifo $reload_fifo reload() { # This just kind of assumes regeneration doesn't take too long. + cargo build --release kill "$treehouse_pid" - cargo run --release -- serve --port 8082 > "$build_log" 2>&1 & + cargo run --release -- serve --port 8082 --commits-only > "$build_log" 2>&1 & treehouse_pid="$!" } diff --git a/crates/treehouse/Cargo.toml b/crates/treehouse/Cargo.toml index 6f849f8..cdd087b 100644 --- a/crates/treehouse/Cargo.toml +++ b/crates/treehouse/Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4.3.22", features = ["derive"] } codespan-reporting = "0.11.1" copy_dir = "0.1.3" env_logger = "0.10.0" +git2 = "0.19.0" handlebars = "4.3.7" http-body = "1.0.0" image = "0.24.8" diff --git a/crates/treehouse/src/cli.rs b/crates/treehouse/src/cli.rs index 5c8bacf..0dc3a25 100644 --- a/crates/treehouse/src/cli.rs +++ b/crates/treehouse/src/cli.rs @@ -44,7 +44,14 @@ pub enum Command { } #[derive(Args)] -pub struct GenerateArgs {} +pub struct GenerateArgs { + /// Only use commits as sources. This will cause the latest revision to be taken from the + /// Git history instead of the working tree. + /// + /// Recommended for deployment. + #[clap(long)] + pub commits_only: bool, +} #[derive(Args)] pub struct FixArgs { diff --git a/crates/treehouse/src/cli/fix.rs b/crates/treehouse/src/cli/fix.rs index caebbaf..f0211ee 100644 --- a/crates/treehouse/src/cli/fix.rs +++ b/crates/treehouse/src/cli/fix.rs @@ -1,12 +1,13 @@ use std::{ffi::OsStr, ops::Range}; use anyhow::Context; +use codespan_reporting::diagnostic::Diagnostic; use treehouse_format::ast::Branch; use walkdir::WalkDir; use crate::{ parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics}, - state::{FileId, Source, Treehouse}, + state::{report_diagnostics, FileId, Source, Treehouse}, }; use super::{FixAllArgs, FixArgs, Paths}; @@ -103,26 +104,32 @@ fn fix_indent_in_generated_toml(toml: &str, min_indent_level: usize) -> String { pub fn fix_file( treehouse: &mut Treehouse, + diagnostics: &mut Vec>, file_id: FileId, ) -> Result { - parse_tree_with_diagnostics(treehouse, file_id).map(|roots| { - let mut source = treehouse.source(file_id).input().to_owned(); - let mut state = State::default(); + parse_tree_with_diagnostics(treehouse, file_id) + .map(|roots| { + let mut source = treehouse.source(file_id).input().to_owned(); + let mut state = State::default(); - for branch in &roots.branches { - dfs_fix_branch(treehouse, file_id, &mut state, branch); - } + for branch in &roots.branches { + dfs_fix_branch(treehouse, file_id, &mut state, branch); + } - // Doing a depth-first search of the branches yields fixes from the beginning of the file - // to its end. The most efficient way to apply all the fixes then is to reverse their order, - // which lets us modify the source string in place because the fix ranges always stay - // correct. - for fix in state.fixes.iter().rev() { - source.replace_range(fix.range.clone(), &fix.replacement); - } + // Doing a depth-first search of the branches yields fixes from the beginning of the file + // to its end. The most efficient way to apply all the fixes then is to reverse their order, + // which lets us modify the source string in place because the fix ranges always stay + // correct. + for fix in state.fixes.iter().rev() { + source.replace_range(fix.range.clone(), &fix.replacement); + } - source - }) + source + }) + .map_err(|mut new| { + diagnostics.append(&mut new); + parse::ErrorsEmitted + }) } pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> { @@ -130,9 +137,10 @@ pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> { let file = std::fs::read_to_string(&fix_args.file).context("cannot read file to fix")?; let mut treehouse = Treehouse::new(); + let mut diagnostics = vec![]; let file_id = treehouse.add_file(utf8_filename, Source::Other(file)); - if let Ok(fixed) = fix_file(&mut treehouse, file_id) { + if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) { if fix_args.apply { // Try to write the backup first. If writing that fails, bail out without overwriting // the source file. @@ -145,7 +153,7 @@ pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> { println!("{fixed}"); } } else { - treehouse.report_diagnostics()?; + report_diagnostics(&treehouse.files, &diagnostics)?; } Ok(()) @@ -160,9 +168,10 @@ pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Resul let utf8_filename = entry.path().to_string_lossy(); let mut treehouse = Treehouse::new(); + let mut diagnostics = vec![]; let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file)); - if let Ok(fixed) = fix_file(&mut treehouse, file_id) { + if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) { if fixed != treehouse.source(file_id).input() { if fix_all_args.apply { println!("fixing: {:?}", entry.path()); @@ -174,7 +183,7 @@ pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Resul } } } else { - treehouse.report_diagnostics()?; + report_diagnostics(&treehouse.files, &diagnostics)?; } } } diff --git a/crates/treehouse/src/cli/serve.rs b/crates/treehouse/src/cli/serve.rs index 1933936..8f84934 100644 --- a/crates/treehouse/src/cli/serve.rs +++ b/crates/treehouse/src/cli/serve.rs @@ -193,9 +193,11 @@ async fn branch(RawQuery(named_id): RawQuery, State(state): State>) .or_else(|| state.treehouse.branch_redirects.get(&named_id).copied()); if let Some(branch_id) = branch_id { let branch = state.treehouse.tree.branch(branch_id); - if let Source::Tree { input, tree_path } = state.treehouse.source(branch.file_id) { - let file_path = state.target_dir.join(format!("{tree_path}.html")); - match std::fs::read_to_string(&file_path) { + if let Source::Tree { + input, target_path, .. + } = state.treehouse.source(branch.file_id) + { + match std::fs::read_to_string(target_path) { Ok(content) => { let branch_markdown_content = input[branch.content.clone()].trim(); let mut per_page_metadata = @@ -212,7 +214,7 @@ async fn branch(RawQuery(named_id): RawQuery, State(state): State>) )); } Err(e) => { - error!("error while reading file {file_path:?}: {e:?}"); + error!("error while reading file {target_path:?}: {e:?}"); } } } diff --git a/crates/treehouse/src/cli/wc.rs b/crates/treehouse/src/cli/wc.rs index 3814e4c..40816b0 100644 --- a/crates/treehouse/src/cli/wc.rs +++ b/crates/treehouse/src/cli/wc.rs @@ -6,7 +6,7 @@ use walkdir::WalkDir; use crate::{ parse::parse_tree_with_diagnostics, - state::{Source, Treehouse}, + state::{report_diagnostics, Source, Treehouse}, }; use super::WcArgs; @@ -53,17 +53,20 @@ pub fn wc_cli(content_dir: &Path, mut wc_args: WcArgs) -> anyhow::Result<()> { .to_string_lossy(); let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file)); - if let Ok(parsed) = parse_tree_with_diagnostics(&mut treehouse, file_id) { - let source = treehouse.source(file_id); - let word_count = wc_roots(source.input(), &parsed); - println!("{word_count:>8} {}", treehouse.filename(file_id)); - total += word_count; + match parse_tree_with_diagnostics(&mut treehouse, file_id) { + Ok(parsed) => { + let source = treehouse.source(file_id); + let word_count = wc_roots(source.input(), &parsed); + println!("{word_count:>8} {}", treehouse.filename(file_id)); + total += word_count; + } + Err(diagnostics) => { + report_diagnostics(&treehouse.files, &diagnostics)?; + } } } println!("{total:>8} total"); - treehouse.report_diagnostics()?; - Ok(()) } diff --git a/crates/treehouse/src/config.rs b/crates/treehouse/src/config.rs index 36026ea..57b0e98 100644 --- a/crates/treehouse/src/config.rs +++ b/crates/treehouse/src/config.rs @@ -26,6 +26,10 @@ pub struct Config { /// TODO djot: Remove this once we transition to Djot fully. pub markup: Markup, + /// 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, diff --git a/crates/treehouse/src/generate.rs b/crates/treehouse/src/generate.rs index ff0bb3d..c6c2f3a 100644 --- a/crates/treehouse/src/generate.rs +++ b/crates/treehouse/src/generate.rs @@ -19,6 +19,7 @@ use walkdir::WalkDir; use crate::{ config::{Config, ConfigDerivedData}, fun::seasons::Season, + history::History, html::{ breadcrumbs::breadcrumbs_to_html, navmap::{build_navigation_map, NavigationMap}, @@ -27,7 +28,7 @@ use crate::{ import_map::ImportMap, include_static::IncludeStatic, parse::parse_tree_with_diagnostics, - state::Source, + state::{has_errors, report_diagnostics, RevisionInfo, Source}, static_urls::StaticUrls, tree::SemaRoots, }; @@ -36,14 +37,25 @@ use crate::state::{FileId, Treehouse}; use super::Paths; -#[derive(Default)] -struct Generator { - tree_files: Vec, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LatestRevision { + /// The working tree is treated as the latest revision. + WorkingTree, + /// The latest commit is treated as the latest revision. The working tree is ignored. + LatestCommit, } -struct Build {} +struct Generator { + tree_files: Vec, + git: git2::Repository, + history: History, + latest_revision: LatestRevision, +} +#[derive(Debug, Clone)] struct ParsedTree { + source_path: String, + root_key: String, tree_path: String, file_id: FileId, target_path: PathBuf, @@ -63,6 +75,27 @@ pub struct Page { pub breadcrumbs: String, pub tree_path: Option, pub tree: String, + + pub revision: RevisionInfo, + pub revision_url: String, + pub source_url: String, + pub history_url: String, +} + +#[derive(Serialize)] +pub struct Commit { + pub revision_number: usize, + pub hash: String, + pub hash_short: String, + pub summary: String, + pub body: String, +} + +#[derive(Serialize)] +pub struct HistoryPage { + pub title: String, + pub commits: Vec, + pub tree_path: String, } #[derive(Serialize)] @@ -85,6 +118,13 @@ struct PageTemplateData<'a> { season: Option, } +#[derive(Serialize)] +struct HistoryTemplateData<'a> { + config: &'a Config, + page: HistoryPage, + season: Option, +} + impl Generator { fn add_directory_rec(&mut self, directory: &Path) -> anyhow::Result<()> { for entry in WalkDir::new(directory) { @@ -96,136 +136,7 @@ impl Generator { Ok(()) } - fn register_template( - handlebars: &mut Handlebars<'_>, - treehouse: &mut Treehouse, - name: &str, - path: &Path, - ) -> anyhow::Result { - let source = std::fs::read_to_string(path) - .with_context(|| format!("cannot read template file {path:?}"))?; - let file_id = - treehouse.add_file(path.to_string_lossy().into_owned(), Source::Other(source)); - let source = treehouse.source(file_id); - if let Err(error) = handlebars.register_template_string(name, source) { - Self::wrangle_handlebars_error_into_diagnostic( - treehouse, - file_id, - error.line_no, - error.column_no, - error.reason().to_string(), - )?; - } - Ok(file_id) - } - - fn wrangle_handlebars_error_into_diagnostic( - treehouse: &mut Treehouse, - file_id: FileId, - line: Option, - column: Option, - message: String, - ) -> anyhow::Result<()> { - if let (Some(line), Some(column)) = (line, column) { - let line_range = treehouse - .files - .line_range(file_id, line) - .expect("file was added to the list"); - treehouse.diagnostics.push(Diagnostic { - severity: Severity::Error, - code: Some("template".into()), - message, - labels: vec![Label { - style: LabelStyle::Primary, - file_id, - range: line_range.start + column..line_range.start + column + 1, - message: String::new(), - }], - notes: vec![], - }) - } else { - let file = treehouse.filename(file_id); - bail!("template error in {file}: {message}"); - } - Ok(()) - } - - fn parse_trees( - &self, - config: &Config, - paths: &Paths<'_>, - ) -> anyhow::Result<(Treehouse, Vec)> { - let mut treehouse = Treehouse::new(); - let mut parsed_trees = vec![]; - - for path in &self.tree_files { - let utf8_filename = path.to_string_lossy(); - - let tree_path = path.strip_prefix(paths.content_dir).unwrap_or(path); - let target_path = if tree_path == OsStr::new("index.tree") { - paths.target_dir.join("index.html") - } else { - paths.target_dir.join(tree_path).with_extension("html") - }; - debug!("generating: {path:?} -> {target_path:?}"); - - let source = match std::fs::read_to_string(path) { - Ok(source) => source, - Err(error) => { - treehouse.diagnostics.push(Diagnostic { - severity: Severity::Error, - code: None, - message: format!("{utf8_filename}: cannot read file: {error}"), - labels: vec![], - notes: vec![], - }); - continue; - } - }; - let tree_path = tree_path - .with_extension("") - .to_string_lossy() - .replace('\\', "/"); - let file_id = treehouse.add_file( - utf8_filename.into_owned(), - Source::Tree { - input: source, - tree_path: tree_path.clone(), - }, - ); - - if let Ok(roots) = parse_tree_with_diagnostics(&mut treehouse, file_id) { - let roots = SemaRoots::from_roots(&mut treehouse, config, file_id, roots); - treehouse.roots.insert(tree_path.clone(), roots); - parsed_trees.push(ParsedTree { - tree_path, - file_id, - target_path, - }); - } - } - - Ok((treehouse, parsed_trees)) - } - - fn generate_all_files( - &self, - treehouse: &mut Treehouse, - config: &Config, - paths: &Paths<'_>, - navigation_map: &NavigationMap, - parsed_trees: Vec, - ) -> anyhow::Result<()> { - let mut handlebars = Handlebars::new(); - let mut config_derived_data = ConfigDerivedData { - image_sizes: Default::default(), - static_urls: StaticUrls::new( - // NOTE: Allow referring to generated static assets here. - paths.target_dir.join("static"), - format!("{}/static", config.site), - ), - }; - + fn init_handlebars(handlebars: &mut Handlebars<'_>, paths: &Paths<'_>, config: &Config) { handlebars_helper!(cat: |a: String, b: String| a + &b); handlebars.register_helper("cat", Box::new(cat)); @@ -245,6 +156,267 @@ impl Generator { base_dir: paths.target_dir.join("static"), }), ); + } + + fn register_template( + handlebars: &mut Handlebars<'_>, + treehouse: &mut Treehouse, + diagnostics: &mut Vec>, + name: &str, + path: &Path, + ) -> anyhow::Result { + let source = std::fs::read_to_string(path) + .with_context(|| format!("cannot read template file {path:?}"))?; + let file_id = + treehouse.add_file(path.to_string_lossy().into_owned(), Source::Other(source)); + let source = treehouse.source(file_id); + if let Err(error) = handlebars.register_template_string(name, source) { + Self::wrangle_handlebars_error_into_diagnostic( + treehouse, + diagnostics, + file_id, + error.line_no, + error.column_no, + error.reason().to_string(), + )?; + } + Ok(file_id) + } + + fn wrangle_handlebars_error_into_diagnostic( + treehouse: &mut Treehouse, + diagnostics: &mut Vec>, + file_id: FileId, + line: Option, + column: Option, + message: String, + ) -> anyhow::Result<()> { + if let (Some(line), Some(column)) = (line, column) { + let line_range = treehouse + .files + .line_range(file_id, line) + .expect("file was added to the list"); + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: Some("template".into()), + message, + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: line_range.start + column..line_range.start + column + 1, + message: String::new(), + }], + notes: vec![], + }) + } else { + let file = treehouse.filename(file_id); + bail!("template error in {file}: {message}"); + } + Ok(()) + } + + fn parse_tree( + treehouse: &mut Treehouse, + config: &Config, + source: String, + source_path: String, + tree_path: String, + target_path: PathBuf, + revision: RevisionInfo, + ) -> anyhow::Result<(Option, Vec>)> { + let file_id = treehouse.add_file( + format!("{source_path}@{}", revision.commit_short), + Source::Tree { + input: source, + target_path: target_path.clone(), + tree_path: tree_path.clone(), + revision_info: revision.clone(), + }, + ); + + match parse_tree_with_diagnostics(treehouse, file_id) { + Ok(roots) => { + let mut diagnostics = vec![]; + let roots = + SemaRoots::from_roots(treehouse, &mut diagnostics, config, file_id, roots); + + let root_key = if revision.is_latest { + tree_path.clone() + } else { + format!("{tree_path}@{}", revision.number) + }; + treehouse.roots.insert(root_key.clone(), roots); + + Ok(( + Some(ParsedTree { + source_path, + root_key, + tree_path, + file_id, + target_path, + }), + diagnostics, + )) + } + Err(diagnostics) => Ok((None, diagnostics)), + } + } + + fn parse_trees( + &self, + config: &Config, + paths: &Paths<'_>, + ) -> anyhow::Result<(Treehouse, Vec, Vec>)> { + let mut treehouse = Treehouse::new(); + let mut diagnostics = vec![]; + let mut parsed_trees = vec![]; + + for path in &self.tree_files { + let utf8_path = path.to_string_lossy(); + + let tree_path = path + .strip_prefix(paths.content_dir) + .unwrap_or(path) + .with_extension("") + .to_string_lossy() + .replace('\\', "/"); + debug!("tree file: {path:?}"); + + let page_history = self.history.by_page.get(&utf8_path[..]); + let working_revision_number = page_history + .map(|history| history.revisions.len() + 1) + .unwrap_or(1); + + if self.latest_revision == LatestRevision::WorkingTree { + let source = std::fs::read_to_string(path)?; + let target_path = paths.target_dir.join(&tree_path).with_extension("html"); + let (parsed_tree, mut parse_diagnostics) = Self::parse_tree( + &mut treehouse, + config, + source, + utf8_path.clone().into_owned(), + tree_path.clone(), + target_path, + RevisionInfo { + is_latest: true, + number: working_revision_number, + commit: "working".into(), + commit_short: "working".into(), + }, + )?; + diagnostics.append(&mut parse_diagnostics); + if let Some(parsed_tree) = parsed_tree { + parsed_trees.push(parsed_tree); + } + } + + if let Some(page_history) = page_history { + for (i, revision) in page_history.revisions.iter().enumerate() { + let revision_number = page_history.revisions.len() - i; + + let source = String::from_utf8( + self.git.find_blob(revision.blob_oid)?.content().to_owned(), + )?; + + let target_path = paths + .target_dir + .join(format!("{tree_path}@{revision_number}")) + .with_extension("html"); + + let (parsed_tree, parse_diagnostics) = Self::parse_tree( + &mut treehouse, + config, + source, + utf8_path.clone().into_owned(), + tree_path.clone(), + target_path, + RevisionInfo { + is_latest: false, + number: revision_number, + commit: revision.commit_oid.to_string(), + commit_short: revision.commit_short(), + }, + )?; + _ = parse_diagnostics; // We don't reemit diagnostics from old revisions. + if let Some(parsed_tree) = parsed_tree { + // If this commit is also considered to be the latest revision, we need + // to generate a second version of the page that will act as the + // latest one. + let is_latest = + self.latest_revision == LatestRevision::LatestCommit && i == 0; + if is_latest { + let root_key = parsed_tree.tree_path.clone(); + treehouse.roots.insert( + root_key.clone(), + treehouse.roots.get(&parsed_tree.root_key).unwrap().clone(), + ); + + let target_path = + paths.target_dir.join(&tree_path).with_extension("html"); + let file_id = { + let file = treehouse.files.get(parsed_tree.file_id).unwrap(); + let filename = file.name().clone(); + let Source::Tree { + input, + tree_path, + target_path, + revision_info, + } = file.source().clone() + else { + panic!(".tree files must have Tree sources") + }; + treehouse.add_file( + filename, + Source::Tree { + input, + tree_path, + target_path: target_path.clone(), + revision_info: RevisionInfo { + is_latest: true, + ..revision_info + }, + }, + ) + }; + + parsed_trees.push(ParsedTree { + root_key, + target_path, + file_id, + ..parsed_tree.clone() + }) + } + + parsed_trees.push(parsed_tree); + } + } + } + } + + Ok((treehouse, parsed_trees, diagnostics)) + } + + fn generate_all_files( + &self, + treehouse: &mut Treehouse, + config: &Config, + paths: &Paths<'_>, + navigation_map: &NavigationMap, + parsed_trees: Vec, + ) -> anyhow::Result>> { + let mut global_diagnostics = vec![]; + + let mut config_derived_data = ConfigDerivedData { + image_sizes: Default::default(), + static_urls: StaticUrls::new( + // NOTE: Allow referring to generated static assets here. + paths.target_dir.join("static"), + format!("{}/static", config.site), + ), + }; + + let mut handlebars = Handlebars::new(); + Self::init_handlebars(&mut handlebars, paths, config); let mut template_file_ids = HashMap::new(); for entry in WalkDir::new(paths.template_dir) { @@ -256,8 +428,13 @@ impl Generator { .to_string_lossy() .into_owned() .replace('\\', "/"); - let file_id = - Self::register_template(&mut handlebars, treehouse, &relative_path, path)?; + let file_id = Self::register_template( + &mut handlebars, + treehouse, + &mut global_diagnostics, + &relative_path, + path, + )?; template_file_ids.insert(relative_path, file_id); } } @@ -277,6 +454,7 @@ impl Generator { Err(error) => { Self::wrangle_handlebars_error_into_diagnostic( treehouse, + &mut global_diagnostics, file_id, error.line_no, error.column_no, @@ -295,7 +473,7 @@ impl Generator { let mut feeds = HashMap::new(); for parsed_tree in &parsed_trees { - let roots = &treehouse.roots[&parsed_tree.tree_path]; + let roots = &treehouse.roots[&parsed_tree.root_key]; if let Some(feed_name) = &roots.attributes.feed { let mut feed = Feed { @@ -310,13 +488,15 @@ impl Generator { } for parsed_tree in parsed_trees { - let breadcrumbs = breadcrumbs_to_html(config, navigation_map, &parsed_tree.tree_path); + debug!("generating: {:?}", parsed_tree.target_path); + + let breadcrumbs = breadcrumbs_to_html(config, navigation_map, &parsed_tree.root_key); let mut tree = String::new(); // Temporarily steal the tree out of the treehouse. let roots = treehouse .roots - .remove(&parsed_tree.tree_path) + .remove(&parsed_tree.root_key) .expect("tree should have been added to the treehouse"); branches_to_html( &mut tree, @@ -328,6 +508,9 @@ impl Generator { &roots.branches, ); + let revision = treehouse + .revision_info(parsed_tree.file_id) + .expect(".tree files should have Tree sources"); let template_data = PageTemplateData { config, page: Page { @@ -347,6 +530,14 @@ impl Generator { .tree_path(parsed_tree.file_id) .map(|s| s.to_owned()), tree, + + revision_url: format!("{}/{}", config.site, parsed_tree.root_key), + source_url: format!( + "{}/{}/{}", + config.commit_base_url, revision.commit, parsed_tree.source_path, + ), + history_url: format!("{}/h/{}", config.site, parsed_tree.tree_path), + revision: revision.clone(), }, feeds: &feeds, season: Season::current(), @@ -357,13 +548,16 @@ impl Generator { .clone() .unwrap_or_else(|| "_tree.hbs".into()); - treehouse.roots.insert(parsed_tree.tree_path, roots); + // Reinsert the stolen roots. + treehouse.roots.insert(parsed_tree.root_key, roots); let templated_html = match handlebars.render(&template_name, &template_data) { Ok(html) => html, Err(error) => { Self::wrangle_handlebars_error_into_diagnostic( treehouse, + // TODO: This should dump diagnostics out somewhere else. + &mut global_diagnostics, template_file_ids[&template_name], error.line_no, error.column_no, @@ -382,11 +576,78 @@ impl Generator { std::fs::write(parsed_tree.target_path, templated_html)?; } - Ok(()) + for (path, page_history) in &self.history.by_page { + let tree_path = path + .strip_prefix("content/") + .unwrap_or(path) + .strip_suffix(".tree") + .unwrap_or(path); + let target_path = paths + .target_dir + .join("h") + .join(path.strip_prefix("content/").unwrap_or(path)) + .with_extension("html"); + std::fs::create_dir_all(target_path.parent().unwrap())?; + + let template_data = HistoryTemplateData { + config, + page: HistoryPage { + title: format!("page history: {tree_path}"), + commits: page_history + .revisions + .iter() + .enumerate() + .map(|(i, revision)| Commit { + revision_number: page_history.revisions.len() - i, + hash: revision.commit_oid.to_string(), + hash_short: revision.commit_short(), + summary: self + .history + .commits + .get(&revision.commit_oid) + .map(|c| c.summary.as_str()) + .unwrap_or("") + .to_owned(), + body: self + .history + .commits + .get(&revision.commit_oid) + .map(|c| c.body.as_str()) + .unwrap_or("") + .to_owned(), + }) + .collect(), + tree_path: tree_path.to_owned(), + }, + season: Season::current(), + }; + let templated_html = match handlebars.render("_history.hbs", &template_data) { + Ok(html) => html, + Err(error) => { + Self::wrangle_handlebars_error_into_diagnostic( + treehouse, + // TODO: This should dump diagnostics out somewhere else. + &mut global_diagnostics, + template_file_ids["_history.hbs"], + error.line_no, + error.column_no, + error.desc, + )?; + continue; + } + }; + + std::fs::write(target_path, templated_html)?; + } + + Ok(global_diagnostics) } } -pub fn generate(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> { +pub fn generate( + paths: &Paths<'_>, + latest_revision: LatestRevision, +) -> anyhow::Result<(Config, Treehouse)> { let start = Instant::now(); info!("loading config"); @@ -406,10 +667,23 @@ pub fn generate(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> { info!("creating static/generated directory"); std::fs::create_dir_all(paths.target_dir.join("static/generated"))?; + info!("getting history"); + let git = git2::Repository::open(".")?; + let history = History::get(&git)?; + info!("parsing tree"); - let mut generator = Generator::default(); + let mut generator = Generator { + tree_files: vec![], + git, + history, + latest_revision, + }; generator.add_directory_rec(paths.content_dir)?; - let (mut treehouse, parsed_trees) = generator.parse_trees(&config, paths)?; + let (mut treehouse, parsed_trees, diagnostics) = generator.parse_trees(&config, paths)?; + report_diagnostics(&treehouse.files, &diagnostics)?; + if has_errors(&diagnostics) { + bail!("diagnostics emitted during parsing"); + } // NOTE: The navigation map is a legacy feature that is lazy-loaded when fragment-based // navigation is used. @@ -431,30 +705,34 @@ pub fn generate(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> { )?; info!("generating standalone pages"); - generator.generate_all_files( + let diagnostics = generator.generate_all_files( &mut treehouse, &config, paths, &navigation_map, parsed_trees, )?; + report_diagnostics(&treehouse.files, &diagnostics)?; - treehouse.report_diagnostics()?; + info!("generating change history pages"); let duration = start.elapsed(); info!("generation done in {duration:?}"); - if !treehouse.has_errors() { + if !has_errors(&diagnostics) { Ok((config, treehouse)) } else { bail!("generation errors occurred; diagnostics were emitted with detailed descriptions"); } } -pub fn regenerate_or_report_error(paths: &Paths<'_>) -> anyhow::Result<(Config, Treehouse)> { +pub fn regenerate_or_report_error( + paths: &Paths<'_>, + latest_revision: LatestRevision, +) -> anyhow::Result<(Config, Treehouse)> { info!("regenerating site content"); - let result = generate(paths); + let result = generate(paths, latest_revision); if let Err(e) = &result { error!("{e:?}"); } diff --git a/crates/treehouse/src/history.rs b/crates/treehouse/src/history.rs new file mode 100644 index 0000000..4a76c97 --- /dev/null +++ b/crates/treehouse/src/history.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +use indexmap::IndexMap; +use log::debug; + +#[derive(Debug, Default, Clone)] +pub struct History { + // Sorted from newest to oldest. + pub commits: IndexMap, + pub by_page: HashMap, +} + +#[derive(Debug, Clone)] +pub struct Commit { + pub summary: String, + pub body: String, +} + +#[derive(Debug, Clone, Default)] +pub struct PageHistory { + // Sorted from newest to oldest, so revision 0 is the current version. + // On the website these are sorted differently: 1 is the oldest revision, succeeding numbers are later revisions. + pub revisions: Vec, +} + +#[derive(Debug, Clone)] +pub struct Revision { + pub commit_oid: git2::Oid, + pub blob_oid: git2::Oid, +} + +impl History { + pub fn get(git: &git2::Repository) -> anyhow::Result { + debug!("reading git history"); + + let mut history = History::default(); + + let mut revwalk = git.revwalk()?; + revwalk.push_head()?; + + for commit_oid in revwalk { + let commit_oid = commit_oid?; + let commit = git.find_commit(commit_oid)?; + history.commits.insert( + commit_oid, + Commit { + summary: String::from_utf8_lossy(commit.summary_bytes().unwrap_or(&[])) + .into_owned(), + body: String::from_utf8_lossy(commit.body_bytes().unwrap_or(&[])).into_owned(), + }, + ); + + let tree = commit.tree()?; + tree.walk(git2::TreeWalkMode::PreOrder, |parent_path, entry| { + if parent_path.is_empty() && entry.name() != Some("content") { + // This is content-only history, so skip all directories that don't contain content. + git2::TreeWalkResult::Skip + } else if entry.kind() == Some(git2::ObjectType::Blob) + && entry.name().is_some_and(|name| name.ends_with(".tree")) + { + let path = format!( + "{parent_path}{}", + String::from_utf8_lossy(entry.name_bytes()) + ); + let page_history = history.by_page.entry(path).or_default(); + + let unchanged = page_history + .revisions + .last() + .is_some_and(|rev| rev.blob_oid == entry.id()); + if unchanged { + // Note again that the history is reversed as we're walking from HEAD + // backwards, so we need to find the _earliest_ commit with this revision. + // Therefore we update that current revision's commit oid with the + // current commit. + page_history.revisions.last_mut().unwrap().commit_oid = commit_oid; + } else { + page_history.revisions.push(Revision { + commit_oid, + blob_oid: entry.id(), + }); + } + git2::TreeWalkResult::Ok + } else { + git2::TreeWalkResult::Ok + } + })?; + } + + Ok(history) + } + + pub fn read_revision( + &self, + git: &git2::Repository, + revision: &Revision, + ) -> anyhow::Result> { + Ok(git.find_blob(revision.blob_oid)?.content().to_owned()) + } +} + +impl Revision { + pub fn commit_short(&self) -> String { + self.commit_oid.to_string()[0..6].to_owned() + } +} diff --git a/crates/treehouse/src/html/djot.rs b/crates/treehouse/src/html/djot.rs index 0255670..d763127 100644 --- a/crates/treehouse/src/html/djot.rs +++ b/crates/treehouse/src/html/djot.rs @@ -33,7 +33,12 @@ pub struct Renderer<'a> { } impl<'a> Renderer<'a> { - pub fn render(self, events: &[(Event, Range)], out: &mut String) { + #[must_use] + pub fn render( + self, + events: &[(Event, Range)], + out: &mut String, + ) -> Vec> { let mut writer = Writer { renderer: self, raw: Raw::None, @@ -42,6 +47,7 @@ impl<'a> Renderer<'a> { list_tightness: vec![], not_first_line: false, ignore_next_event: false, + diagnostics: vec![], }; for (event, range) in events { @@ -49,6 +55,8 @@ impl<'a> Renderer<'a> { .render_event(event, range.clone(), out) .expect("formatting event into string should not fail"); } + + writer.diagnostics } } @@ -85,6 +93,8 @@ struct Writer<'a> { list_tightness: Vec, not_first_line: bool, ignore_next_event: bool, + + diagnostics: Vec>, } impl<'a> Writer<'a> { @@ -95,7 +105,7 @@ impl<'a> Writer<'a> { out: &mut String, ) -> std::fmt::Result { if let Event::Start(Container::Footnote { label: _ }, ..) = e { - self.renderer.treehouse.diagnostics.push(Diagnostic { + self.diagnostics.push(Diagnostic { severity: Severity::Error, code: Some("djot".into()), message: "Djot footnotes are not supported".into(), @@ -523,7 +533,7 @@ impl<'a> Writer<'a> { Raw::Other => {} }, Event::FootnoteReference(_label) => { - self.renderer.treehouse.diagnostics.push(Diagnostic { + self.diagnostics.push(Diagnostic { severity: Severity::Error, code: Some("djot".into()), message: "Djot footnotes are unsupported".into(), diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs index 943b0d9..5d1c612 100644 --- a/crates/treehouse/src/html/tree.rs +++ b/crates/treehouse/src/html/tree.rs @@ -1,6 +1,5 @@ use std::{borrow::Cow, fmt::Write}; -use jotdown::Render; use pulldown_cmark::{BrokenLink, LinkType}; use treehouse_format::pull::BranchKind; @@ -183,7 +182,7 @@ pub fn branch_to_html( let events: Vec<_> = jotdown::Parser::new(&final_markup) .into_offset_iter() .collect(); - djot::Renderer { + let render_diagnostics = djot::Renderer { page_id: treehouse .tree_path(file_id) .expect(".tree file expected") @@ -226,7 +225,7 @@ pub fn branch_to_html( write!( s, "", - EscapeAttribute(&branch.attributes.id) + EscapeAttribute(&branch.named_id) ) .unwrap(); } diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs index aeaff03..f38a216 100644 --- a/crates/treehouse/src/main.rs +++ b/crates/treehouse/src/main.rs @@ -7,13 +7,14 @@ use cli::{ wc::wc_cli, Command, Paths, ProgramArgs, }; -use generate::regenerate_or_report_error; +use generate::{regenerate_or_report_error, LatestRevision}; use log::{error, info, warn}; mod cli; mod config; mod fun; mod generate; +mod history; mod html; mod import_map; mod include_static; @@ -40,16 +41,24 @@ async fn fallible_main() -> anyhow::Result<()> { }; match args.command { - Command::Generate(_generate_args) => { + Command::Generate(generate_args) => { info!("regenerating using directories: {paths:#?}"); - regenerate_or_report_error(&paths)?; + let latest_revision = match generate_args.commits_only { + true => LatestRevision::LatestCommit, + false => LatestRevision::WorkingTree, + }; + regenerate_or_report_error(&paths, latest_revision)?; warn!("`generate` is for debugging only and the files cannot be fully served using a static file server; use `treehouse serve` if you wish to start a treehouse server"); } Command::Serve { - generate: _, + generate: generate_args, serve: serve_args, } => { - let (config, treehouse) = regenerate_or_report_error(&paths)?; + let latest_revision = match generate_args.commits_only { + true => LatestRevision::LatestCommit, + false => LatestRevision::WorkingTree, + }; + let (config, treehouse) = regenerate_or_report_error(&paths, latest_revision)?; serve(config, treehouse, &paths, serve_args.port).await?; } diff --git a/crates/treehouse/src/parse.rs b/crates/treehouse/src/parse.rs index dc7f090..2680a08 100644 --- a/crates/treehouse/src/parse.rs +++ b/crates/treehouse/src/parse.rs @@ -10,10 +10,10 @@ pub struct ErrorsEmitted; pub fn parse_tree_with_diagnostics( treehouse: &mut Treehouse, file_id: FileId, -) -> Result { +) -> Result>> { let input = &treehouse.source(file_id).input(); Roots::parse(&mut treehouse_format::pull::Parser { input, position: 0 }).map_err(|error| { - treehouse.diagnostics.push(Diagnostic { + vec![Diagnostic { severity: Severity::Error, code: Some("tree".into()), message: error.kind.to_string(), @@ -24,8 +24,7 @@ pub fn parse_tree_with_diagnostics( message: String::new(), }], notes: vec![], - }); - ErrorsEmitted + }] }) } @@ -33,17 +32,14 @@ pub fn parse_toml_with_diagnostics( treehouse: &mut Treehouse, file_id: FileId, range: Range, -) -> Result { +) -> Result>> { let input = &treehouse.source(file_id).input()[range.clone()]; toml_edit::Document::from_str(input).map_err(|error| { - treehouse - .diagnostics - .push(toml_error_to_diagnostic(TomlError { - message: error.message().to_owned(), - span: error.span(), - file_id, - input_range: range.clone(), - })); - ErrorsEmitted + vec![toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: range.clone(), + })] }) } diff --git a/crates/treehouse/src/state.rs b/crates/treehouse/src/state.rs index fb1e555..3278e98 100644 --- a/crates/treehouse/src/state.rs +++ b/crates/treehouse/src/state.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, ops::Range}; +use std::{collections::HashMap, ops::Range, path::PathBuf}; use anyhow::Context; use codespan_reporting::{ @@ -6,13 +6,27 @@ use codespan_reporting::{ files::SimpleFiles, term::termcolor::{ColorChoice, StandardStream}, }; +use serde::Serialize; use ulid::Ulid; use crate::tree::{SemaBranchId, SemaRoots, SemaTree}; +#[derive(Debug, Clone, Serialize)] +pub struct RevisionInfo { + pub is_latest: bool, + pub number: usize, + pub commit: String, + pub commit_short: String, +} + #[derive(Debug, Clone)] pub enum Source { - Tree { input: String, tree_path: String }, + Tree { + input: String, + tree_path: String, + target_path: PathBuf, + revision_info: RevisionInfo, + }, Other(String), } @@ -37,7 +51,6 @@ pub type FileId = >::FileId; /// Treehouse compilation context. pub struct Treehouse { pub files: Files, - pub diagnostics: Vec>, pub tree: SemaTree, pub branches_by_named_id: HashMap, @@ -52,7 +65,6 @@ impl Treehouse { pub fn new() -> Self { Self { files: Files::new(), - diagnostics: vec![], tree: SemaTree::default(), branches_by_named_id: HashMap::new(), @@ -91,15 +103,11 @@ impl Treehouse { } } - pub fn report_diagnostics(&self) -> anyhow::Result<()> { - let writer = StandardStream::stderr(ColorChoice::Auto); - let config = codespan_reporting::term::Config::default(); - for diagnostic in &self.diagnostics { - codespan_reporting::term::emit(&mut writer.lock(), &config, &self.files, diagnostic) - .context("could not emit diagnostic")?; + pub fn revision_info(&self, file_id: FileId) -> Option<&RevisionInfo> { + match self.source(file_id) { + Source::Tree { revision_info, .. } => Some(revision_info), + Source::Other(_) => None, } - - Ok(()) } pub fn next_missingno(&mut self) -> Ulid { @@ -107,12 +115,6 @@ impl Treehouse { .generate() .expect("just how much disk space do you have?") } - - pub fn has_errors(&self) -> bool { - self.diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error) - } } pub struct TomlError { @@ -140,3 +142,18 @@ pub fn toml_error_to_diagnostic(error: TomlError) -> Diagnostic { notes: vec![], } } + +pub fn report_diagnostics(files: &Files, diagnostics: &[Diagnostic]) -> anyhow::Result<()> { + let writer = StandardStream::stderr(ColorChoice::Auto); + let config = codespan_reporting::term::Config::default(); + for diagnostic in diagnostics { + codespan_reporting::term::emit(&mut writer.lock(), &config, files, diagnostic) + .context("could not emit diagnostic")?; + } + + Ok(()) +} + +pub fn has_errors(diagnostics: &[Diagnostic]) -> bool { + diagnostics.iter().any(|d| d.severity == Severity::Error) +} diff --git a/crates/treehouse/src/static_urls.rs b/crates/treehouse/src/static_urls.rs index 2828eaf..967e787 100644 --- a/crates/treehouse/src/static_urls.rs +++ b/crates/treehouse/src/static_urls.rs @@ -3,7 +3,7 @@ use std::{ fs::File, io::{self, BufReader}, path::PathBuf, - sync::RwLock, + sync::{Mutex, RwLock}, }; use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson}; @@ -18,6 +18,11 @@ pub struct StaticUrls { // and required you to clone it over to different threads. // Stuff like this is why I really want to implement my own templating engine... hash_cache: RwLock>, + missing_files: Mutex>, +} + +pub struct MissingFile { + pub path: String, } impl StaticUrls { @@ -26,6 +31,7 @@ impl StaticUrls { base_dir, base_url, hash_cache: RwLock::new(HashMap::new()), + missing_files: Mutex::new(vec![]), } } @@ -53,6 +59,10 @@ impl StaticUrls { } Ok(hash) } + + pub fn take_missing_files(&self) -> Vec { + std::mem::take(&mut self.missing_files.lock().unwrap()) + } } impl HelperDef for StaticUrls { @@ -65,9 +75,12 @@ impl HelperDef for StaticUrls { ) -> Result, RenderError> { if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) { return Ok(ScopedJson::Derived(Value::String( - self.get(param).map_err(|error| { - RenderError::new(format!("cannot get asset url for {param}: {error}")) - })?, + self.get(param).unwrap_or_else(|_| { + self.missing_files.lock().unwrap().push(MissingFile { + path: param.to_owned(), + }); + format!("{}/{}", self.base_url, param) + }), ))); } diff --git a/crates/treehouse/src/tree.rs b/crates/treehouse/src/tree.rs index d80280f..6c8dad7 100644 --- a/crates/treehouse/src/tree.rs +++ b/crates/treehouse/src/tree.rs @@ -46,22 +46,24 @@ pub struct SemaRoots { impl SemaRoots { pub fn from_roots( treehouse: &mut Treehouse, + diagnostics: &mut Vec>, config: &Config, file_id: FileId, roots: Roots, ) -> Self { Self { - attributes: Self::parse_attributes(treehouse, config, file_id, &roots), + attributes: Self::parse_attributes(treehouse, diagnostics, config, file_id, &roots), branches: roots .branches .into_iter() - .map(|branch| SemaBranch::from_branch(treehouse, file_id, branch)) + .map(|branch| SemaBranch::from_branch(treehouse, diagnostics, file_id, branch)) .collect(), } } fn parse_attributes( treehouse: &mut Treehouse, + diagnostics: &mut Vec>, config: &Config, file_id: FileId, roots: &Roots, @@ -72,14 +74,12 @@ impl SemaRoots { let mut attributes = if let Some(attributes) = &roots.attributes { toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else( |error| { - treehouse - .diagnostics - .push(toml_error_to_diagnostic(TomlError { - message: error.message().to_owned(), - span: error.span(), - file_id, - input_range: attributes.data.clone(), - })); + diagnostics.push(toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: attributes.data.clone(), + })); successfully_parsed = false; RootAttributes::default() }, @@ -98,7 +98,7 @@ impl SemaRoots { if let Some(thumbnail) = &attributes.thumbnail { if thumbnail.alt.is_none() { - treehouse.diagnostics.push(Diagnostic { + diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("sema".into()), message: "thumbnail without alt text".into(), @@ -116,7 +116,7 @@ impl SemaRoots { } if !config.pics.contains_key(&thumbnail.id) { - treehouse.diagnostics.push(Diagnostic { + diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("sema".into()), message: format!( @@ -149,20 +149,30 @@ pub struct SemaBranch { pub content: Range, pub html_id: String, + pub named_id: String, pub attributes: Attributes, pub children: Vec, } impl SemaBranch { - pub fn from_branch(treehouse: &mut Treehouse, file_id: FileId, branch: Branch) -> SemaBranchId { - let attributes = Self::parse_attributes(treehouse, file_id, &branch); + pub fn from_branch( + treehouse: &mut Treehouse, + diagnostics: &mut Vec>, + file_id: FileId, + branch: Branch, + ) -> SemaBranchId { + let attributes = Self::parse_attributes(treehouse, diagnostics, file_id, &branch); - let named_id = attributes.id.clone(); + let revision_info = treehouse + .revision_info(file_id) + .expect(".tree files must have Tree-type sources"); + let named_id = match revision_info.is_latest { + true => attributes.id.to_owned(), + false => format!("{}@{}", attributes.id, revision_info.commit_short), + }; let html_id = format!( "{}:{}", - treehouse - .tree_path(file_id) - .expect("file should have a tree path"), + treehouse.tree_path(file_id).unwrap(), attributes.id ); @@ -175,11 +185,12 @@ impl SemaBranch { kind_span: branch.kind_span, content: branch.content, html_id, + named_id: named_id.clone(), attributes, children: branch .children .into_iter() - .map(|child| Self::from_branch(treehouse, file_id, child)) + .map(|child| Self::from_branch(treehouse, diagnostics, file_id, child)) .collect(), }; let new_branch_id = treehouse.tree.add_branch(branch); @@ -191,7 +202,7 @@ impl SemaBranch { let new_branch = treehouse.tree.branch(new_branch_id); let old_branch = treehouse.tree.branch(old_branch_id); - treehouse.diagnostics.push( + diagnostics.push( Diagnostic::warning() .with_code("sema") .with_message(format!("two branches share the same id `{}`", named_id)) @@ -220,7 +231,7 @@ impl SemaBranch { let new_branch = treehouse.tree.branch(new_branch_id); let old_branch = treehouse.tree.branch(old_branch_id); - treehouse.diagnostics.push( + diagnostics.push( Diagnostic::warning() .with_code("sema") .with_message(format!( @@ -247,21 +258,24 @@ impl SemaBranch { new_branch_id } - fn parse_attributes(treehouse: &mut Treehouse, file_id: FileId, branch: &Branch) -> Attributes { + fn parse_attributes( + treehouse: &mut Treehouse, + diagnostics: &mut Vec>, + file_id: FileId, + branch: &Branch, + ) -> Attributes { let source = treehouse.source(file_id); let mut successfully_parsed = true; let mut attributes = if let Some(attributes) = &branch.attributes { toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else( |error| { - treehouse - .diagnostics - .push(toml_error_to_diagnostic(TomlError { - message: error.message().to_owned(), - span: error.span(), - file_id, - input_range: attributes.data.clone(), - })); + diagnostics.push(toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: attributes.data.clone(), + })); successfully_parsed = false; Attributes::default() }, @@ -282,7 +296,7 @@ impl SemaBranch { // Check that every block has an ID. if attributes.id.is_empty() { attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); - treehouse.diagnostics.push(Diagnostic { + diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("attr".into()), message: "branch does not have an `id` attribute".into(), @@ -305,7 +319,7 @@ impl SemaBranch { // Check that link-type blocks are `+`-type to facilitate lazy loading. if let Content::Link(_) = &attributes.content { if branch.kind == BranchKind::Expanded { - treehouse.diagnostics.push(Diagnostic { + diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("attr".into()), message: "`content.link` branch is expanded by default".into(), diff --git a/scripts/mkicon.fish b/scripts/mkicon.fish new file mode 100755 index 0000000..5fbca61 --- /dev/null +++ b/scripts/mkicon.fish @@ -0,0 +1,7 @@ +#!/usr/bin/env fish + +set filename $argv[1] +set icon_name (basename $filename .svg) +set icon_base64 (svgcleaner --stdout $filename 2>/dev/null | base64 -w0) + +printf "--icon-%s: url('data:image/svg+xml;base64,%s');" "$icon_name" "$icon_base64" diff --git a/static/css/history.css b/static/css/history.css new file mode 100644 index 0000000..ec57a2b --- /dev/null +++ b/static/css/history.css @@ -0,0 +1,27 @@ +.version-history { + + &>ul.commits { + --recursive-mono: 1; + + list-style: none; + padding-left: 0; + + &>li { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + + display: grid; + grid-template-columns: 4em min-content auto; + align-items: start; + gap: 0.5em; + + &>.revision-number { + justify-self: end; + } + + details>summary { + cursor: pointer; + } + } + } +} diff --git a/static/css/icons.css b/static/css/icons.css new file mode 100644 index 0000000..ae2e6e1 --- /dev/null +++ b/static/css/icons.css @@ -0,0 +1,24 @@ +:root { + --icon-breadcrumb: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTYgMTIgNC00LTQtNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNTU0MjNlIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4='); + --icon-expand: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNyA1di0zaC0ydjNoLTN2MmgzdjNoMnYtM2gzdi0yeiIgZmlsbD0iIzU1NDIzZSIgZmlsbC1vcGFjaXR5PSIuNSIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9zdmc+'); + --icon-leaf: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNiIgY3k9IjYiIGZpbGw9IiM1NTQyM2UiIGZpbGwtb3BhY2l0eT0iLjUiIHI9IjIiLz48L3N2Zz4='); + --icon-collapse: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTMgNmg2IiBzdHJva2U9IiM1NTQyM2UiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); + --icon-more: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQgNiA0IDQgNC00IiBmaWxsPSJub25lIiBzdHJva2U9IiM1NTQyM2UiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); + --icon-permalink: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTcuNjU2ODYgMiAxLjQxNDIxIDEuNDE0MjJjLjY4MDUxLjY4MDUxIDEuMDY0NTMgMS41NDUyMSAxLjE1MjEzIDIuNDMzNjIuODg4NC4wODc2IDEuNzUzMS40NzE2NSAyLjQzMzcgMS4xNTIxNmwxLjQxNDIgMS40MTQyMmMxLjU2MjEgMS41NjIwOSAxLjU2MjEgNC4wOTQ3OCAwIDUuNjU2ODhzLTQuMDk0NzkgMS41NjIxLTUuNjU2ODggMGwtMS40MTQyMi0xLjQxNDJjLS42ODA1MS0uNjgwNi0xLjA2NDU2LTEuNTQ1My0xLjE1MjE2LTIuNDMzNy0uODg4NDEtLjA4NzYtMS43NTMxMS0uNDcxNjItMi40MzM2Mi0xLjE1MjEzbC0xLjQxNDIyLTEuNDE0MjFjLTEuNTYyMDk0LTEuNTYyMS0xLjU2MjA5NC00LjA5NDc2IDAtNS42NTY4NiAxLjU2MjEtMS41NjIwOTQgNC4wOTQ3Ni0xLjU2MjA5NCA1LjY1Njg2IDB6bS42MTggNy42ODkwN2MtLjE0NDMuMDg1MjItLjI5MjgxLjE2MDYxLS40NDQ1NS4yMjYxNi4wMjA4My40ODI1Ny4yMTU0Ni45NTg5Ny41ODM5MSAxLjMyNzM3bDEuNDE0MjEgMS40MTQzYy43ODEwNy43ODEgMi4wNDczNy43ODEgMi44Mjg0NyAwIC43ODEtLjc4MTEuNzgxLTIuMDQ3NCAwLTIuODI4NDdsLTEuNDE0My0xLjQxNDIxYy0uMzY4NC0uMzY4NDUtLjg0NDgtLjU2MzA4LTEuMzI3MzctLjU4MzkxLS4wNjU1NS4xNTE3My0uMTQwOTMuMzAwMjQtLjIyNjE2LjQ0NDU0bDEuODQ2NDMgMS44NDY0NS0xLjQxNDIgMS40MTQyem0tLjYxOC00Ljg2MDY0Yy4zNjg0NC4zNjg0NS41NjMwOC44NDQ4OC41ODM5MSAxLjMyNzQyLS4xNTE3NC4wNjU1NC0uMzAwMjQuMTQwOTMtLjQ0NDU0LjIyNjE1bC0xLjkxNzU0LTEuOTE3NTMtMS40MTQyMSAxLjQxNDIxIDEuOTE3NTMgMS45MTc1M2MtLjA4NTIzLjE0NDMxLS4xNjA2MS4yOTI4Mi0uMjI2MTYuNDQ0NTYtLjQ4MjU0LS4wMjA4My0uOTU4OTctLjIxNTQ3LTEuMzI3NDItLjU4MzkxbC0xLjQxNDIxLTEuNDE0MjJjLS43ODEwNS0uNzgxMDUtLjc4MTA1LTIuMDQ3MzcgMC0yLjgyODQyczIuMDQ3MzctLjc4MTA1IDIuODI4NDIgMHoiIGZpbGw9IiM1NTQyM2UiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=="); + --icon-go: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTEwLjU4NTggNy0yLjI5Mjg5LTIuMjkyODkgMS40MTQyMS0xLjQxNDIyIDQuNzA3MDggNC43MDcxMS00LjcwNzA4IDQuNzA3MS0xLjQxNDIxLTEuNDE0MiAyLjI5Mjg5LTIuMjkyOWgtNy41ODU4di0yeiIgZmlsbD0iIzU1NDIzZSIvPjwvc3ZnPg=="); + --icon-history: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHdpZHRoPSIxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im0xMi4yNDI2IDMuNzU3MzZjLTEuMDg1Ny0xLjA4NTc5LTIuNTg1NzUtMS43NTczNi00LjI0MjYtMS43NTczNi0zLjMxMzcxIDAtNiAyLjY4NjI5LTYgNiAwIDMuMzEzNyAyLjY4NjI5IDYgNiA2IDMuMzEzNyAwIDYtMi42ODYzIDYtNmgybC0zLTMtMyAzaDJjMCAyLjIwOTEtMS43OTA5IDQtNCA0LTIuMjA5MTQgMC00LTEuNzkwOS00LTQgMC0yLjIwOTE0IDEuNzkwODYtNCA0LTQgMS4xMDQ1NyAwIDIuMTA0Ni40NDc3MiAyLjgyODQgMS4xNzE1N3ptLTUuMjQyNTkgMS4yNDI2NHYyLjU4NTc5bC0xLjIwNzEgMS4yMDcxIDEuNDE0MjEgMS40MTQyMSAxLjc5Mjg5LTEuNzkyODl2LTMuNDE0MjF6IiBmaWxsPSIjNTU0MjNlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); +} + +@media (prefers-color-scheme: dark) { + :root { + --icon-breadcrumb: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTYgMTIgNC00LTQtNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDdjZGJmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4='); + --icon-expand: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNyA1di0zaC0ydjNoLTN2MmgzdjNoMnYtM2gzdi0yeiIgZmlsbD0iI2Q3Y2RiZiIgZmlsbC1vcGFjaXR5PSIuNSIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9zdmc+'); + --icon-leaf: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNiIgY3k9IjYiIGZpbGw9IiNkN2NkYmYiIGZpbGwtb3BhY2l0eT0iLjUiIHI9IjIiLz48L3N2Zz4='); + --icon-collapse: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTMgNmg2IiBzdHJva2U9IiNkN2NkYmYiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); + --icon-permalink: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTcuNjU2ODYgMiAxLjQxNDIxIDEuNDE0MjJjLjY4MDUxLjY4MDUxIDEuMDY0NTMgMS41NDUyMSAxLjE1MjEzIDIuNDMzNjIuODg4NC4wODc2IDEuNzUzMS40NzE2NSAyLjQzMzcgMS4xNTIxNmwxLjQxNDIgMS40MTQyMmMxLjU2MjEgMS41NjIwOSAxLjU2MjEgNC4wOTQ3OCAwIDUuNjU2ODhzLTQuMDk0NzkgMS41NjIxLTUuNjU2ODggMGwtMS40MTQyMi0xLjQxNDJjLS42ODA1MS0uNjgwNi0xLjA2NDU2LTEuNTQ1My0xLjE1MjE2LTIuNDMzNy0uODg4NDEtLjA4NzYtMS43NTMxMS0uNDcxNjItMi40MzM2Mi0xLjE1MjEzbC0xLjQxNDIyLTEuNDE0MjFjLTEuNTYyMDk0LTEuNTYyMS0xLjU2MjA5NC00LjA5NDc2IDAtNS42NTY4NiAxLjU2MjEtMS41NjIwOTQgNC4wOTQ3Ni0xLjU2MjA5NCA1LjY1Njg2IDB6bS42MTggNy42ODkwN2MtLjE0NDMuMDg1MjItLjI5MjgxLjE2MDYxLS40NDQ1NS4yMjYxNi4wMjA4My40ODI1Ny4yMTU0Ni45NTg5Ny41ODM5MSAxLjMyNzM3bDEuNDE0MjEgMS40MTQzYy43ODEwNy43ODEgMi4wNDczNy43ODEgMi44Mjg0NyAwIC43ODEtLjc4MTEuNzgxLTIuMDQ3NCAwLTIuODI4NDdsLTEuNDE0My0xLjQxNDIxYy0uMzY4NC0uMzY4NDUtLjg0NDgtLjU2MzA4LTEuMzI3MzctLjU4MzkxLS4wNjU1NS4xNTE3My0uMTQwOTMuMzAwMjQtLjIyNjE2LjQ0NDU0bDEuODQ2NDMgMS44NDY0NS0xLjQxNDIgMS40MTQyem0tLjYxOC00Ljg2MDY0Yy4zNjg0NC4zNjg0NS41NjMwOC44NDQ4OC41ODM5MSAxLjMyNzQyLS4xNTE3NC4wNjU1NC0uMzAwMjQuMTQwOTMtLjQ0NDU0LjIyNjE1bC0xLjkxNzU0LTEuOTE3NTMtMS40MTQyMSAxLjQxNDIxIDEuOTE3NTMgMS45MTc1M2MtLjA4NTIzLjE0NDMxLS4xNjA2MS4yOTI4Mi0uMjI2MTYuNDQ0NTYtLjQ4MjU0LS4wMjA4My0uOTU4OTctLjIxNTQ3LTEuMzI3NDItLjU4MzkxbC0xLjQxNDIxLTEuNDE0MjJjLS43ODEwNS0uNzgxMDUtLjc4MTA1LTIuMDQ3MzcgMC0yLjgyODQyczIuMDQ3MzctLjc4MTA1IDIuODI4NDIgMHoiIGZpbGw9IiNkN2NkYmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=="); + --icon-go: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTEwLjU4NTggNy0yLjI5Mjg5LTIuMjkyODkgMS40MTQyMS0xLjQxNDIyIDQuNzA3MDggNC43MDcxMS00LjcwNzA4IDQuNzA3MS0xLjQxNDIxLTEuNDE0MiAyLjI5Mjg5LTIuMjkyOWgtNy41ODU4di0yeiIgZmlsbD0iI2Q3Y2RiZiIvPjwvc3ZnPg=="); + --icon-more: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQgNiA0IDQgNC00IiBmaWxsPSJub25lIiBzdHJva2U9IiNkN2NkYmYiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); + --icon-history: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHdpZHRoPSIxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im0xMi4yNDI2IDMuNzU3MzZjLTEuMDg1Ny0xLjA4NTc5LTIuNTg1NzUtMS43NTczNi00LjI0MjYtMS43NTczNi0zLjMxMzcxIDAtNiAyLjY4NjI5LTYgNiAwIDMuMzEzNyAyLjY4NjI5IDYgNiA2IDMuMzEzNyAwIDYtMi42ODYzIDYtNmgybC0zLTMtMyAzaDJjMCAyLjIwOTEtMS43OTA5IDQtNCA0LTIuMjA5MTQgMC00LTEuNzkwOS00LTQgMC0yLjIwOTE0IDEuNzkwODYtNCA0LTQgMS4xMDQ1NyAwIDIuMTA0Ni40NDc3MiAyLjgyODQgMS4xNzE1N3ptLTUuMjQyNTkgMS4yNDI2NHYyLjU4NTc5bC0xLjIwNzEgMS4yMDcxIDEuNDE0MjEgMS40MTQyMSAxLjc5Mjg5LTEuNzkyODl2LTMuNDE0MjF6IiBmaWxsPSIjZDdjZGJmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); + } +} + diff --git a/static/css/main.css b/static/css/main.css index 7edf499..7d98220 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -538,12 +538,70 @@ span.badge { /* Style the footer */ footer { margin-top: 4rem; + padding-right: 1.75rem; text-align: right; - opacity: 40%; + + display: flex; + flex-direction: row; + + &>section:first-child { + flex-grow: 1; + } + + &>section:last-child { + flex-shrink: 0; + } + + & #version-info { + display: flex; + flex-direction: row; + align-items: center; + justify-content: end; + opacity: 50%; + padding-left: 1.75rem; + transition: var(--transition-duration) opacity; + + & .icon-history { + display: inline-block; + width: 24px; + height: 24px; + padding-right: 1.75rem; + background-image: var(--icon-history); + background-repeat: no-repeat; + background-position: 50% 50%; + } + + &>ul { + display: flex; + flex-direction: row; + list-style: none; + padding-left: 0; + opacity: 0%; + transition: var(--transition-duration) opacity; + } + + &>ul>li:not(:first-child)::before { + content: 'ยท'; + text-decoration: none; + display: inline-block; + padding-left: 0.75em; + padding-right: 0.75em; + } + + & a { + display: inline-block; + color: var(--text-color); + } + + &:hover>ul { + opacity: 100%; + } + } & #footer-icon { color: var(--text-color); padding-right: 1.75rem; + opacity: 40%; } } diff --git a/static/css/tree.css b/static/css/tree.css index ecdf37e..15a4dba 100644 --- a/static/css/tree.css +++ b/static/css/tree.css @@ -1,27 +1,3 @@ -/*** Icons ***/ - -:root { - --icon-breadcrumb: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTYgMTIgNC00LTQtNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNTU0MjNlIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4='); - --icon-expand: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNyA1di0zaC0ydjNoLTN2MmgzdjNoMnYtM2gzdi0yeiIgZmlsbD0iIzU1NDIzZSIgZmlsbC1vcGFjaXR5PSIuNSIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9zdmc+'); - --icon-leaf: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNiIgY3k9IjYiIGZpbGw9IiM1NTQyM2UiIGZpbGwtb3BhY2l0eT0iLjUiIHI9IjIiLz48L3N2Zz4='); - --icon-collapse: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTMgNmg2IiBzdHJva2U9IiM1NTQyM2UiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); - --icon-more: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQgNiA0IDQgNC00IiBmaWxsPSJub25lIiBzdHJva2U9IiM1NTQyM2UiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); - --icon-permalink: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTcuNjU2ODYgMiAxLjQxNDIxIDEuNDE0MjJjLjY4MDUxLjY4MDUxIDEuMDY0NTMgMS41NDUyMSAxLjE1MjEzIDIuNDMzNjIuODg4NC4wODc2IDEuNzUzMS40NzE2NSAyLjQzMzcgMS4xNTIxNmwxLjQxNDIgMS40MTQyMmMxLjU2MjEgMS41NjIwOSAxLjU2MjEgNC4wOTQ3OCAwIDUuNjU2ODhzLTQuMDk0NzkgMS41NjIxLTUuNjU2ODggMGwtMS40MTQyMi0xLjQxNDJjLS42ODA1MS0uNjgwNi0xLjA2NDU2LTEuNTQ1My0xLjE1MjE2LTIuNDMzNy0uODg4NDEtLjA4NzYtMS43NTMxMS0uNDcxNjItMi40MzM2Mi0xLjE1MjEzbC0xLjQxNDIyLTEuNDE0MjFjLTEuNTYyMDk0LTEuNTYyMS0xLjU2MjA5NC00LjA5NDc2IDAtNS42NTY4NiAxLjU2MjEtMS41NjIwOTQgNC4wOTQ3Ni0xLjU2MjA5NCA1LjY1Njg2IDB6bS42MTggNy42ODkwN2MtLjE0NDMuMDg1MjItLjI5MjgxLjE2MDYxLS40NDQ1NS4yMjYxNi4wMjA4My40ODI1Ny4yMTU0Ni45NTg5Ny41ODM5MSAxLjMyNzM3bDEuNDE0MjEgMS40MTQzYy43ODEwNy43ODEgMi4wNDczNy43ODEgMi44Mjg0NyAwIC43ODEtLjc4MTEuNzgxLTIuMDQ3NCAwLTIuODI4NDdsLTEuNDE0My0xLjQxNDIxYy0uMzY4NC0uMzY4NDUtLjg0NDgtLjU2MzA4LTEuMzI3MzctLjU4MzkxLS4wNjU1NS4xNTE3My0uMTQwOTMuMzAwMjQtLjIyNjE2LjQ0NDU0bDEuODQ2NDMgMS44NDY0NS0xLjQxNDIgMS40MTQyem0tLjYxOC00Ljg2MDY0Yy4zNjg0NC4zNjg0NS41NjMwOC44NDQ4OC41ODM5MSAxLjMyNzQyLS4xNTE3NC4wNjU1NC0uMzAwMjQuMTQwOTMtLjQ0NDU0LjIyNjE1bC0xLjkxNzU0LTEuOTE3NTMtMS40MTQyMSAxLjQxNDIxIDEuOTE3NTMgMS45MTc1M2MtLjA4NTIzLjE0NDMxLS4xNjA2MS4yOTI4Mi0uMjI2MTYuNDQ0NTYtLjQ4MjU0LS4wMjA4My0uOTU4OTctLjIxNTQ3LTEuMzI3NDItLjU4MzkxbC0xLjQxNDIxLTEuNDE0MjJjLS43ODEwNS0uNzgxMDUtLjc4MTA1LTIuMDQ3MzcgMC0yLjgyODQyczIuMDQ3MzctLjc4MTA1IDIuODI4NDIgMHoiIGZpbGw9IiM1NTQyM2UiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=="); - --icon-go: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTEwLjU4NTggNy0yLjI5Mjg5LTIuMjkyODkgMS40MTQyMS0xLjQxNDIyIDQuNzA3MDggNC43MDcxMS00LjcwNzA4IDQuNzA3MS0xLjQxNDIxLTEuNDE0MiAyLjI5Mjg5LTIuMjkyOWgtNy41ODU4di0yeiIgZmlsbD0iIzU1NDIzZSIvPjwvc3ZnPg=="); -} - -@media (prefers-color-scheme: dark) { - :root { - --icon-breadcrumb: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTYgMTIgNC00LTQtNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDdjZGJmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4='); - --icon-expand: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNyA1di0zaC0ydjNoLTN2MmgzdjNoMnYtM2gzdi0yeiIgZmlsbD0iI2Q3Y2RiZiIgZmlsbC1vcGFjaXR5PSIuNSIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9zdmc+'); - --icon-leaf: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iNiIgY3k9IjYiIGZpbGw9IiNkN2NkYmYiIGZpbGwtb3BhY2l0eT0iLjUiIHI9IjIiLz48L3N2Zz4='); - --icon-collapse: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEyIiB3aWR0aD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTMgNmg2IiBzdHJva2U9IiNkN2NkYmYiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); - --icon-permalink: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTcuNjU2ODYgMiAxLjQxNDIxIDEuNDE0MjJjLjY4MDUxLjY4MDUxIDEuMDY0NTMgMS41NDUyMSAxLjE1MjEzIDIuNDMzNjIuODg4NC4wODc2IDEuNzUzMS40NzE2NSAyLjQzMzcgMS4xNTIxNmwxLjQxNDIgMS40MTQyMmMxLjU2MjEgMS41NjIwOSAxLjU2MjEgNC4wOTQ3OCAwIDUuNjU2ODhzLTQuMDk0NzkgMS41NjIxLTUuNjU2ODggMGwtMS40MTQyMi0xLjQxNDJjLS42ODA1MS0uNjgwNi0xLjA2NDU2LTEuNTQ1My0xLjE1MjE2LTIuNDMzNy0uODg4NDEtLjA4NzYtMS43NTMxMS0uNDcxNjItMi40MzM2Mi0xLjE1MjEzbC0xLjQxNDIyLTEuNDE0MjFjLTEuNTYyMDk0LTEuNTYyMS0xLjU2MjA5NC00LjA5NDc2IDAtNS42NTY4NiAxLjU2MjEtMS41NjIwOTQgNC4wOTQ3Ni0xLjU2MjA5NCA1LjY1Njg2IDB6bS42MTggNy42ODkwN2MtLjE0NDMuMDg1MjItLjI5MjgxLjE2MDYxLS40NDQ1NS4yMjYxNi4wMjA4My40ODI1Ny4yMTU0Ni45NTg5Ny41ODM5MSAxLjMyNzM3bDEuNDE0MjEgMS40MTQzYy43ODEwNy43ODEgMi4wNDczNy43ODEgMi44Mjg0NyAwIC43ODEtLjc4MTEuNzgxLTIuMDQ3NCAwLTIuODI4NDdsLTEuNDE0My0xLjQxNDIxYy0uMzY4NC0uMzY4NDUtLjg0NDgtLjU2MzA4LTEuMzI3MzctLjU4MzkxLS4wNjU1NS4xNTE3My0uMTQwOTMuMzAwMjQtLjIyNjE2LjQ0NDU0bDEuODQ2NDMgMS44NDY0NS0xLjQxNDIgMS40MTQyem0tLjYxOC00Ljg2MDY0Yy4zNjg0NC4zNjg0NS41NjMwOC44NDQ4OC41ODM5MSAxLjMyNzQyLS4xNTE3NC4wNjU1NC0uMzAwMjQuMTQwOTMtLjQ0NDU0LjIyNjE1bC0xLjkxNzU0LTEuOTE3NTMtMS40MTQyMSAxLjQxNDIxIDEuOTE3NTMgMS45MTc1M2MtLjA4NTIzLjE0NDMxLS4xNjA2MS4yOTI4Mi0uMjI2MTYuNDQ0NTYtLjQ4MjU0LS4wMjA4My0uOTU4OTctLjIxNTQ3LTEuMzI3NDItLjU4MzkxbC0xLjQxNDIxLTEuNDE0MjJjLS43ODEwNS0uNzgxMDUtLjc4MTA1LTIuMDQ3MzcgMC0yLjgyODQyczIuMDQ3MzctLjc4MTA1IDIuODI4NDIgMHoiIGZpbGw9IiNkN2NkYmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=="); - --icon-go: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTEwLjU4NTggNy0yLjI5Mjg5LTIuMjkyODkgMS40MTQyMS0xLjQxNDIyIDQuNzA3MDggNC43MDcxMS00LjcwNzA4IDQuNzA3MS0xLjQxNDIxLTEuNDE0MiAyLjI5Mjg5LTIuMjkyOWgtNy41ODU4di0yeiIgZmlsbD0iI2Q3Y2RiZiIvPjwvc3ZnPg=="); - --icon-more: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB3aWR0aD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQgNiA0IDQgNC00IiBmaWxsPSJub25lIiBzdHJva2U9IiNkN2NkYmYiIHN0cm9rZS1vcGFjaXR5PSIuNSIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+'); - } -} - /*** Variables ***/ :root { diff --git a/static/svg/dark/history.svg b/static/svg/dark/history.svg new file mode 100644 index 0000000..69a5579 --- /dev/null +++ b/static/svg/dark/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/svg/light/history.svg b/static/svg/light/history.svg new file mode 100644 index 0000000..a05146b --- /dev/null +++ b/static/svg/light/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/template/_history.hbs b/template/_history.hbs new file mode 100644 index 0000000..7002892 --- /dev/null +++ b/template/_history.hbs @@ -0,0 +1,42 @@ + + + + + + {{> components/_head.hbs }} + + + + + + {{#> components/_nav.hbs }} + {{/ components/_nav.hbs }} + + {{> components/_noscript.hbs }} + +
+

{{ len page.commits }} commits

+ + +
+ + {{> components/_footer.hbs }} + + + diff --git a/template/components/_footer.hbs b/template/components/_footer.hbs index 475681f..6039fbb 100644 --- a/template/components/_footer.hbs +++ b/template/components/_footer.hbs @@ -1,49 +1,65 @@ diff --git a/template/components/_head.hbs b/template/components/_head.hbs index 066536d..f427868 100644 --- a/template/components/_head.hbs +++ b/template/components/_head.hbs @@ -7,6 +7,7 @@ + {{!-- Import maps currently don't support the src="" attribute. Unless we come up with something diff --git a/treehouse.toml b/treehouse.toml index 4d9d4af..3de84c8 100644 --- a/treehouse.toml +++ b/treehouse.toml @@ -8,6 +8,10 @@ site = "" # TODO djot: Remove once transition is over. markup = "Djot" +# 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`. +commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit" + [user] title = "liquidex's treehouse" author = "liquidex"