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