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 flate2::{Compression, write::GzEncoder};
12use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
13use miette::{IntoDiagnostic, Result};
14use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
15use std::collections::HashMap;
16use tera::{Context, Tera};
17use tokio::sync::{mpsc, watch};
18use tokio::task::JoinSet;
19use tracing::warn;
20
21use crate::{
22 builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig,
23 version_extractors::VersionInfo,
24};
25
26pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2");
27pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2");
28pub const SITE_CSS: &str = include_str!("templates/site.css");
29const ATOM_FEED_FILENAME: &str = "releases.atom";
30
31#[derive(serde::Serialize)]
35struct VersionEntry {
36 version: String,
38 date: Option<String>,
40}
41
42impl VersionEntry {
43 fn from_info(info: &VersionInfo) -> Self {
44 Self {
45 version: info.version.clone(),
46 date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
47 }
48 }
49}
50
51#[derive(serde::Serialize)]
53struct DistFileInfo {
54 name: String,
56 size_bytes: u64,
58 size_human: String,
60 sha256: String,
62}
63
64pub async fn build_site(config: AbbayeConfig) -> Result<()> {
80 let output_dir = &config.site.output_dir;
81
82 tokio::fs::create_dir_all(output_dir)
83 .await
84 .into_diagnostic()?;
85
86 let version_info = config.version_extractor.extract().await?;
88 let version = version_info.version.clone();
89
90 let mut tera = Tera::default();
92 let theme_path = PathBuf::from(".abbaye").join("theme");
93 if theme_path.join("root_index.html.j2").is_file() {
94 tera.add_template_file(
95 theme_path.join("root_index.html.j2"),
96 Some("root_index.html"),
97 )
98 .into_diagnostic()?;
99 } else {
100 tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
101 .into_diagnostic()?;
102 }
103 if theme_path.join("version_index.html.j2").is_file() {
104 tera.add_template_file(
105 theme_path.join("version_index.html.j2"),
106 Some("version_index.html"),
107 )
108 .into_diagnostic()?;
109 } else {
110 tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
111 .into_diagnostic()?;
112 }
113 load_extra_theme_templates(
114 &mut tera,
115 &theme_path,
116 &["root_index.html", "version_index.html"],
117 )?;
118 {
120 let static_dir = output_dir.join("static");
121 tokio::fs::create_dir_all(&static_dir)
122 .await
123 .into_diagnostic()?;
124 tokio::fs::write(static_dir.join("site.css"), SITE_CSS)
125 .await
126 .into_diagnostic()?;
127 }
128 if theme_path.join("static").is_dir() {
130 copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
131 }
132
133 let mut dist_artifacts = Vec::new();
135 let mut doc_artifacts = Vec::new();
136
137 {
138 use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW};
139
140 let id_to_idx: HashMap<&str, usize> = config
144 .builders
145 .iter()
146 .enumerate()
147 .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i)))
148 .collect();
149
150 for (i, entry) in config.builders.iter().enumerate() {
152 for dep in &entry.depends_on {
153 if !id_to_idx.contains_key(dep.as_str()) {
154 return Err(miette::miette!(
155 "builder #{i} ({}) lists '{}' in depends_on, \
156 but no builder has that id",
157 entry.label(),
158 dep
159 ));
160 }
161 }
162 }
163
164 {
166 let n = config.builders.len();
167 let mut state = vec![0u8; n];
168
169 fn dfs(
170 idx: usize,
171 id_to_idx: &HashMap<&str, usize>,
172 builders: &[crate::builders::BuilderEntry],
173 state: &mut Vec<u8>,
174 ) -> Result<()> {
175 if state[idx] == 1 {
176 return Err(miette::miette!(
177 "dependency cycle detected involving builder #{idx} ({})",
178 builders[idx].id.as_deref().unwrap_or(builders[idx].label())
179 ));
180 }
181 if state[idx] == 2 {
182 return Ok(());
183 }
184 state[idx] = 1;
185 for dep in &builders[idx].depends_on {
186 if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) {
187 dfs(dep_idx, id_to_idx, builders, state)?;
188 }
189 }
190 state[idx] = 2;
191 Ok(())
192 }
193
194 for i in 0..n {
195 dfs(i, &id_to_idx, &config.builders, &mut state)?;
196 }
197 }
198
199 let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new();
206 let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new();
207
208 for entry in &config.builders {
209 if let Some(id) = &entry.id {
210 let (tx, rx) = watch::channel(None::<bool>);
211 completion_txs.insert(id.clone(), tx);
212 completion_rxs.insert(id.clone(), rx);
213 }
214 }
215
216 let total = config.builders.len();
218 let multi = MultiProgress::new();
219
220 let summary = multi.add(ProgressBar::new(total as u64));
222 summary.set_style(
223 ProgressStyle::with_template("{pos}/{len} builders {bar:20.green/white} {msg}")
224 .expect("valid template"),
225 );
226 summary.set_message("building…");
227
228 let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> =
229 JoinSet::new();
230
231 for (i, entry) in config.builders.iter().enumerate() {
232 let color = COLOURS[i % COLOURS.len()];
233 let label = entry.id.as_deref().unwrap_or(entry.label());
234 let colored_prefix = format!("{color}[{label}]{RESET}");
235
236 let pb = multi.insert_before(&summary, ProgressBar::new_spinner());
238 pb.set_style(
239 ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
240 .expect("valid template")
241 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
242 );
243 pb.set_prefix(colored_prefix);
244 pb.set_message("starting…");
245 pb.enable_steady_tick(Duration::from_millis(100));
246
247 let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>();
248
249 let pb_log = pb.clone();
254 let multi_log = multi.clone();
255 let parent_color_idx = i;
256 tokio::spawn(async move {
257 let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new();
258 let mut last_child_pb = pb_log.clone();
260 let mut child_color_idx = parent_color_idx + 1;
261
262 while let Some(event) = log_rx.recv().await {
263 match event {
264 LogEvent::Line(line) => {
265 pb_log.set_message(line);
266 }
267 LogEvent::ChildStart { id, label } => {
268 let child_color = COLOURS[child_color_idx % COLOURS.len()];
269 child_color_idx += 1;
270 let child_pb =
271 multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner());
272 child_pb.set_style(
273 ProgressStyle::with_template(" {spinner:.bold} {prefix} {msg}")
274 .expect("valid template")
275 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
276 );
277 child_pb.set_prefix(format!("{child_color}[{label}]{RESET}"));
278 child_pb.set_message("starting…");
279 child_pb.enable_steady_tick(Duration::from_millis(100));
280 last_child_pb = child_pb.clone();
281 child_pbs.insert(id, child_pb);
282 }
283 LogEvent::ChildLine { id, line } => {
284 if let Some(child_pb) = child_pbs.get(&id) {
285 child_pb.set_message(line);
286 }
287 }
288 LogEvent::ChildFinish {
289 id,
290 success,
291 summary,
292 } => {
293 if let Some(child_pb) = child_pbs.remove(&id) {
294 if success {
295 child_pb.finish_with_message(format!(
296 "{GREEN}\u{2713}{RESET} {summary}"
297 ));
298 } else {
299 child_pb.finish_with_message(format!(
300 "{RED}\u{2717}{RESET} {summary}"
301 ));
302 }
303 }
304 }
305 }
306 }
307 });
308
309 let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry
311 .depends_on
312 .iter()
313 .filter_map(|dep_id| {
314 completion_rxs
315 .get(dep_id)
316 .map(|rx| (dep_id.clone(), rx.clone()))
317 })
318 .collect();
319
320 let my_tx: Option<watch::Sender<Option<bool>>> =
323 entry.id.as_ref().and_then(|id| completion_txs.remove(id));
324
325 let entry = entry.clone();
326 let version = version.clone();
327 let pb_task = pb.clone();
328 let summary_task = summary.clone();
329
330 join_set.spawn(async move {
331 for (dep_id, mut rx) in dep_receivers {
333 pb_task.set_message(format!("waiting for '{dep_id}'…"));
334
335 let resolved = rx.wait_for(|v| v.is_some()).await;
338
339 let succeeded = match resolved {
340 Err(_) => false, Ok(r) => r.unwrap_or(false),
342 };
343
344 if !succeeded {
345 summary_task.inc(1);
346 pb_task.finish_with_message(format!(
347 "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)"
348 ));
349 if let Some(tx) = &my_tx {
350 let _ = tx.send(Some(false));
351 }
352 return Ok(vec![]);
355 }
356 }
357
358 pb_task.set_message("running…");
360 let result = entry.build(&version, log_tx).await;
361 let succeeded = result.is_ok();
362
363 if let Some(tx) = my_tx {
364 let _ = tx.send(Some(succeeded));
365 }
366
367 summary_task.inc(1);
368 match &result {
369 Ok(artifacts) => pb_task.finish_with_message(format!(
370 "{GREEN}\u{2713} done{RESET} ({} artifact(s))",
371 artifacts.len()
372 )),
373 Err(e) => {
374 pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"))
375 }
376 }
377 result
378 });
379 }
380
381 let mut errors: Vec<miette::Report> = Vec::new();
384 while let Some(res) = join_set.join_next().await {
385 match res.into_diagnostic()? {
386 Ok(artifacts) => {
387 for artifact in artifacts {
388 if artifact.path.is_dir() {
389 doc_artifacts.push(artifact);
390 } else {
391 dist_artifacts.push(artifact);
392 }
393 }
394 }
395 Err(e) => errors.push(e),
396 }
397 }
398
399 let summary_msg = if errors.is_empty() {
400 format!("{GREEN}all done{RESET}")
401 } else {
402 format!("{RED}some builders failed{RESET}")
403 };
404 summary.finish_with_message(summary_msg);
405
406 if let Some(first_err) = errors.into_iter().next() {
407 return Err(first_err);
408 }
409 }
410
411 let version_dir = output_dir.join(&version);
413 let dist_dir = version_dir.join("dist");
414 tokio::fs::create_dir_all(&dist_dir)
415 .await
416 .into_diagnostic()?;
417
418 for artifact in &dist_artifacts {
419 tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
420 .await
421 .into_diagnostic()?;
422 }
423
424 let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
426 for artifact in &dist_artifacts {
427 let dest = dist_dir.join(&artifact.name);
428 let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
429 let size_bytes = bytes.len() as u64;
430 let sha256 = hex_sha256(&bytes);
431 dist_file_infos.push(DistFileInfo {
432 name: artifact.name.clone(),
433 size_bytes,
434 size_human: human_size(size_bytes),
435 sha256,
436 });
437 }
438 let has_dist = !dist_file_infos.is_empty();
439
440 let has_docs = !doc_artifacts.is_empty();
442 let has_docs_tarball;
443
444 if has_docs {
445 let docs_dir = version_dir.join("docs");
446
447 for artifact in &doc_artifacts {
448 copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
452 }
453
454 if !docs_dir.join("index.html").exists() {
457 let crate_names = find_doc_crates(&docs_dir).await?;
458 write_docs_index(&docs_dir, &crate_names).await?;
459 }
460
461 let tarball = version_dir.join("docs.tar.gz");
462 let docs_dir_c = docs_dir.clone();
463 let tarball_c = tarball.clone();
464 tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
465 .await
466 .into_diagnostic()??;
467
468 has_docs_tarball = true;
469 } else {
470 has_docs_tarball = false;
471 }
472
473 let readme_path = config
475 .site
476 .readme
477 .as_deref()
478 .unwrap_or(Path::new("README.md"));
479
480 let readme_html = match tokio::fs::read_to_string(readme_path).await {
481 Ok(content) => render_markdown(&content),
482 Err(_) => {
483 warn!("README not found at {}", readme_path.display());
484 String::new()
485 }
486 };
487
488 let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
492 if let Ok(content) = tokio::fs::read_to_string(readme_path).await {
493 for rel in extract_local_refs(&content) {
494 let src = readme_dir.join(&rel);
495 if src.is_file() {
496 let dest = version_dir.join(&rel);
498 if let Some(parent) = dest.parent() {
499 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
500 }
501 tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
502 }
503 }
504 }
505
506 let changelog_html = match ChangelogExtractor
508 .section(config.changelog.clone(), &version)
509 .await
510 {
511 Ok(section) => render_markdown(§ion),
512 Err(_) => {
513 warn!("No changelog entry found for version {version}");
514 String::new()
515 }
516 };
517
518 let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| {
523 cfg.clone_url.clone().or_else(|| {
524 config
525 .site
526 .base_url
527 .as_ref()
528 .map(|b| format!("{}/repository.git", b.trim_end_matches('/')))
529 })
530 });
531 let version_tag = config.version_extractor.tag_name(&version);
532
533 let mut version_ctx = Context::new();
534 version_ctx.insert("config", &config);
535 version_ctx.insert("project_name", &config.site.name);
536 version_ctx.insert("lang", &config.site.lang);
537 version_ctx.insert("repo_url", &config.site.repo_url);
538 version_ctx.insert("version", &version);
539 version_ctx.insert("readme_html", &readme_html);
540 version_ctx.insert("changelog_html", &changelog_html);
541 version_ctx.insert("has_docs", &has_docs);
542 version_ctx.insert("has_docs_tarball", &has_docs_tarball);
543 version_ctx.insert("has_dist", &has_dist);
544 version_ctx.insert("dist_files", &dist_file_infos);
545 version_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
546 version_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
547 version_ctx.insert("version_tag", &version_tag);
548
549 let version_html = tera
550 .render("version_index.html", &version_ctx)
551 .into_diagnostic()?;
552 tokio::fs::write(version_dir.join("index.html"), version_html)
553 .await
554 .into_diagnostic()?;
555
556 let mut all_versions = config.version_extractor.extract_all().await?;
560 if !all_versions.iter().any(|v| v.version == version) {
561 all_versions.push(version_info.clone());
562 }
563 all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
564
565 let version_entries: Vec<VersionEntry> =
567 all_versions.iter().map(VersionEntry::from_info).collect();
568
569 let base_url = config
570 .site
571 .base_url
572 .as_deref()
573 .map(|u| u.trim_end_matches('/'));
574
575 let mut root_ctx = Context::new();
576 root_ctx.insert("config", &config);
577 root_ctx.insert("project_name", &config.site.name);
578 root_ctx.insert("lang", &config.site.lang);
579 root_ctx.insert("repo_url", &config.site.repo_url);
580 root_ctx.insert("versions", &version_entries);
581 root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
582 root_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
583 root_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
584
585 let root_html = tera
586 .render("root_index.html", &root_ctx)
587 .into_diagnostic()?;
588 tokio::fs::write(output_dir.join("index.html"), root_html)
589 .await
590 .into_diagnostic()?;
591
592 let changelog_sections = match ChangelogExtractor
595 .all_sections(config.changelog.clone())
596 .await
597 {
598 Ok(map) => map,
599 Err(_) => {
600 warn!("Could not load changelog for Atom feed; entries will have no content");
601 std::collections::HashMap::new()
602 }
603 };
604
605 let atom_xml = generate_atom_feed(
606 &config.site.name,
607 &all_versions,
608 base_url,
609 &changelog_sections,
610 );
611 tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
612 .await
613 .into_diagnostic()?;
614
615 if let Some(latest) = all_versions.first() {
617 update_latest_symlink(output_dir, &latest.version)?;
618 }
619
620 Ok(())
621}
622
623fn extract_local_refs(md: &str) -> Vec<String> {
631 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
632 let mut refs = Vec::new();
633 for event in Parser::new_ext(md, opts) {
634 let url: Option<pulldown_cmark::CowStr> = match event {
635 Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
636 Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
637 Event::End(TagEnd::Image | TagEnd::Link) => None,
639 _ => None,
640 };
641 if let Some(url) = url {
642 let s = url.as_ref();
643 if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
645 refs.push(s.to_owned());
646 }
647 }
648 }
649 refs
650}
651
652fn render_markdown(md: &str) -> String {
654 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
655 let parser = Parser::new_ext(md, opts);
656 let mut buf = String::new();
657 html::push_html(&mut buf, parser);
658 buf
659}
660
661async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
665 let mut names = Vec::new();
666 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
667 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
668 let path = entry.path();
669 if path.is_dir() && path.join("index.html").exists() {
670 names.push(entry.file_name().to_string_lossy().into_owned());
671 }
672 }
673 Ok(names)
674}
675
676async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
679 let html = if crate_names.len() == 1 {
680 format!(
681 "<!DOCTYPE html><html><head>\
682 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
683 </head></html>",
684 crate_names[0]
685 )
686 } else {
687 let items = crate_names
688 .iter()
689 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
690 .collect::<Vec<_>>()
691 .join("\n");
692 format!(
693 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
694 <title>Documentation</title></head>\
695 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
696 )
697 };
698
699 tokio::fs::write(docs_dir.join("index.html"), html)
700 .await
701 .into_diagnostic()
702}
703
704fn copy_dir_recursive(
708 src: PathBuf,
709 dst: PathBuf,
710) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
711 Box::pin(async move {
712 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
713 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
714 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
715 let src_path = entry.path();
716 let dst_path = dst.join(entry.file_name());
717 if src_path.is_dir() {
718 copy_dir_recursive(src_path, dst_path).await?;
719 } else {
720 tokio::fs::copy(&src_path, &dst_path)
721 .await
722 .into_diagnostic()?;
723 }
724 }
725 Ok(())
726 })
727}
728
729fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
733 let dir_name = src
734 .file_name()
735 .map(|n| n.to_string_lossy().into_owned())
736 .unwrap_or_else(|| "docs".to_owned());
737
738 let file = std::fs::File::create(dest).into_diagnostic()?;
739 let enc = GzEncoder::new(file, Compression::default());
740 let mut archive = tar::Builder::new(enc);
741 archive.append_dir_all(&dir_name, src).into_diagnostic()?;
742 archive
743 .into_inner()
744 .into_diagnostic()?
745 .finish()
746 .into_diagnostic()?;
747 Ok(())
748}
749
750fn strip_v(s: &str) -> &str {
753 s.strip_prefix('v').unwrap_or(s)
754}
755
756fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
757 match (
758 semver::Version::parse(strip_v(a)),
759 semver::Version::parse(strip_v(b)),
760 ) {
761 (Ok(va), Ok(vb)) => va.cmp(&vb),
762 _ => a.cmp(b),
763 }
764}
765
766fn hex_sha256(data: &[u8]) -> String {
768 let mut hasher = Sha256::new();
769 hasher.update(data);
770 hasher
771 .finalize()
772 .iter()
773 .map(|b| format!("{b:02x}"))
774 .collect()
775}
776
777fn human_size(bytes: u64) -> String {
779 const KIB: u64 = 1024;
780 const MIB: u64 = KIB * 1024;
781 const GIB: u64 = MIB * 1024;
782 if bytes >= GIB {
783 format!("{:.1} GB", bytes as f64 / GIB as f64)
784 } else if bytes >= MIB {
785 format!("{:.1} MB", bytes as f64 / MIB as f64)
786 } else if bytes >= KIB {
787 format!("{:.1} KB", bytes as f64 / KIB as f64)
788 } else {
789 format!("{bytes} B")
790 }
791}
792
793fn xml_escape(s: &str) -> String {
795 s.replace('&', "&")
796 .replace('<', "<")
797 .replace('>', ">")
798 .replace('"', """)
799 .replace('\'', "'")
800}
801
802fn generate_atom_feed(
811 project_name: &str,
812 versions: &[VersionInfo],
813 base_url: Option<&str>,
814 changelog_sections: &std::collections::HashMap<String, String>,
815) -> String {
816 let now: DateTime<Utc> = Utc::now();
817
818 let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
820
821 let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
822
823 let feed_id = match base_url {
825 Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
826 None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
827 };
828
829 let self_link = match base_url {
830 Some(base) => format!(
831 " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
832 xml_escape(base)
833 ),
834 None => String::new(),
835 };
836 let alt_link = match base_url {
837 Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)),
838 None => String::new(),
839 };
840
841 let entries: String = versions
842 .iter()
843 .map(|vi| {
844 let entry_date = vi.date.unwrap_or(now);
845 let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
846 let v_escaped = xml_escape(&vi.version);
847
848 let entry_id = match base_url {
849 Some(base) => format!("{base}/{v_escaped}/"),
850 None => format!(
851 "urn:abbaye:release:{}:{v_escaped}",
852 xml_escape(project_name)
853 ),
854 };
855
856 let entry_link = match base_url {
857 Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"),
858 None => String::new(),
859 };
860
861 let content_element = match changelog_sections.get(&vi.version) {
864 Some(md) if !md.is_empty() => {
865 let html = render_markdown(md);
866 format!(
867 " <content type=\"html\">{}</content>\n",
868 xml_escape(&html)
869 )
870 }
871 _ => String::new(),
872 };
873
874 format!(
875 " <entry>\n\
876 \x20 <title>{v_escaped}</title>\n\
877 \x20 <id>{entry_id}</id>\n\
878 \x20 <updated>{entry_date_str}</updated>\n\
879 {entry_link}\
880 {content_element}\
881 \x20 </entry>"
882 )
883 })
884 .collect::<Vec<_>>()
885 .join("\n");
886
887 format!(
888 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
889 <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
890 \x20 <title>{name} Releases</title>\n\
891 {self_link}\
892 {alt_link}\
893 \x20 <updated>{feed_updated_str}</updated>\n\
894 \x20 <id>{feed_id}</id>\n\
895 {entries}\n\
896 </feed>\n",
897 name = xml_escape(project_name),
898 )
899}
900
901fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
906 let link = output_dir.join("latest");
907
908 #[cfg(unix)]
909 {
910 if link.exists() || link.is_symlink() {
912 std::fs::remove_file(&link).into_diagnostic()?;
913 }
914 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
915 }
916
917 #[cfg(not(unix))]
918 {
919 std::fs::create_dir_all(&link).into_diagnostic()?;
920 std::fs::write(
921 link.join("index.html"),
922 format!(
923 "<!DOCTYPE html><html><head>\
924 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
925 </head></html>"
926 ),
927 )
928 .into_diagnostic()?;
929 }
930
931 Ok(())
932}
933
934pub(crate) fn load_extra_theme_templates(
942 tera: &mut tera::Tera,
943 theme_path: &std::path::Path,
944 skip: &[&str],
945) -> miette::Result<()> {
946 let entries = match std::fs::read_dir(theme_path) {
947 Ok(e) => e,
948 Err(_) => return Ok(()), };
950 for entry in entries.flatten() {
951 let path = entry.path();
952 if path.extension().and_then(|e| e.to_str()) != Some("j2") {
953 continue;
954 }
955 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
956 continue;
957 };
958 if skip.contains(&stem) {
959 continue;
960 }
961 tera.add_template_file(&path, Some(stem)).map_err(|e| {
962 miette::miette!("failed to load theme template {}: {e}", path.display())
963 })?;
964 }
965 Ok(())
966}