#36 m6/finish-line
Patch
src/agents.rs+4 −2--- a/src/agents.rs+++ b/src/agents.rs@@ -15,7 +15,7 @@1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page_site};18+use crate::layout::{esc, page_site_with_provenance};1919 2020 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).2121 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,@@ -114,12 +114,14 @@ pub fn render(114114 // source so the raw `agents.md` and rendered `agents.html` agree. The115115 // markdown renderer escapes raw HTML again, so `&` etc. stay safe.116116 let html_body = markdown_to_html(&md);117- let html = page_site(117+ let html = page_site_with_provenance(118118 &format!("agents — {site_name}"),119119 nav,120120 &html_body,121121 site_name,122122 description,123+ branch,124+ short_sha,123125 );124126 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;125127 std::fs::write(out.join("agents.html"), html)
src/build.rs+44 −9--- a/src/build.rs+++ b/src/build.rs@@ -941,8 +941,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu941941 Some((&parent_href, &parent_label)),942942 &history,943943 ));944- let html =945- crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);944+ let html = crate::layout::page_site_with_provenance(945+ &format!("{dir} — {name}"),946+ &nav,947+ &body,948+ &name,949+ desc_opt,950+ &branch,951+ &short_sha,952+ );946953 let dest = out.join("files").join(format!("{dir}.html"));947954 if let Some(parent) = dest.parent() {948955 std::fs::create_dir_all(parent)@@ -988,7 +995,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu988995 ));989996 body.push_str(&render_text_pre(&display));990997 }991- let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);998+ let html = crate::layout::page_site_with_provenance(999+ &format!("{f} — {name}"),1000+ &nav,1001+ &body,1002+ &name,1003+ desc_opt,1004+ &branch,1005+ &short_sha,1006+ );9921007 let dest = out.join("files").join(format!("{f}.html"));9931008 if let Some(parent) = dest.parent() {9941009 std::fs::create_dir_all(parent)@@ -1000,15 +1015,33 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10001015 // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):10011016 // one struct pass — the same loaded vecs feed both HTML and JSON.10021017 let commit_log = commits::load(repo, &branch, opts.max_log)?;1003- commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;1018+ commits::render_loaded(1019+ repo,1020+ out,1021+ &commit_log,1022+ &name,1023+ &nav,1024+ &branch,1025+ &short_sha,1026+ desc_opt,1027+ )?;10041028 feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;1005- let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;1029+ let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;10061030 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.10071031 // Tasks/releases/wiki indexes always render even when empty (m610081032 // hide-empty, f0rge #38): only the nav hides the tab, deep links10091033 // keep working with their "No X yet" empty states.1010- forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;1011- releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;1034+ forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;1035+ releases::render_loaded(1036+ &release_data,1037+ repo,1038+ out,1039+ &name,1040+ &nav,1041+ &branch,1042+ &short_sha,1043+ desc_opt,1044+ )?;10121045 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;10131046 agents::render(10141047 out,@@ -1020,7 +1053,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10201053 desc_opt,10211054 )?;10221055 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.1023- crate::inbox::render_forms(out, &name, &nav, desc_opt)?;1056+ crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;10241057 10251058 // Snapshot last so the index can link sizes.10261059 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;@@ -1093,7 +1126,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10931126 }10941127 body.push_str("</section></div>\n");10951128 1096- let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);1129+ let index = crate::layout::page_site_with_provenance(1130+ &name, &nav, &body, &name, desc_opt, &branch, &short_sha,1131+ );10971132 std::fs::write(out.join("index.html"), index)10981133 .map_err(|e| format!("hu13: write index.html: {e}"))?;10991134
src/commits.rs+21 −4--- a/src/commits.rs+++ b/src/commits.rs@@ -9,7 +9,7 @@99 use std::path::Path;1010 1111 use crate::gitx;12-use crate::layout::{diff_html, esc, page_site};12+use crate::layout::{diff_html, esc, page_site_with_provenance};1313 1414 /// Short display form of a full sha (first 7 hex chars, git-style).1515 #[must_use]@@ -25,12 +25,15 @@ pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commi2525 2626 /// Render `commits.html` plus one `commit/<sha>.html` per commit from an2727 /// already-loaded log (see [`load`]).28+#[allow(clippy::too_many_arguments)]2829 pub fn render_loaded(2930 repo: &Path,3031 out: &Path,3132 log: &[gitx::Commit],3233 site_name: &str,3334 nav: &str,35+ branch: &str,36+ snap_sha: &str,3437 description: Option<&str>,3538 ) -> Result<usize, String> {3639 let mut body = String::from("<h1>Commits</h1>\n");@@ -50,12 +53,14 @@ pub fn render_loaded(5053 }5154 body.push_str("</tbody>\n</table></div>\n");5255 }53- let html = page_site(56+ let html = page_site_with_provenance(5457 &format!("commits — {site_name}"),5558 nav,5659 &body,5760 site_name,5861 description,62+ branch,63+ snap_sha,5964 );6065 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;6166 std::fs::write(out.join("commits.html"), html)@@ -75,12 +80,14 @@ pub fn render_loaded(7580 esc(&message),7681 diff_html(&diff),7782 );78- let html = page_site(83+ let html = page_site_with_provenance(7984 &format!("commit {} — {site_name}", short_sha(&c.sha)),8085 nav,8186 &detail,8287 site_name,8388 description,89+ branch,90+ snap_sha,8491 );8592 std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)8693 .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;@@ -103,7 +110,17 @@ pub fn render(103110 description: Option<&str>,104111 ) -> Result<usize, String> {105112 let log = load(repo, branch, max_log)?;106- render_loaded(repo, out, &log, site_name, nav, description)113+ let snap_sha = gitx::rev_parse_short(repo, branch).unwrap_or_default();114+ render_loaded(115+ repo,116+ out,117+ &log,118+ site_name,119+ nav,120+ branch,121+ &snap_sha,122+ description,123+ )107124 }108125 109126 #[cfg(test)]
src/forge.rs+78 −25--- a/src/forge.rs+++ b/src/forge.rs@@ -23,7 +23,7 @@ use std::path::Path;2323 use serde::Deserialize;2424 2525 use crate::build::markdown_to_html;26-use crate::layout::{diff_html, esc, page_site};26+use crate::layout::{diff_html, esc, page_site_with_provenance};2727 2828 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2929 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;@@ -830,6 +830,8 @@ fn render_issue_page(830830 issue: &Issue,831831 site_name: &str,832832 nav: &str,833+ branch: &str,834+ short_sha: &str,833835 description: Option<&str>,834836 ) -> String {835837 let mut body = format!(@@ -879,16 +881,25 @@ fn render_issue_page(879881 body.push_str("</ul>\n");880882 }881883 body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");882- page_site(884+ page_site_with_provenance(883885 &format!("#{} {} — {site_name}", issue.number, issue.title),884886 nav,885887 &body,886888 site_name,887889 description,890+ branch,891+ short_sha,888892 )889893 }890894 891-fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {895+fn render_pr_page(896+ pr: &Pr,897+ site_name: &str,898+ nav: &str,899+ branch: &str,900+ short_sha: &str,901+ description: Option<&str>,902+) -> String {892903 let mut body = format!(893904 "<h1>#{n} {title}</h1>\n",894905 n = pr.number,@@ -963,12 +974,14 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>963974 body.push_str("</ul>\n");964975 }965976 body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");966- page_site(977+ page_site_with_provenance(967978 &format!("#{} {} — {site_name}", pr.number, pr.title),968979 nav,969980 &body,970981 site_name,971982 description,983+ branch,984+ short_sha,972985 )973986 }974987 @@ -976,6 +989,8 @@ fn render_issues_index(976989 data: &ForgeData,977990 site_name: &str,978991 nav: &str,992+ branch: &str,993+ short_sha: &str,979994 description: Option<&str>,980995 ) -> Result<String, String> {981996 let mut body =@@ -1019,12 +1034,14 @@ fn render_issues_index(10191034 }10201035 body.push_str("</ul>\n");10211036 }1022- Ok(page_site(1037+ Ok(page_site_with_provenance(10231038 &format!("issues — {site_name}"),10241039 nav,10251040 &body,10261041 site_name,10271042 description,1043+ branch,1044+ short_sha,10281045 ))10291046 }10301047 @@ -1032,6 +1049,8 @@ fn render_pulls_index(10321049 data: &ForgeData,10331050 site_name: &str,10341051 nav: &str,1052+ branch: &str,1053+ short_sha: &str,10351054 description: Option<&str>,10361055 ) -> String {10371056 let prs = &data.prs;@@ -1059,12 +1078,14 @@ fn render_pulls_index(10591078 "<p class=\"muted\">{n} closed</p>\n",10601079 n = data.closed_pr_count(),10611080 ));1062- page_site(1081+ page_site_with_provenance(10631082 &format!("pulls — {site_name}"),10641083 nav,10651084 &body,10661085 site_name,10671086 description,1087+ branch,1088+ short_sha,10681089 )10691090 }10701091 @@ -1072,6 +1093,8 @@ fn render_tasks_index(10721093 tasks: &[TaskItem],10731094 site_name: &str,10741095 nav: &str,1096+ branch: &str,1097+ short_sha: &str,10751098 description: Option<&str>,10761099 ) -> String {10771100 let mut body = String::from("<h1>Tasks</h1>\n");@@ -1107,16 +1130,25 @@ fn render_tasks_index(11071130 }11081131 }11091132 }1110- page_site(1133+ page_site_with_provenance(11111134 &format!("tasks — {site_name}"),11121135 nav,11131136 &body,11141137 site_name,11151138 description,1139+ branch,1140+ short_sha,11161141 )11171142 }11181143 1119-fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {1144+fn render_ci_index(1145+ ci: &CiInfo,1146+ site_name: &str,1147+ nav: &str,1148+ branch: &str,1149+ short_sha: &str,1150+ description: Option<&str>,1151+) -> String {11201152 let mut body = String::from("<h1>CI</h1>\n");11211153 body.push_str(&meta_row("runner:", &esc(&ci.runner)));11221154 if ci.steps.is_empty() {@@ -1170,12 +1202,14 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<11701202 }11711203 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),11721204 }1173- page_site(1205+ page_site_with_provenance(11741206 &format!("ci — {site_name}"),11751207 nav,11761208 &body,11771209 site_name,11781210 description,1211+ branch,1212+ short_sha,11791213 )11801214 }11811215 @@ -1193,43 +1227,45 @@ pub fn render_loaded(11931227 out: &Path,11941228 site_name: &str,11951229 nav: &str,1230+ branch: &str,1231+ short_sha: &str,11961232 description: Option<&str>,11971233 ) -> Result<(), String> {11981234 write_page(11991235 out,12001236 "issues/index.html",1201- &render_issues_index(data, site_name, nav, description)?,1237+ &render_issues_index(data, site_name, nav, branch, short_sha, description)?,12021238 )?;12031239 for issue in &data.issues {12041240 write_page(12051241 out,12061242 &format!("issues/{}.html", issue.id),1207- &render_issue_page(issue, site_name, nav, description),1243+ &render_issue_page(issue, site_name, nav, branch, short_sha, description),12081244 )?;12091245 }12101246 12111247 write_page(12121248 out,12131249 "pulls/index.html",1214- &render_pulls_index(data, site_name, nav, description),1250+ &render_pulls_index(data, site_name, nav, branch, short_sha, description),12151251 )?;12161252 for pr in &data.prs {12171253 write_page(12181254 out,12191255 &format!("pulls/{}.html", pr.id),1220- &render_pr_page(pr, site_name, nav, description),1256+ &render_pr_page(pr, site_name, nav, branch, short_sha, description),12211257 )?;12221258 }12231259 12241260 write_page(12251261 out,12261262 "tasks/index.html",1227- &render_tasks_index(&data.tasks, site_name, nav, description),1263+ &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),12281264 )?;12291265 write_page(12301266 out,12311267 "ci/index.html",1232- &render_ci_index(&data.ci, site_name, nav, description),1268+ &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),12331269 )?;12341270 12351271 Ok(())@@ -1360,7 +1396,8 @@ mod tests {13601396 assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));13611397 13621398 let out = mktemp("pr-out");1363- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1399+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1400+ .expect("render");13641401 let html =13651402 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))13661403 .expect("pr page");@@ -1389,7 +1426,8 @@ mod tests {13891426 )13901427 .unwrap();13911428 let out = mktemp("pr-diff-out");1392- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1429+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1430+ .expect("render");13931431 let html =13941432 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))13951433 .expect("pr page");@@ -1450,7 +1488,8 @@ mod tests {14501488 assert_eq!(data.ci.latest_verdict(), "fail");14511489 14521490 let out = mktemp("taskci-out");1453- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1491+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1492+ .expect("render");14541493 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");14551494 for status in ["todo", "doing", "blocked", "done"] {14561495 assert!(tasks.contains(status), "group {status}");@@ -1471,7 +1510,8 @@ mod tests {14711510 assert!(data.tasks.is_empty());14721511 assert_eq!(data.ci.latest_verdict(), "none");14731512 let out = mktemp("empty-out");1474- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1513+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1514+ .expect("render");14751515 for page in [14761516 "issues/index.html",14771517 "pulls/index.html",@@ -1490,7 +1530,8 @@ mod tests {14901530 // Empty forge: buttons + linked empty states.14911531 let repo = mktemp("submit-empty");14921532 let out = mktemp("submit-empty-out");1493- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1533+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1534+ .expect("render");14941535 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");14951536 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");14961537 assert!(@@ -1523,7 +1564,16 @@ mod tests {15231564 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",15241565 );15251566 let out2 = mktemp("submit-full-out");1526- render_loaded(&load(&repo), &out2, "test", "<nav>", None).expect("render");1567+ render_loaded(1568+ &load(&repo),1569+ &out2,1570+ "test",1571+ "<nav>",1572+ "main",1573+ "abc1234",1574+ None,1575+ )1576+ .expect("render");15271577 let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");15281578 let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");15291579 assert!(@@ -1641,7 +1691,8 @@ mod tests {16411691 assert!(css.contains("align-items:center"), "inline row");16421692 let repo = mktemp("page-head");16431693 let out = mktemp("page-head-out");1644- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1694+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1695+ .expect("render");16451696 for (page, title, btn) in [16461697 ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),16471698 ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),@@ -1683,7 +1734,8 @@ mod tests {16831734 ),16841735 );16851736 let out = mktemp("long-title-out");1686- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1737+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1738+ .expect("render");16871739 for page in ["issues/index.html", "pulls/index.html"] {16881740 let html = std::fs::read_to_string(out.join(page)).expect(page);16891741 assert!(@@ -1741,7 +1793,7 @@ mod tests {17411793 assert_eq!(data.closed_pr_count(), 1, "merged");17421794 17431795 let out = mktemp("open-counts-out");1744- render_loaded(&data, &out, "test", "<nav>", None).expect("render");1796+ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");17451797 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");17461798 assert!(issues.contains("2 closed"), "issues closed total");17471799 assert!(issues.contains("Issue open"), "open row kept");@@ -1806,7 +1858,8 @@ mod tests {18061858 "---\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",18071859 );18081860 let out = mktemp("cards-out");1809- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1861+ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1862+ .expect("render");18101863 for (page, labels) in [18111864 (18121865 "issues/index.html",
src/inbox.rs+14 −4--- a/src/inbox.rs+++ b/src/inbox.rs@@ -17,7 +17,7 @@1717 1818 use std::path::Path;1919 20-use crate::layout::{esc, page, page_site};20+use crate::layout::{esc, page, page_site_with_provenance};2121 2222 /// Max chars for `title` (both kinds).2323 pub const MAX_TITLE_CHARS: usize = 300;@@ -614,6 +614,8 @@ fn form_page(614614 action: &str,615615 body_name: &str,616616 body_label: &str,617+ branch: &str,618+ short_sha: &str,617619 description: Option<&str>,618620 ) -> String {619621 let body = format!(@@ -649,12 +651,14 @@ fn form_page(649651 body_label = esc(body_label),650652 license = license_block(),651653 );652- page_site(654+ page_site_with_provenance(653655 &format!("{heading} — {site_name}"),654656 nav,655657 &body,656658 site_name,657659 description,660+ branch,661+ short_sha,658662 )659663 }660664 @@ -663,6 +667,8 @@ pub fn render_forms(663667 out: &Path,664668 site_name: &str,665669 nav: &str,670+ branch: &str,671+ short_sha: &str,666672 description: Option<&str>,667673 ) -> Result<(), String> {668674 let issue = form_page(@@ -673,6 +679,8 @@ pub fn render_forms(673679 InboxType::Issue.route(),674680 "body",675681 "Body",682+ branch,683+ short_sha,676684 description,677685 );678686 let pr = form_page(@@ -683,6 +691,8 @@ pub fn render_forms(683691 InboxType::PrPrompt.route(),684692 "prompt",685693 "Prompt",694+ branch,695+ short_sha,686696 description,687697 );688698 std::fs::write(out.join("new-issue.html"), issue)@@ -1036,7 +1046,7 @@ mod tests {10361046 .unwrap_or(0)10371047 ));10381048 std::fs::create_dir_all(&dir).unwrap();1039- render_forms(&dir, "test", "", None).unwrap();1049+ render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();10401050 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();10411051 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();10421052 assert!(issue.contains("action=\"/inbox/issue\""));@@ -1071,7 +1081,7 @@ mod tests {10711081 .unwrap_or(0)10721082 ));10731083 std::fs::create_dir_all(&dir).unwrap();1074- render_forms(&dir, "test", "", None).unwrap();1084+ render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();10751085 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();10761086 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();10771087 for html in [&issue, &pr] {
src/layout.rs+122 −0--- a/src/layout.rs+++ b/src/layout.rs@@ -186,6 +186,27 @@ pub fn apply_active(nav: &str, href: &str) -> String {186186 nav.to_string()187187 }188188 189+/// Provenance footer line (m6 finish-line, f0rge #41): a single muted190+/// line carried on EVERY page — `generated from a snapshot of a repo191+/// and its f0rge`, plus `branch @ short_sha` when the caller knows the192+/// snapshot (build pages do; serve-time stubs don't). Extends the193+/// existing `footer.site-footer` block: exactly one per page, never194+/// duplicated. `branch`/`short_sha` go through [`esc`] (untrusted195+/// config/branch names render as text).196+#[must_use]197+pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> String {198+ let mut line = String::from("generated from a snapshot of a repo and its f0rge");199+ if let (Some(b), Some(s)) = (branch, short_sha) {200+ // Empty snapshot ids degrade to the generic line (never `main @ `).201+ if !b.is_empty() && !s.is_empty() {202+ line.push_str(&format!(" · {} @ {}", esc(b), esc(s)));203+ }204+ }205+ format!(206+ "<footer class=\"site-footer\"><div class=\"wrap\"><p class=\"muted footer-provenance\">{line}</p></div></footer>"207+ )208+}209+189210 #[must_use]190211 pub fn page_site(191212 title: &str,@@ -193,6 +214,45 @@ pub fn page_site(193214 body: &str,194215 site_name: &str,195216 description: Option<&str>,217+) -> String {218+ page_site_inner(title, nav, body, site_name, description, None, None)219+}220+221+/// [`page_site`] plus snapshot provenance in the footer (m6 finish-line,222+/// f0rge #41): `branch`/`short_sha` name the built snapshot so every223+/// build page carries `branch @ sha` after the generic line. Callers224+/// without a repo snapshot (serve 404s, inbox acks) use [`page_site`]225+/// and get the generic line alone.226+#[must_use]227+pub fn page_site_with_provenance(228+ title: &str,229+ nav: &str,230+ body: &str,231+ site_name: &str,232+ description: Option<&str>,233+ branch: &str,234+ short_sha: &str,235+) -> String {236+ page_site_inner(237+ title,238+ nav,239+ body,240+ site_name,241+ description,242+ Some(branch),243+ Some(short_sha),244+ )245+}246+247+#[must_use]248+fn page_site_inner(249+ title: &str,250+ nav: &str,251+ body: &str,252+ site_name: &str,253+ description: Option<&str>,254+ branch: Option<&str>,255+ short_sha: Option<&str>,196256 ) -> String {197257 let title_e = esc(title);198258 // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the@@ -227,6 +287,7 @@ pub fn page_site(227287 Some(href) => apply_active(nav, href),228288 None => nav.to_string(),229289 };290+ let footer = provenance_footer(branch, short_sha);230291 format!(231292 "<!DOCTYPE html>\n\232293 <html lang=\"en\">\n\@@ -234,6 +295,7 @@ pub fn page_site(234295 <body>\n\235296 <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\236297 <main class=\"wrap\">\n{body}\n</main>\n\298+{footer}\n\237299 </body>\n\238300 </html>\n"239301 )@@ -484,4 +546,64 @@ mod tests {484546 assert!(html.contains("Binary files"), "binary note intact");485547 assert!(!html.contains("<span>\n[hu13"), "markers have no span");486548 }549+550+ #[test]551+ fn every_page_carries_a_single_muted_provenance_footer() {552+ // m6 footer-line (f0rge #41): the existing footer block carries553+ // one muted provenance line; serve stubs without a snapshot554+ // get the generic line alone.555+ use super::{page_site_with_provenance, provenance_footer};556+ for html in [557+ page("t", "<a href=\"/\">home</a>", "<p>b</p>"),558+ page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234"),559+ ] {560+ assert_eq!(html.matches("<footer").count(), 1, "exactly one footer");561+ assert!(562+ html.contains("<footer class=\"site-footer\">"),563+ "existing footer block extended, not duplicated"564+ );565+ assert!(566+ html.contains("generated from a snapshot of a repo and its f0rge"),567+ "provenance line present"568+ );569+ assert!(570+ html.contains("class=\"muted footer-provenance\""),571+ "muted styling via .muted"572+ );573+ let f0 = html.find("<footer").expect("footer open");574+ let f1 = html.find("</footer>").expect("footer close");575+ assert!(!html[f0..f1].contains("<br"), "single line, no spacers");576+ }577+ // Generic footer names no snapshot.578+ assert!(579+ !provenance_footer(None, None).contains('@'),580+ "no sha when unknown"581+ );582+ // Empty ids degrade to the generic line, never `main @ `.583+ assert!(!provenance_footer(Some("main"), Some("")).contains('@'));584+ }585+586+ #[test]587+ fn provenance_footer_names_snapshot_and_escapes() {588+ // m6 footer-line (f0rge #41): build pages name branch @ sha;589+ // hostile branch names render as text.590+ use super::page_site_with_provenance;591+ let html =592+ page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234");593+ assert!(html.contains("main @ abc1234"), "branch@sha named: {html}");594+ let evil = page_site_with_provenance(595+ "t",596+ "<nav></nav>",597+ "<p>b</p>",598+ "s",599+ None,600+ "<script>alert(1)</script>",601+ "abc1234",602+ );603+ assert!(!evil.contains("<script>"), "branch escaped: {evil}");604+ assert!(605+ evil.contains("<script>alert(1)</script> @ abc1234"),606+ "escaped branch kept with sha"607+ );608+ }487609 }
src/releases.rs+35 −6--- a/src/releases.rs+++ b/src/releases.rs@@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};2222 use serde::Deserialize;2323 2424 use crate::build::markdown_to_html;25-use crate::layout::{esc, page_site};25+use crate::layout::{esc, page_site_with_provenance};2626 2727 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2828 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;@@ -322,6 +322,8 @@ fn render_release_page(322322 rel: &Release,323323 site_name: &str,324324 nav: &str,325+ branch: &str,326+ short_sha: &str,325327 description: Option<&str>,326328 ) -> String {327329 let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));@@ -364,23 +366,28 @@ fn render_release_page(364366 }365367 }366368 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");367- page_site(369+ page_site_with_provenance(368370 &format!("{} — releases — {site_name}", rel.display_title()),369371 nav,370372 &body,371373 site_name,372374 description,375+ branch,376+ short_sha,373377 )374378 }375379 376380 /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded377381 /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.382+#[allow(clippy::too_many_arguments)]378383 pub fn render_loaded(379384 releases: &[Release],380385 repo: &Path,381386 out: &Path,382387 site_name: &str,383388 nav: &str,389+ branch: &str,390+ short_sha: &str,384391 description: Option<&str>,385392 ) -> Result<(), String> {386393 let root = repo.join(".forge").join("releases");@@ -401,12 +408,14 @@ pub fn render_loaded(401408 }402409 body.push_str("</tbody>\n</table></div>\n");403410 }404- let index = page_site(411+ let index = page_site_with_provenance(405412 &format!("releases — {site_name}"),406413 nav,407414 &body,408415 site_name,409416 description,417+ branch,418+ short_sha,410419 );411420 let dest = out.join("releases").join("index.html");412421 if let Some(parent) = dest.parent() {@@ -423,7 +432,7 @@ pub fn render_loaded(423432 copy_tree(&src_dir, &dest_dir)?;424433 }425434 }426- let html = render_release_page(rel, site_name, nav, description);435+ let html = render_release_page(rel, site_name, nav, branch, short_sha, description);427436 let page_dest = out.join("releases").join(format!("{}.html", rel.version));428437 if let Some(parent) = page_dest.parent() {429438 std::fs::create_dir_all(parent)@@ -486,7 +495,17 @@ mod tests {486495 std::fs::create_dir_all(&repo).unwrap();487496 let out = repo.join("out");488497 std::fs::create_dir_all(&out).unwrap();489- render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");498+ render_loaded(499+ &[rel],500+ &repo,501+ &out,502+ "test",503+ "<nav>",504+ "main",505+ "abc1234",506+ None,507+ )508+ .expect("render");490509 let index =491510 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");492511 assert!(@@ -535,7 +554,17 @@ mod tests {535554 std::fs::create_dir_all(&repo).unwrap();536555 let out = repo.join("out");537556 std::fs::create_dir_all(&out).unwrap();538- render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");557+ render_loaded(558+ &[rel],559+ &repo,560+ &out,561+ "test",562+ "<nav>",563+ "main",564+ "abc1234",565+ None,566+ )567+ .expect("render");539568 let index =540569 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");541570 let tds = index.matches("<td").count();
src/wiki.rs+12 −6--- a/src/wiki.rs+++ b/src/wiki.rs@@ -15,7 +15,7 @@ use std::collections::BTreeMap;1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page_site};18+use crate::layout::{esc, page_site_with_provenance};1919 2020 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).2121 pub const WIKI_MAX_BYTES: u64 = 256 * 1024;@@ -165,6 +165,8 @@ pub fn render(165165 out: &Path,166166 site_name: &str,167167 nav: &str,168+ branch: &str,169+ short_sha: &str,168170 description: Option<&str>,169171 ) -> Result<Vec<WikiPage>, String> {170172 let root = repo.join(".forge").join("wiki");@@ -290,12 +292,14 @@ pub fn render(290292 }291293 std::fs::write(292294 wiki_dir.join("index.html"),293- page_site(295+ page_site_with_provenance(294296 &format!("wiki — {site_name}"),295297 nav,296298 &body,297299 site_name,298300 description,301+ branch,302+ short_sha,299303 ),300304 )301305 .map_err(|e| format!("hu13: write wiki index: {e}"))?;@@ -308,12 +312,14 @@ pub fn render(308312 esc(&p.path),309313 );310314 body.push_str(&markdown_to_html(&text));311- let html = page_site(315+ let html = page_site_with_provenance(312316 &format!("{} — wiki — {site_name}", p.title),313317 nav,314318 &body,315319 site_name,316320 description,321+ branch,322+ short_sha,317323 );318324 let dest = wiki_dir.join(format!("{}.html", p.path));319325 if let Some(parent) = dest.parent() {@@ -383,7 +389,7 @@ mod tests {383389 fn index_titles_sorted_skips_bad() {384390 let repo = fixture_repo();385391 let out = mktemp("out");386- let pages = render(&repo, &out, "test", "<nav>", None).expect("render");392+ let pages = render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");387393 let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();388394 assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);389395 assert_eq!(pages[0].title, "Nested Page");@@ -405,7 +411,7 @@ mod tests {405411 fn pages_render_markdown_escaped() {406412 let repo = fixture_repo();407413 let out = mktemp("out");408- render(&repo, &out, "test", "<nav>", None).expect("render");414+ render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");409415 let hello =410416 std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");411417 assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");@@ -426,7 +432,7 @@ mod tests {426432 fn missing_wiki_dir_renders_empty_index() {427433 let dir = mktemp("nowiki");428434 let out = mktemp("out");429- let pages = render(&dir, &out, "test", "<nav>", None).expect("render");435+ let pages = render(&dir, &out, "test", "<nav>", "main", "abc1234", None).expect("render");430436 assert!(pages.is_empty());431437 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");432438 assert!(index.contains("No wiki pages yet."));
static/style.css+12 −3--- a/static/style.css+++ b/static/style.css@@ -63,9 +63,13 @@ img,video,svg{max-width:100%;height:auto}6363 a{color:var(--blue)}6464 a:visited{color:var(--visited)}6565 main{padding:1.25rem 0 3rem;min-width:0}66-/* Repo header stacks on narrow; clone cmd + sha wrap safely. */67-.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}68-.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}66+/* Repo header stacks on narrow; clone cmd + sha wrap safely.67+ Breathing room under the navbar (m6/code-space, f0rge #40): air via68+ CSS margin-top (never <br> spacers); the base stays tight for 360px69+ and wider screens get a touch more. Title is explicit 2rem so it70+ reads as the page title on every viewport. */71+.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}72+.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}6973 .clone,.repo-meta{margin:.25rem 0;min-width:0}7074 .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}7175 code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;@@ -115,7 +119,11 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}115119 .btn-new:visited{color:var(--btn-fg)}116120 .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}117121 .muted{color:var(--muted)}122+footer.site-footer{border-top:1px solid var(--border)}118123 footer.site-footer .wrap{padding:1rem}124+/* Provenance line (m6/footer-line, f0rge #41): the single muted line125+ inside the existing footer block — small, no extra whitespace. */126+footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}119127 /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.120128 Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/121129 .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav@@ -136,6 +144,7 @@ footer.site-footer .wrap{padding:1rem}136144 @media (min-width:768px){137145 .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}138146 .site-nav .wrap{gap:.25rem 1.25rem}147+ .repo-head{margin-top:1.25rem}139148 main{padding:1.5rem 0 3rem}140149 }141150 @media (min-width:1100px){
tests/code_space.rs+184 −0--- /dev/null+++ b/tests/code_space.rs@@ -0,0 +1,184 @@1+//! Code-tab breathing room (m6, f0rge issue #40):2+//! the repo-head sits clear of the navbar via CSS margin (never `<br>`3+//! spacers), with a slightly larger title — while staying tight on 360px.4+//!5+//! Builds a small fixture repo with the real binary and string-asserts on6+//! the emitted HTML + `static/style.css` (no rendering engine needed).7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn bin() -> &'static str {12+ env!("CARGO_BIN_EXE_hu13")13+}14+15+fn manifest() -> PathBuf {16+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))17+}18+19+fn fresh(prefix: &str) -> PathBuf {20+ let dir = std::env::temp_dir().join(format!(21+ "hu13-codespace-{prefix}-{}-{}",22+ std::process::id(),23+ std::time::SystemTime::now()24+ .duration_since(std::time::UNIX_EPOCH)25+ .map(|d| d.as_nanos())26+ .unwrap_or(0)27+ ));28+ std::fs::create_dir_all(&dir).unwrap();29+ dir30+}31+32+fn git(repo: &Path, args: &[&str]) {33+ let st = Command::new("git")34+ .arg("-C")35+ .arg(repo)36+ .args(args)37+ .status()38+ .expect("spawn git");39+ assert!(st.success(), "git {args:?} failed");40+}41+42+fn fixture_repo() -> PathBuf {43+ let dir = fresh("repo");44+ git(&dir, &["init", "-b", "main"]);45+ git(&dir, &["config", "user.name", "hu13-test"]);46+ git(&dir, &["config", "user.email", "[email protected]"]);47+ git(&dir, &["config", "commit.gpgsign", "false"]);48+ std::fs::write(dir.join("README.md"), "# space fixture\n\nhello\n").unwrap();49+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();50+ git(&dir, &["add", "-A"]);51+ git(&dir, &["commit", "-m", "init"]);52+ dir53+}54+55+fn build_fixture() -> PathBuf {56+ let repo = fixture_repo();57+ let out = fresh("out");58+ let st = Command::new(bin())59+ .arg("build")60+ .arg(&repo)61+ .arg("-o")62+ .arg(&out)63+ .status()64+ .expect("spawn hu13 build");65+ assert!(st.success(), "fixture build exits 0");66+ out67+}68+69+fn css() -> String {70+ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")71+}72+73+/// Value of `prop` in the first `selector{...}` rule inside `scope`.74+fn rule_value(scope: &str, selector: &str, prop: &str) -> Option<String> {75+ let start = scope.find(selector)?;76+ let block_start = scope[start..].find('{')? + start;77+ let block_end = scope[block_start..].find('}')? + block_start;78+ let block = &scope[block_start..block_end];79+ let pos = block.find(prop)?;80+ let after = block[pos + prop.len()..].trim_start().strip_prefix(':')?;81+ let value: String = after82+ .trim_start()83+ .chars()84+ .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t' | '\n'))85+ .collect();86+ Some(value)87+}88+89+fn parse_rem(value: &str) -> Option<f32> {90+ value.strip_suffix("rem")?.trim().parse::<f32>().ok()91+}92+93+#[test]94+fn repo_head_spacing_is_css_margin_not_br_tags() {95+ // Structure assert: the code tab keeps its stacked repo-head96+ // (name + meta + clone block) with air from CSS margin — no `<br>`97+ // spacers between the navbar and the repo-head.98+ let out = build_fixture();99+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");100+ let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");101+ let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;102+ let head = &index[head_start..head_end];103+ assert!(head.contains("<h1>"), "repo-head keeps its title");104+ assert!(105+ !head.contains("<br"),106+ "spacing is CSS margin, not <br> tags"107+ );108+ assert!(109+ head.contains("repo-meta"),110+ "branch/sha meta line kept inside repo-head"111+ );112+ // The header bar ends (</header>) before <main> carries the repo-head:113+ // the break between nav and repo-head is a block boundary + margin.114+ let header_end = index.find("</header>").expect("header close");115+ assert!(116+ header_end < head_start,117+ "repo-head sits below the navbar block"118+ );119+ let between = &index[header_end..head_start];120+ assert!(121+ !between.contains("<br"),122+ "no spacer tags between navbar and repo-head"123+ );124+125+ // CSS assert: `.repo-head` carries the top margin.126+ let css = css();127+ let base = css.split("@media (min-width").next().expect("base css");128+ let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");129+ assert!(130+ parse_rem(&margin).is_some_and(|v| v > 0.0),131+ "repo-head margin-top is positive breathing room, got {margin:?}"132+ );133+}134+135+#[test]136+fn repo_head_title_reads_as_page_title() {137+ // Slightly larger title treatment: explicit size on the repo-head h1.138+ let css = css();139+ let base = css.split("@media (min-width").next().expect("base css");140+ let size = rule_value(base, ".repo-head h1", "font-size").expect("repo-head h1 font-size");141+ let rem = parse_rem(&size).expect("h1 size in rem");142+ assert!(143+ rem >= 1.5,144+ "repo-head title is title-scale (>=1.5rem), got {size:?}"145+ );146+ let out = build_fixture();147+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");148+ assert!(149+ index.contains("<div class=\"repo-head\"><h1>"),150+ "repo-head h1 opens the code tab body"151+ );152+}153+154+#[test]155+fn mobile_stays_tight_wider_screens_get_more() {156+ // 360px must not drown in whitespace: the base (pre-media-query)157+ // margin stays modest; >=768px may open up a touch more.158+ let css = css();159+ let base = css.split("@media (min-width").next().expect("base css");160+ let base_margin =161+ rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");162+ let base_rem = parse_rem(&base_margin).expect("base margin in rem");163+ assert!(164+ base_rem <= 1.0,165+ "mobile margin-top stays tight (<=1rem), got {base_margin:?}"166+ );167+ let desktop = css168+ .split("@media (min-width:768px)")169+ .nth(1)170+ .expect("768px breakpoint");171+ // Only the current breakpoint block: cut before the next @media.172+ let block = desktop.split("@media").next().expect("768 block");173+ let wide_margin =174+ rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");175+ let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");176+ assert!(177+ wide_rem >= base_rem,178+ "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"179+ );180+ assert!(181+ wide_rem <= 2.0,182+ "even wide stays modest (<=2rem), got {wide_margin:?}"183+ );184+}
tests/provenance.rs+161 −1--- /dev/null+++ b/tests/provenance.rs@@ -0,0 +1,161 @@1+//! Footer provenance line (m6, f0rge issue #41):2+//! every page extends the existing footer block with a single muted line3+//! (`generated from a snapshot of a repo and its f0rge` + branch@sha).4+//!5+//! Builds a small fixture repo with the real binary and string-asserts on6+//! the emitted HTML + `static/style.css` (no rendering engine needed).7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn bin() -> &'static str {12+ env!("CARGO_BIN_EXE_hu13")13+}14+15+fn manifest() -> PathBuf {16+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))17+}18+19+fn fresh(prefix: &str) -> PathBuf {20+ let dir = std::env::temp_dir().join(format!(21+ "hu13-provenance-{prefix}-{}-{}",22+ std::process::id(),23+ std::time::SystemTime::now()24+ .duration_since(std::time::UNIX_EPOCH)25+ .map(|d| d.as_nanos())26+ .unwrap_or(0)27+ ));28+ std::fs::create_dir_all(&dir).unwrap();29+ dir30+}31+32+fn git(repo: &Path, args: &[&str]) {33+ let st = Command::new("git")34+ .arg("-C")35+ .arg(repo)36+ .args(args)37+ .status()38+ .expect("spawn git");39+ assert!(st.success(), "git {args:?} failed");40+}41+42+fn git_out(repo: &Path, args: &[&str]) -> String {43+ let out = Command::new("git")44+ .arg("-C")45+ .arg(repo)46+ .args(args)47+ .output()48+ .expect("spawn git");49+ assert!(out.status.success(), "git {args:?} failed");50+ String::from_utf8_lossy(&out.stdout).trim().to_string()51+}52+53+/// Fixture with two commits so list + detail pages exist.54+fn fixture_repo() -> PathBuf {55+ let dir = fresh("repo");56+ git(&dir, &["init", "-b", "main"]);57+ git(&dir, &["config", "user.name", "hu13-test"]);58+ git(&dir, &["config", "user.email", "[email protected]"]);59+ git(&dir, &["config", "commit.gpgsign", "false"]);60+ std::fs::write(dir.join("README.md"), "# provenance fixture\n\nhello\n").unwrap();61+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();62+ git(&dir, &["add", "-A"]);63+ git(&dir, &["commit", "-m", "first"]);64+ std::fs::write(dir.join("hello.txt"), "hello again\n").unwrap();65+ git(&dir, &["add", "-A"]);66+ git(&dir, &["commit", "-m", "second"]);67+ dir68+}69+70+fn footer_of(html: &str) -> &str {71+ let start = html.find("<footer").expect("footer open");72+ let end = html.find("</footer>").expect("footer close") + "</footer>".len();73+ &html[start..end]74+}75+76+#[test]77+fn footer_provenance_on_index_and_detail_pages() {78+ let repo = fixture_repo();79+ let sha = git_out(&repo, &["rev-parse", "--short", "HEAD"]);80+ assert!(!sha.is_empty(), "fixture has a short sha");81+ let out = fresh("out");82+ let st = Command::new(bin())83+ .arg("build")84+ .arg(&repo)85+ .arg("-o")86+ .arg(&out)87+ .status()88+ .expect("spawn hu13 build");89+ assert!(st.success(), "fixture build exits 0");90+91+ // One commit-detail page exists.92+ let commit_page = std::fs::read_dir(out.join("commit"))93+ .expect("commit dir")94+ .filter_map(|e| e.ok())95+ .map(|e| e.path())96+ .find(|p| p.extension().is_some_and(|x| x == "html"))97+ .expect("one commit page");98+99+ // Index + a detail page (commit detail) + breadth across tabs.100+ let mut pages = vec![101+ out.join("index.html"),102+ commit_page,103+ out.join("commits.html"),104+ out.join("files").join("hello.txt.html"),105+ out.join("issues/index.html"),106+ out.join("agents.html"),107+ out.join("new-issue.html"),108+ ];109+ // Wiki/releases tabs render even when empty (deep links keep working).110+ pages.push(out.join("wiki/index.html"));111+ pages.push(out.join("releases/index.html"));112+ for page in &pages {113+ let html =114+ std::fs::read_to_string(page).unwrap_or_else(|_| panic!("read {}", page.display()));115+ assert_eq!(116+ html.matches("<footer").count(),117+ 1,118+ "{} extends the existing footer (exactly one)",119+ page.display()120+ );121+ assert!(122+ html.contains("<footer class=\"site-footer\">"),123+ "{} keeps the footer block",124+ page.display()125+ );126+ assert!(127+ html.contains("generated from a snapshot of a repo and its f0rge"),128+ "{} carries the provenance line",129+ page.display()130+ );131+ assert!(132+ html.contains(&format!("main @ {sha}")),133+ "{} names the built snapshot",134+ page.display()135+ );136+ // Muted, single line: no spacers inside the footer.137+ let footer = footer_of(&html);138+ assert!(139+ footer.contains("muted"),140+ "{} footer is muted",141+ page.display()142+ );143+ assert!(144+ !footer.contains("<br"),145+ "{} footer is a single line",146+ page.display()147+ );148+ }149+}150+151+#[test]152+fn footer_styling_is_muted_and_tiny() {153+ let css = std::fs::read_to_string(manifest().join("static").join("style.css"))154+ .expect("read style.css");155+ assert!(css.contains("footer.site-footer"), "footer block styled");156+ assert!(157+ css.contains(".footer-provenance"),158+ "provenance line has its own rule"159+ );160+ assert!(css.contains(".muted"), "muted tone reused, not a new color");161+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.