use std::path::Path;
use anyhow::{anyhow, Context, Result};
pub const PAGES_BRANCH: &str = "pages";
const PAGES_NAME: &str = "nordisk-pages";
const PAGES_EMAIL: &str = "rickard@x14.se";
pub const PAGES_COMMIT_MSG: &str = "pages: publish rendered docs book.pdf";
pub const NOJEKYLL: &str = "";
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>
"#
)
}
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()
)
}
#[derive(Debug, Clone)]
pub struct PagesBuild {
pub repo: String,
pub commit: String,
pub branch: String,
pub files: Vec<String>,
pub pdf_bytes: usize,
}
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()))?;
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();
let sig = gix::actor::SignatureRef {
name: gix::bstr::BStr::new(PAGES_NAME),
email: gix::bstr::BStr::new(PAGES_EMAIL),
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(),
})
}
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::*;
#[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)");
assert_eq!(html.len(), 733, "byte-for-byte parity with the live knut pages index.html");
}
#[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();
crate::gitio::init(&root).expect("git init");
std::fs::write(root.join("README.md"), b"hello\n").expect("write readme");
let main_sha = crate::gitio::commit_all(&root, "initial").expect("commit main");
let main_branch = crate::gitio::head_branch(&root).expect("head branch before");
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");
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)"
);
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"]);
let head_sha = crate::gitio::head_sha(&root).expect("head sha");
assert_eq!(head_sha, main_sha, "HEAD must stay on the original main commit");
assert_eq!(
crate::gitio::head_branch(&root).expect("head branch after"),
main_branch,
"working branch is untouched"
);
}
}