crates/gunnar-ui-server/src/prerender.rs
//! **A page's content as plain HTML, for the console's own document** (GUN-GUEST, owner
//! 2026-10-30; feature `public-site`).
//!
//! A crawler runs no WebAssembly, so the console's document at a repository's address
//! carries the page's content in `<main id="prerender">` ([`gunnar_public_site::shell`]).
//! This module renders that content, and it renders it from **the console's own reads**,
//! the ones its pages make: the README through the console's README render (the
//! `?as=html` shape, nornir-docbook through ammonia, [`crate::api::readme`]), a Markdown
//! file through the same render ([`crate::api::render`]), the tree and a file through
//! [`crate::api::browse`], the history through [`crate::api::refs::log`] and the issues
//! through [`crate::api::issues`] and `Forge.GetIssue`. Every read is signed by the
//! grantless GUEST key, so gunnar answers it for a public repository only, and nothing
//! here can read more than a visitor's own browser would.
//!
//! No page names a person: a commit's author, an issue's filer and a comment's writer are
//! never printed (the console's own JSON carries them; this HTML does not).
use axum::extract::{Path, Query, State};
use gunnar_public_site as site;
use site::head::{esc, ymd};
use site::shell::Part;
use site::text::pct_encode_path;
use crate::api::{self, ApiError, Reader};
use crate::app::AppState;
use crate::pb;
/// The most lines of a text file a page carries.
const MAX_LINES: usize = 4_000;
/// The most bytes of a text file a page carries.
const MAX_TEXT_BYTES: usize = 512 * 1024;
/// The commits the repository's page lists, and the commits page.
const HOME_COMMITS: usize = 10;
const PAGE_COMMITS: usize = 50;
/// Which page of a repository an address names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
/// `/{ns}/{name}`: the README, the files at the root, the newest commits.
Home,
/// `tree/{path}` or `blob/{path}`: a directory or a file (`""` is the root).
At(String),
/// `commits`.
Commits,
/// `issues`, searched when the query asks.
Issues,
/// `issues/{n}`.
Issue(u64),
}
/// The page `leaf`/`tail` names (a decoded path's parts), or `None` for one that is not
/// pre-rendered (pulls, diff, blame, search, settings…: the console's live view only).
pub fn kind_of(leaf: &str, tail: &str) -> Option<Kind> {
let tail = tail.trim_matches('/');
match leaf {
"" => Some(Kind::Home),
"tree" | "blob" => Some(Kind::At(tail.to_owned())),
"commits" if tail.is_empty() => Some(Kind::Commits),
"issues" if tail.is_empty() => Some(Kind::Issues),
"issues" => tail.parse().ok().map(Kind::Issue),
_ => None,
}
}
/// Why a page could not be rendered now: the console is served without its part.
#[derive(Debug)]
pub struct Unavailable(pub String);
/// The guest's reader: no session, the grantless key, never cached as anyone else's.
fn guest(state: &AppState) -> Result<Reader, Unavailable> {
let gunnar = state.guest.clone().ok_or_else(|| Unavailable("no guest identity is configured".into()))?;
Ok(Reader { gunnar, guest: true, tenant: false, anonymous: true })
}
/// A missing path inside a public repository (or one gunnar will not show the guest).
fn missing(e: &ApiError) -> bool {
matches!(e.0.code.as_str(), "notfound" | "permissiondenied" | "invalidargument")
}
/// The page's address under the repository.
fn base(repo: &site::RepoRef) -> String {
format!("/{}/{}", repo.namespace, repo.name)
}
/// **Render one page of a public repository.** `query` is the request's (an issue search).
pub async fn render(state: &AppState, config: &site::Config, repo: &site::RepoRef, kind: &Kind, query: Option<&str>, kept: Option<&[site::Issue]>) -> Result<Part, Unavailable> {
let reader = guest(state)?;
let title = |what: Option<&str>| match what {
Some(w) => format!("{w} · {} · {}", repo.path(), config.site_name),
None => format!("{} · {}", repo.path(), config.site_name),
};
let description = site::head::repo_meta(config, repo, String::new(), String::new()).description;
let mut html = heading(config, repo);
let page = match kind {
Kind::Home => {
home(state, &reader, repo, &mut html).await?;
Some(title(None))
}
Kind::At(path) => at(state, &reader, repo, path, &mut html).await?.then(|| title(Some(if path.is_empty() { "Files" } else { path }))),
Kind::Commits => {
commits(&reader, repo, PAGE_COMMITS, "Commits", &mut html).await?;
Some(title(Some("Commits")))
}
Kind::Issues => {
issues(&reader, repo, query, kept, &mut html).await?;
Some(title(Some("Issues")))
}
Kind::Issue(n) => issue(&reader, repo, *n, &mut html).await?.map(|t| title(Some(&format!("#{n} {t}")))),
};
html.push_str(&files_for_tools(config, repo));
Ok(match page {
Some(t) => Part { status: 200, title: t, description, html },
None => Part { status: 404, title: title(Some("Not found")), description, html: format!("{}<p>This page does not exist.</p>\n", heading(config, repo)) },
})
}
/// The repository's name, its description, the clone line and its pages.
fn heading(config: &site::Config, repo: &site::RepoRef) -> String {
let b = base(repo);
let mut h = format!("<header>\n<h1><a href=\"{b}\">{}</a></h1>\n", esc(&repo.path()));
if !repo.description.trim().is_empty() {
h.push_str(&format!("<p>{}</p>\n", esc(&site::text::one_line(&repo.description))));
}
if let Some(c) = site::text::clone_url(config, repo) {
h.push_str(&format!("<pre>git clone {}</pre>\n", esc(&c)));
}
h.push_str(&format!("<nav><a href=\"{b}\">README</a> · <a href=\"{b}/tree\">Files</a> · <a href=\"{b}/commits\">Commits</a> · <a href=\"{b}/issues\">Issues</a></nav>\n</header>\n"));
h
}
/// The machine-readable files beside the page.
fn files_for_tools(config: &site::Config, repo: &site::RepoRef) -> String {
let b = base(repo);
let _ = config;
format!("<footer><p>For AI tools: <a href=\"{b}/readme.md\">readme.md</a> · <a href=\"{b}/tree.txt\">tree.txt</a> · <a href=\"{b}/issues.md\">issues.md</a> · <a href=\"/llms.txt\">llms.txt</a></p></footer>\n")
}
/// The repository's page: the README, the files at the root, the newest commits.
async fn home(state: &AppState, reader: &Reader, repo: &site::RepoRef, html: &mut String) -> Result<(), Unavailable> {
let asked = api::readme::At { reference: String::new(), as_shape: Some("html".to_owned()) };
match api::readme::read(reader.clone(), State(state.clone()), Path((repo.namespace.clone(), repo.name.clone())), Query(asked)).await {
Ok(axum::Json(readme)) => html.push_str(&readme_html(&readme)),
Err(e) if missing(&e) => {}
Err(e) => return Err(Unavailable(e.0.message)),
}
html.push_str("<h2>Files</h2>\n");
at(state, reader, repo, "", html).await?;
commits(reader, repo, HOME_COMMITS, "Newest commits", html).await
}
/// A README or a Markdown file, as the console's own render answered it.
fn readme_html(readme: &crate::readme::Readme) -> String {
match readme.kind {
// Sanitised by the render (ammonia), as the console's own page draws it.
"html" => format!("<article class=\"readme\">\n{}\n</article>\n", readme.body),
"text" if !readme.body.is_empty() => format!("<pre>{}</pre>\n", esc(&readme.body)),
_ => String::new(),
}
}
/// A directory's listing or a file. `false` when the path names nothing.
async fn at(state: &AppState, reader: &Reader, repo: &site::RepoRef, path: &str, html: &mut String) -> Result<bool, Unavailable> {
let b = base(repo);
let asked = api::browse::At { rev: String::new(), path: path.to_owned() };
let found = match api::browse::browse(reader.clone(), State(state.clone()), Path((repo.namespace.clone(), repo.name.clone())), Query(asked)).await {
Ok(axum::Json(found)) => found,
Err(e) if missing(&e) => return Ok(false),
Err(e) => return Err(Unavailable(e.0.message)),
};
if !path.is_empty() {
html.push_str(&format!("<h2>{}</h2>\n", esc(path)));
}
match found {
api::browse::Found::Tree(tree) => {
let mut entries: Vec<&crate::git::Entry> = tree.entries.iter().filter(|e| !e.lossy_name).collect();
entries.sort_by(|a, b| b.is_dir().cmp(&a.is_dir()).then(a.name.cmp(&b.name)));
html.push_str("<ul class=\"files\">\n");
for e in entries {
let full = if tree.path.is_empty() { e.name.clone() } else { format!("{}/{}", tree.path, e.name) };
let (leaf, shown) = if e.is_dir() { ("tree", format!("{}/", e.name)) } else { ("blob", e.name.clone()) };
html.push_str(&format!("<li><a href=\"{b}/{leaf}/{}\">{}</a></li>\n", esc(&pct_encode_path(&full)), esc(&shown)));
}
html.push_str("</ul>\n");
}
api::browse::Found::Blob(blob) => {
let raw = format!("/api/repos/{}/{}/raw?path={}", pct_encode_path(&repo.namespace), pct_encode_path(&repo.name), pct_encode_path(&blob.path));
if blob.sealed {
html.push_str("<p>An encrypted file: it opens in the console with the reader's own key.</p>\n");
} else if let Some(lfs) = &blob.lfs {
html.push_str(&format!("<p>A file kept in Git LFS, {} bytes. <a href=\"{}\">Open it</a>.</p>\n", blob.size, esc(&raw)));
let _ = lfs;
} else if blob.binary || blob.too_large {
html.push_str(&format!("<p>A {} file, {} bytes. <a href=\"{}\">Download it</a>.</p>\n", if blob.binary { "binary" } else { "large" }, blob.size, esc(&raw)));
} else if crate::readme::is_markdown(&blob.path) {
let asked = api::render::At { rev: String::new(), path: blob.path.clone(), as_shape: Some("html".to_owned()) };
match api::render::render(reader.clone(), State(state.clone()), Path((repo.namespace.clone(), repo.name.clone())), Query(asked)).await {
Ok(axum::Json(doc)) => html.push_str(&readme_html(&doc)),
Err(e) => return Err(Unavailable(e.0.message)),
}
} else {
html.push_str(&text_of(&blob));
}
}
}
Ok(true)
}
/// A text file's lines, as plain escaped text (bounded).
fn text_of(blob: &api::browse::Blob) -> String {
let mut out = String::from("<pre><code>");
let mut bytes = 0;
let mut cut = blob.truncated;
for (i, line) in blob.lines.iter().enumerate() {
if i >= MAX_LINES || bytes >= MAX_TEXT_BYTES {
cut = true;
break;
}
for t in line {
bytes += t.text.len();
out.push_str(&esc(&t.text));
}
out.push('\n');
}
out.push_str("</code></pre>\n");
if cut {
out.push_str("<p>The file goes on; the console shows all of it.</p>\n");
}
out
}
/// The newest commits: subject, short id and day. No author, no e-mail address.
async fn commits(reader: &Reader, repo: &site::RepoRef, n: usize, heading: &str, html: &mut String) -> Result<(), Unavailable> {
let q = api::refs::LogQuery { start: None, limit: Some(n), path: None, reference: None, all: None };
let page = match api::refs::log(reader.clone(), Path((repo.namespace.clone(), repo.name.clone())), Query(q)).await {
Ok(axum::Json(page)) => page,
// An empty repository has no history.
Err(e) if missing(&e) => {
html.push_str("<p>This repository has no commits yet.</p>\n");
return Ok(());
}
Err(e) => return Err(Unavailable(e.0.message)),
};
html.push_str(&format!("<h2>{}</h2>\n<ul class=\"commits\">\n", esc(heading)));
for c in page.items.iter().take(n) {
let at = c.committer.as_ref().or(c.author.as_ref()).map(|s| ymd(s.time_unix_seconds)).unwrap_or_default();
html.push_str(&format!("<li><code>{}</code> {} <time>{}</time></li>\n", esc(site::text::short(&c.oid)), esc(&c.subject), at));
}
html.push_str("</ul>\n");
Ok(())
}
/// "To …, log in." — the way to the console's own page, signed in.
fn sign_in(to: &str, what: &str) -> String {
format!("<p>To {what}, <a href=\"/login?next={}\">log in</a>.</p>\n", esc(&pct_encode_path(to)))
}
/// The issues, newest first, searched when the query asks (every word, any case, in the
/// title, the body or the thread as last read; `#N` is issue N).
async fn issues(reader: &Reader, repo: &site::RepoRef, query: Option<&str>, kept: Option<&[site::Issue]>, html: &mut String) -> Result<(), Unavailable> {
let listed = match api::issues::list(reader.clone(), Path((repo.namespace.clone(), repo.name.clone()))).await {
Ok(axum::Json(list)) => list,
Err(e) if missing(&e) => Vec::new(),
Err(e) => return Err(Unavailable(e.0.message)),
};
let filter = site::issues::Filter::of_query(query);
let as_site = |i: &api::issues::Issue| site::Issue {
number: i.number,
title: i.title.clone(),
body: i.body.clone(),
open: i.open,
opened_at_unix: i.opened_at_unix_ms.div_euclid(1000),
comments: i.comments,
thread: kept.and_then(|k| k.iter().find(|o| o.number == i.number)).and_then(|o| o.thread.clone()),
};
let mut shown: Vec<site::Issue> = listed.iter().map(as_site).filter(|i| filter.as_ref().is_none_or(|f| f.matches(i))).collect();
shown.sort_by_key(|i| std::cmp::Reverse(i.number));
let b = base(repo);
html.push_str("<h2>Issues</h2>\n");
html.push_str(&format!("<form method=\"get\" action=\"{b}/issues\"><input type=\"search\" name=\"q\" value=\"{}\" aria-label=\"Search the issues\"><button type=\"submit\">Search</button></form>\n", esc(filter.as_ref().map(|f| f.q.as_str()).unwrap_or_default())));
match &filter {
None => {
let open = listed.iter().filter(|i| i.open).count();
html.push_str(&format!("<p>{open} open, {} closed.</p>\n", listed.len() - open));
}
Some(_) => html.push_str(&format!("<p>{} {} match.</p>\n", shown.len(), if shown.len() == 1 { "issue" } else { "issues" })),
}
html.push_str("<ul class=\"issues\">\n");
for i in &shown {
html.push_str(&format!(
"<li>#{n} <a href=\"{b}/issues/{n}\">{}</a> {} · opened <time>{}</time> · {} {}</li>\n",
esc(&i.title),
if i.open { "Open" } else { "Closed" },
ymd(i.opened_at_unix),
i.comments,
if i.comments == 1 { "comment" } else { "comments" },
n = i.number
));
}
html.push_str("</ul>\n");
html.push_str(&format!("<p>Every issue as one Markdown file: <a href=\"{b}/issues.md\">issues.md</a>.</p>\n"));
html.push_str(&sign_in(&format!("{b}/issues"), "file an issue"));
Ok(())
}
/// One issue and its thread; its title, or `None` where there is no such issue.
async fn issue(reader: &Reader, repo: &site::RepoRef, number: u64, html: &mut String) -> Result<Option<String>, Unavailable> {
let asked = pb::GetIssueRequest { repo: repo.path(), number };
let got = match reader.gunnar.forge().get_issue(asked).await {
Ok(r) => r.into_inner(),
Err(s) if matches!(s.code(), tonic::Code::NotFound | tonic::Code::PermissionDenied | tonic::Code::InvalidArgument) => return Ok(None),
Err(s) => return Err(Unavailable(s.message().to_owned())),
};
let Some(state) = got.state else { return Ok(None) };
let b = base(repo);
html.push_str(&format!(
"<h2>#{} {}</h2>\n<p>{} · opened <time>{}</time> · {} {}</p>\n",
state.number,
esc(&state.title),
if state.open { "Open" } else { "Closed" },
ymd(state.opened_at_unix_ms.div_euclid(1000)),
state.comments,
if state.comments == 1 { "comment" } else { "comments" }
));
html.push_str(&format!("<article class=\"issue\">\n{}\n</article>\n", words(&state.body)));
for signed in got.events {
let Some(event) = signed.event else { continue };
let at = ymd(event.at_unix_ms.div_euclid(1000));
match event.kind {
Some(pb::forge_event::Kind::IssueCommented(c)) => html.push_str(&format!("<section class=\"note\"><p>Comment · <time>{at}</time></p>\n{}\n</section>\n", words(&c.body))),
Some(pb::forge_event::Kind::IssueClosed(c)) => html.push_str(&format!("<section class=\"note\"><p>Closed · <time>{at}</time></p>\n{}\n</section>\n", words(&c.reason))),
_ => {}
}
}
html.push_str(&format!("<p><a href=\"{b}/issues\">All issues</a></p>\n"));
html.push_str(&sign_in(&format!("{b}/issues/{number}"), "comment"));
Ok(Some(state.title))
}
/// An issue's or a comment's words: Markdown through the console's own sanitised fallback
/// render ([`crate::readme::plain_html`]), the one a README that defeats the typesetter gets.
fn words(markdown: &str) -> String {
if markdown.trim().is_empty() {
return "<p>(No words.)</p>".to_owned();
}
crate::readme::plain_html(markdown)
}
/// **A namespace's page**: its public repositories, linked, each with its description
/// (GUN-FIELD-TEST, 2026-11-01). From the site's own list (gunnar's public answer to the
/// guest), so it reads nothing new. The head names `/{ns}` as canonical.
pub fn namespace_page(config: &site::Config, namespace: &str, repos: &[site::RepoRef]) -> (site::head::Meta, String) {
let path = format!("/{namespace}");
let url = format!("{}{path}", config.base_url);
let mut html = format!("<header>\n<h1><a href=\"{}\">{}</a></h1>\n<p>Public repositories of {} on {}.</p>\n</header>\n<ul>\n", esc(&path), esc(namespace), esc(namespace), esc(&config.site_name));
let mut parts = Vec::new();
for r in repos {
let href = format!("/{}/{}", r.namespace, r.name);
html.push_str(&format!("<li><a href=\"{}\">{}</a>", esc(&href), esc(&r.path())));
if !r.description.trim().is_empty() {
html.push_str(&format!(" — {}", esc(r.description.trim())));
}
html.push_str("</li>\n");
parts.push(serde_json::json!({"@type": "SoftwareSourceCode", "name": r.path(), "url": format!("{}{href}", config.base_url)}));
}
html.push_str("</ul>\n");
let meta = site::head::Meta {
title: format!("{namespace} · {}", config.site_name),
description: format!("Public repositories of {namespace} on {}.", config.site_name),
path,
og_type: "website",
json_ld: serde_json::json!({"@context": "https://schema.org", "@type": "CollectionPage", "name": namespace, "url": url, "hasPart": parts}),
noindex: false,
};
(meta, html)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pages_that_are_pre_rendered() {
assert_eq!(kind_of("", ""), Some(Kind::Home));
assert_eq!(kind_of("tree", ""), Some(Kind::At(String::new())));
assert_eq!(kind_of("blob", "docs/v2/README.md"), Some(Kind::At("docs/v2/README.md".into())));
assert_eq!(kind_of("commits", ""), Some(Kind::Commits));
assert_eq!(kind_of("issues", ""), Some(Kind::Issues));
assert_eq!(kind_of("issues", "7"), Some(Kind::Issue(7)));
for (leaf, tail) in [("issues", "x"), ("pulls", ""), ("blame", ""), ("settings", ""), ("commits", "x")] {
assert_eq!(kind_of(leaf, tail), None, "{leaf}/{tail}");
}
}
#[test]
fn a_text_file_is_escaped_and_bounded() {
let token = |t: &str| crate::git::highlight::Token { class: "", text: t.to_owned() };
let blob = api::browse::Blob {
revision: crate::git::Revision { commit_oid: String::new(), tree_oid: String::new(), subject: String::new(), author: "Ada <ada@example.invalid>".into(), at_unix_seconds: 0 },
path: "a.js".into(),
crumbs: Vec::new(),
oid: String::new(),
size: 10,
truncated: false,
too_large: false,
binary: false,
syntax: None,
lines: (0..MAX_LINES + 5).map(|_| vec![token("<script>x</script>")]).collect(),
sealed: false,
lfs: None,
};
let html = text_of(&blob);
assert!(!html.contains("<script>") && html.contains("<script>"), "escaped");
assert!(html.contains("The file goes on"), "bounded");
assert!(!html.contains("ada@"), "no author");
}
}