use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
use crate::llms::{self, PageEntry};
use crate::minify;
use crate::render;
use crate::types::{Page, SiteConfig};
pub fn run(dir: &Path, out: &Path, release: bool, allow_orphans: bool) -> Result<()> {
let config = load_config(dir)?;
fs::create_dir_all(out)?;
let out_canonical = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
let mut pages = 0;
let mut assets = 0;
let mut entries: Vec<PageEntry> = Vec::new();
let today = crate::freshness::today_iso();
let mut stale_pages: Vec<StaleEntry> = Vec::new();
let mut page_links: Vec<crate::links::PageLinks> = Vec::new();
for entry in WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.path()
.canonicalize()
.map(|p| p.starts_with(&out_canonical))
.unwrap_or(false)
{
return false;
}
if e.depth() > 0 && e.file_name() == "_site" && e.file_type().is_dir() {
return false;
}
if e.depth() > 0 {
if let Some(name) = e.file_name().to_str() {
if name.starts_with('.') {
return false;
}
}
}
true
})
{
let entry = entry?;
let path = entry.path();
if !entry.file_type().is_file() {
continue;
}
let fname = path.file_name().unwrap_or_default();
if fname == "kazam.yaml" {
continue;
}
let rel = path.strip_prefix(dir)?;
let is_yaml = path.extension().map(|e| e == "yaml").unwrap_or(false);
if is_yaml {
let content =
fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?;
let page: Page =
serde_yaml::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
let base = base_path_for(rel);
let source_filename = rel
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let source_stem = rel
.file_stem()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let source_view_href = if config.view_source {
format!("{}.source.html", source_stem)
} else {
String::new()
};
let html_rel = rel
.with_extension("html")
.to_string_lossy()
.replace('\\', "/");
let source_rel = format!("{}.source.html", source_stem);
let mut html = render::render_page(&page, &config, &base, &source_view_href, &html_rel);
if release {
html = minify::minify_html(&html);
}
let out_path = out.join(rel).with_extension("html");
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&out_path, html)?;
if config.view_source {
let mut source_view = render::render_source_view(
&page,
&config,
&content,
&base,
&source_filename,
&source_rel,
);
if release {
source_view = minify::minify_html(&source_view);
}
let source_view_path =
out_path.with_file_name(format!("{}.source.html", source_stem));
fs::write(&source_view_path, source_view)?;
}
let yaml_out = out.join(rel);
fs::copy(path, &yaml_out)?;
if !page.unlisted {
let html_path = rel.with_extension("html").to_string_lossy().to_string();
let yaml_path = rel.to_string_lossy().to_string();
entries.push(PageEntry {
title: page.title.clone(),
subtitle: page.subtitle.clone(),
html_path,
yaml_path,
});
}
let html_rel_for_links = rel
.with_extension("html")
.to_string_lossy()
.replace('\\', "/");
page_links.push(crate::links::collect_page_links(&html_rel_for_links, &page));
if let Some(info) = crate::freshness::info_for(page.freshness.as_ref(), &today) {
let status = info.status();
if !matches!(status, crate::freshness::FreshnessStatus::Fresh) {
stale_pages.push(StaleEntry {
html_path: rel.with_extension("html").to_string_lossy().to_string(),
title: page.title.clone(),
owner: page.freshness.as_ref().and_then(|f| f.owner.clone()),
status,
cadence: page
.freshness
.as_ref()
.and_then(|f| f.review_every.clone())
.unwrap_or_default(),
});
}
}
println!(" {}", out_path.display());
pages += 1;
} else {
let out_path = out.join(rel);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(path, &out_path)?;
assets += 1;
}
}
if !entries.is_empty() {
llms::write(out, &config, &entries)?;
}
if let Some(site_url) = config.url.as_deref() {
write_sitemap(out, site_url, &entries)?;
write_robots(out, site_url)?;
}
if assets > 0 {
println!(
"\n✓ {} page(s), {} asset(s) → {}{}",
pages,
assets,
out.display(),
if release { " (minified)" } else { "" }
);
} else {
println!(
"\n✓ {} page(s) → {}{}",
pages,
out.display(),
if release { " (minified)" } else { "" }
);
}
print_freshness_report(&stale_pages);
write_freshness_report_md(out, &stale_pages, &today)?;
let mut report = crate::links::analyze(&page_links, config.nav.as_deref());
if allow_orphans {
report.orphans.clear();
}
crate::links::print_report(&report);
crate::links::write_report_md(out, &report)?;
Ok(())
}
struct StaleEntry {
html_path: String,
#[allow(dead_code)]
title: String,
owner: Option<String>,
status: crate::freshness::FreshnessStatus,
cadence: String,
}
fn write_freshness_report_md(out: &Path, stale: &[StaleEntry], today: &str) -> std::io::Result<()> {
use crate::freshness::FreshnessStatus;
if stale.is_empty() {
let p = out.join("stale.md");
if p.exists() {
fs::remove_file(p)?;
}
return Ok(());
}
let mut overdue: Vec<&StaleEntry> = stale
.iter()
.filter(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
.collect();
let mut due_soon: Vec<&StaleEntry> = stale
.iter()
.filter(|e| matches!(e.status, FreshnessStatus::DueSoon { .. }))
.collect();
overdue.sort_by_key(|e| match e.status {
FreshnessStatus::Overdue { days_overdue } => -days_overdue,
_ => 0,
});
due_soon.sort_by_key(|e| match e.status {
FreshnessStatus::DueSoon { days_until_due } => days_until_due,
_ => 0,
});
let mut md = String::new();
md.push_str(&format!(
"# Stale page report\n\n_Generated {} by `kazam build`. Point an agent at this file and ask it to refresh the listed pages — they're in the source tree as `.yaml`, each with its own `freshness.sources_of_truth`._\n\n",
today
));
if !overdue.is_empty() {
md.push_str(&format!("## Overdue ({})\n\n", overdue.len()));
for e in &overdue {
let days = match e.status {
FreshnessStatus::Overdue { days_overdue } => days_overdue,
_ => 0,
};
let owner = e
.owner
.as_deref()
.map(|o| format!(" — owner: {}", o))
.unwrap_or_default();
md.push_str(&format!(
"- **`{}`** — {} day(s) overdue (cadence: every {}){}\n",
e.html_path, days, e.cadence, owner
));
}
md.push('\n');
}
if !due_soon.is_empty() {
md.push_str(&format!("## Due soon ({})\n\n", due_soon.len()));
for e in &due_soon {
let days = match e.status {
FreshnessStatus::DueSoon { days_until_due } => days_until_due,
_ => 0,
};
let owner = e
.owner
.as_deref()
.map(|o| format!(" — owner: {}", o))
.unwrap_or_default();
md.push_str(&format!(
"- **`{}`** — due in {} day(s) (cadence: every {}){}\n",
e.html_path, days, e.cadence, owner
));
}
md.push('\n');
}
fs::write(out.join("stale.md"), md)
}
fn print_freshness_report(stale: &[StaleEntry]) {
use crate::freshness::FreshnessStatus;
let mut overdue: Vec<&StaleEntry> = stale
.iter()
.filter(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
.collect();
let mut due_soon: Vec<&StaleEntry> = stale
.iter()
.filter(|e| matches!(e.status, FreshnessStatus::DueSoon { .. }))
.collect();
if overdue.is_empty() && due_soon.is_empty() {
return;
}
overdue.sort_by_key(|e| match e.status {
FreshnessStatus::Overdue { days_overdue } => -days_overdue,
_ => 0,
});
due_soon.sort_by_key(|e| match e.status {
FreshnessStatus::DueSoon { days_until_due } => days_until_due,
_ => 0,
});
println!();
if !overdue.is_empty() {
println!("⚠ {} overdue page(s):", overdue.len());
for e in overdue {
let days = match e.status {
FreshnessStatus::Overdue { days_overdue } => days_overdue,
_ => 0,
};
let owner = e
.owner
.as_deref()
.map(|o| format!(" — owner {}", o))
.unwrap_or_default();
println!(
" {:<40} {} day(s) overdue (cadence: every {}){}",
e.html_path, days, e.cadence, owner
);
}
}
if !due_soon.is_empty() {
if !stale.is_empty()
&& stale
.iter()
.any(|e| matches!(e.status, FreshnessStatus::Overdue { .. }))
{
println!();
}
println!("⏳ {} page(s) due for review soon:", due_soon.len());
for e in due_soon {
let days = match e.status {
FreshnessStatus::DueSoon { days_until_due } => days_until_due,
_ => 0,
};
let owner = e
.owner
.as_deref()
.map(|o| format!(" — owner {}", o))
.unwrap_or_default();
println!(
" {:<40} due in {} day(s) (cadence: every {}){}",
e.html_path, days, e.cadence, owner
);
}
}
}
fn write_sitemap(out: &Path, site_url: &str, entries: &[PageEntry]) -> Result<()> {
let base = site_url.trim_end_matches('/');
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
);
for e in entries {
xml.push_str(&format!(
" <url><loc>{}/{}</loc></url>\n",
base,
xml_escape(&e.html_path)
));
}
xml.push_str("</urlset>\n");
fs::write(out.join("sitemap.xml"), xml)?;
Ok(())
}
fn write_robots(out: &Path, site_url: &str) -> Result<()> {
let base = site_url.trim_end_matches('/');
let body = format!("User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n", base);
fs::write(out.join("robots.txt"), body)?;
Ok(())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn base_path_for(rel: &Path) -> String {
let depth = rel.parent().map(|p| p.components().count()).unwrap_or(0);
"../".repeat(depth)
}
fn load_config(dir: &Path) -> Result<SiteConfig> {
let config_path = dir.join("kazam.yaml");
if config_path.exists() {
let content = fs::read_to_string(&config_path)?;
serde_yaml::from_str(&content).context("parsing kazam.yaml")
} else {
Ok(SiteConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_path_at_root_is_empty() {
assert_eq!(base_path_for(Path::new("index.yaml")), "");
}
#[test]
fn base_path_one_level_deep() {
assert_eq!(base_path_for(Path::new("customers/acme.yaml")), "../");
}
#[test]
fn base_path_two_levels_deep() {
assert_eq!(base_path_for(Path::new("a/b/c.yaml")), "../../");
}
}