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