at f73fb3b
//! # Abbaye //! //!  //! //! Abbaye is a Static Site Generator (SSG) for your software. As GitHub, //! Gitea, Forgejo and consorts offer, Abbaye can be used to generate a //! website with your software's presentation, documentation, and distribution, per version. //! //! Here's an example file structure: //! //! ```text //! . //! ├── 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?) //! ├── releases.feed # the RSS feed of the releases //! ├── repository/ # the directory containing the repository UI (if enabled in config) //! ├── repository.git # Clonable git repository //! ├── latest -> v2.0.0 # symlink to the latest version (biggest version number) //! ├── v1.0.0/ # the directory containing the version 1.0.0 of the software //! │ ├── index.html # the main page of the version 1.0.0, from the README.md file. //! │ │ # Contains a sidebar with links to the documentation and distribution. //! │ │ # After the readme content, A changelog is displayed. //! │ ├── docs/ # the directory containing the documentation of the version 1.0.0 //! │ │ ├── index.html # the main page of the documentation of the version 1.0.0 //! │ │ └── … //! │ ├── docs.tar.gz # the tarball containing the documentation of the version 1.0.0 //! │ └── dist/ # the directory containing the distribution of the version 1.0.0 //! │ ├── source.tgz # the source code of the version 1.0.0 //! │ ├── mybin-v1.0.0-x86_64-unknown-linux-gnu //! │ └── mybin-v1.0.0-x86_64-unknown-linux-musl //! └── v2.0.0/ # the directory containing the version 2.0.0 of the software //! ├── index.html # the main page of the version 2.0.0 //! ├── … //! └── … //! ``` //! //! ## Why ? //! //! This piece of software is for people that can't or won't use a full-featured forge such as GitHub, GitLab, ForgeJo & others. //! These forges provide "release pages" that allow you to upload and distribute your software, as well as get a changelog. //! //! Abbaye is made to be a simple, lightweight alternative to these forges, for the release/documentation parts. //! //! ### Why "Abbaye" ? //! //! [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. //! //! Anyway, that's where you store and display your relics (your software releases). //! //! ## Installation //! //! ### Pre-built binaries //! //! You can grab a pre-built binary from the [releases page](http://vit.am/~ololduck/abbaye/latest). //! //! 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). //! //! ### From source //! //! To build from source, you need to have Rust installed. You can install Rust using [rustup](https://rustup.rs/). //! //! 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 .` //! //! ## Usage //! //! Run `abbaye init` in your project's directory to create a `abbaye.toml` configuration file. You can then customize the configuration to your liking. //! //! ## CLI commands //! //! | Command | Description | //! |---------|-------------| //! | `init` | Create a new `abbaye.toml` file. | //! | `build` | Build the site. Use `--repository-only` to skip version pages and only rebuild the git UI. | //! | `build-all` | Build the site for every git tag, from lowest to highest semver. Checks out each tag in order, builds, then restores HEAD. | //! | `dump-theme` | Dump the default theme templates to `.abbaye/theme/` (only templates for formats enabled in `[site].formats`). | //! | `dump-schema` | Print a JSON Schema for `abbaye.toml` to stdout (redirect to `abbaye.schema.json` for IDE autocompletion). | //! | `self-update` | Check for and install the latest release. Add `--check` to only check without downloading. | //! //! ## Configuration //! //! Here's an example configuration file to get you started: //! //! ```toml //! [site] //! name = "Abbaye" //! # required for Atom feed generation (canonical URLs are used for feed items) //! base_url = "http://vit.am/~ololduck/abbaye/" //! # URL of the project's repository (shown on release pages) //! repo_url = "https://git.sr.ht/~ololduck/abbaye" //! # Output formats: "html", "gemtext", or both (default: ["html"]) //! formats = ["html", "gemtext"] //! # Fediverse handle for social meta tags //! fediverse_creator = "@ololduck@vit.am" //! //! [site.opengraph] //! image = "latest/logo.svg" //! image_alt = "Abbaye logo" //! //! [version_extractor] //! type = "git" # extract version from git tags //! tag_prefix = "v" //! //! [changelog] # use the default changelog parser (Keepachangelog format in CHANGELOG.md) //! //! [[builders]] # builds the project using cargo build --release //! type = "cargo" //! targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] //! category = "Binaries" # groups artifacts under this heading //! name = "Pre-built binaries" # optional display name on the release page //! comment = "Built for Linux x86_64 (glibc and musl)." //! //! [[builders]] # generates documentation using cargo doc //! type = "cargo_doc" //! no_deps = true # Don't include dependencies in the documentation //! //! [[builders]] //! # creates a compressed tarball of the source code (can be of anything, really) //! type = "archive" //! category = "Source" //! //! [[builders]] //! # Just an example dumb script to showcase the `script` builder type //! type = "script" //! script = [ //! "echo $ABBAYE_BUILDING_VERSION > .version", //! ] //! outputs = [".version"] //! ``` //! //! Builders run **concurrently** by default. To enforce ordering, assign an `id` //! and reference it in `depends_on`: //! //! ```toml //! [[builders]] //! id = "compile" //! type = "cargo" //! //! [[builders]] //! depends_on = ["compile"] //! type = "script" //! script = ["strip target/release/mybin"] //! outputs = ["target/release/mybin"] //! ``` //! //! Then run `abbaye build` to build the site. The site will be generated in the `public/` directory by default. //! Now you can copy the contents of `public/` to your web server to deploy the site. For instance, with rsync: //! `rsync --progress -avz --links --perms --update public/ ololduck@vit.am:public_html/abbaye/` //! //! To have a look at all the available configuration options, please refer to the documentation of [`config::AbbayeConfig`]. //! //! ### A note on the repository UI //! //! The repository UI is **NOT** enabled by default. It must be enabled explicitly in the configuration with the following: //! //! ```toml //! [git_ui] # only this section is needed to enable the repository UI //! ``` //! It has multiple options (each presented with their defaults, if any): //! //! ```toml //! [git_ui] //! default_branch = "main" //! max_commits = 200 //! repo_path = "." //! clone_url = "{{ site.base_url }}/repository.git" //! # Glob patterns for refs to EXCLUDE from the UI (optional). //! exclude = ["gh-pages", "tmp/*"] //! # Glob patterns for refs to INCLUDE (optional, acts as an allowlist). //! include = ["main", "v*"] //! ``` //! //! 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). //! //! ### ✨ Customization ✨ //! //! You can dump the default theme/templates to your local filesystem with `abbaye dump-theme`. //! //! This will create a `.abbaye/theme/` directory in your current directory with the default templates //! (only for the output formats enabled in `[site].formats`), which you can then ✨customize✨. //! //! If you don't want to customize every template, simply delete the ones you don't want to change. //! //! 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. //! //! ## Future plans //! //! - [x] Add support for theming //! - [ ] Add support for more site variables, such as the site title, description, and author, or even a custom footer and stuff. //! - I added OpenGraph support, does that count? //! - [x] Add support for a `self-update`-like command to update the abbaye binary to the latest version. //! //! ## Contributing //! //! 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. //! //! 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}. use std::path::PathBuf; use clap::CommandFactory; use clap::Parser; use human_panic::setup_panic; use miette::{IntoDiagnostic, Result}; use tokio::fs::create_dir_all; use tracing::{info, warn}; use crate::{ builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig}, changelog::ChangelogConfig, config::{AbbayeConfig, SiteConfig}, version_extractors::{AnyVersionExtractor, git::GitVersionConfig}, }; /// All builders for the site (ex: cargo build, cargo doc, etc.). pub mod builders; /// Parses the changelog file and generates a changelog page for the site. pub mod changelog; /// Stuff related to the CLI interface. Also contains colour escape codes for terminal output. pub mod cli; /// Handles the `abbaye.toml` configuration file. pub mod config; /// Static tree browser for git repositories. pub mod git_browse; /// Generates a static git web UI and clonable bare repository. pub mod git_ui; /// Markdown rendering (HTML and Gemtext). pub mod render; /// Generates the site from the configuration and builds it. pub mod site; /// Self-update logic: fetches the release feed and replaces the binary when a newer version exists. mod updater; /// Extracts current version information from different sources (ex: git tags, cargo metadata, etc.). pub mod version_extractors; pub mod utils; /// Build the full website for every git tag, sorted from the lowest semver /// version to the highest. /// /// For each tag the function: /// 1. Runs `git checkout <tag>` to switch the working tree. /// 2. Loads `abbaye.toml` from the checked-out revision (falling back to the /// config that was active before the loop if the file is absent). /// 3. Calls [`site::build_site`] to produce the version page and update the /// root index and Atom feed. /// /// The original HEAD (branch or commit) is always restored after the loop, /// even when an error occurs. async fn build_all() -> Result<()> { // Load the current config to discover the version extractor settings. let base_config = config::load_config()?; // `git for-each-ref --sort=version:refname` returns tags in semver order, // lowest first, which is exactly the order we want. let all_versions = base_config.version_extractor.extract_all().await?; if all_versions.is_empty() { info!("No tagged versions found – nothing to build."); return Ok(()); } // Remember where we are so we can restore it when we're done. // Prefer the branch name (symbolic ref) so that checking it out // afterwards leaves the user on their branch rather than in a // detached-HEAD state. Fall back to the raw commit SHA when HEAD // is already detached. let symref_out = tokio::process::Command::new("git") .args(["symbolic-ref", "--short", "HEAD"]) .output() .await .into_diagnostic()?; let original_head = if symref_out.status.success() { // On a branch. String::from_utf8(symref_out.stdout) .into_diagnostic()? .trim() .to_owned() } else { // Detached HEAD - fall back to the commit SHA. let sha_out = tokio::process::Command::new("git") .args(["rev-parse", "HEAD"]) .output() .await .into_diagnostic()?; if !sha_out.status.success() { return Err(miette::miette!("Could not determine current HEAD")); } String::from_utf8(sha_out.stdout) .into_diagnostic()? .trim() .to_owned() }; let total = all_versions.len(); info!("Building {} version(s) …", total); // Run the build loop; capture the result so we can restore HEAD first. let loop_result = async { for (i, version_info) in all_versions.iter().enumerate() { let tag = base_config .version_extractor .tag_name(&version_info.version); info!("[{}/{}] Checking out {} …", i + 1, total, tag); let checkout = tokio::process::Command::new("git") .args(["checkout", &tag]) .output() .await .into_diagnostic()?; if !checkout.status.success() { let stderr = String::from_utf8_lossy(&checkout.stderr); return Err(miette::miette!("git checkout {tag} failed:\n{stderr}")); } // Reload `abbaye.toml` from the checked-out revision so the build // uses that version's own configuration (builders, readme path, // etc.). If the file does not exist in this revision, fall back // to the config we loaded before the loop. let version_config = config::load_config().unwrap_or_else(|_| base_config.clone()); info!( "[{}/{}] Building version {} …", i + 1, total, version_info.version ); site::build_site(version_config).await?; } Ok(()) } .await; // Always restore HEAD, regardless of whether the loop succeeded. let restore = tokio::process::Command::new("git") .args(["checkout", &original_head]) .output() .await .into_diagnostic()?; if !restore.status.success() { let stderr = String::from_utf8_lossy(&restore.stderr); warn!("Could not restore HEAD to {original_head}:\n{stderr}"); } loop_result?; // Build the git repository UI once, now that HEAD is restored. if let Some(git_ui_cfg) = &base_config.git_ui { git_ui::build_git_repository_ui(&base_config, git_ui_cfg).await?; } info!("Done. Built {total} version(s)."); Ok(()) } #[tokio::main] async fn main() -> Result<()> { setup_panic!(); let cli_args = cli::CliArgs::parse(); let default_level = match cli_args.verbose { 0 => "warn", 1 => "info", _ => "debug", }; let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| default_level.to_string()); tracing_subscriber::fmt() .with_timer(tracing_subscriber::fmt::time::SystemTime) .with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_filter)) .init(); match cli_args.command { cli::CliCommand::Init { path } => { let base_path = if let Some(path) = path { path } else { std::env::current_dir().into_diagnostic()? }; if base_path.join("abbaye.toml").exists() { return Err(miette::miette!( "abbaye.toml already exists in this directory" )); } let abbaye_config = AbbayeConfig { site: SiteConfig { name: "MyProject Release Page".to_string(), ..Default::default() }, version_extractor: AnyVersionExtractor::Git(GitVersionConfig { tag_prefix: Some("v".to_string()), dirty_suffix: "-dirty".to_string(), }), builders: vec![BuilderEntry { builder: AnyBuilder::Archive(ArchiveBuilderConfig { source_dir: None, output: None, prefix: None, ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()], }), id: None, depends_on: vec![], category: None, name: None, comment: None, }], changelog: ChangelogConfig { ..Default::default() }, git_ui: None, }; let config_path = base_path.join("abbaye.toml"); let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?; tokio::fs::write(&config_path, toml) .await .into_diagnostic()?; } cli::CliCommand::Build { repository_only } => { let config = config::load_config()?; if repository_only { match &config.git_ui { Some(git_ui_cfg) => { info!("Building repository UI only …"); git_ui::build_git_repository_ui(&config, git_ui_cfg).await?; } None => { return Err(miette::miette!( "--repository-only requires [git_ui] to be configured in abbaye.toml" )); } } } else { info!("Building site …"); site::build_site(config.clone()).await?; if let Some(git_ui_cfg) = &config.git_ui { info!("Building repository UI …"); git_ui::build_git_repository_ui(&config, git_ui_cfg).await?; } info!("Build complete."); } } cli::CliCommand::BuildAll => { build_all().await?; } cli::CliCommand::DumpSchema => { // Emit a JSON Schema draft-07 document rather than the 2020-12 // default. Taplo (and most TOML LSP tooling) validates against // draft-07, which treats `$ref` as exclusive - it ignores any // sibling keywords. Draft-07 output from schemars wraps `$ref` // in `allOf` instead, keeping the `const` type-discriminators // visible to the validator and resolving `oneOf` ambiguity. let generator = schemars::generate::SchemaSettings::draft07().into_generator(); let schema = generator.into_root_schema_for::<config::AbbayeConfig>(); println!( "{}", serde_json::to_string_pretty(&schema).into_diagnostic()? ); } cli::CliCommand::SelfUpdate { check } => { updater::self_update(check).await?; } cli::CliCommand::UsageSpec => { let mut cmd = cli::CliArgs::command(); clap_usage::generate(&mut cmd, "abbaye", &mut std::io::stdout()); } cli::CliCommand::DumpTheme => { let config = config::load_config()?; let formats = &config.site.formats; let html = formats.contains(&config::OutputFormat::Html); let gemtext = formats.contains(&config::OutputFormat::Gemtext); let theme_path = PathBuf::from(".abbaye").join("theme"); create_dir_all(&theme_path).await.into_diagnostic()?; // Site templates if html { tokio::fs::write(theme_path.join("base.html.j2"), site::TEMPLATE_BASE_HTML) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("root_index.html.j2"), site::TEMPLATE_ROOT_INDEX_HTML, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("version_index.html.j2"), site::TEMPLATE_VERSION_INDEX_HTML, ) .await .into_diagnostic()?; } if gemtext { tokio::fs::write( theme_path.join("root_index.gmi.j2"), site::TEMPLATE_ROOT_INDEX_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("version_index.gmi.j2"), site::TEMPLATE_VERSION_INDEX_GEMTEXT, ) .await .into_diagnostic()?; } // Markdown builder templates if html { tokio::fs::write( theme_path.join("markdown.html.j2"), builders::markdown::TEMPLATE_MARKDOWN_HTML, ) .await .into_diagnostic()?; } if gemtext { tokio::fs::write( theme_path.join("markdown.gmi.j2"), builders::markdown::TEMPLATE_MARKDOWN_GEMTEXT, ) .await .into_diagnostic()?; } // Git UI templates if html { tokio::fs::write( theme_path.join("git_log.html.j2"), git_ui::TEMPLATE_GIT_LOG_HTML, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_commit.html.j2"), git_ui::TEMPLATE_GIT_COMMIT_HTML, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_refs.html.j2"), git_ui::TEMPLATE_GIT_REFS_HTML, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_tree.html.j2"), git_ui::TEMPLATE_GIT_TREE_HTML, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_blob.html.j2"), git_ui::TEMPLATE_GIT_BLOB_HTML, ) .await .into_diagnostic()?; } if gemtext { tokio::fs::write( theme_path.join("git_log.gmi.j2"), git_ui::TEMPLATE_GIT_LOG_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_commit.gmi.j2"), git_ui::TEMPLATE_GIT_COMMIT_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_refs.gmi.j2"), git_ui::TEMPLATE_GIT_REFS_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_tree.gmi.j2"), git_ui::TEMPLATE_GIT_TREE_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_blob.gmi.j2"), git_ui::TEMPLATE_GIT_BLOB_GEMTEXT, ) .await .into_diagnostic()?; } // Static assets (CSS) { let static_dir = theme_path.join("static"); create_dir_all(&static_dir).await.into_diagnostic()?; tokio::fs::write(static_dir.join("site.css"), site::SITE_CSS) .await .into_diagnostic()?; } } } Ok(()) }