Skip to main content

abbaye/
site.rs

1use std::{
2    future::Future,
3    path::{Path, PathBuf},
4    pin::Pin,
5    time::Duration,
6};
7
8use chrono::{DateTime, SecondsFormat, Utc};
9use sha2::{Digest, Sha256};
10
11use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
12use miette::{IntoDiagnostic, Result};
13use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
14use std::collections::HashMap;
15use tera::{Context, Tera};
16use tokio::sync::{mpsc, watch};
17use tokio::task::JoinSet;
18use tracing::warn;
19
20use crate::{
21    builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig, utils,
22    version_extractors::VersionInfo,
23};
24
25pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2");
26pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2");
27pub const SITE_CSS: &str = include_str!("templates/site.css");
28const ATOM_FEED_FILENAME: &str = "releases.atom";
29
30const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
31
32// ── Types ───────────────────────────────────────────────────────────────────
33
34/// A version entry as passed to Tera templates.
35#[derive(serde::Serialize)]
36struct VersionEntry {
37    /// Version string (e.g. `"1.2.3"`).
38    version: String,
39    /// ISO-8601 date string (e.g. `"2024-01-15"`) when the release date is known.
40    date: Option<String>,
41}
42
43impl VersionEntry {
44    fn from_info(info: &VersionInfo) -> Self {
45        Self {
46            version: info.version.clone(),
47            date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
48        }
49    }
50}
51
52/// Metadata about a single file-type dist artifact, passed to Tera templates.
53#[derive(serde::Serialize)]
54struct DistFileInfo {
55    /// File name (relative to `dist/`).
56    name: String,
57    /// Raw byte size of the file.
58    size_bytes: u64,
59    /// Human-readable size (e.g. "1.4 MB").
60    size_human: String,
61    /// Lowercase hex-encoded SHA-256 digest of the file contents.
62    sha256: String,
63}
64
65/// Build the full website into `config.output_dir` (defaults to `public/`).
66///
67/// # Steps
68///
69/// 1. Extract the current version via the configured [`crate::version_extractors::AnyVersionExtractor`].
70/// 2. Run every configured builder and split the resulting [`crate::builders::ArtifactPath`]s
71///    into *dist* (regular files) and *docs* (directories).
72/// 3. Copy dist artifacts to `<output>/<version>/dist/`.
73/// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive
74///    the whole `docs/` tree as `docs.tar.gz`.
75/// 5. Render `<output>/<version>/index.html` from the project README and the
76///    matching changelog section.
77/// 6. Re-render the root `<output>/index.html`, listing all known versions
78///    (newest first).
79/// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other).
80pub async fn build_site(config: AbbayeConfig) -> Result<()> {
81    let output_dir = &config.site.output_dir;
82
83    tokio::fs::create_dir_all(output_dir)
84        .await
85        .into_diagnostic()?;
86
87    // ── 1. Version ────────────────────────────────────────────────────────────
88    let version_info = config.version_extractor.extract().await?;
89    let version = version_info.version.clone();
90
91    // ── 2. Tera setup ─────────────────────────────────────────────────────────
92    let mut tera = Tera::default();
93    let theme_path = PathBuf::from(".abbaye").join("theme");
94    if theme_path.join("root_index.html.j2").is_file() {
95        tera.add_template_file(
96            theme_path.join("root_index.html.j2"),
97            Some("root_index.html"),
98        )
99        .into_diagnostic()?;
100    } else {
101        tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
102            .into_diagnostic()?;
103    }
104    if theme_path.join("version_index.html.j2").is_file() {
105        tera.add_template_file(
106            theme_path.join("version_index.html.j2"),
107            Some("version_index.html"),
108        )
109        .into_diagnostic()?;
110    } else {
111        tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
112            .into_diagnostic()?;
113    }
114    load_extra_theme_templates(
115        &mut tera,
116        &theme_path,
117        &["root_index.html", "version_index.html"],
118    )?;
119    // Write shared CSS to static/ so the git UI templates can reference it.
120    {
121        let static_dir = output_dir.join("static");
122        tokio::fs::create_dir_all(&static_dir)
123            .await
124            .into_diagnostic()?;
125        tokio::fs::write(static_dir.join("site.css"), SITE_CSS)
126            .await
127            .into_diagnostic()?;
128    }
129    // If there's a `static` directory in the theme, copy it over (may overwrite site.css).
130    if theme_path.join("static").is_dir() {
131        copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
132    }
133
134    // ── 3. Builders (run in parallel, respecting depends_on ordering) ─────────
135    let mut dist_artifacts = Vec::new();
136    let mut doc_artifacts = Vec::new();
137
138    {
139        use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW};
140
141        // ── Dependency validation ─────────────────────────────────────────────
142        //
143        // Build a map from id → index so we can reference builders by name.
144        let id_to_idx: HashMap<&str, usize> = config
145            .builders
146            .iter()
147            .enumerate()
148            .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i)))
149            .collect();
150
151        // Check that every depends_on reference resolves to a known id.
152        for (i, entry) in config.builders.iter().enumerate() {
153            for dep in &entry.depends_on {
154                if !id_to_idx.contains_key(dep.as_str()) {
155                    return Err(miette::miette!(
156                        "builder #{i} ({}) lists '{}' in depends_on, \
157                         but no builder has that id",
158                        entry.label(),
159                        dep
160                    ));
161                }
162            }
163        }
164
165        // Cycle detection via iterative DFS (0=unvisited, 1=in-stack, 2=done).
166        {
167            let n = config.builders.len();
168            let mut state = vec![0u8; n];
169
170            fn dfs(
171                idx: usize,
172                id_to_idx: &HashMap<&str, usize>,
173                builders: &[crate::builders::BuilderEntry],
174                state: &mut Vec<u8>,
175            ) -> Result<()> {
176                if state[idx] == 1 {
177                    return Err(miette::miette!(
178                        "dependency cycle detected involving builder #{idx} ({})",
179                        builders[idx].id.as_deref().unwrap_or(builders[idx].label())
180                    ));
181                }
182                if state[idx] == 2 {
183                    return Ok(());
184                }
185                state[idx] = 1;
186                for dep in &builders[idx].depends_on {
187                    if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) {
188                        dfs(dep_idx, id_to_idx, builders, state)?;
189                    }
190                }
191                state[idx] = 2;
192                Ok(())
193            }
194
195            for i in 0..n {
196                dfs(i, &id_to_idx, &config.builders, &mut state)?;
197            }
198        }
199
200        // ── Completion signals ────────────────────────────────────────────────
201        //
202        // For every builder that carries an `id` we open a watch channel.
203        // Dependents receive a clone of the receiver and wait until the value
204        // transitions from `None` (pending) to `Some(true)` (success) or
205        // `Some(false)` (failure / dependency failure).
206        let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new();
207        let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new();
208
209        for entry in &config.builders {
210            if let Some(id) = &entry.id {
211                let (tx, rx) = watch::channel(None::<bool>);
212                completion_txs.insert(id.clone(), tx);
213                completion_rxs.insert(id.clone(), rx);
214            }
215        }
216
217        // ── Progress bars & task spawning ─────────────────────────────────────
218        let total = config.builders.len();
219        let multi = MultiProgress::new();
220
221        // Bottom bar: overall completion counter.
222        let summary = multi.add(ProgressBar::new(total as u64));
223        summary.set_style(
224            ProgressStyle::with_template("{pos}/{len} builders  {bar:20.green/white}  {msg}")
225                .expect("valid template"),
226        );
227        summary.set_message("building…");
228
229        let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> =
230            JoinSet::new();
231
232        for (i, entry) in config.builders.iter().enumerate() {
233            let color = COLOURS[i % COLOURS.len()];
234            let label = entry.id.as_deref().unwrap_or(entry.label());
235            let colored_prefix = format!("{color}[{label}]{RESET}");
236
237            // Spinner inserted above the summary bar.
238            let pb = multi.insert_before(&summary, ProgressBar::new_spinner());
239            pb.set_style(
240                ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
241                    .expect("valid template")
242                    .tick_chars(SPINNER_CHARS),
243            );
244            pb.set_prefix(colored_prefix);
245            pb.set_message("starting…");
246            pb.enable_steady_tick(Duration::from_millis(100));
247
248            let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>();
249
250            // Task: receive LogEvents and update spinners.
251            // ChildStart creates a new sub-spinner inserted right below the
252            // parent (and below any previously created siblings, via
253            // `last_child_pb`).  ChildLine / ChildFinish update or finish it.
254            let pb_log = pb.clone();
255            let multi_log = multi.clone();
256            let parent_color_idx = i;
257            tokio::spawn(async move {
258                let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new();
259                // Track insertion point so siblings stack in order.
260                let mut last_child_pb = pb_log.clone();
261                let mut child_color_idx = parent_color_idx + 1;
262
263                while let Some(event) = log_rx.recv().await {
264                    match event {
265                        LogEvent::Line(line) => {
266                            pb_log.set_message(line);
267                        }
268                        LogEvent::ChildStart { id, label } => {
269                            let child_color = COLOURS[child_color_idx % COLOURS.len()];
270                            child_color_idx += 1;
271                            let child_pb =
272                                multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner());
273                            child_pb.set_style(
274                                ProgressStyle::with_template("  {spinner:.bold} {prefix} {msg}")
275                                    .expect("valid template")
276                                    .tick_chars(SPINNER_CHARS),
277                            );
278                            child_pb.set_prefix(format!("{child_color}[{label}]{RESET}"));
279                            child_pb.set_message("starting…");
280                            child_pb.enable_steady_tick(Duration::from_millis(100));
281                            last_child_pb = child_pb.clone();
282                            child_pbs.insert(id, child_pb);
283                        }
284                        LogEvent::ChildLine { id, line } => {
285                            if let Some(child_pb) = child_pbs.get(&id) {
286                                child_pb.set_message(line);
287                            }
288                        }
289                        LogEvent::ChildFinish {
290                            id,
291                            success,
292                            summary,
293                        } => {
294                            if let Some(child_pb) = child_pbs.remove(&id) {
295                                if success {
296                                    child_pb.finish_with_message(format!(
297                                        "{GREEN}\u{2713}{RESET} {summary}"
298                                    ));
299                                } else {
300                                    child_pb.finish_with_message(format!(
301                                        "{RED}\u{2717}{RESET} {summary}"
302                                    ));
303                                }
304                            }
305                        }
306                    }
307                }
308            });
309
310            // Collect the watch receivers for every declared dependency.
311            let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry
312                .depends_on
313                .iter()
314                .filter_map(|dep_id| {
315                    completion_rxs
316                        .get(dep_id)
317                        .map(|rx| (dep_id.clone(), rx.clone()))
318                })
319                .collect();
320
321            // Take ownership of the completion sender for this builder's own id
322            // (if it has one) so the task can signal its outcome.
323            let my_tx: Option<watch::Sender<Option<bool>>> =
324                entry.id.as_ref().and_then(|id| completion_txs.remove(id));
325
326            let entry = entry.clone();
327            let version = version.clone();
328            let pb_task = pb.clone();
329            let summary_task = summary.clone();
330
331            join_set.spawn(async move {
332                // ── Wait for dependencies ─────────────────────────────────────
333                for (dep_id, mut rx) in dep_receivers {
334                    pb_task.set_message(format!("waiting for '{dep_id}'…"));
335
336                    // Block until the dependency resolves (Some(_)) or its
337                    // sender is dropped (which we treat as a failure).
338                    let resolved = rx.wait_for(|v| v.is_some()).await;
339
340                    let succeeded = match resolved {
341                        Err(_) => false, // sender dropped unexpectedly
342                        Ok(r) => r.unwrap_or(false),
343                    };
344
345                    if !succeeded {
346                        summary_task.inc(1);
347                        pb_task.finish_with_message(format!(
348                            "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)"
349                        ));
350                        if let Some(tx) = &my_tx {
351                            let _ = tx.send(Some(false));
352                        }
353                        // Return an empty artifact list; the dependency error
354                        // itself will surface from the dependency's own task.
355                        return Ok(vec![]);
356                    }
357                }
358
359                // ── Run the builder ───────────────────────────────────────────
360                pb_task.set_message("running…");
361                let result = entry.build(&version, log_tx).await;
362                let succeeded = result.is_ok();
363
364                if let Some(tx) = my_tx {
365                    let _ = tx.send(Some(succeeded));
366                }
367
368                summary_task.inc(1);
369                match &result {
370                    Ok(artifacts) => pb_task.finish_with_message(format!(
371                        "{GREEN}\u{2713} done{RESET} ({} artifact(s))",
372                        artifacts.len()
373                    )),
374                    Err(e) => {
375                        pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"))
376                    }
377                }
378                result
379            });
380        }
381
382        // Collect all results; continue even when some builders fail so every
383        // spinner reaches its final state before we return an error.
384        let mut errors: Vec<miette::Report> = Vec::new();
385        while let Some(res) = join_set.join_next().await {
386            match res.into_diagnostic()? {
387                Ok(artifacts) => {
388                    for artifact in artifacts {
389                        if artifact.path.is_dir() {
390                            doc_artifacts.push(artifact);
391                        } else {
392                            dist_artifacts.push(artifact);
393                        }
394                    }
395                }
396                Err(e) => errors.push(e),
397            }
398        }
399
400        let summary_msg = if errors.is_empty() {
401            format!("{GREEN}all done{RESET}")
402        } else {
403            format!("{RED}some builders failed{RESET}")
404        };
405        summary.finish_with_message(summary_msg);
406
407        if let Some(first_err) = errors.into_iter().next() {
408            return Err(first_err);
409        }
410    }
411
412    // ── 4. Lay out dist/ ──────────────────────────────────────────────────────
413    let version_dir = output_dir.join(&version);
414    let dist_dir = version_dir.join("dist");
415    tokio::fs::create_dir_all(&dist_dir)
416        .await
417        .into_diagnostic()?;
418
419    for artifact in &dist_artifacts {
420        tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
421            .await
422            .into_diagnostic()?;
423    }
424
425    // Compute size + SHA-256 for each copied dist artifact.
426    let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
427    for artifact in &dist_artifacts {
428        let dest = dist_dir.join(&artifact.name);
429        let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
430        let size_bytes = bytes.len() as u64;
431        let sha256 = hex_sha256(&bytes);
432        dist_file_infos.push(DistFileInfo {
433            name: artifact.name.clone(),
434            size_bytes,
435            size_human: human_size(size_bytes),
436            sha256,
437        });
438    }
439    let has_dist = !dist_file_infos.is_empty();
440
441    // ── 5. Lay out docs/ ──────────────────────────────────────────────────────
442    let has_docs = !doc_artifacts.is_empty();
443    let has_docs_tarball;
444
445    if has_docs {
446        let docs_dir = version_dir.join("docs");
447
448        for artifact in &doc_artifacts {
449            // Copy the complete target/doc tree - this includes the shared
450            // rustdoc CSS, JS, fonts and search indices at the root of the
451            // directory in addition to the per-crate HTML subdirectories.
452            copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
453        }
454
455        // Rustdoc writes index.html at the root for single-crate projects.
456        // For workspaces it may be absent; generate a fallback listing then.
457        if !docs_dir.join("index.html").exists() {
458            let crate_names = find_doc_crates(&docs_dir).await?;
459            write_docs_index(&docs_dir, &crate_names).await?;
460        }
461
462        let tarball = version_dir.join("docs.tar.gz");
463        let docs_dir_c = docs_dir.clone();
464        let tarball_c = tarball.clone();
465        tokio::task::spawn_blocking(move || utils::archive_dir(&docs_dir_c, &tarball_c))
466            .await
467            .into_diagnostic()??;
468
469        has_docs_tarball = true;
470    } else {
471        has_docs_tarball = false;
472    }
473
474    // ── 6. README ─────────────────────────────────────────────────────────────
475    let readme_path = config
476        .site
477        .readme
478        .as_deref()
479        .unwrap_or(Path::new("README.md"));
480
481    let readme_html = match tokio::fs::read_to_string(readme_path).await {
482        Ok(content) => render_markdown(&content),
483        Err(_) => {
484            warn!("README not found at {}", readme_path.display());
485            String::new()
486        }
487    };
488
489    // Copy any locally-referenced files (e.g. images) from the README's
490    // directory into the version directory so they resolve correctly from
491    // the generated index.html.
492    let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
493    if let Ok(content) = tokio::fs::read_to_string(readme_path).await {
494        for rel in extract_local_refs(&content) {
495            let src = readme_dir.join(&rel);
496            if src.is_file() {
497                // Preserve any sub-directory structure relative to the README.
498                let dest = version_dir.join(&rel);
499                if let Some(parent) = dest.parent() {
500                    tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
501                }
502                tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
503            }
504        }
505    }
506
507    // ── 7. Changelog section ──────────────────────────────────────────────────
508    let changelog_html = match ChangelogExtractor
509        .section(config.changelog.clone(), &version)
510        .await
511    {
512        Ok(section) => render_markdown(&section),
513        Err(_) => {
514            warn!("No changelog entry found for version {version}");
515            String::new()
516        }
517    };
518
519    // ── 8. Version index.html ─────────────────────────────────────────────────
520
521    // Git UI integration: derive the clone URL once and compute the tag name
522    // for the current version so the templates can link into the repository UI.
523    let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| {
524        cfg.clone_url.clone().or_else(|| {
525            config
526                .site
527                .base_url
528                .as_ref()
529                .map(|b| format!("{}/repository.git", b.trim_end_matches('/')))
530        })
531    });
532    let version_tag = config.version_extractor.tag_name(&version);
533
534    let mut version_ctx = Context::new();
535    version_ctx.insert("config", &config);
536    version_ctx.insert("project_name", &config.site.name);
537    version_ctx.insert("lang", &config.site.lang);
538    version_ctx.insert("repo_url", &config.site.repo_url);
539    version_ctx.insert("version", &version);
540    version_ctx.insert("readme_html", &readme_html);
541    version_ctx.insert("changelog_html", &changelog_html);
542    version_ctx.insert("has_docs", &has_docs);
543    version_ctx.insert("has_docs_tarball", &has_docs_tarball);
544    version_ctx.insert("has_dist", &has_dist);
545    version_ctx.insert("dist_files", &dist_file_infos);
546    version_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
547    version_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
548    version_ctx.insert("version_tag", &version_tag);
549
550    let version_html = tera
551        .render("version_index.html", &version_ctx)
552        .into_diagnostic()?;
553    tokio::fs::write(version_dir.join("index.html"), version_html)
554        .await
555        .into_diagnostic()?;
556
557    // ── 9. Root index.html + Atom feed ──────────────────────────────────────
558    // Collect every known version from the version extractor, ensure the
559    // current version is present (handles untagged builds), then sort newest-first.
560    let mut all_versions = config.version_extractor.extract_all().await?;
561    if !all_versions.iter().any(|v| v.version == version) {
562        all_versions.push(version_info.clone());
563    }
564    all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
565
566    // Build the template-friendly list (version string + optional date string).
567    let version_entries: Vec<VersionEntry> =
568        all_versions.iter().map(VersionEntry::from_info).collect();
569
570    let base_url = config
571        .site
572        .base_url
573        .as_deref()
574        .map(|u| u.trim_end_matches('/'));
575
576    let mut root_ctx = Context::new();
577    root_ctx.insert("config", &config);
578    root_ctx.insert("project_name", &config.site.name);
579    root_ctx.insert("lang", &config.site.lang);
580    root_ctx.insert("repo_url", &config.site.repo_url);
581    root_ctx.insert("versions", &version_entries);
582    root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
583    root_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
584    root_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
585
586    let root_html = tera
587        .render("root_index.html", &root_ctx)
588        .into_diagnostic()?;
589    tokio::fs::write(output_dir.join("index.html"), root_html)
590        .await
591        .into_diagnostic()?;
592
593    // ── 10. Atom feed ─────────────────────────────────────────────────────────
594    // Load all changelog sections once so we can embed release notes in feed entries.
595    let changelog_sections = match ChangelogExtractor
596        .all_sections(config.changelog.clone())
597        .await
598    {
599        Ok(map) => map,
600        Err(_) => {
601            warn!("Could not load changelog for Atom feed; entries will have no content");
602            std::collections::HashMap::new()
603        }
604    };
605
606    let atom_xml = generate_atom_feed(
607        &config.site.name,
608        &all_versions,
609        base_url,
610        &changelog_sections,
611    );
612    tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
613        .await
614        .into_diagnostic()?;
615
616    // ── 11. `latest` symlink ──────────────────────────────────────────────────
617    if let Some(latest) = all_versions.first() {
618        update_latest_symlink(output_dir, &latest.version)?;
619    }
620
621    Ok(())
622}
623
624// ── Private helpers ───────────────────────────────────────────────────────────
625
626/// Extract URLs of locally-referenced files from a Markdown document.
627///
628/// Returns relative paths that are referenced as images or links and that do
629/// not look like remote URLs (no `://` scheme) or bare fragment anchors
630/// (starting with `#`).
631fn extract_local_refs(md: &str) -> Vec<String> {
632    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
633    let mut refs = Vec::new();
634    for event in Parser::new_ext(md, opts) {
635        let url: Option<pulldown_cmark::CowStr> = match event {
636            Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
637            Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
638            // pulldown-cmark emits End events with a TagEnd - ignore those.
639            Event::End(TagEnd::Image | TagEnd::Link) => None,
640            _ => None,
641        };
642        if let Some(url) = url {
643            let s = url.as_ref();
644            // Skip remote URLs, data URIs, and fragment-only links.
645            if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
646                refs.push(s.to_owned());
647            }
648        }
649    }
650    refs
651}
652
653/// Render Markdown to an HTML string.
654fn render_markdown(md: &str) -> String {
655    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
656    let parser = Parser::new_ext(md, opts);
657    let mut buf = String::new();
658    html::push_html(&mut buf, parser);
659    buf
660}
661
662/// Scan `docs_dir` for subdirectories that contain an `index.html` and return
663/// their names. Used to build a fallback listing when rustdoc itself did not
664/// generate a root `index.html` (typical for multi-crate workspaces).
665async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
666    let mut names = Vec::new();
667    let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
668    while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
669        let path = entry.path();
670        if path.is_dir() && path.join("index.html").exists() {
671            names.push(entry.file_name().to_string_lossy().into_owned());
672        }
673    }
674    Ok(names)
675}
676
677/// Write a `docs/index.html` that either redirects straight to the single
678/// crate's docs (one crate) or lists all crates (multiple crates).
679async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
680    let html = if crate_names.len() == 1 {
681        format!(
682            "<!DOCTYPE html><html><head>\
683            <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
684            </head></html>",
685            crate_names[0]
686        )
687    } else {
688        let items = crate_names
689            .iter()
690            .map(|n| format!("  <li><a href=\"{n}/index.html\">{n}</a></li>"))
691            .collect::<Vec<_>>()
692            .join("\n");
693        format!(
694            "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
695            <title>Documentation</title></head>\
696            <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
697        )
698    };
699
700    tokio::fs::write(docs_dir.join("index.html"), html)
701        .await
702        .into_diagnostic()
703}
704
705/// Recursively copy the contents of `src` into `dst`.
706///
707/// Uses explicit boxing to satisfy the compiler for the async recursive call.
708fn copy_dir_recursive(
709    src: PathBuf,
710    dst: PathBuf,
711) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
712    Box::pin(async move {
713        tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
714        let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
715        while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
716            let src_path = entry.path();
717            let dst_path = dst.join(entry.file_name());
718            if src_path.is_dir() {
719                copy_dir_recursive(src_path, dst_path).await?;
720            } else {
721                tokio::fs::copy(&src_path, &dst_path)
722                    .await
723                    .into_diagnostic()?;
724            }
725        }
726        Ok(())
727    })
728}
729
730/// Compare two version strings, preferring semver ordering and falling back
731/// to lexicographic comparison for non-semver strings (e.g. git describe output).
732fn strip_v(s: &str) -> &str {
733    s.strip_prefix('v').unwrap_or(s)
734}
735
736fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
737    match (
738        semver::Version::parse(strip_v(a)),
739        semver::Version::parse(strip_v(b)),
740    ) {
741        (Ok(va), Ok(vb)) => va.cmp(&vb),
742        _ => a.cmp(b),
743    }
744}
745
746/// Compute a lowercase hex-encoded SHA-256 digest of `data`.
747fn hex_sha256(data: &[u8]) -> String {
748    let mut hasher = Sha256::new();
749    hasher.update(data);
750    hasher
751        .finalize()
752        .iter()
753        .map(|b| format!("{b:02x}"))
754        .collect()
755}
756
757/// Format a byte count as a human-readable string (e.g. "1.4 MB").
758fn human_size(bytes: u64) -> String {
759    const KIB: u64 = 1024;
760    const MIB: u64 = KIB * 1024;
761    const GIB: u64 = MIB * 1024;
762    if bytes >= GIB {
763        format!("{:.1} GB", bytes as f64 / GIB as f64)
764    } else if bytes >= MIB {
765        format!("{:.1} MB", bytes as f64 / MIB as f64)
766    } else if bytes >= KIB {
767        format!("{:.1} KB", bytes as f64 / KIB as f64)
768    } else {
769        format!("{bytes} B")
770    }
771}
772
773/// Escape the five XML predefined characters in a string.
774fn xml_escape(s: &str) -> String {
775    s.replace('&', "&amp;")
776        .replace('<', "&lt;")
777        .replace('>', "&gt;")
778        .replace('"', "&quot;")
779        .replace('\'', "&apos;")
780}
781
782/// Generate an Atom 1.0 feed listing all known releases.
783///
784/// - `base_url`: when provided (already stripped of trailing `/`), used to
785///   build `<link>` and `<id>` elements with absolute URLs.  When absent,
786///   a `urn:` based ID is used and no `<link>` elements are emitted.
787/// - `changelog_sections`: map from version string to Markdown release-note
788///   body.  When a matching entry is found it is rendered to HTML and included
789///   as a `<content type="html">` element in the Atom entry.
790fn generate_atom_feed(
791    project_name: &str,
792    versions: &[VersionInfo],
793    base_url: Option<&str>,
794    changelog_sections: &std::collections::HashMap<String, String>,
795) -> String {
796    let now: DateTime<Utc> = Utc::now();
797
798    // The feed's <updated> is the most-recent entry date, or now as fallback.
799    let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
800
801    let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
802
803    // Feed-level <id> and self-link.
804    let feed_id = match base_url {
805        Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
806        None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
807    };
808
809    let self_link = match base_url {
810        Some(base) => format!(
811            "  <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
812            xml_escape(base)
813        ),
814        None => String::new(),
815    };
816    let alt_link = match base_url {
817        Some(base) => format!("  <link href=\"{}\"/>\n", xml_escape(base)),
818        None => String::new(),
819    };
820
821    let entries: String = versions
822        .iter()
823        .map(|vi| {
824            let entry_date = vi.date.unwrap_or(now);
825            let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
826            let v_escaped = xml_escape(&vi.version);
827
828            let entry_id = match base_url {
829                Some(base) => format!("{base}/{v_escaped}/"),
830                None => format!(
831                    "urn:abbaye:release:{}:{v_escaped}",
832                    xml_escape(project_name)
833                ),
834            };
835
836            let entry_link = match base_url {
837                Some(base) => format!("    <link href=\"{base}/{v_escaped}/\"/>\n"),
838                None => String::new(),
839            };
840
841            // Render the changelog section (if any) to HTML, then XML-escape
842            // it for embedding inside <content type="html">.
843            let content_element = match changelog_sections.get(&vi.version) {
844                Some(md) if !md.is_empty() => {
845                    let html = render_markdown(md);
846                    format!(
847                        "    <content type=\"html\">{}</content>\n",
848                        xml_escape(&html)
849                    )
850                }
851                _ => String::new(),
852            };
853
854            format!(
855                "  <entry>\n\
856                 \x20   <title>{v_escaped}</title>\n\
857                 \x20   <id>{entry_id}</id>\n\
858                 \x20   <updated>{entry_date_str}</updated>\n\
859                 {entry_link}\
860                 {content_element}\
861                 \x20 </entry>"
862            )
863        })
864        .collect::<Vec<_>>()
865        .join("\n");
866
867    format!(
868        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
869         <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
870         \x20 <title>{name} Releases</title>\n\
871         {self_link}\
872         {alt_link}\
873         \x20 <updated>{feed_updated_str}</updated>\n\
874         \x20 <id>{feed_id}</id>\n\
875         {entries}\n\
876         </feed>\n",
877        name = xml_escape(project_name),
878    )
879}
880
881/// Create or replace the `latest` symlink in `output_dir`, pointing to
882/// `version_dir_name`.
883///
884/// On non-Unix platforms a meta-refresh redirect page is written instead.
885fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
886    let link = output_dir.join("latest");
887
888    #[cfg(unix)]
889    {
890        // Remove any stale symlink or file before (re-)creating it.
891        if link.exists() || link.is_symlink() {
892            std::fs::remove_file(&link).into_diagnostic()?;
893        }
894        std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
895    }
896
897    #[cfg(not(unix))]
898    {
899        std::fs::create_dir_all(&link).into_diagnostic()?;
900        std::fs::write(
901            link.join("index.html"),
902            format!(
903                "<!DOCTYPE html><html><head>\
904                <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
905                </head></html>"
906            ),
907        )
908        .into_diagnostic()?;
909    }
910
911    Ok(())
912}
913
914/// Scans `theme_path` for any `*.j2` files whose stem (the name without the
915/// `.j2` suffix, e.g. `"base.html"`) is **not** already listed in `skip`, and
916/// loads each one into `tera` under that stem name.
917///
918/// This makes user-supplied helper or base templates - e.g. a `base.html.j2`
919/// referenced by `{% extends "base.html" %}` in a customised main template -
920/// available at render time without the caller needing to enumerate them.
921pub(crate) fn load_extra_theme_templates(
922    tera: &mut tera::Tera,
923    theme_path: &std::path::Path,
924    skip: &[&str],
925) -> miette::Result<()> {
926    let entries = match std::fs::read_dir(theme_path) {
927        Ok(e) => e,
928        Err(_) => return Ok(()), // theme dir absent - nothing to do
929    };
930    for entry in entries.flatten() {
931        let path = entry.path();
932        if path.extension().and_then(|e| e.to_str()) != Some("j2") {
933            continue;
934        }
935        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
936            continue;
937        };
938        if skip.contains(&stem) {
939            continue;
940        }
941        tera.add_template_file(&path, Some(stem)).map_err(|e| {
942            miette::miette!("failed to load theme template {}: {e}", path.display())
943        })?;
944    }
945    Ok(())
946}