1use std::{
2 future::Future,
3 path::{Path, PathBuf},
4 pin::Pin,
5};
6
7use chrono::{DateTime, SecondsFormat, Utc};
8use sha2::{Digest, Sha256};
9
10use flate2::{Compression, write::GzEncoder};
11use miette::{IntoDiagnostic, Result};
12use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
13use tera::{Context, Tera};
14use tracing::warn;
15
16use crate::{changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo};
17
18pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2");
19pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2");
20const ATOM_FEED_FILENAME: &str = "releases.atom";
21
22#[derive(serde::Serialize)]
26struct VersionEntry {
27 version: String,
29 date: Option<String>,
31}
32
33impl VersionEntry {
34 fn from_info(info: &VersionInfo) -> Self {
35 Self {
36 version: info.version.clone(),
37 date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
38 }
39 }
40}
41
42#[derive(serde::Serialize)]
44struct DistFileInfo {
45 name: String,
47 size_bytes: u64,
49 size_human: String,
51 sha256: String,
53}
54
55pub async fn build_site(config: AbbayeConfig) -> Result<()> {
71 let output_dir = config
72 .output_dir
73 .clone()
74 .unwrap_or_else(|| PathBuf::from("public"));
75
76 tokio::fs::create_dir_all(&output_dir)
77 .await
78 .into_diagnostic()?;
79
80 let version_info = config.version_extractor.extract().await?;
82 let version = version_info.version.clone();
83
84 let mut tera = Tera::default();
86 let theme_path = PathBuf::from(".abbaye").join("theme");
87 if theme_path.join("root_index.html.j2").is_file() {
88 tera.add_template_file(
89 theme_path.join("root_index.html.j2"),
90 Some("root_index.html"),
91 )
92 .into_diagnostic()?;
93 } else {
94 tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
95 .into_diagnostic()?;
96 }
97 if theme_path.join("version_index.html.j2").is_file() {
98 tera.add_template_file(
99 theme_path.join("version_index.html.j2"),
100 Some("version_index.html"),
101 )
102 .into_diagnostic()?;
103 } else {
104 tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
105 .into_diagnostic()?;
106 }
107 if theme_path.join("static").is_dir() {
109 copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
110 }
111
112 let mut dist_artifacts = Vec::new();
114 let mut doc_artifacts = Vec::new();
115
116 for builder in &config.builders {
117 for artifact in builder.build(&version).await? {
118 if artifact.path.is_dir() {
119 doc_artifacts.push(artifact);
120 } else {
121 dist_artifacts.push(artifact);
122 }
123 }
124 }
125
126 let version_dir = output_dir.join(&version);
128 let dist_dir = version_dir.join("dist");
129 tokio::fs::create_dir_all(&dist_dir)
130 .await
131 .into_diagnostic()?;
132
133 for artifact in &dist_artifacts {
134 tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
135 .await
136 .into_diagnostic()?;
137 }
138
139 let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
141 for artifact in &dist_artifacts {
142 let dest = dist_dir.join(&artifact.name);
143 let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
144 let size_bytes = bytes.len() as u64;
145 let sha256 = hex_sha256(&bytes);
146 dist_file_infos.push(DistFileInfo {
147 name: artifact.name.clone(),
148 size_bytes,
149 size_human: human_size(size_bytes),
150 sha256,
151 });
152 }
153 let has_dist = !dist_file_infos.is_empty();
154
155 let has_docs = !doc_artifacts.is_empty();
157 let has_docs_tarball;
158
159 if has_docs {
160 let docs_dir = version_dir.join("docs");
161
162 for artifact in &doc_artifacts {
163 copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
167 }
168
169 if !docs_dir.join("index.html").exists() {
172 let crate_names = find_doc_crates(&docs_dir).await?;
173 write_docs_index(&docs_dir, &crate_names).await?;
174 }
175
176 let tarball = version_dir.join("docs.tar.gz");
177 let docs_dir_c = docs_dir.clone();
178 let tarball_c = tarball.clone();
179 tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
180 .await
181 .into_diagnostic()??;
182
183 has_docs_tarball = true;
184 } else {
185 has_docs_tarball = false;
186 }
187
188 let readme_path = config
190 .site
191 .readme
192 .as_deref()
193 .unwrap_or(Path::new("README.md"));
194
195 let readme_html = match tokio::fs::read_to_string(readme_path).await {
196 Ok(content) => render_markdown(&content),
197 Err(_) => {
198 warn!("README not found at {}", readme_path.display());
199 String::new()
200 }
201 };
202
203 let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
207 if let Ok(content) = tokio::fs::read_to_string(readme_path).await {
208 for rel in extract_local_refs(&content) {
209 let src = readme_dir.join(&rel);
210 if src.is_file() {
211 let dest = version_dir.join(&rel);
213 if let Some(parent) = dest.parent() {
214 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
215 }
216 tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
217 }
218 }
219 }
220
221 let changelog_html = match ChangelogExtractor
223 .section(config.changelog.clone(), &version)
224 .await
225 {
226 Ok(section) => render_markdown(§ion),
227 Err(_) => {
228 warn!("No changelog entry found for version {version}");
229 String::new()
230 }
231 };
232
233 let mut version_ctx = Context::new();
235 version_ctx.insert("config", &config);
236 version_ctx.insert("project_name", &config.site.name);
237 version_ctx.insert("lang", &config.site.lang);
238 version_ctx.insert("repo_url", &config.site.repo_url);
239 version_ctx.insert("version", &version);
240 version_ctx.insert("readme_html", &readme_html);
241 version_ctx.insert("changelog_html", &changelog_html);
242 version_ctx.insert("has_docs", &has_docs);
243 version_ctx.insert("has_docs_tarball", &has_docs_tarball);
244 version_ctx.insert("has_dist", &has_dist);
245 version_ctx.insert("dist_files", &dist_file_infos);
246
247 let version_html = tera
248 .render("version_index.html", &version_ctx)
249 .into_diagnostic()?;
250 tokio::fs::write(version_dir.join("index.html"), version_html)
251 .await
252 .into_diagnostic()?;
253
254 let mut all_versions = config.version_extractor.extract_all().await?;
258 if !all_versions.iter().any(|v| v.version == version) {
259 all_versions.push(version_info.clone());
260 }
261 all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
262
263 let version_entries: Vec<VersionEntry> =
265 all_versions.iter().map(VersionEntry::from_info).collect();
266
267 let base_url = config
268 .site
269 .base_url
270 .as_deref()
271 .map(|u| u.trim_end_matches('/'));
272
273 let mut root_ctx = Context::new();
274 root_ctx.insert("config", &config);
275 root_ctx.insert("project_name", &config.site.name);
276 root_ctx.insert("lang", &config.site.lang);
277 root_ctx.insert("repo_url", &config.site.repo_url);
278 root_ctx.insert("versions", &version_entries);
279 root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
280
281 let root_html = tera
282 .render("root_index.html", &root_ctx)
283 .into_diagnostic()?;
284 tokio::fs::write(output_dir.join("index.html"), root_html)
285 .await
286 .into_diagnostic()?;
287
288 let changelog_sections = match ChangelogExtractor
291 .all_sections(config.changelog.clone())
292 .await
293 {
294 Ok(map) => map,
295 Err(_) => {
296 warn!("Could not load changelog for Atom feed; entries will have no content");
297 std::collections::HashMap::new()
298 }
299 };
300
301 let atom_xml = generate_atom_feed(
302 &config.site.name,
303 &all_versions,
304 base_url,
305 &changelog_sections,
306 );
307 tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
308 .await
309 .into_diagnostic()?;
310
311 if let Some(latest) = all_versions.first() {
313 update_latest_symlink(&output_dir, &latest.version)?;
314 }
315
316 Ok(())
317}
318
319fn extract_local_refs(md: &str) -> Vec<String> {
327 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
328 let mut refs = Vec::new();
329 for event in Parser::new_ext(md, opts) {
330 let url: Option<pulldown_cmark::CowStr> = match event {
331 Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
332 Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
333 Event::End(TagEnd::Image | TagEnd::Link) => None,
335 _ => None,
336 };
337 if let Some(url) = url {
338 let s = url.as_ref();
339 if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
341 refs.push(s.to_owned());
342 }
343 }
344 }
345 refs
346}
347
348fn render_markdown(md: &str) -> String {
350 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
351 let parser = Parser::new_ext(md, opts);
352 let mut buf = String::new();
353 html::push_html(&mut buf, parser);
354 buf
355}
356
357async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
361 let mut names = Vec::new();
362 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
363 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
364 let path = entry.path();
365 if path.is_dir() && path.join("index.html").exists() {
366 names.push(entry.file_name().to_string_lossy().into_owned());
367 }
368 }
369 Ok(names)
370}
371
372async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
375 let html = if crate_names.len() == 1 {
376 format!(
377 "<!DOCTYPE html><html><head>\
378 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
379 </head></html>",
380 crate_names[0]
381 )
382 } else {
383 let items = crate_names
384 .iter()
385 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
386 .collect::<Vec<_>>()
387 .join("\n");
388 format!(
389 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
390 <title>Documentation</title></head>\
391 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
392 )
393 };
394
395 tokio::fs::write(docs_dir.join("index.html"), html)
396 .await
397 .into_diagnostic()
398}
399
400fn copy_dir_recursive(
404 src: PathBuf,
405 dst: PathBuf,
406) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
407 Box::pin(async move {
408 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
409 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
410 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
411 let src_path = entry.path();
412 let dst_path = dst.join(entry.file_name());
413 if src_path.is_dir() {
414 copy_dir_recursive(src_path, dst_path).await?;
415 } else {
416 tokio::fs::copy(&src_path, &dst_path)
417 .await
418 .into_diagnostic()?;
419 }
420 }
421 Ok(())
422 })
423}
424
425fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
429 let dir_name = src
430 .file_name()
431 .map(|n| n.to_string_lossy().into_owned())
432 .unwrap_or_else(|| "docs".to_owned());
433
434 let file = std::fs::File::create(dest).into_diagnostic()?;
435 let enc = GzEncoder::new(file, Compression::default());
436 let mut archive = tar::Builder::new(enc);
437 archive.append_dir_all(&dir_name, src).into_diagnostic()?;
438 archive
439 .into_inner()
440 .into_diagnostic()?
441 .finish()
442 .into_diagnostic()?;
443 Ok(())
444}
445
446fn strip_v(s: &str) -> &str {
449 s.strip_prefix('v').unwrap_or(s)
450}
451
452fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
453 match (
454 semver::Version::parse(strip_v(a)),
455 semver::Version::parse(strip_v(b)),
456 ) {
457 (Ok(va), Ok(vb)) => va.cmp(&vb),
458 _ => a.cmp(b),
459 }
460}
461
462fn hex_sha256(data: &[u8]) -> String {
464 let mut hasher = Sha256::new();
465 hasher.update(data);
466 hasher
467 .finalize()
468 .iter()
469 .map(|b| format!("{b:02x}"))
470 .collect()
471}
472
473fn human_size(bytes: u64) -> String {
475 const KIB: u64 = 1024;
476 const MIB: u64 = KIB * 1024;
477 const GIB: u64 = MIB * 1024;
478 if bytes >= GIB {
479 format!("{:.1} GB", bytes as f64 / GIB as f64)
480 } else if bytes >= MIB {
481 format!("{:.1} MB", bytes as f64 / MIB as f64)
482 } else if bytes >= KIB {
483 format!("{:.1} KB", bytes as f64 / KIB as f64)
484 } else {
485 format!("{bytes} B")
486 }
487}
488
489fn xml_escape(s: &str) -> String {
491 s.replace('&', "&")
492 .replace('<', "<")
493 .replace('>', ">")
494 .replace('"', """)
495 .replace('\'', "'")
496}
497
498fn generate_atom_feed(
507 project_name: &str,
508 versions: &[VersionInfo],
509 base_url: Option<&str>,
510 changelog_sections: &std::collections::HashMap<String, String>,
511) -> String {
512 let now: DateTime<Utc> = Utc::now();
513
514 let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
516
517 let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
518
519 let feed_id = match base_url {
521 Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
522 None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
523 };
524
525 let self_link = match base_url {
526 Some(base) => format!(
527 " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
528 xml_escape(base)
529 ),
530 None => String::new(),
531 };
532 let alt_link = match base_url {
533 Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)),
534 None => String::new(),
535 };
536
537 let entries: String = versions
538 .iter()
539 .map(|vi| {
540 let entry_date = vi.date.unwrap_or(now);
541 let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
542 let v_escaped = xml_escape(&vi.version);
543
544 let entry_id = match base_url {
545 Some(base) => format!("{base}/{v_escaped}/"),
546 None => format!(
547 "urn:abbaye:release:{}:{v_escaped}",
548 xml_escape(project_name)
549 ),
550 };
551
552 let entry_link = match base_url {
553 Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"),
554 None => String::new(),
555 };
556
557 let content_element = match changelog_sections.get(&vi.version) {
560 Some(md) if !md.is_empty() => {
561 let html = render_markdown(md);
562 format!(
563 " <content type=\"html\">{}</content>\n",
564 xml_escape(&html)
565 )
566 }
567 _ => String::new(),
568 };
569
570 format!(
571 " <entry>\n\
572 \x20 <title>{v_escaped}</title>\n\
573 \x20 <id>{entry_id}</id>\n\
574 \x20 <updated>{entry_date_str}</updated>\n\
575 {entry_link}\
576 {content_element}\
577 \x20 </entry>"
578 )
579 })
580 .collect::<Vec<_>>()
581 .join("\n");
582
583 format!(
584 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
585 <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
586 \x20 <title>{name} Releases</title>\n\
587 {self_link}\
588 {alt_link}\
589 \x20 <updated>{feed_updated_str}</updated>\n\
590 \x20 <id>{feed_id}</id>\n\
591 {entries}\n\
592 </feed>\n",
593 name = xml_escape(project_name),
594 )
595}
596
597fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
602 let link = output_dir.join("latest");
603
604 #[cfg(unix)]
605 {
606 if link.exists() || link.is_symlink() {
608 std::fs::remove_file(&link).into_diagnostic()?;
609 }
610 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
611 }
612
613 #[cfg(not(unix))]
614 {
615 std::fs::create_dir_all(&link).into_diagnostic()?;
616 std::fs::write(
617 link.join("index.html"),
618 format!(
619 "<!DOCTYPE html><html><head>\
620 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
621 </head></html>"
622 ),
623 )
624 .into_diagnostic()?;
625 }
626
627 Ok(())
628}