Skip to main content

abbaye/
main.rs

1//! # Abbaye
2//!
3//! ![logo](logo-wordmark.svg)
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//! ├── latest -> v2.0.0 # symlink to the latest version (biggest version number)
16//! ├── v1.0.0/ # the directory containing the version 1.0.0 of the software
17//! │   ├── index.html # the main page of the version 1.0.0, from the README.md file.
18//! │   │   # Contains a sidebar with links to the documentation and distribution.
19//! │   │   # After the readme content, A changelog is displayed.
20//! │   ├── docs/ # the directory containing the documentation of the version 1.0.0
21//! │   │   ├── index.html # the main page of the documentation of the version 1.0.0
22//! │   │   └── …
23//! │   ├── docs.tar.gz # the tarball containing the documentation of the version 1.0.0
24//! │   └── dist/ # the directory containing the distribution of the version 1.0.0
25//! │       ├── source.tgz # the source code of the version 1.0.0
26//! │       ├── mybin-v1.0.0-x86_64-unknown-linux-gnu
27//! │       └── mybin-v1.0.0-x86_64-unknown-linux-musl
28//! └── v2.0.0/ # the directory containing the version 2.0.0 of the software
29//!     ├── index.html # the main page of the version 2.0.0
30//!     ├── …
31//!     └── …
32//! ```
33//!
34//! ## Why ?
35//!
36//! This piece of software is for people that can't or won't use a full-featured forge such as GitHub, GitLab, ForgeJo & others.
37//! These forges provide "release pages" that allow you to upload and distribute your software, as well as get a changelog.
38//!
39//! Abbaye is made to be a simple, lightweight alternative to these forges, for the release/documentation parts.
40//!
41//! ### Why "Abbaye" ?
42//!
43//! [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.
44//!
45//! Anyway, that's where you store and display your relics (your software releases).
46//!
47//! ## Installation
48//!
49//! ### Pre-built binaries
50//!
51//! You can grab a pre-built binary from the [releases page](http://vit.am/~ololduck/abbaye/latest).
52//!
53//! 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).
54//!
55//! ### From source
56//!
57//! To build from source, you need to have Rust installed. You can install Rust using [rustup](https://rustup.rs/).
58//!
59//! 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 .`
60//!
61//! ## Usage
62//!
63//! Run `abbaye init` in your project's directory to create a `abbaye.toml` configuration file. You can then customize the configuration to your liking.
64//! Here's an example configuration file to get you started:
65//!
66//! ```toml
67//! [site]
68//! name = "Abbaye"
69//! # required for Atom feed generation (canonical URLs are used for feed items)
70//! base_url = "http://vit.am/~ololduck/abbaye/"
71//!
72//! [version_extractor]
73//! type = "git" # extract version from git tags
74//! tag_prefix = "v"
75//!
76//! [changelog] # use the default changelog parser (Keepachangelog format in CHANGELOG.md)
77//!
78//! [[builders]]  # builds the project using cargo build --release
79//! type = "cargo"
80//! targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]
81//!
82//! [[builders]]  # generates documentation using cargo doc
83//! type = "cargo_doc"
84//! no_deps = true  # Don't include dependencies in the documentation
85//!
86//! [[builders]]
87//! type = "archive"  # creates a compressed tarball of the source code (can be of anything, really)
88//!
89//! [[builders]]
90//! type = "script"
91//! script = [
92//!  "echo $ABBAYE_BUILDING_VERSION > .version",
93//! ]
94//! outputs = [".version"]
95//! ```
96//!
97//! Then run `abbaye build` to build the site. The site will be generated in the `public/` directory by default.
98//! Now you can copy the contents of `public/` to your web server to deploy the site. For instance, with rsync:
99//! `rsync --progress -avz --links --perms --update public/ ololduck@vit.am:public_html/abbaye/`
100//!
101//! To have a look at all the available configuration options, please refer to the documentation of [`config::AbbayeConfig`].
102//!
103//! ### ✨ Customization ✨
104//!
105//! You can dump the default theme/templates to your local filesystem with `abbaye dump-theme`.
106//!
107//! This will create a `.abbaye/theme/` directory in your current directory with the default templates, which you can then ✨customize✨.
108//!
109//! If this directory contains a `static/` directory, it will be copied to the output directory. So you can add custom static assets to your site, and even use a separate CSS!
110//!
111//! ## Future plans
112//!
113//! - [x] Add support for theming
114//! - [ ] Add support for more site variables, such as the site title, description, and author, or even a custom footer and stuff.
115//! - [ ] 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`.
116//!
117//! ## Contributing
118//!
119//! 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.
120//!
121//! 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}.
122
123use std::path::PathBuf;
124
125use clap::CommandFactory;
126use clap::Parser;
127use human_panic::setup_panic;
128use miette::{IntoDiagnostic, Result};
129use tokio::fs::create_dir_all;
130use tracing::{info, warn};
131
132use crate::{
133    builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig},
134    changelog::ChangelogConfig,
135    config::{AbbayeConfig, SiteConfig},
136    version_extractors::{AnyVersionExtractor, git::GitVersionConfig},
137};
138
139/// All builders for the site (ex: cargo build, cargo doc, etc.).
140pub mod builders;
141/// Parses the changelog file and generates a changelog page for the site.
142pub mod changelog;
143mod cli;
144/// Handles the `abbaye.toml` configuration file.
145pub mod config;
146/// Generates the site from the configuration and builds it.
147pub mod site;
148/// Self-update logic: fetches the release feed and replaces the binary when a newer version exists.
149mod updater;
150/// Extracts current version information from different sources (ex: git tags, cargo metadata, etc.).
151pub mod version_extractors;
152
153/// Build the full website for every git tag, sorted from the lowest semver
154/// version to the highest.
155///
156/// For each tag the function:
157/// 1. Runs `git checkout <tag>` to switch the working tree.
158/// 2. Loads `abbaye.toml` from the checked-out revision (falling back to the
159///    config that was active before the loop if the file is absent).
160/// 3. Calls [`site::build_site`] to produce the version page and update the
161///    root index and Atom feed.
162///
163/// The original HEAD (branch or commit) is always restored after the loop,
164/// even when an error occurs.
165async fn build_all() -> Result<()> {
166    // Load the current config to discover the version extractor settings.
167    let base_config = config::load_config()?;
168
169    // `git for-each-ref --sort=version:refname` returns tags in semver order,
170    // lowest first, which is exactly the order we want.
171    let all_versions = base_config.version_extractor.extract_all().await?;
172    if all_versions.is_empty() {
173        info!("No tagged versions found – nothing to build.");
174        return Ok(());
175    }
176
177    // Remember where we are so we can restore it when we're done.
178    // Prefer the branch name (symbolic ref) so that checking it out
179    // afterwards leaves the user on their branch rather than in a
180    // detached-HEAD state.  Fall back to the raw commit SHA when HEAD
181    // is already detached.
182    let symref_out = tokio::process::Command::new("git")
183        .args(["symbolic-ref", "--short", "HEAD"])
184        .output()
185        .await
186        .into_diagnostic()?;
187    let original_head = if symref_out.status.success() {
188        // On a branch.
189        String::from_utf8(symref_out.stdout)
190            .into_diagnostic()?
191            .trim()
192            .to_owned()
193    } else {
194        // Detached HEAD — fall back to the commit SHA.
195        let sha_out = tokio::process::Command::new("git")
196            .args(["rev-parse", "HEAD"])
197            .output()
198            .await
199            .into_diagnostic()?;
200        if !sha_out.status.success() {
201            return Err(miette::miette!("Could not determine current HEAD"));
202        }
203        String::from_utf8(sha_out.stdout)
204            .into_diagnostic()?
205            .trim()
206            .to_owned()
207    };
208
209    let total = all_versions.len();
210    info!("Building {} version(s) …", total);
211
212    // Run the build loop; capture the result so we can restore HEAD first.
213    let loop_result = async {
214        for (i, version_info) in all_versions.iter().enumerate() {
215            let tag = base_config
216                .version_extractor
217                .tag_name(&version_info.version);
218
219            info!("[{}/{}] Checking out {} …", i + 1, total, tag);
220
221            let checkout = tokio::process::Command::new("git")
222                .args(["checkout", &tag])
223                .output()
224                .await
225                .into_diagnostic()?;
226            if !checkout.status.success() {
227                let stderr = String::from_utf8_lossy(&checkout.stderr);
228                return Err(miette::miette!("git checkout {tag} failed:\n{stderr}"));
229            }
230
231            // Reload `abbaye.toml` from the checked-out revision so the build
232            // uses that version's own configuration (builders, readme path,
233            // etc.).  If the file does not exist in this revision, fall back
234            // to the config we loaded before the loop.
235            let version_config = config::load_config().unwrap_or_else(|_| base_config.clone());
236
237            info!(
238                "[{}/{}] Building version {} …",
239                i + 1,
240                total,
241                version_info.version
242            );
243
244            site::build_site(version_config).await?;
245        }
246        Ok(())
247    }
248    .await;
249
250    // Always restore HEAD, regardless of whether the loop succeeded.
251    let restore = tokio::process::Command::new("git")
252        .args(["checkout", &original_head])
253        .output()
254        .await
255        .into_diagnostic()?;
256    if !restore.status.success() {
257        let stderr = String::from_utf8_lossy(&restore.stderr);
258        warn!("Could not restore HEAD to {original_head}:\n{stderr}");
259    }
260
261    loop_result?;
262    info!("Done. Built {total} version(s).");
263    Ok(())
264}
265
266#[tokio::main]
267async fn main() -> Result<()> {
268    setup_panic!();
269    let cli_args = cli::CliArgs::parse();
270
271    tracing_subscriber::fmt()
272        .with_timer(tracing_subscriber::fmt::time::SystemTime)
273        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
274        .init();
275
276    match cli_args.command {
277        cli::CliCommand::Init { path } => {
278            let base_path = if let Some(path) = path {
279                path
280            } else {
281                std::env::current_dir().into_diagnostic()?
282            };
283            if base_path.join("abbaye.toml").exists() {
284                return Err(miette::miette!(
285                    "abbaye.toml already exists in this directory"
286                ));
287            }
288            let abbaye_config = AbbayeConfig {
289                site: SiteConfig {
290                    name: "MyProject Release Page".to_string(),
291                    ..Default::default()
292                },
293                version_extractor: AnyVersionExtractor::Git(GitVersionConfig {
294                    tag_prefix: Some("v".to_string()),
295                    dirty_suffix: "-dirty".to_string(),
296                }),
297                builders: vec![BuilderEntry {
298                    builder: AnyBuilder::Archive(ArchiveBuilderConfig {
299                        source_dir: None,
300                        output: None,
301                        prefix: None,
302                        ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()],
303                    }),
304                    id: None,
305                    depends_on: vec![],
306                }],
307                changelog: ChangelogConfig {
308                    ..Default::default()
309                },
310            };
311            let config_path = base_path.join("abbaye.toml");
312            let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?;
313            tokio::fs::write(&config_path, toml)
314                .await
315                .into_diagnostic()?;
316        }
317        cli::CliCommand::Build => {
318            let config = config::load_config()?;
319            site::build_site(config).await?;
320        }
321        cli::CliCommand::BuildAll => {
322            build_all().await?;
323        }
324        cli::CliCommand::DumpSchema => {
325            // Emit a JSON Schema draft-07 document rather than the 2020-12
326            // default.  Taplo (and most TOML LSP tooling) validates against
327            // draft-07, which treats `$ref` as exclusive — it ignores any
328            // sibling keywords.  Draft-07 output from schemars wraps `$ref`
329            // in `allOf` instead, keeping the `const` type-discriminators
330            // visible to the validator and resolving `oneOf` ambiguity.
331            let generator = schemars::generate::SchemaSettings::draft07().into_generator();
332            let schema = generator.into_root_schema_for::<config::AbbayeConfig>();
333            println!(
334                "{}",
335                serde_json::to_string_pretty(&schema).into_diagnostic()?
336            );
337        }
338        cli::CliCommand::SelfUpdate { check } => {
339            updater::self_update(check).await?;
340        }
341        cli::CliCommand::UsageSpec => {
342            let mut cmd = cli::CliArgs::command();
343            clap_usage::generate(&mut cmd, "abbaye", &mut std::io::stdout());
344        }
345        cli::CliCommand::DumpTheme => {
346            let theme_path = PathBuf::from(".abbaye").join("theme");
347            create_dir_all(&theme_path).await.into_diagnostic()?;
348            tokio::fs::write(
349                theme_path.join("root_index.html.j2"),
350                site::TEMPLATE_ROOT_INDEX,
351            )
352            .await
353            .into_diagnostic()?;
354            tokio::fs::write(
355                theme_path.join("version_index.html.j2"),
356                site::TEMPLATE_VERSION_INDEX,
357            )
358            .await
359            .into_diagnostic()?;
360            tokio::fs::write(
361                theme_path.join("markdown.html.j2"),
362                builders::markdown::TEMPLATE_MARKDOWN,
363            )
364            .await
365            .into_diagnostic()?;
366        }
367    }
368    Ok(())
369}