Skip to main content

abbaye/
site.rs

1use std::{
2    future::Future,
3    path::{Path, PathBuf},
4    pin::Pin,
5};
6
7use chrono::{DateTime, SecondsFormat, Utc};
8use sha2::{Digest, Sha256};
9
10use figment::{
11    Figment,
12    providers::{Format, Toml},
13};
14use flate2::{Compression, write::GzEncoder};
15use miette::{IntoDiagnostic, Result};
16use pulldown_cmark::{Options, Parser, html};
17use tera::{Context, Tera};
18use tracing::warn;
19
20use crate::{changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo};
21
22const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html");
23const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html");
24const ATOM_FEED_FILENAME: &str = "releases.atom";
25
26// ── Types ───────────────────────────────────────────────────────────────────
27
28/// A version entry as passed to Tera templates.
29#[derive(serde::Serialize)]
30struct VersionEntry {
31    /// Version string (e.g. `"1.2.3"`).
32    version: String,
33    /// ISO-8601 date string (e.g. `"2024-01-15"`) when the release date is known.
34    date: Option<String>,
35}
36
37impl VersionEntry {
38    fn from_info(info: &VersionInfo) -> Self {
39        Self {
40            version: info.version.clone(),
41            date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
42        }
43    }
44}
45
46/// Metadata about a single file-type dist artifact, passed to Tera templates.
47#[derive(serde::Serialize)]
48struct DistFileInfo {
49    /// File name (relative to `dist/`).
50    name: String,
51    /// Raw byte size of the file.
52    size_bytes: u64,
53    /// Human-readable size (e.g. "1.4 MB").
54    size_human: String,
55    /// Lowercase hex-encoded SHA-256 digest of the file contents.
56    sha256: String,
57}
58
59// ── Public API ────────────────────────────────────────────────────────────────
60
61/// Load the Abbaye2 configuration from the current working directory.
62///
63/// Looks for `.abbaye.toml` first, then `abbaye.toml`; when both are present
64/// `abbaye.toml` takes precedence (last merge wins).
65pub fn load_config() -> Result<AbbayeConfig> {
66    let cwd = std::env::current_dir().into_diagnostic()?;
67    Figment::new()
68        .merge(Toml::file(cwd.join(".abbaye.toml")))
69        .merge(Toml::file(cwd.join("abbaye.toml")))
70        .extract()
71        .into_diagnostic()
72}
73
74/// Build the full website into `config.output_dir` (defaults to `public/`).
75///
76/// # Steps
77///
78/// 1. Extract the current version via the configured [`crate::version_extractors::AnyVersionExtractor`].
79/// 2. Run every configured builder and split the resulting [`crate::builders::ArtifactPath`]s
80///    into *dist* (regular files) and *docs* (directories).
81/// 3. Copy dist artifacts to `<output>/<version>/dist/`.
82/// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive
83///    the whole `docs/` tree as `docs.tar.gz`.
84/// 5. Render `<output>/<version>/index.html` from the project README and the
85///    matching changelog section.
86/// 6. Re-render the root `<output>/index.html`, listing all known versions
87///    (newest first).
88/// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other).
89pub async fn build_site(config: AbbayeConfig) -> Result<()> {
90    let output_dir = config
91        .output_dir
92        .clone()
93        .unwrap_or_else(|| PathBuf::from("public"));
94
95    tokio::fs::create_dir_all(&output_dir)
96        .await
97        .into_diagnostic()?;
98
99    // ── 1. Version ────────────────────────────────────────────────────────────
100    let version_info = config.version_extractor.extract().await?;
101    let version = version_info.version.clone();
102
103    // ── 2. Tera setup ─────────────────────────────────────────────────────────
104    let mut tera = Tera::default();
105    tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
106        .into_diagnostic()?;
107    tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
108        .into_diagnostic()?;
109
110    // ── 3. Builders ───────────────────────────────────────────────────────────
111    let mut dist_artifacts = Vec::new();
112    let mut doc_artifacts = Vec::new();
113
114    for builder in &config.builders {
115        for artifact in builder.build().await? {
116            if artifact.path.is_dir() {
117                doc_artifacts.push(artifact);
118            } else {
119                dist_artifacts.push(artifact);
120            }
121        }
122    }
123
124    // ── 4. Lay out dist/ ──────────────────────────────────────────────────────
125    let version_dir = output_dir.join(&version);
126    let dist_dir = version_dir.join("dist");
127    tokio::fs::create_dir_all(&dist_dir)
128        .await
129        .into_diagnostic()?;
130
131    for artifact in &dist_artifacts {
132        tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
133            .await
134            .into_diagnostic()?;
135    }
136
137    // Compute size + SHA-256 for each copied dist artifact.
138    let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
139    for artifact in &dist_artifacts {
140        let dest = dist_dir.join(&artifact.name);
141        let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
142        let size_bytes = bytes.len() as u64;
143        let sha256 = hex_sha256(&bytes);
144        dist_file_infos.push(DistFileInfo {
145            name: artifact.name.clone(),
146            size_bytes,
147            size_human: human_size(size_bytes),
148            sha256,
149        });
150    }
151    let has_dist = !dist_file_infos.is_empty();
152
153    // ── 5. Lay out docs/ ──────────────────────────────────────────────────────
154    let has_docs = !doc_artifacts.is_empty();
155    let has_docs_tarball;
156
157    if has_docs {
158        let docs_dir = version_dir.join("docs");
159
160        for artifact in &doc_artifacts {
161            // Copy the complete target/doc tree — this includes the shared
162            // rustdoc CSS, JS, fonts and search indices at the root of the
163            // directory in addition to the per-crate HTML subdirectories.
164            copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
165        }
166
167        // Rustdoc writes index.html at the root for single-crate projects.
168        // For workspaces it may be absent; generate a fallback listing then.
169        if !docs_dir.join("index.html").exists() {
170            let crate_names = find_doc_crates(&docs_dir).await?;
171            write_docs_index(&docs_dir, &crate_names).await?;
172        }
173
174        let tarball = version_dir.join("docs.tar.gz");
175        let docs_dir_c = docs_dir.clone();
176        let tarball_c = tarball.clone();
177        tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
178            .await
179            .into_diagnostic()??;
180
181        has_docs_tarball = true;
182    } else {
183        has_docs_tarball = false;
184    }
185
186    // ── 6. README ─────────────────────────────────────────────────────────────
187    let readme_path = config
188        .site
189        .readme
190        .as_deref()
191        .unwrap_or(Path::new("README.md"));
192
193    let readme_html = match tokio::fs::read_to_string(readme_path).await {
194        Ok(content) => render_markdown(&content),
195        Err(_) => {
196            warn!("README not found at {}", readme_path.display());
197            String::new()
198        }
199    };
200
201    // ── 7. Changelog section ──────────────────────────────────────────────────
202    let changelog_html = match ChangelogExtractor
203        .section(config.changelog.clone(), &version)
204        .await
205    {
206        Ok(section) => render_markdown(&section),
207        Err(_) => {
208            warn!("No changelog entry found for version {version}");
209            String::new()
210        }
211    };
212
213    // ── 8. Version index.html ─────────────────────────────────────────────────
214    let mut version_ctx = Context::new();
215    version_ctx.insert("project_name", &config.site.name);
216    version_ctx.insert("version", &version);
217    version_ctx.insert("readme_html", &readme_html);
218    version_ctx.insert("changelog_html", &changelog_html);
219    version_ctx.insert("has_docs", &has_docs);
220    version_ctx.insert("has_docs_tarball", &has_docs_tarball);
221    version_ctx.insert("has_dist", &has_dist);
222    version_ctx.insert("dist_files", &dist_file_infos);
223
224    let version_html = tera
225        .render("version_index.html", &version_ctx)
226        .into_diagnostic()?;
227    tokio::fs::write(version_dir.join("index.html"), version_html)
228        .await
229        .into_diagnostic()?;
230
231    // ── 9. Root index.html + Atom feed ──────────────────────────────────────
232    // Collect every known version from the version extractor, ensure the
233    // current version is present (handles untagged builds), then sort newest-first.
234    let mut all_versions = config.version_extractor.extract_all().await?;
235    if !all_versions.iter().any(|v| v.version == version) {
236        all_versions.push(version_info.clone());
237    }
238    all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
239
240    // Build the template-friendly list (version string + optional date string).
241    let version_entries: Vec<VersionEntry> =
242        all_versions.iter().map(VersionEntry::from_info).collect();
243
244    let base_url = config
245        .site
246        .base_url
247        .as_deref()
248        .map(|u| u.trim_end_matches('/'));
249
250    let mut root_ctx = Context::new();
251    root_ctx.insert("project_name", &config.site.name);
252    root_ctx.insert("versions", &version_entries);
253    root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
254
255    let root_html = tera
256        .render("root_index.html", &root_ctx)
257        .into_diagnostic()?;
258    tokio::fs::write(output_dir.join("index.html"), root_html)
259        .await
260        .into_diagnostic()?;
261
262    // ── 10. Atom feed ─────────────────────────────────────────────────────────
263    let atom_xml = generate_atom_feed(&config.site.name, &all_versions, base_url);
264    tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
265        .await
266        .into_diagnostic()?;
267
268    // ── 11. `latest` symlink ──────────────────────────────────────────────────
269    if let Some(latest) = all_versions.first() {
270        update_latest_symlink(&output_dir, &latest.version)?;
271    }
272
273    Ok(())
274}
275
276// ── Private helpers ───────────────────────────────────────────────────────────
277
278/// Render Markdown to an HTML string.
279fn render_markdown(md: &str) -> String {
280    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
281    let parser = Parser::new_ext(md, opts);
282    let mut buf = String::new();
283    html::push_html(&mut buf, parser);
284    buf
285}
286
287/// Scan `docs_dir` for subdirectories that contain an `index.html` and return
288/// their names. Used to build a fallback listing when rustdoc itself did not
289/// generate a root `index.html` (typical for multi-crate workspaces).
290async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
291    let mut names = Vec::new();
292    let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
293    while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
294        let path = entry.path();
295        if path.is_dir() && path.join("index.html").exists() {
296            names.push(entry.file_name().to_string_lossy().into_owned());
297        }
298    }
299    Ok(names)
300}
301
302/// Write a `docs/index.html` that either redirects straight to the single
303/// crate's docs (one crate) or lists all crates (multiple crates).
304async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
305    let html = if crate_names.len() == 1 {
306        format!(
307            "<!DOCTYPE html><html><head>\
308            <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
309            </head></html>",
310            crate_names[0]
311        )
312    } else {
313        let items = crate_names
314            .iter()
315            .map(|n| format!("  <li><a href=\"{n}/index.html\">{n}</a></li>"))
316            .collect::<Vec<_>>()
317            .join("\n");
318        format!(
319            "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
320            <title>Documentation</title></head>\
321            <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
322        )
323    };
324
325    tokio::fs::write(docs_dir.join("index.html"), html)
326        .await
327        .into_diagnostic()
328}
329
330/// Recursively copy the contents of `src` into `dst`.
331///
332/// Uses explicit boxing to satisfy the compiler for the async recursive call.
333fn copy_dir_recursive(
334    src: PathBuf,
335    dst: PathBuf,
336) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
337    Box::pin(async move {
338        tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
339        let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
340        while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
341            let src_path = entry.path();
342            let dst_path = dst.join(entry.file_name());
343            if src_path.is_dir() {
344                copy_dir_recursive(src_path, dst_path).await?;
345            } else {
346                tokio::fs::copy(&src_path, &dst_path)
347                    .await
348                    .into_diagnostic()?;
349            }
350        }
351        Ok(())
352    })
353}
354
355/// Pack `src` directory into a `.tar.gz` archive at `dest`.
356///
357/// The top-level entry inside the archive is named after the source directory.
358fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
359    let dir_name = src
360        .file_name()
361        .map(|n| n.to_string_lossy().into_owned())
362        .unwrap_or_else(|| "docs".to_owned());
363
364    let file = std::fs::File::create(dest).into_diagnostic()?;
365    let enc = GzEncoder::new(file, Compression::default());
366    let mut archive = tar::Builder::new(enc);
367    archive.append_dir_all(&dir_name, src).into_diagnostic()?;
368    archive
369        .into_inner()
370        .into_diagnostic()?
371        .finish()
372        .into_diagnostic()?;
373    Ok(())
374}
375
376/// Compare two version strings, preferring semver ordering and falling back
377/// to lexicographic comparison for non-semver strings (e.g. git describe output).
378fn strip_v(s: &str) -> &str {
379    s.strip_prefix('v').unwrap_or(s)
380}
381
382fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
383    match (
384        semver::Version::parse(strip_v(a)),
385        semver::Version::parse(strip_v(b)),
386    ) {
387        (Ok(va), Ok(vb)) => va.cmp(&vb),
388        _ => a.cmp(b),
389    }
390}
391
392/// Compute a lowercase hex-encoded SHA-256 digest of `data`.
393fn hex_sha256(data: &[u8]) -> String {
394    let mut hasher = Sha256::new();
395    hasher.update(data);
396    hasher
397        .finalize()
398        .iter()
399        .map(|b| format!("{b:02x}"))
400        .collect()
401}
402
403/// Format a byte count as a human-readable string (e.g. "1.4 MB").
404fn human_size(bytes: u64) -> String {
405    const KIB: u64 = 1024;
406    const MIB: u64 = KIB * 1024;
407    const GIB: u64 = MIB * 1024;
408    if bytes >= GIB {
409        format!("{:.1} GB", bytes as f64 / GIB as f64)
410    } else if bytes >= MIB {
411        format!("{:.1} MB", bytes as f64 / MIB as f64)
412    } else if bytes >= KIB {
413        format!("{:.1} KB", bytes as f64 / KIB as f64)
414    } else {
415        format!("{bytes} B")
416    }
417}
418
419/// Escape the five XML predefined characters in a string.
420fn xml_escape(s: &str) -> String {
421    s.replace('&', "&amp;")
422        .replace('<', "&lt;")
423        .replace('>', "&gt;")
424        .replace('"', "&quot;")
425        .replace('\'', "&apos;")
426}
427
428/// Generate an Atom 1.0 feed listing all known releases.
429///
430/// - `base_url`: when provided (already stripped of trailing `/`), used to
431///   build `<link>` and `<id>` elements with absolute URLs.  When absent,
432///   a `urn:` based ID is used and no `<link>` elements are emitted.
433fn generate_atom_feed(
434    project_name: &str,
435    versions: &[VersionInfo],
436    base_url: Option<&str>,
437) -> String {
438    let now: DateTime<Utc> = Utc::now();
439
440    // The feed's <updated> is the most-recent entry date, or now as fallback.
441    let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
442
443    let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
444
445    // Feed-level <id> and self-link.
446    let feed_id = match base_url {
447        Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
448        None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
449    };
450
451    let self_link = match base_url {
452        Some(base) => format!(
453            "  <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
454            xml_escape(base)
455        ),
456        None => String::new(),
457    };
458    let alt_link = match base_url {
459        Some(base) => format!("  <link href=\"{}\"/>\n", xml_escape(base)),
460        None => String::new(),
461    };
462
463    let entries: String = versions
464        .iter()
465        .map(|vi| {
466            let entry_date = vi.date.unwrap_or(now);
467            let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
468            let v_escaped = xml_escape(&vi.version);
469
470            let entry_id = match base_url {
471                Some(base) => format!("{base}/{v_escaped}/"),
472                None => format!(
473                    "urn:abbaye:release:{}:{v_escaped}",
474                    xml_escape(project_name)
475                ),
476            };
477
478            let entry_link = match base_url {
479                Some(base) => format!("    <link href=\"{base}/{v_escaped}/\"/>\n"),
480                None => String::new(),
481            };
482
483            format!(
484                "  <entry>\n\
485                 \x20   <title>{v_escaped}</title>\n\
486                 \x20   <id>{entry_id}</id>\n\
487                 \x20   <updated>{entry_date_str}</updated>\n\
488                 {entry_link}\
489                 \x20 </entry>"
490            )
491        })
492        .collect::<Vec<_>>()
493        .join("\n");
494
495    format!(
496        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
497         <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
498         \x20 <title>{name} Releases</title>\n\
499         {self_link}\
500         {alt_link}\
501         \x20 <updated>{feed_updated_str}</updated>\n\
502         \x20 <id>{feed_id}</id>\n\
503         {entries}\n\
504         </feed>\n",
505        name = xml_escape(project_name),
506    )
507}
508
509/// Create or replace the `latest` symlink in `output_dir`, pointing to
510/// `version_dir_name`.
511///
512/// On non-Unix platforms a meta-refresh redirect page is written instead.
513fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
514    let link = output_dir.join("latest");
515
516    #[cfg(unix)]
517    {
518        // Remove any stale symlink or file before (re-)creating it.
519        if link.exists() || link.is_symlink() {
520            std::fs::remove_file(&link).into_diagnostic()?;
521        }
522        std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
523    }
524
525    #[cfg(not(unix))]
526    {
527        std::fs::create_dir_all(&link).into_diagnostic()?;
528        std::fs::write(
529            link.join("index.html"),
530            format!(
531                "<!DOCTYPE html><html><head>\
532                <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
533                </head></html>"
534            ),
535        )
536        .into_diagnostic()?;
537    }
538
539    Ok(())
540}