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
108 let mut dist_artifacts = Vec::new();
110 let mut doc_artifacts = Vec::new();
111
112 for builder in &config.builders {
113 for artifact in builder.build().await? {
114 if artifact.path.is_dir() {
115 doc_artifacts.push(artifact);
116 } else {
117 dist_artifacts.push(artifact);
118 }
119 }
120 }
121
122 let version_dir = output_dir.join(&version);
124 let dist_dir = version_dir.join("dist");
125 tokio::fs::create_dir_all(&dist_dir)
126 .await
127 .into_diagnostic()?;
128
129 for artifact in &dist_artifacts {
130 tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
131 .await
132 .into_diagnostic()?;
133 }
134
135 let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
137 for artifact in &dist_artifacts {
138 let dest = dist_dir.join(&artifact.name);
139 let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
140 let size_bytes = bytes.len() as u64;
141 let sha256 = hex_sha256(&bytes);
142 dist_file_infos.push(DistFileInfo {
143 name: artifact.name.clone(),
144 size_bytes,
145 size_human: human_size(size_bytes),
146 sha256,
147 });
148 }
149 let has_dist = !dist_file_infos.is_empty();
150
151 let has_docs = !doc_artifacts.is_empty();
153 let has_docs_tarball;
154
155 if has_docs {
156 let docs_dir = version_dir.join("docs");
157
158 for artifact in &doc_artifacts {
159 copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
163 }
164
165 if !docs_dir.join("index.html").exists() {
168 let crate_names = find_doc_crates(&docs_dir).await?;
169 write_docs_index(&docs_dir, &crate_names).await?;
170 }
171
172 let tarball = version_dir.join("docs.tar.gz");
173 let docs_dir_c = docs_dir.clone();
174 let tarball_c = tarball.clone();
175 tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
176 .await
177 .into_diagnostic()??;
178
179 has_docs_tarball = true;
180 } else {
181 has_docs_tarball = false;
182 }
183
184 let readme_path = config
186 .site
187 .readme
188 .as_deref()
189 .unwrap_or(Path::new("README.md"));
190
191 let readme_html = match tokio::fs::read_to_string(readme_path).await {
192 Ok(content) => render_markdown(&content),
193 Err(_) => {
194 warn!("README not found at {}", readme_path.display());
195 String::new()
196 }
197 };
198
199 let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
203 if let Ok(content) = tokio::fs::read_to_string(readme_path).await {
204 for rel in extract_local_refs(&content) {
205 let src = readme_dir.join(&rel);
206 if src.is_file() {
207 let dest = version_dir.join(&rel);
209 if let Some(parent) = dest.parent() {
210 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
211 }
212 tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
213 }
214 }
215 }
216
217 let changelog_html = match ChangelogExtractor
219 .section(config.changelog.clone(), &version)
220 .await
221 {
222 Ok(section) => render_markdown(§ion),
223 Err(_) => {
224 warn!("No changelog entry found for version {version}");
225 String::new()
226 }
227 };
228
229 let mut version_ctx = Context::new();
231 version_ctx.insert("project_name", &config.site.name);
232 version_ctx.insert("lang", &config.site.lang);
233 version_ctx.insert("repo_url", &config.site.repo_url);
234 version_ctx.insert("version", &version);
235 version_ctx.insert("readme_html", &readme_html);
236 version_ctx.insert("changelog_html", &changelog_html);
237 version_ctx.insert("has_docs", &has_docs);
238 version_ctx.insert("has_docs_tarball", &has_docs_tarball);
239 version_ctx.insert("has_dist", &has_dist);
240 version_ctx.insert("dist_files", &dist_file_infos);
241
242 let version_html = tera
243 .render("version_index.html", &version_ctx)
244 .into_diagnostic()?;
245 tokio::fs::write(version_dir.join("index.html"), version_html)
246 .await
247 .into_diagnostic()?;
248
249 let mut all_versions = config.version_extractor.extract_all().await?;
253 if !all_versions.iter().any(|v| v.version == version) {
254 all_versions.push(version_info.clone());
255 }
256 all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
257
258 let version_entries: Vec<VersionEntry> =
260 all_versions.iter().map(VersionEntry::from_info).collect();
261
262 let base_url = config
263 .site
264 .base_url
265 .as_deref()
266 .map(|u| u.trim_end_matches('/'));
267
268 let mut root_ctx = Context::new();
269 root_ctx.insert("project_name", &config.site.name);
270 root_ctx.insert("lang", &config.site.lang);
271 root_ctx.insert("repo_url", &config.site.repo_url);
272 root_ctx.insert("versions", &version_entries);
273 root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
274
275 let root_html = tera
276 .render("root_index.html", &root_ctx)
277 .into_diagnostic()?;
278 tokio::fs::write(output_dir.join("index.html"), root_html)
279 .await
280 .into_diagnostic()?;
281
282 let changelog_sections = match ChangelogExtractor
285 .all_sections(config.changelog.clone())
286 .await
287 {
288 Ok(map) => map,
289 Err(_) => {
290 warn!("Could not load changelog for Atom feed; entries will have no content");
291 std::collections::HashMap::new()
292 }
293 };
294
295 let atom_xml = generate_atom_feed(
296 &config.site.name,
297 &all_versions,
298 base_url,
299 &changelog_sections,
300 );
301 tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
302 .await
303 .into_diagnostic()?;
304
305 if let Some(latest) = all_versions.first() {
307 update_latest_symlink(&output_dir, &latest.version)?;
308 }
309
310 Ok(())
311}
312
313fn extract_local_refs(md: &str) -> Vec<String> {
321 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
322 let mut refs = Vec::new();
323 for event in Parser::new_ext(md, opts) {
324 let url: Option<pulldown_cmark::CowStr> = match event {
325 Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
326 Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
327 Event::End(TagEnd::Image | TagEnd::Link) => None,
329 _ => None,
330 };
331 if let Some(url) = url {
332 let s = url.as_ref();
333 if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
335 refs.push(s.to_owned());
336 }
337 }
338 }
339 refs
340}
341
342fn render_markdown(md: &str) -> String {
344 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
345 let parser = Parser::new_ext(md, opts);
346 let mut buf = String::new();
347 html::push_html(&mut buf, parser);
348 buf
349}
350
351async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
355 let mut names = Vec::new();
356 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
357 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
358 let path = entry.path();
359 if path.is_dir() && path.join("index.html").exists() {
360 names.push(entry.file_name().to_string_lossy().into_owned());
361 }
362 }
363 Ok(names)
364}
365
366async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
369 let html = if crate_names.len() == 1 {
370 format!(
371 "<!DOCTYPE html><html><head>\
372 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
373 </head></html>",
374 crate_names[0]
375 )
376 } else {
377 let items = crate_names
378 .iter()
379 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
380 .collect::<Vec<_>>()
381 .join("\n");
382 format!(
383 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
384 <title>Documentation</title></head>\
385 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
386 )
387 };
388
389 tokio::fs::write(docs_dir.join("index.html"), html)
390 .await
391 .into_diagnostic()
392}
393
394fn copy_dir_recursive(
398 src: PathBuf,
399 dst: PathBuf,
400) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
401 Box::pin(async move {
402 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
403 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
404 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
405 let src_path = entry.path();
406 let dst_path = dst.join(entry.file_name());
407 if src_path.is_dir() {
408 copy_dir_recursive(src_path, dst_path).await?;
409 } else {
410 tokio::fs::copy(&src_path, &dst_path)
411 .await
412 .into_diagnostic()?;
413 }
414 }
415 Ok(())
416 })
417}
418
419fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
423 let dir_name = src
424 .file_name()
425 .map(|n| n.to_string_lossy().into_owned())
426 .unwrap_or_else(|| "docs".to_owned());
427
428 let file = std::fs::File::create(dest).into_diagnostic()?;
429 let enc = GzEncoder::new(file, Compression::default());
430 let mut archive = tar::Builder::new(enc);
431 archive.append_dir_all(&dir_name, src).into_diagnostic()?;
432 archive
433 .into_inner()
434 .into_diagnostic()?
435 .finish()
436 .into_diagnostic()?;
437 Ok(())
438}
439
440fn strip_v(s: &str) -> &str {
443 s.strip_prefix('v').unwrap_or(s)
444}
445
446fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
447 match (
448 semver::Version::parse(strip_v(a)),
449 semver::Version::parse(strip_v(b)),
450 ) {
451 (Ok(va), Ok(vb)) => va.cmp(&vb),
452 _ => a.cmp(b),
453 }
454}
455
456fn hex_sha256(data: &[u8]) -> String {
458 let mut hasher = Sha256::new();
459 hasher.update(data);
460 hasher
461 .finalize()
462 .iter()
463 .map(|b| format!("{b:02x}"))
464 .collect()
465}
466
467fn human_size(bytes: u64) -> String {
469 const KIB: u64 = 1024;
470 const MIB: u64 = KIB * 1024;
471 const GIB: u64 = MIB * 1024;
472 if bytes >= GIB {
473 format!("{:.1} GB", bytes as f64 / GIB as f64)
474 } else if bytes >= MIB {
475 format!("{:.1} MB", bytes as f64 / MIB as f64)
476 } else if bytes >= KIB {
477 format!("{:.1} KB", bytes as f64 / KIB as f64)
478 } else {
479 format!("{bytes} B")
480 }
481}
482
483fn xml_escape(s: &str) -> String {
485 s.replace('&', "&")
486 .replace('<', "<")
487 .replace('>', ">")
488 .replace('"', """)
489 .replace('\'', "'")
490}
491
492fn generate_atom_feed(
501 project_name: &str,
502 versions: &[VersionInfo],
503 base_url: Option<&str>,
504 changelog_sections: &std::collections::HashMap<String, String>,
505) -> String {
506 let now: DateTime<Utc> = Utc::now();
507
508 let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
510
511 let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
512
513 let feed_id = match base_url {
515 Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
516 None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
517 };
518
519 let self_link = match base_url {
520 Some(base) => format!(
521 " <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
522 xml_escape(base)
523 ),
524 None => String::new(),
525 };
526 let alt_link = match base_url {
527 Some(base) => format!(" <link href=\"{}\"/>\n", xml_escape(base)),
528 None => String::new(),
529 };
530
531 let entries: String = versions
532 .iter()
533 .map(|vi| {
534 let entry_date = vi.date.unwrap_or(now);
535 let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
536 let v_escaped = xml_escape(&vi.version);
537
538 let entry_id = match base_url {
539 Some(base) => format!("{base}/{v_escaped}/"),
540 None => format!(
541 "urn:abbaye:release:{}:{v_escaped}",
542 xml_escape(project_name)
543 ),
544 };
545
546 let entry_link = match base_url {
547 Some(base) => format!(" <link href=\"{base}/{v_escaped}/\"/>\n"),
548 None => String::new(),
549 };
550
551 let content_element = match changelog_sections.get(&vi.version) {
554 Some(md) if !md.is_empty() => {
555 let html = render_markdown(md);
556 format!(
557 " <content type=\"html\">{}</content>\n",
558 xml_escape(&html)
559 )
560 }
561 _ => String::new(),
562 };
563
564 format!(
565 " <entry>\n\
566 \x20 <title>{v_escaped}</title>\n\
567 \x20 <id>{entry_id}</id>\n\
568 \x20 <updated>{entry_date_str}</updated>\n\
569 {entry_link}\
570 {content_element}\
571 \x20 </entry>"
572 )
573 })
574 .collect::<Vec<_>>()
575 .join("\n");
576
577 format!(
578 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
579 <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
580 \x20 <title>{name} Releases</title>\n\
581 {self_link}\
582 {alt_link}\
583 \x20 <updated>{feed_updated_str}</updated>\n\
584 \x20 <id>{feed_id}</id>\n\
585 {entries}\n\
586 </feed>\n",
587 name = xml_escape(project_name),
588 )
589}
590
591fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
596 let link = output_dir.join("latest");
597
598 #[cfg(unix)]
599 {
600 if link.exists() || link.is_symlink() {
602 std::fs::remove_file(&link).into_diagnostic()?;
603 }
604 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
605 }
606
607 #[cfg(not(unix))]
608 {
609 std::fs::create_dir_all(&link).into_diagnostic()?;
610 std::fs::write(
611 link.join("index.html"),
612 format!(
613 "<!DOCTYPE html><html><head>\
614 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
615 </head></html>"
616 ),
617 )
618 .into_diagnostic()?;
619 }
620
621 Ok(())
622}