at de43235
use std::path::Path; use gix::bstr::ByteSlice; use miette::{IntoDiagnostic, Result}; use serde::Serialize; use tera::{Context, Tera}; use crate::config::OutputFormat; use crate::git_ui::{ TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT, TEMPLATE_GIT_TREE_HTML, }; // ── Types ────────────────────────────────────────────────────────────────────── #[derive(Serialize)] struct Crumb { name: String, url: Option<String>, } #[derive(Serialize)] #[serde(rename_all = "lowercase")] enum TreeEntryKind { Tree, Blob, } #[derive(Serialize)] struct TreeEntry { name: String, kind: TreeEntryKind, url: String, } // ── Entry point ──────────────────────────────────────────────────────────────── /// Build the full static tree browser for every revision in `revisions`. #[allow(clippy::too_many_arguments)] pub(crate) fn build_browse_pages( revisions: &[(String, gix::ObjectId)], browse_dir: &Path, repo_path: &Path, theme_path: &Path, project_name: &str, lang: &Option<String>, clone_url: &Option<String>, formats: &[OutputFormat], ) -> Result<()> { use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; let ss = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); let theme = &ts.themes["InspiredGitHub"]; let mut tera = Tera::default(); crate::site::register_format_templates( &mut tera, theme_path, formats, &[ ( "git_tree", TEMPLATE_GIT_TREE_HTML, TEMPLATE_GIT_TREE_GEMTEXT, ), ( "git_blob", TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_BLOB_GEMTEXT, ), ], )?; for (hash, oid) in revisions { let rev_dir = browse_dir.join(hash); std::fs::create_dir_all(&rev_dir).into_diagnostic()?; let repo = gix::open(repo_path).into_diagnostic()?; let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit(); let decoded = commit_obj.decode().into_diagnostic()?; let tree_id = decoded.tree(); walk_tree_dir( repo_path, &repo, tree_id, "", hash, &rev_dir, &tera, project_name, lang, clone_url, &ss, theme, formats, )?; } Ok(()) } // ── Tree walker ──────────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] fn walk_tree_dir( repo_path: &Path, repo: &gix::Repository, tree_id: gix::ObjectId, dir_path: &str, commit_hash: &str, rev_dir: &Path, tera: &Tera, project_name: &str, lang: &Option<String>, clone_url: &Option<String>, ss: &syntect::parsing::SyntaxSet, theme: &syntect::highlighting::Theme, formats: &[OutputFormat], ) -> Result<()> { let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree(); let decoded = tree_obj.decode().into_diagnostic()?; let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count(); let page_dir = if dir_path.is_empty() { rev_dir.to_path_buf() } else { dir_path .split('/') .filter(|s| !s.is_empty()) .fold(rev_dir.to_path_buf(), |p, c| p.join(c)) }; std::fs::create_dir_all(&page_dir).into_diagnostic()?; let mut entries: Vec<TreeEntry> = Vec::new(); let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new(); let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); for entry in decoded.entries.iter() { let name = entry.filename.to_str_lossy().into_owned(); let oid: gix::ObjectId = entry.oid.to_owned(); if entry.mode.is_tree() { entries.push(TreeEntry { url: format!("{name}/index.html"), name: name.clone(), kind: TreeEntryKind::Tree, }); subdirs.push((name, oid)); } else { entries.push(TreeEntry { url: format!("{name}.html"), name: name.clone(), kind: TreeEntryKind::Blob, }); if !entry.mode.is_commit() { blobs.push((name, oid)); } } } entries.sort_by(|a, b| { let a_tree = matches!(a.kind, TreeEntryKind::Tree); let b_tree = matches!(b.kind, TreeEntryKind::Tree); b_tree.cmp(&a_tree).then(a.name.cmp(&b.name)) }); let root_path = "../".repeat(3 + depth); let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); let breadcrumbs = make_crumbs(dir_path, false, None); for format in formats { let suffix = format.extension(); let tmpl_name = format!("git_tree.{suffix}"); let ext = format.extension(); let mut ctx = Context::new(); ctx.insert("project_name", project_name); ctx.insert("lang", lang); ctx.insert("clone_url", clone_url); ctx.insert("commit_hash", commit_hash); ctx.insert("commit_hash_short", &commit_hash[..7]); ctx.insert("commit_url", &commit_url); ctx.insert("dir_path", dir_path); ctx.insert("entries", &entries); ctx.insert("breadcrumbs", &breadcrumbs); ctx.insert("root_path", &root_path); let content = tera .render(&tmpl_name, &ctx) .map_err(|e| miette::miette!("{e}"))?; std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?; } for (name, oid) in subdirs { let child_path = if dir_path.is_empty() { name } else { format!("{dir_path}/{name}") }; walk_tree_dir( repo_path, repo, oid, &child_path, commit_hash, rev_dir, tera, project_name, lang, clone_url, ss, theme, formats, )?; } for (name, oid) in blobs { let file_path = if dir_path.is_empty() { name.clone() } else { format!("{dir_path}/{name}") }; render_blob_page( repo_path, &name, &file_path, oid, commit_hash, depth, &page_dir, tera, project_name, lang, clone_url, ss, theme, formats, )?; } Ok(()) } // ── Blob page ────────────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] fn render_blob_page( repo_path: &Path, filename: &str, file_path: &str, oid: gix::ObjectId, commit_hash: &str, depth: usize, page_dir: &Path, tera: &Tera, project_name: &str, lang: &Option<String>, clone_url: &Option<String>, ss: &syntect::parsing::SyntaxSet, theme: &syntect::highlighting::Theme, formats: &[OutputFormat], ) -> Result<()> { const MAX_BLOB_BYTES: usize = 1024 * 1024; let data: Vec<u8> = std::process::Command::new("git") .current_dir(repo_path) .args(["cat-file", "blob", &oid.to_string()]) .output() .map(|o| o.stdout) .unwrap_or_default(); let is_binary = data[..data.len().min(8192)].contains(&0u8); let too_large = data.len() > MAX_BLOB_BYTES; let text = String::from_utf8_lossy(&data); let content_plain: Option<String> = if is_binary || too_large || data.is_empty() { None } else { Some(text.to_string()) }; let content_html: Option<String> = if is_binary || too_large || data.is_empty() { None } else { let ext = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); let syntax = ss .find_syntax_by_extension(ext) .or_else(|| { text.lines() .next() .and_then(|l| ss.find_syntax_by_first_line(l)) }) .unwrap_or_else(|| ss.find_syntax_plain_text()); Some( syntect::html::highlighted_html_for_string(&text, ss, syntax, theme) .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))), ) }; let root_path = "../".repeat(3 + depth); let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); let breadcrumbs = make_crumbs( std::path::Path::new(file_path) .parent() .and_then(|p| p.to_str()) .unwrap_or(""), true, Some(filename), ); for format in formats { let suffix = format.extension(); let tmpl_name = format!("git_blob.{suffix}"); let ext = format.extension(); let mut ctx = Context::new(); ctx.insert("project_name", project_name); ctx.insert("lang", lang); ctx.insert("clone_url", clone_url); ctx.insert("commit_hash", commit_hash); ctx.insert("commit_hash_short", &commit_hash[..7]); ctx.insert("commit_url", &commit_url); ctx.insert("file_path", file_path); ctx.insert("filename", filename); ctx.insert("breadcrumbs", &breadcrumbs); ctx.insert("content_html", &content_html); ctx.insert("content_plain", &content_plain); ctx.insert("is_binary", &is_binary); ctx.insert("too_large", &too_large); ctx.insert("size", &data.len()); ctx.insert("root_path", &root_path); let content = tera .render(&tmpl_name, &ctx) .map_err(|e| miette::miette!("{e}"))?; std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?; } Ok(()) } // ── Helpers ──────────────────────────────────────────────────────────────────── fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> { let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); let depth = parts.len(); let mut crumbs = Vec::new(); let root_url = if depth == 0 && !is_blob { None } else { Some(format!("{}index.html", "../".repeat(depth))) }; crumbs.push(Crumb { name: "~".to_string(), url: root_url, }); for (i, &part) in parts.iter().enumerate() { let is_last_and_tree = i == depth - 1 && !is_blob; let url = if is_last_and_tree { None } else { let levels_up = depth - i - 1; Some(format!("{}index.html", "../".repeat(levels_up))) }; crumbs.push(Crumb { name: part.to_string(), url, }); } if is_blob { if let Some(name) = filename { crumbs.push(Crumb { name: name.to_string(), url: None, }); } } crumbs } fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") }