Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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> &nbsp;·&nbsp; <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"
        );
    }
}