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 indicatif::ProgressStyle;
9use miette::{IntoDiagnostic, Result};
10use sha2::{Digest, Sha256};
11use tera::{Context, Tera};
12use tracing::{info, warn};
13
14use crate::{
15    changelog::ChangelogExtractor,
16    config::{AbbayeConfig, OutputFormat},
17    render::{extract_local_refs, render_markdown_gemtext, render_markdown_html},
18    utils,
19    version_extractors::VersionInfo,
20};
21
22pub const TEMPLATE_BASE_HTML: &str = include_str!("templates/base.html.j2");
23pub const TEMPLATE_ROOT_INDEX_HTML: &str = include_str!("templates/root_index.html.j2");
24pub const TEMPLATE_VERSION_INDEX_HTML: &str = include_str!("templates/version_index.html.j2");
25pub const TEMPLATE_ROOT_INDEX_GEMTEXT: &str = include_str!("templates/root_index.gmi.j2");
26pub const TEMPLATE_VERSION_INDEX_GEMTEXT: &str = include_str!("templates/version_index.gmi.j2");
27pub const SITE_CSS: &str = include_str!("templates/site.css");
28const ATOM_FEED_FILENAME: &str = "releases.atom";
29
30pub(crate) const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
31
32/// Create a spinner style matching the rest of the abbaye UI.
33/// When `indent` is true the template is prefixed with two spaces (for child spinners).
34pub(crate) fn spinner_style(indent: bool) -> ProgressStyle {
35    let tmpl = if indent {
36        "  {spinner:.bold} {prefix} {msg}"
37    } else {
38        "{spinner:.bold} {prefix} {msg}"
39    };
40    ProgressStyle::with_template(tmpl)
41        .expect("valid template")
42        .tick_chars(SPINNER_CHARS)
43}
44
45// ── Types ───────────────────────────────────────────────────────────────────
46
47/// A version entry as passed to Tera templates.
48#[derive(serde::Serialize)]
49struct VersionEntry {
50    /// Version string (e.g. `"1.2.3"`).
51    version: String,
52    /// ISO-8601 date string (e.g. `"2024-01-15"`) when the release date is known.
53    date: Option<String>,
54}
55
56impl VersionEntry {
57    fn from_info(info: &VersionInfo) -> Self {
58        Self {
59            version: info.version.clone(),
60            date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
61        }
62    }
63}
64
65/// Metadata about a single file-type dist artifact, passed to Tera templates.
66#[derive(serde::Serialize, Clone)]
67struct DistFileInfo {
68    /// File name (relative to `dist/`).
69    name: String,
70    /// Raw byte size of the file.
71    size_bytes: u64,
72    /// Human-readable size (e.g. "1.4 MB").
73    size_human: String,
74    /// Lowercase hex-encoded SHA-256 digest of the file contents.
75    sha256: String,
76    /// Optional category inherited from the builder that produced this file.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    category: Option<String>,
79    /// Optional display name inherited from the builder entry.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    group_name: Option<String>,
82    /// Optional comment inherited from the builder entry.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    group_comment: Option<String>,
85}
86
87/// A sub-group of dist artifacts sharing the same group name (from the same
88/// builder entry).
89#[derive(serde::Serialize)]
90struct DistSubgroup {
91    /// Display name for this group, or `None`.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    name: Option<String>,
94    /// Comment for this group, or `None`.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    comment: Option<String>,
97    /// Files in this sub-group.
98    files: Vec<DistFileInfo>,
99}
100
101/// A group of dist artifacts sharing the same builder category.
102#[derive(serde::Serialize)]
103struct DistCategory {
104    /// The shared category, or `None` for builders without one.
105    category: Option<String>,
106    /// Sub-groups within this category, each from a different builder entry.
107    groups: Vec<DistSubgroup>,
108}
109
110/// Build the full website into `config.output_dir` (defaults to `public/`).
111///
112/// # Steps
113///
114/// 1. Extract the current version via the configured [`crate::version_extractors::AnyVersionExtractor`].
115/// 2. Run every configured builder and split the resulting [`crate::builders::ArtifactPath`]s
116///    into *dist* (regular files) and *docs* (directories).
117/// 3. Copy dist artifacts to `<output>/<version>/dist/`.
118/// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive
119///    the whole `docs/` tree as `docs.tar.gz`.
120/// 5. Render `<output>/<version>/index.html` from the project README and the
121///    matching changelog section.
122/// 6. Re-render the root `<output>/index.html`, listing all known versions
123///    (newest first).
124/// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other).
125pub async fn build_site(config: AbbayeConfig) -> Result<()> {
126    let output_dir = &config.site.output_dir;
127
128    tokio::fs::create_dir_all(output_dir)
129        .await
130        .into_diagnostic()?;
131
132    // ── 1. Version ────────────────────────────────────────────────────────────
133    info!("Extracting version …");
134    let version_info = config.version_extractor.extract().await?;
135    let version = version_info.version.clone();
136
137    // ── 2. Tera setup ─────────────────────────────────────────────────────────
138    info!("Setting up templates …");
139    let mut tera = Tera::default();
140    let theme_path = PathBuf::from(".abbaye").join("theme");
141
142    // Load templates for each configured format (with theme overrides).
143    register_format_templates(
144        &mut tera,
145        &theme_path,
146        &config.site.formats,
147        &[
148            (
149                "root_index",
150                TEMPLATE_ROOT_INDEX_HTML,
151                TEMPLATE_ROOT_INDEX_GEMTEXT,
152            ),
153            (
154                "version_index",
155                TEMPLATE_VERSION_INDEX_HTML,
156                TEMPLATE_VERSION_INDEX_GEMTEXT,
157            ),
158        ],
159    )?;
160    // Write shared CSS to static/ so the git UI templates can reference it.
161    {
162        let static_dir = output_dir.join("static");
163        tokio::fs::create_dir_all(&static_dir)
164            .await
165            .into_diagnostic()?;
166        tokio::fs::write(static_dir.join("site.css"), SITE_CSS)
167            .await
168            .into_diagnostic()?;
169    }
170    // If there's a `static` directory in the theme, copy it over (may overwrite site.css).
171    if theme_path.join("static").is_dir() {
172        copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
173    }
174
175    // ── 3. Builders (run in parallel, respecting depends_on ordering) ─────────
176    info!("Running builders for version {version} …");
177    let (dist_artifacts, doc_artifacts) =
178        crate::builders::orchestrator::run_builders(&config.builders, &version).await?;
179
180    // ── 4. Lay out dist/ ──────────────────────────────────────────────────────
181    info!("Laying out {} dist artifact(s) …", dist_artifacts.len());
182    let version_dir = output_dir.join(&version);
183    let dist_dir = version_dir.join("dist");
184    tokio::fs::create_dir_all(&dist_dir)
185        .await
186        .into_diagnostic()?;
187
188    // Copy dist artifacts and compute size + SHA-256 in a single pass.
189    let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
190    for artifact in &dist_artifacts {
191        let bytes = tokio::fs::read(&artifact.path).await.into_diagnostic()?;
192        let size_bytes = bytes.len() as u64;
193        let sha256 = hex_sha256(&bytes);
194        let dest = dist_dir.join(&artifact.name);
195        tokio::fs::write(&dest, &bytes).await.into_diagnostic()?;
196        dist_file_infos.push(DistFileInfo {
197            name: artifact.name.clone(),
198            size_bytes,
199            size_human: human_size(size_bytes),
200            sha256,
201            category: artifact.category.clone(),
202            group_name: artifact.group_name.clone(),
203            group_comment: artifact.group_comment.clone(),
204        });
205    }
206
207    // Group dist artifacts by category, then by group name within each category.
208    let mut category_map: std::collections::BTreeMap<Option<String>, Vec<DistFileInfo>> =
209        std::collections::BTreeMap::new();
210    for info in dist_file_infos {
211        category_map
212            .entry(info.category.clone())
213            .or_default()
214            .push(info);
215    }
216    let dist_categories: Vec<DistCategory> = category_map
217        .into_iter()
218        .map(|(category, files)| {
219            let mut subgroup_map: std::collections::BTreeMap<
220                (Option<String>, Option<String>),
221                Vec<DistFileInfo>,
222            > = std::collections::BTreeMap::new();
223            for f in files {
224                subgroup_map
225                    .entry((f.group_name.clone(), f.group_comment.clone()))
226                    .or_default()
227                    .push(f);
228            }
229            let groups: Vec<DistSubgroup> = subgroup_map
230                .into_iter()
231                .map(|((name, comment), files)| DistSubgroup {
232                    name,
233                    comment,
234                    files,
235                })
236                .collect();
237            DistCategory { category, groups }
238        })
239        .collect();
240    let has_dist = !dist_categories.is_empty();
241
242    // ── 5. Lay out docs/ ──────────────────────────────────────────────────────
243    info!("Laying out {} doc artifact(s) …", doc_artifacts.len());
244    let has_docs = !doc_artifacts.is_empty();
245    let has_docs_tarball;
246
247    if has_docs {
248        let docs_dir = version_dir.join("docs");
249
250        for artifact in &doc_artifacts {
251            // Copy the complete target/doc tree - this includes the shared
252            // rustdoc CSS, JS, fonts and search indices at the root of the
253            // directory in addition to the per-crate HTML subdirectories.
254            copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
255        }
256
257        // Rustdoc writes index.html at the root for single-crate projects.
258        // For workspaces it may be absent; generate a fallback listing then.
259        if !docs_dir.join("index.html").exists() {
260            let crate_names = find_doc_crates(&docs_dir).await?;
261            write_docs_index(&docs_dir, &crate_names).await?;
262        }
263
264        let tarball = version_dir.join("docs.tar.gz");
265        let docs_dir_c = docs_dir.clone();
266        let tarball_c = tarball.clone();
267        tokio::task::spawn_blocking(move || utils::archive_dir(&docs_dir_c, &tarball_c))
268            .await
269            .into_diagnostic()??;
270
271        has_docs_tarball = true;
272    } else {
273        has_docs_tarball = false;
274    }
275
276    // ── 6. Raw README content ─────────────────────────────────────────────────
277    let readme_path = config
278        .site
279        .readme
280        .as_deref()
281        .unwrap_or(Path::new("README.md"));
282
283    let readme_md = tokio::fs::read_to_string(readme_path)
284        .await
285        .unwrap_or_else(|_| {
286            warn!("README not found at {}", readme_path.display());
287            String::new()
288        });
289
290    // Copy any locally-referenced files (e.g. images) from the README's
291    // directory into the version directory so they resolve correctly from
292    // the generated pages.
293    if !readme_md.is_empty() {
294        let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
295        for rel in extract_local_refs(&readme_md) {
296            let src = readme_dir.join(&rel);
297            if src.is_file() {
298                let dest = version_dir.join(&rel);
299                if let Some(parent) = dest.parent() {
300                    tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
301                }
302                tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
303            }
304        }
305    }
306
307    // ── 7. Raw changelog section ──────────────────────────────────────────────
308    let changelog_md = match ChangelogExtractor
309        .section(config.changelog.clone(), &version)
310        .await
311    {
312        Ok(section) => section,
313        Err(_) => {
314            warn!("No changelog entry found for version {version}");
315            String::new()
316        }
317    };
318
319    // ── 8. Version pages (per format) ─────────────────────────────────────────
320    info!("Rendering version pages …");
321
322    // Git UI integration: derive the clone URL once and compute the tag name
323    // for the current version so the templates can link into the repository UI.
324    let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| {
325        cfg.clone_url.clone().or_else(|| {
326            config
327                .site
328                .base_url
329                .as_ref()
330                .map(|b| format!("{}/repository.git", b.trim_end_matches('/')))
331        })
332    });
333    let version_tag = config.version_extractor.tag_name(&version);
334
335    for format in &config.site.formats {
336        let suffix = format.extension();
337        let tmpl_name = format!("version_index.{suffix}");
338        let ext = format.extension();
339
340        let (readme_content, changelog_content): (String, String) = match format {
341            OutputFormat::Html => (
342                render_markdown_html(&readme_md),
343                render_markdown_html(&changelog_md),
344            ),
345            OutputFormat::Gemtext => (
346                render_markdown_gemtext(&readme_md),
347                render_markdown_gemtext(&changelog_md),
348            ),
349        };
350
351        // Pre-render HTML versions (only needed for the HTML template)
352        let (readme_html, changelog_html) = match format {
353            OutputFormat::Html => (
354                render_markdown_html(&readme_md),
355                render_markdown_html(&changelog_md),
356            ),
357            OutputFormat::Gemtext => (String::new(), String::new()),
358        };
359
360        let mut ctx = Context::new();
361        ctx.insert("config", &config);
362        ctx.insert("project_name", &config.site.name);
363        ctx.insert("lang", &config.site.lang);
364        ctx.insert("repo_url", &config.site.repo_url);
365        ctx.insert("version", &version);
366        ctx.insert("readme_html", &readme_html);
367        ctx.insert("changelog_html", &changelog_html);
368        ctx.insert("readme_content", &readme_content);
369        ctx.insert("changelog_content", &changelog_content);
370        ctx.insert("has_docs", &has_docs);
371        ctx.insert("has_docs_tarball", &has_docs_tarball);
372        ctx.insert("has_dist", &has_dist);
373        ctx.insert("dist_categories", &dist_categories);
374        ctx.insert("git_ui_enabled", &config.git_ui.is_some());
375        ctx.insert("git_ui_clone_url", &git_ui_clone_url);
376        ctx.insert("version_tag", &version_tag);
377        ctx.insert("root_path", "../");
378
379        let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
380        tokio::fs::write(version_dir.join(format!("index.{ext}")), content)
381            .await
382            .into_diagnostic()?;
383    }
384
385    // ── 9. Root index.html + Atom feed ──────────────────────────────────────
386    info!("Rendering root index and Atom feed …");
387    // Collect every known version from the version extractor, ensure the
388    // current version is present (handles untagged builds), then sort newest-first.
389    let mut all_versions = config.version_extractor.extract_all().await?;
390    if !all_versions.iter().any(|v| v.version == version) {
391        all_versions.push(version_info.clone());
392    }
393    all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
394
395    // Build the template-friendly list (version string + optional date string).
396    let version_entries: Vec<VersionEntry> =
397        all_versions.iter().map(VersionEntry::from_info).collect();
398
399    let base_url = config
400        .site
401        .base_url
402        .as_deref()
403        .map(|u| u.trim_end_matches('/'));
404
405    for format in &config.site.formats {
406        let suffix = format.extension();
407        let tmpl_name = format!("root_index.{suffix}");
408        let ext = format.extension();
409
410        let mut ctx = Context::new();
411        ctx.insert("config", &config);
412        ctx.insert("project_name", &config.site.name);
413        ctx.insert("lang", &config.site.lang);
414        ctx.insert("repo_url", &config.site.repo_url);
415        ctx.insert("versions", &version_entries);
416        ctx.insert("atom_feed", ATOM_FEED_FILENAME);
417        ctx.insert("git_ui_enabled", &config.git_ui.is_some());
418        ctx.insert("git_ui_clone_url", &git_ui_clone_url);
419        ctx.insert("root_path", "");
420
421        let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
422        tokio::fs::write(output_dir.join(format!("index.{ext}")), content)
423            .await
424            .into_diagnostic()?;
425    }
426
427    // ── 10. Atom feed ─────────────────────────────────────────────────────────
428    // Load all changelog sections once so we can embed release notes in feed entries.
429    let changelog_sections = match ChangelogExtractor
430        .all_sections(config.changelog.clone())
431        .await
432    {
433        Ok(map) => map,
434        Err(_) => {
435            warn!("Could not load changelog for Atom feed; entries will have no content");
436            std::collections::HashMap::new()
437        }
438    };
439
440    let atom_xml = generate_atom_feed(
441        &config.site.name,
442        &all_versions,
443        base_url,
444        &changelog_sections,
445    );
446    tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
447        .await
448        .into_diagnostic()?;
449
450    // ── 11. `latest` symlink ──────────────────────────────────────────────────
451    if let Some(latest) = all_versions.first() {
452        update_latest_symlink(output_dir, &latest.version)?;
453    }
454
455    Ok(())
456}
457
458// ── Private helpers ───────────────────────────────────────────────────────────
459
460/// Scan `docs_dir` for subdirectories that contain an `index.html` and return
461/// their names. Used to build a fallback listing when rustdoc itself did not
462/// generate a root `index.html` (typical for multi-crate workspaces).
463async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
464    let mut names = Vec::new();
465    let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
466    while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
467        let path = entry.path();
468        if path.is_dir() && path.join("index.html").exists() {
469            names.push(entry.file_name().to_string_lossy().into_owned());
470        }
471    }
472    Ok(names)
473}
474
475/// Write a `docs/index.html` that either redirects straight to the single
476/// crate's docs (one crate) or lists all crates (multiple crates).
477async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
478    let html = if crate_names.len() == 1 {
479        format!(
480            "<!DOCTYPE html><html><head>\
481            <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
482            </head></html>",
483            crate_names[0]
484        )
485    } else {
486        let items = crate_names
487            .iter()
488            .map(|n| format!("  <li><a href=\"{n}/index.html\">{n}</a></li>"))
489            .collect::<Vec<_>>()
490            .join("\n");
491        format!(
492            "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
493            <title>Documentation</title></head>\
494            <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
495        )
496    };
497
498    tokio::fs::write(docs_dir.join("index.html"), html)
499        .await
500        .into_diagnostic()
501}
502
503/// Recursively copy the contents of `src` into `dst`.
504///
505/// Uses explicit boxing to satisfy the compiler for the async recursive call.
506fn copy_dir_recursive(
507    src: PathBuf,
508    dst: PathBuf,
509) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
510    Box::pin(async move {
511        tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
512        let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
513        while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
514            let src_path = entry.path();
515            let dst_path = dst.join(entry.file_name());
516            if src_path.is_dir() {
517                copy_dir_recursive(src_path, dst_path).await?;
518            } else {
519                tokio::fs::copy(&src_path, &dst_path)
520                    .await
521                    .into_diagnostic()?;
522            }
523        }
524        Ok(())
525    })
526}
527
528/// Compare two version strings, preferring semver ordering and falling back
529/// to lexicographic comparison for non-semver strings (e.g. git describe output).
530fn strip_v(s: &str) -> &str {
531    s.strip_prefix('v').unwrap_or(s)
532}
533
534fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
535    match (
536        semver::Version::parse(strip_v(a)),
537        semver::Version::parse(strip_v(b)),
538    ) {
539        (Ok(va), Ok(vb)) => va.cmp(&vb),
540        _ => a.cmp(b),
541    }
542}
543
544/// Compute a lowercase hex-encoded SHA-256 digest of `data`.
545fn hex_sha256(data: &[u8]) -> String {
546    let mut hasher = Sha256::new();
547    hasher.update(data);
548    hasher
549        .finalize()
550        .iter()
551        .map(|b| format!("{b:02x}"))
552        .collect()
553}
554
555/// Format a byte count as a human-readable string (e.g. "1.4 MB").
556fn human_size(bytes: u64) -> String {
557    const KIB: u64 = 1024;
558    const MIB: u64 = KIB * 1024;
559    const GIB: u64 = MIB * 1024;
560    if bytes >= GIB {
561        format!("{:.1} GB", bytes as f64 / GIB as f64)
562    } else if bytes >= MIB {
563        format!("{:.1} MB", bytes as f64 / MIB as f64)
564    } else if bytes >= KIB {
565        format!("{:.1} KB", bytes as f64 / KIB as f64)
566    } else {
567        format!("{bytes} B")
568    }
569}
570
571/// Escape the five XML predefined characters in a string.
572fn xml_escape(s: &str) -> String {
573    s.replace('&', "&amp;")
574        .replace('<', "&lt;")
575        .replace('>', "&gt;")
576        .replace('"', "&quot;")
577        .replace('\'', "&apos;")
578}
579
580/// Generate an Atom 1.0 feed listing all known releases.
581///
582/// - `base_url`: when provided (already stripped of trailing `/`), used to
583///   build `<link>` and `<id>` elements with absolute URLs.  When absent,
584///   a `urn:` based ID is used and no `<link>` elements are emitted.
585/// - `changelog_sections`: map from version string to Markdown release-note
586///   body.  When a matching entry is found it is rendered to HTML and included
587///   as a `<content type="html">` element in the Atom entry.
588fn generate_atom_feed(
589    project_name: &str,
590    versions: &[VersionInfo],
591    base_url: Option<&str>,
592    changelog_sections: &std::collections::HashMap<String, String>,
593) -> String {
594    let now: DateTime<Utc> = Utc::now();
595
596    // The feed's <updated> is the most-recent entry date, or now as fallback.
597    let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
598
599    let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
600
601    // Feed-level <id> and self-link.
602    let feed_id = match base_url {
603        Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
604        None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
605    };
606
607    let self_link = match base_url {
608        Some(base) => format!(
609            "  <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
610            xml_escape(base)
611        ),
612        None => String::new(),
613    };
614    let alt_link = match base_url {
615        Some(base) => format!("  <link href=\"{}\"/>\n", xml_escape(base)),
616        None => String::new(),
617    };
618
619    let entries: String = versions
620        .iter()
621        .map(|vi| {
622            let entry_date = vi.date.unwrap_or(now);
623            let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
624            let v_escaped = xml_escape(&vi.version);
625
626            let entry_id = match base_url {
627                Some(base) => format!("{base}/{v_escaped}/"),
628                None => format!(
629                    "urn:abbaye:release:{}:{v_escaped}",
630                    xml_escape(project_name)
631                ),
632            };
633
634            let entry_link = match base_url {
635                Some(base) => format!("    <link href=\"{base}/{v_escaped}/\"/>\n"),
636                None => String::new(),
637            };
638
639            // Render the changelog section (if any) to HTML, then XML-escape
640            // it for embedding inside <content type="html">.
641            let content_element = match changelog_sections.get(&vi.version) {
642                Some(md) if !md.is_empty() => {
643                    let html = render_markdown_html(md);
644                    format!(
645                        "    <content type=\"html\">{}</content>\n",
646                        xml_escape(&html)
647                    )
648                }
649                _ => String::new(),
650            };
651
652            format!(
653                "  <entry>\n\
654                 \x20   <title>{v_escaped}</title>\n\
655                 \x20   <id>{entry_id}</id>\n\
656                 \x20   <updated>{entry_date_str}</updated>\n\
657                 {entry_link}\
658                 {content_element}\
659                 \x20 </entry>"
660            )
661        })
662        .collect::<Vec<_>>()
663        .join("\n");
664
665    format!(
666        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
667         <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
668         \x20 <title>{name} Releases</title>\n\
669         {self_link}\
670         {alt_link}\
671         \x20 <updated>{feed_updated_str}</updated>\n\
672         \x20 <id>{feed_id}</id>\n\
673         {entries}\n\
674         </feed>\n",
675        name = xml_escape(project_name),
676    )
677}
678
679/// Create or replace the `latest` symlink in `output_dir`, pointing to
680/// `version_dir_name`.
681///
682/// On non-Unix platforms a meta-refresh redirect page is written instead.
683fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
684    let link = output_dir.join("latest");
685
686    #[cfg(unix)]
687    {
688        // Remove any stale symlink or file before (re-)creating it.
689        if link.exists() || link.is_symlink() {
690            std::fs::remove_file(&link).into_diagnostic()?;
691        }
692        std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
693    }
694
695    #[cfg(not(unix))]
696    {
697        std::fs::create_dir_all(&link).into_diagnostic()?;
698        std::fs::write(
699            link.join("index.html"),
700            format!(
701                "<!DOCTYPE html><html><head>\
702                <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
703                </head></html>"
704            ),
705        )
706        .into_diagnostic()?;
707    }
708
709    Ok(())
710}
711
712/// Register templates for each configured output format, supporting theme overrides.
713///
714/// For each `format` in `formats` and each `(base_name, html_builtin, gmi_builtin)`
715/// tuple in `templates`, registers a Tera template named `"{base_name}.{ext}"` —
716/// first checking for a user override at `theme_path / "{base_name}.{ext}.j2"`,
717/// otherwise falling back to the corresponding builtin constant.
718///
719/// After registering the format-specific templates, also loads any extra `.j2`
720/// files from the theme directory that weren't explicitly registered (see
721/// [`load_extra_theme_templates`]).
722pub(crate) fn register_format_templates(
723    tera: &mut Tera,
724    theme_path: &Path,
725    formats: &[OutputFormat],
726    templates: &[(&str, &str, &str)],
727) -> Result<()> {
728    // Register the shared HTML base template so child templates can
729    // {% extends "base.html" %}.  A theme override at
730    // theme/base.html.j2 takes precedence when present.
731    let base_theme = theme_path.join("base.html.j2");
732    if base_theme.is_file() {
733        tera.add_template_file(&base_theme, Some("base.html"))
734            .into_diagnostic()?;
735    } else {
736        tera.add_raw_template("base.html", TEMPLATE_BASE_HTML)
737            .into_diagnostic()?;
738    }
739
740    for format in formats {
741        let ext = format.extension();
742        for (template_base, builtin_html, builtin_gmi) in templates {
743            let name = format!("{template_base}.{ext}");
744            let builtin = match format {
745                OutputFormat::Html => builtin_html,
746                OutputFormat::Gemtext => builtin_gmi,
747            };
748            let theme_file = theme_path.join(format!("{template_base}.{ext}.j2"));
749            if theme_file.is_file() {
750                tera.add_template_file(&theme_file, Some(&name))
751                    .into_diagnostic()?;
752            } else {
753                tera.add_raw_template(&name, builtin).into_diagnostic()?;
754            }
755        }
756    }
757
758    let skip_names: Vec<String> = formats
759        .iter()
760        .flat_map(|fmt| {
761            let ext = fmt.extension();
762            templates
763                .iter()
764                .map(move |(base, _, _)| format!("{base}.{ext}"))
765        })
766        .collect();
767    let skip_refs: Vec<&str> = skip_names.iter().map(|s| s.as_str()).collect();
768    load_extra_theme_templates(tera, theme_path, &skip_refs)?;
769    Ok(())
770}
771
772/// Scans `theme_path` for any `*.j2` files whose stem (the name without the
773/// `.j2` suffix, e.g. `"base.html"`) is **not** already listed in `skip`, and
774/// loads each one into `tera` under that stem name.
775///
776/// This makes user-supplied helper or base templates - e.g. a `base.html.j2`
777/// referenced by `{% extends "base.html" %}` in a customised main template -
778/// available at render time without the caller needing to enumerate them.
779pub(crate) fn load_extra_theme_templates(
780    tera: &mut tera::Tera,
781    theme_path: &std::path::Path,
782    skip: &[&str],
783) -> miette::Result<()> {
784    let entries = match std::fs::read_dir(theme_path) {
785        Ok(e) => e,
786        Err(_) => return Ok(()), // theme dir absent - nothing to do
787    };
788    for entry in entries.flatten() {
789        let path = entry.path();
790        if path.extension().and_then(|e| e.to_str()) != Some("j2") {
791            continue;
792        }
793        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
794            continue;
795        };
796        if skip.contains(&stem) {
797            continue;
798        }
799        tera.add_template_file(&path, Some(stem)).map_err(|e| {
800            miette::miette!("failed to load theme template {}: {e}", path.display())
801        })?;
802    }
803    Ok(())
804}