src/layout.rs 33.2 KiB 831 lines Raw
1 //! Base HTML layout: hand-rolled, escaped by construction, zero JS.2 //!3 //! `esc()` escapes `<`, `>`, `&`, `"` — the full set this crate escapes.4 //! `page()` escapes the `<title>` and inlines `nav`/`body` as-is, so callers5 //! must pass already-escaped (or intentionally trusted-static) HTML for6 //! `nav` and `body`. Text from git / `.forge/` / inbox must go through7 //! `esc()` first and must never be pasted into a shell (see gl0w.md).8 9 /// Escape `<`, `>`, `&`, `"` for HTML text/attribute contexts.10 // TODO(m1+): stubs only — `build`/`serve` will call this; allow until wired.11 #[allow(dead_code)]12 #[must_use]13 pub fn esc(input: &str) -> String {14     let mut out = String::with_capacity(input.len());15     for ch in input.chars() {16         match ch {17             '&' => out.push_str("&amp;"),18             '<' => out.push_str("&lt;"),19             '>' => out.push_str("&gt;"),20             '"' => out.push_str("&quot;"),21             _ => out.push(ch),22         }23     }24     out25 }26 27 /// Render unified diff text as one card per file (f0rge #74).28 ///29 /// Each card carries the path, `+n −m` counts and a proportional bar;30 /// hunk lines show old/new line numbers in a two-column gutter. `diff31 /// --git`, `index`, mode and `Binary files` lines stay visible as dim32 /// metadata; the format-patch mbox preamble (From/Date/Subject/stat) is33 /// dropped because it duplicates the PR title. Every byte is escaped34 /// before wrapping, so a hostile `<script>` in an added line is text.35 #[must_use]36 pub fn diff_html(raw: &str) -> String {37     if raw.is_empty() {38         return String::new();39     }40     if !raw.lines().any(|l| l.starts_with("diff --git ")) {41         return plain_diff_html(raw);42     }43     let all: Vec<&str> = raw.lines().collect();44     let mut starts: Vec<usize> = all45         .iter()46         .enumerate()47         .filter(|(_, l)| l.starts_with("diff --git "))48         .map(|(i, _)| i)49         .collect();50     if starts.is_empty() {51         return plain_diff_html(raw);52     }53     starts.push(all.len());54     let mut out = String::with_capacity(raw.len() + raw.len() / 3);55     for w in starts.windows(2) {56         let chunk = &all[w[0]..w[1]];57         let path = diff_chunk_path(chunk);58         let mut added = 0usize;59         let mut removed = 0usize;60         let mut counting_hunk = false;61         for line in chunk {62             if line.starts_with("@@") {63                 counting_hunk = true;64             } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) {65                 added += 1;66             } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) {67                 removed += 1;68             }69         }70         let total = added + removed;71         let pct = (added * 100).checked_div(total).unwrap_or(0);72         out.push_str("<details open class=\"difffile\"><summary><code class=\"df-path\">");73         out.push_str(&esc(&path));74         out.push_str("</code><span class=\"df-count\"><span class=\"df-add\">+");75         out.push_str(&added.to_string());76         out.push_str("</span> <span class=\"df-del\">−");77         out.push_str(&removed.to_string());78         out.push_str("</span></span>");79         if total > 0 {80             out.push_str("<span class=\"df-bar\" aria-hidden=\"true\"><span class=\"df-bar-add\" style=\"width:");81             out.push_str(&pct.to_string());82             out.push_str("%\"></span></span>");83         }84         out.push_str("</summary>\n<div class=\"pre-scroll\"><pre class=\"diff\">");85 86         let mut old_ln = 0i64;87         let mut new_ln = 0i64;88         let mut in_hunk = false;89         for line in chunk {90             if line.starts_with("@@") {91                 in_hunk = true;92                 let mut parts = line.split_whitespace();93                 let _ = parts.next();94                 if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {95                     old_ln = o96                         .split(',')97                         .next()98                         .and_then(|v| v.parse().ok())99                         .unwrap_or(0);100                 }101                 if let Some(n) = parts.next().and_then(|v| v.strip_prefix('+')) {102                     new_ln = n103                         .split(',')104                         .next()105                         .and_then(|v| v.parse().ok())106                         .unwrap_or(0);107                 }108                 out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));109             } else if line.starts_with("+++") || line.starts_with("---") {110                 if in_hunk {111                     if line.starts_with('+') {112                         out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));113                         new_ln += 1;114                     } else {115                         out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));116                         old_ln += 1;117                     }118                 } else {119                     out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));120                 }121             } else if line.starts_with('+') {122                 out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));123                 new_ln += 1;124             } else if line.starts_with('-') {125                 out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));126                 old_ln += 1;127             } else if line.starts_with(' ') {128                 out.push_str(&diff_numbered(129                     &old_ln.to_string(),130                     &new_ln.to_string(),131                     "dctx",132                     line,133                 ));134                 old_ln += 1;135                 new_ln += 1;136             } else {137                 out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));138             }139         }140         out.push_str("</pre></div></details>\n");141     }142     out143 }144 145 fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {146     format!(147         "<span class=\"df-line {class}-row\"><span class=\"dln\">{old}</span><span class=\"dln\">{new}</span><span class=\"{class}\">{}</span></span>",148         esc(line)149     )150 }151 152 /// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the153 /// `diff --git` header. `/dev/null` falls through to the other side.154 fn diff_chunk_path(lines: &[&str]) -> String {155     let headers = lines.iter().take_while(|l| !l.starts_with("@@"));156     for line in headers.clone() {157         if let Some(rest) = line.strip_prefix("+++ ") {158             if rest != "/dev/null" {159                 return rest.strip_prefix("b/").unwrap_or(rest).to_string();160             }161         }162     }163     for line in headers {164         if let Some(rest) = line.strip_prefix("--- ") {165             if rest != "/dev/null" {166                 return rest.strip_prefix("a/").unwrap_or(rest).to_string();167             }168         }169     }170     lines171         .first()172         .and_then(|l| l.strip_prefix("diff --git a/"))173         .and_then(|rest| rest.split(" b/").next())174         .unwrap_or("diff")175         .to_string()176 }177 178 /// Fallback for diff text without `diff --git` headers: one escaped block179 /// with line-level coloring (markers stay span-free, old behavior).180 fn plain_diff_html(raw: &str) -> String {181     let mut out = String::with_capacity(raw.len() + raw.len() / 4);182     for line in raw.lines() {183         // One block span per line; no literal newlines between spans or184         // `white-space:pre` inserts a blank row (f0rge #86).185         out.push_str("<span class=\"dline\">");186         out.push_str(&diff_line_span(line));187         out.push_str("</span>");188     }189     out190 }191 192 fn diff_line_span(line: &str) -> String {193     let e = esc(line);194     if line.starts_with("@@") {195         format!("<span class=\"diff-hunk\">{e}</span>")196     } else if line.starts_with("+++") || line.starts_with("---") {197         format!("<span class=\"diff-file\">{e}</span>")198     } else if line.starts_with('+') {199         format!("<span class=\"diff-add\">{e}</span>")200     } else if line.starts_with('-') {201         format!("<span class=\"diff-del\">{e}</span>")202     } else {203         e204     }205 }206 207 /// Render a full HTML page. `title` is escaped; `nav` and `body` are208 /// inlined as-is (caller must escape untrusted text with [`esc`]).209 #[allow(dead_code)]210 #[must_use]211 pub fn page(title: &str, nav: &str, body: &str) -> String {212     page_site(title, nav, body, "hu13", None)213 }214 215 /// Render a full HTML page with site identity (m6 header config, f0rge216 /// issues #21 + #24; minimal header f0rge issues #27 + #28): the header217 /// carries zero names (logo-with-text art only + nav), `<title>` is218 /// the escaped page `title` (callers build it as `"{page} \u2014 {site}"`219 /// so the repo name is in every tab), `og:title` mirrors the page title,220 /// and `og:description` / `meta[name=description]` carry `description`221 /// when present. `site_name` still names the title/og surfaces (and the222 /// feed title + code-page repo-head via `build`); it is never rendered in223 /// the header. `description` is escaped (hostile `<script>` renders as224 /// text). `nav`/`body` are inlined as-is like [`page`].225 ///226 /// Head standards (m6 favicon, f0rge issue #16) ride along on every page:227 /// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at228 /// `/favicon.svg` — no binary blobs in the repo), light/dark229 /// `theme-color` (Tokyo Night `#f6f7f9` / `#1a1b26`, matching230 /// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`231 /// link..232 /// Which nav `href` is active for a page (m6 navbar, f0rge issue #18).233 ///234 /// `page_site` knows only `title`/`body`/`site_name`, so the mapping stays235 /// title-driven: exact section titles (`commits`, `issues`, …) map to their236 /// index hrefs, `commit <sha>` details map to commits, `X — releases` /237 /// `X — wiki` details map to their indexes, `Suggest an issue`/`Suggest a238 /// change` map to issues/pulls, and `#N …` detail pages disambiguate via239 /// the body (`all issues` vs `all pulls`). File/dir pages (`{path} —240 /// {site}` with no section prefix) map to `/` (code); bare titles with no241 /// site suffix (health, 404s, inbox acks) map to nothing so no tab claims242 /// them. Returns the exact `href` to mark, or `None` for no active tab.243 #[must_use]244 pub fn active_href(title: &str, body: &str, site_name: &str) -> Option<&'static str> {245     if title == site_name {246         return Some("/");247     }248     let suffix = format!(" — {site_name}");249     let base = title.strip_suffix(&suffix).unwrap_or(title);250     if base == "commits" || base == "commit" || base.starts_with("commit ") {251         return Some("/commits.html");252     }253     if base == "issues" {254         return Some("/issues/index.html");255     }256     if base == "pulls" {257         return Some("/pulls/index.html");258     }259     if base == "tasks" {260         return Some("/tasks/index.html");261     }262     if base == "ci" {263         return Some("/ci/index.html");264     }265     if base == "releases" || base.ends_with(" — releases") {266         return Some("/releases/index.html");267     }268     if base == "wiki" || base.ends_with(" — wiki") {269         return Some("/wiki/index.html");270     }271     if base == "agents" {272         return Some("/agents.html");273     }274     if base == "Suggest an issue" {275         return Some("/issues/index.html");276     }277     if base == "Suggest a change" {278         return Some("/pulls/index.html");279     }280     if base.starts_with('#') {281         if body.contains("all pulls") {282             return Some("/pulls/index.html");283         }284         if body.contains("all issues") {285             return Some("/issues/index.html");286         }287         return None;288     }289     // File/dir pages carry the site suffix but matched no section above.290     if title.ends_with(&suffix) {291         return Some("/");292     }293     None294 }295 296 /// Inject `aria-current="page"` into the first nav link matching `href`297 /// (m6 navbar, f0rge issue #18). Leaves nav untouched when it already298 /// carries an `aria-current` or lacks the href (unit-test stubs, 404s).299 #[must_use]300 pub fn apply_active(nav: &str, href: &str) -> String {301     if nav.contains("aria-current") {302         return nav.to_string();303     }304     let needle = format!("<a href=\"{href}\"");305     if let Some(pos) = nav.find(&needle) {306         let mut out = String::with_capacity(nav.len() + 22);307         out.push_str(&nav[..pos + needle.len()]);308         out.push_str(" aria-current=\"page\"");309         out.push_str(&nav[pos + needle.len()..]);310         return out;311     }312     nav.to_string()313 }314 315 /// Provenance footer line (m6 finish-line, f0rge #41): a single muted316 /// line carried on EVERY page — `generated from a snapshot of a repo317 /// and its f0rge`, plus `branch @ short_sha` when the caller knows the318 /// snapshot (build pages do; serve-time stubs don't). Extends the319 /// existing `footer.site-footer` block: exactly one per page, never320 /// duplicated. `branch`/`short_sha` go through [`esc`] (untrusted321 /// config/branch names render as text).322 #[must_use]323 pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> String {324     let mut line = String::from("generated from a snapshot of a repo and its f0rge");325     if let (Some(b), Some(s)) = (branch, short_sha) {326         // Empty snapshot ids degrade to the generic line (never `main @ `).327         if !b.is_empty() && !s.is_empty() {328             line.push_str(&format!(" · {} @ {}", esc(b), esc(s)));329         }330     }331     format!(332         "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\" aria-label=\"gl0w\">{gl0w}</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/new-issue.html\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>",333         gl0w = crate::logo::GL0W_SVG334     )335 }336 337 #[must_use]338 pub fn page_site(339     title: &str,340     nav: &str,341     body: &str,342     site_name: &str,343     description: Option<&str>,344 ) -> String {345     page_site_inner(title, nav, body, site_name, description, None, None)346 }347 348 /// [`page_site`] plus snapshot provenance in the footer (m6 finish-line,349 /// f0rge #41): `branch`/`short_sha` name the built snapshot so every350 /// build page carries `branch @ sha` after the generic line. Callers351 /// without a repo snapshot (serve 404s, inbox acks) use [`page_site`]352 /// and get the generic line alone.353 #[must_use]354 pub fn page_site_with_provenance(355     title: &str,356     nav: &str,357     body: &str,358     site_name: &str,359     description: Option<&str>,360     branch: &str,361     short_sha: &str,362 ) -> String {363     page_site_inner(364         title,365         nav,366         body,367         site_name,368         description,369         Some(branch),370         Some(short_sha),371     )372 }373 374 #[must_use]375 fn page_site_inner(376     title: &str,377     nav: &str,378     body: &str,379     site_name: &str,380     description: Option<&str>,381     branch: Option<&str>,382     short_sha: Option<&str>,383 ) -> String {384     let title_e = esc(title);385     // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the386     // active link (if any) gets `aria-current="page"` before the nav is387     // inlined. Logo art is `logo.svg` (diamond-hub + hu13 as <path>, no388     // <text>/fonts); `favicon.svg` is the simplified mark-only variant389     // for 16px (see `logo.rs`). Header is logo + nav only: exactly one390     // `•` separator, no `hu13-word` / `brand` name nodes.391     let mut head = format!(392         "<meta charset=\"utf-8\">\n\393 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\394 <meta name=\"color-scheme\" content=\"dark light\">\n\395 <link rel=\"stylesheet\" href=\"/style.css\">\n\396 <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n\397 <link rel=\"apple-touch-icon\" href=\"/favicon.svg\">\n\398 <meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">\n\399 <meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">\n\400 <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n\401 <link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\402 <title>{title_e}</title>\n\403 <meta property=\"og:title\" content=\"{title_e}\">\n\404 <meta property=\"og:type\" content=\"website\">\n\405 <meta property=\"og:site_name\" content=\"hu13 by gl0w\">\n\406 <meta property=\"og:image\" content=\"/og.png\">\n\407 <meta property=\"og:image:width\" content=\"1200\">\n\408 <meta property=\"og:image:height\" content=\"630\">\n\409 <meta property=\"og:image:alt\" content=\"hu13 — a gl0w project\">\n\410 <meta name=\"twitter:card\" content=\"summary_large_image\">\n"411     );412     if let Some(d) = description {413         let d_e = esc(d);414         head.push_str(&format!(415             "<meta name=\"description\" content=\"{d_e}\">\n\416 <meta property=\"og:description\" content=\"{d_e}\">\n"417         ));418     }419     let nav_active = match active_href(title, body, site_name) {420         Some(href) => apply_active(nav, href),421         None => nav.to_string(),422     };423     let footer = provenance_footer(branch, short_sha);424     format!(425         "<!DOCTYPE html>\n\426 <html lang=\"en\">\n\427 <head>\n{head}</head>\n\428 <body>\n\429 <header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\" aria-label=\"hu13 by gl0w — home\"><span class=\"gl0w-mark\" aria-hidden=\"true\">{gl0w}</span><span class=\"lockup-slash\" aria-hidden=\"true\">/</span><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"84\" height=\"28\"></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\430 <main class=\"wrap\">\n{body}\n</main>\n\431 {footer}\n\432 </body>\n\433 </html>\n",434         gl0w = crate::logo::GL0W_SVG435     )436 }437 438 /// Healthcheck page stub (used by `serve` liveness checks in later issues).439 #[allow(dead_code)]440 #[must_use]441 pub fn healthcheck_page() -> String {442     page("health", "", "<p>ok</p>")443 }444 445 #[cfg(test)]446 mod tests {447     use super::{diff_html, esc, healthcheck_page, page};448 449     #[test]450     fn esc_escapes_angle_amp_quote() {451         assert_eq!(esc("<>&\""), "&lt;&gt;&amp;&quot;");452     }453 454     #[test]455     fn esc_leaves_plain_text_alone() {456         assert_eq!(esc("hello world 123"), "hello world 123");457     }458 459     #[test]460     fn esc_does_not_double_escape_order() {461         // `&` must be escaped first, so `<` becomes `&lt;` not `&amp;lt;`.462         assert_eq!(esc("<"), "&lt;");463         assert_eq!(esc("&"), "&amp;");464     }465 466     #[test]467     fn page_escapes_title_but_inlines_body() {468         let html = page(469             "<Title> & \"quoted\"",470             "<a href=\"/\">home</a>",471             "<p>hi</p>",472         );473         assert!(html.contains("<title>&lt;Title&gt; &amp; &quot;quoted&quot;</title>"));474         assert!(html.contains("<p>hi</p>"));475         assert!(html.contains("<!DOCTYPE html>"));476     }477 478     #[test]479     fn healthcheck_contains_ok() {480         let html = healthcheck_page();481         assert!(html.contains("ok"));482         assert!(html.contains("<main"));483     }484 485     #[test]486     fn head_carries_favicon_manifest_theme_og_standards() {487         // m6 favicon (f0rge issue #16): every layout page carries the488         // icon links, apple-touch-icon, both theme-colors, og tags,489         // and the manifest link.490         let html = page("t", "<nav></nav>", "<p>b</p>");491         for needle in [492             "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",493             "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",494             "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">",495             "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",496             "<meta property=\"og:title\"",497             "<meta property=\"og:type\" content=\"website\">",498             "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",499         ] {500             assert!(html.contains(needle), "head carries {needle}");501         }502     }503 504     #[test]505     fn header_is_one_bar_with_logo_sep_and_scroll_nav() {506         // m6 navbar (#18) + logo-text (#27 + #28): a single <header>507         // holds logo-with-text • scrollable nav only; no hu13-word or508         // brand name nodes. The repo name still lives in <title>/og.509         use super::{active_href, apply_active, page_site};510         let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a>";511         let html = page_site("demo", nav, "<p>b</p>", "demo", None);512         assert_eq!(html.matches("<header").count(), 1, "single header bar");513         assert!(514             html.contains("<img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\""),515             "logo lockup img with alt text"516         );517         assert!(518             !html.contains("hu13-word"),519             "no hu13 word node (letters live in logo.svg paths)"520         );521         assert!(522             !html.contains("<span class=\"brand\">"),523             "no repo brand node in header"524         );525         let h0 = html.find("<header").expect("header open");526         let h1 = html.find("</header>").expect("header close");527         let header = &html[h0..h1];528         assert!(!header.contains(">demo<"), "header carries zero names");529         assert_eq!(530             header.matches("•").count(),531             1,532             "exactly one sep (logo • nav)"533         );534         // Repo name still lives in title + og:title.535         assert!(html.contains("<title>demo</title>"), "title keeps name");536         assert!(537             html.contains("<meta property=\"og:title\" content=\"demo\">"),538             "og:title keeps name"539         );540         assert!(html.contains("nav-scroll"), "nav has scroll class");541         assert!(html.contains("<nav"), "nav lives inside the header");542         // Active mapping is title-driven; unknown bare titles mark nothing.543         assert_eq!(active_href("demo", "<p>b</p>", "demo"), Some("/"));544         assert_eq!(545             active_href("commits — demo", "", "demo"),546             Some("/commits.html")547         );548         assert_eq!(active_href("health", "", "hu13"), None);549         let marked = apply_active(nav, "/commits.html");550         assert!(551             marked.contains("<a href=\"/commits.html\" aria-current=\"page\">"),552             "aria-current injected, got: {marked}"553         );554     }555 556     #[test]557     fn active_tab_marks_each_section_and_file_pages_are_code() {558         use super::{active_href, apply_active, page_site};559         let nav = concat!(560             "<a href=\"/\">code</a> · ",561             "<a href=\"/commits.html\">commits</a> · ",562             "<a href=\"/issues/index.html\">issues</a> · ",563             "<a href=\"/pulls/index.html\">pulls</a> · ",564             "<a href=\"/tasks/index.html\">tasks</a> · ",565             "<a href=\"/ci/index.html\">ci</a> · ",566             "<a href=\"/releases/index.html\">releases</a> · ",567             "<a href=\"/wiki/index.html\">wiki</a> · ",568             "<a href=\"/agents.html\">agents</a>",569         );570         for (title, body, want) in [571             ("s", "<p>x</p>", "/"),572             ("notes.txt — s", "<p>f</p>", "/"),573             ("commits — s", "", "/commits.html"),574             ("commit abc1234 — s", "", "/commits.html"),575             ("issues — s", "", "/issues/index.html"),576             ("pulls — s", "", "/pulls/index.html"),577             ("tasks — s", "", "/tasks/index.html"),578             ("ci — s", "", "/ci/index.html"),579             ("releases — s", "", "/releases/index.html"),580             ("v1 — releases — s", "", "/releases/index.html"),581             ("wiki — s", "", "/wiki/index.html"),582             ("Guide — wiki — s", "", "/wiki/index.html"),583             ("agents — s", "", "/agents.html"),584         ] {585             let site = "s";586             let title_full = if title == "s" {587                 site.to_string()588             } else {589                 title.to_string()590             };591             assert_eq!(592                 active_href(&title_full, body, site),593                 Some(want),594                 "active for {title_full:?}"595             );596             let html = page_site(&title_full, nav, body, site, None);597             assert_eq!(html.matches("aria-current=\"page\"").count(), 1);598             assert!(html.contains(&format!("<a href=\"{want}\" aria-current=\"page\">")));599         }600         // `#N` detail pages disambiguate through the body footer links.601         assert_eq!(602             active_href(603                 "#3 Foo — s",604                 "<a href=\"/issues/index.html\">all issues</a>",605                 "s"606             ),607             Some("/issues/index.html")608         );609         assert_eq!(610             active_href(611                 "#4 Bar — s",612                 "<a href=\"/pulls/index.html\">all pulls</a>",613                 "s"614             ),615             Some("/pulls/index.html")616         );617         // Bare error/health titles claim no tab; pre-marked nav is kept.618         assert_eq!(active_href("not found", "<p>x</p>", "hu13"), None);619         let pre = "<a href=\"/\" aria-current=\"page\">code</a>";620         assert_eq!(apply_active(pre, "/"), pre);621     }622 623     #[test]624     fn diff_html_colors_each_line_kind() {625         // m6 diff-colors (f0rge #22): line-level only, spans + classes.626         let raw = "diff --git a/notes.txt b/notes.txt\n\627 --- a/notes.txt\n+++ b/notes.txt\n\628 @@ -1 +1,2 @@\n line one\n-line gone\n+line two added\n";629         let html = diff_html(raw);630         assert!(631             html.contains("<span class=\"diff-hunk\">@@ -1 +1,2 @@</span>"),632             "hunk cyan: {html}"633         );634         assert!(635             html.contains("<span class=\"diff-file\">--- a/notes.txt</span>"),636             "--- file bold: {html}"637         );638         assert!(639             html.contains("<span class=\"diff-file\">+++ b/notes.txt</span>"),640             "+++ file bold: {html}"641         );642         assert!(643             html.contains("<span class=\"diff-add\">+line two added</span>"),644             "added green: {html}"645         );646         assert!(647             html.contains("<span class=\"diff-del\">-line gone</span>"),648             "removed red: {html}"649         );650         // Context + `diff --git` header stay plain (no span).651         assert!(html.contains(" line one"), "context kept");652         assert!(html.contains("diff --git"), "diff header kept");653         assert!(!html.contains("<span> line one"), "context has no span");654     }655 656     #[test]657     fn diff_rows_have_no_blank_lines_and_content_headers_count() {658         // f0rge #86: no literal newline between block rows.659         // f0rge #87: `++++` after a hunk is added content, not a header.660         let raw = "diff --git a/a.txt b/a.txt\n\661 --- a/a.txt\n+++ b/a.txt\n\662 @@ -1 +1,4 @@\n context\n\663 ++++ not a header\n+--- not a header\n----- removed content\n+plain\n";664         let html = diff_html(raw);665         assert!(666             !html.contains("</span>\n<span class=\"df-line\""),667             "no newline between diff rows: {html}"668         );669         assert!(670             html.contains("<span class=\"df-add\">+3</span>"),671             "three added lines, including +++ content: {html}"672         );673         assert!(674             html.contains("<span class=\"df-del\">−1</span>"),675             "one removed line, including --- content: {html}"676         );677         assert!(678             html.contains("<span class=\"diff-add\">++++ not a header</span>"),679             "added +++ content keeps its marker and is not a file header: {html}"680         );681         assert!(682             html.contains("<span class=\"diff-del\">----- removed content</span>"),683             "removed --- content is a removal: {html}"684         );685         assert_eq!(686             html.matches("<span class=\"diff-file\">").count(),687             2,688             "only the real ---/+++ file headers"689         );690     }691 692     #[test]693     fn diff_html_escapes_before_wrapping() {694         let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");695         assert!(!html.contains("<script>"), "no raw HTML: {html}");696         assert!(697             html.contains(698                 "<span class=\"diff-add\">+evil &lt;script&gt;alert(1)&lt;/script&gt;</span>"699             ),700             "added line escaped in span: {html}"701         );702         assert!(703             html.contains("<span class=\"diff-del\">-old &amp; &quot;quoted&quot;</span>"),704             "removed line escaped in span: {html}"705         );706     }707 708     #[test]709     fn diff_html_keeps_markers_as_plain_text() {710         // Truncation + binary notes pass through escaped, span-free.711         let marker = "[hu13: diff truncated at 2000 lines]";712         let binary = "Binary files a/bin.dat and b/bin.dat differ";713         let html = diff_html(&format!("+x\n{marker}\n{binary}\n"));714         assert!(html.contains("<span class=\"diff-add\">+x</span>"));715         assert!(html.contains(marker), "truncation marker intact");716         assert!(html.contains("Binary files"), "binary note intact");717         assert!(!html.contains("<span>\n[hu13"), "markers have no span");718     }719 720     #[test]721     fn every_page_carries_a_single_muted_provenance_footer() {722         // m6 footer-line (f0rge #41): the existing footer block carries723         // one muted provenance line; serve stubs without a snapshot724         // get the generic line alone.725         use super::{page_site_with_provenance, provenance_footer};726         for html in [727             page("t", "<a href=\"/\">home</a>", "<p>b</p>"),728             page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234"),729         ] {730             assert_eq!(html.matches("<footer").count(), 1, "exactly one footer");731             assert!(732                 html.contains("<footer class=\"site-footer\">"),733                 "existing footer block extended, not duplicated"734             );735             assert!(736                 html.contains("generated from a snapshot of a repo and its f0rge"),737                 "provenance line present"738             );739             assert!(740                 html.contains("class=\"muted footer-provenance\""),741                 "muted styling via .muted"742             );743             let f0 = html.find("<footer").expect("footer open");744             let f1 = html.find("</footer>").expect("footer close");745             assert!(!html[f0..f1].contains("<br"), "single line, no spacers");746         }747         // Generic footer names no snapshot.748         assert!(749             !provenance_footer(None, None).contains('@'),750             "no sha when unknown"751         );752         // Empty ids degrade to the generic line, never `main @ `.753         assert!(!provenance_footer(Some("main"), Some("")).contains('@'));754     }755 756     #[test]757     fn finish_gap_vars_logo_footer() {758         // m6 finish (f0rge #50 + #51 + #52): unified head gaps via vars,759         // 48px logo, right footer — all in one CSS + header assert.760         let css = include_str!("../static/style.css");761         // #50: single vars, mobile tighter (.75/1rem) via :root, desktop762         // looser (1.25/1.5rem) via the existing min-width breakpoint.763         assert!(css.contains("--gap-head"), "gap-head var");764         assert!(css.contains("--gap-content"), "gap-content var");765         assert!(css.contains("--gap-head:.75rem"), "mobile head gap");766         assert!(css.contains("--gap-content:1rem"), "mobile content gap");767         assert!(css.contains("--gap-head:1.25rem"), "desktop head gap");768         assert!(css.contains("--gap-content:1.5rem"), "desktop content gap");769         assert!(770             css.contains(".page-head") && css.contains("margin-top:var(--gap-head)"),771             "page-head uses gap-head"772         );773         assert!(774             css.contains(".page-head") && css.contains("margin-bottom:var(--gap-content)"),775             "page-head uses gap-content"776         );777         assert!(778             css.contains(".repo-head") && css.contains("margin-top:var(--gap-head)"),779             "repo-head uses gap-head"780         );781         assert!(782             css.contains("main>h1:first-child") && css.contains("margin-top:var(--gap-head)"),783             "bare h1 shares nav->head gap"784         );785         // #70: the 96x32 lockup keeps its 3:1 aspect instead of being786         // squeezed into a 48x48 square (lettering was illegible).787         assert!(css.contains(".site-logo"), "logo rule");788         assert!(789             css.contains("width:auto;height:1.75rem"),790             "logo keeps its aspect at 28px tall"791         );792         assert!(!css.contains("width:3rem"), "old square box gone");793         let html = page("t", "<nav></nav>", "<p>b</p>");794         assert!(795             html.contains("width=\"84\" height=\"28\""),796             "header img 3:1 attrs"797         );798         // #70: footer is a left-aligned brand block (gl0w + hu13), not a799         // lone right-aligned provenance line.800         assert!(801             css.contains(".footer-provenance") && css.contains("text-align:left"),802             "footer aligns with content"803         );804         assert!(css.contains(".footer-brand"), "footer brand row styled");805         assert!(css.contains(".gl0w-mark"), "gl0w mark styled");806     }807 808     #[test]809     fn provenance_footer_names_snapshot_and_escapes() {810         // m6 footer-line (f0rge #41): build pages name branch @ sha;811         // hostile branch names render as text.812         use super::page_site_with_provenance;813         let html =814             page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234");815         assert!(html.contains("main @ abc1234"), "branch@sha named: {html}");816         let evil = page_site_with_provenance(817             "t",818             "<nav></nav>",819             "<p>b</p>",820             "s",821             None,822             "<script>alert(1)</script>",823             "abc1234",824         );825         assert!(!evil.contains("<script>"), "branch escaped: {evil}");826         assert!(827             evil.contains("&lt;script&gt;alert(1)&lt;/script&gt; @ abc1234"),828             "escaped branch kept with sha"829         );830     }831 }