at bb4e6ed
use std::{ future::Future, path::{Path, PathBuf}, pin::Pin, }; use figment::{ Figment, providers::{Format, Toml}, }; use flate2::{Compression, write::GzEncoder}; use miette::{IntoDiagnostic, Result}; use pulldown_cmark::{Options, Parser, html}; use tera::{Context, Tera}; use tracing::warn; use crate::{changelog::ChangelogExtractor, config::AbbayeConfig}; const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html"); const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html"); // ── Public API ──────────────────────────────────────────────────────────────── /// Load the Abbaye2 configuration from the current working directory. /// /// Looks for `.abbaye.toml` first, then `abbaye.toml`; when both are present /// `abbaye.toml` takes precedence (last merge wins). pub fn load_config() -> Result<AbbayeConfig> { let cwd = std::env::current_dir().into_diagnostic()?; Figment::new() .merge(Toml::file(cwd.join(".abbaye.toml"))) .merge(Toml::file(cwd.join("abbaye.toml"))) .extract() .into_diagnostic() } /// Build the full website into `config.output_dir` (defaults to `public/`). /// /// # Steps /// /// 1. Extract the current version via the configured [`AnyVersionExtractor`]. /// 2. Run every configured builder and split the resulting [`ArtifactPath`]s /// into *dist* (regular files) and *docs* (directories). /// 3. Copy dist artifacts to `<output>/<version>/dist/`. /// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive /// the whole `docs/` tree as `docs.tar.gz`. /// 5. Render `<output>/<version>/index.html` from the project README and the /// matching changelog section. /// 6. Re-render the root `<output>/index.html`, listing all known versions /// (newest first). /// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other). pub async fn build_site(config: AbbayeConfig) -> Result<()> { let output_dir = config .output_dir .clone() .unwrap_or_else(|| PathBuf::from("public")); tokio::fs::create_dir_all(&output_dir) .await .into_diagnostic()?; // ── 1. Version ──────────────────────────────────────────────────────────── let version = config.version_extractor.extract().await?; // ── 2. Tera setup ───────────────────────────────────────────────────────── let mut tera = Tera::default(); tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX) .into_diagnostic()?; tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX) .into_diagnostic()?; // ── 3. Builders ─────────────────────────────────────────────────────────── let mut dist_artifacts = Vec::new(); let mut doc_artifacts = Vec::new(); for builder in &config.builders { for artifact in builder.build().await? { if artifact.path.is_dir() { doc_artifacts.push(artifact); } else { dist_artifacts.push(artifact); } } } // ── 4. Lay out dist/ ────────────────────────────────────────────────────── let version_dir = output_dir.join(&version); let dist_dir = version_dir.join("dist"); tokio::fs::create_dir_all(&dist_dir) .await .into_diagnostic()?; for artifact in &dist_artifacts { tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name)) .await .into_diagnostic()?; } let dist_file_names: Vec<String> = dist_artifacts.iter().map(|a| a.name.clone()).collect(); let has_dist = !dist_file_names.is_empty(); // ── 5. Lay out docs/ ────────────────────────────────────────────────────── let has_docs = !doc_artifacts.is_empty(); let has_docs_tarball; if has_docs { let docs_dir = version_dir.join("docs"); for artifact in &doc_artifacts { // Copy the complete target/doc tree — this includes the shared // rustdoc CSS, JS, fonts and search indices at the root of the // directory in addition to the per-crate HTML subdirectories. copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?; } // Rustdoc writes index.html at the root for single-crate projects. // For workspaces it may be absent; generate a fallback listing then. if !docs_dir.join("index.html").exists() { let crate_names = find_doc_crates(&docs_dir).await?; write_docs_index(&docs_dir, &crate_names).await?; } let tarball = version_dir.join("docs.tar.gz"); let docs_dir_c = docs_dir.clone(); let tarball_c = tarball.clone(); tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c)) .await .into_diagnostic()??; has_docs_tarball = true; } else { has_docs_tarball = false; } // ── 6. README ───────────────────────────────────────────────────────────── let readme_path = config .site .readme .as_deref() .unwrap_or(Path::new("README.md")); let readme_html = match tokio::fs::read_to_string(readme_path).await { Ok(content) => render_markdown(&content), Err(_) => { warn!("README not found at {}", readme_path.display()); String::new() } }; // ── 7. Changelog section ────────────────────────────────────────────────── let changelog_html = match ChangelogExtractor .section(config.changelog.clone(), &version) .await { Ok(section) => render_markdown(§ion), Err(_) => { warn!("No changelog entry found for version {version}"); String::new() } }; // ── 8. Version index.html ───────────────────────────────────────────────── let mut version_ctx = Context::new(); version_ctx.insert("project_name", &config.site.name); version_ctx.insert("version", &version); version_ctx.insert("readme_html", &readme_html); version_ctx.insert("changelog_html", &changelog_html); version_ctx.insert("has_docs", &has_docs); version_ctx.insert("has_docs_tarball", &has_docs_tarball); version_ctx.insert("has_dist", &has_dist); version_ctx.insert("dist_files", &dist_file_names); let version_html = tera .render("version_index.html", &version_ctx) .into_diagnostic()?; tokio::fs::write(version_dir.join("index.html"), version_html) .await .into_diagnostic()?; // ── 9. Root index.html ──────────────────────────────────────────────────── // Collect every known version from the version extractor, ensure the // current version is present (handles untagged builds), then sort newest-first. let mut versions = config.version_extractor.extract_all().await?; if !versions.contains(&version) { versions.push(version.clone()); } versions.sort_by(|a, b| compare_versions(b, a)); let mut root_ctx = Context::new(); root_ctx.insert("project_name", &config.site.name); root_ctx.insert("versions", &versions); let root_html = tera .render("root_index.html", &root_ctx) .into_diagnostic()?; tokio::fs::write(output_dir.join("index.html"), root_html) .await .into_diagnostic()?; // ── 10. `latest` symlink ────────────────────────────────────────────────── if let Some(latest) = versions.first() { update_latest_symlink(&output_dir, latest)?; } Ok(()) } // ── Private helpers ─────────────────────────────────────────────────────────── /// Render Markdown to an HTML string. fn render_markdown(md: &str) -> String { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let parser = Parser::new_ext(md, opts); let mut buf = String::new(); html::push_html(&mut buf, parser); buf } /// Scan `docs_dir` for subdirectories that contain an `index.html` and return /// their names. Used to build a fallback listing when rustdoc itself did not /// generate a root `index.html` (typical for multi-crate workspaces). async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> { let mut names = Vec::new(); let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?; while let Some(entry) = entries.next_entry().await.into_diagnostic()? { let path = entry.path(); if path.is_dir() && path.join("index.html").exists() { names.push(entry.file_name().to_string_lossy().into_owned()); } } Ok(names) } /// Write a `docs/index.html` that either redirects straight to the single /// crate's docs (one crate) or lists all crates (multiple crates). async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> { let html = if crate_names.len() == 1 { format!( "<!DOCTYPE html><html><head>\ <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\ </head></html>", crate_names[0] ) } else { let items = crate_names .iter() .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>")) .collect::<Vec<_>>() .join("\n"); format!( "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\ <title>Documentation</title></head>\ <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>" ) }; tokio::fs::write(docs_dir.join("index.html"), html) .await .into_diagnostic() } /// Recursively copy the contents of `src` into `dst`. /// /// Uses explicit boxing to satisfy the compiler for the async recursive call. fn copy_dir_recursive( src: PathBuf, dst: PathBuf, ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> { Box::pin(async move { tokio::fs::create_dir_all(&dst).await.into_diagnostic()?; let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?; while let Some(entry) = entries.next_entry().await.into_diagnostic()? { let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(src_path, dst_path).await?; } else { tokio::fs::copy(&src_path, &dst_path) .await .into_diagnostic()?; } } Ok(()) }) } /// Pack `src` directory into a `.tar.gz` archive at `dest`. /// /// The top-level entry inside the archive is named after the source directory. fn archive_dir(src: &Path, dest: &Path) -> Result<()> { let dir_name = src .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "docs".to_owned()); let file = std::fs::File::create(dest).into_diagnostic()?; let enc = GzEncoder::new(file, Compression::default()); let mut archive = tar::Builder::new(enc); archive.append_dir_all(&dir_name, src).into_diagnostic()?; archive .into_inner() .into_diagnostic()? .finish() .into_diagnostic()?; Ok(()) } /// Compare two version strings, preferring semver ordering and falling back /// to lexicographic comparison for non-semver strings (e.g. git describe output). fn strip_v(s: &str) -> &str { s.strip_prefix('v').unwrap_or(s) } fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { match ( semver::Version::parse(strip_v(a)), semver::Version::parse(strip_v(b)), ) { (Ok(va), Ok(vb)) => va.cmp(&vb), _ => a.cmp(b), } } /// Create or replace the `latest` symlink in `output_dir`, pointing to /// `version_dir_name`. /// /// On non-Unix platforms a meta-refresh redirect page is written instead. fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> { let link = output_dir.join("latest"); #[cfg(unix)] { // Remove any stale symlink or file before (re-)creating it. if link.exists() || link.is_symlink() { std::fs::remove_file(&link).into_diagnostic()?; } std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?; } #[cfg(not(unix))] { std::fs::create_dir_all(&link).into_diagnostic()?; std::fs::write( link.join("index.html"), format!( "<!DOCTYPE html><html><head>\ <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\ </head></html>" ), ) .into_diagnostic()?; } Ok(()) }