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("&"),18 '<' => out.push_str("<"),19 '>' => out.push_str(">"),20 '"' => out.push_str("""),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("<>&\""), "<>&"");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 `<` not `&lt;`.462 assert_eq!(esc("<"), "<");463 assert_eq!(esc("&"), "&");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><Title> & "quoted"</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 <script>alert(1)</script></span>"699 ),700 "added line escaped in span: {html}"701 );702 assert!(703 html.contains("<span class=\"diff-del\">-old & "quoted"</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("<script>alert(1)</script> @ abc1234"),828 "escaped branch kept with sha"829 );830 }831 }