Skip to main content

abbaye/
site.rs

1use std::{
2    future::Future,
3    path::{Path, PathBuf},
4    pin::Pin,
5};
6
7use figment::{
8    Figment,
9    providers::{Format, Toml},
10};
11use flate2::{Compression, write::GzEncoder};
12use miette::{IntoDiagnostic, Result};
13use pulldown_cmark::{Options, Parser, html};
14use tera::{Context, Tera};
15use tracing::warn;
16
17use crate::{changelog::ChangelogExtractor, config::AbbayeConfig};
18
19const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html");
20const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html");
21
22// ── Public API ────────────────────────────────────────────────────────────────
23
24/// Load the Abbaye2 configuration from the current working directory.
25///
26/// Looks for `.abbaye.toml` first, then `abbaye.toml`; when both are present
27/// `abbaye.toml` takes precedence (last merge wins).
28pub fn load_config() -> Result<AbbayeConfig> {
29    let cwd = std::env::current_dir().into_diagnostic()?;
30    Figment::new()
31        .merge(Toml::file(cwd.join(".abbaye.toml")))
32        .merge(Toml::file(cwd.join("abbaye.toml")))
33        .extract()
34        .into_diagnostic()
35}
36
37/// Build the full website into `config.output_dir` (defaults to `public/`).
38///
39/// # Steps
40///
41/// 1. Extract the current version via the configured [`AnyVersionExtractor`].
42/// 2. Run every configured builder and split the resulting [`ArtifactPath`]s
43///    into *dist* (regular files) and *docs* (directories).
44/// 3. Copy dist artifacts to `<output>/<version>/dist/`.
45/// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive
46///    the whole `docs/` tree as `docs.tar.gz`.
47/// 5. Render `<output>/<version>/index.html` from the project README and the
48///    matching changelog section.
49/// 6. Re-render the root `<output>/index.html`, listing all known versions
50///    (newest first).
51/// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other).
52pub async fn build_site(config: AbbayeConfig) -> Result<()> {
53    let output_dir = config
54        .output_dir
55        .clone()
56        .unwrap_or_else(|| PathBuf::from("public"));
57
58    tokio::fs::create_dir_all(&output_dir)
59        .await
60        .into_diagnostic()?;
61
62    // ── 1. Version ────────────────────────────────────────────────────────────
63    let version = config.version_extractor.extract().await?;
64
65    // ── 2. Tera setup ─────────────────────────────────────────────────────────
66    let mut tera = Tera::default();
67    tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
68        .into_diagnostic()?;
69    tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
70        .into_diagnostic()?;
71
72    // ── 3. Builders ───────────────────────────────────────────────────────────
73    let mut dist_artifacts = Vec::new();
74    let mut doc_artifacts = Vec::new();
75
76    for builder in &config.builders {
77        for artifact in builder.build().await? {
78            if artifact.path.is_dir() {
79                doc_artifacts.push(artifact);
80            } else {
81                dist_artifacts.push(artifact);
82            }
83        }
84    }
85
86    // ── 4. Lay out dist/ ──────────────────────────────────────────────────────
87    let version_dir = output_dir.join(&version);
88    let dist_dir = version_dir.join("dist");
89    tokio::fs::create_dir_all(&dist_dir)
90        .await
91        .into_diagnostic()?;
92
93    for artifact in &dist_artifacts {
94        tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
95            .await
96            .into_diagnostic()?;
97    }
98
99    let dist_file_names: Vec<String> = dist_artifacts.iter().map(|a| a.name.clone()).collect();
100    let has_dist = !dist_file_names.is_empty();
101
102    // ── 5. Lay out docs/ ──────────────────────────────────────────────────────
103    let has_docs = !doc_artifacts.is_empty();
104    let has_docs_tarball;
105
106    if has_docs {
107        let docs_dir = version_dir.join("docs");
108
109        for artifact in &doc_artifacts {
110            // Copy the complete target/doc tree — this includes the shared
111            // rustdoc CSS, JS, fonts and search indices at the root of the
112            // directory in addition to the per-crate HTML subdirectories.
113            copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
114        }
115
116        // Rustdoc writes index.html at the root for single-crate projects.
117        // For workspaces it may be absent; generate a fallback listing then.
118        if !docs_dir.join("index.html").exists() {
119            let crate_names = find_doc_crates(&docs_dir).await?;
120            write_docs_index(&docs_dir, &crate_names).await?;
121        }
122
123        let tarball = version_dir.join("docs.tar.gz");
124        let docs_dir_c = docs_dir.clone();
125        let tarball_c = tarball.clone();
126        tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
127            .await
128            .into_diagnostic()??;
129
130        has_docs_tarball = true;
131    } else {
132        has_docs_tarball = false;
133    }
134
135    // ── 6. README ─────────────────────────────────────────────────────────────
136    let readme_path = config
137        .site
138        .readme
139        .as_deref()
140        .unwrap_or(Path::new("README.md"));
141
142    let readme_html = match tokio::fs::read_to_string(readme_path).await {
143        Ok(content) => render_markdown(&content),
144        Err(_) => {
145            warn!("README not found at {}", readme_path.display());
146            String::new()
147        }
148    };
149
150    // ── 7. Changelog section ──────────────────────────────────────────────────
151    let changelog_html = match ChangelogExtractor
152        .section(config.changelog.clone(), &version)
153        .await
154    {
155        Ok(section) => render_markdown(&section),
156        Err(_) => {
157            warn!("No changelog entry found for version {version}");
158            String::new()
159        }
160    };
161
162    // ── 8. Version index.html ─────────────────────────────────────────────────
163    let mut version_ctx = Context::new();
164    version_ctx.insert("project_name", &config.site.name);
165    version_ctx.insert("version", &version);
166    version_ctx.insert("readme_html", &readme_html);
167    version_ctx.insert("changelog_html", &changelog_html);
168    version_ctx.insert("has_docs", &has_docs);
169    version_ctx.insert("has_docs_tarball", &has_docs_tarball);
170    version_ctx.insert("has_dist", &has_dist);
171    version_ctx.insert("dist_files", &dist_file_names);
172
173    let version_html = tera
174        .render("version_index.html", &version_ctx)
175        .into_diagnostic()?;
176    tokio::fs::write(version_dir.join("index.html"), version_html)
177        .await
178        .into_diagnostic()?;
179
180    // ── 9. Root index.html ────────────────────────────────────────────────────
181    // Collect every known version from the version extractor, ensure the
182    // current version is present (handles untagged builds), then sort newest-first.
183    let mut versions = config.version_extractor.extract_all().await?;
184    if !versions.contains(&version) {
185        versions.push(version.clone());
186    }
187    versions.sort_by(|a, b| compare_versions(b, a));
188
189    let mut root_ctx = Context::new();
190    root_ctx.insert("project_name", &config.site.name);
191    root_ctx.insert("versions", &versions);
192
193    let root_html = tera
194        .render("root_index.html", &root_ctx)
195        .into_diagnostic()?;
196    tokio::fs::write(output_dir.join("index.html"), root_html)
197        .await
198        .into_diagnostic()?;
199
200    // ── 10. `latest` symlink ──────────────────────────────────────────────────
201    if let Some(latest) = versions.first() {
202        update_latest_symlink(&output_dir, latest)?;
203    }
204
205    Ok(())
206}
207
208// ── Private helpers ───────────────────────────────────────────────────────────
209
210/// Render Markdown to an HTML string.
211fn render_markdown(md: &str) -> String {
212    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
213    let parser = Parser::new_ext(md, opts);
214    let mut buf = String::new();
215    html::push_html(&mut buf, parser);
216    buf
217}
218
219/// Scan `docs_dir` for subdirectories that contain an `index.html` and return
220/// their names. Used to build a fallback listing when rustdoc itself did not
221/// generate a root `index.html` (typical for multi-crate workspaces).
222async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
223    let mut names = Vec::new();
224    let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
225    while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
226        let path = entry.path();
227        if path.is_dir() && path.join("index.html").exists() {
228            names.push(entry.file_name().to_string_lossy().into_owned());
229        }
230    }
231    Ok(names)
232}
233
234/// Write a `docs/index.html` that either redirects straight to the single
235/// crate's docs (one crate) or lists all crates (multiple crates).
236async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
237    let html = if crate_names.len() == 1 {
238        format!(
239            "<!DOCTYPE html><html><head>\
240            <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
241            </head></html>",
242            crate_names[0]
243        )
244    } else {
245        let items = crate_names
246            .iter()
247            .map(|n| format!("  <li><a href=\"{n}/index.html\">{n}</a></li>"))
248            .collect::<Vec<_>>()
249            .join("\n");
250        format!(
251            "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
252            <title>Documentation</title></head>\
253            <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
254        )
255    };
256
257    tokio::fs::write(docs_dir.join("index.html"), html)
258        .await
259        .into_diagnostic()
260}
261
262/// Recursively copy the contents of `src` into `dst`.
263///
264/// Uses explicit boxing to satisfy the compiler for the async recursive call.
265fn copy_dir_recursive(
266    src: PathBuf,
267    dst: PathBuf,
268) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
269    Box::pin(async move {
270        tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
271        let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
272        while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
273            let src_path = entry.path();
274            let dst_path = dst.join(entry.file_name());
275            if src_path.is_dir() {
276                copy_dir_recursive(src_path, dst_path).await?;
277            } else {
278                tokio::fs::copy(&src_path, &dst_path)
279                    .await
280                    .into_diagnostic()?;
281            }
282        }
283        Ok(())
284    })
285}
286
287/// Pack `src` directory into a `.tar.gz` archive at `dest`.
288///
289/// The top-level entry inside the archive is named after the source directory.
290fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
291    let dir_name = src
292        .file_name()
293        .map(|n| n.to_string_lossy().into_owned())
294        .unwrap_or_else(|| "docs".to_owned());
295
296    let file = std::fs::File::create(dest).into_diagnostic()?;
297    let enc = GzEncoder::new(file, Compression::default());
298    let mut archive = tar::Builder::new(enc);
299    archive.append_dir_all(&dir_name, src).into_diagnostic()?;
300    archive
301        .into_inner()
302        .into_diagnostic()?
303        .finish()
304        .into_diagnostic()?;
305    Ok(())
306}
307
308/// Compare two version strings, preferring semver ordering and falling back
309/// to lexicographic comparison for non-semver strings (e.g. git describe output).
310fn strip_v(s: &str) -> &str {
311    s.strip_prefix('v').unwrap_or(s)
312}
313
314fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
315    match (
316        semver::Version::parse(strip_v(a)),
317        semver::Version::parse(strip_v(b)),
318    ) {
319        (Ok(va), Ok(vb)) => va.cmp(&vb),
320        _ => a.cmp(b),
321    }
322}
323
324/// Create or replace the `latest` symlink in `output_dir`, pointing to
325/// `version_dir_name`.
326///
327/// On non-Unix platforms a meta-refresh redirect page is written instead.
328fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
329    let link = output_dir.join("latest");
330
331    #[cfg(unix)]
332    {
333        // Remove any stale symlink or file before (re-)creating it.
334        if link.exists() || link.is_symlink() {
335            std::fs::remove_file(&link).into_diagnostic()?;
336        }
337        std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
338    }
339
340    #[cfg(not(unix))]
341    {
342        std::fs::create_dir_all(&link).into_diagnostic()?;
343        std::fs::write(
344            link.join("index.html"),
345            format!(
346                "<!DOCTYPE html><html><head>\
347                <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
348                </head></html>"
349            ),
350        )
351        .into_diagnostic()?;
352    }
353
354    Ok(())
355}