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}");
}
}
}