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 figment::{
11 Figment,
12 providers::{Format, Toml},
13};
14use flate2::{Compression, write::GzEncoder};
15use miette::{IntoDiagnostic, Result};
16use pulldown_cmark::{Options, Parser, html};
17use tera::{Context, Tera};
18use tracing::warn;
19
20use crate::{changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo};
21
22const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html");
23const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html");
24const ATOM_FEED_FILENAME: &str = "releases.atom";
25
26#[derive(serde::Serialize)]
30struct VersionEntry {
31 version: String,
33 date: Option<String>,
35}
36
37impl VersionEntry {
38 fn from_info(info: &VersionInfo) -> Self {
39 Self {
40 version: info.version.clone(),
41 date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
42 }
43 }
44}
45
46#[derive(serde::Serialize)]
48struct DistFileInfo {
49 name: String,
51 size_bytes: u64,
53 size_human: String,
55 sha256: String,
57}
58
59pub fn load_config() -> Result<AbbayeConfig> {
66 let cwd = std::env::current_dir().into_diagnostic()?;
67 Figment::new()
68 .merge(Toml::file(cwd.join(".abbaye.toml")))
69 .merge(Toml::file(cwd.join("abbaye.toml")))
70 .extract()
71 .into_diagnostic()
72}
73
74pub async fn build_site(config: AbbayeConfig) -> Result<()> {
90 let output_dir = config
91 .output_dir
92 .clone()
93 .unwrap_or_else(|| PathBuf::from("public"));
94
95 tokio::fs::create_dir_all(&output_dir)
96 .await
97 .into_diagnostic()?;
98
99 let version_info = config.version_extractor.extract().await?;
101 let version = version_info.version.clone();
102
103 let mut tera = Tera::default();
105 tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
106 .into_diagnostic()?;
107 tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
108 .into_diagnostic()?;
109
110 let mut dist_artifacts = Vec::new();
112 let mut doc_artifacts = Vec::new();
113
114 for builder in &config.builders {
115 for artifact in builder.build().await? {
116 if artifact.path.is_dir() {
117 doc_artifacts.push(artifact);
118 } else {
119 dist_artifacts.push(artifact);
120 }
121 }
122 }
123
124 let version_dir = output_dir.join(&version);
126 let dist_dir = version_dir.join("dist");
127 tokio::fs::create_dir_all(&dist_dir)
128 .await
129 .into_diagnostic()?;
130
131 for artifact in &dist_artifacts {
132 tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
133 .await
134 .into_diagnostic()?;
135 }
136
137 let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
139 for artifact in &dist_artifacts {
140 let dest = dist_dir.join(&artifact.name);
141 let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
142 let size_bytes = bytes.len() as u64;
143 let sha256 = hex_sha256(&bytes);
144 dist_file_infos.push(DistFileInfo {
145 name: artifact.name.clone(),
146 size_bytes,
147 size_human: human_size(size_bytes),
148 sha256,
149 });
150 }
151 let has_dist = !dist_file_infos.is_empty();
152
153 let has_docs = !doc_artifacts.is_empty();
155 let has_docs_tarball;
156
157 if has_docs {
158 let docs_dir = version_dir.join("docs");
159
160 for artifact in &doc_artifacts {
161 copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
165 }
166
167 if !docs_dir.join("index.html").exists() {
170 let crate_names = find_doc_crates(&docs_dir).await?;
171 write_docs_index(&docs_dir, &crate_names).await?;
172 }
173
174 let tarball = version_dir.join("docs.tar.gz");
175 let docs_dir_c = docs_dir.clone();
176 let tarball_c = tarball.clone();
177 tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
178 .await
179 .into_diagnostic()??;
180
181 has_docs_tarball = true;
182 } else {
183 has_docs_tarball = false;
184 }
185
186 let readme_path = config
188 .site
189 .readme
190 .as_deref()
191 .unwrap_or(Path::new("README.md"));
192
193 let readme_html = match tokio::fs::read_to_string(readme_path).await {
194 Ok(content) => render_markdown(&content),
195 Err(_) => {
196 warn!("README not found at {}", readme_path.display());
197 String::new()
198 }
199 };
200
201 let changelog_html = match ChangelogExtractor
203 .section(config.changelog.clone(), &version)
204 .await
205 {
206 Ok(section) => render_markdown(§ion),
207 Err(_) => {
208 warn!("No changelog entry found for version {version}");
209 String::new()
210 }
211 };
212
213 let mut version_ctx = Context::new();
215 version_ctx.insert("project_name", &config.site.name);
216 version_ctx.insert("version", &version);
217 version_ctx.insert("readme_html", &readme_html);
218 version_ctx.insert("changelog_html", &changelog_html);
219 version_ctx.insert("has_docs", &has_docs);
220 version_ctx.insert("has_docs_tarball", &has_docs_tarball);
221 version_ctx.insert("has_dist", &has_dist);
222 version_ctx.insert("dist_files", &dist_file_infos);
223
224 let version_html = tera
225 .render("version_index.html", &version_ctx)
226 .into_diagnostic()?;
227 tokio::fs::write(version_dir.join("index.html"), version_html)
228 .await
229 .into_diagnostic()?;
230
231 let mut all_versions = config.version_extractor.extract_all().await?;
235 if !all_versions.iter().any(|v| v.version == version) {
236 all_versions.push(version_info.clone());
237 }
238 all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
239
240 let version_entries: Vec<VersionEntry> =
242 all_versions.iter().map(VersionEntry::from_info).collect();
243
244 let base_url = config
245 .site
246 .base_url
247 .as_deref()
248 .map(|u| u.trim_end_matches('/'));
249
250 let mut root_ctx = Context::new();
251 root_ctx.insert("project_name", &config.site.name);
252 root_ctx.insert("versions", &version_entries);
253 root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
254
255 let root_html = tera
256 .render("root_index.html", &root_ctx)
257 .into_diagnostic()?;
258 tokio::fs::write(output_dir.join("index.html"), root_html)
259 .await
260 .into_diagnostic()?;
261
262 let atom_xml = generate_atom_feed(&config.site.name, &all_versions, base_url);
264 tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
265 .await
266 .into_diagnostic()?;
267
268 if let Some(latest) = all_versions.first() {
270 update_latest_symlink(&output_dir, &latest.version)?;
271 }
272
273 Ok(())
274}
275
276fn render_markdown(md: &str) -> String {
280 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
281 let parser = Parser::new_ext(md, opts);
282 let mut buf = String::new();
283 html::push_html(&mut buf, parser);
284 buf
285}
286
287async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
291 let mut names = Vec::new();
292 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
293 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
294 let path = entry.path();
295 if path.is_dir() && path.join("index.html").exists() {
296 names.push(entry.file_name().to_string_lossy().into_owned());
297 }
298 }
299 Ok(names)
300}
301
302async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
305 let html = if crate_names.len() == 1 {
306 format!(
307 "<!DOCTYPE html><html><head>\
308 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
309 </head></html>",
310 crate_names[0]
311 )
312 } else {
313 let items = crate_names
314 .iter()
315 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
316 .collect::<Vec<_>>()
317 .join("\n");
318 format!(
319 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
320 <title>Documentation</title></head>\
321 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
322 )
323 };
324
325 tokio::fs::write(docs_dir.join("index.html"), html)
326 .await
327 .into_diagnostic()
328}
329
330fn copy_dir_recursive(
334 src: PathBuf,
335 dst: PathBuf,
336) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
337 Box::pin(async move {
338 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
339 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
340 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
341 let src_path = entry.path();
342 let dst_path = dst.join(entry.file_name());
343 if src_path.is_dir() {
344 copy_dir_recursive(src_path, dst_path).await?;
345 } else {
346 tokio::fs::copy(&src_path, &dst_path)
347 .await
348 .into_diagnostic()?;
349 }
350 }
351 Ok(())
352 })
353}
354
355fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
359 let dir_name = src
360 .file_name()
361 .map(|n| n.to_string_lossy().into_owned())
362 .unwrap_or_else(|| "docs".to_owned());
363
364 let file = std::fs::File::create(dest).into_diagnostic()?;
365 let enc = GzEncoder::new(file, Compression::default());
366 let mut archive = tar::Builder::new(enc);
367 archive.append_dir_all(&dir_name, src).into_diagnostic()?;
368 archive
369 .into_inner()
370 .into_diagnostic()?
371 .finish()
372 .into_diagnostic()?;
373 Ok(())
374}
375
376fn strip_v(s: &str) -> &str {
379 s.strip_prefix('v').unwrap_or(s)
380}
381
382fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
383 match (
384 semver::Version::parse(strip_v(a)),
385 semver::Version::parse(strip_v(b)),
386 ) {
387 (Ok(va), Ok(vb)) => va.cmp(&vb),
388 _ => a.cmp(b),
389 }
390}
391
392fn hex_sha256(data: &[u8]) -> String {
394 let mut hasher = Sha256::new();
395 hasher.update(data);
396 hasher
397 .finalize()
398 .iter()
399 .map(|b| format!("{b:02x}"))
400 .collect()
401}
402
403fn human_size(bytes: u64) -> String {
405 const KIB: u64 = 1024;
406 const MIB: u64 = KIB * 1024;
407 const GIB: u64 = MIB * 1024;
408 if bytes >= GIB {
409 format!("{:.1} GB", bytes as f64 / GIB as f64)
410 } else if bytes >= MIB {
411 format!("{:.1} MB", bytes as f64 / MIB as f64)
412 } else if bytes >= KIB {
413 format!("{:.1} KB", bytes as f64 / KIB as f64)
414 } else {
415 format!("{bytes} B")
416 }
417}
418
419fn xml_escape(s: &str) -> String {
421 s.replace('&', "&")
422 .replace('<', "<")
423 .replace('>', ">")
424 .replace('"', """)
425 .replace('\'', "'")
426}
427
428fn generate_atom_feed(
434 project_name: &str,
435 versions: &[VersionInfo],
436 base_url: Option<&str>,
437) -> String {
438 let now: DateTime<Utc> = Utc::now();
439
440 let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
442
443 let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
444
445 let feed_id = match base_url {
447 Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
448 None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
449 };
450
451 let self_link = match base_url {
452 Some(base) => format!(
453 " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
454 xml_escape(base)
455 ),
456 None => String::new(),
457 };
458 let alt_link = match base_url {
459 Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)),
460 None => String::new(),
461 };
462
463 let entries: String = versions
464 .iter()
465 .map(|vi| {
466 let entry_date = vi.date.unwrap_or(now);
467 let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
468 let v_escaped = xml_escape(&vi.version);
469
470 let entry_id = match base_url {
471 Some(base) => format!("{base}/{v_escaped}/"),
472 None => format!(
473 "urn:abbaye:release:{}:{v_escaped}",
474 xml_escape(project_name)
475 ),
476 };
477
478 let entry_link = match base_url {
479 Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"),
480 None => String::new(),
481 };
482
483 format!(
484 " <entry>\n\
485 \x20 <title>{v_escaped}</title>\n\
486 \x20 <id>{entry_id}</id>\n\
487 \x20 <updated>{entry_date_str}</updated>\n\
488 {entry_link}\
489 \x20 </entry>"
490 )
491 })
492 .collect::<Vec<_>>()
493 .join("\n");
494
495 format!(
496 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
497 <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
498 \x20 <title>{name} Releases</title>\n\
499 {self_link}\
500 {alt_link}\
501 \x20 <updated>{feed_updated_str}</updated>\n\
502 \x20 <id>{feed_id}</id>\n\
503 {entries}\n\
504 </feed>\n",
505 name = xml_escape(project_name),
506 )
507}
508
509fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
514 let link = output_dir.join("latest");
515
516 #[cfg(unix)]
517 {
518 if link.exists() || link.is_symlink() {
520 std::fs::remove_file(&link).into_diagnostic()?;
521 }
522 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
523 }
524
525 #[cfg(not(unix))]
526 {
527 std::fs::create_dir_all(&link).into_diagnostic()?;
528 std::fs::write(
529 link.join("index.html"),
530 format!(
531 "<!DOCTYPE html><html><head>\
532 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
533 </head></html>"
534 ),
535 )
536 .into_diagnostic()?;
537 }
538
539 Ok(())
540}