Commit
Message
Changed Files (10)
-
modified CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f31062..23ad161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [unreleased] +### 🚀 Features + +- Render dist file metadata in version index +- Render dist metadata in version index + RSS + ### 📚 Documentation - Add Logos and fill up the README -
modified Cargo.lock
diff --git a/Cargo.lock b/Cargo.lock index 0814612..941a4bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "abbaye" version = "0.1.1" dependencies = [ + "chrono", "figment", "flate2", "globset", @@ -173,7 +174,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] -
modified Cargo.toml
diff --git a/Cargo.toml b/Cargo.toml index 6411f90..97590d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ include = ["CHANGELOG.md", "README.md", "src/**/*"] rust-version = "1.85.1" [dependencies] +chrono = { version = "0.4", features = ["serde"] } figment = { version = "0.10.19", features = ["env", "toml"] } flate2 = "1" globset = "0.4" -
modified abbaye.toml
diff --git a/abbaye.toml b/abbaye.toml index e836b26..5ed77fe 100644 --- a/abbaye.toml +++ b/abbaye.toml @@ -1,5 +1,6 @@ [site] name = "Abbaye" +base_url = "http://vit.am/~ololduck/abbaye/" [version_extractor] type = "git" -
modified src/config.rs
diff --git a/src/config.rs b/src/config.rs index d594db1..a5bcf0e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,10 @@ pub struct SiteConfig { /// Path to the README file rendered on each version page. /// Defaults to `README.md` in the current working directory. pub readme: Option<PathBuf>, + /// Canonical base URL of the published site (e.g. `"https://example.com"`). + /// When set, the generated `releases.atom` feed will include absolute links + /// and proper entry IDs. Trailing slashes are stripped automatically. + pub base_url: Option<String>, } /// A full configuration for the Abbaye site generator. -
modified src/site.rs
diff --git a/src/site.rs b/src/site.rs index 0682714..f973934 100644 --- a/src/site.rs +++ b/src/site.rs @@ -4,6 +4,7 @@ use std::{ pin::Pin, }; +use chrono::{DateTime, SecondsFormat, Utc}; use sha2::{Digest, Sha256}; use figment::{ @@ -16,13 +17,32 @@ use pulldown_cmark::{Options, Parser, html}; use tera::{Context, Tera}; use tracing::warn; -use crate::{changelog::ChangelogExtractor, config::AbbayeConfig}; +use crate::{changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo}; const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html"); const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html"); +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 { @@ -77,7 +97,8 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { .into_diagnostic()?; // ── 1. Version ──────────────────────────────────────────────────────────── - let version = config.version_extractor.extract().await?; + let version_info = config.version_extractor.extract().await?; + let version = version_info.version.clone(); // ── 2. Tera setup ───────────────────────────────────────────────────────── let mut tera = Tera::default(); @@ -207,18 +228,29 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { .await .into_diagnostic()?; - // ── 9. Root index.html ──────────────────────────────────────────────────── + // ── 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 versions = config.version_extractor.extract_all().await?; - if !versions.contains(&version) { - versions.push(version.clone()); + 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()); } - versions.sort_by(|a, b| compare_versions(b, a)); + 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("project_name", &config.site.name); - root_ctx.insert("versions", &versions); + root_ctx.insert("versions", &version_entries); + root_ctx.insert("atom_feed", ATOM_FEED_FILENAME); let root_html = tera .render("root_index.html", &root_ctx) @@ -227,9 +259,15 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { .await .into_diagnostic()?; - // ── 10. `latest` symlink ────────────────────────────────────────────────── - if let Some(latest) = versions.first() { - update_latest_symlink(&output_dir, latest)?; + // ── 10. Atom feed ───────────────────────────────────────────────────────── + let atom_xml = generate_atom_feed(&config.site.name, &all_versions, base_url); + 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(()) @@ -378,6 +416,96 @@ fn human_size(bytes: u64) -> String { } } +/// 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. +fn generate_atom_feed( + project_name: &str, + versions: &[VersionInfo], + base_url: Option<&str>, +) -> 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(), + }; + + format!( + " <entry>\n\ + \x20 <title>{v_escaped}</title>\n\ + \x20 <id>{entry_id}</id>\n\ + \x20 <updated>{entry_date_str}</updated>\n\ + {entry_link}\ + \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`. /// -
modified src/templates/root_index.html
diff --git a/src/templates/root_index.html b/src/templates/root_index.html index 0d43e10..fcedc06 100644 --- a/src/templates/root_index.html +++ b/src/templates/root_index.html @@ -4,6 +4,12 @@ <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{{ project_name }}</title> + <link + rel="alternate" + type="application/atom+xml" + title="{{ project_name }} Releases" + href="{{ atom_feed }}" + /> <style> *, *::before, @@ -27,12 +33,43 @@ background: #3d5732; color: #f0e8d8; padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; } header h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: 0.01em; } + .feed-link { + display: flex; + align-items: center; + gap: 0.4em; + color: #f0e8d8; + text-decoration: none; + font-size: 0.85rem; + opacity: 0.8; + } + .feed-link:hover { + opacity: 1; + text-decoration: underline; + } + .feed-icon { + /* Classic orange RSS/Atom square */ + display: inline-block; + width: 1em; + height: 1em; + flex-shrink: 0; + background: #f96b15; + border-radius: 2px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='3' cy='13' r='2' fill='white'/%3E%3Cpath d='M3 6.5A6.5 6.5 0 0 1 9.5 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3Cpath d='M3 2A11 11 0 0 1 14 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 80%; + } + main { max-width: 760px; margin: 2.5rem auto; @@ -82,21 +119,35 @@ padding: 0.2em 0.6em; border-radius: 999px; } + .version-date { + margin-left: auto; + font-size: 0.8rem; + color: #8a7060; + font-variant-numeric: tabular-nums; + } </style> </head> <body> <header> <h1>{{ project_name }}</h1> + <a class="feed-link" href="{{ atom_feed }}"> + <span class="feed-icon" aria-hidden="true"></span> + Atom feed + </a> </header> <main> <h2>Versions</h2> <ul> - {% for version in versions %} + {% for v in versions %} <li> - <a class="version-link" href="{{ version }}/" - >{{ version }}</a + <a class="version-link" href="{{ v.version }}/" + >{{ v.version }}</a > {% if loop.first %}<span class="badge-latest">latest</span + >{% endif %} {% if v.date %}<time + class="version-date" + datetime="{{ v.date }}" + >{{ v.date }}</time >{% endif %} </li> {% endfor %} -
modified src/version_extractors/cargo.rs
diff --git a/src/version_extractors/cargo.rs b/src/version_extractors/cargo.rs index 97de99f..8be5eef 100644 --- a/src/version_extractors/cargo.rs +++ b/src/version_extractors/cargo.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use miette::{IntoDiagnostic, Result, miette}; use serde::Deserialize; -use super::VersionExtractor; +use super::{VersionExtractor, VersionInfo}; /// Configuration for [`CargoVersion`]. #[derive(Debug, Default, Clone, Deserialize)] @@ -30,7 +30,7 @@ struct CargoPackage { impl VersionExtractor for CargoVersion { type ConfigType = CargoVersionConfig; - async fn get_last_version(&self, config: Self::ConfigType) -> Result<String> { + async fn get_last_version(&self, config: Self::ConfigType) -> Result<VersionInfo> { let path = config .manifest_path .unwrap_or_else(|| PathBuf::from("Cargo.toml")); @@ -39,10 +39,16 @@ impl VersionExtractor for CargoVersion { let manifest: CargoManifest = toml::from_str(&content).into_diagnostic()?; - manifest + let version = manifest .package .ok_or_else(|| miette!("{} has no [package] section", path.display()))? .version - .ok_or_else(|| miette!("no version field in [package] in {}", path.display())) + .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))?; + + // Cargo.toml carries no release date information. + Ok(VersionInfo { + version, + date: None, + }) } } -
modified src/version_extractors/git.rs
diff --git a/src/version_extractors/git.rs b/src/version_extractors/git.rs index adb8a64..d9bb867 100644 --- a/src/version_extractors/git.rs +++ b/src/version_extractors/git.rs @@ -1,7 +1,8 @@ +use chrono::{DateTime, Utc}; use miette::{IntoDiagnostic, Result, miette}; use serde::Deserialize; -use super::VersionExtractor; +use super::{VersionExtractor, VersionInfo}; fn default_dirty_suffix() -> String { "-dirty".to_owned() @@ -33,10 +34,30 @@ impl Default for GitVersionConfig { /// Extracts the version by running `git describe --tags --always`. pub struct GitVersion; +/// Run `git log -1 --format=%cI <refspec>` and parse the result as a UTC +/// [`DateTime`]. Returns `None` on any failure so callers can treat the date +/// as optional. +async fn git_commit_date(refspec: &str) -> Option<DateTime<Utc>> { + let output = tokio::process::Command::new("git") + .args(["log", "-1", "--format=%cI", refspec]) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let raw = String::from_utf8(output.stdout).ok()?; + DateTime::parse_from_rfc3339(raw.trim()) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + impl VersionExtractor for GitVersion { type ConfigType = GitVersionConfig; - async fn get_last_version(&self, config: Self::ConfigType) -> Result<String> { + async fn get_last_version(&self, config: Self::ConfigType) -> Result<VersionInfo> { let dirty_arg = format!("--dirty={}", config.dirty_suffix); let output = tokio::process::Command::new("git") @@ -51,26 +72,41 @@ impl VersionExtractor for GitVersion { } let raw = String::from_utf8(output.stdout).into_diagnostic()?; - let version = raw.trim(); + let version_raw = raw.trim(); let version = match &config.tag_prefix { - Some(prefix) => version.strip_prefix(prefix.as_str()).unwrap_or(version), - None => version, - }; + Some(prefix) => version_raw + .strip_prefix(prefix.as_str()) + .unwrap_or(version_raw), + None => version_raw, + } + .to_owned(); - Ok(version.to_owned()) + // Use the HEAD commit date as the "release date" for the current build. + let date = git_commit_date("HEAD").await; + + Ok(VersionInfo { version, date }) } - async fn get_all_versions(&self, config: Self::ConfigType) -> Result<Vec<String>> { + /// Return all Git tags as [`VersionInfo`] entries, each carrying the + /// tag's creator date (annotated tag date, or commit date for lightweight + /// tags). Uses a single `git for-each-ref` invocation. + async fn get_all_versions(&self, config: Self::ConfigType) -> Result<Vec<VersionInfo>> { + // %(creatordate:iso-strict) works for both annotated and lightweight tags. let output = tokio::process::Command::new("git") - .args(["tag", "--list", "--sort=version:refname"]) + .args([ + "for-each-ref", + "--sort=version:refname", + "--format=%(refname:short)\t%(creatordate:iso-strict)", + "refs/tags", + ]) .output() .await .into_diagnostic()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(miette!("git tag failed:\n{stderr}")); + return Err(miette!("git for-each-ref failed:\n{stderr}")); } let raw = String::from_utf8(output.stdout).into_diagnostic()?; @@ -78,9 +114,21 @@ impl VersionExtractor for GitVersion { let versions = raw .lines() .filter(|s| !s.is_empty()) - .map(|tag| match &config.tag_prefix { - Some(prefix) => tag.strip_prefix(prefix.as_str()).unwrap_or(tag).to_owned(), - None => tag.to_owned(), + .map(|line| { + // Split on the first tab; anything after is the date string. + let (tag, date_str) = line.split_once('\t').unwrap_or((line, "")); + + let version = match &config.tag_prefix { + Some(prefix) => tag.strip_prefix(prefix.as_str()).unwrap_or(tag), + None => tag, + } + .to_owned(); + + let date = DateTime::parse_from_rfc3339(date_str.trim()) + .ok() + .map(|dt| dt.with_timezone(&Utc)); + + VersionInfo { version, date } }) .collect(); -
modified src/version_extractors/mod.rs
diff --git a/src/version_extractors/mod.rs b/src/version_extractors/mod.rs index d1473ba..f19626f 100644 --- a/src/version_extractors/mod.rs +++ b/src/version_extractors/mod.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use miette::{Result, bail}; use serde::Deserialize; @@ -7,13 +8,26 @@ pub mod git; use cargo::{CargoVersion, CargoVersionConfig}; use git::{GitVersion, GitVersionConfig}; +/// A version string paired with an optional release date. +/// +/// Returned by [`VersionExtractor::get_last_version`] and +/// [`VersionExtractor::get_all_versions`] so that callers can propagate +/// date information (e.g. to Atom feed entries) without a second round-trip. +#[derive(Debug, Clone)] +pub struct VersionInfo { + /// The human-readable version string (e.g. `"1.2.3"`). + pub version: String, + /// The UTC date/time at which this version was released, if known. + pub date: Option<DateTime<Utc>>, +} + #[allow(async_fn_in_trait)] pub trait VersionExtractor { type ConfigType: Default + for<'de> Deserialize<'de> + Clone; - async fn get_last_version(&self, config: Self::ConfigType) -> Result<String>; + async fn get_last_version(&self, config: Self::ConfigType) -> Result<VersionInfo>; - async fn get_all_versions(&self, _config: Self::ConfigType) -> Result<Vec<String>> { + async fn get_all_versions(&self, _config: Self::ConfigType) -> Result<Vec<VersionInfo>> { bail!("get_all_versions is not supported by this version extractor") } } @@ -46,14 +60,14 @@ pub enum AnyVersionExtractor { } impl AnyVersionExtractor { - pub async fn extract(&self) -> Result<String> { + pub async fn extract(&self) -> Result<VersionInfo> { match self { Self::Cargo(config) => CargoVersion.get_last_version(config.clone()).await, Self::Git(config) => GitVersion.get_last_version(config.clone()).await, } } - pub async fn extract_all(&self) -> Result<Vec<String>> { + pub async fn extract_all(&self) -> Result<Vec<VersionInfo>> { match self { Self::Cargo(config) => CargoVersion.get_all_versions(config.clone()).await, Self::Git(config) => GitVersion.get_all_versions(config.clone()).await,