1use std::path::Path;
2
3use gix::bstr::ByteSlice;
4use miette::{IntoDiagnostic, Result};
5use serde::Serialize;
6use tera::{Context, Tera};
7
8use crate::config::OutputFormat;
9use crate::git_ui::{
10 TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT,
11 TEMPLATE_GIT_TREE_HTML,
12};
13
14#[derive(Serialize)]
17struct Crumb {
18 name: String,
19 url: Option<String>,
20}
21
22#[derive(Serialize)]
23#[serde(rename_all = "lowercase")]
24enum TreeEntryKind {
25 Tree,
26 Blob,
27}
28
29#[derive(Serialize)]
30struct TreeEntry {
31 name: String,
32 kind: TreeEntryKind,
33 url: String,
34 raw_url: Option<String>,
35}
36
37#[allow(clippy::too_many_arguments)]
41pub(crate) fn build_browse_pages(
42 revisions: &[(String, gix::ObjectId)],
43 browse_dir: &Path,
44 repo_path: &Path,
45 theme_path: &Path,
46 project_name: &str,
47 lang: &Option<String>,
48 clone_url: &Option<String>,
49 formats: &[OutputFormat],
50 git_ui_path: &str,
51 prefix_empty: bool,
52 ref_map: &[(&str, &str)],
53) -> Result<()> {
54 use syntect::highlighting::ThemeSet;
55 use syntect::parsing::SyntaxSet;
56
57 let ss = SyntaxSet::load_defaults_newlines();
58 let ts = ThemeSet::load_defaults();
59 let theme = &ts.themes["InspiredGitHub"];
60
61 let mut tera = Tera::default();
62 crate::site::register_format_templates(
63 &mut tera,
64 theme_path,
65 formats,
66 &[
67 (
68 "git_tree",
69 TEMPLATE_GIT_TREE_HTML,
70 TEMPLATE_GIT_TREE_GEMTEXT,
71 ),
72 (
73 "git_blob",
74 TEMPLATE_GIT_BLOB_HTML,
75 TEMPLATE_GIT_BLOB_GEMTEXT,
76 ),
77 ],
78 )?;
79
80 for (hash, oid) in revisions {
81 let rev_dir = browse_dir.join(hash);
82 std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
83
84 let repo = gix::open(repo_path).into_diagnostic()?;
85 let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
86 let decoded = commit_obj.decode().into_diagnostic()?;
87 let tree_id = decoded.tree();
88
89 walk_tree_dir(
90 repo_path,
91 &repo,
92 tree_id,
93 "",
94 hash,
95 &rev_dir,
96 &tera,
97 project_name,
98 lang,
99 clone_url,
100 &ss,
101 theme,
102 formats,
103 git_ui_path,
104 prefix_empty,
105 )?;
106 }
107
108 for (ref_name, commit_hash) in ref_map {
112 let target = browse_dir.join(ref_name);
113 if target.exists() {
114 std::fs::remove_file(&target).ok();
115 std::fs::remove_dir(&target).ok();
116 }
117 let _ = std::os::unix::fs::symlink(commit_hash, &target);
118 }
119
120 Ok(())
121}
122
123#[allow(clippy::too_many_arguments)]
126fn walk_tree_dir(
127 repo_path: &Path,
128 repo: &gix::Repository,
129 tree_id: gix::ObjectId,
130 dir_path: &str,
131 commit_hash: &str,
132 rev_dir: &Path,
133 tera: &Tera,
134 project_name: &str,
135 lang: &Option<String>,
136 clone_url: &Option<String>,
137 ss: &syntect::parsing::SyntaxSet,
138 theme: &syntect::highlighting::Theme,
139 formats: &[OutputFormat],
140 git_ui_path: &str,
141 prefix_empty: bool,
142) -> Result<()> {
143 let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
144 let decoded = tree_obj.decode().into_diagnostic()?;
145
146 let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
147
148 let page_dir = if dir_path.is_empty() {
149 rev_dir.to_path_buf()
150 } else {
151 dir_path
152 .split('/')
153 .filter(|s| !s.is_empty())
154 .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
155 };
156 std::fs::create_dir_all(&page_dir).into_diagnostic()?;
157
158 let mut entries: Vec<TreeEntry> = Vec::new();
159 let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
160 let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
161
162 for entry in decoded.entries.iter() {
163 let name = entry.filename.to_str_lossy().into_owned();
164 let oid: gix::ObjectId = entry.oid.to_owned();
165
166 if entry.mode.is_tree() {
167 entries.push(TreeEntry {
168 url: format!("{name}/index.html"),
169 name: name.clone(),
170 kind: TreeEntryKind::Tree,
171 raw_url: None,
172 });
173 subdirs.push((name, oid));
174 } else {
175 entries.push(TreeEntry {
176 url: format!("{name}.html"),
177 name: name.clone(),
178 kind: TreeEntryKind::Blob,
179 raw_url: Some(name.clone()),
180 });
181 if !entry.mode.is_commit() {
182 blobs.push((name, oid));
183 }
184 }
185 }
186
187 entries.sort_by(|a, b| {
188 let a_tree = matches!(a.kind, TreeEntryKind::Tree);
189 let b_tree = matches!(b.kind, TreeEntryKind::Tree);
190 b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
191 });
192
193 let prefix_depth = if prefix_empty { 0 } else { 1 };
194 let root_path = "../".repeat(prefix_depth + depth + 2);
195 let commit_url = format!(
196 "{}commit/{commit_hash}.html",
197 "../".repeat(prefix_depth + depth + 1)
198 );
199 let breadcrumbs = make_crumbs(dir_path, false, None);
200
201 for format in formats {
202 let suffix = format.extension();
203 let tmpl_name = format!("git_tree.{suffix}");
204 let ext = format.extension();
205
206 let mut ctx = Context::new();
207 ctx.insert("project_name", project_name);
208 ctx.insert("lang", lang);
209 ctx.insert("clone_url", clone_url);
210 ctx.insert("commit_hash", commit_hash);
211 ctx.insert("commit_hash_short", &commit_hash[..7]);
212 ctx.insert("commit_url", &commit_url);
213 ctx.insert("dir_path", dir_path);
214 ctx.insert("entries", &entries);
215 ctx.insert("breadcrumbs", &breadcrumbs);
216 ctx.insert("root_path", &root_path);
217 ctx.insert("git_ui_path", git_ui_path);
218
219 let content = tera
220 .render(&tmpl_name, &ctx)
221 .map_err(|e| miette::miette!("{e}"))?;
222 std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?;
223 }
224
225 for (name, oid) in subdirs {
226 let child_path = if dir_path.is_empty() {
227 name
228 } else {
229 format!("{dir_path}/{name}")
230 };
231 walk_tree_dir(
232 repo_path,
233 repo,
234 oid,
235 &child_path,
236 commit_hash,
237 rev_dir,
238 tera,
239 project_name,
240 lang,
241 clone_url,
242 ss,
243 theme,
244 formats,
245 git_ui_path,
246 prefix_empty,
247 )?;
248 }
249
250 for (name, oid) in blobs {
251 let file_path = if dir_path.is_empty() {
252 name.clone()
253 } else {
254 format!("{dir_path}/{name}")
255 };
256 render_blob_page(
257 repo_path,
258 &name,
259 &file_path,
260 oid,
261 commit_hash,
262 depth,
263 &page_dir,
264 tera,
265 project_name,
266 lang,
267 clone_url,
268 ss,
269 theme,
270 formats,
271 git_ui_path,
272 prefix_empty,
273 )?;
274 }
275
276 Ok(())
277}
278
279#[allow(clippy::too_many_arguments)]
282fn render_blob_page(
283 repo_path: &Path,
284 filename: &str,
285 file_path: &str,
286 oid: gix::ObjectId,
287 commit_hash: &str,
288 depth: usize,
289 page_dir: &Path,
290 tera: &Tera,
291 project_name: &str,
292 lang: &Option<String>,
293 clone_url: &Option<String>,
294 ss: &syntect::parsing::SyntaxSet,
295 theme: &syntect::highlighting::Theme,
296 formats: &[OutputFormat],
297 git_ui_path: &str,
298 prefix_empty: bool,
299) -> Result<()> {
300 const MAX_BLOB_BYTES: usize = 1024 * 1024;
301
302 let data: Vec<u8> = std::process::Command::new("git")
303 .current_dir(repo_path)
304 .args(["cat-file", "blob", &oid.to_string()])
305 .output()
306 .map(|o| o.stdout)
307 .unwrap_or_default();
308
309 let is_binary = data[..data.len().min(8192)].contains(&0u8);
310 let too_large = data.len() > MAX_BLOB_BYTES;
311
312 if !data.is_empty() {
314 std::fs::write(page_dir.join(filename), &data).into_diagnostic()?;
315 }
316
317 let text = String::from_utf8_lossy(&data);
318 let content_plain: Option<String> = if is_binary || too_large || data.is_empty() {
319 None
320 } else {
321 Some(text.to_string())
322 };
323 let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
324 None
325 } else {
326 let ext = std::path::Path::new(filename)
327 .extension()
328 .and_then(|s| s.to_str())
329 .unwrap_or("");
330 let syntax = ss
331 .find_syntax_by_extension(ext)
332 .or_else(|| {
333 text.lines()
334 .next()
335 .and_then(|l| ss.find_syntax_by_first_line(l))
336 })
337 .unwrap_or_else(|| ss.find_syntax_plain_text());
338 Some(
339 syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
340 .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
341 )
342 };
343
344 let prefix_depth = if prefix_empty { 0 } else { 1 };
345 let root_path = "../".repeat(prefix_depth + depth + 2);
346 let commit_url = format!(
347 "{}commit/{commit_hash}.html",
348 "../".repeat(prefix_depth + depth + 1)
349 );
350 let breadcrumbs = make_crumbs(
351 std::path::Path::new(file_path)
352 .parent()
353 .and_then(|p| p.to_str())
354 .unwrap_or(""),
355 true,
356 Some(filename),
357 );
358
359 for format in formats {
360 let suffix = format.extension();
361 let tmpl_name = format!("git_blob.{suffix}");
362 let ext = format.extension();
363
364 let mut ctx = Context::new();
365 ctx.insert("project_name", project_name);
366 ctx.insert("lang", lang);
367 ctx.insert("clone_url", clone_url);
368 ctx.insert("commit_hash", commit_hash);
369 ctx.insert("commit_hash_short", &commit_hash[..7]);
370 ctx.insert("commit_url", &commit_url);
371 ctx.insert("file_path", file_path);
372 ctx.insert("filename", filename);
373 ctx.insert("breadcrumbs", &breadcrumbs);
374 ctx.insert("content_html", &content_html);
375 ctx.insert("content_plain", &content_plain);
376 ctx.insert("is_binary", &is_binary);
377 ctx.insert("too_large", &too_large);
378 ctx.insert("size", &data.len());
379 ctx.insert("root_path", &root_path);
380 ctx.insert("git_ui_path", git_ui_path);
381 ctx.insert("raw_url", filename);
382
383 let content = tera
384 .render(&tmpl_name, &ctx)
385 .map_err(|e| miette::miette!("{e}"))?;
386 std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?;
387 }
388
389 Ok(())
390}
391
392fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
395 let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
396 let depth = parts.len();
397 let mut crumbs = Vec::new();
398
399 let root_url = if depth == 0 && !is_blob {
400 None
401 } else {
402 Some(format!("{}index.html", "../".repeat(depth)))
403 };
404 crumbs.push(Crumb {
405 name: "~".to_string(),
406 url: root_url,
407 });
408
409 for (i, &part) in parts.iter().enumerate() {
410 let is_last_and_tree = i == depth - 1 && !is_blob;
411 let url = if is_last_and_tree {
412 None
413 } else {
414 let levels_up = depth - i - 1;
415 Some(format!("{}index.html", "../".repeat(levels_up)))
416 };
417 crumbs.push(Crumb {
418 name: part.to_string(),
419 url,
420 });
421 }
422
423 if is_blob {
424 if let Some(name) = filename {
425 crumbs.push(Crumb {
426 name: name.to_string(),
427 url: None,
428 });
429 }
430 }
431
432 crumbs
433}
434
435fn escape_html(s: &str) -> String {
436 s.replace('&', "&")
437 .replace('<', "<")
438 .replace('>', ">")
439}