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//! ├── 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//! ### Via mise
52//!
53//! If you use [mise](https://mise.jdx.dev), you can install abbaye via the included plugin:
54//!
55//! ```bash
56//! mise plugin install abbaye https://git.sr.ht/~ololduck/abbaye
57//! mise install abbaye
58//! ```
59//!
60//! Or in your `mise.toml`:
61//!
62//! ```toml
63//! [tools]
64//! abbaye = "latest"
65//!
66//! [plugins]
67//! abbaye = "https://git.sr.ht/~ololduck/abbaye"
68//! ```
69//!
70//! The plugin downloads pre-built binaries from the [releases page](http://vit.am/~ololduck/abbaye/latest) - no compilation needed.
71//!
72//! ### Pre-built binaries
73//!
74//! You can grab a pre-built binary from the [releases page](http://vit.am/~ololduck/abbaye/latest).
75//!
76//! 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).
77//!
78//! ### From source
79//!
80//! To build from source, you need to have Rust installed. You can install Rust using [rustup](https://rustup.rs/).
81//!
82//! 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 .`
83//!
84//! ## Usage
85//!
86//! Run `abbaye init` in your project's directory to create a `abbaye.toml` configuration file. You can then customize the configuration to your liking.
87//!
88//! ## CLI commands
89//!
90//! | Command | Description |
91//! |---------|-------------|
92//! | `init` | Create a new `abbaye.toml` file. |
93//! | `build` | Build the site. Use `--repository-only` to skip version pages and only rebuild the git UI. |
94//! | `build-all` | Build the site for every git tag, from lowest to highest semver. Checks out each tag in order, builds, then restores HEAD. |
95//! | `dump-theme` | Dump the default theme templates to `.abbaye/theme/` (only templates for formats enabled in `[site].formats`). |
96//! | `dump-schema` | Print a JSON Schema for `abbaye.toml` to stdout (redirect to `abbaye.schema.json` for IDE autocompletion). |
97//! | `self-update` | Check for and install the latest release. Add `--check` to only check without downloading. |
98//!
99//! ## Configuration
100//!
101//! Here's an example configuration file to get you started:
102//!
103//! ```toml
104//! [site]
105//! name = "Abbaye"
106//! # required for Atom feed generation (canonical URLs are used for feed items)
107//! base_url = "http://vit.am/~ololduck/abbaye/"
108//! # URL of the project's repository (shown on release pages)
109//! repo_url = "https://git.sr.ht/~ololduck/abbaye"
110//! # Output formats: "html", "gemtext", or both (default: ["html"])
111//! formats = ["html", "gemtext"]
112//! # Fediverse handle for social meta tags
113//! fediverse_creator = "@ololduck@vit.am"
114//!
115//! [site.opengraph]
116//! image = "latest/logo.svg"
117//! image_alt = "Abbaye logo"
118//!
119//! [version_extractor]
120//! type = "git" # extract version from git tags
121//! tag_prefix = "v"
122//!
123//! [changelog] # use the default changelog parser (Keepachangelog format in CHANGELOG.md)
124//!
125//! [[builders]]  # builds the project using cargo build --release
126//! type = "cargo"
127//! targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]
128//! category = "Binaries"          # groups artifacts under this heading
129//! name = "Pre-built binaries"    # optional display name on the release page
130//! comment = "Built for Linux x86_64 (glibc and musl)."
131//!
132//! [[builders]]  # generates documentation using cargo doc
133//! type = "cargo_doc"
134//! no_deps = true  # Don't include dependencies in the documentation
135//!
136//! [[builders]]
137//! # creates a compressed tarball of the source code (can be of anything, really)
138//! type = "archive"
139//! category = "Source"
140//!
141//! [[builders]]
142//! # Just an example dumb script to showcase the `script` builder type
143//! type = "script"
144//! script = [
145//!  "echo $ABBAYE_BUILDING_VERSION > .version",
146//! ]
147//! outputs = [".version"]
148//! ```
149//!
150//! Builders run **concurrently** by default. To enforce ordering, assign an `id`
151//! and reference it in `depends_on`:
152//!
153//! ```toml
154//! [[builders]]
155//! id = "compile"
156//! type = "cargo"
157//!
158//! [[builders]]
159//! depends_on = ["compile"]
160//! type = "script"
161//! script = ["strip target/release/mybin"]
162//! outputs = ["target/release/mybin"]
163//! ```
164//!
165//! Then run `abbaye build` to build the site. The site will be generated in the `public/` directory by default.
166//! Now you can copy the contents of `public/` to your web server to deploy the site. For instance, with rsync:
167//! `rsync --progress -avz --links --perms --update public/ ololduck@vit.am:public_html/abbaye/`
168//!
169//! To have a look at all the available configuration options, please refer to the documentation of [`config::AbbayeConfig`].
170//!
171//! ### A note on the repository UI
172//!
173//! The repository UI is **NOT** enabled by default. It must be enabled explicitly in the configuration with the following:
174//!
175//! ```toml
176//! [git_ui] # only this section is needed to enable the repository UI
177//! ```
178//! It has multiple options (each presented with their defaults, if any):
179//!
180//! ```toml
181//! [git_ui]
182//! default_branch = "main"
183//! max_commits = 200
184//! repo_path = "."
185//! clone_url = "{{ site.base_url }}/repository.git"
186//! # Glob patterns for refs to EXCLUDE from the UI (optional).
187//! exclude = ["gh-pages", "tmp/*"]
188//! # Glob patterns for refs to INCLUDE (optional, acts as an allowlist).
189//! include = ["main", "v*"]
190//! ```
191//!
192//! 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).
193//!
194//! ### ✨ Customization ✨
195//!
196//! You can dump the default theme/templates to your local filesystem with `abbaye dump-theme`.
197//!
198//! This will create a `.abbaye/theme/` directory in your current directory with the default templates
199//! (only for the output formats enabled in `[site].formats`), which you can then ✨customize✨.
200//!
201//! If you don't want to customize every template, simply delete the ones you don't want to change.
202//!
203//! 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.
204//!
205//! ## Future plans
206//!
207//! - [x] Add support for theming
208//! - [ ] Add support for more site variables, such as the site title, description, and author, or even a custom footer and stuff.
209//!   - I added OpenGraph support, does that count?
210//! - [x] Add support for a `self-update`-like command to update the abbaye binary to the latest version.
211//!
212//! ## Contributing
213//!
214//! 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.
215//!
216//! Just clone the repository and {send me an email,contact me on {IRC (ololduck@irc.libera.chat),the Fediverse (@ololduck@vit.am)} with {a link to your fork,a git patch,compliments and adoration}.
217
218use std::path::PathBuf;
219
220use clap::CommandFactory;
221use clap::Parser;
222use human_panic::setup_panic;
223use miette::{IntoDiagnostic, Result};
224use tokio::fs::create_dir_all;
225use tracing::{info, warn};
226
227use crate::{
228    builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig},
229    changelog::ChangelogConfig,
230    config::{AbbayeConfig, SiteConfig},
231    version_extractors::{AnyVersionExtractor, git::GitVersionConfig},
232};
233
234/// All builders for the site (ex: cargo build, cargo doc, etc.).
235pub mod builders;
236/// Parses the changelog file and generates a changelog page for the site.
237pub mod changelog;
238/// Stuff related to the CLI interface. Also contains colour escape codes for terminal output.
239pub mod cli;
240/// Handles the `abbaye.toml` configuration file.
241pub mod config;
242/// Static tree browser for git repositories.
243pub mod git_browse;
244/// Generates a static git web UI and clonable bare repository.
245pub mod git_ui;
246/// Markdown rendering (HTML and Gemtext).
247pub mod render;
248/// Generates the site from the configuration and builds it.
249pub mod site;
250/// Self-update logic: fetches the release feed and replaces the binary when a newer version exists.
251mod updater;
252/// Extracts current version information from different sources (ex: git tags, cargo metadata, etc.).
253pub mod version_extractors;
254
255pub mod utils;
256
257/// Build the full website for every git tag, sorted from the lowest semver
258/// version to the highest.
259///
260/// For each tag the function:
261/// 1. Runs `git checkout <tag>` to switch the working tree.
262/// 2. Loads `abbaye.toml` from the checked-out revision (falling back to the
263///    config that was active before the loop if the file is absent).
264/// 3. Calls [`site::build_site`] to produce the version page and update the
265///    root index and Atom feed.
266///
267/// The original HEAD (branch or commit) is always restored after the loop,
268/// even when an error occurs.
269async fn build_all() -> Result<()> {
270    // Load the current config to discover the version extractor settings.
271    let base_config = config::load_config()?;
272
273    // `git for-each-ref --sort=version:refname` returns tags in semver order,
274    // lowest first, which is exactly the order we want.
275    let all_versions = base_config.version_extractor.extract_all().await?;
276    if all_versions.is_empty() {
277        info!("No tagged versions found – nothing to build.");
278        return Ok(());
279    }
280
281    // Remember where we are so we can restore it when we're done.
282    // Prefer the branch name (symbolic ref) so that checking it out
283    // afterwards leaves the user on their branch rather than in a
284    // detached-HEAD state.  Fall back to the raw commit SHA when HEAD
285    // is already detached.
286    let symref_out = tokio::process::Command::new("git")
287        .args(["symbolic-ref", "--short", "HEAD"])
288        .output()
289        .await
290        .into_diagnostic()?;
291    let original_head = if symref_out.status.success() {
292        // On a branch.
293        String::from_utf8(symref_out.stdout)
294            .into_diagnostic()?
295            .trim()
296            .to_owned()
297    } else {
298        // Detached HEAD - fall back to the commit SHA.
299        let sha_out = tokio::process::Command::new("git")
300            .args(["rev-parse", "HEAD"])
301            .output()
302            .await
303            .into_diagnostic()?;
304        if !sha_out.status.success() {
305            return Err(miette::miette!("Could not determine current HEAD"));
306        }
307        String::from_utf8(sha_out.stdout)
308            .into_diagnostic()?
309            .trim()
310            .to_owned()
311    };
312
313    let total = all_versions.len();
314    info!("Building {} version(s) …", total);
315
316    // Run the build loop; capture the result so we can restore HEAD first.
317    let loop_result = async {
318        for (i, version_info) in all_versions.iter().enumerate() {
319            let tag = base_config
320                .version_extractor
321                .tag_name(&version_info.version);
322
323            info!("[{}/{}] Checking out {} …", i + 1, total, tag);
324
325            let checkout = tokio::process::Command::new("git")
326                .args(["checkout", &tag])
327                .output()
328                .await
329                .into_diagnostic()?;
330            if !checkout.status.success() {
331                let stderr = String::from_utf8_lossy(&checkout.stderr);
332                return Err(miette::miette!("git checkout {tag} failed:\n{stderr}"));
333            }
334
335            // Reload `abbaye.toml` from the checked-out revision so the build
336            // uses that version's own configuration (builders, readme path,
337            // etc.).  If the file does not exist in this revision, fall back
338            // to the config we loaded before the loop.
339            let version_config = config::load_config().unwrap_or_else(|_| base_config.clone());
340
341            info!(
342                "[{}/{}] Building version {} …",
343                i + 1,
344                total,
345                version_info.version
346            );
347
348            site::build_site(version_config).await?;
349        }
350        Ok(())
351    }
352    .await;
353
354    // Always restore HEAD, regardless of whether the loop succeeded.
355    let restore = tokio::process::Command::new("git")
356        .args(["checkout", &original_head])
357        .output()
358        .await
359        .into_diagnostic()?;
360    if !restore.status.success() {
361        let stderr = String::from_utf8_lossy(&restore.stderr);
362        warn!("Could not restore HEAD to {original_head}:\n{stderr}");
363    }
364
365    loop_result?;
366
367    // Build the git repository UI once, now that HEAD is restored.
368    if let Some(git_ui_cfg) = &base_config.git_ui {
369        git_ui::build_git_repository_ui(&base_config, git_ui_cfg).await?;
370    }
371
372    info!("Done. Built {total} version(s).");
373    Ok(())
374}
375
376#[tokio::main]
377async fn main() -> Result<()> {
378    setup_panic!();
379    let cli_args = cli::CliArgs::parse();
380
381    let default_level = match cli_args.verbose {
382        0 => "warn",
383        1 => "info",
384        _ => "debug",
385    };
386    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| default_level.to_string());
387    tracing_subscriber::fmt()
388        .with_timer(tracing_subscriber::fmt::time::SystemTime)
389        .with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_filter))
390        .init();
391
392    match cli_args.command {
393        cli::CliCommand::Init { path } => {
394            let base_path = if let Some(path) = path {
395                path
396            } else {
397                std::env::current_dir().into_diagnostic()?
398            };
399            if base_path.join("abbaye.toml").exists() {
400                return Err(miette::miette!(
401                    "abbaye.toml already exists in this directory"
402                ));
403            }
404            let abbaye_config = AbbayeConfig {
405                site: SiteConfig {
406                    name: "MyProject Release Page".to_string(),
407                    ..Default::default()
408                },
409                version_extractor: AnyVersionExtractor::Git(GitVersionConfig {
410                    tag_prefix: Some("v".to_string()),
411                    dirty_suffix: "-dirty".to_string(),
412                }),
413                builders: vec![BuilderEntry {
414                    builder: AnyBuilder::Archive(ArchiveBuilderConfig {
415                        source_dir: None,
416                        output: None,
417                        prefix: None,
418                        ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()],
419                    }),
420                    id: None,
421                    depends_on: vec![],
422                    category: None,
423                    name: None,
424                    comment: None,
425                }],
426                changelog: ChangelogConfig {
427                    ..Default::default()
428                },
429                git_ui: None,
430            };
431            let config_path = base_path.join("abbaye.toml");
432            let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?;
433            tokio::fs::write(&config_path, toml)
434                .await
435                .into_diagnostic()?;
436        }
437        cli::CliCommand::Build { repository_only } => {
438            let config = config::load_config()?;
439            if repository_only {
440                match &config.git_ui {
441                    Some(git_ui_cfg) => {
442                        info!("Building repository UI only …");
443                        git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
444                    }
445                    None => {
446                        return Err(miette::miette!(
447                            "--repository-only requires [git_ui] to be configured in abbaye.toml"
448                        ));
449                    }
450                }
451            } else {
452                info!("Building site …");
453                site::build_site(config.clone()).await?;
454                if let Some(git_ui_cfg) = &config.git_ui {
455                    info!("Building repository UI …");
456                    git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
457                }
458                info!("Build complete.");
459            }
460        }
461        cli::CliCommand::BuildAll => {
462            build_all().await?;
463        }
464        cli::CliCommand::DumpSchema => {
465            // Emit a JSON Schema draft-07 document rather than the 2020-12
466            // default.  Taplo (and most TOML LSP tooling) validates against
467            // draft-07, which treats `$ref` as exclusive - it ignores any
468            // sibling keywords.  Draft-07 output from schemars wraps `$ref`
469            // in `allOf` instead, keeping the `const` type-discriminators
470            // visible to the validator and resolving `oneOf` ambiguity.
471            let generator = schemars::generate::SchemaSettings::draft07().into_generator();
472            let schema = generator.into_root_schema_for::<config::AbbayeConfig>();
473            println!(
474                "{}",
475                serde_json::to_string_pretty(&schema).into_diagnostic()?
476            );
477        }
478        cli::CliCommand::SelfUpdate { check } => {
479            updater::self_update(check).await?;
480        }
481        cli::CliCommand::UsageSpec => {
482            let mut cmd = cli::CliArgs::command();
483            clap_usage::generate(&mut cmd, "abbaye", &mut std::io::stdout());
484        }
485        cli::CliCommand::DumpTheme => {
486            let config = config::load_config()?;
487            let formats = &config.site.formats;
488            let html = formats.contains(&config::OutputFormat::Html);
489            let gemtext = formats.contains(&config::OutputFormat::Gemtext);
490
491            let theme_path = PathBuf::from(".abbaye").join("theme");
492            create_dir_all(&theme_path).await.into_diagnostic()?;
493
494            // Site templates
495            if html {
496                tokio::fs::write(theme_path.join("base.html.j2"), site::TEMPLATE_BASE_HTML)
497                    .await
498                    .into_diagnostic()?;
499                tokio::fs::write(
500                    theme_path.join("root_index.html.j2"),
501                    site::TEMPLATE_ROOT_INDEX_HTML,
502                )
503                .await
504                .into_diagnostic()?;
505                tokio::fs::write(
506                    theme_path.join("version_index.html.j2"),
507                    site::TEMPLATE_VERSION_INDEX_HTML,
508                )
509                .await
510                .into_diagnostic()?;
511            }
512            if gemtext {
513                tokio::fs::write(
514                    theme_path.join("root_index.gmi.j2"),
515                    site::TEMPLATE_ROOT_INDEX_GEMTEXT,
516                )
517                .await
518                .into_diagnostic()?;
519                tokio::fs::write(
520                    theme_path.join("version_index.gmi.j2"),
521                    site::TEMPLATE_VERSION_INDEX_GEMTEXT,
522                )
523                .await
524                .into_diagnostic()?;
525            }
526
527            // Markdown builder templates
528            if html {
529                tokio::fs::write(
530                    theme_path.join("markdown.html.j2"),
531                    builders::markdown::TEMPLATE_MARKDOWN_HTML,
532                )
533                .await
534                .into_diagnostic()?;
535            }
536            if gemtext {
537                tokio::fs::write(
538                    theme_path.join("markdown.gmi.j2"),
539                    builders::markdown::TEMPLATE_MARKDOWN_GEMTEXT,
540                )
541                .await
542                .into_diagnostic()?;
543            }
544
545            // Git UI templates
546            if html {
547                tokio::fs::write(
548                    theme_path.join("git_log.html.j2"),
549                    git_ui::TEMPLATE_GIT_LOG_HTML,
550                )
551                .await
552                .into_diagnostic()?;
553                tokio::fs::write(
554                    theme_path.join("git_commit.html.j2"),
555                    git_ui::TEMPLATE_GIT_COMMIT_HTML,
556                )
557                .await
558                .into_diagnostic()?;
559                tokio::fs::write(
560                    theme_path.join("git_refs.html.j2"),
561                    git_ui::TEMPLATE_GIT_REFS_HTML,
562                )
563                .await
564                .into_diagnostic()?;
565                tokio::fs::write(
566                    theme_path.join("git_tree.html.j2"),
567                    git_ui::TEMPLATE_GIT_TREE_HTML,
568                )
569                .await
570                .into_diagnostic()?;
571                tokio::fs::write(
572                    theme_path.join("git_blob.html.j2"),
573                    git_ui::TEMPLATE_GIT_BLOB_HTML,
574                )
575                .await
576                .into_diagnostic()?;
577            }
578            if gemtext {
579                tokio::fs::write(
580                    theme_path.join("git_log.gmi.j2"),
581                    git_ui::TEMPLATE_GIT_LOG_GEMTEXT,
582                )
583                .await
584                .into_diagnostic()?;
585                tokio::fs::write(
586                    theme_path.join("git_commit.gmi.j2"),
587                    git_ui::TEMPLATE_GIT_COMMIT_GEMTEXT,
588                )
589                .await
590                .into_diagnostic()?;
591                tokio::fs::write(
592                    theme_path.join("git_refs.gmi.j2"),
593                    git_ui::TEMPLATE_GIT_REFS_GEMTEXT,
594                )
595                .await
596                .into_diagnostic()?;
597                tokio::fs::write(
598                    theme_path.join("git_tree.gmi.j2"),
599                    git_ui::TEMPLATE_GIT_TREE_GEMTEXT,
600                )
601                .await
602                .into_diagnostic()?;
603                tokio::fs::write(
604                    theme_path.join("git_blob.gmi.j2"),
605                    git_ui::TEMPLATE_GIT_BLOB_GEMTEXT,
606                )
607                .await
608                .into_diagnostic()?;
609            }
610
611            // Static assets (CSS)
612            {
613                let static_dir = theme_path.join("static");
614                create_dir_all(&static_dir).await.into_diagnostic()?;
615                tokio::fs::write(static_dir.join("site.css"), site::SITE_CSS)
616                    .await
617                    .into_diagnostic()?;
618            }
619        }
620    }
621    Ok(())
622}