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#[derive(serde::Serialize)]
36struct VersionEntry {
37 version: String,
39 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#[derive(serde::Serialize)]
54struct DistFileInfo {
55 name: String,
57 size_bytes: u64,
59 size_human: String,
61 sha256: String,
63}
64
65pub 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 let version_info = config.version_extractor.extract().await?;
89 let version = version_info.version.clone();
90
91 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 {
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 theme_path.join("static").is_dir() {
131 copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
132 }
133
134 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 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 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 {
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 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 let total = config.builders.len();
219 let multi = MultiProgress::new();
220
221 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 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 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 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 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 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 for (dep_id, mut rx) in dep_receivers {
334 pb_task.set_message(format!("waiting for '{dep_id}'…"));
335
336 let resolved = rx.wait_for(|v| v.is_some()).await;
339
340 let succeeded = match resolved {
341 Err(_) => false, 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 Ok(vec![]);
356 }
357 }
358
359 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 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 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 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 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_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
453 }
454
455 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 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 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 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 let changelog_html = match ChangelogExtractor
509 .section(config.changelog.clone(), &version)
510 .await
511 {
512 Ok(section) => render_markdown(§ion),
513 Err(_) => {
514 warn!("No changelog entry found for version {version}");
515 String::new()
516 }
517 };
518
519 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 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 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 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 if let Some(latest) = all_versions.first() {
618 update_latest_symlink(output_dir, &latest.version)?;
619 }
620
621 Ok(())
622}
623
624fn 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 Event::End(TagEnd::Image | TagEnd::Link) => None,
640 _ => None,
641 };
642 if let Some(url) = url {
643 let s = url.as_ref();
644 if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
646 refs.push(s.to_owned());
647 }
648 }
649 }
650 refs
651}
652
653fn 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
662async 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
677async 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
705fn 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
730fn 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
746fn 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
757fn 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
773fn xml_escape(s: &str) -> String {
775 s.replace('&', "&")
776 .replace('<', "<")
777 .replace('>', ">")
778 .replace('"', """)
779 .replace('\'', "'")
780}
781
782fn 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 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 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 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
881fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
886 let link = output_dir.join("latest");
887
888 #[cfg(unix)]
889 {
890 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
914pub(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(()), };
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}