crates/gunnar-public-site/src/head.rs
//! **The machine-only head of a page** (S-4): the title, the meta description,
//! the canonical URL, Open Graph, the Twitter card and JSON-LD, from ONE helper
//! ([`search_tags`]). GUN-GUEST (owner 2026-10-30): the site draws no page of
//! its own any more; these tags go into the console's own documents (the
//! landing at `/`, [`home`], and the console's shell, [`crate::shell`]).
use serde_json::{json, Value};
use crate::{text, Config, RepoRef};
/// HTML-escape text for an element or a double-quoted attribute.
pub fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
/// What one page says about itself in its head (S-4).
pub struct Meta {
pub title: String,
pub description: String,
/// The page's own path, for its canonical URL.
pub path: String,
/// Open Graph's `og:type`: `website` for the home, `article` for a page
/// about one repository.
pub og_type: &'static str,
/// The page's JSON-LD (an object or an array of them).
pub json_ld: Value,
/// True for a page no search engine should index (a search's answer).
pub noindex: bool,
}
/// **The machine-only half of the head** (PUB-HOME, owner 2026-10-29): the meta
/// description, canonical, Open Graph, the Twitter card, the llms.txt link and
/// the JSON-LD. Nothing here is drawn; it is what a search engine or an AI tool
/// reads, and it is all the public site adds to the console's own landing page.
pub fn search_tags(config: &Config, meta: &Meta) -> String {
let url = format!("{}{}", config.base_url, meta.path);
let title = esc(&meta.title);
let description = esc(text::cut(&text::one_line(&meta.description), 300));
// `<` never reaches the script element, so no string in the data can close it.
let ld = serde_json::to_string(&meta.json_ld).unwrap_or_else(|_| "{}".into()).replace('<', "\\u003c");
format!(
"{robots}<meta name=\"description\" content=\"{description}\">\n\
<link rel=\"canonical\" href=\"{u}\">\n\
<meta property=\"og:type\" content=\"{og}\">\n\
<meta property=\"og:site_name\" content=\"{site}\">\n\
<meta property=\"og:title\" content=\"{title}\">\n\
<meta property=\"og:description\" content=\"{description}\">\n\
<meta property=\"og:url\" content=\"{u}\">\n\
<meta name=\"twitter:card\" content=\"summary\">\n\
<meta name=\"twitter:title\" content=\"{title}\">\n\
<meta name=\"twitter:description\" content=\"{description}\">\n\
<link rel=\"alternate\" type=\"text/plain\" title=\"llms.txt\" href=\"{base}/llms.txt\">\n\
<script type=\"application/ld+json\">{ld}</script>\n",
u = esc(&url),
robots = if meta.noindex { "<meta name=\"robots\" content=\"noindex\">\n" } else { "" },
og = meta.og_type,
site = esc(&config.site_name),
base = esc(&config.base_url),
)
}
/// The site's own JSON-LD: the organisation and the software.
pub fn site_ld(config: &Config) -> Value {
json!([
{
"@context": "https://schema.org",
"@type": "Organization",
"name": config.site_name,
"url": format!("{}/", config.base_url),
},
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": config.site_name,
"description": config.description,
"applicationCategory": "DeveloperApplication",
"url": format!("{}/", config.base_url),
}
])
}
/// A repository's JSON-LD.
pub fn repo_ld(config: &Config, repo: &RepoRef) -> Value {
json!({
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": repo.path(),
"description": repo.description,
"codeRepository": text::repo_url(config, repo),
"url": text::repo_url(config, repo),
})
}
/// **`/`** for a visitor with no session (PUB-HOME, owner 2026-10-29): the
/// console's OWN landing page, exactly as it looks without the public site, with
/// the site's [`search_tags`] put in its `<head>` and nothing drawn added. The
/// landing is the console's ([`Config::landing`]); without one the site builds
/// no `/` and the console answers it as before.
pub fn home(config: &Config, landing: &str) -> String {
let meta = Meta {
title: format!("{} — {}", config.site_name, config.tagline),
description: config.description.clone(),
path: "/".into(),
og_type: "website",
json_ld: site_ld(config),
noindex: false,
};
let tags = search_tags(config, &meta);
// The one `<title>` is the site's words (a search result's line), never the
// landing's "log in"; nothing a visitor sees on the page changes.
let landing = match (landing.find("<title>"), landing.find("</title>")) {
(Some(open), Some(close)) if open < close => format!("{}<title>{}</title>{}", &landing[..open], esc(&meta.title), &landing[close + "</title>".len()..]),
_ => landing.to_owned(),
};
match landing.find("</head>") {
Some(at) => format!("{}{tags}{}", &landing[..at], &landing[at..]),
None => format!("{tags}{landing}"),
}
}
/// `YYYY-MM-DD` of a unix time, UTC (Howard Hinnant's civil-from-days).
pub fn ymd(unix: i64) -> String {
let z = unix.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
/// A page about one repository: its title, its description (the repository's own, or what
/// the page holds), its canonical path and its JSON-LD.
pub fn repo_meta(config: &Config, repo: &RepoRef, title: String, path: String) -> Meta {
let description = if repo.description.trim().is_empty() {
format!("{} on {}: its README, files and newest commits.", repo.path(), config.site_name)
} else {
format!("{} — {}", repo.path(), text::one_line(&repo.description))
};
Meta { title, description, path, og_type: "article", json_ld: repo_ld(config, repo), noindex: false }
}
/// `/{ns}/{name}`, the repository's console address.
pub fn repo_base(repo: &RepoRef) -> String {
format!("/{}/{}", repo.namespace, repo.name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_covers_the_five() {
assert_eq!(esc("<a href=\"x\">&'"), "<a href="x">&'");
}
#[test]
fn a_unix_time_is_a_calendar_day() {
assert_eq!(ymd(0), "1970-01-01");
assert_eq!(ymd(1_761_350_400), "2025-10-25");
assert_eq!(ymd(951_782_400), "2000-02-29");
assert_eq!(ymd(-1), "1969-12-31");
}
}