Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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("&lt;script&gt;"), "escaped");
        assert!(html.contains("The file goes on"), "bounded");
        assert!(!html.contains("ada@"), "no author");
    }
}