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/text.rs

//! The plain-text and Markdown pages: `/llms.txt`, `/llms-full.txt`, and per
//! public repository its README and file tree at a stable URL.

use std::fmt::Write as _;
use std::sync::Arc;

use crate::{Config, RepoRef, RepoSnapshot};

/// The most README text one repository adds to `/llms-full.txt`.
const FULL_README_BYTES: usize = 32 * 1024;
/// The most `/llms-full.txt` grows to in all.
const FULL_BYTES: usize = 1024 * 1024;

/// `/{ns}/{name}`.
pub fn repo_url(config: &Config, repo: &RepoRef) -> String {
    format!("{}/{}/{}", config.base_url, repo.namespace, repo.name)
}

/// `/{ns}/{name}/readme.md`: the README as Markdown.
pub fn readme_md_path(repo: &RepoRef) -> String {
    format!("/{}/{}/readme.md", repo.namespace, repo.name)
}

/// `/{ns}/{name}/tree.txt`: the file tree as plain text.
pub fn tree_txt_path(repo: &RepoRef) -> String {
    format!("/{}/{}/tree.txt", repo.namespace, repo.name)
}

/// The clone line for a public repository, when the operator set a git URL.
pub fn clone_url(config: &Config, repo: &RepoRef) -> Option<String> {
    config.git_url.as_ref().map(|g| format!("{}/{}/{}", g.trim_end_matches('/'), repo.namespace, repo.name))
}

/// The way in, as `(path, title, what it is)`: sign up (where this site signs
/// people up) and log in.
pub fn key_pages(config: &Config) -> Vec<(String, &'static str, &'static str)> {
    let mut out = Vec::new();
    if config.signup {
        out.push(("/signup".to_owned(), "Sign up", "make an account"));
    }
    out.push(("/login".to_owned(), "Log in", "sign in with a passkey"));
    out
}

/// **`/llms.txt`** (llmstxt.org): the one line of what Gunnar is
/// ([`crate::GUNNAR`]), the way in and the public repositories. Nothing else
/// (owner 2026-10-29).
pub fn llms_txt(config: &Config, repos: &[(RepoRef, Arc<RepoSnapshot>)]) -> String {
    let base = &config.base_url;
    let mut s = String::new();
    let _ = writeln!(s, "# {}\n", config.site_name);
    let _ = writeln!(s, "> {}\n", config.description);
    for (path, title, what) in key_pages(config) {
        let _ = writeln!(s, "- [{title}]({base}{path}): {what}");
    }
    let _ = writeln!(s, "\n## Public repositories\n");
    if repos.is_empty() {
        let _ = writeln!(s, "- (none yet)");
    }
    for (repo, _) in repos {
        let what = if repo.description.trim().is_empty() { String::new() } else { format!(": {}", one_line(&repo.description)) };
        let _ = writeln!(
            s,
            "- [{}]({}){what} (README: {base}{}, files: {base}{}, issues: {base}{})",
            repo.path(),
            repo_url(config, repo),
            readme_md_path(repo),
            tree_txt_path(repo),
            crate::issues::archive_path(repo)
        );
    }
    s
}

/// **`/llms-full.txt`**: what `/llms.txt` says, then each public repository's
/// README (bounded).
pub fn llms_full_txt(config: &Config, repos: &[(RepoRef, Arc<RepoSnapshot>)]) -> String {
    let mut s = llms_txt(config, repos);
    s.push_str("\n# Public repositories, their READMEs\n\n");
    for (repo, snap) in repos {
        if s.len() >= FULL_BYTES {
            let _ = writeln!(s, "(The rest is left out here; see {}/llms.txt for every repository.)", config.base_url);
            break;
        }
        let _ = writeln!(s, "## {}\n\nAt {}.", repo.path(), repo_url(config, repo));
        if let Some(c) = clone_url(config, repo) {
            let _ = writeln!(s, "Clone it with `git clone {c}`.");
        }
        if !repo.description.trim().is_empty() {
            let _ = writeln!(s, "\n{}", one_line(&repo.description));
        }
        match &snap.readme {
            Some((name, text)) => {
                let cut = cut(text, FULL_README_BYTES);
                let _ = writeln!(s, "\n### {name}\n\n{}", demote(cut.trim_end()));
                if cut.len() < text.len() {
                    let _ = writeln!(s, "\n(The README goes on at {}{}.)", config.base_url, readme_md_path(repo));
                }
            }
            None => {
                let _ = writeln!(s, "\nThis repository has no README.");
            }
        }
        s.push('\n');
    }
    s
}

/// `/{ns}/{name}/readme.md`.
pub fn readme_md(config: &Config, repo: &RepoRef, snap: &RepoSnapshot) -> String {
    match &snap.readme {
        Some((_, text)) => text.clone(),
        None => format!("# {}\n\nThis repository has no README. Its files: {}{}\n", repo.path(), config.base_url, tree_txt_path(repo)),
    }
}

/// `/{ns}/{name}/tree.txt`: one path per line, directories with a slash.
pub fn tree_txt(repo: &RepoRef, snap: &RepoSnapshot) -> String {
    let mut s = format!("# {}: files", repo.path());
    if let Some(head) = &snap.head {
        let _ = write!(s, " at {}", short(&head.oid));
    }
    s.push('\n');
    for e in &snap.tree {
        s.push_str(&e.path);
        if e.dir {
            s.push('/');
        }
        s.push('\n');
    }
    if !snap.tree_complete {
        s.push_str("# (cut here: the tree is longer; clone the repository for all of it)\n");
    }
    s
}

/// The first 12 characters of an object id.
pub fn short(oid: &str) -> &str {
    &oid[..oid.len().min(12)]
}

/// Newlines out, for a description on one list line.
pub fn one_line(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// At most `max` bytes of `text`, cut on a character boundary.
pub fn cut(text: &str, max: usize) -> &str {
    if text.len() <= max {
        return text;
    }
    let mut end = max;
    while !text.is_char_boundary(end) {
        end -= 1;
    }
    &text[..end]
}

/// A README's headings pushed down two levels, so it nests under its
/// repository's heading in one file.
fn demote(markdown: &str) -> String {
    let mut out = String::with_capacity(markdown.len() + 64);
    let mut fence = false;
    for line in markdown.lines() {
        if line.trim_start().starts_with("```") {
            fence = !fence;
        }
        if !fence && line.starts_with('#') {
            out.push_str("###");
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

/// Percent-decode a URL path; `None` when the bytes are not UTF-8.
pub fn pct_decode(path: &str) -> Option<String> {
    let b = path.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 2 < b.len() {
            let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
            if let Some(v) = hex {
                out.push(v);
                i += 3;
                continue;
            }
        }
        out.push(b[i]);
        i += 1;
    }
    String::from_utf8(out).ok()
}

/// Percent-encode a repository file path for a URL: `/` is kept, every byte
/// outside the unreserved set is encoded.
pub fn pct_encode_path(path: &str) -> String {
    let mut s = String::with_capacity(path.len());
    for b in path.bytes() {
        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {
            s.push(b as char);
        } else {
            let _ = write!(s, "%{b:02X}");
        }
    }
    s
}

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

    #[test]
    fn a_path_round_trips_through_the_url() {
        for p in ["docs/a b.md", "src/ü.rs", "plain/path.txt", "100%.txt"] {
            assert_eq!(pct_decode(&pct_encode_path(p)).as_deref(), Some(p));
        }
        assert_eq!(pct_decode("/a%2"), Some("/a%2".to_owned()));
        assert_eq!(pct_decode("/%ff"), None);
    }

    #[test]
    fn a_long_text_is_cut_on_a_character() {
        assert_eq!(cut("åäö", 3), "å");
        assert_eq!(cut("abc", 10), "abc");
    }

    #[test]
    fn a_readme_nests_under_its_repository() {
        assert_eq!(demote("# Title\n```\n# not a heading\n```\n## Sub"), "#### Title\n```\n# not a heading\n```\n##### Sub\n");
    }

    #[test]
    fn a_description_is_one_line() {
        assert_eq!(one_line("a\n b\tc "), "a b c");
    }
}