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
32pub(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#[derive(serde::Serialize)]
49struct VersionEntry {
50 version: String,
52 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#[derive(serde::Serialize, Clone)]
67struct DistFileInfo {
68 name: String,
70 size_bytes: u64,
72 size_human: String,
74 sha256: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 category: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 group_name: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 group_comment: Option<String>,
85}
86
87#[derive(serde::Serialize)]
90struct DistSubgroup {
91 #[serde(skip_serializing_if = "Option::is_none")]
93 name: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 comment: Option<String>,
97 files: Vec<DistFileInfo>,
99}
100
101#[derive(serde::Serialize)]
103struct DistCategory {
104 category: Option<String>,
106 groups: Vec<DistSubgroup>,
108}
109
110pub 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 info!("Extracting version …");
134 let version_info = config.version_extractor.extract().await?;
135 let version = version_info.version.clone();
136
137 info!("Setting up templates …");
139 let mut tera = Tera::default();
140 let theme_path = PathBuf::from(".abbaye").join("theme");
141
142 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 {
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 theme_path.join("static").is_dir() {
172 copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
173 }
174
175 info!("Running builders for version {version} …");
177 let (dist_artifacts, doc_artifacts) =
178 crate::builders::orchestrator::run_builders(&config.builders, &version).await?;
179
180 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 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 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 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_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
255 }
256
257 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 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 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 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 info!("Rendering version pages …");
321
322 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 let git_ui_path = config
335 .git_ui
336 .as_ref()
337 .map(|cfg| {
338 if cfg.prefix.is_empty() {
339 String::new()
340 } else {
341 format!("{}/", cfg.prefix)
342 }
343 })
344 .unwrap_or_default();
345
346 for format in &config.site.formats {
347 let suffix = format.extension();
348 let tmpl_name = format!("version_index.{suffix}");
349 let ext = format.extension();
350
351 let (readme_content, changelog_content): (String, String) = match format {
352 OutputFormat::Html => (
353 render_markdown_html(&readme_md),
354 render_markdown_html(&changelog_md),
355 ),
356 OutputFormat::Gemtext => (
357 render_markdown_gemtext(&readme_md),
358 render_markdown_gemtext(&changelog_md),
359 ),
360 };
361
362 let (readme_html, changelog_html) = match format {
364 OutputFormat::Html => (
365 render_markdown_html(&readme_md),
366 render_markdown_html(&changelog_md),
367 ),
368 OutputFormat::Gemtext => (String::new(), String::new()),
369 };
370
371 let mut ctx = Context::new();
372 ctx.insert("config", &config);
373 ctx.insert("project_name", &config.site.name);
374 ctx.insert("lang", &config.site.lang);
375 ctx.insert("repo_url", &config.site.repo_url);
376 ctx.insert("version", &version);
377 ctx.insert("readme_html", &readme_html);
378 ctx.insert("changelog_html", &changelog_html);
379 ctx.insert("readme_content", &readme_content);
380 ctx.insert("changelog_content", &changelog_content);
381 ctx.insert("has_docs", &has_docs);
382 ctx.insert("has_docs_tarball", &has_docs_tarball);
383 ctx.insert("has_dist", &has_dist);
384 ctx.insert("dist_categories", &dist_categories);
385 ctx.insert("git_ui_enabled", &config.git_ui.is_some());
386 ctx.insert("git_ui_clone_url", &git_ui_clone_url);
387 ctx.insert("git_ui_path", &git_ui_path);
388 ctx.insert("version_tag", &version_tag);
389 ctx.insert("root_path", "../");
390
391 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
392 tokio::fs::write(version_dir.join(format!("index.{ext}")), content)
393 .await
394 .into_diagnostic()?;
395 }
396
397 info!("Rendering root index and Atom feed …");
399 let mut all_versions = config.version_extractor.extract_all().await?;
402 if !all_versions.iter().any(|v| v.version == version) {
403 all_versions.push(version_info.clone());
404 }
405 all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
406
407 let version_entries: Vec<VersionEntry> =
409 all_versions.iter().map(VersionEntry::from_info).collect();
410
411 let base_url = config
412 .site
413 .base_url
414 .as_deref()
415 .map(|u| u.trim_end_matches('/'));
416
417 for format in &config.site.formats {
418 let suffix = format.extension();
419 let tmpl_name = format!("root_index.{suffix}");
420 let ext = format.extension();
421
422 let mut ctx = Context::new();
423 ctx.insert("config", &config);
424 ctx.insert("project_name", &config.site.name);
425 ctx.insert("lang", &config.site.lang);
426 ctx.insert("repo_url", &config.site.repo_url);
427 ctx.insert("versions", &version_entries);
428 ctx.insert("atom_feed", ATOM_FEED_FILENAME);
429 ctx.insert("git_ui_enabled", &config.git_ui.is_some());
430 ctx.insert("git_ui_clone_url", &git_ui_clone_url);
431 ctx.insert("git_ui_path", &git_ui_path);
432 ctx.insert("root_path", "");
433
434 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
435 tokio::fs::write(output_dir.join(format!("index.{ext}")), content)
436 .await
437 .into_diagnostic()?;
438 }
439
440 let changelog_sections = match ChangelogExtractor
443 .all_sections(config.changelog.clone())
444 .await
445 {
446 Ok(map) => map,
447 Err(_) => {
448 warn!("Could not load changelog for Atom feed; entries will have no content");
449 std::collections::HashMap::new()
450 }
451 };
452
453 let atom_xml = generate_atom_feed(
454 &config.site.name,
455 &all_versions,
456 base_url,
457 &changelog_sections,
458 );
459 tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
460 .await
461 .into_diagnostic()?;
462
463 if let Some(latest) = all_versions.first() {
465 update_latest_symlink(output_dir, &latest.version)?;
466 }
467
468 Ok(())
469}
470
471async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
477 let mut names = Vec::new();
478 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
479 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
480 let path = entry.path();
481 if path.is_dir() && path.join("index.html").exists() {
482 names.push(entry.file_name().to_string_lossy().into_owned());
483 }
484 }
485 Ok(names)
486}
487
488async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
491 let html = if crate_names.len() == 1 {
492 format!(
493 "<!DOCTYPE html><html><head>\
494 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
495 </head></html>",
496 crate_names[0]
497 )
498 } else {
499 let items = crate_names
500 .iter()
501 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
502 .collect::<Vec<_>>()
503 .join("\n");
504 format!(
505 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
506 <title>Documentation</title></head>\
507 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
508 )
509 };
510
511 tokio::fs::write(docs_dir.join("index.html"), html)
512 .await
513 .into_diagnostic()
514}
515
516fn copy_dir_recursive(
520 src: PathBuf,
521 dst: PathBuf,
522) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
523 Box::pin(async move {
524 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
525 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
526 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
527 let src_path = entry.path();
528 let dst_path = dst.join(entry.file_name());
529 if src_path.is_dir() {
530 copy_dir_recursive(src_path, dst_path).await?;
531 } else {
532 tokio::fs::copy(&src_path, &dst_path)
533 .await
534 .into_diagnostic()?;
535 }
536 }
537 Ok(())
538 })
539}
540
541fn strip_v(s: &str) -> &str {
544 s.strip_prefix('v').unwrap_or(s)
545}
546
547fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
548 match (
549 semver::Version::parse(strip_v(a)),
550 semver::Version::parse(strip_v(b)),
551 ) {
552 (Ok(va), Ok(vb)) => va.cmp(&vb),
553 _ => a.cmp(b),
554 }
555}
556
557fn hex_sha256(data: &[u8]) -> String {
559 let mut hasher = Sha256::new();
560 hasher.update(data);
561 hasher
562 .finalize()
563 .iter()
564 .map(|b| format!("{b:02x}"))
565 .collect()
566}
567
568fn human_size(bytes: u64) -> String {
570 const KIB: u64 = 1024;
571 const MIB: u64 = KIB * 1024;
572 const GIB: u64 = MIB * 1024;
573 if bytes >= GIB {
574 format!("{:.1} GB", bytes as f64 / GIB as f64)
575 } else if bytes >= MIB {
576 format!("{:.1} MB", bytes as f64 / MIB as f64)
577 } else if bytes >= KIB {
578 format!("{:.1} KB", bytes as f64 / KIB as f64)
579 } else {
580 format!("{bytes} B")
581 }
582}
583
584fn xml_escape(s: &str) -> String {
586 s.replace('&', "&")
587 .replace('<', "<")
588 .replace('>', ">")
589 .replace('"', """)
590 .replace('\'', "'")
591}
592
593fn generate_atom_feed(
602 project_name: &str,
603 versions: &[VersionInfo],
604 base_url: Option<&str>,
605 changelog_sections: &std::collections::HashMap<String, String>,
606) -> String {
607 let now: DateTime<Utc> = Utc::now();
608
609 let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
611
612 let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
613
614 let feed_id = match base_url {
616 Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
617 None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
618 };
619
620 let self_link = match base_url {
621 Some(base) => format!(
622 " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
623 xml_escape(base)
624 ),
625 None => String::new(),
626 };
627 let alt_link = match base_url {
628 Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)),
629 None => String::new(),
630 };
631
632 let entries: String = versions
633 .iter()
634 .map(|vi| {
635 let entry_date = vi.date.unwrap_or(now);
636 let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
637 let v_escaped = xml_escape(&vi.version);
638
639 let entry_id = match base_url {
640 Some(base) => format!("{base}/{v_escaped}/"),
641 None => format!(
642 "urn:abbaye:release:{}:{v_escaped}",
643 xml_escape(project_name)
644 ),
645 };
646
647 let entry_link = match base_url {
648 Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"),
649 None => String::new(),
650 };
651
652 let content_element = match changelog_sections.get(&vi.version) {
655 Some(md) if !md.is_empty() => {
656 let html = render_markdown_html(md);
657 format!(
658 " <content type=\"html\">{}</content>\n",
659 xml_escape(&html)
660 )
661 }
662 _ => String::new(),
663 };
664
665 format!(
666 " <entry>\n\
667 \x20 <title>{v_escaped}</title>\n\
668 \x20 <id>{entry_id}</id>\n\
669 \x20 <updated>{entry_date_str}</updated>\n\
670 {entry_link}\
671 {content_element}\
672 \x20 </entry>"
673 )
674 })
675 .collect::<Vec<_>>()
676 .join("\n");
677
678 format!(
679 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
680 <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
681 \x20 <title>{name} Releases</title>\n\
682 {self_link}\
683 {alt_link}\
684 \x20 <updated>{feed_updated_str}</updated>\n\
685 \x20 <id>{feed_id}</id>\n\
686 {entries}\n\
687 </feed>\n",
688 name = xml_escape(project_name),
689 )
690}
691
692fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
697 let link = output_dir.join("latest");
698
699 #[cfg(unix)]
700 {
701 if link.exists() || link.is_symlink() {
703 std::fs::remove_file(&link).into_diagnostic()?;
704 }
705 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
706 }
707
708 #[cfg(not(unix))]
709 {
710 std::fs::create_dir_all(&link).into_diagnostic()?;
711 std::fs::write(
712 link.join("index.html"),
713 format!(
714 "<!DOCTYPE html><html><head>\
715 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
716 </head></html>"
717 ),
718 )
719 .into_diagnostic()?;
720 }
721
722 Ok(())
723}
724
725pub(crate) fn register_format_templates(
736 tera: &mut Tera,
737 theme_path: &Path,
738 formats: &[OutputFormat],
739 templates: &[(&str, &str, &str)],
740) -> Result<()> {
741 let base_theme = theme_path.join("base.html.j2");
745 if base_theme.is_file() {
746 tera.add_template_file(&base_theme, Some("base.html"))
747 .into_diagnostic()?;
748 } else {
749 tera.add_raw_template("base.html", TEMPLATE_BASE_HTML)
750 .into_diagnostic()?;
751 }
752
753 for format in formats {
754 let ext = format.extension();
755 for (template_base, builtin_html, builtin_gmi) in templates {
756 let name = format!("{template_base}.{ext}");
757 let builtin = match format {
758 OutputFormat::Html => builtin_html,
759 OutputFormat::Gemtext => builtin_gmi,
760 };
761 let theme_file = theme_path.join(format!("{template_base}.{ext}.j2"));
762 if theme_file.is_file() {
763 tera.add_template_file(&theme_file, Some(&name))
764 .into_diagnostic()?;
765 } else {
766 tera.add_raw_template(&name, builtin).into_diagnostic()?;
767 }
768 }
769 }
770
771 let skip_names: Vec<String> = formats
772 .iter()
773 .flat_map(|fmt| {
774 let ext = fmt.extension();
775 templates
776 .iter()
777 .map(move |(base, _, _)| format!("{base}.{ext}"))
778 })
779 .collect();
780 let skip_refs: Vec<&str> = skip_names.iter().map(|s| s.as_str()).collect();
781 load_extra_theme_templates(tera, theme_path, &skip_refs)?;
782 Ok(())
783}
784
785pub(crate) fn load_extra_theme_templates(
793 tera: &mut tera::Tera,
794 theme_path: &std::path::Path,
795 skip: &[&str],
796) -> miette::Result<()> {
797 let entries = match std::fs::read_dir(theme_path) {
798 Ok(e) => e,
799 Err(_) => return Ok(()), };
801 for entry in entries.flatten() {
802 let path = entry.path();
803 if path.extension().and_then(|e| e.to_str()) != Some("j2") {
804 continue;
805 }
806 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
807 continue;
808 };
809 if skip.contains(&stem) {
810 continue;
811 }
812 tera.add_template_file(&path, Some(stem)).map_err(|e| {
813 miette::miette!("failed to load theme template {}: {e}", path.display())
814 })?;
815 }
816 Ok(())
817}