#34 m6/hide-empty
Patch
src/build.rs+46 −15--- a/src/build.rs+++ b/src/build.rs@@ -757,18 +757,37 @@ fn render_text_pre(display: &str) -> String {757757 }758758 759759 /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:760-/// open means `state == "open"` exactly; drafts count as open).761-fn nav_bar(issue_count: usize, pr_count: usize) -> String {762- format!(763- "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \764- <a href=\"/issues/index.html\">issues ({issue_count})</a> · \765- <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \766- <a href=\"/tasks/index.html\">tasks</a> · \767- <a href=\"/ci/index.html\">ci</a> · \768- <a href=\"/releases/index.html\">releases</a> · \769- <a href=\"/wiki/index.html\">wiki</a> · \770- <a href=\"/agents.html\">agents</a>"771- )760+/// open means `state == "open"` exactly; drafts count as open) and761+/// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /762+/// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`763+/// always stay (they carry the submit path), as do `code` / `commits` /764+/// `ci` / `agents`. Hidden sections still build (deep links keep765+/// working); only the nav omits the tab.766+fn nav_bar(767+ issue_count: usize,768+ pr_count: usize,769+ has_tasks: bool,770+ has_releases: bool,771+ has_wiki: bool,772+) -> String {773+ let mut tabs = vec![774+ "<a href=\"/\">code</a>".to_string(),775+ "<a href=\"/commits.html\">commits</a>".to_string(),776+ format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),777+ format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),778+ ];779+ if has_tasks {780+ tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());781+ }782+ tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());783+ if has_releases {784+ tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());785+ }786+ if has_wiki {787+ tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());788+ }789+ tabs.push("<a href=\"/agents.html\">agents</a>".to_string());790+ tabs.join(" · ")772791 }773792 774793 /// Write `out/style.css` from the embedded static file.@@ -843,9 +862,19 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu843862 // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front844863 // so every page (including file pages below) gets a nav with live845864 // open counts (m6 open-counts, f0rge #29). Loading never fails:846- // bad files are skipped + warned.865+ // bad files are skipped + warned. Releases load here too (m6866+ // hide-empty, f0rge #38) so the nav knows whether the releases tab867+ // has content; wiki presence comes from `wiki::page_count` (same868+ // filters as `wiki::render`, read-only, no files emitted).847869 let forge_data = forge::load(repo);848- let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());870+ let release_data = releases::load(repo);871+ let nav = nav_bar(872+ forge_data.open_issue_count(),873+ forge_data.open_pr_count(),874+ !forge_data.tasks.is_empty(),875+ !release_data.is_empty(),876+ wiki::page_count(repo) > 0,877+ );849878 850879 // Raw files first: byte-identical copies.851880 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();@@ -975,8 +1004,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu9751004 feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;9761005 let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;9771006 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.1007+ // Tasks/releases/wiki indexes always render even when empty (m61008+ // hide-empty, f0rge #38): only the nav hides the tab, deep links1009+ // keep working with their "No X yet" empty states.9781010 forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;979- let release_data = releases::load(repo);9801011 releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;9811012 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;9821013 agents::render(
src/wiki.rs+90 −0--- a/src/wiki.rs+++ b/src/wiki.rs@@ -66,6 +66,96 @@ pub fn page_title(body: &str, filename: &str) -> String {6666 filename.to_string()6767 }6868 69+/// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38).70+///71+/// Same walk + filters as [`render`] (symlinked dirs/files skipped,72+/// oversize files skipped, non-UTF-8 files skipped), so the header nav73+/// hides the wiki tab exactly when the index would say "No wiki pages74+/// yet". Read-only: emits no files, only the `eprintln!` warnings that75+/// [`render`] would emit for skipped entries.76+#[must_use]77+pub fn page_count(repo: &Path) -> usize {78+ let root = repo.join(".forge").join("wiki");79+ if !root.exists() {80+ return 0;81+ }82+ // Same walk as `render`: `/`-separated relative dirs, symlinked83+ // dirs never descended, symlinked files skipped later.84+ let mut rel_files: Vec<String> = Vec::new();85+ let mut stack: Vec<String> = vec![String::new()];86+ while let Some(dir) = stack.pop() {87+ let abs = if dir.is_empty() {88+ root.clone()89+ } else {90+ root.join(&dir)91+ };92+ let rd = match std::fs::read_dir(&abs) {93+ Ok(rd) => rd,94+ Err(e) => {95+ eprintln!(96+ "hu13: wiki: cannot list {}: {e}",97+ if dir.is_empty() { "." } else { &dir }98+ );99+ continue;100+ }101+ };102+ for ent in rd {103+ let ent = match ent {104+ Ok(ent) => ent,105+ Err(e) => {106+ eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");107+ continue;108+ }109+ };110+ let meta = match std::fs::symlink_metadata(ent.path()) {111+ Ok(m) => m,112+ Err(e) => {113+ eprintln!(114+ "hu13: wiki: cannot stat {}: {e}",115+ ent.file_name().to_string_lossy()116+ );117+ continue;118+ }119+ };120+ if meta.file_type().is_symlink() {121+ continue;122+ }123+ let name = ent.file_name().to_string_lossy().into_owned();124+ let rel = if dir.is_empty() {125+ name126+ } else {127+ format!("{dir}/{name}")128+ };129+ if meta.is_dir() {130+ stack.push(rel);131+ } else if meta.is_file() {132+ rel_files.push(rel);133+ }134+ }135+ }136+ // Same per-file filters as `render`: oversize + unreadable +137+ // non-UTF-8 files never become pages, so they never keep the tab.138+ let mut count = 0;139+ for rel in rel_files {140+ let abs = root.join(&rel);141+ let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);142+ if len > WIKI_MAX_BYTES {143+ continue;144+ }145+ let bytes = match std::fs::read(&abs) {146+ Ok(b) => b,147+ Err(_) => continue,148+ };149+ if bytes.len() as u64 > WIKI_MAX_BYTES {150+ continue;151+ }152+ if std::str::from_utf8(&bytes).is_err() {153+ continue;154+ }155+ count += 1;156+ }157+ count158+}69159 /// Render `wiki/index.html` plus one `wiki/<path>.html` per page.70160 ///71161 /// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki
tests/hide_empty.rs+152 −1--- /dev/null+++ b/tests/hide_empty.rs@@ -0,0 +1,152 @@1+//! Conditional nav tabs (m6 hide-empty, f0rge issue #38):2+//! `tasks` / `releases` / `wiki` render in the header ONLY when non-empty;3+//! `issues` / `pulls` / `ci` / `code` / `commits` / `agents` always stay.4+//! Hidden sections still build (deep links keep working with their5+//! "No X yet" empty states); only the nav omits the tab.6+7+use std::path::{Path, PathBuf};8+use std::process::Command;9+10+fn bin() -> &'static str {11+ env!("CARGO_BIN_EXE_hu13")12+}13+14+fn fresh(prefix: &str) -> PathBuf {15+ let dir = std::env::temp_dir().join(format!(16+ "hu13-hide-empty-{prefix}-{}-{}",17+ std::process::id(),18+ std::time::SystemTime::now()19+ .duration_since(std::time::UNIX_EPOCH)20+ .map(|d| d.as_nanos())21+ .unwrap_or(0)22+ ));23+ std::fs::create_dir_all(&dir).unwrap();24+ dir25+}26+27+fn git(repo: &Path, args: &[&str]) {28+ let st = Command::new("git")29+ .arg("-C")30+ .arg(repo)31+ .args(args)32+ .status()33+ .expect("spawn git");34+ assert!(st.success(), "git {args:?} failed");35+}36+37+fn forge_write(repo: &Path, rel: &str, content: &str) {38+ let dest = repo.join(".forge").join(rel);39+ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();40+ std::fs::write(&dest, content).unwrap();41+}42+43+fn bare_repo() -> PathBuf {44+ let dir = fresh("repo");45+ git(&dir, &["init", "-b", "main"]);46+ git(&dir, &["config", "user.name", "hu13-test"]);47+ git(&dir, &["config", "user.email", "[email protected]"]);48+ git(&dir, &["config", "commit.gpgsign", "false"]);49+ std::fs::write(dir.join("README.md"), "# hide-empty fixture\n").unwrap();50+ git(&dir, &["add", "-A"]);51+ git(&dir, &["commit", "-m", "init"]);52+ dir53+}54+55+fn build(repo: &Path) -> PathBuf {56+ let out = fresh("out");57+ let st = Command::new(bin())58+ .arg("build")59+ .arg(repo)60+ .arg("-o")61+ .arg(&out)62+ .status()63+ .expect("spawn hu13 build");64+ assert!(st.success(), "build exits 0");65+ out66+}67+68+fn header_of(out: &Path, rel: &str) -> String {69+ let html = std::fs::read_to_string(out.join(rel)).expect(rel);70+ html.split("</header>")71+ .next()72+ .expect("header close")73+ .to_string()74+}75+76+const ALWAYS_TABS: [&str; 6] = [77+ "<a href=\"/\"",78+ "<a href=\"/commits.html\"",79+ "<a href=\"/issues/index.html\"",80+ "<a href=\"/pulls/index.html\"",81+ "<a href=\"/ci/index.html\"",82+ "<a href=\"/agents.html\"",83+];84+85+const CONDITIONAL_TABS: [(&str, &str); 3] = [86+ ("tasks", "<a href=\"/tasks/index.html\""),87+ ("releases", "<a href=\"/releases/index.html\""),88+ ("wiki", "<a href=\"/wiki/index.html\""),89+];90+91+#[test]92+fn empty_sections_hide_tabs_but_pages_still_build() {93+ let out = build(&bare_repo());94+ // Empty-state pages still build (deep links keep working).95+ for (page, marker) in [96+ ("tasks/index.html", "No tasks yet"),97+ ("releases/index.html", "No releases yet"),98+ ("wiki/index.html", "No wiki pages yet"),99+ ] {100+ let html = std::fs::read_to_string(out.join(page)).expect(page);101+ assert!(html.contains(marker), "{page} keeps its empty state");102+ }103+ // Every sampled header hides the three tabs but keeps the rest.104+ for page in ["index.html", "commits.html", "tasks/index.html"] {105+ let header = header_of(&out, page);106+ for (name, needle) in CONDITIONAL_TABS {107+ assert!(!header.contains(needle), "{page} header hides empty {name}");108+ }109+ for needle in ALWAYS_TABS {110+ assert!(111+ header.contains(needle),112+ "{page} header keeps always-shown {needle}"113+ );114+ }115+ }116+}117+118+#[test]119+fn full_forge_shows_all_tabs() {120+ let repo = bare_repo();121+ forge_write(122+ &repo,123+ "tasks/77777777-7777-4777-8777-777777777777.md",124+ "---\nid: 77777777-7777-4777-8777-777777777777\ntitle: A task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",125+ );126+ forge_write(127+ &repo,128+ "releases/v1.0.md",129+ "---\nversion: v1.0\ntitle: First\ncreated: 2026-09-10T02:00:00Z\n---\n# v1.0 notes\n",130+ );131+ forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");132+ git(&repo, &["add", "-A"]);133+ git(&repo, &["commit", "-m", "full forge"]);134+ let out = build(&repo);135+ let header = header_of(&out, "index.html");136+ for (name, needle) in CONDITIONAL_TABS {137+ assert!(138+ header.contains(needle),139+ "populated {name} tab shown in header"140+ );141+ }142+ for needle in ALWAYS_TABS {143+ assert!(header.contains(needle), "header keeps {needle}");144+ }145+ // Populated pages list their content.146+ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");147+ assert!(tasks.contains("A task"), "task listed");148+ let releases = std::fs::read_to_string(out.join("releases/index.html")).expect("releases");149+ assert!(releases.contains("v1.0"), "release listed");150+ let wiki = std::fs::read_to_string(out.join("wiki/index.html")).expect("wiki");151+ assert!(wiki.contains("Notes"), "wiki page listed");152+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.