1use std::{
2 future::Future,
3 path::{Path, PathBuf},
4 pin::Pin,
5};
6
7use figment::{
8 Figment,
9 providers::{Format, Toml},
10};
11use flate2::{Compression, write::GzEncoder};
12use miette::{IntoDiagnostic, Result};
13use pulldown_cmark::{Options, Parser, html};
14use tera::{Context, Tera};
15use tracing::warn;
16
17use crate::{changelog::ChangelogExtractor, config::AbbayeConfig};
18
19const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html");
20const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html");
21
22pub fn load_config() -> Result<AbbayeConfig> {
29 let cwd = std::env::current_dir().into_diagnostic()?;
30 Figment::new()
31 .merge(Toml::file(cwd.join(".abbaye.toml")))
32 .merge(Toml::file(cwd.join("abbaye.toml")))
33 .extract()
34 .into_diagnostic()
35}
36
37pub async fn build_site(config: AbbayeConfig) -> Result<()> {
53 let output_dir = config
54 .output_dir
55 .clone()
56 .unwrap_or_else(|| PathBuf::from("public"));
57
58 tokio::fs::create_dir_all(&output_dir)
59 .await
60 .into_diagnostic()?;
61
62 let version = config.version_extractor.extract().await?;
64
65 let mut tera = Tera::default();
67 tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
68 .into_diagnostic()?;
69 tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
70 .into_diagnostic()?;
71
72 let mut dist_artifacts = Vec::new();
74 let mut doc_artifacts = Vec::new();
75
76 for builder in &config.builders {
77 for artifact in builder.build().await? {
78 if artifact.path.is_dir() {
79 doc_artifacts.push(artifact);
80 } else {
81 dist_artifacts.push(artifact);
82 }
83 }
84 }
85
86 let version_dir = output_dir.join(&version);
88 let dist_dir = version_dir.join("dist");
89 tokio::fs::create_dir_all(&dist_dir)
90 .await
91 .into_diagnostic()?;
92
93 for artifact in &dist_artifacts {
94 tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
95 .await
96 .into_diagnostic()?;
97 }
98
99 let dist_file_names: Vec<String> = dist_artifacts.iter().map(|a| a.name.clone()).collect();
100 let has_dist = !dist_file_names.is_empty();
101
102 let has_docs = !doc_artifacts.is_empty();
104 let has_docs_tarball;
105
106 if has_docs {
107 let docs_dir = version_dir.join("docs");
108
109 for artifact in &doc_artifacts {
110 copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
114 }
115
116 if !docs_dir.join("index.html").exists() {
119 let crate_names = find_doc_crates(&docs_dir).await?;
120 write_docs_index(&docs_dir, &crate_names).await?;
121 }
122
123 let tarball = version_dir.join("docs.tar.gz");
124 let docs_dir_c = docs_dir.clone();
125 let tarball_c = tarball.clone();
126 tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
127 .await
128 .into_diagnostic()??;
129
130 has_docs_tarball = true;
131 } else {
132 has_docs_tarball = false;
133 }
134
135 let readme_path = config
137 .site
138 .readme
139 .as_deref()
140 .unwrap_or(Path::new("README.md"));
141
142 let readme_html = match tokio::fs::read_to_string(readme_path).await {
143 Ok(content) => render_markdown(&content),
144 Err(_) => {
145 warn!("README not found at {}", readme_path.display());
146 String::new()
147 }
148 };
149
150 let changelog_html = match ChangelogExtractor
152 .section(config.changelog.clone(), &version)
153 .await
154 {
155 Ok(section) => render_markdown(§ion),
156 Err(_) => {
157 warn!("No changelog entry found for version {version}");
158 String::new()
159 }
160 };
161
162 let mut version_ctx = Context::new();
164 version_ctx.insert("project_name", &config.site.name);
165 version_ctx.insert("version", &version);
166 version_ctx.insert("readme_html", &readme_html);
167 version_ctx.insert("changelog_html", &changelog_html);
168 version_ctx.insert("has_docs", &has_docs);
169 version_ctx.insert("has_docs_tarball", &has_docs_tarball);
170 version_ctx.insert("has_dist", &has_dist);
171 version_ctx.insert("dist_files", &dist_file_names);
172
173 let version_html = tera
174 .render("version_index.html", &version_ctx)
175 .into_diagnostic()?;
176 tokio::fs::write(version_dir.join("index.html"), version_html)
177 .await
178 .into_diagnostic()?;
179
180 let mut versions = config.version_extractor.extract_all().await?;
184 if !versions.contains(&version) {
185 versions.push(version.clone());
186 }
187 versions.sort_by(|a, b| compare_versions(b, a));
188
189 let mut root_ctx = Context::new();
190 root_ctx.insert("project_name", &config.site.name);
191 root_ctx.insert("versions", &versions);
192
193 let root_html = tera
194 .render("root_index.html", &root_ctx)
195 .into_diagnostic()?;
196 tokio::fs::write(output_dir.join("index.html"), root_html)
197 .await
198 .into_diagnostic()?;
199
200 if let Some(latest) = versions.first() {
202 update_latest_symlink(&output_dir, latest)?;
203 }
204
205 Ok(())
206}
207
208fn render_markdown(md: &str) -> String {
212 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
213 let parser = Parser::new_ext(md, opts);
214 let mut buf = String::new();
215 html::push_html(&mut buf, parser);
216 buf
217}
218
219async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
223 let mut names = Vec::new();
224 let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
225 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
226 let path = entry.path();
227 if path.is_dir() && path.join("index.html").exists() {
228 names.push(entry.file_name().to_string_lossy().into_owned());
229 }
230 }
231 Ok(names)
232}
233
234async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
237 let html = if crate_names.len() == 1 {
238 format!(
239 "<!DOCTYPE html><html><head>\
240 <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
241 </head></html>",
242 crate_names[0]
243 )
244 } else {
245 let items = crate_names
246 .iter()
247 .map(|n| format!(" <li><a href=\"{n}/index.html\">{n}</a></li>"))
248 .collect::<Vec<_>>()
249 .join("\n");
250 format!(
251 "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
252 <title>Documentation</title></head>\
253 <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
254 )
255 };
256
257 tokio::fs::write(docs_dir.join("index.html"), html)
258 .await
259 .into_diagnostic()
260}
261
262fn copy_dir_recursive(
266 src: PathBuf,
267 dst: PathBuf,
268) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
269 Box::pin(async move {
270 tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
271 let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
272 while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
273 let src_path = entry.path();
274 let dst_path = dst.join(entry.file_name());
275 if src_path.is_dir() {
276 copy_dir_recursive(src_path, dst_path).await?;
277 } else {
278 tokio::fs::copy(&src_path, &dst_path)
279 .await
280 .into_diagnostic()?;
281 }
282 }
283 Ok(())
284 })
285}
286
287fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
291 let dir_name = src
292 .file_name()
293 .map(|n| n.to_string_lossy().into_owned())
294 .unwrap_or_else(|| "docs".to_owned());
295
296 let file = std::fs::File::create(dest).into_diagnostic()?;
297 let enc = GzEncoder::new(file, Compression::default());
298 let mut archive = tar::Builder::new(enc);
299 archive.append_dir_all(&dir_name, src).into_diagnostic()?;
300 archive
301 .into_inner()
302 .into_diagnostic()?
303 .finish()
304 .into_diagnostic()?;
305 Ok(())
306}
307
308fn strip_v(s: &str) -> &str {
311 s.strip_prefix('v').unwrap_or(s)
312}
313
314fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
315 match (
316 semver::Version::parse(strip_v(a)),
317 semver::Version::parse(strip_v(b)),
318 ) {
319 (Ok(va), Ok(vb)) => va.cmp(&vb),
320 _ => a.cmp(b),
321 }
322}
323
324fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
329 let link = output_dir.join("latest");
330
331 #[cfg(unix)]
332 {
333 if link.exists() || link.is_symlink() {
335 std::fs::remove_file(&link).into_diagnostic()?;
336 }
337 std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
338 }
339
340 #[cfg(not(unix))]
341 {
342 std::fs::create_dir_all(&link).into_diagnostic()?;
343 std::fs::write(
344 link.join("index.html"),
345 format!(
346 "<!DOCTYPE html><html><head>\
347 <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
348 </head></html>"
349 ),
350 )
351 .into_diagnostic()?;
352 }
353
354 Ok(())
355}