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