version history MVP

implement basic version history support; there's now an icon in the footer that lets you see the previous versions and their sources
I'm a bit worried about spoilers but honestly it's yet another way to hint yourself at the cool secrets so I don't mind
This commit is contained in:
liquidex 2024-09-28 23:43:05 +02:00
parent 46dee56331
commit c58c07d846
28 changed files with 1066 additions and 330 deletions

110
Cargo.lock generated
View file

@ -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"

View file

@ -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="$!"
}

View file

@ -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"

View file

@ -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 {

View file

@ -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<Diagnostic<FileId>>,
file_id: FileId,
) -> Result<String, parse::ErrorsEmitted> {
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)?;
}
}
}

View file

@ -193,9 +193,11 @@ async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>)
.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<Arc<Server>>)
));
}
Err(e) => {
error!("error while reading file {file_path:?}: {e:?}");
error!("error while reading file {target_path:?}: {e:?}");
}
}
}

View file

@ -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(())
}

View file

@ -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<String, String>,

View file

@ -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<PathBuf>,
#[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<PathBuf>,
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<String>,
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<Commit>,
pub tree_path: String,
}
#[derive(Serialize)]
@ -85,6 +118,13 @@ struct PageTemplateData<'a> {
season: Option<Season>,
}
#[derive(Serialize)]
struct HistoryTemplateData<'a> {
config: &'a Config,
page: HistoryPage,
season: Option<Season>,
}
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<FileId> {
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<usize>,
column: Option<usize>,
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<ParsedTree>)> {
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<ParsedTree>,
) -> 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<Diagnostic<FileId>>,
name: &str,
path: &Path,
) -> anyhow::Result<FileId> {
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<Diagnostic<FileId>>,
file_id: FileId,
line: Option<usize>,
column: Option<usize>,
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<ParsedTree>, Vec<Diagnostic<FileId>>)> {
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<ParsedTree>, Vec<Diagnostic<FileId>>)> {
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<ParsedTree>,
) -> anyhow::Result<Vec<Diagnostic<FileId>>> {
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("<no summary available>")
.to_owned(),
body: self
.history
.commits
.get(&revision.commit_oid)
.map(|c| c.body.as_str())
.unwrap_or("<no body available>")
.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:?}");
}

View file

@ -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<git2::Oid, Commit>,
pub by_page: HashMap<String, PageHistory>,
}
#[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<Revision>,
}
#[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<Self> {
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<Vec<u8>> {
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()
}
}

View file

@ -33,7 +33,12 @@ pub struct Renderer<'a> {
}
impl<'a> Renderer<'a> {
pub fn render(self, events: &[(Event, Range<usize>)], out: &mut String) {
#[must_use]
pub fn render(
self,
events: &[(Event, Range<usize>)],
out: &mut String,
) -> Vec<Diagnostic<FileId>> {
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<bool>,
not_first_line: bool,
ignore_next_event: bool,
diagnostics: Vec<Diagnostic<FileId>>,
}
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(),

View file

@ -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(