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