crates/gunnar-public-site/src/sitemap.rs
//! **`/sitemap.xml`** (S-3): the public pages and every PUBLIC repository,
//! each with its `lastmod`, rebuilt with the rest of the site on the timer. The
//! addresses are the console's (GUN-GUEST): a repository, its issues, and its
//! documentation (each Markdown file outside a hidden directory) at any depth.
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::sync::Arc;
use crate::head::{esc, ymd};
use crate::{issues, robots, text, Config, RepoRef, RepoSnapshot};
/// The documentation files one repository lists at most.
const MAX_DOCS_PER_REPO: usize = 500;
/// A documentation file: Markdown, outside any hidden directory (`.nornir/`, `.github/`).
fn is_doc(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
(lower.ends_with(".md") || lower.ends_with(".markdown")) && !path.split('/').any(|seg| seg.starts_with('.'))
}
/// The file. `now` is the build's own time, the home's lastmod when no
/// repository is newer.
pub fn sitemap_xml(config: &Config, repos: &[(RepoRef, Arc<RepoSnapshot>)], kept: &issues::Kept, now_unix: i64) -> String {
let base = &config.base_url;
let newest = repos.iter().filter_map(|(_, s)| s.head.as_ref().map(|c| c.at_unix)).max();
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
let mut url = |loc: String, lastmod: Option<i64>| {
// A namespace whose name starts like a console path (`/accessories`
// under `Disallow: /access`) is left out, so the sitemap never lists
// what robots.txt tells a crawler to stay out of.
let path = loc.strip_prefix(base.as_str()).unwrap_or(&loc);
if robots::DISALLOWED.iter().any(|d| path.starts_with(d)) {
return;
}
let _ = write!(s, "<url><loc>{}</loc>", esc(&loc));
if let Some(t) = lastmod {
let _ = write!(s, "<lastmod>{}</lastmod>", ymd(t));
}
s.push_str("</url>\n");
};
url(format!("{base}/"), Some(newest.unwrap_or(now_unix)));
if config.signup {
url(format!("{base}/signup"), None);
}
let namespaces: BTreeSet<&str> = repos.iter().map(|(r, _)| r.namespace.as_str()).collect();
for ns in namespaces {
let at = repos.iter().filter(|(r, _)| r.namespace == ns).filter_map(|(_, s)| s.head.as_ref().map(|c| c.at_unix)).max();
url(format!("{base}/{ns}"), at);
}
for (repo, snap) in repos {
let at = snap.head.as_ref().map(|c| c.at_unix);
url(text::repo_url(config, repo), at);
for doc in snap.tree.iter().filter(|e| !e.dir && is_doc(&e.path)).take(MAX_DOCS_PER_REPO) {
url(format!("{}/blob/{}", text::repo_url(config, repo), text::pct_encode_path(&doc.path)), at);
}
// PUB-ISSUES: the issue list and each issue, by when it last moved.
let list = kept.get(&issues::list_path(repo)).map(|(_, l)| l.as_slice()).unwrap_or_default();
url(format!("{base}{}", issues::list_path(repo)), list.iter().map(crate::Issue::last_at_unix).max());
for issue in list {
url(format!("{base}{}", issues::issue_path(repo, issue.number)), Some(issue.last_at_unix()));
}
}
s.push_str("</urlset>\n");
s
}