at 2f4eb91
use std::{ future::Future, path::{Path, PathBuf}, pin::Pin, time::Duration, }; use chrono::{DateTime, SecondsFormat, Utc}; use sha2::{Digest, Sha256}; use flate2::{Compression, write::GzEncoder}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result}; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html}; use std::collections::HashMap; use tera::{Context, Tera}; use tokio::sync::{mpsc, watch}; use tokio::task::JoinSet; use tracing::warn; use crate::{ builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo, }; pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2"); pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2"); pub const SITE_CSS: &str = include_str!("templates/site.css"); const ATOM_FEED_FILENAME: &str = "releases.atom"; // ── Types ─────────────────────────────────────────────────────────────────── /// A version entry as passed to Tera templates. #[derive(serde::Serialize)] struct VersionEntry { /// Version string (e.g. `"1.2.3"`). version: String, /// ISO-8601 date string (e.g. `"2024-01-15"`) when the release date is known. date: Option<String>, } impl VersionEntry { fn from_info(info: &VersionInfo) -> Self { Self { version: info.version.clone(), date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()), } } } /// Metadata about a single file-type dist artifact, passed to Tera templates. #[derive(serde::Serialize)] struct DistFileInfo { /// File name (relative to `dist/`). name: String, /// Raw byte size of the file. size_bytes: u64, /// Human-readable size (e.g. "1.4 MB"). size_human: String, /// Lowercase hex-encoded SHA-256 digest of the file contents. sha256: String, } /// Build the full website into `config.output_dir` (defaults to `public/`). /// /// # Steps /// /// 1. Extract the current version via the configured [`crate::version_extractors::AnyVersionExtractor`]. /// 2. Run every configured builder and split the resulting [`crate::builders::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.site.output_dir; tokio::fs::create_dir_all(output_dir) .await .into_diagnostic()?; // ── 1. Version ──────────────────────────────────────────────────────────── let version_info = config.version_extractor.extract().await?; let version = version_info.version.clone(); // ── 2. Tera setup ───────────────────────────────────────────────────────── let mut tera = Tera::default(); let theme_path = PathBuf::from(".abbaye").join("theme"); if theme_path.join("root_index.html.j2").is_file() { tera.add_template_file( theme_path.join("root_index.html.j2"), Some("root_index.html"), ) .into_diagnostic()?; } else { tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX) .into_diagnostic()?; } if theme_path.join("version_index.html.j2").is_file() { tera.add_template_file( theme_path.join("version_index.html.j2"), Some("version_index.html"), ) .into_diagnostic()?; } else { tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX) .into_diagnostic()?; } load_extra_theme_templates( &mut tera, &theme_path, &["root_index.html", "version_index.html"], )?; // Write shared CSS to static/ so the git UI templates can reference it. { let static_dir = output_dir.join("static"); tokio::fs::create_dir_all(&static_dir) .await .into_diagnostic()?; tokio::fs::write(static_dir.join("site.css"), SITE_CSS) .await .into_diagnostic()?; } // If there's a `static` directory in the theme, copy it over (may overwrite site.css). if theme_path.join("static").is_dir() { copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?; } // ── 3. Builders (run in parallel, respecting depends_on ordering) ───────── let mut dist_artifacts = Vec::new(); let mut doc_artifacts = Vec::new(); { use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW}; // ── Dependency validation ───────────────────────────────────────────── // // Build a map from id → index so we can reference builders by name. let id_to_idx: HashMap<&str, usize> = config .builders .iter() .enumerate() .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i))) .collect(); // Check that every depends_on reference resolves to a known id. for (i, entry) in config.builders.iter().enumerate() { for dep in &entry.depends_on { if !id_to_idx.contains_key(dep.as_str()) { return Err(miette::miette!( "builder #{i} ({}) lists '{}' in depends_on, \ but no builder has that id", entry.label(), dep )); } } } // Cycle detection via iterative DFS (0=unvisited, 1=in-stack, 2=done). { let n = config.builders.len(); let mut state = vec![0u8; n]; fn dfs( idx: usize, id_to_idx: &HashMap<&str, usize>, builders: &[crate::builders::BuilderEntry], state: &mut Vec<u8>, ) -> Result<()> { if state[idx] == 1 { return Err(miette::miette!( "dependency cycle detected involving builder #{idx} ({})", builders[idx].id.as_deref().unwrap_or(builders[idx].label()) )); } if state[idx] == 2 { return Ok(()); } state[idx] = 1; for dep in &builders[idx].depends_on { if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) { dfs(dep_idx, id_to_idx, builders, state)?; } } state[idx] = 2; Ok(()) } for i in 0..n { dfs(i, &id_to_idx, &config.builders, &mut state)?; } } // ── Completion signals ──────────────────────────────────────────────── // // For every builder that carries an `id` we open a watch channel. // Dependents receive a clone of the receiver and wait until the value // transitions from `None` (pending) to `Some(true)` (success) or // `Some(false)` (failure / dependency failure). let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new(); let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new(); for entry in &config.builders { if let Some(id) = &entry.id { let (tx, rx) = watch::channel(None::<bool>); completion_txs.insert(id.clone(), tx); completion_rxs.insert(id.clone(), rx); } } // ── Progress bars & task spawning ───────────────────────────────────── let total = config.builders.len(); let multi = MultiProgress::new(); // Bottom bar: overall completion counter. let summary = multi.add(ProgressBar::new(total as u64)); summary.set_style( ProgressStyle::with_template("{pos}/{len} builders {bar:20.green/white} {msg}") .expect("valid template"), ); summary.set_message("building…"); let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> = JoinSet::new(); for (i, entry) in config.builders.iter().enumerate() { let color = COLOURS[i % COLOURS.len()]; let label = entry.id.as_deref().unwrap_or(entry.label()); let colored_prefix = format!("{color}[{label}]{RESET}"); // Spinner inserted above the summary bar. let pb = multi.insert_before(&summary, ProgressBar::new_spinner()); pb.set_style( ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}") .expect("valid template") .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), ); pb.set_prefix(colored_prefix); pb.set_message("starting…"); pb.enable_steady_tick(Duration::from_millis(100)); let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>(); // Task: receive LogEvents and update spinners. // ChildStart creates a new sub-spinner inserted right below the // parent (and below any previously created siblings, via // `last_child_pb`). ChildLine / ChildFinish update or finish it. let pb_log = pb.clone(); let multi_log = multi.clone(); let parent_color_idx = i; tokio::spawn(async move { let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new(); // Track insertion point so siblings stack in order. let mut last_child_pb = pb_log.clone(); let mut child_color_idx = parent_color_idx + 1; while let Some(event) = log_rx.recv().await { match event { LogEvent::Line(line) => { pb_log.set_message(line); } LogEvent::ChildStart { id, label } => { let child_color = COLOURS[child_color_idx % COLOURS.len()]; child_color_idx += 1; let child_pb = multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner()); child_pb.set_style( ProgressStyle::with_template(" {spinner:.bold} {prefix} {msg}") .expect("valid template") .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), ); child_pb.set_prefix(format!("{child_color}[{label}]{RESET}")); child_pb.set_message("starting…"); child_pb.enable_steady_tick(Duration::from_millis(100)); last_child_pb = child_pb.clone(); child_pbs.insert(id, child_pb); } LogEvent::ChildLine { id, line } => { if let Some(child_pb) = child_pbs.get(&id) { child_pb.set_message(line); } } LogEvent::ChildFinish { id, success, summary, } => { if let Some(child_pb) = child_pbs.remove(&id) { if success { child_pb.finish_with_message(format!( "{GREEN}\u{2713}{RESET} {summary}" )); } else { child_pb.finish_with_message(format!( "{RED}\u{2717}{RESET} {summary}" )); } } } } } }); // Collect the watch receivers for every declared dependency. let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry .depends_on .iter() .filter_map(|dep_id| { completion_rxs .get(dep_id) .map(|rx| (dep_id.clone(), rx.clone())) }) .collect(); // Take ownership of the completion sender for this builder's own id // (if it has one) so the task can signal its outcome. let my_tx: Option<watch::Sender<Option<bool>>> = entry.id.as_ref().and_then(|id| completion_txs.remove(id)); let entry = entry.clone(); let version = version.clone(); let pb_task = pb.clone(); let summary_task = summary.clone(); join_set.spawn(async move { // ── Wait for dependencies ───────────────────────────────────── for (dep_id, mut rx) in dep_receivers { pb_task.set_message(format!("waiting for '{dep_id}'…")); // Block until the dependency resolves (Some(_)) or its // sender is dropped (which we treat as a failure). let resolved = rx.wait_for(|v| v.is_some()).await; let succeeded = match resolved { Err(_) => false, // sender dropped unexpectedly Ok(r) => r.unwrap_or(false), }; if !succeeded { summary_task.inc(1); pb_task.finish_with_message(format!( "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)" )); if let Some(tx) = &my_tx { let _ = tx.send(Some(false)); } // Return an empty artifact list; the dependency error // itself will surface from the dependency's own task. return Ok(vec![]); } } // ── Run the builder ─────────────────────────────────────────── pb_task.set_message("running…"); let result = entry.build(&version, log_tx).await; let succeeded = result.is_ok(); if let Some(tx) = my_tx { let _ = tx.send(Some(succeeded)); } summary_task.inc(1); match &result { Ok(artifacts) => pb_task.finish_with_message(format!( "{GREEN}\u{2713} done{RESET} ({} artifact(s))", artifacts.len() )), Err(e) => { pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}")) } } result }); } // Collect all results; continue even when some builders fail so every // spinner reaches its final state before we return an error. let mut errors: Vec<miette::Report> = Vec::new(); while let Some(res) = join_set.join_next().await { match res.into_diagnostic()? { Ok(artifacts) => { for artifact in artifacts { if artifact.path.is_dir() { doc_artifacts.push(artifact); } else { dist_artifacts.push(artifact); } } } Err(e) => errors.push(e), } } let summary_msg = if errors.is_empty() { format!("{GREEN}all done{RESET}") } else { format!("{RED}some builders failed{RESET}") }; summary.finish_with_message(summary_msg); if let Some(first_err) = errors.into_iter().next() { return Err(first_err); } } // ── 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()?; } // Compute size + SHA-256 for each copied dist artifact. let mut dist_file_infos: Vec<DistFileInfo> = Vec::new(); for artifact in &dist_artifacts { let dest = dist_dir.join(&artifact.name); let bytes = tokio::fs::read(&dest).await.into_diagnostic()?; let size_bytes = bytes.len() as u64; let sha256 = hex_sha256(&bytes); dist_file_infos.push(DistFileInfo { name: artifact.name.clone(), size_bytes, size_human: human_size(size_bytes), sha256, }); } let has_dist = !dist_file_infos.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() } }; // Copy any locally-referenced files (e.g. images) from the README's // directory into the version directory so they resolve correctly from // the generated index.html. let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new(".")); if let Ok(content) = tokio::fs::read_to_string(readme_path).await { for rel in extract_local_refs(&content) { let src = readme_dir.join(&rel); if src.is_file() { // Preserve any sub-directory structure relative to the README. let dest = version_dir.join(&rel); if let Some(parent) = dest.parent() { tokio::fs::create_dir_all(parent).await.into_diagnostic()?; } tokio::fs::copy(&src, &dest).await.into_diagnostic()?; } } } // ── 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 ───────────────────────────────────────────────── // Git UI integration: derive the clone URL once and compute the tag name // for the current version so the templates can link into the repository UI. let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| { cfg.clone_url.clone().or_else(|| { config .site .base_url .as_ref() .map(|b| format!("{}/repository.git", b.trim_end_matches('/'))) }) }); let version_tag = config.version_extractor.tag_name(&version); let mut version_ctx = Context::new(); version_ctx.insert("config", &config); version_ctx.insert("project_name", &config.site.name); version_ctx.insert("lang", &config.site.lang); version_ctx.insert("repo_url", &config.site.repo_url); 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_infos); version_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); version_ctx.insert("git_ui_clone_url", &git_ui_clone_url); version_ctx.insert("version_tag", &version_tag); 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 + Atom feed ────────────────────────────────────── // Collect every known version from the version extractor, ensure the // current version is present (handles untagged builds), then sort newest-first. let mut all_versions = config.version_extractor.extract_all().await?; if !all_versions.iter().any(|v| v.version == version) { all_versions.push(version_info.clone()); } all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version)); // Build the template-friendly list (version string + optional date string). let version_entries: Vec<VersionEntry> = all_versions.iter().map(VersionEntry::from_info).collect(); let base_url = config .site .base_url .as_deref() .map(|u| u.trim_end_matches('/')); let mut root_ctx = Context::new(); root_ctx.insert("config", &config); root_ctx.insert("project_name", &config.site.name); root_ctx.insert("lang", &config.site.lang); root_ctx.insert("repo_url", &config.site.repo_url); root_ctx.insert("versions", &version_entries); root_ctx.insert("atom_feed", ATOM_FEED_FILENAME); root_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); root_ctx.insert("git_ui_clone_url", &git_ui_clone_url); 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. Atom feed ───────────────────────────────────────────────────────── // Load all changelog sections once so we can embed release notes in feed entries. let changelog_sections = match ChangelogExtractor .all_sections(config.changelog.clone()) .await { Ok(map) => map, Err(_) => { warn!("Could not load changelog for Atom feed; entries will have no content"); std::collections::HashMap::new() } }; let atom_xml = generate_atom_feed( &config.site.name, &all_versions, base_url, &changelog_sections, ); tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml) .await .into_diagnostic()?; // ── 11. `latest` symlink ────────────────────────────────────────────────── if let Some(latest) = all_versions.first() { update_latest_symlink(output_dir, &latest.version)?; } Ok(()) } // ── Private helpers ─────────────────────────────────────────────────────────── /// Extract URLs of locally-referenced files from a Markdown document. /// /// Returns relative paths that are referenced as images or links and that do /// not look like remote URLs (no `://` scheme) or bare fragment anchors /// (starting with `#`). fn extract_local_refs(md: &str) -> Vec<String> { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let mut refs = Vec::new(); for event in Parser::new_ext(md, opts) { let url: Option<pulldown_cmark::CowStr> = match event { Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url), Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url), // pulldown-cmark emits End events with a TagEnd — ignore those. Event::End(TagEnd::Image | TagEnd::Link) => None, _ => None, }; if let Some(url) = url { let s = url.as_ref(); // Skip remote URLs, data URIs, and fragment-only links. if !s.contains("://") && !s.starts_with('#') && !s.is_empty() { refs.push(s.to_owned()); } } } refs } /// 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), } } /// Compute a lowercase hex-encoded SHA-256 digest of `data`. fn hex_sha256(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); hasher .finalize() .iter() .map(|b| format!("{b:02x}")) .collect() } /// Format a byte count as a human-readable string (e.g. "1.4 MB"). fn human_size(bytes: u64) -> String { const KIB: u64 = 1024; const MIB: u64 = KIB * 1024; const GIB: u64 = MIB * 1024; if bytes >= GIB { format!("{:.1} GB", bytes as f64 / GIB as f64) } else if bytes >= MIB { format!("{:.1} MB", bytes as f64 / MIB as f64) } else if bytes >= KIB { format!("{:.1} KB", bytes as f64 / KIB as f64) } else { format!("{bytes} B") } } /// Escape the five XML predefined characters in a string. fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Generate an Atom 1.0 feed listing all known releases. /// /// - `base_url`: when provided (already stripped of trailing `/`), used to /// build `<link>` and `<id>` elements with absolute URLs. When absent, /// a `urn:` based ID is used and no `<link>` elements are emitted. /// - `changelog_sections`: map from version string to Markdown release-note /// body. When a matching entry is found it is rendered to HTML and included /// as a `<content type="html">` element in the Atom entry. fn generate_atom_feed( project_name: &str, versions: &[VersionInfo], base_url: Option<&str>, changelog_sections: &std::collections::HashMap<String, String>, ) -> String { let now: DateTime<Utc> = Utc::now(); // The feed's <updated> is the most-recent entry date, or now as fallback. let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now); let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true); // Feed-level <id> and self-link. let feed_id = match base_url { Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"), None => format!("urn:abbaye:feed:{}", xml_escape(project_name)), }; let self_link = match base_url { Some(base) => format!( " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n", xml_escape(base) ), None => String::new(), }; let alt_link = match base_url { Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)), None => String::new(), }; let entries: String = versions .iter() .map(|vi| { let entry_date = vi.date.unwrap_or(now); let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true); let v_escaped = xml_escape(&vi.version); let entry_id = match base_url { Some(base) => format!("{base}/{v_escaped}/"), None => format!( "urn:abbaye:release:{}:{v_escaped}", xml_escape(project_name) ), }; let entry_link = match base_url { Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"), None => String::new(), }; // Render the changelog section (if any) to HTML, then XML-escape // it for embedding inside <content type="html">. let content_element = match changelog_sections.get(&vi.version) { Some(md) if !md.is_empty() => { let html = render_markdown(md); format!( " <content type=\"html\">{}</content>\n", xml_escape(&html) ) } _ => String::new(), }; format!( " <entry>\n\ \x20 <title>{v_escaped}</title>\n\ \x20 <id>{entry_id}</id>\n\ \x20 <updated>{entry_date_str}</updated>\n\ {entry_link}\ {content_element}\ \x20 </entry>" ) }) .collect::<Vec<_>>() .join("\n"); format!( "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\ <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\ \x20 <title>{name} Releases</title>\n\ {self_link}\ {alt_link}\ \x20 <updated>{feed_updated_str}</updated>\n\ \x20 <id>{feed_id}</id>\n\ {entries}\n\ </feed>\n", name = xml_escape(project_name), ) } /// 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(()) } /// Scans `theme_path` for any `*.j2` files whose stem (the name without the /// `.j2` suffix, e.g. `"base.html"`) is **not** already listed in `skip`, and /// loads each one into `tera` under that stem name. /// /// This makes user-supplied helper or base templates — e.g. a `base.html.j2` /// referenced by `{% extends "base.html" %}` in a customised main template — /// available at render time without the caller needing to enumerate them. pub(crate) fn load_extra_theme_templates( tera: &mut tera::Tera, theme_path: &std::path::Path, skip: &[&str], ) -> miette::Result<()> { let entries = match std::fs::read_dir(theme_path) { Ok(e) => e, Err(_) => return Ok(()), // theme dir absent — nothing to do }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("j2") { continue; } let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue; }; if skip.contains(&stem) { continue; } tera.add_template_file(&path, Some(stem)).map_err(|e| { miette::miette!("failed to load theme template {}: {e}", path.display()) })?; } Ok(()) }