1 From 985dba6a02dd44508ee4898852e2fc14a490686a Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 08:54:47 +01004 Subject: [PATCH] m6/finish-line: repo-head spacing + footer provenance5 6 ---7 src/agents.rs | 6 +-8 src/build.rs | 53 ++++++++++---9 src/commits.rs | 25 +++++-10 src/forge.rs | 103 +++++++++++++++++++------11 src/inbox.rs | 18 ++++-12 src/layout.rs | 122 +++++++++++++++++++++++++++++13 src/releases.rs | 41 ++++++++--14 src/wiki.rs | 18 +++--15 static/style.css | 15 +++-16 tests/code_space.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++17 tests/provenance.rs | 161 ++++++++++++++++++++++++++++++++++++++18 11 files changed, 687 insertions(+), 59 deletions(-)19 create mode 100644 tests/code_space.rs20 create mode 100644 tests/provenance.rs21 22 diff --git a/src/agents.rs b/src/agents.rs23 index f0f3646..d4e49f1 10064424 --- a/src/agents.rs25 +++ b/src/agents.rs26 @@ -15,7 +15,7 @@27 use std::path::Path;28 29 use crate::build::markdown_to_html;30 -use crate::layout::{esc, page_site};31 +use crate::layout::{esc, page_site_with_provenance};32 33 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).34 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,35 @@ -114,12 +114,14 @@ pub fn render(36 // source so the raw `agents.md` and rendered `agents.html` agree. The37 // markdown renderer escapes raw HTML again, so `&` etc. stay safe.38 let html_body = markdown_to_html(&md);39 - let html = page_site(40 + let html = page_site_with_provenance(41 &format!("agents — {site_name}"),42 nav,43 &html_body,44 site_name,45 description,46 + branch,47 + short_sha,48 );49 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;50 std::fs::write(out.join("agents.html"), html)51 diff --git a/src/build.rs b/src/build.rs52 index 96c819f..5d176ba 10064453 --- a/src/build.rs54 +++ b/src/build.rs55 @@ -941,8 +941,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu56 Some((&parent_href, &parent_label)),57 &history,58 ));59 - let html =60 - crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);61 + let html = crate::layout::page_site_with_provenance(62 + &format!("{dir} — {name}"),63 + &nav,64 + &body,65 + &name,66 + desc_opt,67 + &branch,68 + &short_sha,69 + );70 let dest = out.join("files").join(format!("{dir}.html"));71 if let Some(parent) = dest.parent() {72 std::fs::create_dir_all(parent)73 @@ -988,7 +995,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu74 ));75 body.push_str(&render_text_pre(&display));76 }77 - let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);78 + let html = crate::layout::page_site_with_provenance(79 + &format!("{f} — {name}"),80 + &nav,81 + &body,82 + &name,83 + desc_opt,84 + &branch,85 + &short_sha,86 + );87 let dest = out.join("files").join(format!("{f}.html"));88 if let Some(parent) = dest.parent() {89 std::fs::create_dir_all(parent)90 @@ -1000,15 +1015,33 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu91 // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):92 // one struct pass — the same loaded vecs feed both HTML and JSON.93 let commit_log = commits::load(repo, &branch, opts.max_log)?;94 - commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;95 + commits::render_loaded(96 + repo,97 + out,98 + &commit_log,99 + &name,100 + &nav,101 + &branch,102 + &short_sha,103 + desc_opt,104 + )?;105 feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;106 - let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;107 + let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;108 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.109 // Tasks/releases/wiki indexes always render even when empty (m6110 // hide-empty, f0rge #38): only the nav hides the tab, deep links111 // keep working with their "No X yet" empty states.112 - forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;113 - releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;114 + forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;115 + releases::render_loaded(116 + &release_data,117 + repo,118 + out,119 + &name,120 + &nav,121 + &branch,122 + &short_sha,123 + desc_opt,124 + )?;125 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;126 agents::render(127 out,128 @@ -1020,7 +1053,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu129 desc_opt,130 )?;131 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.132 - crate::inbox::render_forms(out, &name, &nav, desc_opt)?;133 + crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;134 135 // Snapshot last so the index can link sizes.136 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;137 @@ -1093,7 +1126,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu138 }139 body.push_str("</section></div>\n");140 141 - let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);142 + let index = crate::layout::page_site_with_provenance(143 + &name, &nav, &body, &name, desc_opt, &branch, &short_sha,144 + );145 std::fs::write(out.join("index.html"), index)146 .map_err(|e| format!("hu13: write index.html: {e}"))?;147 148 diff --git a/src/commits.rs b/src/commits.rs149 index 7075113..60566f2 100644150 --- a/src/commits.rs151 +++ b/src/commits.rs152 @@ -9,7 +9,7 @@153 use std::path::Path;154 155 use crate::gitx;156 -use crate::layout::{diff_html, esc, page_site};157 +use crate::layout::{diff_html, esc, page_site_with_provenance};158 159 /// Short display form of a full sha (first 7 hex chars, git-style).160 #[must_use]161 @@ -25,12 +25,15 @@ pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commi162 163 /// Render `commits.html` plus one `commit/<sha>.html` per commit from an164 /// already-loaded log (see [`load`]).165 +#[allow(clippy::too_many_arguments)]166 pub fn render_loaded(167 repo: &Path,168 out: &Path,169 log: &[gitx::Commit],170 site_name: &str,171 nav: &str,172 + branch: &str,173 + snap_sha: &str,174 description: Option<&str>,175 ) -> Result<usize, String> {176 let mut body = String::from("<h1>Commits</h1>\n");177 @@ -50,12 +53,14 @@ pub fn render_loaded(178 }179 body.push_str("</tbody>\n</table></div>\n");180 }181 - let html = page_site(182 + let html = page_site_with_provenance(183 &format!("commits — {site_name}"),184 nav,185 &body,186 site_name,187 description,188 + branch,189 + snap_sha,190 );191 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;192 std::fs::write(out.join("commits.html"), html)193 @@ -75,12 +80,14 @@ pub fn render_loaded(194 esc(&message),195 diff_html(&diff),196 );197 - let html = page_site(198 + let html = page_site_with_provenance(199 &format!("commit {} — {site_name}", short_sha(&c.sha)),200 nav,201 &detail,202 site_name,203 description,204 + branch,205 + snap_sha,206 );207 std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)208 .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;209 @@ -103,7 +110,17 @@ pub fn render(210 description: Option<&str>,211 ) -> Result<usize, String> {212 let log = load(repo, branch, max_log)?;213 - render_loaded(repo, out, &log, site_name, nav, description)214 + let snap_sha = gitx::rev_parse_short(repo, branch).unwrap_or_default();215 + render_loaded(216 + repo,217 + out,218 + &log,219 + site_name,220 + nav,221 + branch,222 + &snap_sha,223 + description,224 + )225 }226 227 #[cfg(test)]228 diff --git a/src/forge.rs b/src/forge.rs229 index b41ed7a..040b552 100644230 --- a/src/forge.rs231 +++ b/src/forge.rs232 @@ -23,7 +23,7 @@ use std::path::Path;233 use serde::Deserialize;234 235 use crate::build::markdown_to_html;236 -use crate::layout::{diff_html, esc, page_site};237 +use crate::layout::{diff_html, esc, page_site_with_provenance};238 239 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).240 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;241 @@ -830,6 +830,8 @@ fn render_issue_page(242 issue: &Issue,243 site_name: &str,244 nav: &str,245 + branch: &str,246 + short_sha: &str,247 description: Option<&str>,248 ) -> String {249 let mut body = format!(250 @@ -879,16 +881,25 @@ fn render_issue_page(251 body.push_str("</ul>\n");252 }253 body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");254 - page_site(255 + page_site_with_provenance(256 &format!("#{} {} — {site_name}", issue.number, issue.title),257 nav,258 &body,259 site_name,260 description,261 + branch,262 + short_sha,263 )264 }265 266 -fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {267 +fn render_pr_page(268 + pr: &Pr,269 + site_name: &str,270 + nav: &str,271 + branch: &str,272 + short_sha: &str,273 + description: Option<&str>,274 +) -> String {275 let mut body = format!(276 "<h1>#{n} {title}</h1>\n",277 n = pr.number,278 @@ -963,12 +974,14 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>279 body.push_str("</ul>\n");280 }281 body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");282 - page_site(283 + page_site_with_provenance(284 &format!("#{} {} — {site_name}", pr.number, pr.title),285 nav,286 &body,287 site_name,288 description,289 + branch,290 + short_sha,291 )292 }293 294 @@ -976,6 +989,8 @@ fn render_issues_index(295 data: &ForgeData,296 site_name: &str,297 nav: &str,298 + branch: &str,299 + short_sha: &str,300 description: Option<&str>,301 ) -> Result<String, String> {302 let mut body =303 @@ -1019,12 +1034,14 @@ fn render_issues_index(304 }305 body.push_str("</ul>\n");306 }307 - Ok(page_site(308 + Ok(page_site_with_provenance(309 &format!("issues — {site_name}"),310 nav,311 &body,312 site_name,313 description,314 + branch,315 + short_sha,316 ))317 }318 319 @@ -1032,6 +1049,8 @@ fn render_pulls_index(320 data: &ForgeData,321 site_name: &str,322 nav: &str,323 + branch: &str,324 + short_sha: &str,325 description: Option<&str>,326 ) -> String {327 let prs = &data.prs;328 @@ -1059,12 +1078,14 @@ fn render_pulls_index(329 "<p class=\"muted\">{n} closed</p>\n",330 n = data.closed_pr_count(),331 ));332 - page_site(333 + page_site_with_provenance(334 &format!("pulls — {site_name}"),335 nav,336 &body,337 site_name,338 description,339 + branch,340 + short_sha,341 )342 }343 344 @@ -1072,6 +1093,8 @@ fn render_tasks_index(345 tasks: &[TaskItem],346 site_name: &str,347 nav: &str,348 + branch: &str,349 + short_sha: &str,350 description: Option<&str>,351 ) -> String {352 let mut body = String::from("<h1>Tasks</h1>\n");353 @@ -1107,16 +1130,25 @@ fn render_tasks_index(354 }355 }356 }357 - page_site(358 + page_site_with_provenance(359 &format!("tasks — {site_name}"),360 nav,361 &body,362 site_name,363 description,364 + branch,365 + short_sha,366 )367 }368 369 -fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {370 +fn render_ci_index(371 + ci: &CiInfo,372 + site_name: &str,373 + nav: &str,374 + branch: &str,375 + short_sha: &str,376 + description: Option<&str>,377 +) -> String {378 let mut body = String::from("<h1>CI</h1>\n");379 body.push_str(&meta_row("runner:", &esc(&ci.runner)));380 if ci.steps.is_empty() {381 @@ -1170,12 +1202,14 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<382 }383 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),384 }385 - page_site(386 + page_site_with_provenance(387 &format!("ci — {site_name}"),388 nav,389 &body,390 site_name,391 description,392 + branch,393 + short_sha,394 )395 }396 397 @@ -1193,43 +1227,45 @@ pub fn render_loaded(398 out: &Path,399 site_name: &str,400 nav: &str,401 + branch: &str,402 + short_sha: &str,403 description: Option<&str>,404 ) -> Result<(), String> {405 write_page(406 out,407 "issues/index.html",408 - &render_issues_index(data, site_name, nav, description)?,409 + &render_issues_index(data, site_name, nav, branch, short_sha, description)?,410 )?;411 for issue in &data.issues {412 write_page(413 out,414 &format!("issues/{}.html", issue.id),415 - &render_issue_page(issue, site_name, nav, description),416 + &render_issue_page(issue, site_name, nav, branch, short_sha, description),417 )?;418 }419 420 write_page(421 out,422 "pulls/index.html",423 - &render_pulls_index(data, site_name, nav, description),424 + &render_pulls_index(data, site_name, nav, branch, short_sha, description),425 )?;426 for pr in &data.prs {427 write_page(428 out,429 &format!("pulls/{}.html", pr.id),430 - &render_pr_page(pr, site_name, nav, description),431 + &render_pr_page(pr, site_name, nav, branch, short_sha, description),432 )?;433 }434 435 write_page(436 out,437 "tasks/index.html",438 - &render_tasks_index(&data.tasks, site_name, nav, description),439 + &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),440 )?;441 write_page(442 out,443 "ci/index.html",444 - &render_ci_index(&data.ci, site_name, nav, description),445 + &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),446 )?;447 448 Ok(())449 @@ -1360,7 +1396,8 @@ mod tests {450 assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));451 452 let out = mktemp("pr-out");453 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");454 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)455 + .expect("render");456 let html =457 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))458 .expect("pr page");459 @@ -1389,7 +1426,8 @@ mod tests {460 )461 .unwrap();462 let out = mktemp("pr-diff-out");463 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");464 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)465 + .expect("render");466 let html =467 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))468 .expect("pr page");469 @@ -1450,7 +1488,8 @@ mod tests {470 assert_eq!(data.ci.latest_verdict(), "fail");471 472 let out = mktemp("taskci-out");473 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");474 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)475 + .expect("render");476 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");477 for status in ["todo", "doing", "blocked", "done"] {478 assert!(tasks.contains(status), "group {status}");479 @@ -1471,7 +1510,8 @@ mod tests {480 assert!(data.tasks.is_empty());481 assert_eq!(data.ci.latest_verdict(), "none");482 let out = mktemp("empty-out");483 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");484 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)485 + .expect("render");486 for page in [487 "issues/index.html",488 "pulls/index.html",489 @@ -1490,7 +1530,8 @@ mod tests {490 // Empty forge: buttons + linked empty states.491 let repo = mktemp("submit-empty");492 let out = mktemp("submit-empty-out");493 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");494 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)495 + .expect("render");496 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");497 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");498 assert!(499 @@ -1523,7 +1564,16 @@ mod tests {500 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",501 );502 let out2 = mktemp("submit-full-out");503 - render_loaded(&load(&repo), &out2, "test", "<nav>", None).expect("render");504 + render_loaded(505 + &load(&repo),506 + &out2,507 + "test",508 + "<nav>",509 + "main",510 + "abc1234",511 + None,512 + )513 + .expect("render");514 let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");515 let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");516 assert!(517 @@ -1641,7 +1691,8 @@ mod tests {518 assert!(css.contains("align-items:center"), "inline row");519 let repo = mktemp("page-head");520 let out = mktemp("page-head-out");521 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");522 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)523 + .expect("render");524 for (page, title, btn) in [525 ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),526 ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),527 @@ -1683,7 +1734,8 @@ mod tests {528 ),529 );530 let out = mktemp("long-title-out");531 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");532 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)533 + .expect("render");534 for page in ["issues/index.html", "pulls/index.html"] {535 let html = std::fs::read_to_string(out.join(page)).expect(page);536 assert!(537 @@ -1741,7 +1793,7 @@ mod tests {538 assert_eq!(data.closed_pr_count(), 1, "merged");539 540 let out = mktemp("open-counts-out");541 - render_loaded(&data, &out, "test", "<nav>", None).expect("render");542 + render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");543 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");544 assert!(issues.contains("2 closed"), "issues closed total");545 assert!(issues.contains("Issue open"), "open row kept");546 @@ -1806,7 +1858,8 @@ mod tests {547 "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_test.sh\n exit: 0\n duration_ms: 7\n---\nok\n",548 );549 let out = mktemp("cards-out");550 - render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");551 + render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)552 + .expect("render");553 for (page, labels) in [554 (555 "issues/index.html",556 diff --git a/src/inbox.rs b/src/inbox.rs557 index 9806475..ecceb1d 100644558 --- a/src/inbox.rs559 +++ b/src/inbox.rs560 @@ -17,7 +17,7 @@561 562 use std::path::Path;563 564 -use crate::layout::{esc, page, page_site};565 +use crate::layout::{esc, page, page_site_with_provenance};566 567 /// Max chars for `title` (both kinds).568 pub const MAX_TITLE_CHARS: usize = 300;569 @@ -614,6 +614,8 @@ fn form_page(570 action: &str,571 body_name: &str,572 body_label: &str,573 + branch: &str,574 + short_sha: &str,575 description: Option<&str>,576 ) -> String {577 let body = format!(578 @@ -649,12 +651,14 @@ fn form_page(579 body_label = esc(body_label),580 license = license_block(),581 );582 - page_site(583 + page_site_with_provenance(584 &format!("{heading} — {site_name}"),585 nav,586 &body,587 site_name,588 description,589 + branch,590 + short_sha,591 )592 }593 594 @@ -663,6 +667,8 @@ pub fn render_forms(595 out: &Path,596 site_name: &str,597 nav: &str,598 + branch: &str,599 + short_sha: &str,600 description: Option<&str>,601 ) -> Result<(), String> {602 let issue = form_page(603 @@ -673,6 +679,8 @@ pub fn render_forms(604 InboxType::Issue.route(),605 "body",606 "Body",607 + branch,608 + short_sha,609 description,610 );611 let pr = form_page(612 @@ -683,6 +691,8 @@ pub fn render_forms(613 InboxType::PrPrompt.route(),614 "prompt",615 "Prompt",616 + branch,617 + short_sha,618 description,619 );620 std::fs::write(out.join("new-issue.html"), issue)621 @@ -1036,7 +1046,7 @@ mod tests {622 .unwrap_or(0)623 ));624 std::fs::create_dir_all(&dir).unwrap();625 - render_forms(&dir, "test", "", None).unwrap();626 + render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();627 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();628 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();629 assert!(issue.contains("action=\"/inbox/issue\""));630 @@ -1071,7 +1081,7 @@ mod tests {631 .unwrap_or(0)632 ));633 std::fs::create_dir_all(&dir).unwrap();634 - render_forms(&dir, "test", "", None).unwrap();635 + render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();636 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();637 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();638 for html in [&issue, &pr] {639 diff --git a/src/layout.rs b/src/layout.rs640 index 430c397..096a227 100644641 --- a/src/layout.rs642 +++ b/src/layout.rs643 @@ -186,6 +186,27 @@ pub fn apply_active(nav: &str, href: &str) -> String {644 nav.to_string()645 }646 647 +/// Provenance footer line (m6 finish-line, f0rge #41): a single muted648 +/// line carried on EVERY page — `generated from a snapshot of a repo649 +/// and its f0rge`, plus `branch @ short_sha` when the caller knows the650 +/// snapshot (build pages do; serve-time stubs don't). Extends the651 +/// existing `footer.site-footer` block: exactly one per page, never652 +/// duplicated. `branch`/`short_sha` go through [`esc`] (untrusted653 +/// config/branch names render as text).654 +#[must_use]655 +pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> String {656 + let mut line = String::from("generated from a snapshot of a repo and its f0rge");657 + if let (Some(b), Some(s)) = (branch, short_sha) {658 + // Empty snapshot ids degrade to the generic line (never `main @ `).659 + if !b.is_empty() && !s.is_empty() {660 + line.push_str(&format!(" · {} @ {}", esc(b), esc(s)));661 + }662 + }663 + format!(664 + "<footer class=\"site-footer\"><div class=\"wrap\"><p class=\"muted footer-provenance\">{line}</p></div></footer>"665 + )666 +}667 +668 #[must_use]669 pub fn page_site(670 title: &str,671 @@ -193,6 +214,45 @@ pub fn page_site(672 body: &str,673 site_name: &str,674 description: Option<&str>,675 +) -> String {676 + page_site_inner(title, nav, body, site_name, description, None, None)677 +}678 +679 +/// [`page_site`] plus snapshot provenance in the footer (m6 finish-line,680 +/// f0rge #41): `branch`/`short_sha` name the built snapshot so every681 +/// build page carries `branch @ sha` after the generic line. Callers682 +/// without a repo snapshot (serve 404s, inbox acks) use [`page_site`]683 +/// and get the generic line alone.684 +#[must_use]685 +pub fn page_site_with_provenance(686 + title: &str,687 + nav: &str,688 + body: &str,689 + site_name: &str,690 + description: Option<&str>,691 + branch: &str,692 + short_sha: &str,693 +) -> String {694 + page_site_inner(695 + title,696 + nav,697 + body,698 + site_name,699 + description,700 + Some(branch),701 + Some(short_sha),702 + )703 +}704 +705 +#[must_use]706 +fn page_site_inner(707 + title: &str,708 + nav: &str,709 + body: &str,710 + site_name: &str,711 + description: Option<&str>,712 + branch: Option<&str>,713 + short_sha: Option<&str>,714 ) -> String {715 let title_e = esc(title);716 // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the717 @@ -227,6 +287,7 @@ pub fn page_site(718 Some(href) => apply_active(nav, href),719 None => nav.to_string(),720 };721 + let footer = provenance_footer(branch, short_sha);722 format!(723 "<!DOCTYPE html>\n\724 <html lang=\"en\">\n\725 @@ -234,6 +295,7 @@ pub fn page_site(726 <body>\n\727 <header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"96\" height=\"32\"></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav></div></header>\n\728 <main class=\"wrap\">\n{body}\n</main>\n\729 +{footer}\n\730 </body>\n\731 </html>\n"732 )733 @@ -484,4 +546,64 @@ mod tests {734 assert!(html.contains("Binary files"), "binary note intact");735 assert!(!html.contains("<span>\n[hu13"), "markers have no span");736 }737 +738 + #[test]739 + fn every_page_carries_a_single_muted_provenance_footer() {740 + // m6 footer-line (f0rge #41): the existing footer block carries741 + // one muted provenance line; serve stubs without a snapshot742 + // get the generic line alone.743 + use super::{page_site_with_provenance, provenance_footer};744 + for html in [745 + page("t", "<a href=\"/\">home</a>", "<p>b</p>"),746 + page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234"),747 + ] {748 + assert_eq!(html.matches("<footer").count(), 1, "exactly one footer");749 + assert!(750 + html.contains("<footer class=\"site-footer\">"),751 + "existing footer block extended, not duplicated"752 + );753 + assert!(754 + html.contains("generated from a snapshot of a repo and its f0rge"),755 + "provenance line present"756 + );757 + assert!(758 + html.contains("class=\"muted footer-provenance\""),759 + "muted styling via .muted"760 + );761 + let f0 = html.find("<footer").expect("footer open");762 + let f1 = html.find("</footer>").expect("footer close");763 + assert!(!html[f0..f1].contains("<br"), "single line, no spacers");764 + }765 + // Generic footer names no snapshot.766 + assert!(767 + !provenance_footer(None, None).contains('@'),768 + "no sha when unknown"769 + );770 + // Empty ids degrade to the generic line, never `main @ `.771 + assert!(!provenance_footer(Some("main"), Some("")).contains('@'));772 + }773 +774 + #[test]775 + fn provenance_footer_names_snapshot_and_escapes() {776 + // m6 footer-line (f0rge #41): build pages name branch @ sha;777 + // hostile branch names render as text.778 + use super::page_site_with_provenance;779 + let html =780 + page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234");781 + assert!(html.contains("main @ abc1234"), "branch@sha named: {html}");782 + let evil = page_site_with_provenance(783 + "t",784 + "<nav></nav>",785 + "<p>b</p>",786 + "s",787 + None,788 + "<script>alert(1)</script>",789 + "abc1234",790 + );791 + assert!(!evil.contains("<script>"), "branch escaped: {evil}");792 + assert!(793 + evil.contains("<script>alert(1)</script> @ abc1234"),794 + "escaped branch kept with sha"795 + );796 + }797 }798 diff --git a/src/releases.rs b/src/releases.rs799 index 87493bb..507c24e 100644800 --- a/src/releases.rs801 +++ b/src/releases.rs802 @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};803 use serde::Deserialize;804 805 use crate::build::markdown_to_html;806 -use crate::layout::{esc, page_site};807 +use crate::layout::{esc, page_site_with_provenance};808 809 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).810 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;811 @@ -322,6 +322,8 @@ fn render_release_page(812 rel: &Release,813 site_name: &str,814 nav: &str,815 + branch: &str,816 + short_sha: &str,817 description: Option<&str>,818 ) -> String {819 let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));820 @@ -364,23 +366,28 @@ fn render_release_page(821 }822 }823 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");824 - page_site(825 + page_site_with_provenance(826 &format!("{} — releases — {site_name}", rel.display_title()),827 nav,828 &body,829 site_name,830 description,831 + branch,832 + short_sha,833 )834 }835 836 /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded837 /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.838 +#[allow(clippy::too_many_arguments)]839 pub fn render_loaded(840 releases: &[Release],841 repo: &Path,842 out: &Path,843 site_name: &str,844 nav: &str,845 + branch: &str,846 + short_sha: &str,847 description: Option<&str>,848 ) -> Result<(), String> {849 let root = repo.join(".forge").join("releases");850 @@ -401,12 +408,14 @@ pub fn render_loaded(851 }852 body.push_str("</tbody>\n</table></div>\n");853 }854 - let index = page_site(855 + let index = page_site_with_provenance(856 &format!("releases — {site_name}"),857 nav,858 &body,859 site_name,860 description,861 + branch,862 + short_sha,863 );864 let dest = out.join("releases").join("index.html");865 if let Some(parent) = dest.parent() {866 @@ -423,7 +432,7 @@ pub fn render_loaded(867 copy_tree(&src_dir, &dest_dir)?;868 }869 }870 - let html = render_release_page(rel, site_name, nav, description);871 + let html = render_release_page(rel, site_name, nav, branch, short_sha, description);872 let page_dest = out.join("releases").join(format!("{}.html", rel.version));873 if let Some(parent) = page_dest.parent() {874 std::fs::create_dir_all(parent)875 @@ -486,7 +495,17 @@ mod tests {876 std::fs::create_dir_all(&repo).unwrap();877 let out = repo.join("out");878 std::fs::create_dir_all(&out).unwrap();879 - render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");880 + render_loaded(881 + &[rel],882 + &repo,883 + &out,884 + "test",885 + "<nav>",886 + "main",887 + "abc1234",888 + None,889 + )890 + .expect("render");891 let index =892 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");893 assert!(894 @@ -535,7 +554,17 @@ mod tests {895 std::fs::create_dir_all(&repo).unwrap();896 let out = repo.join("out");897 std::fs::create_dir_all(&out).unwrap();898 - render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");899 + render_loaded(900 + &[rel],901 + &repo,902 + &out,903 + "test",904 + "<nav>",905 + "main",906 + "abc1234",907 + None,908 + )909 + .expect("render");910 let index =911 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");912 let tds = index.matches("<td").count();913 diff --git a/src/wiki.rs b/src/wiki.rs914 index 02edd5c..746c229 100644915 --- a/src/wiki.rs916 +++ b/src/wiki.rs917 @@ -15,7 +15,7 @@ use std::collections::BTreeMap;918 use std::path::Path;919 920 use crate::build::markdown_to_html;921 -use crate::layout::{esc, page_site};922 +use crate::layout::{esc, page_site_with_provenance};923 924 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).925 pub const WIKI_MAX_BYTES: u64 = 256 * 1024;926 @@ -165,6 +165,8 @@ pub fn render(927 out: &Path,928 site_name: &str,929 nav: &str,930 + branch: &str,931 + short_sha: &str,932 description: Option<&str>,933 ) -> Result<Vec<WikiPage>, String> {934 let root = repo.join(".forge").join("wiki");935 @@ -290,12 +292,14 @@ pub fn render(936 }937 std::fs::write(938 wiki_dir.join("index.html"),939 - page_site(940 + page_site_with_provenance(941 &format!("wiki — {site_name}"),942 nav,943 &body,944 site_name,945 description,946 + branch,947 + short_sha,948 ),949 )950 .map_err(|e| format!("hu13: write wiki index: {e}"))?;951 @@ -308,12 +312,14 @@ pub fn render(952 esc(&p.path),953 );954 body.push_str(&markdown_to_html(&text));955 - let html = page_site(956 + let html = page_site_with_provenance(957 &format!("{} — wiki — {site_name}", p.title),958 nav,959 &body,960 site_name,961 description,962 + branch,963 + short_sha,964 );965 let dest = wiki_dir.join(format!("{}.html", p.path));966 if let Some(parent) = dest.parent() {967 @@ -383,7 +389,7 @@ mod tests {968 fn index_titles_sorted_skips_bad() {969 let repo = fixture_repo();970 let out = mktemp("out");971 - let pages = render(&repo, &out, "test", "<nav>", None).expect("render");972 + let pages = render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");973 let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();974 assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);975 assert_eq!(pages[0].title, "Nested Page");976 @@ -405,7 +411,7 @@ mod tests {977 fn pages_render_markdown_escaped() {978 let repo = fixture_repo();979 let out = mktemp("out");980 - render(&repo, &out, "test", "<nav>", None).expect("render");981 + render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");982 let hello =983 std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");984 assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");985 @@ -426,7 +432,7 @@ mod tests {986 fn missing_wiki_dir_renders_empty_index() {987 let dir = mktemp("nowiki");988 let out = mktemp("out");989 - let pages = render(&dir, &out, "test", "<nav>", None).expect("render");990 + let pages = render(&dir, &out, "test", "<nav>", "main", "abc1234", None).expect("render");991 assert!(pages.is_empty());992 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");993 assert!(index.contains("No wiki pages yet."));994 diff --git a/static/style.css b/static/style.css995 index 2efee6d..fa1b70d 100644996 --- a/static/style.css997 +++ b/static/style.css998 @@ -63,9 +63,13 @@ img,video,svg{max-width:100%;height:auto}999 a{color:var(--blue)}1000 a:visited{color:var(--visited)}1001 main{padding:1.25rem 0 3rem;min-width:0}1002 -/* Repo header stacks on narrow; clone cmd + sha wrap safely. */1003 -.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}1004 -.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}1005 +/* Repo header stacks on narrow; clone cmd + sha wrap safely.1006 + Breathing room under the navbar (m6/code-space, f0rge #40): air via1007 + CSS margin-top (never <br> spacers); the base stays tight for 360px1008 + and wider screens get a touch more. Title is explicit 2rem so it1009 + reads as the page title on every viewport. */1010 +.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}1011 +.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}1012 .clone,.repo-meta{margin:.25rem 0;min-width:0}1013 .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}1014 code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;1015 @@ -115,7 +119,11 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}1016 .btn-new:visited{color:var(--btn-fg)}1017 .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}1018 .muted{color:var(--muted)}1019 +footer.site-footer{border-top:1px solid var(--border)}1020 footer.site-footer .wrap{padding:1rem}1021 +/* Provenance line (m6/footer-line, f0rge #41): the single muted line1022 + inside the existing footer block — small, no extra whitespace. */1023 +footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}1024 /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.1025 Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/1026 .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav1027 @@ -136,6 +144,7 @@ footer.site-footer .wrap{padding:1rem}1028 @media (min-width:768px){1029 .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}1030 .site-nav .wrap{gap:.25rem 1.25rem}1031 + .repo-head{margin-top:1.25rem}1032 main{padding:1.5rem 0 3rem}1033 }1034 @media (min-width:1100px){1035 diff --git a/tests/code_space.rs b/tests/code_space.rs1036 new file mode 1006441037 index 0000000..7d73a3a1038 --- /dev/null1039 +++ b/tests/code_space.rs1040 @@ -0,0 +1,184 @@1041 +//! Code-tab breathing room (m6, f0rge issue #40):1042 +//! the repo-head sits clear of the navbar via CSS margin (never `<br>`1043 +//! spacers), with a slightly larger title — while staying tight on 360px.1044 +//!1045 +//! Builds a small fixture repo with the real binary and string-asserts on1046 +//! the emitted HTML + `static/style.css` (no rendering engine needed).1047 +1048 +use std::path::{Path, PathBuf};1049 +use std::process::Command;1050 +1051 +fn bin() -> &'static str {1052 + env!("CARGO_BIN_EXE_hu13")1053 +}1054 +1055 +fn manifest() -> PathBuf {1056 + PathBuf::from(env!("CARGO_MANIFEST_DIR"))1057 +}1058 +1059 +fn fresh(prefix: &str) -> PathBuf {1060 + let dir = std::env::temp_dir().join(format!(1061 + "hu13-codespace-{prefix}-{}-{}",1062 + std::process::id(),1063 + std::time::SystemTime::now()1064 + .duration_since(std::time::UNIX_EPOCH)1065 + .map(|d| d.as_nanos())1066 + .unwrap_or(0)1067 + ));1068 + std::fs::create_dir_all(&dir).unwrap();1069 + dir1070 +}1071 +1072 +fn git(repo: &Path, args: &[&str]) {1073 + let st = Command::new("git")1074 + .arg("-C")1075 + .arg(repo)1076 + .args(args)1077 + .status()1078 + .expect("spawn git");1079 + assert!(st.success(), "git {args:?} failed");1080 +}1081 +1082 +fn fixture_repo() -> PathBuf {1083 + let dir = fresh("repo");1084 + git(&dir, &["init", "-b", "main"]);1085 + git(&dir, &["config", "user.name", "hu13-test"]);1086 + git(&dir, &["config", "user.email", "[email protected]"]);1087 + git(&dir, &["config", "commit.gpgsign", "false"]);1088 + std::fs::write(dir.join("README.md"), "# space fixture\n\nhello\n").unwrap();1089 + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1090 + git(&dir, &["add", "-A"]);1091 + git(&dir, &["commit", "-m", "init"]);1092 + dir1093 +}1094 +1095 +fn build_fixture() -> PathBuf {1096 + let repo = fixture_repo();1097 + let out = fresh("out");1098 + let st = Command::new(bin())1099 + .arg("build")1100 + .arg(&repo)1101 + .arg("-o")1102 + .arg(&out)1103 + .status()1104 + .expect("spawn hu13 build");1105 + assert!(st.success(), "fixture build exits 0");1106 + out1107 +}1108 +1109 +fn css() -> String {1110 + std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")1111 +}1112 +1113 +/// Value of `prop` in the first `selector{...}` rule inside `scope`.1114 +fn rule_value(scope: &str, selector: &str, prop: &str) -> Option<String> {1115 + let start = scope.find(selector)?;1116 + let block_start = scope[start..].find('{')? + start;1117 + let block_end = scope[block_start..].find('}')? + block_start;1118 + let block = &scope[block_start..block_end];1119 + let pos = block.find(prop)?;1120 + let after = block[pos + prop.len()..].trim_start().strip_prefix(':')?;1121 + let value: String = after1122 + .trim_start()1123 + .chars()1124 + .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t' | '\n'))1125 + .collect();1126 + Some(value)1127 +}1128 +1129 +fn parse_rem(value: &str) -> Option<f32> {1130 + value.strip_suffix("rem")?.trim().parse::<f32>().ok()1131 +}1132 +1133 +#[test]1134 +fn repo_head_spacing_is_css_margin_not_br_tags() {1135 + // Structure assert: the code tab keeps its stacked repo-head1136 + // (name + meta + clone block) with air from CSS margin — no `<br>`1137 + // spacers between the navbar and the repo-head.1138 + let out = build_fixture();1139 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");1140 + let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");1141 + let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;1142 + let head = &index[head_start..head_end];1143 + assert!(head.contains("<h1>"), "repo-head keeps its title");1144 + assert!(1145 + !head.contains("<br"),1146 + "spacing is CSS margin, not <br> tags"1147 + );1148 + assert!(1149 + head.contains("repo-meta"),1150 + "branch/sha meta line kept inside repo-head"1151 + );1152 + // The header bar ends (</header>) before <main> carries the repo-head:1153 + // the break between nav and repo-head is a block boundary + margin.1154 + let header_end = index.find("</header>").expect("header close");1155 + assert!(1156 + header_end < head_start,1157 + "repo-head sits below the navbar block"1158 + );1159 + let between = &index[header_end..head_start];1160 + assert!(1161 + !between.contains("<br"),1162 + "no spacer tags between navbar and repo-head"1163 + );1164 +1165 + // CSS assert: `.repo-head` carries the top margin.1166 + let css = css();1167 + let base = css.split("@media (min-width").next().expect("base css");1168 + let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");1169 + assert!(1170 + parse_rem(&margin).is_some_and(|v| v > 0.0),1171 + "repo-head margin-top is positive breathing room, got {margin:?}"1172 + );1173 +}1174 +1175 +#[test]1176 +fn repo_head_title_reads_as_page_title() {1177 + // Slightly larger title treatment: explicit size on the repo-head h1.1178 + let css = css();1179 + let base = css.split("@media (min-width").next().expect("base css");1180 + let size = rule_value(base, ".repo-head h1", "font-size").expect("repo-head h1 font-size");1181 + let rem = parse_rem(&size).expect("h1 size in rem");1182 + assert!(1183 + rem >= 1.5,1184 + "repo-head title is title-scale (>=1.5rem), got {size:?}"1185 + );1186 + let out = build_fixture();1187 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");1188 + assert!(1189 + index.contains("<div class=\"repo-head\"><h1>"),1190 + "repo-head h1 opens the code tab body"1191 + );1192 +}1193 +1194 +#[test]1195 +fn mobile_stays_tight_wider_screens_get_more() {1196 + // 360px must not drown in whitespace: the base (pre-media-query)1197 + // margin stays modest; >=768px may open up a touch more.1198 + let css = css();1199 + let base = css.split("@media (min-width").next().expect("base css");1200 + let base_margin =1201 + rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");1202 + let base_rem = parse_rem(&base_margin).expect("base margin in rem");1203 + assert!(1204 + base_rem <= 1.0,1205 + "mobile margin-top stays tight (<=1rem), got {base_margin:?}"1206 + );1207 + let desktop = css1208 + .split("@media (min-width:768px)")1209 + .nth(1)1210 + .expect("768px breakpoint");1211 + // Only the current breakpoint block: cut before the next @media.1212 + let block = desktop.split("@media").next().expect("768 block");1213 + let wide_margin =1214 + rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");1215 + let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");1216 + assert!(1217 + wide_rem >= base_rem,1218 + "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"1219 + );1220 + assert!(1221 + wide_rem <= 2.0,1222 + "even wide stays modest (<=2rem), got {wide_margin:?}"1223 + );1224 +}1225 diff --git a/tests/provenance.rs b/tests/provenance.rs1226 new file mode 1006441227 index 0000000..dff7e551228 --- /dev/null1229 +++ b/tests/provenance.rs1230 @@ -0,0 +1,161 @@1231 +//! Footer provenance line (m6, f0rge issue #41):1232 +//! every page extends the existing footer block with a single muted line1233 +//! (`generated from a snapshot of a repo and its f0rge` + branch@sha).1234 +//!1235 +//! Builds a small fixture repo with the real binary and string-asserts on1236 +//! the emitted HTML + `static/style.css` (no rendering engine needed).1237 +1238 +use std::path::{Path, PathBuf};1239 +use std::process::Command;1240 +1241 +fn bin() -> &'static str {1242 + env!("CARGO_BIN_EXE_hu13")1243 +}1244 +1245 +fn manifest() -> PathBuf {1246 + PathBuf::from(env!("CARGO_MANIFEST_DIR"))1247 +}1248 +1249 +fn fresh(prefix: &str) -> PathBuf {1250 + let dir = std::env::temp_dir().join(format!(1251 + "hu13-provenance-{prefix}-{}-{}",1252 + std::process::id(),1253 + std::time::SystemTime::now()1254 + .duration_since(std::time::UNIX_EPOCH)1255 + .map(|d| d.as_nanos())1256 + .unwrap_or(0)1257 + ));1258 + std::fs::create_dir_all(&dir).unwrap();1259 + dir1260 +}1261 +1262 +fn git(repo: &Path, args: &[&str]) {1263 + let st = Command::new("git")1264 + .arg("-C")1265 + .arg(repo)1266 + .args(args)1267 + .status()1268 + .expect("spawn git");1269 + assert!(st.success(), "git {args:?} failed");1270 +}1271 +1272 +fn git_out(repo: &Path, args: &[&str]) -> String {1273 + let out = Command::new("git")1274 + .arg("-C")1275 + .arg(repo)1276 + .args(args)1277 + .output()1278 + .expect("spawn git");1279 + assert!(out.status.success(), "git {args:?} failed");1280 + String::from_utf8_lossy(&out.stdout).trim().to_string()1281 +}1282 +1283 +/// Fixture with two commits so list + detail pages exist.1284 +fn fixture_repo() -> PathBuf {1285 + let dir = fresh("repo");1286 + git(&dir, &["init", "-b", "main"]);1287 + git(&dir, &["config", "user.name", "hu13-test"]);1288 + git(&dir, &["config", "user.email", "[email protected]"]);1289 + git(&dir, &["config", "commit.gpgsign", "false"]);1290 + std::fs::write(dir.join("README.md"), "# provenance fixture\n\nhello\n").unwrap();1291 + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1292 + git(&dir, &["add", "-A"]);1293 + git(&dir, &["commit", "-m", "first"]);1294 + std::fs::write(dir.join("hello.txt"), "hello again\n").unwrap();1295 + git(&dir, &["add", "-A"]);1296 + git(&dir, &["commit", "-m", "second"]);1297 + dir1298 +}1299 +1300 +fn footer_of(html: &str) -> &str {1301 + let start = html.find("<footer").expect("footer open");1302 + let end = html.find("</footer>").expect("footer close") + "</footer>".len();1303 + &html[start..end]1304 +}1305 +1306 +#[test]1307 +fn footer_provenance_on_index_and_detail_pages() {1308 + let repo = fixture_repo();1309 + let sha = git_out(&repo, &["rev-parse", "--short", "HEAD"]);1310 + assert!(!sha.is_empty(), "fixture has a short sha");1311 + let out = fresh("out");1312 + let st = Command::new(bin())1313 + .arg("build")1314 + .arg(&repo)1315 + .arg("-o")1316 + .arg(&out)1317 + .status()1318 + .expect("spawn hu13 build");1319 + assert!(st.success(), "fixture build exits 0");1320 +1321 + // One commit-detail page exists.1322 + let commit_page = std::fs::read_dir(out.join("commit"))1323 + .expect("commit dir")1324 + .filter_map(|e| e.ok())1325 + .map(|e| e.path())1326 + .find(|p| p.extension().is_some_and(|x| x == "html"))1327 + .expect("one commit page");1328 +1329 + // Index + a detail page (commit detail) + breadth across tabs.1330 + let mut pages = vec![1331 + out.join("index.html"),1332 + commit_page,1333 + out.join("commits.html"),1334 + out.join("files").join("hello.txt.html"),1335 + out.join("issues/index.html"),1336 + out.join("agents.html"),1337 + out.join("new-issue.html"),1338 + ];1339 + // Wiki/releases tabs render even when empty (deep links keep working).1340 + pages.push(out.join("wiki/index.html"));1341 + pages.push(out.join("releases/index.html"));1342 + for page in &pages {1343 + let html =1344 + std::fs::read_to_string(page).unwrap_or_else(|_| panic!("read {}", page.display()));1345 + assert_eq!(1346 + html.matches("<footer").count(),1347 + 1,1348 + "{} extends the existing footer (exactly one)",1349 + page.display()1350 + );1351 + assert!(1352 + html.contains("<footer class=\"site-footer\">"),1353 + "{} keeps the footer block",1354 + page.display()1355 + );1356 + assert!(1357 + html.contains("generated from a snapshot of a repo and its f0rge"),1358 + "{} carries the provenance line",1359 + page.display()1360 + );1361 + assert!(1362 + html.contains(&format!("main @ {sha}")),1363 + "{} names the built snapshot",1364 + page.display()1365 + );1366 + // Muted, single line: no spacers inside the footer.1367 + let footer = footer_of(&html);1368 + assert!(1369 + footer.contains("muted"),1370 + "{} footer is muted",1371 + page.display()1372 + );1373 + assert!(1374 + !footer.contains("<br"),1375 + "{} footer is a single line",1376 + page.display()1377 + );1378 + }1379 +}1380 +1381 +#[test]1382 +fn footer_styling_is_muted_and_tiny() {1383 + let css = std::fs::read_to_string(manifest().join("static").join("style.css"))1384 + .expect("read style.css");1385 + assert!(css.contains("footer.site-footer"), "footer block styled");1386 + assert!(1387 + css.contains(".footer-provenance"),1388 + "provenance line has its own rule"1389 + );1390 + assert!(css.contains(".muted"), "muted tone reused, not a new color");1391 +}1392 -- 1393 2.55.01394