crates/nornir-docgen/src/pages.rs
//! Codeberg Pages publishing — assemble (and force-push) an orphan `pages`
//! branch that serves a repo's rendered `docs/book.pdf` at
//! `https://nordisk.codeberg.page/<repo>/`.
//!
//! The branch holds EXACTLY three files — no source, no history carry-over:
//!
//! ```text
//! .nojekyll (empty marker — tells Codeberg Pages to skip Jekyll)
//! book.pdf (a copy of <repo>/docs/book.pdf)
//! index.html (a dark, full-viewport PDF-embed viewer)
//! ```
//!
//! This mirrors the layout of the hand-built `pages` branches already live for
//! `nornir`, `knut`, `znippy-zoomies` and `Skidbladnir`, so re-publishing any
//! repo through this path is byte-for-byte consistent with them.
//!
//! ## Pure-Rust, no push (nornir ethos)
//!
//! The tree + orphan commit are built **in-process via `gix`** (like
//! [`nornir_git::gitio::commit_all`] and the forge's `release_doctor::publish::commit_release`),
//! pointing `refs/heads/pages` at the new commit WITHOUT touching `HEAD` or the
//! working tree. nornir forbids `git` subprocesses and C deps (libgit2), and
//! `gix` (0.84) implements fetch but not send-pack — so, exactly like
//! the forge's `release_doctor::publish::push`, the actual force-push is left to the
//! operator/CI and the command prints the one line to run. See
//! [`force_push_cmd`].
use std::path::Path;
use anyhow::{Context, Result, anyhow};
/// The branch served by Codeberg Pages.
pub const PAGES_BRANCH: &str = "pages";
/// Fixed identity for `pages` commits (so the authorship line stays uniform across
/// every repo's `pages`). The e-mail is not a person's: `NORNIR_PAGES_EMAIL`
/// overrides it (e.g. to keep matching branches that were built by hand).
const PAGES_NAME: &str = "nordisk-pages";
const PAGES_EMAIL_DEFAULT: &str = "nordisk-pages@nordisk.invalid";
fn pages_email() -> String {
std::env::var("NORNIR_PAGES_EMAIL").unwrap_or_else(|_| PAGES_EMAIL_DEFAULT.to_string())
}
/// Commit subject for a pages publish.
pub const PAGES_COMMIT_MSG: &str = "pages: publish rendered docs book.pdf";
/// The empty `.nojekyll` marker's content (Codeberg Pages serves the tree
/// verbatim when this is present — no Jekyll pre-processing).
pub const NOJEKYLL: &str = "";
/// Render the dark, full-viewport PDF-embed `index.html` for `repo`.
///
/// Byte-for-byte identical to the template on the existing hand-built `pages`
/// branches (only the repo name is substituted, in the `<title>` and header).
pub fn index_html(repo: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{repo} — documentation</title>
<style>
html, body {{ margin: 0; height: 100%; }}
body {{ display: flex; flex-direction: column; font-family: system-ui, sans-serif; background: #0b0d10; color: #e6e6e6; }}
header {{ padding: 10px 16px; border-bottom: 1px solid #222; }}
header a {{ color: #7db4ff; text-decoration: none; }}
embed {{ flex: 1 1 auto; border: 0; width: 100%; }}
</style>
</head>
<body>
<header><strong>{repo} — documentation</strong> · <a href="book.pdf">book.pdf</a></header>
<embed src="book.pdf" type="application/pdf" />
</body>
</html>
"#
)
}
/// The exact `git push` line the operator/CI runs to publish the staged
/// `pages` branch (force, since pages is an always-latest mirror).
pub fn force_push_cmd(repo_root: &Path, remote: &str) -> String {
format!(
"git -C {} push --force {remote} {PAGES_BRANCH}:refs/heads/{PAGES_BRANCH}",
repo_root.display()
)
}
/// Outcome of staging the `pages` branch locally.
#[derive(Debug, Clone)]
pub struct PagesBuild {
/// Repo whose docs were published.
pub repo: String,
/// New orphan commit SHA that `refs/heads/pages` now points at.
pub commit: String,
/// Branch updated (always [`PAGES_BRANCH`]).
pub branch: String,
/// File names placed on the branch, sorted (`.nojekyll`, `book.pdf`, `index.html`).
pub files: Vec<String>,
/// Size of the embedded `book.pdf` in bytes.
pub pdf_bytes: usize,
}
/// Assemble (or rebuild) the orphan `pages` branch for `repo` from
/// `book_pdf`, pointing `refs/heads/pages` at a fresh root commit that
/// contains ONLY `.nojekyll` + `book.pdf` + `index.html`.
///
/// Pure `gix`: writes blobs + a flat tree + an orphan (parent-less) commit,
/// then updates `refs/heads/pages` directly. `HEAD`, the working branch and the
/// working tree are never touched. Force-pushing the result is the caller's job
/// (see [`force_push_cmd`]) — `gix` has no in-process send-pack.
pub fn stage_pages_branch(repo_root: &Path, repo: &str, book_pdf: &Path) -> Result<PagesBuild> {
use gix::object::tree::EntryKind;
let pdf =
std::fs::read(book_pdf).with_context(|| format!("read book pdf {}", book_pdf.display()))?;
if pdf.is_empty() {
return Err(anyhow!(
"{} is empty — render the book first (`nornir docs book {repo}`)",
book_pdf.display()
));
}
let html = index_html(repo);
let grepo =
gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
// Flat tree: build from the empty tree and upsert the three leaves.
let empty = gix::ObjectId::empty_tree(grepo.object_hash());
let mut editor = grepo.edit_tree(empty).context("seed empty tree editor")?;
let entries: [(&str, &[u8]); 3] = [
(".nojekyll", NOJEKYLL.as_bytes()),
("book.pdf", &pdf),
("index.html", html.as_bytes()),
];
for (name, bytes) in entries {
let blob = grepo
.write_blob(bytes)
.with_context(|| format!("write blob {name}"))?;
editor
.upsert(gix::bstr::BStr::new(name), EntryKind::Blob, blob.detach())
.with_context(|| format!("tree upsert {name}"))?;
}
let tree = editor.write().context("write pages tree")?.detach();
// Orphan commit: NO parents → the branch carries no source history.
let pages_email = pages_email();
let sig = gix::actor::SignatureRef {
name: gix::bstr::BStr::new(PAGES_NAME),
email: gix::bstr::BStr::new(pages_email.as_str()),
time: &now_git_time(),
};
let parents: Vec<gix::ObjectId> = Vec::new();
let full_ref = format!("refs/heads/{PAGES_BRANCH}");
let commit = grepo
.commit_as(sig, sig, full_ref.as_str(), PAGES_COMMIT_MSG, tree, parents)
.context("create orphan pages commit")?;
Ok(PagesBuild {
repo: repo.to_string(),
commit: commit.to_string(),
branch: PAGES_BRANCH.to_string(),
files: vec![".nojekyll".into(), "book.pdf".into(), "index.html".into()],
pdf_bytes: pdf.len(),
})
}
/// Current time formatted as git's signature time (`<unix_secs> +0000`).
fn now_git_time() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs} +0000")
}
#[cfg(test)]
mod tests {
use super::*;
/// The viewer template substitutes the repo name in BOTH the `<title>` and
/// the header, links + embeds `book.pdf`, carries the `.nojekyll`-friendly
/// dark style, and ends with a trailing newline (byte-parity with the
/// hand-built branches).
#[test]
fn index_html_is_repo_named_and_embeds_the_pdf() {
let html = index_html("knut");
assert!(
html.contains("<title>knut — documentation</title>"),
"title names repo"
);
assert!(
html.contains("<strong>knut — documentation</strong>"),
"header names repo"
);
assert!(
html.contains(r#"<a href="book.pdf">book.pdf</a>"#),
"links book.pdf"
);
assert!(
html.contains(r#"<embed src="book.pdf" type="application/pdf" />"#),
"embeds book.pdf"
);
assert!(html.contains("background: #0b0d10"), "dark viewer style");
assert!(
html.ends_with("</html>\n"),
"trailing newline (byte-parity)"
);
// The known-good knut template is exactly 733 bytes.
assert_eq!(
html.len(),
733,
"byte-for-byte parity with the live knut pages index.html"
);
}
/// LAW (inject-assert): build a real repo with a `main` commit, stage the
/// pages branch from a fake `book.pdf`, and assert: (a) `refs/heads/pages`
/// exists and is an ORPHAN (no parents), (b) its tree is EXACTLY the three
/// pages files, (c) the embedded pdf round-trips, and (d) `HEAD`/`main` did
/// NOT move — the working branch is untouched.
#[test]
fn stage_pages_branch_builds_orphan_three_file_tree_without_touching_head() {
let td = tempfile::tempdir().expect("tempdir");
let root = td.path().to_path_buf();
nornir_git::gitio::init(&root).expect("git init");
std::fs::write(root.join("README.md"), b"hello\n").expect("write readme");
let main_sha = nornir_git::gitio::commit_all(&root, "initial").expect("commit main");
let main_branch = nornir_git::gitio::head_branch(&root).expect("head branch before");
// A non-trivial fake book.pdf under docs/.
std::fs::create_dir_all(root.join("docs")).expect("mkdir docs");
let pdf_bytes = b"%PDF-1.7\n%fake book bytes\n".to_vec();
let book = root.join("docs/book.pdf");
std::fs::write(&book, &pdf_bytes).expect("write book.pdf");
let built = stage_pages_branch(&root, "demo", &book).expect("stage pages");
assert_eq!(built.branch, "pages");
assert_eq!(built.files, vec![".nojekyll", "book.pdf", "index.html"]);
assert_eq!(built.pdf_bytes, pdf_bytes.len());
let repo = gix::open(&root).expect("reopen");
// (a) refs/heads/pages exists and is an orphan.
let pages_commit = repo
.rev_parse_single("refs/heads/pages")
.expect("resolve pages ref")
.object()
.expect("pages object")
.try_into_commit()
.expect("pages is a commit");
assert_eq!(
pages_commit.id.to_string(),
built.commit,
"ref points at reported commit"
);
assert_eq!(
pages_commit.parent_ids().count(),
0,
"pages must be an ORPHAN (no parents — carries no source history)"
);
// (b) tree is exactly the three pages files.
let tree = pages_commit.tree().expect("pages tree");
let mut names: Vec<String> = tree
.iter()
.flatten()
.map(|e| e.filename().to_string())
.collect();
names.sort();
assert_eq!(names, vec![".nojekyll", "book.pdf", "index.html"]);
// (c) HEAD / main never moved — the working branch is untouched.
let head_sha = nornir_git::gitio::head_sha(&root).expect("head sha");
assert_eq!(
head_sha, main_sha,
"HEAD must stay on the original main commit"
);
assert_eq!(
nornir_git::gitio::head_branch(&root).expect("head branch after"),
main_branch,
"working branch is untouched"
);
}
}