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