zuka
zuka/src/web/pages.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/web/pages.rs
RSpages.rs53.9 KBDownload
1// The individual views.
2//
3// Each page reads through `git::discover`, which is the same code the REST API and
4// the MCP tools use — so the browser cannot show something the API disagrees with,
5// and a fix to a git query lands everywhere at once.
6//
7// The audience is deliberately not developers. The people these pages are for have
8// used a phone, a chat app and a browser, and are meeting "a repository" for the
9// first time — so the front page is the README rendered as a document, the words
10// are Files, History and Branches rather than tree, log and refs, and the one row
11// of tabs never disappears or forgets which branch the reader was on.
12//
13// Every value that reaches the output goes through `html::escape` or `url_escape`.
14
15use super::html::{self, escape, url_escape, LinkBase, Page, Seo};
16use super::{default_short_ref, ok_html, resolve_ref};
17use crate::error::{Error, Result};
18use crate::git::{discover, validate};
19use crate::http::response::Body;
20use crate::http::AppState;
21use crate::store::RepoRecord;
22use hyper::{Response, StatusCode};
23use serde_json::Value;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27pub struct Context {
28 pub record: RepoRecord,
29 /// Canonical URL prefix for every link this page emits.
30 pub base: String,
31 pub state: Arc<AppState>,
32}
33
34impl Context {
35 fn link(&self, suffix: &str) -> String {
36 format!("{}{}", self.base, suffix)
37 }
38
39 fn crumbs(&self, trail: &str) -> String {
40 let mut out = format!(
41 "<a href=\"{}\">{}</a>",
42 self.link(""),
43 escape(&self.record.display_name)
44 );
45 if !trail.is_empty() {
46 out.push_str(&format!("<span class=\"sep\">/</span>{trail}"));
47 }
48 out
49 }
50
51 fn title(&self, extra: &str) -> String {
52 if extra.is_empty() {
53 format!("{}/{}", self.record.account, self.record.display_name)
54 } else {
55 format!(
56 "{} · {}/{}",
57 extra, self.record.account, self.record.display_name
58 )
59 }
60 }
61
62 fn link_base(&self, reference: &str, dir: &str) -> LinkBase {
63 LinkBase {
64 repo: self.base.clone(),
65 reference: reference.to_string(),
66 dir: dir.to_string(),
67 }
68 }
69}
70
71fn overview_seo(ctx: &Context, reference: &str, has_og_image: bool) -> Seo {
72 let description = ctx.record.description.clone().unwrap_or_else(|| {
73 format!(
74 "{} is a repository served by {}.",
75 ctx.record.display_name,
76 crate::brand::NAME
77 )
78 });
79 let short_description = description
80 .split_once('.')
81 .map(|(first, _)| first)
82 .unwrap_or(&description)
83 .trim();
84 let origin = ctx.state.config.public_host();
85 let canonical = format!("{}{}", origin, ctx.base);
86 let image = has_og_image.then(|| {
87 format!(
88 "{}{}/media/{}/assets/og-image.png",
89 origin,
90 ctx.base,
91 url_escape(reference)
92 )
93 });
94 Seo {
95 description: description.clone(),
96 canonical,
97 image,
98 image_alt: Some(format!(
99 "{} — {}",
100 ctx.record.display_name, short_description
101 )),
102 }
103}
104
105fn overview_title(ctx: &Context) -> String {
106 let description = ctx
107 .record
108 .description
109 .as_deref()
110 .unwrap_or("Git repository");
111 let short_description = description
112 .split_once('.')
113 .map(|(first, _)| first)
114 .unwrap_or(description)
115 .trim();
116 format!("{} — {}", ctx.record.display_name, short_description)
117}
118
119/// The tab a page sits under. One of these is always lit, so a reader always
120/// knows where they are — the cheapest cure for getting lost.
121#[derive(Clone, Copy, PartialEq, Eq)]
122pub enum Tab {
123 Overview,
124 Files,
125 History,
126 Branches,
127}
128
129/// Names tried, in order, when looking for a README to render.
130const README_NAMES: &[&str] = &["README.md", "readme.md", "README", "README.markdown"];
131
132/// Files that are content but not code, and would be meaningless as text.
133fn is_probably_binary(bytes: &[u8]) -> bool {
134 // A NUL in the first few KiB is what git itself uses to call a file binary.
135 bytes.iter().take(8000).any(|b| *b == 0)
136}
137
138/// Whether the viewer should render this file as a document rather than as code.
139fn is_markdown(name: &str) -> bool {
140 let lower = name.to_lowercase();
141 lower.ends_with(".md") || lower.ends_with(".markdown")
142}
143
144fn relative_time(then: u64) -> String {
145 let now = crate::account::token::now_secs();
146 let delta = now.saturating_sub(then);
147 match delta {
148 0..=59 => "just now".to_string(),
149 60..=3599 => format!("{} min ago", delta / 60),
150 3600..=86399 => format!("{} hr ago", delta / 3600),
151 86400..=2591999 => format!("{} days ago", delta / 86400),
152 2592000..=31535999 => format!("{} months ago", delta / 2592000),
153 _ => format!("{} years ago", delta / 31536000),
154 }
155}
156
157fn human_size(bytes: u64) -> String {
158 const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
159 let mut value = bytes as f64;
160 let mut unit = 0;
161 while value >= 1024.0 && unit < UNITS.len() - 1 {
162 value /= 1024.0;
163 unit += 1;
164 }
165 if unit == 0 {
166 format!("{bytes} {}", UNITS[0])
167 } else {
168 format!("{value:.1} {}", UNITS[unit])
169 }
170}
171
172fn short_sha(sha: &str) -> &str {
173 sha.get(..8).unwrap_or(sha)
174}
175
176/// An initials disc, the way a chat app shows a contact with no photo.
177///
178/// The colour is a stable function of the name so the same author always gets the
179/// same colour, from a fixed palette of classes — the CSP forbids inline styles,
180/// which is exactly the property that keeps commit messages inert.
181fn avatar(name: &str) -> String {
182 let initials: String = name
183 .split_whitespace()
184 .take(2)
185 .filter_map(|word| word.chars().next())
186 .flat_map(|c| c.to_uppercase())
187 .collect();
188 let initials = if initials.is_empty() {
189 "?".to_string()
190 } else {
191 initials
192 };
193 let mut hash: u32 = 2166136261;
194 for byte in name.bytes() {
195 hash ^= u32::from(byte);
196 hash = hash.wrapping_mul(16777619);
197 }
198 format!(
199 "<span class=\"avatar av{}\">{}</span>",
200 hash % 8,
201 escape(&initials)
202 )
203}
204
205/// The little label in front of a file name: its extension, or a dot when it has
206/// none. A word a beginner can read, where an icon font would be another asset.
207fn file_chip(name: &str) -> String {
208 let extension = name
209 .rsplit_once('.')
210 .map(|(stem, ext)| if stem.is_empty() { "" } else { ext })
211 .unwrap_or("");
212 let label: String = extension.chars().take(4).collect();
213 let label = if label.is_empty() {
214 "·".to_string()
215 } else {
216 label.to_uppercase()
217 };
218 format!("<span class=\"chip\">{}</span>", escape(&label))
219}
220
221/// A change status letter as a word a person can read without a legend.
222fn change_label(status: char) -> (&'static str, &'static str) {
223 match status {
224 'A' => ("delta-add", "Added"),
225 'D' => ("delta-del", "Removed"),
226 'R' => ("delta-mod", "Renamed"),
227 'C' => ("delta-mod", "Copied"),
228 _ => ("delta-mod", "Changed"),
229 }
230}
231
232/// The header block every repository page carries: identity, then the tabs.
233fn repo_header(ctx: &Context, active: Tab, reference: &str) -> String {
234 let mut out = String::new();
235 out.push_str("<div class=\"repo-head\">\n");
236
237 let badge = if ctx.record.visibility.is_public() {
238 "<span class=\"badge public\">public</span>"
239 } else {
240 "<span class=\"badge\">private</span>"
241 };
242 out.push_str(&format!(
243 "<h1 class=\"repo-title\"><span class=\"repo-owner\">{} /</span> {}{}</h1>\n",
244 escape(&ctx.record.account),
245 escape(&ctx.record.display_name),
246 badge
247 ));
248
249 if let Some(description) = &ctx.record.description {
250 out.push_str(&format!(
251 "<p class=\"repo-desc\">{}</p>\n",
252 escape(description)
253 ));
254 }
255
256 // Only shown for public repositories: printing a clone URL that a reader cannot
257 // use without a credential they have not got is an invitation to a failure.
258 if ctx.record.visibility.is_public() {
259 let http = format!(
260 "{}/{}/{}.git",
261 ctx.state.config.public_host(),
262 ctx.record.account,
263 ctx.record.name
264 );
265 out.push_str("<div class=\"clone\">\n");
266 out.push_str("<span class=\"clone-label\">Get a copy:</span>\n");
267 out.push_str(&format!("<code>git clone {}</code>\n", escape(&http)));
268 out.push_str("</div>\n");
269 }
270
271 out.push_str("</div>\n");
272 out.push_str(&tabs(ctx, active, reference));
273 out
274}
275
276/// The persistent tab row. Every tab carries the current ref, so switching between
277/// Files and History never silently resets the reader to the default branch.
278fn tabs(ctx: &Context, active: Tab, reference: &str) -> String {
279 let r = url_escape(reference);
280 let entries: [(Tab, String, &str); 4] = [
281 (Tab::Overview, ctx.link(""), "Overview"),
282 (Tab::Files, format!("{}/tree/{}/", ctx.base, r), "Files"),
283 (
284 Tab::History,
285 format!("{}/commits/{}", ctx.base, r),
286 "History",
287 ),
288 (Tab::Branches, ctx.link("/refs"), "Branches"),
289 ];
290 let mut out = String::from("<nav class=\"tabs\">\n");
291 for (tab, href, label) in entries {
292 let on = if tab == active { " on" } else { "" };
293 out.push_str(&format!(
294 "<a class=\"tab{on}\" href=\"{href}\">{label}</a>\n"
295 ));
296 }
297 out.push_str("</nav>\n");
298 out
299}
300
301/// Where the branch menu should send the reader on each branch.
302enum Keep<'a> {
303 Tree(&'a str),
304 Blob(&'a str),
305 History,
306}
307
308/// The branch menu: a native `<details>` dropdown, because this surface has no
309/// script. Each entry preserves the reader's place — same folder, same file, same
310/// view — on the other branch, rather than dumping them back at the root.
311fn branch_menu(ctx: &Context, refs: &[(String, String)], current: &str, keep: &Keep) -> String {
312 if refs.is_empty() {
313 return String::new();
314 }
315 let mut out = String::from("<details class=\"menu\">\n");
316 out.push_str(&format!(
317 "<summary><span class=\"menu-label\">branch</span>{} ▾</summary>\n",
318 escape(current)
319 ));
320 out.push_str("<div class=\"menu-list\">\n");
321 for (_, short) in refs.iter().take(30) {
322 let suffix = match keep {
323 Keep::Tree(path) => format!("/tree/{}/{}", url_escape(short), url_escape(path)),
324 Keep::Blob(path) => format!("/blob/{}/{}", url_escape(short), url_escape(path)),
325 Keep::History => format!("/commits/{}", url_escape(short)),
326 };
327 let on = if short == current {
328 " class=\"on\""
329 } else {
330 ""
331 };
332 out.push_str(&format!(
333 "<a{on} href=\"{}{}\">{}</a>\n",
334 ctx.base,
335 suffix,
336 escape(short)
337 ));
338 }
339 out.push_str(&format!(
340 "<a class=\"menu-all\" href=\"{}\">All branches →</a>\n",
341 ctx.link("/refs")
342 ));
343 out.push_str("</div>\n</details>\n");
344 out
345}
346
347/// Branch and tag short names, branches first.
348fn ref_list(git_dir: &Path) -> Vec<(String, String)> {
349 let Ok(value) = discover::refs(git_dir, None) else {
350 return Vec::new();
351 };
352 let mut out = Vec::new();
353 for item in value
354 .get("items")
355 .and_then(|i| i.as_array())
356 .into_iter()
357 .flatten()
358 {
359 let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
360 continue;
361 };
362 if let Some(short) = name.strip_prefix("refs/heads/") {
363 out.push((name.to_string(), short.to_string()));
364 }
365 }
366 out
367}
368
369/// "Latest change" strip above a listing: the most recent commit as one line, the
370/// way a chat shows its last message — the fastest possible answer to "is this
371/// thing alive?".
372fn pulse(ctx: &Context, latest: Option<&Value>) -> String {
373 let Some(item) = latest else {
374 return String::new();
375 };
376 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
377 let subject = item
378 .get("message")
379 .and_then(|m| m.as_str())
380 .and_then(|m| m.lines().next())
381 .unwrap_or("");
382 let author = item
383 .get("author_name")
384 .and_then(|a| a.as_str())
385 .unwrap_or("unknown");
386 let at = item
387 .get("authored_at")
388 .and_then(|a| a.as_u64())
389 .unwrap_or(0);
390 format!(
391 "<a class=\"pulse\" href=\"{}/commit/{}\">{}\
392 <span class=\"pulse-subject\">{}</span>\
393 <span class=\"pulse-meta\">{} · {}</span></a>\n",
394 ctx.base,
395 url_escape(sha),
396 avatar(author),
397 escape(subject),
398 escape(author),
399 escape(&relative_time(at))
400 )
401}
402
403fn empty_repo(ctx: &Context, reference: &str) -> Response<Body> {
404 let body = format!(
405 "{}<div class=\"panel\"><div class=\"notice\"><strong>Nothing here yet</strong>\
406 This project is brand new — no files have arrived. After the first \
407 <code>git push</code>, everything shows up here.</div></div>",
408 repo_header(ctx, Tab::Overview, reference)
409 );
410 ok_html(html::document(&Page {
411 title: ctx.title(""),
412 heading: ctx.crumbs(""),
413 wide: false,
414 seo: None,
415 body,
416 }))
417}
418
419// ── pages ───────────────────────────────────────────────────────────────────
420
421/// The front page of a repository: its README, rendered as a document.
422///
423/// This is the Markdoc idea applied to a repository — the README *is* the site,
424/// and the file listing is one tab away rather than the thing a visitor must read
425/// first. A repository without a README falls back to the listing, which is the
426/// only honest page left.
427pub async fn overview(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
428 let reference = default_short_ref(&ctx.record.default_branch).to_string();
429 let dir = git_dir.clone();
430 let wanted = reference.clone();
431 let public = ctx.record.visibility.is_public();
432
433 let found = crate::git::exec::blocking(move || {
434 // An empty repository has no resolvable ref at all, which is a normal state
435 // straight after `repo_create` and must not read as an error.
436 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
437 return Ok::<_, Error>(None);
438 };
439 let readme = find_readme(&dir, &resolved, "");
440 let has_og_image = public
441 && discover::blob(
442 &dir,
443 &resolved,
444 "assets/og-image.png",
445 discover::Limits {
446 max_blob_bytes: 2 * 1024 * 1024,
447 log_walk_budget: 0,
448 },
449 )
450 .is_ok();
451 Ok(Some((readme, has_og_image)))
452 })
453 .await?;
454
455 let readme = match found {
456 None => return Ok(empty_repo(&ctx, &reference)),
457 Some((None, _)) => return tree(ctx, git_dir, reference, "", Tab::Overview).await,
458 Some((Some(readme), has_og_image)) => (readme, has_og_image),
459 };
460
461 // Full-bleed page, and the website starts at the hero: above the document
462 // sits one slim strip — who this is, and the tabs — because a landing page
463 // buried under a stack of repository furniture is a repository page with a
464 // coloured blob in it. The description, clone command and source link move
465 // to a quiet strip after the document, where the reader who wants the
466 // machinery goes looking for it.
467 let ((name, source), has_og_image) = readme;
468 let badge = if ctx.record.visibility.is_public() {
469 "<span class=\"badge public\">public</span>"
470 } else {
471 "<span class=\"badge\">private</span>"
472 };
473 let mut body = String::from("<div class=\"wrap site-head\">\n");
474 body.push_str(&format!(
475 "<span class=\"site-id\"><span class=\"repo-owner\">{} /</span> {}{}</span>\n",
476 escape(&ctx.record.account),
477 escape(&ctx.record.display_name),
478 badge
479 ));
480 body.push_str(&tabs(&ctx, Tab::Overview, &reference));
481 body.push_str("</div>\n");
482
483 body.push_str(&format!(
484 "<article class=\"doc doc-site\">{}</article>\n",
485 html::readme(&source, &ctx.link_base(&reference, ""))
486 ));
487
488 body.push_str("<div class=\"wrap\">\n<div class=\"site-about\">\n");
489 if let Some(description) = &ctx.record.description {
490 body.push_str(&format!("<span>{}</span>\n", escape(description)));
491 }
492 if ctx.record.visibility.is_public() {
493 let http = format!(
494 "{}/{}/{}.git",
495 ctx.state.config.public_host(),
496 ctx.record.account,
497 ctx.record.name
498 );
499 body.push_str(&format!(
500 "<div class=\"clone\"><span class=\"clone-label\">Get a copy:</span>\
501 <code>git clone {}</code></div>\n",
502 escape(&http)
503 ));
504 }
505 body.push_str(&format!(
506 "<span class=\"doc-source\">this page is <a href=\"{}/blob/{}/{}\">{}</a>, \
507 rendered</span>\n",
508 ctx.base,
509 url_escape(&reference),
510 url_escape(&name),
511 escape(&name)
512 ));
513 body.push_str("</div>\n");
514 body.push_str(&format!(
515 "<div class=\"doc-more\">\
516 <a href=\"{base}/tree/{r}/\">Browse the files →</a>\
517 <a href=\"{base}/commits/{r}\">See what changed →</a>\
518 </div></div>\n",
519 base = ctx.base,
520 r = url_escape(&reference)
521 ));
522
523 Ok(ok_html(html::document(&Page {
524 title: overview_title(&ctx),
525 heading: ctx.crumbs(""),
526 wide: true,
527 seo: ctx
528 .record
529 .visibility
530 .is_public()
531 .then(|| overview_seo(&ctx, &reference, has_og_image)),
532 body,
533 })))
534}
535
536pub async fn tree(
537 ctx: Context,
538 git_dir: PathBuf,
539 reference: String,
540 path: &str,
541 active: Tab,
542) -> Result<Response<Body>> {
543 let path = path.trim_end_matches('/').to_string();
544 let dir = git_dir.clone();
545 let wanted = reference.clone();
546 let sub = path.clone();
547 let limits = ctx.state.config.read_limits();
548
549 let rendered = crate::git::exec::blocking(move || {
550 let refs = ref_list(&dir);
551 // An empty repository has no resolvable ref at all, which is a normal state
552 // straight after `repo_create` and must not read as an error.
553 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
554 return Ok::<_, Error>(None);
555 };
556 let listing = discover::tree(
557 &dir,
558 &resolved,
559 if sub.is_empty() { None } else { Some(&sub) },
560 )?;
561 let latest = discover::log(&dir, &resolved, 1, None, limits)
562 .ok()
563 .and_then(|log| log.get("items")?.as_array()?.first().cloned());
564 Ok(Some((refs, listing, latest)))
565 })
566 .await?;
567
568 let Some((refs, listing, latest)) = rendered else {
569 return Ok(empty_repo(&ctx, &reference));
570 };
571
572 let mut body = repo_header(&ctx, active, &reference);
573 body.push_str("<div class=\"toolbar\">\n");
574 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::Tree(&path)));
575 body.push_str(&path_crumbs(&ctx, &reference, &path));
576 body.push_str("</div>\n");
577 if path.is_empty() {
578 body.push_str(&pulse(&ctx, latest.as_ref()));
579 }
580
581 body.push_str("<div class=\"panel\">\n");
582 body.push_str(&format!(
583 "<div class=\"panel-head\"><span class=\"name\">{}</span></div>\n",
584 if path.is_empty() {
585 escape("All files")
586 } else {
587 escape(&path)
588 }
589 ));
590
591 let items = listing
592 .get("items")
593 .and_then(|i| i.as_array())
594 .cloned()
595 .unwrap_or_default();
596
597 if items.is_empty() {
598 body.push_str("<div class=\"empty\">This folder is empty.</div>\n");
599 } else {
600 body.push_str("<table class=\"listing\">\n");
601
602 if !path.is_empty() {
603 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
604 body.push_str(&format!(
605 "<tr><td class=\"name\" colspan=\"2\">\
606 <span class=\"chip dir\">↰</span><a href=\"{}/tree/{}/{}\">back</a></td></tr>\n",
607 ctx.link(""),
608 url_escape(&reference),
609 url_escape(parent)
610 ));
611 }
612
613 // Directories first, then files, each alphabetically — the order a person
614 // expects, which git's own output does not guarantee.
615 let mut rows: Vec<&Value> = items.iter().collect();
616 rows.sort_by_key(|item| {
617 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
618 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
619 (kind != "dir", name.to_lowercase())
620 });
621
622 for item in rows {
623 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
624 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
625 let full = item.get("path").and_then(|p| p.as_str()).unwrap_or(name);
626 let size = item.get("size").and_then(|s| s.as_u64());
627
628 let (verb, chip) = match kind {
629 "dir" => ("tree", "<span class=\"chip dir\">▸</span>".to_string()),
630 "submodule" => ("tree", "<span class=\"chip\">⧉</span>".to_string()),
631 _ => ("blob", file_chip(name)),
632 };
633
634 body.push_str(&format!(
635 "<tr><td class=\"name\">{}<a href=\"{}/{}/{}/{}\">{}</a></td>\
636 <td class=\"size\">{}</td></tr>\n",
637 chip,
638 ctx.link(""),
639 verb,
640 url_escape(&reference),
641 url_escape(full),
642 escape(name),
643 size.filter(|_| kind == "file")
644 .map(human_size)
645 .unwrap_or_default()
646 ));
647 }
648 body.push_str("</table>\n");
649 }
650 body.push_str("</div>\n");
651
652 Ok(ok_html(html::document(&Page {
653 title: ctx.title(if path.is_empty() { "" } else { &path }),
654 heading: ctx.crumbs(&escape(&reference)),
655 wide: false,
656 seo: None,
657 body,
658 })))
659}
660
661fn find_readme(git_dir: &Path, resolved: &str, dir: &str) -> Option<(String, String)> {
662 // Overview renders one README as the repository's page, so a huge one is a
663 // page nobody can load. This is a display cap, not the API's blob limit.
664 let limits = discover::Limits {
665 max_blob_bytes: 1024 * 1024,
666 log_walk_budget: 0,
667 };
668 for name in README_NAMES {
669 let path = if dir.is_empty() {
670 (*name).to_string()
671 } else {
672 format!("{dir}/{name}")
673 };
674 if let Ok(blob) = discover::blob(git_dir, resolved, &path, limits) {
675 if let Ok(text) = String::from_utf8(blob.bytes) {
676 return Some(((*name).to_string(), text));
677 }
678 }
679 }
680 None
681}
682
683fn path_crumbs(ctx: &Context, reference: &str, path: &str) -> String {
684 if path.is_empty() {
685 return String::new();
686 }
687 let mut out = String::from("<div class=\"crumbs\">");
688 out.push_str(&format!(
689 "<a href=\"{}/tree/{}/\">{}</a>",
690 ctx.link(""),
691 url_escape(reference),
692 escape(&ctx.record.display_name)
693 ));
694
695 let mut walked = String::new();
696 let parts: Vec<&str> = path.split('/').collect();
697 for (i, part) in parts.iter().enumerate() {
698 if !walked.is_empty() {
699 walked.push('/');
700 }
701 walked.push_str(part);
702 out.push_str("<span class=\"sep\">/</span>");
703 if i + 1 == parts.len() {
704 out.push_str(&escape(part));
705 } else {
706 out.push_str(&format!(
707 "<a href=\"{}/tree/{}/{}\">{}</a>",
708 ctx.link(""),
709 url_escape(reference),
710 url_escape(&walked),
711 escape(part)
712 ));
713 }
714 }
715 out.push_str("</div>");
716 out
717}
718
719pub async fn blob(
720 ctx: Context,
721 git_dir: PathBuf,
722 reference: String,
723 path: &str,
724 plain: bool,
725) -> Result<Response<Body>> {
726 let dir = git_dir.clone();
727 let wanted = reference.clone();
728 let target = path.to_string();
729 let limits = ctx.state.config.read_limits();
730
731 let (refs, blob) = crate::git::exec::blocking(move || {
732 let refs = ref_list(&dir);
733 let resolved = resolve_ref(&dir, &wanted)?;
734 let blob = discover::blob(&dir, &resolved, &target, limits)?;
735 Ok::<_, Error>((refs, blob))
736 })
737 .await?;
738
739 let filename = path.rsplit('/').next().unwrap_or(path);
740 let rendered_markdown = is_markdown(filename) && !plain;
741
742 let mut body = repo_header(&ctx, Tab::Files, &reference);
743 body.push_str("<div class=\"toolbar\">\n");
744 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::Blob(path)));
745 body.push_str(&path_crumbs(&ctx, &reference, path));
746 body.push_str("</div>\n");
747
748 body.push_str("<div class=\"panel\">\n");
749 body.push_str(&format!(
750 "<div class=\"panel-head\">{}<span class=\"name\">{}</span><span>{}</span>\
751 <span class=\"spacer\"></span>{}\
752 <a href=\"{}/raw/{}/{}\">Download</a></div>\n",
753 file_chip(filename),
754 escape(filename),
755 escape(&human_size(blob.bytes.len() as u64)),
756 markdown_toggle(&ctx, &reference, path, plain),
757 ctx.base,
758 url_escape(&reference),
759 url_escape(path),
760 ));
761
762 if is_probably_binary(&blob.bytes) {
763 body.push_str(
764 "<div class=\"empty\">This file can't be shown as text — \
765 use Download to save it to your computer.</div>\n</div>\n",
766 );
767 } else if rendered_markdown {
768 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
769 let text = String::from_utf8_lossy(&blob.bytes);
770 body.push_str(&format!(
771 "<article class=\"doc\">{}</article>\n</div>\n",
772 html::readme(&text, &ctx.link_base(&reference, parent))
773 ));
774 } else {
775 let text = String::from_utf8_lossy(&blob.bytes);
776 body.push_str("<div class=\"code\">\n<table class=\"code-table\">\n");
777 for (i, line) in text.lines().enumerate() {
778 let n = i + 1;
779 body.push_str(&format!(
780 "<tr id=\"L{n}\"><td class=\"ln\"><a href=\"#L{n}\">{n}</a></td>\
781 <td class=\"src\">{}</td></tr>\n",
782 escape(line)
783 ));
784 }
785 body.push_str("</table>\n</div>\n</div>\n");
786 }
787
788 Ok(ok_html(html::document(&Page {
789 title: ctx.title(path),
790 heading: ctx.crumbs(&escape(path)),
791 wide: false,
792 seo: None,
793 body,
794 })))
795}
796
797/// The Formatted / Plain switch for markdown files. Both states are links, because
798/// state on this surface lives in the URL and nowhere else.
799fn markdown_toggle(ctx: &Context, reference: &str, path: &str, plain: bool) -> String {
800 if !is_markdown(path.rsplit('/').next().unwrap_or(path)) {
801 return String::new();
802 }
803 let href = format!(
804 "{}/blob/{}/{}",
805 ctx.base,
806 url_escape(reference),
807 url_escape(path)
808 );
809 if plain {
810 format!("<a href=\"{href}\">Formatted</a>")
811 } else {
812 format!("<a href=\"{href}?plain=1\">Plain text</a>")
813 }
814}
815
816/// Raw bytes of one file, addressed like the other viewer URLs.
817///
818/// This exists so "Download" works for an anonymous visitor — the REST raw
819/// endpoint requires a credential this surface deliberately does not have. Access
820/// still goes through `open_repo_for_reading`; this is a different rendering, not
821/// a different rule.
822pub async fn raw(
823 ctx: Context,
824 git_dir: PathBuf,
825 reference: String,
826 path: &str,
827) -> Result<Response<Body>> {
828 validate::tree_path(path)?;
829 let dir = git_dir.clone();
830 let wanted = reference.clone();
831 let target = path.to_string();
832 let limits = ctx.state.config.read_limits();
833
834 let blob = crate::git::exec::blocking(move || {
835 let resolved = resolve_ref(&dir, &wanted)?;
836 discover::blob(&dir, &resolved, &target, limits)
837 })
838 .await?;
839
840 Ok(crate::http::response::blob(
841 blob.bytes,
842 &blob.sha,
843 blob.immutable,
844 ))
845}
846
847/// The deliberately small image type allow-list for rendered README media.
848///
849/// `image/svg+xml` is omitted on purpose. An SVG is XML with a browser execution
850/// model, not inert pixels; accepting only raster formats means an image committed
851/// by a stranger can never become an active same-origin document.
852fn image_content_type(path: &str) -> Option<&'static str> {
853 let extension = path.rsplit('.').next()?.to_ascii_lowercase();
854 match extension.as_str() {
855 "png" => Some("image/png"),
856 "jpg" | "jpeg" => Some("image/jpeg"),
857 "webp" => Some("image/webp"),
858 "gif" => Some("image/gif"),
859 "avif" => Some("image/avif"),
860 _ => None,
861 }
862}
863
864/// Pixels for a rendered README: the safe inline counterpart to `raw`.
865///
866/// A generated or authored PNG belongs in the repository alongside its README.
867/// The path is intentionally separate from raw downloads because its response is
868/// an allow-listed image type, never an attachment and never arbitrary bytes.
869pub async fn media(
870 ctx: Context,
871 git_dir: PathBuf,
872 reference: String,
873 path: &str,
874) -> Result<Response<Body>> {
875 validate::tree_path(path)?;
876 let content_type = image_content_type(path).ok_or(Error::NotFound("image"))?;
877 let dir = git_dir.clone();
878 let wanted = reference.clone();
879 let target = path.to_string();
880 let limits = ctx.state.config.read_limits();
881
882 let blob = crate::git::exec::blocking(move || {
883 let resolved = resolve_ref(&dir, &wanted)?;
884 discover::blob(&dir, &resolved, &target, limits)
885 })
886 .await?;
887
888 Ok(crate::http::response::image(
889 blob.bytes,
890 content_type,
891 &blob.sha,
892 blob.immutable,
893 ))
894}
895
896/// How many commits one History page shows. Small enough to load instantly on a
897/// phone; the pager carries the reader further back.
898const HISTORY_PAGE: usize = 50;
899
900pub async fn commits(
901 ctx: Context,
902 git_dir: PathBuf,
903 reference: String,
904 from: Option<String>,
905) -> Result<Response<Body>> {
906 let dir = git_dir.clone();
907 let wanted = reference.clone();
908 let start = from.clone();
909 let limits = ctx.state.config.read_limits();
910
911 let (refs, log) = crate::git::exec::blocking(move || {
912 let refs = ref_list(&dir);
913 let resolved = resolve_ref(&dir, &wanted)?;
914 // Paging is "start the walk at this commit": the page boundary is itself a
915 // commit sha, so the link stays valid no matter how much history arrives
916 // after it. One extra row is fetched to learn whether an older page exists.
917 let top = match &start {
918 Some(sha) => sha.clone(),
919 None => resolved,
920 };
921 let log = discover::log(&dir, &top, HISTORY_PAGE + 1, None, limits)?;
922 Ok::<_, Error>((refs, log))
923 })
924 .await?;
925
926 let items = log
927 .get("items")
928 .and_then(|i| i.as_array())
929 .cloned()
930 .unwrap_or_default();
931
932 let mut body = repo_header(&ctx, Tab::History, &reference);
933 body.push_str("<div class=\"toolbar\">\n");
934 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::History));
935 body.push_str("</div>\n");
936
937 body.push_str("<div class=\"panel\">\n");
938 body.push_str(
939 "<div class=\"panel-head\"><span class=\"name\">Every change, newest first</span></div>\n",
940 );
941 if items.is_empty() {
942 body.push_str("<div class=\"empty\">No changes yet.</div>\n");
943 }
944 body.push_str("<div class=\"feed\">\n");
945
946 for item in items.iter().take(HISTORY_PAGE) {
947 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
948 let message = item.get("message").and_then(|m| m.as_str()).unwrap_or("");
949 let subject = message.lines().next().unwrap_or("");
950 let author = item
951 .get("author_name")
952 .and_then(|a| a.as_str())
953 .unwrap_or("unknown");
954 let at = item
955 .get("authored_at")
956 .and_then(|a| a.as_u64())
957 .unwrap_or(0);
958
959 body.push_str(&format!(
960 "<a class=\"feed-item\" href=\"{}/commit/{}\">{}\
961 <span class=\"feed-main\"><span class=\"feed-subject\">{}</span>\
962 <span class=\"feed-meta\">{} · {}</span></span>\
963 <code class=\"sha\">{}</code></a>\n",
964 ctx.base,
965 url_escape(sha),
966 avatar(author),
967 escape(subject),
968 escape(author),
969 escape(&relative_time(at)),
970 escape(short_sha(sha))
971 ));
972 }
973
974 body.push_str("</div>\n</div>\n");
975
976 let older = items
977 .get(HISTORY_PAGE)
978 .and_then(|item| item.get("sha"))
979 .and_then(|s| s.as_str());
980 if from.is_some() || older.is_some() {
981 body.push_str("<div class=\"pager\">\n");
982 if from.is_some() {
983 body.push_str(&format!(
984 "<a href=\"{}/commits/{}\">↑ Back to the latest</a>\n",
985 ctx.base,
986 url_escape(&reference)
987 ));
988 }
989 body.push_str("<span class=\"spacer\"></span>\n");
990 if let Some(sha) = older {
991 body.push_str(&format!(
992 "<a href=\"{}/commits/{}?from={}\">Older →</a>\n",
993 ctx.base,
994 url_escape(&reference),
995 url_escape(sha)
996 ));
997 }
998 body.push_str("</div>\n");
999 }
1000
1001 Ok(ok_html(html::document(&Page {
1002 title: ctx.title("history"),
1003 heading: ctx.crumbs("history"),
1004 wide: false,
1005 seo: None,
1006 body,
1007 })))
1008}
1009
1010pub async fn commit(ctx: Context, git_dir: PathBuf, sha: String) -> Result<Response<Body>> {
1011 let dir = git_dir.clone();
1012 let wanted = sha.clone();
1013
1014 let detail = crate::git::exec::blocking(move || {
1015 let resolved = discover::revision(&wanted)?;
1016 discover::commit(&dir, &resolved)
1017 })
1018 .await?;
1019
1020 let info = detail.get("commit").cloned().unwrap_or(Value::Null);
1021 let message = info.get("message").and_then(|m| m.as_str()).unwrap_or("");
1022 let subject = message.lines().next().unwrap_or("");
1023 let author = info
1024 .get("author_name")
1025 .and_then(|a| a.as_str())
1026 .unwrap_or("unknown");
1027 let at = info
1028 .get("authored_at")
1029 .and_then(|a| a.as_u64())
1030 .unwrap_or(0);
1031
1032 // The tabs carry the commit itself: Files shows the project exactly as it was
1033 // at this change, History walks back from here. A commit belongs to no single
1034 // branch, so pretending it was reached from one would sometimes be a lie.
1035 let mut body = repo_header(&ctx, Tab::History, &sha);
1036 body.push_str("<div class=\"panel\">\n");
1037 body.push_str(&format!(
1038 "<div class=\"panel-head\">{}<span class=\"feed-main\">\
1039 <span class=\"feed-subject\">{}</span>\
1040 <span class=\"feed-meta\">{} · {}</span></span>\
1041 <code class=\"sha\">{}</code></div>\n",
1042 avatar(author),
1043 escape(subject),
1044 escape(author),
1045 escape(&relative_time(at)),
1046 escape(short_sha(&sha))
1047 ));
1048
1049 // The body of the message, when there is more than the subject line.
1050 let rest = message
1051 .strip_prefix(subject)
1052 .unwrap_or("")
1053 .trim_matches('\n');
1054 if !rest.is_empty() {
1055 body.push_str(&format!("<pre class=\"message\">{}</pre>\n", escape(rest)));
1056 }
1057 body.push_str("</div>\n");
1058
1059 let changed = detail
1060 .get("changed")
1061 .and_then(|c| c.as_array())
1062 .cloned()
1063 .unwrap_or_default();
1064
1065 if !changed.is_empty() {
1066 body.push_str("<div class=\"panel\">\n");
1067 body.push_str(&format!(
1068 "<div class=\"panel-head\"><span class=\"name\">What changed</span>\
1069 <span>{} file{}</span></div>\n",
1070 changed.len(),
1071 if changed.len() == 1 { "" } else { "s" }
1072 ));
1073 body.push_str("<table class=\"listing\">\n");
1074 for entry in &changed {
1075 let status = entry
1076 .get("status")
1077 .and_then(|s| s.as_str())
1078 .unwrap_or("M")
1079 .chars()
1080 .next()
1081 .unwrap_or('M');
1082 let (class, word) = change_label(status);
1083 let path = entry.get("path").and_then(|p| p.as_str()).unwrap_or("");
1084 body.push_str(&format!(
1085 "<tr><td class=\"name\"><span class=\"delta {class}\">{word}</span>\
1086 <a href=\"{}/blob/{}/{}\">{}</a></td></tr>\n",
1087 ctx.link(""),
1088 url_escape(&sha),
1089 url_escape(path),
1090 escape(path)
1091 ));
1092 }
1093 body.push_str("</table>\n</div>\n");
1094 }
1095
1096 Ok(ok_html(html::document(&Page {
1097 title: ctx.title(subject),
1098 heading: ctx.crumbs(&escape(short_sha(&sha))),
1099 wide: false,
1100 seo: None,
1101 body,
1102 })))
1103}
1104
1105/// Branches and tags, in words: what they are called, how fresh they are, and a
1106/// way into each one — never a bare table of fully-qualified ref names.
1107pub async fn branches(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
1108 let dir = git_dir.clone();
1109 let value = crate::git::exec::blocking(move || discover::refs(&dir, None)).await?;
1110
1111 let default_ref = default_short_ref(&ctx.record.default_branch).to_string();
1112 let mut body = repo_header(&ctx, Tab::Branches, &default_ref);
1113
1114 let items: Vec<Value> = value
1115 .get("items")
1116 .and_then(|i| i.as_array())
1117 .cloned()
1118 .unwrap_or_default();
1119
1120 for (section, prefix, blurb) in [
1121 (
1122 "Branches",
1123 "refs/heads/",
1124 "Lines of work. Each one is a complete copy of the project.",
1125 ),
1126 (
1127 "Tags",
1128 "refs/tags/",
1129 "Named moments — usually releases — frozen in time.",
1130 ),
1131 ] {
1132 let rows: Vec<(&str, &Value)> = items
1133 .iter()
1134 .filter_map(|item| {
1135 let name = item.get("name")?.as_str()?;
1136 Some((name.strip_prefix(prefix)?, item))
1137 })
1138 .collect();
1139 if rows.is_empty() {
1140 continue;
1141 }
1142
1143 body.push_str("<div class=\"panel\">\n");
1144 body.push_str(&format!(
1145 "<div class=\"panel-head\"><span class=\"name\">{section}</span>\
1146 <span>{blurb}</span></div>\n"
1147 ));
1148 body.push_str("<table class=\"listing\">\n");
1149 for (short, item) in rows {
1150 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
1151 let at = item.get("at").and_then(|a| a.as_u64()).unwrap_or(0);
1152 let mark = if short == default_ref {
1153 " <span class=\"badge\">default</span>"
1154 } else {
1155 ""
1156 };
1157 body.push_str(&format!(
1158 "<tr><td class=\"name\"><a href=\"{base}/tree/{r}/\">{name}</a>{mark}</td>\
1159 <td class=\"size\"><a href=\"{base}/commits/{r}\">history</a></td>\
1160 <td class=\"when\">{when}</td>\
1161 <td class=\"size\"><code class=\"sha\">{sha}</code></td></tr>\n",
1162 base = ctx.base,
1163 r = url_escape(short),
1164 name = escape(short),
1165 mark = mark,
1166 when = escape(&relative_time(at)),
1167 sha = escape(short_sha(sha))
1168 ));
1169 }
1170 body.push_str("</table>\n</div>\n");
1171 }
1172
1173 if items.is_empty() {
1174 body.push_str(
1175 "<div class=\"panel\"><div class=\"empty\">No branches yet — \
1176 they appear with the first push.</div></div>\n",
1177 );
1178 }
1179
1180 Ok(ok_html(html::document(&Page {
1181 title: ctx.title("branches"),
1182 heading: ctx.crumbs("branches"),
1183 wide: false,
1184 seo: None,
1185 body,
1186 })))
1187}
1188
1189/// `llms.txt`: the machine-readable front door of a repository.
1190///
1191/// The site convention agents already crawl for. A committed `llms.txt` is the
1192/// author speaking and is served verbatim; a repository without one still answers,
1193/// with a generated summary — name, description, where the documents are, how to
1194/// clone and how to read over the API — because on an agent-first host "no file"
1195/// must not mean "no front door". Access follows the page rule: private
1196/// repositories answer only to their owner.
1197pub async fn llms(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
1198 let reference = default_short_ref(&ctx.record.default_branch).to_string();
1199 let dir = git_dir.clone();
1200 let wanted = reference.clone();
1201
1202 let found = crate::git::exec::blocking(move || {
1203 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
1204 return Ok::<_, Error>(None);
1205 };
1206 // A display cap, same reasoning as the README's: this is a page-sized
1207 // document, not a blob endpoint.
1208 let limits = discover::Limits {
1209 max_blob_bytes: 256 * 1024,
1210 log_walk_budget: 0,
1211 };
1212 if let Ok(blob) = discover::blob(&dir, &resolved, "llms.txt", limits) {
1213 if let Ok(text) = String::from_utf8(blob.bytes) {
1214 return Ok(Some((Some(text), Vec::new())));
1215 }
1216 }
1217 let items = discover::tree(&dir, &resolved, None)
1218 .ok()
1219 .and_then(|v| v.get("items")?.as_array().cloned())
1220 .unwrap_or_default();
1221 let docs: Vec<String> = items
1222 .iter()
1223 .filter_map(|item| {
1224 let kind = item.get("type")?.as_str()?;
1225 let name = item.get("name")?.as_str()?;
1226 (kind == "file" && is_markdown(name)).then(|| name.to_string())
1227 })
1228 .collect();
1229 Ok(Some((None, docs)))
1230 })
1231 .await?;
1232
1233 let (custom, mut docs) = found.unwrap_or((None, Vec::new()));
1234 if let Some(text) = custom {
1235 return Ok(crate::http::response::plain(text));
1236 }
1237 docs.sort();
1238 Ok(crate::http::response::plain(llms_default_text(
1239 &ctx.record,
1240 &ctx.state.config.public_host(),
1241 ctx.state.config.package_url.as_deref(),
1242 &reference,
1243 &docs,
1244 )))
1245}
1246
1247/// The generated `llms.txt` for a repository that did not write its own.
1248fn llms_default_text(
1249 record: &RepoRecord,
1250 host: &str,
1251 package_url: Option<&str>,
1252 reference: &str,
1253 docs: &[String],
1254) -> String {
1255 let base = format!("/{}/{}", record.account, record.name);
1256 let mut out = format!("# {}\n\n", record.display_name);
1257 out.push_str(&format!(
1258 "> {}\n\n",
1259 record
1260 .description
1261 .as_deref()
1262 .unwrap_or("A git repository, served as a website.")
1263 ));
1264 out.push_str(&format!(
1265 "Served by {}. The front page renders the README as a document; \
1266 this file is the front door for agents.\n\n",
1267 crate::brand::NAME
1268 ));
1269
1270 out.push_str("## This repository\n\n");
1271 out.push_str(&format!("- Web: {host}{base}\n"));
1272 out.push_str(&format!(
1273 "- Clone: git clone {host}/{}/{}.git\n",
1274 record.account, record.name
1275 ));
1276 out.push_str(&format!("- Default branch: {reference}\n\n"));
1277 if let Some(package_url) = package_url {
1278 out.push_str(&format!(
1279 "- Deno module (public release): {package_url}/{}/{}@0.0.1/mod.ts — create it with `git tag pkg/0.0.1 && git push origin pkg/0.0.1`\n\n",
1280 record.account, record.name
1281 ));
1282 }
1283
1284 if !docs.is_empty() {
1285 out.push_str("## Documents\n\n");
1286 for name in docs {
1287 out.push_str(&format!("- {host}{base}/blob/{reference}/{name}\n"));
1288 }
1289 out.push('\n');
1290 }
1291
1292 out.push_str("## Read without cloning\n\n");
1293 out.push_str(&format!(
1294 "- Raw bytes: {host}{base}/raw/{reference}/{{path}}\n"
1295 ));
1296 out.push_str(&format!(
1297 "- REST (token): {host}/v1/repos/{}/{}/tree/{{path}}, /raw/{{path}}, /commits, /search?q=, /blame/{{path}}\n",
1298 record.account, record.name
1299 ));
1300 out.push_str(&format!(
1301 "- MCP (token): POST {host}/mcp — tree_list, file_read, search, commit_log, commit_get, compare, blame\n\n"
1302 ));
1303 out.push_str(
1304 "The one write path is `git push`. Nothing in the API writes to a repository.\n\n",
1305 );
1306
1307 // The dialect is only as real as its discoverability: an agent that creates a
1308 // repository learns here, without being told, that the README it is about to
1309 // write can be a full website.
1310 out.push_str("## Writing the front page\n\n");
1311 out.push_str(
1312 "README.md renders as a website: full markdown, plus optional block tags, \
1313 each alone on its own line, closed with {% /name %}.\n\n\
1314 - {% hero tone=\"grape\" align=\"left\" %} — the opening section; links inside become buttons\n\
1315 - {% visual %} around a Markdown PNG/JPEG/WebP/AVIF/GIF — a full-width authored image\n\
1316 - {% steps %} around an ordinary numbered list — a readable route\n\
1317 - {% band tone=\"mint\" %} — a full-width painted stripe\n\
1318 - {% big tone=\"sun\" %} — a display-size statement\n\
1319 - {% callout type=\"note|tip|warn|danger\" title=\"...\" %}\n\
1320 - {% cards columns=\"2|3|4\" %} holding {% card tone=\"sky\" title=\"...\" %}\n\
1321 - {% details summary=\"...\" %} — collapsible, no script\n\n\
1322 Tones: sun, mint, sky, rose, grape, ink. Attributes are optional and \
1323 allow-listed; unknown tags degrade to their content. Relative links \
1324 between markdown files become site navigation, and headings get anchor \
1325 ids.\n",
1326 );
1327 out
1328}
1329
1330/// The host's own `llms.txt`, for a root that features no repository.
1331pub fn service_llms() -> Response<Body> {
1332 crate::http::response::plain(service_llms_text())
1333}
1334
1335fn service_llms_text() -> String {
1336 let name = crate::brand::NAME;
1337 format!(
1338 "# {name}\n\n\
1339 > Agent-first git hosting: bare repositories over HTTP and SSH, a REST API, \
1340 MCP and CI, in one binary. The one write path is `git push`.\n\n\
1341 ## Machine access\n\n\
1342 - MCP: POST /mcp (JSON-RPC 2.0 — initialize, tools/list, tools/call)\n\
1343 - OpenAPI: /openapi.json\n\
1344 - Health: /healthz\n\n\
1345 ## The rules that matter to an agent\n\n\
1346 - Nothing in the API writes to a repository. Write by pushing with git.\n\
1347 - Public repositories are readable with no credential; everything else \
1348 wants a bearer token.\n\
1349 - Every repository serves its own /{{account}}/{{repo}}/llms.txt, which \
1350 also documents the README block-tag dialect — a README here renders as \
1351 a website.\n"
1352 )
1353}
1354
1355/// Shown at `/` when the host has not named a repository to feature.
1356pub fn welcome() -> Response<Body> {
1357 let body = format!(
1358 "<div class=\"panel welcome\"><div class=\"notice\">\
1359 <strong>{} is up and running</strong>\
1360 No project is published at this address yet. Set {}HOME_REPO to \
1361 <code>account/repo</code> to feature one here.</div></div>",
1362 escape(crate::brand::NAME),
1363 escape(crate::brand::env_prefix()),
1364 );
1365 ok_html(html::document(&Page {
1366 title: crate::brand::NAME.to_string(),
1367 heading: String::new(),
1368 wide: false,
1369 seo: None,
1370 body,
1371 }))
1372}
1373
1374/// A friendly 404 for the browser surface.
1375///
1376/// The API's problem+json is the right answer for a program and the wrong one for
1377/// a person who tapped a link in a chat. Deliberately identical for "does not
1378/// exist" and "exists but is private", for the same reason the status code is:
1379/// the page must not leak which one it was.
1380pub fn not_found() -> Response<Body> {
1381 let body = "<div class=\"panel welcome\"><div class=\"notice\">\
1382 <strong>There's nothing at this address</strong>\
1383 The link may be mistyped, or the project may be private. \
1384 If someone sent you here, ask them to check the link — \
1385 or head to the <a href=\"/\">front page</a>.</div></div>"
1386 .to_string();
1387 crate::http::response::html(
1388 StatusCode::NOT_FOUND,
1389 html::document(&Page {
1390 title: "Not found".to_string(),
1391 heading: String::new(),
1392 wide: false,
1393 seo: None,
1394 body,
1395 }),
1396 )
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402
1403 #[test]
1404 fn a_file_with_a_nul_byte_is_treated_as_binary() {
1405 assert!(is_probably_binary(b"\x7fELF\0\0\0"));
1406 assert!(!is_probably_binary(b"fn main() {}\n"));
1407 // Only the head is inspected, so a large text file stays cheap to classify.
1408 let mut long = vec![b'a'; 9000];
1409 long.push(0);
1410 assert!(!is_probably_binary(&long));
1411 }
1412
1413 #[test]
1414 fn sizes_read_the_way_a_person_expects() {
1415 assert_eq!(human_size(0), "0 B");
1416 assert_eq!(human_size(999), "999 B");
1417 assert_eq!(human_size(1024), "1.0 KB");
1418 assert_eq!(human_size(1536), "1.5 KB");
1419 assert_eq!(human_size(1024 * 1024 * 3), "3.0 MB");
1420 }
1421
1422 #[test]
1423 fn a_short_sha_never_panics_on_an_odd_length() {
1424 assert_eq!(short_sha("abcdef0123456789"), "abcdef01");
1425 // Slicing would panic here; a truncated value must degrade, not crash.
1426 assert_eq!(short_sha("abc"), "abc");
1427 assert_eq!(short_sha(""), "");
1428 }
1429
1430 #[test]
1431 fn markdown_is_recognised_by_extension_alone() {
1432 assert!(is_markdown("README.md"));
1433 assert!(is_markdown("GUIDE.MARKDOWN"));
1434 assert!(!is_markdown("main.rs"));
1435 // A file literally named `md` has no extension and is not markdown.
1436 assert!(!is_markdown("md"));
1437 }
1438
1439 #[test]
1440 fn rendered_media_is_limited_to_inert_raster_images() {
1441 assert_eq!(image_content_type("cover.PNG"), Some("image/png"));
1442 assert_eq!(image_content_type("photo.jpeg"), Some("image/jpeg"));
1443 assert_eq!(image_content_type("scene.webp"), Some("image/webp"));
1444 assert_eq!(image_content_type("motion.gif"), Some("image/gif"));
1445 assert_eq!(image_content_type("poster.avif"), Some("image/avif"));
1446 // SVG can contain executable XML; read it as a download, never an image.
1447 assert_eq!(image_content_type("payload.svg"), None);
1448 assert_eq!(image_content_type("README.md"), None);
1449 assert_eq!(image_content_type("no-extension"), None);
1450 }
1451
1452 #[test]
1453 fn an_avatar_is_stable_and_always_printable() {
1454 // The same author must get the same colour on every page load, and a name
1455 // that is all whitespace must still render something.
1456 assert_eq!(avatar("Ada Lovelace"), avatar("Ada Lovelace"));
1457 assert!(avatar("Ada Lovelace").contains("AL"));
1458 assert!(avatar(" ").contains('?'));
1459 // The class index stays inside the palette.
1460 for name in ["a", "b", "Grace Hopper", "линус"] {
1461 let out = avatar(name);
1462 assert!(
1463 (0..8).any(|i| out.contains(&format!("av{i}"))),
1464 "no palette class in {out}"
1465 );
1466 }
1467 }
1468
1469 #[test]
1470 fn a_file_chip_is_short_and_never_carries_markup() {
1471 assert_eq!(file_chip("main.rs"), "<span class=\"chip\">RS</span>");
1472 assert_eq!(file_chip("notes"), "<span class=\"chip\">·</span>");
1473 // A dotfile's "extension" is its name; the stem check keeps the dot label.
1474 assert_eq!(file_chip(".gitignore"), "<span class=\"chip\">·</span>");
1475 // Long extensions are truncated, hostile ones are escaped.
1476 let hostile = file_chip("a.<script>");
1477 assert!(
1478 !hostile.contains("<scr") && !hostile.contains("<SCR"),
1479 "{hostile}"
1480 );
1481 }
1482
1483 #[test]
1484 fn a_generated_llms_txt_gives_an_agent_everything_it_needs() {
1485 // A repository with no llms.txt must still hand an agent a front door:
1486 // where the site is, how to clone, where the documents are, how to read
1487 // over the API — or the convention silently only works for authors who
1488 // already know it.
1489 let record = RepoRecord {
1490 account: "alice".into(),
1491 name: "site".into(),
1492 display_name: "Site".into(),
1493 default_branch: "refs/heads/main".into(),
1494 description: Some("A tiny site".into()),
1495 created_at: 0,
1496 state: crate::store::RepoState::Ready,
1497 visibility: crate::store::Visibility::Public,
1498 };
1499 let docs = vec!["GUIDE.md".to_string(), "README.md".to_string()];
1500 let out = llms_default_text(
1501 &record,
1502 "https://example.com",
1503 Some("https://pkg.example.com"),
1504 "main",
1505 &docs,
1506 );
1507
1508 assert!(out.starts_with("# Site\n"), "{out}");
1509 assert!(out.contains("> A tiny site"), "{out}");
1510 assert!(out.contains("https://example.com/alice/site"), "{out}");
1511 assert!(
1512 out.contains("git clone https://example.com/alice/site.git"),
1513 "{out}"
1514 );
1515 assert!(
1516 out.contains("https://example.com/alice/site/blob/main/README.md"),
1517 "{out}"
1518 );
1519 assert!(out.contains("POST https://example.com/mcp"), "{out}");
1520 assert!(
1521 out.contains("https://pkg.example.com/alice/site@0.0.1/mod.ts"),
1522 "{out}"
1523 );
1524 // The front door teaches the dialect, so any agent that creates a
1525 // repository discovers that its README can be a website.
1526 assert!(out.contains("{% hero"), "{out}");
1527 assert!(out.contains("sun, mint, sky, rose, grape, ink"), "{out}");
1528 // The absence of a description degrades to a sentence, not a blank quote.
1529 let bare = RepoRecord {
1530 description: None,
1531 ..record
1532 };
1533 let out = llms_default_text(&bare, "https://example.com", None, "main", &[]);
1534 assert!(!out.contains(">\n"), "{out}");
1535 }
1536
1537 #[test]
1538 fn the_service_llms_txt_points_at_the_machine_surfaces() {
1539 let out = service_llms_text();
1540 assert!(out.contains("POST /mcp"), "{out}");
1541 assert!(out.contains("/openapi.json"), "{out}");
1542 assert!(out.contains("git push"), "{out}");
1543 }
1544
1545 #[test]
1546 fn change_letters_become_words() {
1547 assert_eq!(change_label('A').1, "Added");
1548 assert_eq!(change_label('D').1, "Removed");
1549 assert_eq!(change_label('M').1, "Changed");
1550 assert_eq!(change_label('R').1, "Renamed");
1551 assert_eq!(change_label('X').1, "Changed");
1552 }
1553}