1 From 37d631341ec843514cc6637eb43479bd03c8b56b Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 08:38:01 +01004 Subject: [PATCH] m6/hide-empty: conditional nav tabs, pages still build5 6 ---7 src/build.rs | 61 +++++++++++++-----8 src/wiki.rs | 90 ++++++++++++++++++++++++++9 tests/hide_empty.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++10 3 files changed, 288 insertions(+), 15 deletions(-)11 create mode 100644 tests/hide_empty.rs12 13 diff --git a/src/build.rs b/src/build.rs14 index d70dc3f..70aa5d9 10064415 --- a/src/build.rs16 +++ b/src/build.rs17 @@ -757,18 +757,37 @@ fn render_text_pre(display: &str) -> String {18 }19 20 /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:21 -/// open means `state == "open"` exactly; drafts count as open).22 -fn nav_bar(issue_count: usize, pr_count: usize) -> String {23 - format!(24 - "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \25 - <a href=\"/issues/index.html\">issues ({issue_count})</a> · \26 - <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \27 - <a href=\"/tasks/index.html\">tasks</a> · \28 - <a href=\"/ci/index.html\">ci</a> · \29 - <a href=\"/releases/index.html\">releases</a> · \30 - <a href=\"/wiki/index.html\">wiki</a> · \31 - <a href=\"/agents.html\">agents</a>"32 - )33 +/// open means `state == "open"` exactly; drafts count as open) and34 +/// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /35 +/// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`36 +/// always stay (they carry the submit path), as do `code` / `commits` /37 +/// `ci` / `agents`. Hidden sections still build (deep links keep38 +/// working); only the nav omits the tab.39 +fn nav_bar(40 + issue_count: usize,41 + pr_count: usize,42 + has_tasks: bool,43 + has_releases: bool,44 + has_wiki: bool,45 +) -> String {46 + let mut tabs = vec![47 + "<a href=\"/\">code</a>".to_string(),48 + "<a href=\"/commits.html\">commits</a>".to_string(),49 + format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),50 + format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),51 + ];52 + if has_tasks {53 + tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());54 + }55 + tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());56 + if has_releases {57 + tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());58 + }59 + if has_wiki {60 + tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());61 + }62 + tabs.push("<a href=\"/agents.html\">agents</a>".to_string());63 + tabs.join(" · ")64 }65 66 /// Write `out/style.css` from the embedded static file.67 @@ -843,9 +862,19 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu68 // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front69 // so every page (including file pages below) gets a nav with live70 // open counts (m6 open-counts, f0rge #29). Loading never fails:71 - // bad files are skipped + warned.72 + // bad files are skipped + warned. Releases load here too (m673 + // hide-empty, f0rge #38) so the nav knows whether the releases tab74 + // has content; wiki presence comes from `wiki::page_count` (same75 + // filters as `wiki::render`, read-only, no files emitted).76 let forge_data = forge::load(repo);77 - let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());78 + let release_data = releases::load(repo);79 + let nav = nav_bar(80 + forge_data.open_issue_count(),81 + forge_data.open_pr_count(),82 + !forge_data.tasks.is_empty(),83 + !release_data.is_empty(),84 + wiki::page_count(repo) > 0,85 + );86 87 // Raw files first: byte-identical copies.88 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();89 @@ -975,8 +1004,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu90 feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;91 let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;92 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.93 + // Tasks/releases/wiki indexes always render even when empty (m694 + // hide-empty, f0rge #38): only the nav hides the tab, deep links95 + // keep working with their "No X yet" empty states.96 forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;97 - let release_data = releases::load(repo);98 releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;99 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;100 agents::render(101 diff --git a/src/wiki.rs b/src/wiki.rs102 index 4f20af8..02edd5c 100644103 --- a/src/wiki.rs104 +++ b/src/wiki.rs105 @@ -66,6 +66,96 @@ pub fn page_title(body: &str, filename: &str) -> String {106 filename.to_string()107 }108 109 +/// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38).110 +///111 +/// Same walk + filters as [`render`] (symlinked dirs/files skipped,112 +/// oversize files skipped, non-UTF-8 files skipped), so the header nav113 +/// hides the wiki tab exactly when the index would say "No wiki pages114 +/// yet". Read-only: emits no files, only the `eprintln!` warnings that115 +/// [`render`] would emit for skipped entries.116 +#[must_use]117 +pub fn page_count(repo: &Path) -> usize {118 + let root = repo.join(".forge").join("wiki");119 + if !root.exists() {120 + return 0;121 + }122 + // Same walk as `render`: `/`-separated relative dirs, symlinked123 + // dirs never descended, symlinked files skipped later.124 + let mut rel_files: Vec<String> = Vec::new();125 + let mut stack: Vec<String> = vec![String::new()];126 + while let Some(dir) = stack.pop() {127 + let abs = if dir.is_empty() {128 + root.clone()129 + } else {130 + root.join(&dir)131 + };132 + let rd = match std::fs::read_dir(&abs) {133 + Ok(rd) => rd,134 + Err(e) => {135 + eprintln!(136 + "hu13: wiki: cannot list {}: {e}",137 + if dir.is_empty() { "." } else { &dir }138 + );139 + continue;140 + }141 + };142 + for ent in rd {143 + let ent = match ent {144 + Ok(ent) => ent,145 + Err(e) => {146 + eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");147 + continue;148 + }149 + };150 + let meta = match std::fs::symlink_metadata(ent.path()) {151 + Ok(m) => m,152 + Err(e) => {153 + eprintln!(154 + "hu13: wiki: cannot stat {}: {e}",155 + ent.file_name().to_string_lossy()156 + );157 + continue;158 + }159 + };160 + if meta.file_type().is_symlink() {161 + continue;162 + }163 + let name = ent.file_name().to_string_lossy().into_owned();164 + let rel = if dir.is_empty() {165 + name166 + } else {167 + format!("{dir}/{name}")168 + };169 + if meta.is_dir() {170 + stack.push(rel);171 + } else if meta.is_file() {172 + rel_files.push(rel);173 + }174 + }175 + }176 + // Same per-file filters as `render`: oversize + unreadable +177 + // non-UTF-8 files never become pages, so they never keep the tab.178 + let mut count = 0;179 + for rel in rel_files {180 + let abs = root.join(&rel);181 + let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);182 + if len > WIKI_MAX_BYTES {183 + continue;184 + }185 + let bytes = match std::fs::read(&abs) {186 + Ok(b) => b,187 + Err(_) => continue,188 + };189 + if bytes.len() as u64 > WIKI_MAX_BYTES {190 + continue;191 + }192 + if std::str::from_utf8(&bytes).is_err() {193 + continue;194 + }195 + count += 1;196 + }197 + count198 +}199 /// Render `wiki/index.html` plus one `wiki/<path>.html` per page.200 ///201 /// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki202 diff --git a/tests/hide_empty.rs b/tests/hide_empty.rs203 new file mode 100644204 index 0000000..5b19f01205 --- /dev/null206 +++ b/tests/hide_empty.rs207 @@ -0,0 +1,152 @@208 +//! Conditional nav tabs (m6 hide-empty, f0rge issue #38):209 +//! `tasks` / `releases` / `wiki` render in the header ONLY when non-empty;210 +//! `issues` / `pulls` / `ci` / `code` / `commits` / `agents` always stay.211 +//! Hidden sections still build (deep links keep working with their212 +//! "No X yet" empty states); only the nav omits the tab.213 +214 +use std::path::{Path, PathBuf};215 +use std::process::Command;216 +217 +fn bin() -> &'static str {218 + env!("CARGO_BIN_EXE_hu13")219 +}220 +221 +fn fresh(prefix: &str) -> PathBuf {222 + let dir = std::env::temp_dir().join(format!(223 + "hu13-hide-empty-{prefix}-{}-{}",224 + std::process::id(),225 + std::time::SystemTime::now()226 + .duration_since(std::time::UNIX_EPOCH)227 + .map(|d| d.as_nanos())228 + .unwrap_or(0)229 + ));230 + std::fs::create_dir_all(&dir).unwrap();231 + dir232 +}233 +234 +fn git(repo: &Path, args: &[&str]) {235 + let st = Command::new("git")236 + .arg("-C")237 + .arg(repo)238 + .args(args)239 + .status()240 + .expect("spawn git");241 + assert!(st.success(), "git {args:?} failed");242 +}243 +244 +fn forge_write(repo: &Path, rel: &str, content: &str) {245 + let dest = repo.join(".forge").join(rel);246 + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();247 + std::fs::write(&dest, content).unwrap();248 +}249 +250 +fn bare_repo() -> PathBuf {251 + let dir = fresh("repo");252 + git(&dir, &["init", "-b", "main"]);253 + git(&dir, &["config", "user.name", "hu13-test"]);254 + git(&dir, &["config", "user.email", "[email protected]"]);255 + git(&dir, &["config", "commit.gpgsign", "false"]);256 + std::fs::write(dir.join("README.md"), "# hide-empty fixture\n").unwrap();257 + git(&dir, &["add", "-A"]);258 + git(&dir, &["commit", "-m", "init"]);259 + dir260 +}261 +262 +fn build(repo: &Path) -> PathBuf {263 + let out = fresh("out");264 + let st = Command::new(bin())265 + .arg("build")266 + .arg(repo)267 + .arg("-o")268 + .arg(&out)269 + .status()270 + .expect("spawn hu13 build");271 + assert!(st.success(), "build exits 0");272 + out273 +}274 +275 +fn header_of(out: &Path, rel: &str) -> String {276 + let html = std::fs::read_to_string(out.join(rel)).expect(rel);277 + html.split("</header>")278 + .next()279 + .expect("header close")280 + .to_string()281 +}282 +283 +const ALWAYS_TABS: [&str; 6] = [284 + "<a href=\"/\"",285 + "<a href=\"/commits.html\"",286 + "<a href=\"/issues/index.html\"",287 + "<a href=\"/pulls/index.html\"",288 + "<a href=\"/ci/index.html\"",289 + "<a href=\"/agents.html\"",290 +];291 +292 +const CONDITIONAL_TABS: [(&str, &str); 3] = [293 + ("tasks", "<a href=\"/tasks/index.html\""),294 + ("releases", "<a href=\"/releases/index.html\""),295 + ("wiki", "<a href=\"/wiki/index.html\""),296 +];297 +298 +#[test]299 +fn empty_sections_hide_tabs_but_pages_still_build() {300 + let out = build(&bare_repo());301 + // Empty-state pages still build (deep links keep working).302 + for (page, marker) in [303 + ("tasks/index.html", "No tasks yet"),304 + ("releases/index.html", "No releases yet"),305 + ("wiki/index.html", "No wiki pages yet"),306 + ] {307 + let html = std::fs::read_to_string(out.join(page)).expect(page);308 + assert!(html.contains(marker), "{page} keeps its empty state");309 + }310 + // Every sampled header hides the three tabs but keeps the rest.311 + for page in ["index.html", "commits.html", "tasks/index.html"] {312 + let header = header_of(&out, page);313 + for (name, needle) in CONDITIONAL_TABS {314 + assert!(!header.contains(needle), "{page} header hides empty {name}");315 + }316 + for needle in ALWAYS_TABS {317 + assert!(318 + header.contains(needle),319 + "{page} header keeps always-shown {needle}"320 + );321 + }322 + }323 +}324 +325 +#[test]326 +fn full_forge_shows_all_tabs() {327 + let repo = bare_repo();328 + forge_write(329 + &repo,330 + "tasks/77777777-7777-4777-8777-777777777777.md",331 + "---\nid: 77777777-7777-4777-8777-777777777777\ntitle: A task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",332 + );333 + forge_write(334 + &repo,335 + "releases/v1.0.md",336 + "---\nversion: v1.0\ntitle: First\ncreated: 2026-09-10T02:00:00Z\n---\n# v1.0 notes\n",337 + );338 + forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");339 + git(&repo, &["add", "-A"]);340 + git(&repo, &["commit", "-m", "full forge"]);341 + let out = build(&repo);342 + let header = header_of(&out, "index.html");343 + for (name, needle) in CONDITIONAL_TABS {344 + assert!(345 + header.contains(needle),346 + "populated {name} tab shown in header"347 + );348 + }349 + for needle in ALWAYS_TABS {350 + assert!(header.contains(needle), "header keeps {needle}");351 + }352 + // Populated pages list their content.353 + let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");354 + assert!(tasks.contains("A task"), "task listed");355 + let releases = std::fs::read_to_string(out.join("releases/index.html")).expect("releases");356 + assert!(releases.contains("v1.0"), "release listed");357 + let wiki = std::fs::read_to_string(out.join("wiki/index.html")).expect("wiki");358 + assert!(wiki.contains("Notes"), "wiki page listed");359 +}360 -- 361 2.55.0362