abbaye/main.rs
1//! # Abbaye
2//!
3//! 
4//!
5//! Abbaye is a Static Site Generator (SSG) for your software. As GitHub,
6//! Gitea, Forgejo and consorts offer, Abbaye can be used to generate a
7//! website with your software's presentation, documentation, and distribution, per version.
8//!
9//! Here's an example file structure:
10//!
11//! ```text
12//! .
13//! ├── index.html # the main page of the website, enabling choosing a version, defaults to "latest" (contains a list of available versions and a iframe to the selected version?)
14//! ├── releases.feed # the RSS feed of the releases
15//! ├── repository/ # the directory containing the repository UI (if enabled in config)
16//! ├── repository.git # Clonable git repository
17//! ├── latest -> v2.0.0 # symlink to the latest version (biggest version number)
18//! ├── v1.0.0/ # the directory containing the version 1.0.0 of the software
19//! │ ├── index.html # the main page of the version 1.0.0, from the README.md file.
20//! │ │ # Contains a sidebar with links to the documentation and distribution.
21//! │ │ # After the readme content, A changelog is displayed.
22//! │ ├── docs/ # the directory containing the documentation of the version 1.0.0
23//! │ │ ├── index.html # the main page of the documentation of the version 1.0.0
24//! │ │ └── …
25//! │ ├── docs.tar.gz # the tarball containing the documentation of the version 1.0.0
26//! │ └── dist/ # the directory containing the distribution of the version 1.0.0
27//! │ ├── source.tgz # the source code of the version 1.0.0
28//! │ ├── mybin-v1.0.0-x86_64-unknown-linux-gnu
29//! │ └── mybin-v1.0.0-x86_64-unknown-linux-musl
30//! └── v2.0.0/ # the directory containing the version 2.0.0 of the software
31//! ├── index.html # the main page of the version 2.0.0
32//! ├── …
33//! └── …
34//! ```
35//!
36//! ## Why ?
37//!
38//! This piece of software is for people that can't or won't use a full-featured forge such as GitHub, GitLab, ForgeJo & others.
39//! These forges provide "release pages" that allow you to upload and distribute your software, as well as get a changelog.
40//!
41//! Abbaye is made to be a simple, lightweight alternative to these forges, for the release/documentation parts.
42//!
43//! ### Why "Abbaye" ?
44//!
45//! [Abbaye](https://en.wikipedia.org/wiki/Abbaye) is a French word for Abbey. An Abbey is a type of monastery, on the big-ish side, but still a small, quiet place.
46//!
47//! Anyway, that's where you store and display your relics (your software releases).
48//!
49//! ## Installation
50//!
51//! ### Pre-built binaries
52//!
53//! You can grab a pre-built binary from the [releases page](http://vit.am/~ololduck/abbaye/latest).
54//!
55//! The `-musl` binaries are statically linked and should run everywhere, while the `-gnu` binaries are dynamically linked and require a compatible system library(which is probably available if you're not using an exotic distribution).
56//!
57//! ### From source
58//!
59//! To build from source, you need to have Rust installed. You can install Rust using [rustup](https://rustup.rs/).
60//!
61//! You can clone the [repository](https://git.sr.ht/~ololduck/abbaye) and build the project using `cargo build --release`. The built binary will be located in `target/release/abbaye`. You can also install it directly using `cargo install --path .`
62//!
63//! ## Usage
64//!
65//! Run `abbaye init` in your project's directory to create a `abbaye.toml` configuration file. You can then customize the configuration to your liking.
66//! Here's an example configuration file to get you started:
67//!
68//! ```toml
69//! [site]
70//! name = "Abbaye"
71//! # required for Atom feed generation (canonical URLs are used for feed items)
72//! base_url = "http://vit.am/~ololduck/abbaye/"
73//!
74//! [version_extractor]
75//! type = "git" # extract version from git tags
76//! tag_prefix = "v"
77//!
78//! [changelog] # use the default changelog parser (Keepachangelog format in CHANGELOG.md)
79//!
80//! [[builders]] # builds the project using cargo build --release
81//! type = "cargo"
82//! targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]
83//!
84//! [[builders]] # generates documentation using cargo doc
85//! type = "cargo_doc"
86//! no_deps = true # Don't include dependencies in the documentation
87//!
88//! [[builders]]
89//! # creates a compressed tarball of the source code (can be of anything, really)
90//! type = "archive"
91//!
92//! [[builders]]
93//! # Just an example dumb script to showcase the `script` builder type
94//! type = "script"
95//! script = [
96//! "echo $ABBAYE_BUILDING_VERSION > .version",
97//! ]
98//! outputs = [".version"]
99//! ```
100//!
101//! Then run `abbaye build` to build the site. The site will be generated in the `public/` directory by default.
102//! Now you can copy the contents of `public/` to your web server to deploy the site. For instance, with rsync:
103//! `rsync --progress -avz --links --perms --update public/ ololduck@vit.am:public_html/abbaye/`
104//!
105//! To have a look at all the available configuration options, please refer to the documentation of [`config::AbbayeConfig`].
106//!
107//! ### A note on the repository UI
108//!
109//! The repository UI is **NOT** enabled by default. It must be enabled explicitly in the configuration with the following:
110//!
111//! ```toml
112//! [git_ui] # only this section is needed to enable the repository UI
113//! ```
114//! It has multiple options (each presented with their defaults, if any):
115//!
116//! ```toml
117//! [git_ui]
118//! default_branch = "main"
119//! max_commits = 200
120//! repo_path = "."
121//! clone_url = "{{ site.base_url }}/repository.git"
122//! ```
123//!
124//! When the git UI is enabled, pages permitting browsing the tree of the repository are available, _but only for the branches tips and the tags_, as to not generate too much content. It already is quite a lot of content to generate for a simple repository UI (about 10MBs for Abbaye's repository at the time of writing).
125//!
126//! ### ✨ Customization ✨
127//!
128//! You can dump the default theme/templates to your local filesystem with `abbaye dump-theme`.
129//!
130//! This will create a `.abbaye/theme/` directory in your current directory with the default templates, which you can then ✨customize✨.
131//!
132//! If you don't want to customize every template, simply delete the ones you don't want to change.
133//!
134//! In `.abbaye/theme/static/`, you will find the CSS sheet used by the default theme, across all templates. Editing this file will change the look and feel of all your site's pages.
135//!
136//! ## Future plans
137//!
138//! - [x] Add support for theming
139//! - [ ] Add support for more site variables, such as the site title, description, and author, or even a custom footer and stuff.
140//! - I added OpenGraph support, does that count?
141//! - [x] Add support for a `self-update`-like command to update the abbaye binary to the latest version. The mechanisms put in place for this goal should be usable to any user of `abbaye`.
142//!
143//! ## Contributing
144//!
145//! Contributions are welcome! As i am mainly a rust developer, i am open to any contributions that improve the project, especially to support more artifacts builders/types.
146//!
147//! Just clone the repository and {send me an email,contact me on {IRC (ololduck@irc.libera.chat),the Fediverse (@ololduck@fosstodon.org)}} with {a link to your fork,a git patch,compliments and adoration}.
148
149use std::path::PathBuf;
150
151use clap::CommandFactory;
152use clap::Parser;
153use human_panic::setup_panic;
154use miette::{IntoDiagnostic, Result};
155use tokio::fs::create_dir_all;
156use tracing::{info, warn};
157
158use crate::{
159 builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig},
160 changelog::ChangelogConfig,
161 config::{AbbayeConfig, SiteConfig},
162 version_extractors::{AnyVersionExtractor, git::GitVersionConfig},
163};
164
165/// All builders for the site (ex: cargo build, cargo doc, etc.).
166pub mod builders;
167/// Parses the changelog file and generates a changelog page for the site.
168pub mod changelog;
169/// Stuff related to the CLI interface. Also contains colour escape codes for terminal output.
170pub mod cli;
171/// Handles the `abbaye.toml` configuration file.
172pub mod config;
173/// Generates a static git web UI and clonable bare repository.
174pub mod git_ui;
175/// Generates the site from the configuration and builds it.
176pub mod site;
177/// Self-update logic: fetches the release feed and replaces the binary when a newer version exists.
178mod updater;
179/// Extracts current version information from different sources (ex: git tags, cargo metadata, etc.).
180pub mod version_extractors;
181
182pub mod utils;
183
184/// Build the full website for every git tag, sorted from the lowest semver
185/// version to the highest.
186///
187/// For each tag the function:
188/// 1. Runs `git checkout <tag>` to switch the working tree.
189/// 2. Loads `abbaye.toml` from the checked-out revision (falling back to the
190/// config that was active before the loop if the file is absent).
191/// 3. Calls [`site::build_site`] to produce the version page and update the
192/// root index and Atom feed.
193///
194/// The original HEAD (branch or commit) is always restored after the loop,
195/// even when an error occurs.
196async fn build_all() -> Result<()> {
197 // Load the current config to discover the version extractor settings.
198 let base_config = config::load_config()?;
199
200 // `git for-each-ref --sort=version:refname` returns tags in semver order,
201 // lowest first, which is exactly the order we want.
202 let all_versions = base_config.version_extractor.extract_all().await?;
203 if all_versions.is_empty() {
204 info!("No tagged versions found – nothing to build.");
205 return Ok(());
206 }
207
208 // Remember where we are so we can restore it when we're done.
209 // Prefer the branch name (symbolic ref) so that checking it out
210 // afterwards leaves the user on their branch rather than in a
211 // detached-HEAD state. Fall back to the raw commit SHA when HEAD
212 // is already detached.
213 let symref_out = tokio::process::Command::new("git")
214 .args(["symbolic-ref", "--short", "HEAD"])
215 .output()
216 .await
217 .into_diagnostic()?;
218 let original_head = if symref_out.status.success() {
219 // On a branch.
220 String::from_utf8(symref_out.stdout)
221 .into_diagnostic()?
222 .trim()
223 .to_owned()
224 } else {
225 // Detached HEAD - fall back to the commit SHA.
226 let sha_out = tokio::process::Command::new("git")
227 .args(["rev-parse", "HEAD"])
228 .output()
229 .await
230 .into_diagnostic()?;
231 if !sha_out.status.success() {
232 return Err(miette::miette!("Could not determine current HEAD"));
233 }
234 String::from_utf8(sha_out.stdout)
235 .into_diagnostic()?
236 .trim()
237 .to_owned()
238 };
239
240 let total = all_versions.len();
241 info!("Building {} version(s) …", total);
242
243 // Run the build loop; capture the result so we can restore HEAD first.
244 let loop_result = async {
245 for (i, version_info) in all_versions.iter().enumerate() {
246 let tag = base_config
247 .version_extractor
248 .tag_name(&version_info.version);
249
250 info!("[{}/{}] Checking out {} …", i + 1, total, tag);
251
252 let checkout = tokio::process::Command::new("git")
253 .args(["checkout", &tag])
254 .output()
255 .await
256 .into_diagnostic()?;
257 if !checkout.status.success() {
258 let stderr = String::from_utf8_lossy(&checkout.stderr);
259 return Err(miette::miette!("git checkout {tag} failed:\n{stderr}"));
260 }
261
262 // Reload `abbaye.toml` from the checked-out revision so the build
263 // uses that version's own configuration (builders, readme path,
264 // etc.). If the file does not exist in this revision, fall back
265 // to the config we loaded before the loop.
266 let version_config = config::load_config().unwrap_or_else(|_| base_config.clone());
267
268 info!(
269 "[{}/{}] Building version {} …",
270 i + 1,
271 total,
272 version_info.version
273 );
274
275 site::build_site(version_config).await?;
276 }
277 Ok(())
278 }
279 .await;
280
281 // Always restore HEAD, regardless of whether the loop succeeded.
282 let restore = tokio::process::Command::new("git")
283 .args(["checkout", &original_head])
284 .output()
285 .await
286 .into_diagnostic()?;
287 if !restore.status.success() {
288 let stderr = String::from_utf8_lossy(&restore.stderr);
289 warn!("Could not restore HEAD to {original_head}:\n{stderr}");
290 }
291
292 loop_result?;
293
294 // Build the git repository UI once, now that HEAD is restored.
295 if let Some(git_ui_cfg) = &base_config.git_ui {
296 git_ui::build_git_repository_ui(&base_config, git_ui_cfg).await?;
297 }
298
299 info!("Done. Built {total} version(s).");
300 Ok(())
301}
302
303#[tokio::main]
304async fn main() -> Result<()> {
305 setup_panic!();
306 let cli_args = cli::CliArgs::parse();
307
308 tracing_subscriber::fmt()
309 .with_timer(tracing_subscriber::fmt::time::SystemTime)
310 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
311 .init();
312
313 match cli_args.command {
314 cli::CliCommand::Init { path } => {
315 let base_path = if let Some(path) = path {
316 path
317 } else {
318 std::env::current_dir().into_diagnostic()?
319 };
320 if base_path.join("abbaye.toml").exists() {
321 return Err(miette::miette!(
322 "abbaye.toml already exists in this directory"
323 ));
324 }
325 let abbaye_config = AbbayeConfig {
326 site: SiteConfig {
327 name: "MyProject Release Page".to_string(),
328 ..Default::default()
329 },
330 version_extractor: AnyVersionExtractor::Git(GitVersionConfig {
331 tag_prefix: Some("v".to_string()),
332 dirty_suffix: "-dirty".to_string(),
333 }),
334 builders: vec![BuilderEntry {
335 builder: AnyBuilder::Archive(ArchiveBuilderConfig {
336 source_dir: None,
337 output: None,
338 prefix: None,
339 ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()],
340 }),
341 id: None,
342 depends_on: vec![],
343 }],
344 changelog: ChangelogConfig {
345 ..Default::default()
346 },
347 git_ui: None,
348 };
349 let config_path = base_path.join("abbaye.toml");
350 let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?;
351 tokio::fs::write(&config_path, toml)
352 .await
353 .into_diagnostic()?;
354 }
355 cli::CliCommand::Build { repository_only } => {
356 let config = config::load_config()?;
357 if repository_only {
358 match &config.git_ui {
359 Some(git_ui_cfg) => {
360 git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
361 }
362 None => {
363 return Err(miette::miette!(
364 "--repository-only requires [git_ui] to be configured in abbaye.toml"
365 ));
366 }
367 }
368 } else {
369 site::build_site(config.clone()).await?;
370 if let Some(git_ui_cfg) = &config.git_ui {
371 git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
372 }
373 }
374 }
375 cli::CliCommand::BuildAll => {
376 build_all().await?;
377 }
378 cli::CliCommand::DumpSchema => {
379 // Emit a JSON Schema draft-07 document rather than the 2020-12
380 // default. Taplo (and most TOML LSP tooling) validates against
381 // draft-07, which treats `$ref` as exclusive - it ignores any
382 // sibling keywords. Draft-07 output from schemars wraps `$ref`
383 // in `allOf` instead, keeping the `const` type-discriminators
384 // visible to the validator and resolving `oneOf` ambiguity.
385 let generator = schemars::generate::SchemaSettings::draft07().into_generator();
386 let schema = generator.into_root_schema_for::<config::AbbayeConfig>();
387 println!(
388 "{}",
389 serde_json::to_string_pretty(&schema).into_diagnostic()?
390 );
391 }
392 cli::CliCommand::SelfUpdate { check } => {
393 updater::self_update(check).await?;
394 }
395 cli::CliCommand::UsageSpec => {
396 let mut cmd = cli::CliArgs::command();
397 clap_usage::generate(&mut cmd, "abbaye", &mut std::io::stdout());
398 }
399 cli::CliCommand::DumpTheme => {
400 let theme_path = PathBuf::from(".abbaye").join("theme");
401 create_dir_all(&theme_path).await.into_diagnostic()?;
402 tokio::fs::write(
403 theme_path.join("root_index.html.j2"),
404 site::TEMPLATE_ROOT_INDEX,
405 )
406 .await
407 .into_diagnostic()?;
408 tokio::fs::write(
409 theme_path.join("version_index.html.j2"),
410 site::TEMPLATE_VERSION_INDEX,
411 )
412 .await
413 .into_diagnostic()?;
414 tokio::fs::write(
415 theme_path.join("markdown.html.j2"),
416 builders::markdown::TEMPLATE_MARKDOWN,
417 )
418 .await
419 .into_diagnostic()?;
420 tokio::fs::write(theme_path.join("git_log.html.j2"), git_ui::TEMPLATE_GIT_LOG)
421 .await
422 .into_diagnostic()?;
423 tokio::fs::write(
424 theme_path.join("git_commit.html.j2"),
425 git_ui::TEMPLATE_GIT_COMMIT,
426 )
427 .await
428 .into_diagnostic()?;
429 tokio::fs::write(
430 theme_path.join("git_refs.html.j2"),
431 git_ui::TEMPLATE_GIT_REFS,
432 )
433 .await
434 .into_diagnostic()?;
435 {
436 let static_dir = theme_path.join("static");
437 create_dir_all(&static_dir).await.into_diagnostic()?;
438 tokio::fs::write(static_dir.join("site.css"), site::SITE_CSS)
439 .await
440 .into_diagnostic()?;
441 }
442 tokio::fs::write(
443 theme_path.join("git_tree.html.j2"),
444 git_ui::TEMPLATE_GIT_TREE,
445 )
446 .await
447 .into_diagnostic()?;
448 tokio::fs::write(
449 theme_path.join("git_blob.html.j2"),
450 git_ui::TEMPLATE_GIT_BLOB,
451 )
452 .await
453 .into_diagnostic()?;
454 }
455 }
456 Ok(())
457}