#4 m1/code-tab
Patch
Cargo.lock+195 −0--- a/Cargo.lock+++ b/Cargo.lock@@ -2,6 +2,201 @@22 # It is not intended for manual editing.33 version = 444 5+[[package]]6+name = "bitflags"7+version = "2.13.2"8+source = "registry+https://github.com/rust-lang/crates.io-index"9+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"10+11+[[package]]12+name = "equivalent"13+version = "1.0.2"14+source = "registry+https://github.com/rust-lang/crates.io-index"15+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"16+17+[[package]]18+name = "getopts"19+version = "0.2.24"20+source = "registry+https://github.com/rust-lang/crates.io-index"21+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"22+dependencies = [23+ "unicode-width",24+]25+26+[[package]]27+name = "hashbrown"28+version = "0.17.1"29+source = "registry+https://github.com/rust-lang/crates.io-index"30+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"31+532 [[package]]633 name = "hu13"734 version = "0.1.0"35+dependencies = [36+ "pulldown-cmark",37+ "serde",38+ "serde_json",39+ "yaml_serde",40+]41+42+[[package]]43+name = "indexmap"44+version = "2.14.2"45+source = "registry+https://github.com/rust-lang/crates.io-index"46+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"47+dependencies = [48+ "equivalent",49+ "hashbrown",50+]51+52+[[package]]53+name = "itoa"54+version = "1.0.18"55+source = "registry+https://github.com/rust-lang/crates.io-index"56+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"57+58+[[package]]59+name = "libyaml-rs"60+version = "0.3.0"61+source = "registry+https://github.com/rust-lang/crates.io-index"62+checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777"63+64+[[package]]65+name = "memchr"66+version = "2.8.3"67+source = "registry+https://github.com/rust-lang/crates.io-index"68+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"69+70+[[package]]71+name = "proc-macro2"72+version = "1.0.107"73+source = "registry+https://github.com/rust-lang/crates.io-index"74+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"75+dependencies = [76+ "unicode-ident",77+]78+79+[[package]]80+name = "pulldown-cmark"81+version = "0.13.4"82+source = "registry+https://github.com/rust-lang/crates.io-index"83+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"84+dependencies = [85+ "bitflags",86+ "getopts",87+ "memchr",88+ "pulldown-cmark-escape",89+ "unicase",90+]91+92+[[package]]93+name = "pulldown-cmark-escape"94+version = "0.11.0"95+source = "registry+https://github.com/rust-lang/crates.io-index"96+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"97+98+[[package]]99+name = "quote"100+version = "1.0.47"101+source = "registry+https://github.com/rust-lang/crates.io-index"102+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"103+dependencies = [104+ "proc-macro2",105+]106+107+[[package]]108+name = "ryu"109+version = "1.0.23"110+source = "registry+https://github.com/rust-lang/crates.io-index"111+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"112+113+[[package]]114+name = "serde"115+version = "1.0.229"116+source = "registry+https://github.com/rust-lang/crates.io-index"117+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"118+dependencies = [119+ "serde_core",120+ "serde_derive",121+]122+123+[[package]]124+name = "serde_core"125+version = "1.0.229"126+source = "registry+https://github.com/rust-lang/crates.io-index"127+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"128+dependencies = [129+ "serde_derive",130+]131+132+[[package]]133+name = "serde_derive"134+version = "1.0.229"135+source = "registry+https://github.com/rust-lang/crates.io-index"136+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"137+dependencies = [138+ "proc-macro2",139+ "quote",140+ "syn",141+]142+143+[[package]]144+name = "serde_json"145+version = "1.0.151"146+source = "registry+https://github.com/rust-lang/crates.io-index"147+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"148+dependencies = [149+ "itoa",150+ "memchr",151+ "serde",152+ "serde_core",153+ "zmij",154+]155+156+[[package]]157+name = "syn"158+version = "3.0.5"159+source = "registry+https://github.com/rust-lang/crates.io-index"160+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"161+dependencies = [162+ "proc-macro2",163+ "quote",164+ "unicode-ident",165+]166+167+[[package]]168+name = "unicase"169+version = "2.9.0"170+source = "registry+https://github.com/rust-lang/crates.io-index"171+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"172+173+[[package]]174+name = "unicode-ident"175+version = "1.0.24"176+source = "registry+https://github.com/rust-lang/crates.io-index"177+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"178+179+[[package]]180+name = "unicode-width"181+version = "0.2.2"182+source = "registry+https://github.com/rust-lang/crates.io-index"183+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"184+185+[[package]]186+name = "yaml_serde"187+version = "0.10.7"188+source = "registry+https://github.com/rust-lang/crates.io-index"189+checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918"190+dependencies = [191+ "indexmap",192+ "itoa",193+ "libyaml-rs",194+ "ryu",195+ "serde",196+]197+198+[[package]]199+name = "zmij"200+version = "1.0.23"201+source = "registry+https://github.com/rust-lang/crates.io-index"202+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml+6 −0--- a/Cargo.toml+++ b/Cargo.toml@@ -4,3 +4,9 @@ version = "0.1.0"44 edition = "2021"55 description = "tiny static-site generator + text-only inbox for one f0rge-managed repo"66 license = "MIT OR Apache-2.0"7+8+[dependencies]9+pulldown-cmark = "=0.13.4"10+serde = { version = "=1.0.229", features = ["derive"] }11+serde_json = "=1.0.151"12+yaml_serde = "=0.10.7"
src/build.rs+689 −0--- /dev/null+++ b/src/build.rs@@ -0,0 +1,689 @@1+//! `hu13 build` — code-tab static site (m1, f0rge issue #4).2+//!3+//! Reads one branch via [`crate::gitx`], emits `dist/`:4+//! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5+//! `repo.bundle` + `repo.tar.gz`, and `style.css`.6+//!7+//! Bodies from the repo are semi-trusted but treated as untrusted:8+//! markdown goes through [`markdown_to_html`], which escapes raw HTML.9+10+use std::collections::BTreeMap;11+use std::path::Path;12+13+use crate::gitx;14+use crate::layout::esc;15+16+/// Files at or above this size get a placeholder page + download link.17+pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;18+19+/// Options for [`run_build`] (flags only, no config file).20+#[derive(Debug, Clone)]21+pub struct BuildOptions {22+ /// `--branch` flag (else `$HU13_BRANCH`, else main/master).23+ pub branch_flag: Option<String>,24+ /// `--clone-url` flag (else git origin remote, else none).25+ pub clone_url_flag: Option<String>,26+ /// `--title` site title override (else repo dir name).27+ pub title_override: Option<String>,28+ /// `--max-log` (accepted for CLI parity; commits land in m2).29+ pub max_log: usize,30+}31+32+/// Summary of one successful build (for logs/tests).33+#[derive(Debug, Clone)]34+pub struct BuildSummary {35+ /// Branch that was rendered.36+ pub branch: String,37+ /// Short sha of the branch head.38+ pub short_sha: String,39+ /// Number of files emitted under `raw/`.40+ pub file_count: usize,41+}42+43+/// Render markdown to HTML, escaping raw HTML.44+///45+/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched46+/// (R1 finding), so map them through [`esc`] first. Everything else47+/// (code spans, emphasis, lists) renders normally.48+#[must_use]49+pub fn markdown_to_html(md: &str) -> String {50+ use pulldown_cmark::{html, Event, Options, Parser};51+ let parser = Parser::new_ext(md, Options::empty());52+ let mapped = parser.map(|ev| match ev {53+ Event::Html(h) => Event::Html(esc(&h).into()),54+ Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),55+ other => other,56+ });57+ let mut out = String::new();58+ html::push_html(&mut out, mapped);59+ out60+}61+62+/// Resolve the clone URL to advertise.63+///64+/// Precedence: `--clone-url` flag > `git remote get-url origin` at build65+/// time > none (`None`, and the index shows no URL line).66+#[must_use]67+pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {68+ if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {69+ return Some(f.to_string());70+ }71+ let out = std::process::Command::new("git")72+ .arg("-C")73+ .arg(repo)74+ .arg("remote")75+ .arg("get-url")76+ .arg("origin")77+ .output();78+ match out {79+ Ok(o) if o.status.success() => {80+ let s = String::from_utf8_lossy(&o.stdout).trim().to_string();81+ if s.is_empty() {82+ None83+ } else {84+ Some(s)85+ }86+ }87+ _ => None,88+ }89+}90+91+/// README lookup chain (f0rge convention): repo-root `README.md` >92+/// `readme.md` > `README`, else `.forge/wiki/README.md` >93+/// `.forge/wiki/readme.md`, else none.94+///95+/// Returns the winning path plus its blob.96+pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {97+ for candidate in [98+ "README.md",99+ "readme.md",100+ "README",101+ ".forge/wiki/README.md",102+ ".forge/wiki/readme.md",103+ ] {104+ if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {105+ return Some((candidate.to_string(), blob));106+ }107+ }108+ None109+}110+111+/// `true` when a blob must not be inlined (binary or over the cap).112+#[must_use]113+pub fn is_placeholder(bytes: &[u8]) -> bool {114+ bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)115+}116+117+/// `true` for markdown-rendered paths (`.md`/`.markdown`, any case).118+#[must_use]119+pub fn is_markdown(path: &str) -> bool {120+ let lower = path.to_lowercase();121+ lower.ends_with(".md") || lower.ends_with(".markdown")122+}123+124+fn href_esc(path: &str) -> String {125+ esc(path)126+}127+128+/// Render one text file body: escaped `<pre>` with line numbers.129+fn render_text_pre(display: &str) -> String {130+ let mut out = String::from("<pre class=\"code\">\n");131+ if display.is_empty() {132+ out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");133+ } else {134+ for (i, line) in display.split('\n').enumerate() {135+ out.push_str("<span class=\"line\"><span class=\"ln\">");136+ out.push_str(&(i + 1).to_string());137+ out.push_str("</span> ");138+ out.push_str(&esc(line));139+ out.push_str("</span>\n");140+ }141+ }142+ out.push_str("</pre>\n");143+ out144+}145+146+fn nav_code() -> String {147+ "<a href=\"/\">code</a> <!-- TODO(m3): commits/issues/pulls/tasks/ci/releases/wiki tabs -->"148+ .to_string()149+}150+151+/// Write `out/style.css` from the embedded static file.152+fn write_style(out: &Path) -> Result<(), String> {153+ let css = include_str!("../static/style.css");154+ std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))155+}156+157+/// Build the full static site. Returns a summary on success.158+pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {159+ let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity160+ let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())161+ .map_err(|e| format!("hu13: {e}"))?;162+ let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;163+ let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());164+165+ let name = match &opts.title_override {166+ Some(t) if !t.trim().is_empty() => t.trim().to_string(),167+ _ => repo168+ .file_name()169+ .map(|n| n.to_string_lossy().into_owned())170+ .filter(|n| !n.is_empty())171+ .unwrap_or_else(|| "repo".to_string()),172+ };173+174+ // Walk the whole tree (dirs-first order from gitx), remembering each175+ // dir's entries so dir pages need no second pass.176+ let mut dir_entries: BTreeMap<String, Vec<gitx::TreeEntry>> = BTreeMap::new();177+ let mut dirs: Vec<String> = vec![String::new()];178+ let mut files: Vec<String> = Vec::new();179+ let mut queue: Vec<String> = vec![String::new()];180+ while let Some(dir) = queue.pop() {181+ let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?;182+ for e in &entries {183+ let full = if dir.is_empty() {184+ e.name.clone()185+ } else {186+ format!("{dir}/{}", e.name)187+ };188+ if e.is_dir {189+ queue.push(full);190+ } else {191+ files.push(full);192+ }193+ }194+ dir_entries.insert(dir.clone(), entries);195+ let _ = dirs.len();196+ }197+ dirs = dir_entries.keys().cloned().collect();198+199+ let readme = find_readme(repo, &branch);200+ let description = readme.as_ref().and_then(|(_, blob)| {201+ blob.display202+ .lines()203+ .map(str::trim)204+ .find(|l| !l.is_empty())205+ .map(|l| {206+ let l = l.strip_prefix('#').unwrap_or(l).trim();207+ if l.len() > 200 {208+ l[..200].to_string()209+ } else {210+ l.to_string()211+ }212+ })213+ .filter(|l| !l.is_empty())214+ });215+216+ std::fs::create_dir_all(out.join("files"))217+ .map_err(|e| format!("hu13: create files dir: {e}"))?;218+ std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;219+ write_style(out)?;220+221+ // Raw files first: byte-identical copies.222+ let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();223+ for f in &files {224+ let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;225+ let dest = out.join("raw").join(f);226+ if let Some(parent) = dest.parent() {227+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;228+ }229+ std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;230+ blobs.insert(f.clone(), blob.bytes);231+ }232+233+ // Per-path pages.234+ for dir in &dirs {235+ if dir.is_empty() {236+ continue; // root is index.html237+ }238+ let entries = dir_entries.get(dir).cloned().unwrap_or_default();239+ let parent = match dir.rfind('/') {240+ Some(i) => dir[..i].to_string(),241+ None => String::new(),242+ };243+ let parent_link = if parent.is_empty() {244+ "<a href=\"/\">.. (root)</a>".to_string()245+ } else {246+ format!(247+ "<a href=\"/files/{}.html\">.. ({})</a>",248+ href_esc(&parent),249+ esc(&parent)250+ )251+ };252+ let mut body = format!(253+ "<h1>dir: {}</h1>\n<p>{} · <a href=\"/\">code</a></p>\n<ul class=\"tree\">\n",254+ esc(dir),255+ parent_link256+ );257+ for e in &entries {258+ let full = format!("{dir}/{}", e.name);259+ if e.is_dir {260+ body.push_str(&format!(261+ "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",262+ href_esc(&full),263+ esc(&e.name)264+ ));265+ } else {266+ body.push_str(&format!(267+ "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",268+ href_esc(&full),269+ esc(&e.name),270+ href_esc(&full)271+ ));272+ }273+ }274+ body.push_str("</ul>\n");275+ let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body);276+ let dest = out.join("files").join(format!("{dir}.html"));277+ if let Some(parent) = dest.parent() {278+ std::fs::create_dir_all(parent)279+ .map_err(|e| format!("hu13: create files parent: {e}"))?;280+ }281+ std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;282+ }283+284+ for f in &files {285+ let bytes = blobs.get(f).cloned().unwrap_or_default();286+ let parent = match f.rfind('/') {287+ Some(i) => f[..i].to_string(),288+ None => String::new(),289+ };290+ let parent_link = if parent.is_empty() {291+ "<a href=\"/\">.. (root)</a>".to_string()292+ } else {293+ format!(294+ "<a href=\"/files/{}.html\">.. ({})</a>",295+ href_esc(&parent),296+ esc(&parent)297+ )298+ };299+ let mut body = format!(300+ "<h1>{}</h1>\n<p>{} · <a href=\"/raw/{}\">raw</a> · <a href=\"/\">code</a></p>\n",301+ esc(f),302+ parent_link,303+ href_esc(f)304+ );305+ if is_placeholder(&bytes) {306+ let kind = if bytes.len() >= PREVIEW_MAX_BYTES {307+ format!("too large to preview ({} bytes)", bytes.len())308+ } else {309+ "binary file".to_string()310+ };311+ body.push_str(&format!(312+ "<p class=\"notice\">placeholder: {kind} — preview omitted. \313+ <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",314+ href_esc(f)315+ ));316+ } else if is_markdown(f) {317+ let display = String::from_utf8_lossy(&bytes).into_owned();318+ body.push_str(&markdown_to_html(&display));319+ body.push_str(&format!(320+ "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",321+ href_esc(f)322+ ));323+ } else {324+ let display = String::from_utf8_lossy(&bytes).into_owned();325+ body.push_str(&render_text_pre(&display));326+ }327+ let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body);328+ let dest = out.join("files").join(format!("{f}.html"));329+ if let Some(parent) = dest.parent() {330+ std::fs::create_dir_all(parent)331+ .map_err(|e| format!("hu13: create files parent: {e}"))?;332+ }333+ std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;334+ }335+336+ // Snapshot last so the index can link sizes.337+ let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;338+ let bundle_size = std::fs::metadata(&snap.bundle)339+ .map(|m| m.len())340+ .unwrap_or(0);341+ let tarball_size = std::fs::metadata(&snap.tarball)342+ .map(|m| m.len())343+ .unwrap_or(0);344+345+ // index.html header.346+ let mut body = format!("<h1>{}</h1>\n", esc(&name));347+ match &description {348+ Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),349+ None => body.push_str("<p class=\"muted\">No description.</p>\n"),350+ }351+ if let Some(url) = &clone_url {352+ body.push_str(&format!(353+ "<p>Clone: <code>git clone {}</code></p>\n",354+ esc(url)355+ ));356+ }357+ body.push_str(&format!(358+ "<p>Branch <code>{}</code> @ <code>{}</code></p>\n",359+ esc(&branch),360+ esc(&short_sha)361+ ));362+ body.push_str(363+ "<p>issues (0) · pulls (0) · tasks (0) <!-- TODO(m3): real f0rge counts --></p>\n",364+ );365+ body.push_str("<p>CI: unknown (stub)</p>\n");366+ body.push_str(&format!(367+ "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \368+ <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"369+ ));370+371+ body.push_str("<h2>Files</h2>\n<ul class=\"tree\">\n");372+ for e in dir_entries.get("").cloned().unwrap_or_default() {373+ if e.is_dir {374+ body.push_str(&format!(375+ "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",376+ href_esc(&e.name),377+ esc(&e.name)378+ ));379+ } else {380+ body.push_str(&format!(381+ "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",382+ href_esc(&e.name),383+ esc(&e.name),384+ href_esc(&e.name)385+ ));386+ }387+ }388+ body.push_str("</ul>\n");389+390+ body.push_str("<h2>README</h2>\n");391+ match &readme {392+ Some((path, blob)) => {393+ body.push_str(&format!(394+ "<p class=\"muted\">from <code>{}</code></p>\n",395+ esc(path)396+ ));397+ if is_markdown(path) || path.to_lowercase().contains("readme") {398+ body.push_str(&markdown_to_html(&blob.display));399+ } else {400+ body.push_str(&render_text_pre(&blob.display));401+ }402+ }403+ None => body.push_str("<p class=\"muted\">No README found.</p>\n"),404+ }405+406+ let index = crate::layout::page(&name, &nav_code(), &body);407+ std::fs::write(out.join("index.html"), index)408+ .map_err(|e| format!("hu13: write index.html: {e}"))?;409+410+ Ok(BuildSummary {411+ branch,412+ short_sha,413+ file_count: files.len(),414+ })415+}416+417+/// Collect every file path under `dir` (relative, `/`-separated).418+#[cfg(test)]419+pub fn test_all_files(dir: &Path) -> Vec<std::path::PathBuf> {420+ let mut out = Vec::new();421+ let mut stack = vec![dir.to_path_buf()];422+ while let Some(d) = stack.pop() {423+ let rd = std::fs::read_dir(&d).expect("read_dir");424+ for ent in rd {425+ let ent = ent.expect("dir entry");426+ let p = ent.path();427+ if p.is_dir() {428+ stack.push(p);429+ } else {430+ out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());431+ }432+ }433+ }434+ out.sort();435+ out436+}437+438+#[cfg(test)]439+mod tests {440+ use super::*;441+ use std::path::PathBuf;442+ use std::process::Command;443+ use std::sync::atomic::{AtomicU64, Ordering};444+445+ static COUNTER: AtomicU64 = AtomicU64::new(0);446+447+ fn mktemp(prefix: &str) -> PathBuf {448+ let id = COUNTER.fetch_add(1, Ordering::SeqCst);449+ let dir =450+ std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id()));451+ std::fs::create_dir_all(&dir).expect("mktemp");452+ dir453+ }454+455+ fn git(repo: &Path, args: &[&str]) {456+ let st = Command::new("git")457+ .arg("-C")458+ .arg(repo)459+ .args(args)460+ .status()461+ .expect("spawn git");462+ assert!(st.success(), "git {args:?} failed in {}", repo.display());463+ }464+465+ fn opts() -> BuildOptions {466+ BuildOptions {467+ branch_flag: None,468+ clone_url_flag: None,469+ title_override: None,470+ max_log: 100,471+ }472+ }473+474+ /// Fixture: 10 files, no `.forge/` (the no-forge case) —475+ /// README + subdir + binary + >256KB file.476+ fn fixture_repo() -> PathBuf {477+ let dir = mktemp("fixture");478+ git(&dir, &["init", "-b", "main"]);479+ git(&dir, &["config", "user.name", "hu13-test"]);480+ git(&dir, &["config", "user.email", "[email protected]"]);481+ git(&dir, &["config", "commit.gpgsign", "false"]);482+ std::fs::write(483+ dir.join("README.md"),484+ "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n<script>alert(1)</script>\n",485+ )486+ .unwrap();487+ std::fs::create_dir_all(dir.join("src")).unwrap();488+ std::fs::write(489+ dir.join("src").join("main.rs"),490+ "fn main() {\n println!(\"hi\");\n}\n",491+ )492+ .unwrap();493+ std::fs::write(494+ dir.join("src").join("lib.rs"),495+ "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",496+ )497+ .unwrap();498+ std::fs::create_dir_all(dir.join("docs")).unwrap();499+ std::fs::write(500+ dir.join("docs").join("guide.md"),501+ "# Guide\n\nSome *docs* here.\n",502+ )503+ .unwrap();504+ std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap();505+ std::fs::create_dir_all(dir.join("assets")).unwrap();506+ let mut bin: Vec<u8> = (0u8..=255).collect();507+ bin.extend_from_slice(b"\x00\x01\x02binary tail");508+ std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap();509+ std::fs::create_dir_all(dir.join("data")).unwrap();510+ std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap();511+ std::fs::create_dir_all(dir.join("sub")).unwrap();512+ std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();513+ std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap();514+ std::fs::write(515+ dir.join("LICENSE"),516+ "MIT License\n\nPermission is hereby granted.\n",517+ )518+ .unwrap();519+ git(&dir, &["add", "-A"]);520+ git(&dir, &["commit", "-m", "fixture commit"]);521+ dir522+ }523+524+ fn build_fixture() -> (PathBuf, PathBuf) {525+ let repo = fixture_repo();526+ let out = mktemp("out");527+ let summary = run_build(&repo, &out, &opts()).expect("build fixture");528+ assert_eq!(summary.branch, "main");529+ assert_eq!(summary.file_count, 10);530+ assert!(!summary.short_sha.is_empty());531+ (repo, out)532+ }533+534+ #[test]535+ fn index_has_tree_and_readme_html() {536+ let (_repo, out) = build_fixture();537+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");538+ // File tree (dirs-first flat root listing).539+ for name in ["src", "docs", "assets", "data", "sub"] {540+ assert!(index.contains(name), "tree should list {name}");541+ }542+ for name in ["README.md", "notes.txt", "LICENSE"] {543+ assert!(index.contains(name), "tree should list {name}");544+ }545+ // README rendered to HTML (markdown heading), raw HTML escaped.546+ assert!(index.contains("Hello fixture"), "README body rendered");547+ assert!(548+ !index.contains("<script>alert(1)</script>"),549+ "raw HTML escaped"550+ );551+ assert!(index.contains("<script>"), "escaped HTML present");552+ // Header: branch + short sha, f0rge counts stub, CI stub.553+ assert!(index.contains("main"), "branch shown");554+ assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker");555+ assert!(index.contains("issues (0)"), "f0rge counts as 0");556+ assert!(index.contains("CI:"), "CI verdict stub");557+ // Snapshot links with sizes.558+ assert!(index.contains("repo.bundle"), "bundle linked");559+ assert!(index.contains("repo.tar.gz"), "tarball linked");560+ // No clone URL line when no origin remote is configured.561+ assert!(!index.contains("git clone"), "no URL line without remote");562+ }563+564+ #[test]565+ fn clone_url_flag_wins() {566+ let repo = fixture_repo();567+ let out = mktemp("out-flag");568+ let mut o = opts();569+ o.clone_url_flag = Some("https://example.com/x.git".to_string());570+ run_build(&repo, &out, &o).expect("build");571+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");572+ assert!(index.contains("git clone https://example.com/x.git"));573+ }574+575+ #[test]576+ fn raw_byte_identical_for_all_files() {577+ let (repo, out) = build_fixture();578+ let raws = test_all_files(&out.join("raw"));579+ assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}");580+ for rel in &raws {581+ let want = std::fs::read(repo.join(rel)).expect("repo file");582+ let got = std::fs::read(out.join("raw").join(rel)).expect("raw file");583+ assert_eq!(got, want, "byte-identical: {}", rel.display());584+ }585+ }586+587+ #[test]588+ fn placeholder_for_binary_and_big_file() {589+ let (_repo, out) = build_fixture();590+ for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] {591+ let html = std::fs::read_to_string(out.join(page)).expect(page);592+ assert!(html.contains("placeholder"), "{page} has placeholder");593+ assert!(html.contains("/raw/"), "{page} links a download");594+ }595+ // Binary bytes must never be inlined into the page.596+ let bin_page =597+ std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page");598+ assert!(!bin_page.contains("binary tail"), "binary not inlined");599+ // Small text file gets line numbers + raw link, no placeholder.600+ let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");601+ assert!(txt.contains("/raw/notes.txt"), "raw link");602+ assert!(!txt.contains("placeholder"), "text file inlined");603+ assert!(604+ txt.contains("<") || txt.contains("plain notes"),605+ "escaped pre body"606+ );607+ }608+609+ #[test]610+ fn md_files_are_rendered() {611+ let (_repo, out) = build_fixture();612+ let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");613+ assert!(614+ html.contains("<h1>Guide</h1>"),615+ "markdown rendered, got: {html}"616+ );617+ assert!(html.contains("/raw/docs/guide.md"), "raw link kept");618+ }619+620+ #[test]621+ fn bundle_clones() {622+ let (_repo, out) = build_fixture();623+ assert!(out.join("repo.bundle").exists());624+ assert!(out.join("repo.tar.gz").exists());625+ let dest_parent = mktemp("clone");626+ let dest = dest_parent.join("cloned");627+ let st = Command::new("git")628+ .arg("clone")629+ .arg("-b")630+ .arg("main")631+ .arg(out.join("repo.bundle"))632+ .arg(&dest)633+ .status()634+ .expect("git clone");635+ assert!(st.success(), "bundle clones");636+ assert!(dest.join("README.md").exists());637+ assert!(dest.join("sub").join("nested.txt").exists());638+ }639+640+ #[test]641+ fn no_forge_build_succeeds() {642+ // Fixture has no `.forge/` at all; build must still exit Ok (0).643+ let repo = fixture_repo();644+ assert!(!repo.join(".forge").exists());645+ let out = mktemp("out-noforge");646+ let res = run_build(&repo, &out, &opts());647+ assert!(res.is_ok(), "no-forge build exits 0, got {res:?}");648+ assert!(out.join("index.html").exists());649+ }650+651+ #[test]652+ fn readme_chain_prefers_root_then_wiki() {653+ let dir = mktemp("readme-chain");654+ git(&dir, &["init", "-b", "main"]);655+ git(&dir, &["config", "user.name", "hu13-test"]);656+ git(&dir, &["config", "user.email", "[email protected]"]);657+ git(&dir, &["config", "commit.gpgsign", "false"]);658+ // Wiki-only README first.659+ std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();660+ std::fs::write(661+ dir.join(".forge").join("wiki").join("README.md"),662+ "# Wiki readme\n",663+ )664+ .unwrap();665+ std::fs::write(dir.join("notes.txt"), "x\n").unwrap();666+ git(&dir, &["add", "-A"]);667+ git(&dir, &["commit", "-m", "wiki readme only"]);668+ let (path, _) = find_readme(&dir, "main").expect("wiki fallback");669+ assert_eq!(path, ".forge/wiki/README.md");670+ // Root README.md wins once added.671+ std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap();672+ git(&dir, &["add", "-A"]);673+ git(&dir, &["commit", "-m", "root readme"]);674+ let (path, _) = find_readme(&dir, "main").expect("root");675+ assert_eq!(path, "README.md");676+ }677+678+ #[test]679+ fn markdown_escapes_raw_html() {680+ let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");681+ assert!(html.contains("<h1>hi</h1>"), "heading renders");682+ assert!(!html.contains("<script>"), "no raw passthrough");683+ assert!(html.contains("<script>"), "escaped instead");684+ assert!(685+ html.contains("<strong>bold</strong>"),686+ "legit markdown intact"687+ );688+ }689+}
src/main.rs+98 −5--- a/src/main.rs+++ b/src/main.rs@@ -1,3 +1,4 @@1+mod build;12 mod gitx;23 mod layout;34 @@ -27,7 +28,7 @@ Run `hu13 <subcommand> --help` for subcommand help.2728 fn print_build_help() {2829 print!(2930 "\30-hu13-build — build a static site from a repo checkout (stub)31+hu13-build — build a static site from a repo checkout3132 3233 USAGE:3334 hu13 build <REPO> -o <OUT> [OPTIONS]@@ -42,8 +43,6 @@ OPTIONS:4243 --title <S> Site title override4344 --max-log <N> Max commits in log [default: 100]4445 -h, --help Print this help45-46-NOTE: m1/scaffold stub — prints help and exits 0, no output written yet.4746 "4847 );4948 }@@ -98,8 +97,102 @@ fn run_build(args: &[String]) -> i32 {9897 print_build_help();9998 return 0;10099 }101- print_build_help();102- 0100+ let mut repo: Option<String> = None;101+ let mut out: Option<String> = None;102+ let mut branch: Option<String> = None;103+ let mut clone_url: Option<String> = None;104+ let mut title: Option<String> = None;105+ let mut max_log: usize = 100;106+ let mut i = 0;107+ while i < args.len() {108+ match args[i].as_str() {109+ "-o" | "--output" => {110+ i += 1;111+ if i >= args.len() {112+ eprintln!("hu13 build: -o needs a value");113+ return 2;114+ }115+ out = Some(args[i].clone());116+ }117+ "--branch" => {118+ i += 1;119+ if i >= args.len() {120+ eprintln!("hu13 build: --branch needs a value");121+ return 2;122+ }123+ branch = Some(args[i].clone());124+ }125+ "--clone-url" => {126+ i += 1;127+ if i >= args.len() {128+ eprintln!("hu13 build: --clone-url needs a value");129+ return 2;130+ }131+ clone_url = Some(args[i].clone());132+ }133+ "--title" => {134+ i += 1;135+ if i >= args.len() {136+ eprintln!("hu13 build: --title needs a value");137+ return 2;138+ }139+ title = Some(args[i].clone());140+ }141+ "--max-log" => {142+ i += 1;143+ if i >= args.len() {144+ eprintln!("hu13 build: --max-log needs a value");145+ return 2;146+ }147+ match args[i].parse::<usize>() {148+ Ok(n) => max_log = n,149+ Err(_) => {150+ eprintln!("hu13 build: --max-log must be a number");151+ return 2;152+ }153+ }154+ }155+ s if s.starts_with('-') => {156+ eprintln!("hu13 build: unknown option '{s}'");157+ return 2;158+ }159+ s => {160+ if repo.is_some() {161+ eprintln!("hu13 build: unexpected argument '{s}'");162+ return 2;163+ }164+ repo = Some(s.to_string());165+ }166+ }167+ i += 1;168+ }169+ let (Some(repo), Some(out)) = (repo, out) else {170+ eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--title S] [--max-log N]");171+ return 2;172+ };173+ let opts = build::BuildOptions {174+ branch_flag: branch,175+ clone_url_flag: clone_url,176+ title_override: title,177+ max_log,178+ };179+ match build::run_build(180+ std::path::Path::new(&repo),181+ std::path::Path::new(&out),182+ &opts,183+ ) {184+ Ok(summary) => {185+ println!(186+ "hu13: built branch {} @ {} ({} files) -> {}",187+ summary.branch, summary.short_sha, summary.file_count, out188+ );189+ 0190+ }191+ Err(e) => {192+ eprintln!("{e}");193+ 1194+ }195+ }103196 }104197 105198 fn run_serve(args: &[String]) -> i32 {
tests/build_cli.rs+56 −1--- /dev/null+++ b/tests/build_cli.rs@@ -0,0 +1,56 @@1+//! CLI-level test: `hu13 build` on a repo without `.forge/` exits 0.2+3+use std::path::{Path, PathBuf};4+use std::process::Command;5+6+fn git(repo: &Path, args: &[&str]) {7+ let st = Command::new("git")8+ .arg("-C")9+ .arg(repo)10+ .args(args)11+ .status()12+ .expect("spawn git");13+ assert!(st.success(), "git {args:?} failed");14+}15+16+#[test]17+fn build_no_forge_repo_exits_zero() {18+ let dir = std::env::temp_dir().join(format!(19+ "hu13-cli-noforge-{}-{}",20+ std::process::id(),21+ std::time::SystemTime::now()22+ .duration_since(std::time::UNIX_EPOCH)23+ .map(|d| d.as_nanos())24+ .unwrap_or(0)25+ ));26+ std::fs::create_dir_all(&dir).unwrap();27+ git(&dir, &["init", "-b", "main"]);28+ git(&dir, &["config", "user.name", "hu13-test"]);29+ git(&dir, &["config", "user.email", "[email protected]"]);30+ git(&dir, &["config", "commit.gpgsign", "false"]);31+ std::fs::write(dir.join("README.md"), "# cli fixture\n").unwrap();32+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();33+ git(&dir, &["add", "-A"]);34+ git(&dir, &["commit", "-m", "init"]);35+ assert!(!dir.join(".forge").exists());36+37+ let out: PathBuf = dir.join("..").join(format!(38+ "hu13-cli-out-{}-{}",39+ std::process::id(),40+ std::time::SystemTime::now()41+ .duration_since(std::time::UNIX_EPOCH)42+ .map(|d| d.as_nanos())43+ .unwrap_or(1)44+ ));45+ let bin = env!("CARGO_BIN_EXE_hu13");46+ let st = Command::new(bin)47+ .arg("build")48+ .arg(&dir)49+ .arg("-o")50+ .arg(&out)51+ .status()52+ .expect("spawn hu13");53+ assert!(st.success(), "no-forge build exits 0, got {st}");54+ assert!(out.join("index.html").exists());55+ assert!(out.join("raw").join("README.md").exists());56+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.