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-public-site/src/shell.rs

//! **The console's shell with a page's content in it** (GUN-GUEST, owner 2026-10-30).
//!
//! One address for everybody: `/{ns}/{name}/…` is the console's route, and its answer
//! is the console's own `index.html` for a visitor, a signed-in person and a crawler
//! alike (no user-agent is read: everyone gets the same bytes). Into that document
//! [`compose`] puts the page's title and its search tags ([`crate::head::search_tags`])
//! and, first in the body, a `<main id="prerender">` holding the page's content as
//! plain sanitised HTML. A crawler, which runs no WebAssembly, reads that; a browser
//! boots the console, which takes the element away and draws its live view.
//!
//! The content is not this crate's: the console renders it (its own README render,
//! its own reads as the grantless guest) and [`Prerendered`] keeps it, bounded,
//! until the repository changes (a push, an issue) or the hourly refresh.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use crate::head::{esc, search_tags, Meta};
use crate::Config;

/// The id of the element that holds a page's content until the console replaces it.
pub const PRERENDER_ID: &str = "prerender";

/// **The console's document with one page's content in it.** `index` is the console's
/// `index.html`; its `<title>` becomes the page's, the search tags go at the end of its
/// head, and `<main id="prerender">{fragment}</main>` goes first in its body. Nothing
/// else in the document changes, so the console boots from it exactly as from its own.
pub fn compose(config: &Config, index: &str, meta: &Meta, fragment: &str) -> String {
    // The head ends at its LAST `</head>`, and the body starts after it: the console's own
    // head carries scripts and comments that name `<body>` (its no-WebAssembly notice), and
    // the part must never land inside one of them (it would change a hashed inline script).
    let head_end = index.rfind("</head>").unwrap_or(0);
    let title = format!("<title>{}</title>", esc(&meta.title));
    let mut doc = match (index[..head_end].find("<title>"), index[..head_end].find("</title>")) {
        (Some(open), Some(close)) if open < close => format!("{}{title}{}", &index[..open], &index[close + "</title>".len()..]),
        _ => format!("{}{title}\n{}", &index[..head_end], &index[head_end..]),
    };
    let head_end = doc.rfind("</head>");
    let tags = search_tags(config, meta);
    if let Some(at) = head_end {
        doc.insert_str(at, &tags);
    }
    let main = format!("<main id=\"{PRERENDER_ID}\">\n{fragment}\n</main>\n");
    match body_open_end(&doc, doc.rfind("</head>").unwrap_or(0)) {
        Some(at) => doc.insert_str(at, &main),
        None => doc.push_str(&main),
    }
    doc
}

/// Where the `<body …>` tag after `from` ends, if the document has one.
fn body_open_end(doc: &str, from: usize) -> Option<usize> {
    let at = from + doc[from..].find("<body")?;
    let close = doc[at..].find('>')?;
    Some(at + close + 1)
}

/// A minimal document for a console with no built bundle (a test, or `trunk serve`
/// not run): the same shape, nothing to boot.
pub const BARE_INDEX: &str = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Gunnar</title>\n</head>\n<body></body>\n</html>\n";

/// **One page's content, as the console rendered it**, and what its head says.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Part {
    /// The answer's status: 200, or 404 for a path the repository does not have.
    pub status: u16,
    pub title: String,
    pub description: String,
    /// The plain, sanitised HTML that goes inside `<main id="prerender">`.
    pub html: String,
}

/// The bounds on what [`Prerendered`] keeps.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bounds {
    /// Parts kept at most; past it the oldest go.
    pub max_parts: usize,
    /// All kept HTML together, in bytes.
    pub max_bytes: usize,
    /// The largest one part, in bytes; a bigger one is not kept (it is still served).
    pub max_part_bytes: usize,
}

impl Default for Bounds {
    fn default() -> Bounds {
        Bounds { max_parts: 4_096, max_bytes: 64 * 1024 * 1024, max_part_bytes: 2 * 1024 * 1024 }
    }
}

struct Kept {
    part: Arc<Part>,
    /// `ns/name`, so a change of the repository drops it.
    repo: String,
    at: Instant,
}

/// **The cache of pre-rendered parts**, by page path. Bounded ([`Bounds`]); a part is
/// dropped when its repository changes ([`Prerendered::invalidate_repo`]) or when it is
/// older than the refresh ([`Prerendered::expire`]).
pub struct Prerendered {
    bounds: Bounds,
    kept: Mutex<HashMap<String, Kept>>,
}

impl Default for Prerendered {
    fn default() -> Prerendered {
        Prerendered::new(Bounds::default())
    }
}

impl Prerendered {
    pub fn new(bounds: Bounds) -> Prerendered {
        Prerendered { bounds, kept: Mutex::new(HashMap::new()) }
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Kept>> {
        self.kept.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// The part kept for `path`.
    pub fn get(&self, path: &str) -> Option<Arc<Part>> {
        self.lock().get(path).map(|k| k.part.clone())
    }

    /// Keep `part` for `path` (a page of repository `repo`, `ns/name`), at `now`.
    pub fn put(&self, path: &str, repo: &str, part: Part, now: Instant) -> Arc<Part> {
        let part = Arc::new(part);
        if part.html.len() > self.bounds.max_part_bytes {
            return part;
        }
        let mut kept = self.lock();
        kept.insert(path.to_owned(), Kept { part: part.clone(), repo: repo.to_owned(), at: now });
        let bytes = |k: &HashMap<String, Kept>| k.values().map(|v| v.part.html.len()).sum::<usize>();
        while kept.len() > self.bounds.max_parts || bytes(&kept) > self.bounds.max_bytes {
            let Some(oldest) = kept.iter().min_by_key(|(_, v)| v.at).map(|(p, _)| p.clone()) else { break };
            kept.remove(&oldest);
        }
        part
    }

    /// **A repository changed** (a push, an issue): every part of it goes.
    pub fn invalidate_repo(&self, repo: &str) -> usize {
        let mut kept = self.lock();
        let before = kept.len();
        kept.retain(|_, v| v.repo != repo);
        before - kept.len()
    }

    /// Every part older than `max_age` at `now` goes (the hourly safety refresh).
    pub fn expire(&self, max_age: Duration, now: Instant) -> usize {
        let mut kept = self.lock();
        let before = kept.len();
        kept.retain(|_, v| now.saturating_duration_since(v.at) < max_age);
        before - kept.len()
    }

    /// How many parts are kept.
    pub fn len(&self) -> usize {
        self.lock().len()
    }

    /// Whether none is.
    pub fn is_empty(&self) -> bool {
        self.lock().is_empty()
    }
}

/// **What a console address names**, as far as the shell cares: a repository and the
/// page of it. `None` for anything that is not `/{ns}/{name}[/…]` with plain names, or
/// whose first segment is one of `reserved` (the console's own routes, the server's).
pub fn repo_of<'a>(path: &'a str, reserved: &[String]) -> Option<(&'a str, &'a str, &'a str)> {
    let rest = path.strip_prefix('/')?;
    let mut parts = rest.splitn(3, '/');
    let ns = parts.next()?;
    let name = parts.next()?;
    let tail = parts.next().unwrap_or("");
    if !crate::safe_name(ns) || !crate::safe_name(name) || reserved.iter().any(|r| r.eq_ignore_ascii_case(ns)) {
        return None;
    }
    Some((ns, name, tail))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn part(html: &str) -> Part {
        Part { status: 200, title: "t".into(), description: "d".into(), html: html.into() }
    }

    #[test]
    fn a_part_is_dropped_when_its_repository_changes_and_when_it_is_old() {
        let c = Prerendered::default();
        let now = Instant::now();
        c.put("/a/b", "a/b", part("x"), now);
        c.put("/a/b/commits", "a/b", part("y"), now);
        c.put("/a/c", "a/c", part("z"), now);
        assert_eq!(c.invalidate_repo("a/b"), 2);
        assert!(c.get("/a/b").is_none() && c.get("/a/c").is_some());
        assert_eq!(c.expire(Duration::from_secs(3600), now + Duration::from_secs(3601)), 1);
        assert!(c.is_empty());
    }

    #[test]
    fn the_cache_is_bounded_and_the_oldest_go_first() {
        let c = Prerendered::new(Bounds { max_parts: 2, max_bytes: 10, max_part_bytes: 6 });
        let now = Instant::now();
        c.put("/a/1", "a/1", part("1234"), now);
        c.put("/a/2", "a/2", part("1234"), now + Duration::from_secs(1));
        c.put("/a/3", "a/3", part("1234"), now + Duration::from_secs(2));
        assert_eq!(c.len(), 2);
        assert!(c.get("/a/1").is_none(), "the oldest went");
        let big = c.put("/a/4", "a/4", part("1234567"), now + Duration::from_secs(3));
        assert_eq!(big.html, "1234567", "a part past the bound is still served");
        assert!(c.get("/a/4").is_none(), "and not kept");
    }

    #[test]
    fn only_a_plain_repository_address_names_a_repository() {
        let reserved: Vec<String> = ["api", "admin", "repo"].iter().map(|s| s.to_string()).collect();
        assert_eq!(repo_of("/nordisk/gunnar", &reserved), Some(("nordisk", "gunnar", "")));
        assert_eq!(repo_of("/nordisk/gunnar/blob/docs/v2/README.md", &reserved), Some(("nordisk", "gunnar", "blob/docs/v2/README.md")));
        for not in ["/", "/nordisk", "/admin/storage", "/api/repos", "/repo/a/b", "/a b/c", "/../x"] {
            assert_eq!(repo_of(not, &reserved), None, "{not}");
        }
    }
}