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/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("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            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\">&'"), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
    }

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