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