1 From e5256e04e69f973511a8086151b33a7699ca10c3 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:09:13 +01004 Subject: [PATCH] m1/code-tab: build cmd, index/files/raw, clone chain,5 bundle+tarball (25 tests)6 7 ---8 Cargo.lock | 195 +++++++++++++9 Cargo.toml | 6 +10 src/build.rs | 689 +++++++++++++++++++++++++++++++++++++++++++++11 src/main.rs | 103 ++++++-12 tests/build_cli.rs | 56 ++++13 5 files changed, 1044 insertions(+), 5 deletions(-)14 create mode 100644 src/build.rs15 create mode 100644 tests/build_cli.rs16 17 diff --git a/Cargo.lock b/Cargo.lock18 index 9569aba..d1596b8 10064419 --- a/Cargo.lock20 +++ b/Cargo.lock21 @@ -2,6 +2,201 @@22 # It is not intended for manual editing.23 version = 424 25 +[[package]]26 +name = "bitflags"27 +version = "2.13.2"28 +source = "registry+https://github.com/rust-lang/crates.io-index"29 +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"30 +31 +[[package]]32 +name = "equivalent"33 +version = "1.0.2"34 +source = "registry+https://github.com/rust-lang/crates.io-index"35 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"36 +37 +[[package]]38 +name = "getopts"39 +version = "0.2.24"40 +source = "registry+https://github.com/rust-lang/crates.io-index"41 +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"42 +dependencies = [43 + "unicode-width",44 +]45 +46 +[[package]]47 +name = "hashbrown"48 +version = "0.17.1"49 +source = "registry+https://github.com/rust-lang/crates.io-index"50 +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"51 +52 [[package]]53 name = "hu13"54 version = "0.1.0"55 +dependencies = [56 + "pulldown-cmark",57 + "serde",58 + "serde_json",59 + "yaml_serde",60 +]61 +62 +[[package]]63 +name = "indexmap"64 +version = "2.14.2"65 +source = "registry+https://github.com/rust-lang/crates.io-index"66 +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"67 +dependencies = [68 + "equivalent",69 + "hashbrown",70 +]71 +72 +[[package]]73 +name = "itoa"74 +version = "1.0.18"75 +source = "registry+https://github.com/rust-lang/crates.io-index"76 +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"77 +78 +[[package]]79 +name = "libyaml-rs"80 +version = "0.3.0"81 +source = "registry+https://github.com/rust-lang/crates.io-index"82 +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777"83 +84 +[[package]]85 +name = "memchr"86 +version = "2.8.3"87 +source = "registry+https://github.com/rust-lang/crates.io-index"88 +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"89 +90 +[[package]]91 +name = "proc-macro2"92 +version = "1.0.107"93 +source = "registry+https://github.com/rust-lang/crates.io-index"94 +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"95 +dependencies = [96 + "unicode-ident",97 +]98 +99 +[[package]]100 +name = "pulldown-cmark"101 +version = "0.13.4"102 +source = "registry+https://github.com/rust-lang/crates.io-index"103 +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"104 +dependencies = [105 + "bitflags",106 + "getopts",107 + "memchr",108 + "pulldown-cmark-escape",109 + "unicase",110 +]111 +112 +[[package]]113 +name = "pulldown-cmark-escape"114 +version = "0.11.0"115 +source = "registry+https://github.com/rust-lang/crates.io-index"116 +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"117 +118 +[[package]]119 +name = "quote"120 +version = "1.0.47"121 +source = "registry+https://github.com/rust-lang/crates.io-index"122 +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"123 +dependencies = [124 + "proc-macro2",125 +]126 +127 +[[package]]128 +name = "ryu"129 +version = "1.0.23"130 +source = "registry+https://github.com/rust-lang/crates.io-index"131 +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"132 +133 +[[package]]134 +name = "serde"135 +version = "1.0.229"136 +source = "registry+https://github.com/rust-lang/crates.io-index"137 +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"138 +dependencies = [139 + "serde_core",140 + "serde_derive",141 +]142 +143 +[[package]]144 +name = "serde_core"145 +version = "1.0.229"146 +source = "registry+https://github.com/rust-lang/crates.io-index"147 +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"148 +dependencies = [149 + "serde_derive",150 +]151 +152 +[[package]]153 +name = "serde_derive"154 +version = "1.0.229"155 +source = "registry+https://github.com/rust-lang/crates.io-index"156 +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"157 +dependencies = [158 + "proc-macro2",159 + "quote",160 + "syn",161 +]162 +163 +[[package]]164 +name = "serde_json"165 +version = "1.0.151"166 +source = "registry+https://github.com/rust-lang/crates.io-index"167 +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"168 +dependencies = [169 + "itoa",170 + "memchr",171 + "serde",172 + "serde_core",173 + "zmij",174 +]175 +176 +[[package]]177 +name = "syn"178 +version = "3.0.5"179 +source = "registry+https://github.com/rust-lang/crates.io-index"180 +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"181 +dependencies = [182 + "proc-macro2",183 + "quote",184 + "unicode-ident",185 +]186 +187 +[[package]]188 +name = "unicase"189 +version = "2.9.0"190 +source = "registry+https://github.com/rust-lang/crates.io-index"191 +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"192 +193 +[[package]]194 +name = "unicode-ident"195 +version = "1.0.24"196 +source = "registry+https://github.com/rust-lang/crates.io-index"197 +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"198 +199 +[[package]]200 +name = "unicode-width"201 +version = "0.2.2"202 +source = "registry+https://github.com/rust-lang/crates.io-index"203 +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"204 +205 +[[package]]206 +name = "yaml_serde"207 +version = "0.10.7"208 +source = "registry+https://github.com/rust-lang/crates.io-index"209 +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918"210 +dependencies = [211 + "indexmap",212 + "itoa",213 + "libyaml-rs",214 + "ryu",215 + "serde",216 +]217 +218 +[[package]]219 +name = "zmij"220 +version = "1.0.23"221 +source = "registry+https://github.com/rust-lang/crates.io-index"222 +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"223 diff --git a/Cargo.toml b/Cargo.toml224 index d163f7d..12c7eb7 100644225 --- a/Cargo.toml226 +++ b/Cargo.toml227 @@ -4,3 +4,9 @@ version = "0.1.0"228 edition = "2021"229 description = "tiny static-site generator + text-only inbox for one f0rge-managed repo"230 license = "MIT OR Apache-2.0"231 +232 +[dependencies]233 +pulldown-cmark = "=0.13.4"234 +serde = { version = "=1.0.229", features = ["derive"] }235 +serde_json = "=1.0.151"236 +yaml_serde = "=0.10.7"237 diff --git a/src/build.rs b/src/build.rs238 new file mode 100644239 index 0000000..5280528240 --- /dev/null241 +++ b/src/build.rs242 @@ -0,0 +1,689 @@243 +//! `hu13 build` — code-tab static site (m1, f0rge issue #4).244 +//!245 +//! Reads one branch via [`crate::gitx`], emits `dist/`:246 +//! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),247 +//! `repo.bundle` + `repo.tar.gz`, and `style.css`.248 +//!249 +//! Bodies from the repo are semi-trusted but treated as untrusted:250 +//! markdown goes through [`markdown_to_html`], which escapes raw HTML.251 +252 +use std::collections::BTreeMap;253 +use std::path::Path;254 +255 +use crate::gitx;256 +use crate::layout::esc;257 +258 +/// Files at or above this size get a placeholder page + download link.259 +pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;260 +261 +/// Options for [`run_build`] (flags only, no config file).262 +#[derive(Debug, Clone)]263 +pub struct BuildOptions {264 + /// `--branch` flag (else `$HU13_BRANCH`, else main/master).265 + pub branch_flag: Option<String>,266 + /// `--clone-url` flag (else git origin remote, else none).267 + pub clone_url_flag: Option<String>,268 + /// `--title` site title override (else repo dir name).269 + pub title_override: Option<String>,270 + /// `--max-log` (accepted for CLI parity; commits land in m2).271 + pub max_log: usize,272 +}273 +274 +/// Summary of one successful build (for logs/tests).275 +#[derive(Debug, Clone)]276 +pub struct BuildSummary {277 + /// Branch that was rendered.278 + pub branch: String,279 + /// Short sha of the branch head.280 + pub short_sha: String,281 + /// Number of files emitted under `raw/`.282 + pub file_count: usize,283 +}284 +285 +/// Render markdown to HTML, escaping raw HTML.286 +///287 +/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched288 +/// (R1 finding), so map them through [`esc`] first. Everything else289 +/// (code spans, emphasis, lists) renders normally.290 +#[must_use]291 +pub fn markdown_to_html(md: &str) -> String {292 + use pulldown_cmark::{html, Event, Options, Parser};293 + let parser = Parser::new_ext(md, Options::empty());294 + let mapped = parser.map(|ev| match ev {295 + Event::Html(h) => Event::Html(esc(&h).into()),296 + Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),297 + other => other,298 + });299 + let mut out = String::new();300 + html::push_html(&mut out, mapped);301 + out302 +}303 +304 +/// Resolve the clone URL to advertise.305 +///306 +/// Precedence: `--clone-url` flag > `git remote get-url origin` at build307 +/// time > none (`None`, and the index shows no URL line).308 +#[must_use]309 +pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {310 + if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {311 + return Some(f.to_string());312 + }313 + let out = std::process::Command::new("git")314 + .arg("-C")315 + .arg(repo)316 + .arg("remote")317 + .arg("get-url")318 + .arg("origin")319 + .output();320 + match out {321 + Ok(o) if o.status.success() => {322 + let s = String::from_utf8_lossy(&o.stdout).trim().to_string();323 + if s.is_empty() {324 + None325 + } else {326 + Some(s)327 + }328 + }329 + _ => None,330 + }331 +}332 +333 +/// README lookup chain (f0rge convention): repo-root `README.md` >334 +/// `readme.md` > `README`, else `.forge/wiki/README.md` >335 +/// `.forge/wiki/readme.md`, else none.336 +///337 +/// Returns the winning path plus its blob.338 +pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {339 + for candidate in [340 + "README.md",341 + "readme.md",342 + "README",343 + ".forge/wiki/README.md",344 + ".forge/wiki/readme.md",345 + ] {346 + if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {347 + return Some((candidate.to_string(), blob));348 + }349 + }350 + None351 +}352 +353 +/// `true` when a blob must not be inlined (binary or over the cap).354 +#[must_use]355 +pub fn is_placeholder(bytes: &[u8]) -> bool {356 + bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)357 +}358 +359 +/// `true` for markdown-rendered paths (`.md`/`.markdown`, any case).360 +#[must_use]361 +pub fn is_markdown(path: &str) -> bool {362 + let lower = path.to_lowercase();363 + lower.ends_with(".md") || lower.ends_with(".markdown")364 +}365 +366 +fn href_esc(path: &str) -> String {367 + esc(path)368 +}369 +370 +/// Render one text file body: escaped `<pre>` with line numbers.371 +fn render_text_pre(display: &str) -> String {372 + let mut out = String::from("<pre class=\"code\">\n");373 + if display.is_empty() {374 + out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");375 + } else {376 + for (i, line) in display.split('\n').enumerate() {377 + out.push_str("<span class=\"line\"><span class=\"ln\">");378 + out.push_str(&(i + 1).to_string());379 + out.push_str("</span> ");380 + out.push_str(&esc(line));381 + out.push_str("</span>\n");382 + }383 + }384 + out.push_str("</pre>\n");385 + out386 +}387 +388 +fn nav_code() -> String {389 + "<a href=\"/\">code</a> <!-- TODO(m3): commits/issues/pulls/tasks/ci/releases/wiki tabs -->"390 + .to_string()391 +}392 +393 +/// Write `out/style.css` from the embedded static file.394 +fn write_style(out: &Path) -> Result<(), String> {395 + let css = include_str!("../static/style.css");396 + std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))397 +}398 +399 +/// Build the full static site. Returns a summary on success.400 +pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {401 + let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity402 + let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())403 + .map_err(|e| format!("hu13: {e}"))?;404 + let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;405 + let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());406 +407 + let name = match &opts.title_override {408 + Some(t) if !t.trim().is_empty() => t.trim().to_string(),409 + _ => repo410 + .file_name()411 + .map(|n| n.to_string_lossy().into_owned())412 + .filter(|n| !n.is_empty())413 + .unwrap_or_else(|| "repo".to_string()),414 + };415 +416 + // Walk the whole tree (dirs-first order from gitx), remembering each417 + // dir's entries so dir pages need no second pass.418 + let mut dir_entries: BTreeMap<String, Vec<gitx::TreeEntry>> = BTreeMap::new();419 + let mut dirs: Vec<String> = vec![String::new()];420 + let mut files: Vec<String> = Vec::new();421 + let mut queue: Vec<String> = vec![String::new()];422 + while let Some(dir) = queue.pop() {423 + let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?;424 + for e in &entries {425 + let full = if dir.is_empty() {426 + e.name.clone()427 + } else {428 + format!("{dir}/{}", e.name)429 + };430 + if e.is_dir {431 + queue.push(full);432 + } else {433 + files.push(full);434 + }435 + }436 + dir_entries.insert(dir.clone(), entries);437 + let _ = dirs.len();438 + }439 + dirs = dir_entries.keys().cloned().collect();440 +441 + let readme = find_readme(repo, &branch);442 + let description = readme.as_ref().and_then(|(_, blob)| {443 + blob.display444 + .lines()445 + .map(str::trim)446 + .find(|l| !l.is_empty())447 + .map(|l| {448 + let l = l.strip_prefix('#').unwrap_or(l).trim();449 + if l.len() > 200 {450 + l[..200].to_string()451 + } else {452 + l.to_string()453 + }454 + })455 + .filter(|l| !l.is_empty())456 + });457 +458 + std::fs::create_dir_all(out.join("files"))459 + .map_err(|e| format!("hu13: create files dir: {e}"))?;460 + std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;461 + write_style(out)?;462 +463 + // Raw files first: byte-identical copies.464 + let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();465 + for f in &files {466 + let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;467 + let dest = out.join("raw").join(f);468 + if let Some(parent) = dest.parent() {469 + std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;470 + }471 + std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;472 + blobs.insert(f.clone(), blob.bytes);473 + }474 +475 + // Per-path pages.476 + for dir in &dirs {477 + if dir.is_empty() {478 + continue; // root is index.html479 + }480 + let entries = dir_entries.get(dir).cloned().unwrap_or_default();481 + let parent = match dir.rfind('/') {482 + Some(i) => dir[..i].to_string(),483 + None => String::new(),484 + };485 + let parent_link = if parent.is_empty() {486 + "<a href=\"/\">.. (root)</a>".to_string()487 + } else {488 + format!(489 + "<a href=\"/files/{}.html\">.. ({})</a>",490 + href_esc(&parent),491 + esc(&parent)492 + )493 + };494 + let mut body = format!(495 + "<h1>dir: {}</h1>\n<p>{} · <a href=\"/\">code</a></p>\n<ul class=\"tree\">\n",496 + esc(dir),497 + parent_link498 + );499 + for e in &entries {500 + let full = format!("{dir}/{}", e.name);501 + if e.is_dir {502 + body.push_str(&format!(503 + "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",504 + href_esc(&full),505 + esc(&e.name)506 + ));507 + } else {508 + body.push_str(&format!(509 + "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",510 + href_esc(&full),511 + esc(&e.name),512 + href_esc(&full)513 + ));514 + }515 + }516 + body.push_str("</ul>\n");517 + let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body);518 + let dest = out.join("files").join(format!("{dir}.html"));519 + if let Some(parent) = dest.parent() {520 + std::fs::create_dir_all(parent)521 + .map_err(|e| format!("hu13: create files parent: {e}"))?;522 + }523 + std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;524 + }525 +526 + for f in &files {527 + let bytes = blobs.get(f).cloned().unwrap_or_default();528 + let parent = match f.rfind('/') {529 + Some(i) => f[..i].to_string(),530 + None => String::new(),531 + };532 + let parent_link = if parent.is_empty() {533 + "<a href=\"/\">.. (root)</a>".to_string()534 + } else {535 + format!(536 + "<a href=\"/files/{}.html\">.. ({})</a>",537 + href_esc(&parent),538 + esc(&parent)539 + )540 + };541 + let mut body = format!(542 + "<h1>{}</h1>\n<p>{} · <a href=\"/raw/{}\">raw</a> · <a href=\"/\">code</a></p>\n",543 + esc(f),544 + parent_link,545 + href_esc(f)546 + );547 + if is_placeholder(&bytes) {548 + let kind = if bytes.len() >= PREVIEW_MAX_BYTES {549 + format!("too large to preview ({} bytes)", bytes.len())550 + } else {551 + "binary file".to_string()552 + };553 + body.push_str(&format!(554 + "<p class=\"notice\">placeholder: {kind} — preview omitted. \555 + <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",556 + href_esc(f)557 + ));558 + } else if is_markdown(f) {559 + let display = String::from_utf8_lossy(&bytes).into_owned();560 + body.push_str(&markdown_to_html(&display));561 + body.push_str(&format!(562 + "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",563 + href_esc(f)564 + ));565 + } else {566 + let display = String::from_utf8_lossy(&bytes).into_owned();567 + body.push_str(&render_text_pre(&display));568 + }569 + let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body);570 + let dest = out.join("files").join(format!("{f}.html"));571 + if let Some(parent) = dest.parent() {572 + std::fs::create_dir_all(parent)573 + .map_err(|e| format!("hu13: create files parent: {e}"))?;574 + }575 + std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;576 + }577 +578 + // Snapshot last so the index can link sizes.579 + let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;580 + let bundle_size = std::fs::metadata(&snap.bundle)581 + .map(|m| m.len())582 + .unwrap_or(0);583 + let tarball_size = std::fs::metadata(&snap.tarball)584 + .map(|m| m.len())585 + .unwrap_or(0);586 +587 + // index.html header.588 + let mut body = format!("<h1>{}</h1>\n", esc(&name));589 + match &description {590 + Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),591 + None => body.push_str("<p class=\"muted\">No description.</p>\n"),592 + }593 + if let Some(url) = &clone_url {594 + body.push_str(&format!(595 + "<p>Clone: <code>git clone {}</code></p>\n",596 + esc(url)597 + ));598 + }599 + body.push_str(&format!(600 + "<p>Branch <code>{}</code> @ <code>{}</code></p>\n",601 + esc(&branch),602 + esc(&short_sha)603 + ));604 + body.push_str(605 + "<p>issues (0) · pulls (0) · tasks (0) <!-- TODO(m3): real f0rge counts --></p>\n",606 + );607 + body.push_str("<p>CI: unknown (stub)</p>\n");608 + body.push_str(&format!(609 + "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \610 + <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"611 + ));612 +613 + body.push_str("<h2>Files</h2>\n<ul class=\"tree\">\n");614 + for e in dir_entries.get("").cloned().unwrap_or_default() {615 + if e.is_dir {616 + body.push_str(&format!(617 + "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",618 + href_esc(&e.name),619 + esc(&e.name)620 + ));621 + } else {622 + body.push_str(&format!(623 + "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",624 + href_esc(&e.name),625 + esc(&e.name),626 + href_esc(&e.name)627 + ));628 + }629 + }630 + body.push_str("</ul>\n");631 +632 + body.push_str("<h2>README</h2>\n");633 + match &readme {634 + Some((path, blob)) => {635 + body.push_str(&format!(636 + "<p class=\"muted\">from <code>{}</code></p>\n",637 + esc(path)638 + ));639 + if is_markdown(path) || path.to_lowercase().contains("readme") {640 + body.push_str(&markdown_to_html(&blob.display));641 + } else {642 + body.push_str(&render_text_pre(&blob.display));643 + }644 + }645 + None => body.push_str("<p class=\"muted\">No README found.</p>\n"),646 + }647 +648 + let index = crate::layout::page(&name, &nav_code(), &body);649 + std::fs::write(out.join("index.html"), index)650 + .map_err(|e| format!("hu13: write index.html: {e}"))?;651 +652 + Ok(BuildSummary {653 + branch,654 + short_sha,655 + file_count: files.len(),656 + })657 +}658 +659 +/// Collect every file path under `dir` (relative, `/`-separated).660 +#[cfg(test)]661 +pub fn test_all_files(dir: &Path) -> Vec<std::path::PathBuf> {662 + let mut out = Vec::new();663 + let mut stack = vec![dir.to_path_buf()];664 + while let Some(d) = stack.pop() {665 + let rd = std::fs::read_dir(&d).expect("read_dir");666 + for ent in rd {667 + let ent = ent.expect("dir entry");668 + let p = ent.path();669 + if p.is_dir() {670 + stack.push(p);671 + } else {672 + out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());673 + }674 + }675 + }676 + out.sort();677 + out678 +}679 +680 +#[cfg(test)]681 +mod tests {682 + use super::*;683 + use std::path::PathBuf;684 + use std::process::Command;685 + use std::sync::atomic::{AtomicU64, Ordering};686 +687 + static COUNTER: AtomicU64 = AtomicU64::new(0);688 +689 + fn mktemp(prefix: &str) -> PathBuf {690 + let id = COUNTER.fetch_add(1, Ordering::SeqCst);691 + let dir =692 + std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id()));693 + std::fs::create_dir_all(&dir).expect("mktemp");694 + dir695 + }696 +697 + fn git(repo: &Path, args: &[&str]) {698 + let st = Command::new("git")699 + .arg("-C")700 + .arg(repo)701 + .args(args)702 + .status()703 + .expect("spawn git");704 + assert!(st.success(), "git {args:?} failed in {}", repo.display());705 + }706 +707 + fn opts() -> BuildOptions {708 + BuildOptions {709 + branch_flag: None,710 + clone_url_flag: None,711 + title_override: None,712 + max_log: 100,713 + }714 + }715 +716 + /// Fixture: 10 files, no `.forge/` (the no-forge case) —717 + /// README + subdir + binary + >256KB file.718 + fn fixture_repo() -> PathBuf {719 + let dir = mktemp("fixture");720 + git(&dir, &["init", "-b", "main"]);721 + git(&dir, &["config", "user.name", "hu13-test"]);722 + git(&dir, &["config", "user.email", "[email protected]"]);723 + git(&dir, &["config", "commit.gpgsign", "false"]);724 + std::fs::write(725 + dir.join("README.md"),726 + "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n<script>alert(1)</script>\n",727 + )728 + .unwrap();729 + std::fs::create_dir_all(dir.join("src")).unwrap();730 + std::fs::write(731 + dir.join("src").join("main.rs"),732 + "fn main() {\n println!(\"hi\");\n}\n",733 + )734 + .unwrap();735 + std::fs::write(736 + dir.join("src").join("lib.rs"),737 + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",738 + )739 + .unwrap();740 + std::fs::create_dir_all(dir.join("docs")).unwrap();741 + std::fs::write(742 + dir.join("docs").join("guide.md"),743 + "# Guide\n\nSome *docs* here.\n",744 + )745 + .unwrap();746 + std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap();747 + std::fs::create_dir_all(dir.join("assets")).unwrap();748 + let mut bin: Vec<u8> = (0u8..=255).collect();749 + bin.extend_from_slice(b"\x00\x01\x02binary tail");750 + std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap();751 + std::fs::create_dir_all(dir.join("data")).unwrap();752 + std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap();753 + std::fs::create_dir_all(dir.join("sub")).unwrap();754 + std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();755 + std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap();756 + std::fs::write(757 + dir.join("LICENSE"),758 + "MIT License\n\nPermission is hereby granted.\n",759 + )760 + .unwrap();761 + git(&dir, &["add", "-A"]);762 + git(&dir, &["commit", "-m", "fixture commit"]);763 + dir764 + }765 +766 + fn build_fixture() -> (PathBuf, PathBuf) {767 + let repo = fixture_repo();768 + let out = mktemp("out");769 + let summary = run_build(&repo, &out, &opts()).expect("build fixture");770 + assert_eq!(summary.branch, "main");771 + assert_eq!(summary.file_count, 10);772 + assert!(!summary.short_sha.is_empty());773 + (repo, out)774 + }775 +776 + #[test]777 + fn index_has_tree_and_readme_html() {778 + let (_repo, out) = build_fixture();779 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");780 + // File tree (dirs-first flat root listing).781 + for name in ["src", "docs", "assets", "data", "sub"] {782 + assert!(index.contains(name), "tree should list {name}");783 + }784 + for name in ["README.md", "notes.txt", "LICENSE"] {785 + assert!(index.contains(name), "tree should list {name}");786 + }787 + // README rendered to HTML (markdown heading), raw HTML escaped.788 + assert!(index.contains("Hello fixture"), "README body rendered");789 + assert!(790 + !index.contains("<script>alert(1)</script>"),791 + "raw HTML escaped"792 + );793 + assert!(index.contains("<script>"), "escaped HTML present");794 + // Header: branch + short sha, f0rge counts stub, CI stub.795 + assert!(index.contains("main"), "branch shown");796 + assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker");797 + assert!(index.contains("issues (0)"), "f0rge counts as 0");798 + assert!(index.contains("CI:"), "CI verdict stub");799 + // Snapshot links with sizes.800 + assert!(index.contains("repo.bundle"), "bundle linked");801 + assert!(index.contains("repo.tar.gz"), "tarball linked");802 + // No clone URL line when no origin remote is configured.803 + assert!(!index.contains("git clone"), "no URL line without remote");804 + }805 +806 + #[test]807 + fn clone_url_flag_wins() {808 + let repo = fixture_repo();809 + let out = mktemp("out-flag");810 + let mut o = opts();811 + o.clone_url_flag = Some("https://example.com/x.git".to_string());812 + run_build(&repo, &out, &o).expect("build");813 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");814 + assert!(index.contains("git clone https://example.com/x.git"));815 + }816 +817 + #[test]818 + fn raw_byte_identical_for_all_files() {819 + let (repo, out) = build_fixture();820 + let raws = test_all_files(&out.join("raw"));821 + assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}");822 + for rel in &raws {823 + let want = std::fs::read(repo.join(rel)).expect("repo file");824 + let got = std::fs::read(out.join("raw").join(rel)).expect("raw file");825 + assert_eq!(got, want, "byte-identical: {}", rel.display());826 + }827 + }828 +829 + #[test]830 + fn placeholder_for_binary_and_big_file() {831 + let (_repo, out) = build_fixture();832 + for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] {833 + let html = std::fs::read_to_string(out.join(page)).expect(page);834 + assert!(html.contains("placeholder"), "{page} has placeholder");835 + assert!(html.contains("/raw/"), "{page} links a download");836 + }837 + // Binary bytes must never be inlined into the page.838 + let bin_page =839 + std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page");840 + assert!(!bin_page.contains("binary tail"), "binary not inlined");841 + // Small text file gets line numbers + raw link, no placeholder.842 + let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");843 + assert!(txt.contains("/raw/notes.txt"), "raw link");844 + assert!(!txt.contains("placeholder"), "text file inlined");845 + assert!(846 + txt.contains("<") || txt.contains("plain notes"),847 + "escaped pre body"848 + );849 + }850 +851 + #[test]852 + fn md_files_are_rendered() {853 + let (_repo, out) = build_fixture();854 + let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");855 + assert!(856 + html.contains("<h1>Guide</h1>"),857 + "markdown rendered, got: {html}"858 + );859 + assert!(html.contains("/raw/docs/guide.md"), "raw link kept");860 + }861 +862 + #[test]863 + fn bundle_clones() {864 + let (_repo, out) = build_fixture();865 + assert!(out.join("repo.bundle").exists());866 + assert!(out.join("repo.tar.gz").exists());867 + let dest_parent = mktemp("clone");868 + let dest = dest_parent.join("cloned");869 + let st = Command::new("git")870 + .arg("clone")871 + .arg("-b")872 + .arg("main")873 + .arg(out.join("repo.bundle"))874 + .arg(&dest)875 + .status()876 + .expect("git clone");877 + assert!(st.success(), "bundle clones");878 + assert!(dest.join("README.md").exists());879 + assert!(dest.join("sub").join("nested.txt").exists());880 + }881 +882 + #[test]883 + fn no_forge_build_succeeds() {884 + // Fixture has no `.forge/` at all; build must still exit Ok (0).885 + let repo = fixture_repo();886 + assert!(!repo.join(".forge").exists());887 + let out = mktemp("out-noforge");888 + let res = run_build(&repo, &out, &opts());889 + assert!(res.is_ok(), "no-forge build exits 0, got {res:?}");890 + assert!(out.join("index.html").exists());891 + }892 +893 + #[test]894 + fn readme_chain_prefers_root_then_wiki() {895 + let dir = mktemp("readme-chain");896 + git(&dir, &["init", "-b", "main"]);897 + git(&dir, &["config", "user.name", "hu13-test"]);898 + git(&dir, &["config", "user.email", "[email protected]"]);899 + git(&dir, &["config", "commit.gpgsign", "false"]);900 + // Wiki-only README first.901 + std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();902 + std::fs::write(903 + dir.join(".forge").join("wiki").join("README.md"),904 + "# Wiki readme\n",905 + )906 + .unwrap();907 + std::fs::write(dir.join("notes.txt"), "x\n").unwrap();908 + git(&dir, &["add", "-A"]);909 + git(&dir, &["commit", "-m", "wiki readme only"]);910 + let (path, _) = find_readme(&dir, "main").expect("wiki fallback");911 + assert_eq!(path, ".forge/wiki/README.md");912 + // Root README.md wins once added.913 + std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap();914 + git(&dir, &["add", "-A"]);915 + git(&dir, &["commit", "-m", "root readme"]);916 + let (path, _) = find_readme(&dir, "main").expect("root");917 + assert_eq!(path, "README.md");918 + }919 +920 + #[test]921 + fn markdown_escapes_raw_html() {922 + let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");923 + assert!(html.contains("<h1>hi</h1>"), "heading renders");924 + assert!(!html.contains("<script>"), "no raw passthrough");925 + assert!(html.contains("<script>"), "escaped instead");926 + assert!(927 + html.contains("<strong>bold</strong>"),928 + "legit markdown intact"929 + );930 + }931 +}932 diff --git a/src/main.rs b/src/main.rs933 index 100cafe..f7902e5 100644934 --- a/src/main.rs935 +++ b/src/main.rs936 @@ -1,3 +1,4 @@937 +mod build;938 mod gitx;939 mod layout;940 941 @@ -27,7 +28,7 @@ Run `hu13 <subcommand> --help` for subcommand help.942 fn print_build_help() {943 print!(944 "\945 -hu13-build — build a static site from a repo checkout (stub)946 +hu13-build — build a static site from a repo checkout947 948 USAGE:949 hu13 build <REPO> -o <OUT> [OPTIONS]950 @@ -42,8 +43,6 @@ OPTIONS:951 --title <S> Site title override952 --max-log <N> Max commits in log [default: 100]953 -h, --help Print this help954 -955 -NOTE: m1/scaffold stub — prints help and exits 0, no output written yet.956 "957 );958 }959 @@ -98,8 +97,102 @@ fn run_build(args: &[String]) -> i32 {960 print_build_help();961 return 0;962 }963 - print_build_help();964 - 0965 + let mut repo: Option<String> = None;966 + let mut out: Option<String> = None;967 + let mut branch: Option<String> = None;968 + let mut clone_url: Option<String> = None;969 + let mut title: Option<String> = None;970 + let mut max_log: usize = 100;971 + let mut i = 0;972 + while i < args.len() {973 + match args[i].as_str() {974 + "-o" | "--output" => {975 + i += 1;976 + if i >= args.len() {977 + eprintln!("hu13 build: -o needs a value");978 + return 2;979 + }980 + out = Some(args[i].clone());981 + }982 + "--branch" => {983 + i += 1;984 + if i >= args.len() {985 + eprintln!("hu13 build: --branch needs a value");986 + return 2;987 + }988 + branch = Some(args[i].clone());989 + }990 + "--clone-url" => {991 + i += 1;992 + if i >= args.len() {993 + eprintln!("hu13 build: --clone-url needs a value");994 + return 2;995 + }996 + clone_url = Some(args[i].clone());997 + }998 + "--title" => {999 + i += 1;1000 + if i >= args.len() {1001 + eprintln!("hu13 build: --title needs a value");1002 + return 2;1003 + }1004 + title = Some(args[i].clone());1005 + }1006 + "--max-log" => {1007 + i += 1;1008 + if i >= args.len() {1009 + eprintln!("hu13 build: --max-log needs a value");1010 + return 2;1011 + }1012 + match args[i].parse::<usize>() {1013 + Ok(n) => max_log = n,1014 + Err(_) => {1015 + eprintln!("hu13 build: --max-log must be a number");1016 + return 2;1017 + }1018 + }1019 + }1020 + s if s.starts_with('-') => {1021 + eprintln!("hu13 build: unknown option '{s}'");1022 + return 2;1023 + }1024 + s => {1025 + if repo.is_some() {1026 + eprintln!("hu13 build: unexpected argument '{s}'");1027 + return 2;1028 + }1029 + repo = Some(s.to_string());1030 + }1031 + }1032 + i += 1;1033 + }1034 + let (Some(repo), Some(out)) = (repo, out) else {1035 + eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--title S] [--max-log N]");1036 + return 2;1037 + };1038 + let opts = build::BuildOptions {1039 + branch_flag: branch,1040 + clone_url_flag: clone_url,1041 + title_override: title,1042 + max_log,1043 + };1044 + match build::run_build(1045 + std::path::Path::new(&repo),1046 + std::path::Path::new(&out),1047 + &opts,1048 + ) {1049 + Ok(summary) => {1050 + println!(1051 + "hu13: built branch {} @ {} ({} files) -> {}",1052 + summary.branch, summary.short_sha, summary.file_count, out1053 + );1054 + 01055 + }1056 + Err(e) => {1057 + eprintln!("{e}");1058 + 11059 + }1060 + }1061 }1062 1063 fn run_serve(args: &[String]) -> i32 {1064 diff --git a/tests/build_cli.rs b/tests/build_cli.rs1065 new file mode 1006441066 index 0000000..652b59c1067 --- /dev/null1068 +++ b/tests/build_cli.rs1069 @@ -0,0 +1,56 @@1070 +//! CLI-level test: `hu13 build` on a repo without `.forge/` exits 0.1071 +1072 +use std::path::{Path, PathBuf};1073 +use std::process::Command;1074 +1075 +fn git(repo: &Path, args: &[&str]) {1076 + let st = Command::new("git")1077 + .arg("-C")1078 + .arg(repo)1079 + .args(args)1080 + .status()1081 + .expect("spawn git");1082 + assert!(st.success(), "git {args:?} failed");1083 +}1084 +1085 +#[test]1086 +fn build_no_forge_repo_exits_zero() {1087 + let dir = std::env::temp_dir().join(format!(1088 + "hu13-cli-noforge-{}-{}",1089 + std::process::id(),1090 + std::time::SystemTime::now()1091 + .duration_since(std::time::UNIX_EPOCH)1092 + .map(|d| d.as_nanos())1093 + .unwrap_or(0)1094 + ));1095 + std::fs::create_dir_all(&dir).unwrap();1096 + git(&dir, &["init", "-b", "main"]);1097 + git(&dir, &["config", "user.name", "hu13-test"]);1098 + git(&dir, &["config", "user.email", "[email protected]"]);1099 + git(&dir, &["config", "commit.gpgsign", "false"]);1100 + std::fs::write(dir.join("README.md"), "# cli fixture\n").unwrap();1101 + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1102 + git(&dir, &["add", "-A"]);1103 + git(&dir, &["commit", "-m", "init"]);1104 + assert!(!dir.join(".forge").exists());1105 +1106 + let out: PathBuf = dir.join("..").join(format!(1107 + "hu13-cli-out-{}-{}",1108 + std::process::id(),1109 + std::time::SystemTime::now()1110 + .duration_since(std::time::UNIX_EPOCH)1111 + .map(|d| d.as_nanos())1112 + .unwrap_or(1)1113 + ));1114 + let bin = env!("CARGO_BIN_EXE_hu13");1115 + let st = Command::new(bin)1116 + .arg("build")1117 + .arg(&dir)1118 + .arg("-o")1119 + .arg(&out)1120 + .status()1121 + .expect("spawn hu13");1122 + assert!(st.success(), "no-forge build exits 0, got {st}");1123 + assert!(out.join("index.html").exists());1124 + assert!(out.join("raw").join("README.md").exists());1125 +}1126 -- 1127 2.55.01128