#34 m6/hide-empty

mergedpassopened
details
branches
master ← m6/hide-empty
updated
id
59f46a16-161c-419f-a9ba-59cfdfb5b748

Patch

src/build.rs+46 −15
diff --git a/src/build.rs b/src/build.rsindex d70dc3f..70aa5d9 100644--- 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
diff --git a/src/wiki.rs b/src/wiki.rsindex 4f20af8..02edd5c 100644--- 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
diff --git a/tests/hide_empty.rs b/tests/hide_empty.rsnew file mode 100644index 0000000..5b19f01--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls