#4 m1/code-tab

mergedpassopened
details
branches
master ← m1/code-tab
updated
id
c455884a-764a-4202-9bb8-298b284dc27a

Patch

Cargo.lock+195 −0
diff --git a/Cargo.lock b/Cargo.lockindex 9569aba..d1596b8 100644--- 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
diff --git a/Cargo.toml b/Cargo.tomlindex d163f7d..12c7eb7 100644--- 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
diff --git a/src/build.rs b/src/build.rsnew file mode 100644index 0000000..5280528--- /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("&lt;script&gt;"), "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("&lt;") || 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("&lt;script&gt;"), "escaped instead");684+        assert!(685+            html.contains("<strong>bold</strong>"),686+            "legit markdown intact"687+        );688+    }689+}
src/main.rs+98 −5
diff --git a/src/main.rs b/src/main.rsindex 100cafe..f7902e5 100644--- 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
diff --git a/tests/build_cli.rs b/tests/build_cli.rsnew file mode 100644index 0000000..652b59c--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls