1 //! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).2 //!3 //! Reads one branch via [`crate::gitx`], emits `dist/`:4 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5 //! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,6 //! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,7 //! `tasks/index.html`, `ci/index.html`, `releases/index.html`,8 //! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,9 //! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +10 //! `agents.md`, `repo.bundle` + `repo.tar.gz` + `repo.git/` (dumb-HTTP,11 //! issue #54: `git clone <origin>/repo.git` works on any static server),12 //! and `style.css`.13 //!14 //! Bodies from the repo are semi-trusted but treated as untrusted:15 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;16 //! commit messages and diffs are escaped `<pre>` (never markdown).17 //!18 //! Machine JSON (`api/*.json`) and HTML are built from one struct pass over19 //! the same loaded readers (no divergent logic): `forge::load`,20 //! `commits::load`, `releases::load`, `wiki::render`.21 22 use std::collections::BTreeMap;23 use std::path::Path;24 25 use crate::layout::esc;26 use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};27 28 /// Files at or above this size get a placeholder page + download link.29 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;30 31 /// Options for [`run_build`] (flags only, no config file).32 #[derive(Debug, Clone)]33 pub struct BuildOptions {34 /// `--branch` flag (else `$HU13_BRANCH`, else main/master).35 pub branch_flag: Option<String>,36 /// `--clone-url` flag (else git origin remote, else none).37 pub clone_url_flag: Option<String>,38 /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).39 /// Base URL of this server, e.g. `http://host:39313`. When set, the40 /// clone block advertises `git clone <origin>/repo.git` (dumb-HTTP).41 /// Empty/whitespace falls through to unset.42 pub public_origin_flag: Option<String>,43 /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).44 /// `--title` is accepted as a deprecated alias for the same field.45 /// Empty/whitespace always falls back (the header is never blank).46 pub repo_name_flag: Option<String>,47 /// `--description` flag (else `$HU13_DESCRIPTION`, else none).48 /// Empty/whitespace means absent: the header description line is49 /// hidden entirely (no empty `<p>`).50 pub description_flag: Option<String>,51 /// `--max-log`: max commits listed in `commits.html` (default 100).52 pub max_log: usize,53 }54 55 /// Resolve the repo display name (m6, f0rge issue #24).56 ///57 /// Precedence: `--repo-name` flag > `$HU13_REPO_NAME` > directory name of58 /// `repo` > `"repo"`. Flag and env values are trimmed; empty values fall59 /// through so the header is never blank.60 #[must_use]61 pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {62 if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {63 return f.to_string();64 }65 if let Ok(env) = std::env::var("HU13_REPO_NAME") {66 let t = env.trim();67 if !t.is_empty() {68 return t.to_string();69 }70 }71 // `hu13 build .` must name the repo, not the path "." (f0rge #68).72 let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());73 abs.file_name()74 .map(|n| n.to_string_lossy().into_owned())75 .map(|n| n.trim().to_string())76 .filter(|n| !n.is_empty())77 .unwrap_or_else(|| "repo".to_string())78 }79 80 /// Resolve the site description (m6, f0rge issue #21).81 ///82 /// Precedence: `--description` flag > `$HU13_DESCRIPTION` > absent83 /// (`None`). Flag and env values are trimmed; empty values fall through84 /// to absent, and an absent description hides the header line entirely.85 #[must_use]86 pub fn resolve_description(flag: Option<&str>) -> Option<String> {87 if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {88 return Some(f.to_string());89 }90 if let Ok(env) = std::env::var("HU13_DESCRIPTION") {91 let t = env.trim();92 if !t.is_empty() {93 return Some(t.to_string());94 }95 }96 None97 }98 99 /// Summary of one successful build (for logs/tests).100 #[derive(Debug, Clone)]101 pub struct BuildSummary {102 /// Branch that was rendered.103 pub branch: String,104 /// Short sha of the branch head.105 pub short_sha: String,106 /// Number of files emitted under `raw/`.107 pub file_count: usize,108 }109 110 /// Decode the HTML entities a browser would decode before treating a111 /// URL as a scheme (`javascript:…`, `javascript:…`).112 ///113 /// `pulldown-cmark` writes `dest_url` into `href`/`src` and then escapes114 /// it; a destination that still contains `:` will be decoded by the115 /// *browser* into `:` — so the scheme check has to decode it first.116 fn decode_entities(s: &str) -> String {117 let mut out = String::with_capacity(s.len());118 let mut rest = s;119 while let Some(i) = rest.find('&') {120 out.push_str(&rest[..i]);121 let tail = &rest[i + 1..];122 let Some(semi) = tail.find(';') else {123 out.push('&');124 rest = tail;125 continue;126 };127 let name = &tail[..semi];128 let decoded: Option<char> = match name {129 "colon" | "Colon" => Some(':'),130 "sol" | "Tab" | "NewLine" => Some(match name {131 "Tab" => '\t',132 "NewLine" => '\n',133 _ => '/',134 }),135 "amp" => Some('&'),136 "lt" => Some('<'),137 "gt" => Some('>'),138 "quot" => Some('"'),139 "apos" => Some('\''),140 "nbsp" => Some('\u{a0}'),141 _ => name.strip_prefix('#').and_then(|n| {142 let n = n.strip_prefix(['x', 'X']).map_or_else(143 || n.parse::<u32>().ok(),144 |hex| u32::from_str_radix(hex, 16).ok(),145 );146 n.and_then(char::from_u32)147 }),148 };149 match decoded {150 Some(ch) => {151 out.push(ch);152 rest = &tail[semi + 1..];153 }154 None => {155 out.push('&');156 rest = tail;157 }158 }159 }160 out.push_str(rest);161 out162 }163 164 /// `true` when `url` may be emitted into `href`/`src` (f0rge #62).165 ///166 /// Only `http`, `https`, `mailto` and `tel` are allowed as schemes.167 /// Relative URLs (no scheme) are allowed. `javascript:`, `data:`,168 /// `vbscript:`, `file:` etc. are rejected — including when obfuscated169 /// with entities, control characters or mixed case.170 fn safe_url(url: &str) -> bool {171 let decoded = decode_entities(url);172 // Browsers strip tab/newline/CR and leading/trailing C0/space before173 // parsing the scheme.174 let cleaned: String = decoded175 .chars()176 .filter(|c| !c.is_ascii_control() && !c.is_whitespace())177 .collect();178 let lower = cleaned.to_ascii_lowercase();179 let Some(colon) = lower.find(':') else {180 return true; // no scheme: relative path181 };182 let scheme = &lower[..colon];183 if scheme.is_empty()184 || !scheme.chars().enumerate().all(|(i, c)| {185 c.is_ascii_alphabetic()186 || (i > 0 && (c.is_ascii_digit() || matches!(c, '+' | '-' | '.')))187 })188 {189 return true; // `./a:b`, `#x:y`, `?q=1:2` — not a scheme190 }191 matches!(scheme, "http" | "https" | "mailto" | "tel")192 }193 194 /// Context for relative markdown destinations: a repo file page uses195 /// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses196 /// `/wiki/...` + raw/assets for non-page files.197 #[derive(Clone, Copy)]198 enum LinkBase<'a> {199 Repo(&'a str),200 Wiki(&'a str),201 }202 203 fn base_path(base: LinkBase<'_>) -> &str {204 match base {205 LinkBase::Repo(p) | LinkBase::Wiki(p) => p,206 }207 }208 209 /// Lexically resolve a relative link against a repo- or wiki-relative210 /// base directory. Returns `None` when it climbs above the root or has211 /// unsafe/absolute components.212 fn rel_join(base: &str, path: &str) -> Option<String> {213 if path.starts_with('/') || path.contains(['\\', '\0']) {214 return None;215 }216 let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();217 for seg in path.split('/') {218 match seg {219 "" | "." => {}220 ".." => {221 parts.pop()?;222 }223 other => parts.push(other),224 }225 }226 if parts.is_empty() {227 return None;228 }229 Some(parts.join("/"))230 }231 232 /// Extensions safe to display inline from `assets/`: raster/static233 /// images whose bytes cannot execute as a document. SVG and HTML stay234 /// downloads under `raw/`.235 fn is_inline_image(path: &str) -> bool {236 let base = path.rsplit('/').next().unwrap_or(path);237 let Some((stem, ext)) = base.rsplit_once('.') else {238 return false;239 };240 !stem.is_empty()241 && matches!(242 ext.to_ascii_lowercase().as_str(),243 "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"244 )245 }246 247 /// Rewrite one relative markdown destination to the generated route.248 /// External URLs, fragments, absolute paths and anything that cannot be249 /// resolved stay as-is. Query/fragment suffixes are preserved.250 fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option<String> {251 let d = dest.trim();252 if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {253 return None;254 }255 // A real scheme (`http:`, `mailto:`, ...) is not a relative file.256 if let Some(colon) = d.find(':') {257 let scheme = &d[..colon];258 let mut chars = scheme.chars();259 if chars.next().is_some_and(|c| c.is_ascii_alphabetic())260 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))261 {262 return None;263 }264 }265 let cut = d.find(['?', '#']).unwrap_or(d.len());266 let (path, suffix) = d.split_at(cut);267 let target = rel_join(base_path(base), path)?;268 let target = target.trim_end_matches('/');269 if target.is_empty() {270 return None;271 }272 let enc = encode_path(target);273 let asset = match base {274 LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),275 LinkBase::Repo(_) => format!("/assets/{enc}"),276 };277 let route = if is_inline_image(target) {278 asset279 } else if image {280 format!("/raw/{enc}")281 } else {282 match base {283 LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),284 LinkBase::Repo(_) => {285 let name = target.rsplit('/').next().unwrap_or(target);286 if is_markdown(target) || name.eq_ignore_ascii_case("readme") {287 format!("/files/{enc}.html")288 } else {289 format!("/raw/{enc}")290 }291 }292 }293 };294 Some(format!("{route}{suffix}"))295 }296 297 /// Strip simple inline/block tags for a heading slug. Entity decoding298 /// runs afterwards so `&` becomes a word separator, not `amp`.299 fn heading_text(inner: &str) -> String {300 let mut out = String::with_capacity(inner.len());301 let mut in_tag = false;302 for c in inner.chars() {303 match c {304 '<' => in_tag = true,305 '>' => in_tag = false,306 _ if !in_tag => out.push(c),307 _ => {}308 }309 }310 decode_entities(&out)311 }312 313 fn slugify(text: &str) -> String {314 let mut out = String::new();315 let mut dash = false;316 for c in text.chars().flat_map(char::to_lowercase) {317 if c.is_alphanumeric() {318 out.push(c);319 dash = false;320 } else if !out.is_empty() && !dash {321 out.push('-');322 dash = true;323 }324 }325 while out.ends_with('-') {326 out.pop();327 }328 if out.is_empty() {329 "section".to_string()330 } else {331 out332 }333 }334 335 fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {336 let b = line.as_bytes();337 if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'338 {339 return None;340 }341 let level = usize::from(b[2] - b'0');342 let close = format!("</h{level}>");343 let end = line.find(&close)?;344 Some((level, &line[4..end], &line[end + close.len()..]))345 }346 347 /// Add deterministic heading ids so `#section` links work. Input is the348 /// renderer's own output, so headings are one per line and contain only349 /// generated tags; code-block lines are entity-escaped and never match.350 fn add_heading_ids(html: &str) -> String {351 let mut out = String::with_capacity(html.len() + 64);352 let mut seen: BTreeMap<String, usize> = BTreeMap::new();353 for line in html.split_inclusive('\n') {354 if let Some((level, inner, tail)) = heading_parts(line) {355 let base = slugify(&heading_text(inner));356 let count = seen.entry(base.clone()).or_insert(0);357 *count += 1;358 let id = if *count == 1 {359 base360 } else {361 format!("{base}-{}", *count)362 };363 out.push_str(&format!("<h{level} id=\"{id}\">{inner}</h{level}>{tail}"));364 } else {365 out.push_str(line);366 }367 }368 out369 }370 371 fn render_markdown(md: &str, base: Option<LinkBase<'_>>) -> String {372 use pulldown_cmark::{html, Event, Options, Parser, Tag};373 let options = Options::ENABLE_TABLES374 | Options::ENABLE_TASKLISTS375 | Options::ENABLE_STRIKETHROUGH376 | Options::ENABLE_FOOTNOTES;377 let parser = Parser::new_ext(md, options);378 let mapped = parser.map(|ev| match ev {379 Event::Html(h) => Event::Html(esc(&h).into()),380 Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),381 Event::Start(Tag::Link {382 link_type,383 dest_url,384 title,385 id,386 }) => {387 let dest = if safe_url(&dest_url) {388 let decoded = decode_entities(&dest_url);389 match base.and_then(|b| rewrite_dest(&decoded, b, false)) {390 Some(rewritten) => rewritten,391 None => decoded,392 }393 } else {394 "#".to_string()395 };396 Event::Start(Tag::Link {397 link_type,398 dest_url: dest.into(),399 title,400 id,401 })402 }403 Event::Start(Tag::Image {404 link_type,405 dest_url,406 title,407 id,408 }) => {409 let dest = if safe_url(&dest_url) {410 let decoded = decode_entities(&dest_url);411 match base.and_then(|b| rewrite_dest(&decoded, b, true)) {412 Some(rewritten) => rewritten,413 None => decoded,414 }415 } else {416 "#".to_string()417 };418 Event::Start(Tag::Image {419 link_type,420 dest_url: dest.into(),421 title,422 id,423 })424 }425 other => other,426 });427 let mut out = String::from("<div class=\"markdown\">\n");428 html::push_html(&mut out, mapped);429 out.push_str("</div>\n");430 add_heading_ids(&out)431 }432 433 /// Render markdown to HTML, escaping raw HTML.434 ///435 /// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched436 /// (R1 finding), so map them through [`esc`] first. GFM tables, task437 /// lists and strikethrough are enabled (f0rge #71); link/image438 /// destinations are scheme-filtered (f0rge #62). Headings get stable439 /// ids so `#section` links resolve (f0rge #112).440 #[must_use]441 pub fn markdown_to_html(md: &str) -> String {442 render_markdown(md, None)443 }444 445 /// Render markdown from a repo file page or wiki page, rewriting446 /// relative links/images to generated routes (f0rge #112).447 #[must_use]448 pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {449 let base = if wiki {450 LinkBase::Wiki(base)451 } else {452 LinkBase::Repo(base)453 };454 render_markdown(md, Some(base))455 }456 457 /// Resolve the clone URL to advertise.458 ///459 /// Precedence: `--clone-url` flag > `git remote get-url origin` at build460 /// time > none (`None`, and the index shows no URL line).461 #[must_use]462 pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {463 if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {464 return Some(f.to_string());465 }466 let out = std::process::Command::new("git")467 .arg("-C")468 .arg(repo)469 .arg("remote")470 .arg("get-url")471 .arg("origin")472 .output();473 match out {474 Ok(o) if o.status.success() => {475 let s = String::from_utf8_lossy(&o.stdout).trim().to_string();476 if s.is_empty() {477 None478 } else {479 Some(s)480 }481 }482 _ => None,483 }484 }485 486 /// Resolve the public origin base URL (m6, f0rge issue #32).487 ///488 /// Precedence: `--public-origin` flag > `$HU13_PUBLIC_ORIGIN` > unset489 /// (`None`). Flag and env values are trimmed; empty values fall through490 /// to unset. A single trailing `/` run is stripped so491 /// `<origin>/repo.bundle` never doubles the slash.492 #[must_use]493 pub fn resolve_public_origin(flag: Option<&str>) -> Option<String> {494 if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {495 let n = f.trim_end_matches('/');496 if !n.is_empty() {497 return Some(n.to_string());498 }499 }500 if let Ok(env) = std::env::var("HU13_PUBLIC_ORIGIN") {501 let t = env.trim();502 if !t.is_empty() {503 let n = t.trim_end_matches('/');504 if !n.is_empty() {505 return Some(n.to_string());506 }507 }508 }509 None510 }511 512 /// README lookup chain (f0rge convention): repo-root `README.md` >513 /// `readme.md` > `README` > `readme`, else `.forge/wiki/README.md` >514 /// `.forge/wiki/readme.md` > `.forge/wiki/README` > `.forge/wiki/readme`,515 /// else none.516 ///517 /// Returns the winning path plus its blob.518 pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {519 for candidate in [520 "README.md",521 "readme.md",522 "README",523 "readme",524 ".forge/wiki/README.md",525 ".forge/wiki/readme.md",526 ".forge/wiki/README",527 ".forge/wiki/readme",528 ] {529 let fits = gitx::blob_size(repo, branch, Path::new(candidate))530 .map(|size| size <= README_MAX_BYTES)531 .unwrap_or(false);532 if fits {533 if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {534 return Some((candidate.to_string(), blob));535 }536 }537 }538 None539 }540 541 /// `true` when a blob must not be inlined (binary or over the cap).542 #[must_use]543 pub fn is_placeholder(bytes: &[u8]) -> bool {544 bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)545 }546 547 /// Blobs above this size are streamed straight to `raw/` instead of548 /// being held in memory (f0rge #66). Reading every blob fully used to549 /// abort the process in the allocator on a few-hundred-MB file; the550 /// page for such a blob is a placeholder anyway, so only the raw copy551 /// needs the bytes.552 pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;553 554 /// READMEs above this size are ignored (next candidate in the chain is555 /// tried). Prevents a huge README from bloating every page's preview.556 pub const README_MAX_BYTES: u64 = 1024 * 1024;557 558 /// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors559 /// every blob, so an ordinary asset-heavy repo can exceed these even560 /// when the build itself succeeds.561 pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;562 pub const PAGES_MAX_FILES: usize = 20_000;563 564 /// Bytes sniffed for the language-math binary check (first 8KiB).565 pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;566 567 /// `true` when a blob is binary for language math: the first 8KiB568 /// contain a NUL byte (m6, f0rge issue #37). Binaries stay569 /// downloadable and keep placeholder pages (see [`is_placeholder`]);570 /// they are only excluded from the language mix, never from the571 /// tree, raw output, or snapshots.572 #[must_use]573 pub fn is_binary(bytes: &[u8]) -> bool {574 bytes.iter().take(BINARY_SNIFF_BYTES).any(|b| *b == 0)575 }576 577 /// `(path, size)` rows eligible for language math.578 ///579 /// Skips `.forge/` (forge metadata: issues, PRs, patch diffs — not580 /// project code), gitignored paths (`ignored` is the set from581 /// [`gitx::ignored_paths`]) and binary blobs ([`is_binary`]).582 /// Everything kept is a real project text file; unmapped extensions583 /// still count (as `Other` via [`language_of`]). Percentages from584 /// [`compute_languages`] over this output therefore sum to counted585 /// text bytes only.586 #[must_use]587 pub fn lang_rows(588 blobs: &std::collections::BTreeMap<String, Vec<u8>>,589 ignored: &std::collections::HashSet<String>,590 ) -> Vec<(String, u64)> {591 blobs592 .iter()593 .filter(|(path, _)| !is_forge_meta(path))594 .filter(|(path, _)| !ignored.contains(path.as_str()))595 .filter(|(_, bytes)| !is_binary(bytes))596 .map(|(path, bytes)| (path.clone(), bytes.len() as u64))597 .collect()598 }599 600 /// `true` for `.forge/` metadata paths, which are excluded from601 /// language math: the forge (issues, PRs, diffs, runs) describes the602 /// project but is not its code (m6, f0rge issue #37).603 #[must_use]604 pub fn is_forge_meta(path: &str) -> bool {605 path == ".forge" || path.starts_with(".forge/")606 }607 608 /// `true` for markdown-rendered paths (`.md`/`.markdown`, any case).609 #[must_use]610 pub fn is_markdown(path: &str) -> bool {611 let lower = path.to_lowercase();612 lower.ends_with(".md") || lower.ends_with(".markdown")613 }614 615 /// Percent-encode a repo path for a URL, then HTML-escape it. Git616 /// allows `#`, `?`, `%`, spaces and quotes in names; without this the617 /// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal618 /// so file paths remain hierarchical; everything outside the unreserved619 /// set becomes `%XX` of its UTF-8 bytes.620 pub(crate) fn encode_path(path: &str) -> String {621 let mut out = String::with_capacity(path.len());622 for &b in path.as_bytes() {623 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {624 out.push(char::from(b));625 } else {626 out.push_str(&format!("%{b:02X}"));627 }628 }629 out630 }631 632 pub(crate) fn href_esc(path: &str) -> String {633 esc(&encode_path(path))634 }635 636 /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars637 /// and listings (m6, f0rge issue #20).638 #[must_use]639 pub fn human_size(n: u64) -> String {640 if n < 1024 {641 format!("{n} B")642 } else if n < 1024 * 1024 {643 format!("{:.1} KiB", n as f64 / 1024.0)644 } else {645 format!("{:.1} MiB", n as f64 / 1024.0 / 1024.0)646 }647 }648 649 /// Breadcrumb trail for a repo-relative `path`: `root` plus one cumulative650 /// link per segment (`/files/<acc>.html`), so every segment is clickable651 /// (m6, f0rge issue #20). No JS; the nav element labels it for AT.652 #[must_use]653 pub fn breadcrumb(path: &str) -> String {654 let mut out =655 String::from("<nav class=\"crumbs\" aria-label=\"breadcrumb\"><a href=\"/\">root</a>");656 if !path.is_empty() {657 let mut acc = String::new();658 for seg in path.split('/') {659 if !acc.is_empty() {660 acc.push('/');661 }662 acc.push_str(seg);663 out.push_str(&format!(664 " / <a href=\"/files/{}.html\">{}</a>",665 href_esc(&acc),666 esc(seg)667 ));668 }669 }670 out.push_str("</nav>\n");671 out672 }673 674 /// File meta bar: full path text (selectable, no-JS copy affordance),675 /// humanized size, optional line count, and a Raw download link676 /// (m6, f0rge issue #20).677 #[must_use]678 pub fn file_meta_bar(path: &str, size_bytes: u64, lines: Option<usize>) -> String {679 let mut out = format!(680 "<div class=\"file-meta\"><code class=\"file-path\">{}</code>\n\681 <span class=\"file-size\">{}</span>",682 esc(path),683 human_size(size_bytes)684 );685 if let Some(n) = lines {686 out.push_str(&format!("\n<span class=\"file-lines\">{n} lines</span>"));687 }688 out.push_str(&format!(689 "\n<a class=\"file-raw\" href=\"/raw/{}\">Raw</a></div>\n",690 href_esc(path)691 ));692 out693 }694 695 /// Content sha of a snapshot artifact via `git hash-object` (no new deps;696 /// git is already required to build). `None` when git cannot hash.697 #[must_use]698 pub fn file_hash(path: &Path) -> Option<String> {699 let out = std::process::Command::new("git")700 .arg("hash-object")701 .arg(path)702 .output()703 .ok()?;704 if out.status.success() {705 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();706 if !s.is_empty() {707 return Some(s);708 }709 }710 None711 }712 713 /// Max commit-message chars shown in file tables (m6 code-gitea,714 /// f0rge #33, Gitea-style: short message, truncated). Research note:715 /// live Gitea page (`gitea.com/gitea/tea`, fetched 2026-09-10) renders716 /// file rows as name / commit-summary / age divs with dirs-first and a717 /// `<relative-time>` age; hu13 adapts that to a static TABLE with718 /// build-time relative ages (no JS) per the issue.719 pub const MSG_TRUNC_CHARS: usize = 72;720 721 /// Truncate a commit subject to [`MSG_TRUNC_CHARS`] chars (char-wise,722 /// never splitting a boundary). Short messages pass through; longer723 /// ones are cut to 72 chars plus a trailing `…` so truncation is724 /// visible. Callers escape the result with [`esc`] for HTML.725 #[must_use]726 pub fn truncate_msg(s: &str) -> String {727 let n = s.chars().count();728 if n <= MSG_TRUNC_CHARS {729 return s.to_string();730 }731 let kept: String = s.chars().take(MSG_TRUNC_CHARS).collect();732 format!("{kept}…")733 }734 735 /// Current unix time (seconds) for build-time relative ages.736 #[must_use]737 pub fn now_unix() -> i64 {738 std::time::SystemTime::now()739 .duration_since(std::time::UNIX_EPOCH)740 .map(|d| d.as_secs() as i64)741 .unwrap_or(0)742 }743 744 /// Parse an RFC3339 timestamp to unix seconds (fractional seconds and745 /// numeric offsets accepted; `Z` = UTC). Hand-rolled: no date dependency.746 ///747 /// Returns `None` for anything that is not a real timestamp, so callers748 /// can fall back to showing the raw string.749 #[must_use]750 pub fn parse_ts_secs(s: &str) -> Option<i64> {751 let s = s.trim();752 // Timestamps are ASCII by definition. Reject other text before any753 // byte-index slicing: a 200-char multibyte `created` used to panic754 // on a non-char-boundary (f0rge #108).755 if !s.is_ascii() {756 return None;757 }758 // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's759 // `2026-10-27 14:08:48 +0000` / `+00:00` forms.760 let (date, rest) = match s.split_once('T') {761 Some(v) => v,762 None => s.split_once(' ')?,763 };764 let mut d = date.split('-');765 let y: i64 = d.next()?.parse().ok()?;766 let mo: i64 = d.next()?.parse().ok()?;767 let da: i64 = d.next()?.parse().ok()?;768 // Keep the civil-date math overflow-free and refuse dates that never769 // existed (Feb 31 used to normalize to Mar 3; huge years panicked).770 if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {771 return None;772 }773 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;774 let max_day = match mo {775 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,776 4 | 6 | 9 | 11 => 30,777 2 if leap => 29,778 2 => 28,779 _ => return None,780 };781 if !(1..=max_day).contains(&da) {782 return None;783 }784 let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {785 (t, 0i64)786 } else if rest.len() >= 6787 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')788 && rest.as_bytes()[rest.len() - 3] == b':'789 {790 let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {791 -1792 } else {793 1794 };795 let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;796 let om: i64 = rest[rest.len() - 2..].parse().ok()?;797 if oh > 23 || om > 59 {798 return None;799 }800 (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))801 } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {802 let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {803 -1804 } else {805 1806 };807 let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;808 let om: i64 = rest[rest.len() - 2..].parse().ok()?;809 if oh > 23 || om > 59 {810 return None;811 }812 (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))813 } else {814 (rest, 0)815 };816 let mut t = hms.split(':');817 let h: i64 = t.next()?.parse().ok()?;818 let mi: i64 = t.next()?.parse().ok()?;819 let sec: i64 = t820 .next()821 .unwrap_or("0")822 .split('.')823 .next()824 .unwrap_or("0")825 .trim()826 .parse()827 .ok()?;828 if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {829 return None;830 }831 // Howard Hinnant's days_from_civil.832 let y2 = if mo <= 2 { y - 1 } else { y };833 let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;834 let yoe = y2 - era * 400;835 let mp = (mo + 9) % 12;836 let doy = (153 * mp + 2) / 5 + da - 1;837 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;838 let days = era * 146_097 + doe - 719_468;839 Some(days * 86_400 + h * 3600 + mi * 60 + sec - offset)840 }841 842 /// Short human title for a timestamp: `2026-09-10 03:52 UTC`843 /// (falls back to the input when it does not parse).844 #[must_use]845 pub fn ts_title(s: &str) -> String {846 let Some(secs) = parse_ts_secs(s) else {847 return s.to_string();848 };849 let days = secs.div_euclid(86_400);850 let rem = secs.rem_euclid(86_400);851 // Civil-from-days (inverse of days_from_civil).852 let z = days + 719_468;853 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;854 let doe = z - era * 146_097;855 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;856 let y = yoe + era * 400;857 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);858 let mp = (5 * doy + 2) / 153;859 let d = doy - (153 * mp + 2) / 5 + 1;860 let m = if mp < 10 { mp + 3 } else { mp - 9 };861 let y = if m <= 2 { y + 1 } else { y };862 format!(863 "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",864 rem / 3600,865 (rem % 3600) / 60866 )867 }868 869 /// Build-time relative age (`3d ago` style, Gitea `relative-time`870 /// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds;871 /// future timestamps clamp to `just now`. Thresholds: ≤10s just now,872 /// <60s `Ns ago`, <1h `Nm ago`, <1d `Nh ago`, <30d `Nd ago`,873 /// <365d `Nmo ago`, else `Ny ago`.874 #[must_use]875 pub fn relative_age(now_ts: i64, then_ts: i64) -> String {876 let mut diff = now_ts.saturating_sub(then_ts);877 if diff < 0 {878 diff = 0;879 }880 if diff <= 10 {881 return "just now".to_string();882 }883 if diff < 60 {884 return format!("{diff}s ago");885 }886 if diff < 3600 {887 return format!("{}m ago", diff / 60);888 }889 if diff < 86_400 {890 return format!("{}h ago", diff / 3600);891 }892 if diff < 86_400 * 30 {893 return format!("{}d ago", diff / 86_400);894 }895 if diff < 86_400 * 365 {896 return format!("{}mo ago", diff / (86_400 * 30));897 }898 format!("{}y ago", diff / (86_400 * 365))899 }900 901 /// Language of a repo-relative path by extension (lowercased).902 /// Map (m6 code-gitea, f0rge #33): rs/py/js/ts/go/sh/css/html/md903 /// (+markdown)/toml/yaml (+yml)/json/c/h; everything else is `Other`.904 /// `Dockerfile`/`Makefile`/extensionless names are `Other` by design.905 #[must_use]906 pub fn language_of(path: &str) -> &'static str {907 let ext = match path.rsplit('.').next() {908 Some(e) if path.contains('.') => e.to_lowercase(),909 _ => String::new(),910 };911 match ext.as_str() {912 "rs" => "Rust",913 "py" => "Python",914 "js" => "JavaScript",915 "ts" => "TypeScript",916 "go" => "Go",917 "sh" => "Shell",918 "css" => "CSS",919 "html" => "HTML",920 "md" | "markdown" => "Markdown",921 "toml" => "TOML",922 "yaml" | "yml" => "YAML",923 "json" => "JSON",924 "c" | "h" => "C",925 _ => "Other",926 }927 }928 929 /// Tokyo Night hue per language for the stacked bar + legend.930 #[must_use]931 pub fn language_color(lang: &str) -> &'static str {932 match lang {933 "Rust" => "#f7768e",934 "Python" => "#7aa2f7",935 "JavaScript" => "#e0af68",936 "TypeScript" => "#7dcfff",937 "Go" => "#73daca",938 "Shell" => "#9ece6a",939 "CSS" => "#bb9af7",940 "HTML" => "#ff9e64",941 "Markdown" => "#9aa5ce",942 "TOML" => "#c0caf5",943 "YAML" => "#ff007c",944 "JSON" => "#89ddff",945 "C" => "#1abc9c",946 _ => "#565f89",947 }948 }949 950 /// One language share for the mix bar.951 #[derive(Debug, Clone, PartialEq)]952 pub struct LangStat {953 /// Display name (`Rust`, …, `Other`).954 pub lang: String,955 /// Total bytes in this language.956 pub bytes: u64,957 /// Share of all counted bytes (0–100).958 pub pct: f64,959 }960 961 /// Group `(path, bytes)` rows into language shares (bytes share).962 ///963 /// Callers pass pre-filtered rows (see [`lang_rows`]): gitignored964 /// paths and binary blobs are already excluded, so the total here is965 /// counted text bytes only and `Other` holds just real text files966 /// with unmapped extensions. Languages under 1% merge into `Other`;967 /// output sorts bytes-desc. Empty input (or zero total) yields an968 /// empty vec (caller hides the bar and shows the no-languages note969 /// instead).970 #[must_use]971 pub fn compute_languages(files: &[(String, u64)]) -> Vec<LangStat> {972 use std::collections::BTreeMap;973 let mut by_lang: BTreeMap<String, u64> = BTreeMap::new();974 for (path, size) in files {975 *by_lang.entry(language_of(path).to_string()).or_default() += *size;976 }977 let total: u64 = by_lang.values().sum();978 if total == 0 {979 return Vec::new();980 }981 let mut main: Vec<(String, u64)> = Vec::new();982 let mut other_bytes: u64 = by_lang.get("Other").copied().unwrap_or(0);983 for (lang, bytes) in &by_lang {984 if lang == "Other" {985 continue;986 }987 let pct = *bytes as f64 * 100.0 / total as f64;988 if pct < 1.0 {989 other_bytes += *bytes;990 } else {991 main.push((lang.clone(), *bytes));992 }993 }994 if other_bytes > 0 {995 main.push(("Other".to_string(), other_bytes));996 }997 let mut out: Vec<LangStat> = main998 .into_iter()999 .map(|(lang, bytes)| LangStat {1000 pct: bytes as f64 * 100.0 / total as f64,1001 lang,1002 bytes,1003 })1004 .collect();1005 out.sort_by_key(|a| std::cmp::Reverse(a.bytes));1006 out1007 }1008 1009 /// Render the stacked language bar + legend (pure build-time, no JS).1010 ///1011 /// `files` is pre-filtered `(path, bytes)` text rows (see [`lang_rows`]:1012 /// gitignored and binary bytes never reach here, so `Other` is real1013 /// text with unmapped extensions only). Returns a1014 /// `<section class="langs">` with a Tokyo Night stacked bar and a1015 /// `%` legend when at least one code file exists (a file whose1016 /// extension maps to a known language); otherwise a muted1017 /// `No languages yet.` note and no bar (hidden when no code).1018 /// Everything is escaped; widths are `pct` with one decimal.1019 #[must_use]1020 pub fn language_bar(files: &[(String, u64)]) -> String {1021 let has_code = files.iter().any(|(p, _)| language_of(p) != "Other");1022 if !has_code {1023 return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();1024 }1025 let stats = compute_languages(files);1026 if stats.is_empty() {1027 return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();1028 }1029 let aria = stats1030 .iter()1031 .map(|s| format!("{} {:.1}%", s.lang, s.pct))1032 .collect::<Vec<_>>()1033 .join(", ");1034 let mut out = format!(1035 "<section class=\"langs\" aria-label=\"Languages\">\n<h2>Languages</h2>\n\1036 <div class=\"lang-bar\" role=\"img\" aria-label=\"{}\">\n",1037 esc(&aria)1038 );1039 for s in &stats {1040 out.push_str(&format!(1041 "<span style=\"width:{:.1}%;background:{}\"></span>",1042 s.pct,1043 language_color(&s.lang)1044 ));1045 }1046 out.push_str("\n</div>\n<ul class=\"lang-legend\">\n");1047 for s in &stats {1048 out.push_str(&format!(1049 "<li><span class=\"lang-dot\" style=\"background:{}\"></span>{} {:.1}%</li>\n",1050 language_color(&s.lang),1051 esc(&s.lang),1052 s.pct1053 ));1054 }1055 out.push_str("</ul>\n</section>\n");1056 out1057 }1058 1059 /// Gitea-style file table (m6 code-gitea, f0rge #33).1060 ///1061 /// `entries` stay in `gitx::ls_tree` order (dirs-first, each alpha).1062 /// `parent` is `Some((href, label))` for subdirs (`..` row) or `None`1063 /// at the root. `history` maps full repo-relative path →1064 /// `(short_message, age)` already truncated + aged at build; missing1065 /// entries render empty cells. Names link to `/files/<full>.html`1066 /// (dirs with a `/` suffix); messages are escaped + truncated to1067 /// [`MSG_TRUNC_CHARS`]; ages are precomputed `relative_age` strings.1068 /// Output is a scroll-wrapped TABLE (file-table class) with1069 /// Name / Last commit / Age headers.1070 #[must_use]1071 pub fn file_table_html(1072 dir: &str,1073 entries: &[gitx::TreeEntry],1074 parent: Option<(&str, &str)>,1075 history: &std::collections::BTreeMap<String, (String, String)>,1076 ) -> String {1077 let mut out = String::from(1078 "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">\n\1079 <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",1080 );1081 if let Some((href, label)) = parent {1082 out.push_str(&format!(1083 "<tr class=\"parent\"><td class=\"trunc\" title=\"{}\"><a href=\"{}\">{}</a></td>\1084 <td class=\"trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",1085 esc(label),1086 esc(href),1087 esc(label)1088 ));1089 }1090 for e in entries {1091 let full = if dir.is_empty() {1092 e.name.clone()1093 } else {1094 format!("{dir}/{}", e.name)1095 };1096 let (msg, age) = history.get(&full).cloned().unwrap_or_default();1097 let msg_short = truncate_msg(&msg);1098 if e.is_dir {1099 out.push_str(&format!(1100 "<tr class=\"dir\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\1101 <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1102 esc(&e.name),1103 href_esc(&full),1104 esc(&e.name),1105 esc(&msg),1106 esc(&msg_short),1107 esc(&age),1108 esc(&age)1109 ));1110 } else if e.is_gitlink {1111 // Submodule: no blob in this repo, so no file page/raw copy.1112 out.push_str(&format!(1113 "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\1114 <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",1115 esc(&e.name),1116 ));1117 } else {1118 out.push_str(&format!(1119 "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\1120 <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1121 esc(&e.name),1122 href_esc(&full),1123 esc(&e.name),1124 esc(&msg),1125 esc(&msg_short),1126 esc(&age),1127 esc(&age)1128 ));1129 }1130 }1131 out.push_str("</tbody>\n</table></div>\n");1132 out1133 }1134 1135 /// Prominent Clone & download section for the code tab (m6, f0rge1136 /// issues #20 + #32 + #34; dumb-clone #54): collapsed native `<details>`1137 /// (closed by default, zero JS, keyboard accessible, works file://) with1138 /// stacked no-JS sections and selectable `<pre>` commands inside. When a1139 /// public origin is configured the primary command is1140 /// `git clone <origin>/repo.git` (dumb-HTTP, works on Pages/nginx/serve1141 /// with zero dynamic; exact snapshot note + bundle/tarball secondary,1142 /// origin remote demoted to "mirror source"); otherwise dumb-local first1143 /// (`git clone <url>` first when a clone URL is configured, else local1144 /// `git clone repo.git`) plus the bundle/tarball links with sizes and1145 /// shas. The `<summary>` always shows `Clone & download`1146 /// plus a branch@sha hint. The summary also carries the CI verdict badge1147 /// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`1148 /// so the standalone repo-meta branch/CI line is gone (no duplication).1149 #[must_use]1150 #[allow(clippy::too_many_arguments)]1151 pub fn clone_block(1152 clone_url: Option<&str>,1153 public_origin: Option<&str>,1154 branch: &str,1155 short_sha: &str,1156 ci_verdict: &str,1157 bundle_size: u64,1158 bundle_sha: Option<&str>,1159 tarball_size: u64,1160 tarball_sha: Option<&str>,1161 ) -> String {1162 let bundle_link = match bundle_sha {1163 Some(s) => format!(1164 "<a href=\"/repo.bundle\">repo.bundle</a> ({}; sha <code>{}</code>)",1165 human_size(bundle_size),1166 esc(s)1167 ),1168 None => format!(1169 "<a href=\"/repo.bundle\">repo.bundle</a> ({})",1170 human_size(bundle_size)1171 ),1172 };1173 let tarball_link = match tarball_sha {1174 Some(s) => format!(1175 "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({}; sha <code>{}</code>)",1176 human_size(tarball_size),1177 esc(s)1178 ),1179 None => format!(1180 "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({})",1181 human_size(tarball_size)1182 ),1183 };1184 let badge = crate::forge::verdict_badge(ci_verdict);1185 let mut out = format!(1186 "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\1187 <summary><span class=\"cb-seg\">Clone & download</span><span class=\"cb-seg\">{} @ {}</span><span class=\"cb-seg\">CI {}</span></summary>\n<div class=\"clone-body\">\n",1188 esc(branch),1189 esc(short_sha),1190 badge1191 );1192 if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {1193 let base = origin.trim_end_matches('/');1194 let dumb_url = format!("{base}/repo.git");1195 let bundle_url = format!("{base}/repo.bundle");1196 let tarball_url = format!("{base}/repo.tar.gz");1197 out.push_str("<h3>Clone with git</h3>\n");1198 out.push_str(&format!(1199 "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\1200 <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",1201 esc(&dumb_url),1202 esc(branch),1203 esc(short_sha)1204 ));1205 out.push_str("<h3>Download the snapshot</h3>\n");1206 out.push_str(&format!(1207 "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n\1208 <pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",1209 esc(&bundle_url),1210 esc(branch)1211 ));1212 out.push_str("<h3>Download the tarball</h3>\n");1213 out.push_str(&format!(1214 "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",1215 esc(&tarball_url)1216 ));1217 if let Some(url) = clone_url {1218 out.push_str(&format!(1219 "<p class=\"muted\">mirror source: <code>{}</code></p>\n",1220 esc(url)1221 ));1222 }1223 out.push_str("</div>\n</details>\n");1224 return out;1225 }1226 if let Some(url) = clone_url {1227 out.push_str("<h3>Clone with git</h3>\n");1228 out.push_str(&format!(1229 "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n",1230 esc(url)1231 ));1232 }1233 // Unset origin: the bundle is the authoritative, self-contained1234 // clone method (no host address needed). `repo.git` is the same1235 // snapshot as a served directory, mentioned second (f0rge #59).1236 out.push_str("<h3>Clone the bundle</h3>\n");1237 out.push_str(&format!(1238 "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",1239 esc(branch)1240 ));1241 out.push_str("<h3>Served as a directory</h3>\n");1242 out.push_str(&format!(1243 "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\1244 <p>the same snapshot as a dumb-HTTP checkout under this site's \1245 web root (<code>/repo.git</code>), no smart server needed.</p>\n",1246 esc(branch)1247 ));1248 out.push_str("<h3>Download the tarball</h3>\n");1249 out.push_str(&format!(1250 "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"1251 ));1252 if clone_url.is_none() {1253 out.push_str(1254 "<p class=\"muted\">No clone URL is configured for this mirror — \1255 the bundle and tarball above carry the full snapshot. \1256 They are served from this server: ask the host for its address.</p>\n",1257 );1258 } else {1259 out.push_str(1260 "<p class=\"muted\">The bundle and tarball above are \1261 served from this server: ask the host for its address.</p>\n",1262 );1263 }1264 out.push_str("</div>\n</details>\n");1265 out1266 }1267 1268 /// Render one text file body: escaped `<pre>` with clickable per-line1269 /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container1270 /// so long lines never break the 360px layout.1271 fn render_text_pre(display: &str, path: &str) -> String {1272 // Normalize line endings and drop exactly one trailing terminator:1273 // `split('\n')` used to turn a final newline into an empty L404 and1274 // kept `\r` in CRLF files (f0rge #88).1275 let normalized = display.replace("\r\n", "\n").replace('\r', "\n");1276 let text = normalized.strip_suffix('\n').unwrap_or(&normalized);1277 let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");1278 if text.is_empty() {1279 out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");1280 } else {1281 // Build-time tokens (f0rge #73): unknown/huge files stay plain.1282 let highlighted = crate::highlight::highlight_lines(path, text);1283 for (i, line) in text.split('\n').enumerate() {1284 let n = i + 1;1285 out.push_str("<span class=\"line\" id=\"L");1286 out.push_str(&n.to_string());1287 out.push_str("\"><a class=\"ln\" href=\"#L");1288 out.push_str(&n.to_string());1289 out.push_str("\">");1290 out.push_str(&n.to_string());1291 out.push_str("</a> ");1292 match &highlighted {1293 Some(lines) => out.push_str(&lines[i]),1294 None => out.push_str(&esc(line)),1295 }1296 out.push_str("</span>");1297 }1298 }1299 out.push_str("</pre></div>\n");1300 out1301 }1302 1303 /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:1304 /// open means `state == "open"` exactly; drafts count as open) and1305 /// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /1306 /// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`1307 /// always stay (they carry the submit path), as do `code` / `commits` /1308 /// `ci` / `agents`. Hidden sections still build (deep links keep1309 /// working); only the nav omits the tab.1310 fn nav_bar(1311 issue_count: usize,1312 pr_count: usize,1313 has_tasks: bool,1314 has_releases: bool,1315 has_wiki: bool,1316 ) -> String {1317 let mut tabs = vec![1318 "<a href=\"/\">code</a>".to_string(),1319 "<a href=\"/commits.html\">commits</a>".to_string(),1320 format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),1321 format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),1322 ];1323 if has_tasks {1324 tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());1325 }1326 tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());1327 if has_releases {1328 tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());1329 }1330 if has_wiki {1331 tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());1332 }1333 tabs.push("<a href=\"/agents.html\">agents</a>".to_string());1334 tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")1335 }1336 1337 /// Write `out/style.css` from the embedded static file.1338 fn write_style(out: &Path) -> Result<(), String> {1339 let css = include_str!("../static/style.css");1340 std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))1341 }1342 1343 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`1344 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).1345 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`1346 /// Pages Functions routing: only `/inbox/*` runs a Function; every1347 /// other path stays a static asset. Must live in the build output, not1348 /// the repo root (f0rge #79).1349 pub const ROUTES_FILE: &str =1350 "{\n \"version\": 1,\n \"include\": [\"/inbox/*\"],\n \"exclude\": []\n}\n";1351 1352 /// Header rules for Cloudflare Pages (and any host that reads `_headers`).1353 ///1354 /// `/raw/**` holds committed, untrusted bytes: a repo can contain1355 /// `raw/evil.html` signing script in the site's own origin, where the1356 /// inbox endpoints live. `serve` sets the same policy directly1357 /// (f0rge #61); this file gives Pages the equivalent.1358 pub const HEADERS_FILE: &str = "\1359 # generated by hu13: committed files and release artifacts are untrusted1360 /raw/*1361 Content-Disposition: attachment1362 X-Content-Type-Options: nosniff1363 # Artifacts are build outputs copied from .forge/releases/out/<ver>/;1364 # never let an artifact .html/.svg execute in the site origin (f0rge #97).1365 # Cloudflare Pages allows one splat per rule. A named `:version`1366 # placeholder plus one splat covers every artifact depth (f0rge #106).1367 # A two-splat rule is silently skipped by Pages and leaves artifacts active.1368 /releases/:version/*1369 Content-Disposition: attachment1370 X-Content-Type-Options: nosniff1371 /*1372 X-Content-Type-Options: nosniff1373 Referrer-Policy: strict-origin-when-cross-origin1374 ";1375 1376 /// (diamond-hub + hu13 as <path>, no <text>/fonts), the favicon is the1377 /// simplified mark-only variant for 16px, and the manifest points at the1378 /// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui).1379 fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {1380 let logo = include_str!("../static/logo.svg");1381 std::fs::write(out.join("logo.svg"), logo).map_err(|e| format!("hu13: write logo.svg: {e}"))?;1382 let gl0w = include_str!("../static/gl0w.svg");1383 std::fs::write(out.join("gl0w.svg"), gl0w).map_err(|e| format!("hu13: write gl0w.svg: {e}"))?;1384 let icon = include_str!("../static/favicon.svg");1385 std::fs::write(out.join("favicon.svg"), icon)1386 .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;1387 let social = include_bytes!("../static/og.png");1388 std::fs::write(out.join("og.png"), social).map_err(|e| format!("hu13: write og.png: {e}"))?;1389 let manifest = include_str!("../static/manifest.webmanifest");1390 std::fs::write(out.join("manifest.webmanifest"), manifest)1391 .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;1392 std::fs::write(out.join("_headers"), HEADERS_FILE)1393 .map_err(|e| format!("hu13: write _headers: {e}"))?;1394 std::fs::write(out.join("_routes.json"), ROUTES_FILE)1395 .map_err(|e| format!("hu13: write _routes.json: {e}"))?;1396 Ok(())1397 }1398 1399 /// Absolute form of `p` (join cwd for relative paths, no symlink work).1400 fn absolute(p: &Path) -> std::path::PathBuf {1401 if p.is_absolute() {1402 p.to_path_buf()1403 } else {1404 std::env::current_dir()1405 .map(|cwd| cwd.join(p))1406 .unwrap_or_else(|_| p.to_path_buf())1407 }1408 }1409 1410 /// Absolute lexical form of `p` with `.`/`..` collapsed. Does not1411 /// touch the filesystem (unlike canonicalize), so it also works for an1412 /// output path that does not exist yet.1413 fn normalize_lexical(p: &Path) -> std::path::PathBuf {1414 use std::path::Component;1415 let mut out = std::path::PathBuf::new();1416 for c in p.components() {1417 match c {1418 Component::CurDir => {}1419 Component::ParentDir => {1420 out.pop();1421 }1422 Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {1423 out.push(c.as_os_str());1424 }1425 }1426 }1427 out1428 }1429 1430 /// Resolve symlinks in the deepest existing ancestor, then append the1431 /// not-yet-created suffix. This catches `-o alias/..` where `alias` is a1432 /// symlink into the repo (f0rge #77).1433 fn resolve_existing(p: &Path) -> std::path::PathBuf {1434 let abs = normalize_lexical(&absolute(p));1435 let mut suffix: Vec<std::ffi::OsString> = Vec::new();1436 let mut cur = abs.as_path();1437 loop {1438 if let Ok(mut resolved) = cur.canonicalize() {1439 for part in suffix.iter().rev() {1440 resolved.push(part);1441 }1442 return normalize_lexical(&resolved);1443 }1444 match cur.file_name() {1445 Some(name) => {1446 suffix.push(name.to_os_string());1447 match cur.parent() {1448 Some(parent) => cur = parent,1449 None => return abs,1450 }1451 }1452 None => return abs,1453 }1454 }1455 }1456 1457 /// `true` when git tracks at least one path under repo-relative `rel`.1458 fn has_tracked_under(repo: &Path, rel: &Path) -> bool {1459 if rel.as_os_str().is_empty() {1460 return true;1461 }1462 std::process::Command::new("git")1463 .arg("-C")1464 .arg(repo)1465 .arg("ls-files")1466 .arg("-z")1467 .arg("--")1468 .arg(rel)1469 .output()1470 .map(|o| o.status.success() && !o.stdout.is_empty())1471 .unwrap_or(true)1472 }1473 1474 /// Remove everything already in `out/` before a build (f0rge #67).1475 ///1476 /// Without this, pages for deleted issues/files keep being served from a1477 /// reused output dir. Refuses to delete the repo itself or anything that1478 /// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked1479 /// ancestors before comparing, and refuses an in-repo output that holds1480 /// tracked files (f0rge #77).1481 fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {1482 let out_path = normalize_lexical(&absolute(out));1483 let out_abs = resolve_existing(&out_path);1484 let repo_abs = resolve_existing(repo);1485 if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {1486 return Err(format!(1487 "hu13: refusing to clean output dir {}: it contains the repo",1488 out.display()1489 ));1490 }1491 if out_abs.parent().is_none() {1492 return Err("hu13: refusing to use the filesystem root as output dir".to_string());1493 }1494 if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {1495 if rel1496 .components()1497 .next()1498 .is_some_and(|c| c.as_os_str() == ".git")1499 || has_tracked_under(&repo_abs, rel)1500 {1501 return Err(format!(1502 "hu13: refusing to clean in-repo output dir {}: it holds tracked files",1503 out.display()1504 ));1505 }1506 }1507 match std::fs::symlink_metadata(&out_path) {1508 Ok(meta) if meta.file_type().is_symlink() => {1509 std::fs::remove_file(&out_path)1510 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;1511 }1512 Ok(meta) if meta.is_dir() => {1513 std::fs::remove_dir_all(&out_path)1514 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;1515 }1516 Ok(_) => {1517 std::fs::remove_file(&out_path)1518 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;1519 }1520 Err(_) => {}1521 }1522 std::fs::create_dir_all(&out_path)1523 .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))1524 }1525 1526 /// Make `og:image` absolute on every built page when a public origin is1527 /// configured (f0rge #95). Done once over the output tree because the1528 /// origin is build config, not page content: all pages share the same1529 /// `content="/og.png"` marker.1530 fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {1531 let base = origin.trim_end_matches('/');1532 if base.is_empty() {1533 return Ok(());1534 }1535 let absolute = format!("content=\"{}/og.png\"", esc(base));1536 let mut stack = vec![out.to_path_buf()];1537 while let Some(dir) = stack.pop() {1538 let rd = std::fs::read_dir(&dir)1539 .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;1540 for ent in rd {1541 let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;1542 let path = ent.path();1543 if path.is_dir() {1544 stack.push(path);1545 continue;1546 }1547 if !path.extension().is_some_and(|x| x == "html") {1548 continue;1549 }1550 let rel = path.strip_prefix(out).unwrap_or(&path);1551 let rel = rel.to_string_lossy().replace('\\', "/");1552 // Only generated pages carry this marker. `/raw/` and release1553 // artifacts are byte-for-byte copies of untrusted inputs: reading1554 // them as UTF-8 can fail the build and rewriting them corrupts the1555 // download (f0rge #100). `repo.git` is git internals, never HTML.1556 if rel.starts_with("raw/")1557 || rel.starts_with("repo.git/")1558 || crate::releases::is_artifact_path(&rel)1559 {1560 continue;1561 }1562 let html = std::fs::read_to_string(&path)1563 .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1564 let replaced = html.replace("content=\"/og.png\"", &absolute);1565 if replaced != html {1566 std::fs::write(&path, replaced)1567 .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;1568 }1569 }1570 }1571 Ok(())1572 }1573 1574 /// Check an output tree against deployment limits. Returns the first1575 /// offending file (or the file-count overflow) with enough context to1576 /// fix the repo. Kept separate from the walk so tests can use tiny caps.1577 pub fn check_output_budget(1578 out: &Path,1579 max_file_bytes: u64,1580 max_files: usize,1581 ) -> Result<(), String> {1582 let mut stack = vec![out.to_path_buf()];1583 let mut files = 0usize;1584 while let Some(dir) = stack.pop() {1585 let rd = std::fs::read_dir(&dir)1586 .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;1587 for ent in rd {1588 let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;1589 let path = ent.path();1590 let meta = ent1591 .metadata()1592 .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;1593 if meta.is_dir() {1594 stack.push(path);1595 } else if meta.is_file() {1596 files += 1;1597 if files > max_files {1598 return Err(format!(1599 "hu13: output has more than {max_files} files (Cloudflare Pages limit); last: {}",1600 path.display()1601 ));1602 }1603 if meta.len() > max_file_bytes {1604 return Err(format!(1605 "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes; keep large assets out of git or use Git LFS",1606 path.display(),1607 meta.len(),1608 max_file_bytes1609 ));1610 }1611 }1612 }1613 }1614 Ok(())1615 }1616 1617 /// Build the full static site. Returns a summary on success.1618 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {1619 let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())1620 .map_err(|e| format!("hu13: {e}"))?;1621 let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;1622 prepare_out(repo, out)?;1623 let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());1624 let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());1625 1626 let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());1627 let description = resolve_description(opts.description_flag.as_deref());1628 let desc_opt = description.as_deref();1629 1630 // Walk the whole tree (dirs-first order from gitx), remembering each1631 // dir's entries so dir pages need no second pass.1632 let mut dir_entries: BTreeMap<String, Vec<gitx::TreeEntry>> = BTreeMap::new();1633 let mut dirs: Vec<String> = vec![String::new()];1634 let mut files: Vec<String> = Vec::new();1635 let mut queue: Vec<String> = vec![String::new()];1636 while let Some(dir) = queue.pop() {1637 let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?;1638 for e in &entries {1639 let full = if dir.is_empty() {1640 e.name.clone()1641 } else {1642 format!("{dir}/{}", e.name)1643 };1644 if e.is_dir {1645 queue.push(full);1646 } else if !e.is_gitlink {1647 // Gitlinks have no blob in this repo (f0rge #68).1648 files.push(full);1649 }1650 }1651 dir_entries.insert(dir.clone(), entries);1652 let _ = dirs.len();1653 }1654 dirs = dir_entries.keys().cloned().collect();1655 1656 let readme = find_readme(repo, &branch);1657 1658 std::fs::create_dir_all(out.join("files"))1659 .map_err(|e| format!("hu13: create files dir: {e}"))?;1660 std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;1661 write_style(out)?;1662 write_favicon_and_manifest(out)?;1663 1664 // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front1665 // so every page (including file pages below) gets a nav with live1666 // open counts (m6 open-counts, f0rge #29). Loading never fails:1667 // bad files are skipped + warned. Releases load here too (m61668 // hide-empty, f0rge #38) so the nav knows whether the releases tab1669 // has content; wiki presence comes from `wiki::page_count` (same1670 // filters as `wiki::render`, read-only, no files emitted).1671 let forge_data = forge::load(repo);1672 let release_data = releases::load(repo);1673 let nav = nav_bar(1674 forge_data.open_issue_count(),1675 forge_data.open_pr_count(),1676 !forge_data.tasks.is_empty(),1677 !release_data.is_empty(),1678 wiki::page_count(repo) > 0,1679 );1680 1681 // Raw files first: byte-identical copies. Blobs over MAX_INLINE_BLOB1682 // are streamed to disk and never enter the in-memory map; their file1683 // pages become placeholders (f0rge #66).1684 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();1685 let mut oversize: BTreeMap<String, u64> = BTreeMap::new();1686 for f in &files {1687 let dest = out.join("raw").join(f);1688 if let Some(parent) = dest.parent() {1689 std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;1690 }1691 let size =1692 gitx::blob_size(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;1693 if size > MAX_INLINE_BLOB {1694 gitx::show_to_file(repo, &branch, Path::new(f), &dest)1695 .map_err(|e| format!("hu13: {e}"))?;1696 oversize.insert(f.clone(), size);1697 } else {1698 let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;1699 std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;1700 blobs.insert(f.clone(), blob.bytes);1701 }1702 // Raster images mentioned by markdown need an inline route; `/raw`1703 // intentionally downloads everything. Copy only non-executable1704 // image formats under `/assets` (f0rge #112). SVG/HTML stay raw.1705 if is_inline_image(f) {1706 let adest = out.join("assets").join(f);1707 if let Some(parent) = adest.parent() {1708 std::fs::create_dir_all(parent)1709 .map_err(|e| format!("hu13: create assets parent: {e}"))?;1710 }1711 std::fs::copy(&dest, &adest).map_err(|e| format!("hu13: write asset {f}: {e}"))?;1712 }1713 }1714 1715 // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file1716 // TABLEs with last-commit message + build-time relative age.1717 // History is one `git log -1` per tree path (tiny repos only);1718 // failures degrade to empty cells, never a failed build.1719 let now_ts = now_unix();1720 let mut history: BTreeMap<String, (String, String)> = BTreeMap::new();1721 {1722 let mut all_paths: Vec<String> = Vec::new();1723 for (d, ents) in &dir_entries {1724 for e in ents {1725 if e.is_gitlink {1726 continue;1727 }1728 let full = if d.is_empty() {1729 e.name.clone()1730 } else {1731 format!("{d}/{}", e.name)1732 };1733 all_paths.push(full);1734 }1735 }1736 for fp in all_paths {1737 let cell = match gitx::last_commit_for_path(repo, &branch, &fp) {1738 Ok(Some(pc)) => (pc.subject, relative_age(now_ts, pc.committer_ts)),1739 _ => (String::new(), String::new()),1740 };1741 history.insert(fp, cell);1742 }1743 }1744 for dir in &dirs {1745 if dir.is_empty() {1746 continue; // root is index.html1747 }1748 let entries = dir_entries.get(dir).cloned().unwrap_or_default();1749 let parent = match dir.rfind('/') {1750 Some(i) => dir[..i].to_string(),1751 None => String::new(),1752 };1753 let parent_href = if parent.is_empty() {1754 "/".to_string()1755 } else {1756 format!("/files/{}.html", href_esc(&parent))1757 };1758 let parent_label = if parent.is_empty() {1759 ".. (root)".to_string()1760 } else {1761 format!(".. ({parent})")1762 };1763 let mut body = breadcrumb(dir);1764 body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));1765 body.push_str(&file_table_html(1766 dir,1767 &entries,1768 Some((&parent_href, &parent_label)),1769 &history,1770 ));1771 let html = crate::layout::page_site_with_provenance(1772 &format!("{dir} — {name}"),1773 &nav,1774 &body,1775 &name,1776 desc_opt,1777 &branch,1778 &short_sha,1779 );1780 let dest = out.join("files").join(format!("{dir}.html"));1781 if let Some(parent) = dest.parent() {1782 std::fs::create_dir_all(parent)1783 .map_err(|e| format!("hu13: create files parent: {e}"))?;1784 }1785 std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;1786 }1787 1788 for f in &files {1789 let mut body = breadcrumb(f);1790 if let Some(&size) = oversize.get(f) {1791 body.push_str(&file_meta_bar(f, size, None));1792 body.push_str(&format!(1793 "<p class=\"notice\">placeholder: too large to preview ({size} bytes) — \1794 <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",1795 href_esc(f)1796 ));1797 } else {1798 let bytes = blobs.get(f).cloned().unwrap_or_default();1799 if is_placeholder(&bytes) {1800 body.push_str(&file_meta_bar(f, bytes.len() as u64, None));1801 let kind = if bytes.len() >= PREVIEW_MAX_BYTES {1802 format!("too large to preview ({} bytes)", bytes.len())1803 } else {1804 "binary file".to_string()1805 };1806 body.push_str(&format!(1807 "<p class=\"notice\">placeholder: {kind} — preview omitted. \1808 <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",1809 href_esc(f)1810 ));1811 } else if is_markdown(f) {1812 let display = String::from_utf8_lossy(&bytes).into_owned();1813 body.push_str(&file_meta_bar(1814 f,1815 bytes.len() as u64,1816 Some(display.lines().count()),1817 ));1818 let base = f.rsplit_once('/').map(|(d, _)| d).unwrap_or("");1819 body.push_str(&markdown_to_html_in(&display, base, false));1820 body.push_str(&format!(1821 "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",1822 href_esc(f)1823 ));1824 } else {1825 let display = String::from_utf8_lossy(&bytes).into_owned();1826 body.push_str(&file_meta_bar(1827 f,1828 bytes.len() as u64,1829 Some(display.lines().count()),1830 ));1831 body.push_str(&render_text_pre(&display, f));1832 }1833 }1834 let html = crate::layout::page_site_with_provenance(1835 &format!("{f} — {name}"),1836 &nav,1837 &body,1838 &name,1839 desc_opt,1840 &branch,1841 &short_sha,1842 );1843 let dest = out.join("files").join(format!("{f}.html"));1844 if let Some(parent) = dest.parent() {1845 std::fs::create_dir_all(parent)1846 .map_err(|e| format!("hu13: create files parent: {e}"))?;1847 }1848 std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;1849 }1850 1851 // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):1852 // one struct pass — the same loaded vecs feed both HTML and JSON.1853 let commit_log = commits::load(repo, &branch, opts.max_log)?;1854 commits::render_loaded(1855 repo,1856 out,1857 &commit_log,1858 &name,1859 &nav,1860 &branch,1861 &short_sha,1862 desc_opt,1863 )?;1864 feed::render_loaded_with_origin(1865 repo,1866 out,1867 &branch,1868 &name,1869 &commit_log,1870 desc_opt,1871 public_origin.as_deref(),1872 )?;1873 let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;1874 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.1875 // Tasks/releases/wiki indexes always render even when empty (m61876 // hide-empty, f0rge #38): only the nav hides the tab, deep links1877 // keep working with their "No X yet" empty states.1878 forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;1879 releases::render_loaded(1880 &release_data,1881 repo,1882 out,1883 &name,1884 &nav,1885 &branch,1886 &short_sha,1887 desc_opt,1888 )?;1889 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;1890 agents::render(1891 out,1892 &name,1893 &nav,1894 clone_url.as_deref(),1895 &branch,1896 &short_sha,1897 desc_opt,1898 )?;1899 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.1900 crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;1901 1902 // Snapshot last so the index can link sizes. Dumb-HTTP repo.git (#54)1903 // rides alongside the bundle/tarball so `git clone <origin>/repo.git`1904 // works on any static server (Pages/nginx/serve), no smart HTTP.1905 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;1906 gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?;1907 let bundle_size = std::fs::metadata(&snap.bundle)1908 .map(|m| m.len())1909 .unwrap_or(0);1910 let tarball_size = std::fs::metadata(&snap.tarball)1911 .map(|m| m.len())1912 .unwrap_or(0);1913 1914 // index.html header: stacked repo-head (name + desc + clonebox) so narrow1915 // screens wrap safely. Branch/sha/CI live ONLY in the clone summary1916 // (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`1917 // — the old standalone repo-meta branch/CI line is deleted (no dupe).1918 // No counts line here (m6 open-counts, f0rge #36): nav carries counts.1919 let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));1920 if let Some(d) = &description {1921 body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));1922 }1923 body.push_str(&clone_block(1924 clone_url.as_deref(),1925 public_origin.as_deref(),1926 &branch,1927 &short_sha,1928 forge_data.ci.latest_verdict(),1929 bundle_size,1930 file_hash(&snap.bundle).as_deref(),1931 tarball_size,1932 file_hash(&snap.tarball).as_deref(),1933 ));1934 1935 body.push_str("</div>\n");1936 // Files then README in one stacked column at every width (f0rge #120);1937 // no desktop sidebar split.1938 // Code tab file TABLE (m6 code-gitea, f0rge #33, Gitea-style) plus the1939 // language mix bar (bytes share, Tokyo Night hues, no JS).1940 // Language math (m6, f0rge #37): tracked files only (via gitx, so1941 // untracked/ignored junk never counts), minus force-added paths1942 // that still match gitignore, minus binary blobs (NUL in first1943 // 8KiB). `Other` is real text with unmapped extensions only.1944 // Ignore lookup never fails the build (degrades to no ignores).1945 let root_entries = dir_entries.get("").cloned().unwrap_or_default();1946 let ignored: std::collections::HashSet<String> =1947 gitx::ignored_paths(repo, &files).unwrap_or_default();1948 let lang_files: Vec<(String, u64)> = lang_rows(&blobs, &ignored);1949 let lang_section = language_bar(&lang_files);1950 body.push_str("<div class=\"cols\"><section class=\"code-files\"><h2>Files</h2>\n");1951 body.push_str(&file_table_html("", &root_entries, None, &history));1952 body.push_str(&lang_section);1953 body.push_str("</section>\n<section class=\"code-readme\"><h2>README</h2>\n");1954 match &readme {1955 Some((path, blob)) => {1956 if is_markdown(path) || path.to_lowercase().contains("readme") {1957 let (base, wiki) = if let Some(rest) = path.strip_prefix(".forge/wiki/") {1958 (rest.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), true)1959 } else {1960 (path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), false)1961 };1962 body.push_str(&markdown_to_html_in(&blob.display, base, wiki));1963 } else {1964 body.push_str(&render_text_pre(&blob.display, path));1965 }1966 }1967 None => body.push_str("<p class=\"muted\">No README found.</p>\n"),1968 }1969 body.push_str("</section></div>\n");1970 1971 let index = crate::layout::page_site_with_provenance(1972 &name, &nav, &body, &name, desc_opt, &branch, &short_sha,1973 );1974 std::fs::write(out.join("index.html"), index)1975 .map_err(|e| format!("hu13: write index.html: {e}"))?;1976 1977 // Branded 404 for Pages/nginx; `serve` streams this file with status1978 // 404 on unknown paths (f0rge #85). Same nav/footer as every page.1979 let not_found = crate::layout::page_site_with_provenance(1980 &format!("not found — {name}"),1981 &nav,1982 "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",1983 &name,1984 desc_opt,1985 &branch,1986 &short_sha,1987 );1988 std::fs::write(out.join("404.html"), not_found)1989 .map_err(|e| format!("hu13: write 404.html: {e}"))?;1990 1991 if let Some(origin) = public_origin.as_deref() {1992 absolutize_social_meta(out, origin)?;1993 }1994 1995 // Fail loudly here instead of letting Pages reject an opaque deploy1996 // (f0rge #81). Counts the whole emitted tree, including repo.git.1997 check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;1998 1999 Ok(BuildSummary {2000 branch,2001 short_sha,2002 file_count: files.len(),2003 })2004 }2005 2006 /// Collect every file path under `dir` (relative, `/`-separated).2007 #[cfg(test)]2008 pub fn test_all_files(dir: &Path) -> Vec<std::path::PathBuf> {2009 let mut out = Vec::new();2010 let mut stack = vec![dir.to_path_buf()];2011 while let Some(d) = stack.pop() {2012 let rd = std::fs::read_dir(&d).expect("read_dir");2013 for ent in rd {2014 let ent = ent.expect("dir entry");2015 let p = ent.path();2016 if p.is_dir() {2017 stack.push(p);2018 } else {2019 out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());2020 }2021 }2022 }2023 out.sort();2024 out2025 }2026 2027 #[cfg(test)]2028 mod tests {2029 use super::*;2030 use std::path::PathBuf;2031 use std::process::Command;2032 use std::sync::atomic::{AtomicU64, Ordering};2033 2034 static COUNTER: AtomicU64 = AtomicU64::new(0);2035 2036 fn mktemp(prefix: &str) -> PathBuf {2037 let id = COUNTER.fetch_add(1, Ordering::SeqCst);2038 let dir =2039 std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id()));2040 std::fs::create_dir_all(&dir).expect("mktemp");2041 dir2042 }2043 2044 fn git(repo: &Path, args: &[&str]) {2045 let st = Command::new("git")2046 .arg("-C")2047 .arg(repo)2048 .args(args)2049 .status()2050 .expect("spawn git");2051 assert!(st.success(), "git {args:?} failed in {}", repo.display());2052 }2053 2054 fn opts() -> BuildOptions {2055 BuildOptions {2056 branch_flag: None,2057 clone_url_flag: None,2058 public_origin_flag: None,2059 repo_name_flag: None,2060 description_flag: None,2061 max_log: 100,2062 }2063 }2064 2065 /// Fixture: 10 files, no `.forge/` (the no-forge case) —2066 /// README + subdir + binary + >256KB file.2067 fn fixture_repo() -> PathBuf {2068 let dir = mktemp("fixture");2069 git(&dir, &["init", "-b", "main"]);2070 git(&dir, &["config", "user.name", "hu13-test"]);2071 git(&dir, &["config", "user.email", "[email protected]"]);2072 git(&dir, &["config", "commit.gpgsign", "false"]);2073 std::fs::write(2074 dir.join("README.md"),2075 "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n<script>alert(1)</script>\n",2076 )2077 .unwrap();2078 std::fs::create_dir_all(dir.join("src")).unwrap();2079 std::fs::write(2080 dir.join("src").join("main.rs"),2081 "fn main() {\n println!(\"hi\");\n}\n",2082 )2083 .unwrap();2084 std::fs::write(2085 dir.join("src").join("lib.rs"),2086 "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",2087 )2088 .unwrap();2089 std::fs::create_dir_all(dir.join("docs")).unwrap();2090 std::fs::write(2091 dir.join("docs").join("guide.md"),2092 "# Guide\n\nSome *docs* here.\n",2093 )2094 .unwrap();2095 std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap();2096 std::fs::create_dir_all(dir.join("assets")).unwrap();2097 let mut bin: Vec<u8> = (0u8..=255).collect();2098 bin.extend_from_slice(b"\x00\x01\x02binary tail");2099 std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap();2100 std::fs::create_dir_all(dir.join("data")).unwrap();2101 std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap();2102 std::fs::create_dir_all(dir.join("sub")).unwrap();2103 std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();2104 std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap();2105 std::fs::write(2106 dir.join("LICENSE"),2107 "MIT License\n\nPermission is hereby granted.\n",2108 )2109 .unwrap();2110 git(&dir, &["add", "-A"]);2111 git(&dir, &["commit", "-m", "fixture commit"]);2112 dir2113 }2114 2115 fn build_fixture() -> (PathBuf, PathBuf) {2116 let repo = fixture_repo();2117 let out = mktemp("out");2118 let summary = run_build(&repo, &out, &opts()).expect("build fixture");2119 assert_eq!(summary.branch, "main");2120 assert_eq!(summary.file_count, 10);2121 assert!(!summary.short_sha.is_empty());2122 (repo, out)2123 }2124 2125 #[test]2126 fn index_has_tree_and_readme_html() {2127 let (_repo, out) = build_fixture();2128 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2129 // File tree (dirs-first flat root listing).2130 for name in ["src", "docs", "assets", "data", "sub"] {2131 assert!(index.contains(name), "tree should list {name}");2132 }2133 for name in ["README.md", "notes.txt", "LICENSE"] {2134 assert!(index.contains(name), "tree should list {name}");2135 }2136 // README rendered to HTML (markdown heading), raw HTML escaped.2137 assert!(index.contains("Hello fixture"), "README body rendered");2138 assert!(2139 !index.contains("<script>alert(1)</script>"),2140 "raw HTML escaped"2141 );2142 assert!(index.contains("<script>"), "escaped HTML present");2143 // Header (m6 finish, f0rge #53): branch/sha/CI live ONLY in the2144 // clone summary — no standalone repo-meta / CI dupe line.2145 assert!(index.contains("main"), "branch shown");2146 assert!(!index.contains("TODO"), "no TODO markers left");2147 assert!(index.contains("issues (0)"), "nav open issues 0");2148 assert!(index.contains("pulls (0)"), "nav open pulls 0");2149 assert!(!index.contains("repo-meta"), "no dupe repo-meta line");2150 assert!(!index.contains("<p>CI:"), "no dupe CI line");2151 assert!(index.contains("badge"), "CI verdict badge in summary");2152 let summ = index2153 .find("<summary><span class=\"cb-seg\">Clone")2154 .expect("summary");2155 let send = index[summ..].find("</summary>").expect("summary end") + summ;2156 let summary = &index[summ..send];2157 assert!(summary.contains("main"), "branch in summary");2158 assert!(summary.contains("CI"), "CI in summary");2159 assert!(summary.contains("badge"), "badge in summary");2160 // f0rge #36: code tab carries no redundant counts line.2161 let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");2162 let details_end = index[head_start..].find("</details>").expect("details end") + head_start;2163 let head = &index[head_start..details_end];2164 for needle in ["issues (", "pulls (", "tasks (", "releases ("] {2165 assert!(!head.contains(needle), "code tab drops {needle}");2166 }2167 // Clone block (bundle-first: no origin remote is configured).2168 assert!(index.contains("clone-block"), "clone block present");2169 assert!(index.contains("repo.bundle"), "bundle linked");2170 assert!(index.contains("repo.tar.gz"), "tarball linked");2171 assert!(index.contains("git clone repo.bundle"), "bundle clone docs");2172 assert!(2173 index.contains("tar -xzf repo.tar.gz"),2174 "tarball extract docs"2175 );2176 // No clone URL line when no origin remote is configured.2177 assert!(2178 !index.contains("git clone http"),2179 "no URL line without remote"2180 );2181 }2182 2183 #[test]2184 fn open_counts_nav_and_no_code_tab_counts() {2185 // m6/open-counts (f0rge #29 + #36): header nav is open-only,2186 // code tab keeps branch/sha/CI but drops the counts line,2187 // index pages carry the `N closed` totals.2188 let repo = fixture_repo();2189 let forge_write = |rel: &str, content: &str| {2190 let dest = repo.join(".forge").join(rel);2191 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();2192 std::fs::write(&dest, content).unwrap();2193 };2194 for (uuid, state, hour) in [2195 ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),2196 ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),2197 ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),2198 ] {2199 forge_write(2200 &format!("issues/{uuid}/issue.md"),2201 &format!(2202 "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"2203 ),2204 );2205 }2206 for (uuid, state, draft, hour) in [2207 ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),2208 ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),2209 (2210 "ffffffff-ffff-ffff-ffff-ffffffffffff",2211 "merged",2212 false,2213 "05",2214 ),2215 ] {2216 forge_write(2217 &format!("prs/{uuid}/pr.md"),2218 &format!(2219 "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"2220 ),2221 );2222 }2223 git(&repo, &["add", "-A"]);2224 git(&repo, &["commit", "-m", "forge counts"]);2225 let out = mktemp("out-open-counts");2226 run_build(&repo, &out, &opts()).expect("build");2227 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2228 assert!(index.contains("issues (1)"), "nav open issues");2229 assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");2230 assert!(2231 !index.contains("repo-meta"),2232 "no dupe repo-meta (f0rge #53)"2233 );2234 assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");2235 assert!(index.contains("badge"), "CI badge in summary");2236 let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");2237 let details_end = index[head_start..].find("</details>").expect("details end") + head_start;2238 let head = &index[head_start..details_end];2239 for needle in ["issues (", "pulls (", "tasks (", "releases ("] {2240 assert!(!head.contains(needle), "code tab drops {needle}");2241 }2242 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2243 assert!(issues.contains("2 closed"), "issues closed total");2244 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2245 assert!(pulls.contains("1 closed"), "pulls closed total");2246 assert!(pulls.contains(" draft"), "draft stays marked");2247 }2248 2249 #[test]2250 fn clone_url_flag_wins() {2251 let repo = fixture_repo();2252 let out = mktemp("out-flag");2253 let mut o = opts();2254 o.clone_url_flag = Some("https://example.com/x.git".to_string());2255 run_build(&repo, &out, &o).expect("build");2256 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2257 assert!(index.contains("git clone https://example.com/x.git"));2258 }2259 2260 #[test]2261 fn raw_byte_identical_for_all_files() {2262 let (repo, out) = build_fixture();2263 let raws = test_all_files(&out.join("raw"));2264 assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}");2265 for rel in &raws {2266 let want = std::fs::read(repo.join(rel)).expect("repo file");2267 let got = std::fs::read(out.join("raw").join(rel)).expect("raw file");2268 assert_eq!(got, want, "byte-identical: {}", rel.display());2269 }2270 }2271 2272 #[test]2273 fn placeholder_for_binary_and_big_file() {2274 let (_repo, out) = build_fixture();2275 for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] {2276 let html = std::fs::read_to_string(out.join(page)).expect(page);2277 assert!(html.contains("placeholder"), "{page} has placeholder");2278 assert!(html.contains("/raw/"), "{page} links a download");2279 }2280 // Binary bytes must never be inlined into the page.2281 let bin_page =2282 std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page");2283 assert!(!bin_page.contains("binary tail"), "binary not inlined");2284 // Small text file gets line numbers + raw link, no placeholder.2285 let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2286 assert!(txt.contains("/raw/notes.txt"), "raw link");2287 assert!(!txt.contains("placeholder"), "text file inlined");2288 assert!(2289 txt.contains("<") || txt.contains("plain notes"),2290 "escaped pre body"2291 );2292 }2293 2294 #[test]2295 fn md_files_are_rendered() {2296 let (_repo, out) = build_fixture();2297 let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");2298 assert!(2299 html.contains("<h1 id=\"guide\">Guide</h1>"),2300 "markdown rendered, got: {html}"2301 );2302 assert!(html.contains("/raw/docs/guide.md"), "raw link kept");2303 }2304 2305 #[test]2306 fn bundle_clones() {2307 let (_repo, out) = build_fixture();2308 assert!(out.join("repo.bundle").exists());2309 assert!(out.join("repo.tar.gz").exists());2310 let dest_parent = mktemp("clone");2311 let dest = dest_parent.join("cloned");2312 let st = Command::new("git")2313 .arg("clone")2314 .arg("-b")2315 .arg("main")2316 .arg(out.join("repo.bundle"))2317 .arg(&dest)2318 .status()2319 .expect("git clone");2320 assert!(st.success(), "bundle clones");2321 assert!(dest.join("README.md").exists());2322 assert!(dest.join("sub").join("nested.txt").exists());2323 }2324 2325 #[test]2326 fn no_forge_build_succeeds() {2327 // Fixture has no `.forge/` at all; build must still exit Ok (0).2328 let repo = fixture_repo();2329 assert!(!repo.join(".forge").exists());2330 let out = mktemp("out-noforge");2331 let res = run_build(&repo, &out, &opts());2332 assert!(res.is_ok(), "no-forge build exits 0, got {res:?}");2333 assert!(out.join("index.html").exists());2334 }2335 2336 #[test]2337 fn readme_chain_prefers_root_then_wiki() {2338 let dir = mktemp("readme-chain");2339 git(&dir, &["init", "-b", "main"]);2340 git(&dir, &["config", "user.name", "hu13-test"]);2341 git(&dir, &["config", "user.email", "[email protected]"]);2342 git(&dir, &["config", "commit.gpgsign", "false"]);2343 // Wiki-only README first.2344 std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2345 std::fs::write(2346 dir.join(".forge").join("wiki").join("README.md"),2347 "# Wiki readme\n",2348 )2349 .unwrap();2350 std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2351 git(&dir, &["add", "-A"]);2352 git(&dir, &["commit", "-m", "wiki readme only"]);2353 let (path, _) = find_readme(&dir, "main").expect("wiki fallback");2354 assert_eq!(path, ".forge/wiki/README.md");2355 // Root README.md wins once added.2356 std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap();2357 git(&dir, &["add", "-A"]);2358 git(&dir, &["commit", "-m", "root readme"]);2359 let (path, _) = find_readme(&dir, "main").expect("root");2360 assert_eq!(path, "README.md");2361 }2362 2363 #[test]2364 fn readme_chain_shows_extensionless_wiki_readme() {2365 let dir = mktemp("readme-extless");2366 git(&dir, &["init", "-b", "main"]);2367 git(&dir, &["config", "user.name", "hu13-test"]);2368 git(&dir, &["config", "user.email", "[email protected]"]);2369 git(&dir, &["config", "commit.gpgsign", "false"]);2370 // Extensionless wiki README only (f0rge issue #35).2371 std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2372 std::fs::write(2373 dir.join(".forge").join("wiki").join("README"),2374 "# Wiki extless\n",2375 )2376 .unwrap();2377 std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2378 git(&dir, &["add", "-A"]);2379 git(&dir, &["commit", "-m", "wiki extless only"]);2380 let (path, _) = find_readme(&dir, "main").expect("wiki extless fallback");2381 assert_eq!(path, ".forge/wiki/README");2382 // Code tab renders it.2383 let out = mktemp("out-extless");2384 run_build(&dir, &out, &opts()).expect("build");2385 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2386 assert!(index.contains("Wiki extless"), "code tab shows wiki README");2387 assert!(2388 !index.contains("from <code>") && !index.contains(".forge/wiki/README</code>"),2389 "code tab does not name the README path"2390 );2391 }2392 2393 #[test]2394 fn readme_chain_full_priority_order() {2395 let dir = mktemp("readme-priority");2396 git(&dir, &["init", "-b", "main"]);2397 git(&dir, &["config", "user.name", "hu13-test"]);2398 git(&dir, &["config", "user.email", "[email protected]"]);2399 git(&dir, &["config", "commit.gpgsign", "false"]);2400 let chain = [2401 "README.md",2402 "readme.md",2403 "README",2404 "readme",2405 ".forge/wiki/README.md",2406 ".forge/wiki/readme.md",2407 ".forge/wiki/README",2408 ".forge/wiki/readme",2409 ];2410 std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2411 for (i, cand) in chain.iter().enumerate() {2412 std::fs::write(dir.join(cand), format!("# marker {i}\n")).unwrap();2413 }2414 std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2415 git(&dir, &["add", "-A"]);2416 git(&dir, &["commit", "-m", "all readmes"]);2417 for (i, want) in chain.iter().enumerate() {2418 let (path, blob) = find_readme(&dir, "main").expect("readme present");2419 assert_eq!(&path, want, "priority position {i}");2420 assert!(2421 blob.display.contains(&format!("marker {i}")),2422 "winning body at {want}"2423 );2424 // Root extensionless beats wiki once wiki-only remains.2425 if *want == "readme" {2426 let (p, _) = find_readme(&dir, "main").expect("root extless");2427 assert_eq!(p, "readme");2428 }2429 git(&dir, &["rm", "-q", want]);2430 git(&dir, &["commit", "-m", &format!("drop {want}")]);2431 }2432 assert!(find_readme(&dir, "main").is_none(), "none when chain empty");2433 }2434 2435 #[test]2436 fn build_emits_commits_and_wiki() {2437 let repo = fixture_repo();2438 // On-disk wiki with one page (proves the .forge — not git — read path).2439 std::fs::create_dir_all(repo.join(".forge").join("wiki")).unwrap();2440 std::fs::write(2441 repo.join(".forge").join("wiki").join("notes.md"),2442 "# Build notes\n\nwiki body\n",2443 )2444 .unwrap();2445 git(&repo, &["add", "-A"]);2446 git(&repo, &["commit", "-m", "add wiki"]);2447 let out = mktemp("out-commits-wiki");2448 run_build(&repo, &out, &opts()).expect("build");2449 // Commits wired in.2450 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");2451 assert!(list.contains("fixture commit"));2452 assert!(list.contains("add wiki"));2453 assert!(list.contains("/commit/"), "detail links");2454 let log = gitx::log_n(&repo, "main", 100).expect("log");2455 assert!(out2456 .join("commit")2457 .join(format!("{}.html", log[0].sha))2458 .exists());2459 // Wiki wired in (index + per-page), nav links present.2460 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2461 assert!(index.contains("commits.html"), "nav links commits");2462 assert!(index.contains("wiki/index.html"), "nav links wiki");2463 let windex =2464 std::fs::read_to_string(out.join("wiki").join("index.html")).expect("wiki index");2465 assert!(windex.contains("Build notes"), "H1 title");2466 assert!(out.join("wiki").join("notes.md.html").exists());2467 }2468 2469 #[test]2470 fn markdown_escapes_raw_html() {2471 let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");2472 assert!(html.contains("<h1 id=\"hi\">hi</h1>"), "heading renders");2473 assert!(!html.contains("<script>"), "no raw passthrough");2474 assert!(html.contains("<script>"), "escaped instead");2475 assert!(2476 html.contains("<strong>bold</strong>"),2477 "legit markdown intact"2478 );2479 }2480 2481 #[test]2482 fn markdown_enables_gfm_tables_tasks_strikethrough() {2483 let md = "| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n~~gone~~\n";2484 let html = markdown_to_html(md);2485 let table = format!("<{}>", "table");2486 assert!(html.contains(&table), "table parsed: {html}");2487 assert!(html.contains("<th>a</th>"), "header cell");2488 assert!(html.contains("<td>1</td>"), "body cell");2489 assert!(html.contains("type=\"checkbox\""), "task checkbox");2490 assert!(html.contains("checked"), "checked task preserved");2491 assert!(html.contains("<li>"), "task list item");2492 assert!(html.contains("<del>gone</del>"), "strikethrough");2493 }2494 2495 #[test]2496 fn markdown_blocks_dangerous_link_schemes() {2497 for evil in [2498 "[x](javascript:alert(1))",2499 "[x](JaVaScRiPt:alert(1))",2500 "[x](java\tscript:alert(1))",2501 "[x](javascript:alert(1))",2502 "[x](javascript:alert(1))",2503 "[x](data:text/html;base64,PHNjcmlwdD4=)",2504 "[x](vbscript:msgbox(1))",2505 "[x](file:///etc/passwd)",2506 ")",2507 ">)",2508 ] {2509 let html = markdown_to_html(evil);2510 let lower = html.to_ascii_lowercase();2511 for attr in ["href=", "src="] {2512 for bad in ["javascript:", "vbscript:", "data:", "file:"] {2513 assert!(2514 !lower.contains(&format!("{attr}\"{bad}")),2515 "{evil:?} must not emit {attr}{bad}: {html}"2516 );2517 }2518 }2519 }2520 }2521 2522 #[test]2523 fn markdown_keeps_safe_links_and_relative_urls() {2524 let md = "[a](https://example.com/x?y=1&z=2) [b](/files/x.html) [c](mailto:a@b) [d](tel:+1) [e](#L3)";2525 let html = markdown_to_html(md);2526 assert!(html.contains("href=\"https://example.com/x?y=1&z=2\""));2527 assert!(html.contains("href=\"/files/x.html\""));2528 assert!(html.contains("href=\"mailto:a@b\""));2529 assert!(html.contains("href=\"tel:+1\""));2530 assert!(html.contains("href=\"#L3\""));2531 }2532 2533 #[test]2534 fn human_size_formats() {2535 assert_eq!(human_size(0), "0 B");2536 assert_eq!(human_size(24), "24 B");2537 assert_eq!(human_size(1023), "1023 B");2538 assert_eq!(human_size(2048), "2.0 KiB");2539 assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB");2540 }2541 2542 #[test]2543 fn breadcrumb_links_each_segment() {2544 let (_repo, out) = build_fixture();2545 // File page: root plus every segment linked.2546 let html =2547 std::fs::read_to_string(out.join("files/sub/nested.txt.html")).expect("file page");2548 assert!(html.contains("<a href=\"/\">root</a>"), "root crumb");2549 assert!(2550 html.contains("<a href=\"/files/sub.html\">sub</a>"),2551 "dir segment linked"2552 );2553 assert!(2554 html.contains("<a href=\"/files/sub/nested.txt.html\">nested.txt</a>"),2555 "file segment linked"2556 );2557 // Dir page carries a breadcrumb too.2558 let dir = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");2559 assert!(dir.contains("<a href=\"/\">root</a>"), "dir root crumb");2560 assert!(2561 dir.contains("<a href=\"/files/sub.html\">sub</a>"),2562 "dir self crumb"2563 );2564 }2565 2566 #[test]2567 fn line_numbers_are_shareable_anchors() {2568 let (_repo, out) = build_fixture();2569 let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2570 assert!(html.contains("id=\"L1\""), "line 1 anchor");2571 assert!(html.contains("href=\"#L1\""), "line 1 link");2572 assert!(html.contains("id=\"L2\""), "line 2 anchor");2573 assert!(html.contains("href=\"#L2\""), "line 2 link");2574 }2575 2576 #[test]2577 fn file_meta_bar_has_size_lines_raw_and_path() {2578 let (_repo, out) = build_fixture();2579 let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2580 assert!(html.contains("file-meta"), "meta bar");2581 assert!(html.contains("notes.txt"), "path text");2582 // "plain notes\nsecond line\n" is 24 bytes in 2 lines.2583 assert!(html.contains("24 B"), "humanized size");2584 assert!(html.contains("2 lines"), "line count");2585 assert!(html.contains("/raw/notes.txt"), "raw button");2586 }2587 2588 #[test]2589 fn dir_page_has_parent_row_and_sizes() {2590 // m6/code-gitea (f0rge #33): dir pages are a Gitea-style TABLE2591 // (Name / Last commit / Age); sizes + raw live on file pages2592 // (file_meta_bar), not in the table. Breadcrumb stays.2593 let (_repo, out) = build_fixture();2594 let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");2595 assert!(2596 html.contains("file-table\">"),2597 "file TABLE, got: {}",2598 &html[..2000.min(html.len())]2599 );2600 assert!(2601 html.contains("<a href=\"/\">.. (root)</a>"),2602 "parent .. row to root"2603 );2604 assert!(html.contains("nested.txt"), "lists file");2605 assert!(html.contains("class=\"crumbs\""), "breadcrumb kept");2606 assert!(html.contains("<th>Name</th>"), "Name header");2607 assert!(html.contains("<th>Last commit</th>"), "message header");2608 assert!(html.contains("<th>Age</th>"), "age header");2609 // Sizes + raw live on the file page itself (meta bar), not the table.2610 let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2611 assert!(txt.contains("24 B"), "file size on file page");2612 assert!(txt.contains("/raw/notes.txt"), "raw on file page");2613 }2614 2615 #[test]2616 fn file_table_is_gitea_style_dirs_first() {2617 // f0rge #33 rubric: TABLE (not bullets), dirs-first then files2618 // each alpha, dirs with `/` suffix, parent `..` row, breadcrumb.2619 let (_repo, out) = build_fixture();2620 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2621 assert!(index.contains("file-table\">"), "root TABLE");2622 assert!(2623 !index.contains("<ul class=\"tree\">"),2624 "no bullets on code tab"2625 );2626 assert!(2627 index.contains("class=\"crumbs\"") || index.contains("repo-head"),2628 "page kept"2629 );2630 // Dirs carry a trailing slash link; files do not gain one.2631 assert!(index.contains(">src/</a>"), "dir with / suffix");2632 // Dirs-first: every root dir sorts before every root file.2633 let dir_pos = index.find(">src/</a>").expect("src pos");2634 let file_pos = index.find(">README.md</a>").expect("readme pos");2635 assert!(dir_pos < file_pos, "dirs-first in root TABLE");2636 // Subdir page: parent row + breadcrumb + own TABLE.2637 let sub = std::fs::read_to_string(out.join("files/sub.html")).expect("sub");2638 assert!(sub.contains("file-table\">"), "subdir TABLE");2639 assert!(sub.contains("<tr class=\"parent\">"), "parent row element");2640 assert!(2641 sub.contains("<a href=\"/\">.. (root)</a>"),2642 "parent .. link"2643 );2644 assert!(2645 sub.contains("<nav class=\"crumbs\""),2646 "breadcrumb kept on subdir"2647 );2648 // Message + age columns exist even when history is empty-ish.2649 assert!(2650 sub.contains("<td class=\"title trunc trunc-message\""),2651 "message cell trunc"2652 );2653 assert!(sub.contains("title=\""), "title attrs kept");2654 }2655 2656 /// m6/cards2 (f0rge #46): file table stays a real table on all widths —2657 /// no data-label card treatment, scroll wrapper kept, trunc + title kept.2658 #[test]2659 fn file_table_stays_table_no_card_labels() {2660 let css = include_str!("../static/style.css");2661 assert!(css.contains("@media (max-width:767px)"), "narrow query");2662 assert!(2663 css.contains(".table-scroll:not(.file-scroll)"),2664 "cards scoped off file-scroll"2665 );2666 assert!(2667 css.contains(".file-scroll{overflow-x:auto}"),2668 "file-scroll keeps h-scroll on narrow"2669 );2670 let (_repo, out) = build_fixture();2671 for page in ["index.html", "files/sub.html"] {2672 let html = std::fs::read_to_string(out.join(page)).expect(page);2673 assert!(2674 html.contains(2675 "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">"2676 ),2677 "{page}: scroll wrapper kept"2678 );2679 assert!(!html.contains("data-label="), "{page}: no card labels");2680 assert!(2681 html.contains("<td class=\"title trunc trunc-message\""),2682 "{page}: message cell trunc kept"2683 );2684 assert!(html.contains("title=\""), "{page}: title attrs kept");2685 assert!(2686 html.contains("<th>Name</th>")2687 && html.contains("<th>Last commit</th>")2688 && html.contains("<th>Age</th>"),2689 "{page}: headers kept"2690 );2691 }2692 }2693 2694 #[test]2695 fn truncate_msg_cuts_at_72_with_ellipsis() {2696 assert_eq!(truncate_msg("short"), "short");2697 let exactly = "x".repeat(72);2698 assert_eq!(truncate_msg(&exactly), exactly, "72 chars untouched");2699 let long = "y".repeat(100);2700 let got = truncate_msg(&long);2701 assert_eq!(got.chars().count(), 73, "72 + ellipsis, got {got:?}");2702 assert!(got.ends_with("…"), "visible truncation");2703 // Multi-byte chars count as chars, never split a boundary.2704 let uni = "é".repeat(100);2705 let got = truncate_msg(&uni);2706 assert_eq!(got.chars().count(), 73);2707 assert!(got.ends_with("…"));2708 }2709 2710 #[test]2711 fn parse_git_and_rfc3339_dates() {2712 assert_eq!(2713 parse_ts_secs("2026-09-10T07:43:55Z"),2714 parse_ts_secs("2026-09-10 07:43:55 +0000")2715 );2716 let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();2717 let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();2718 assert_eq!(utc, plus1, "offset is applied");2719 assert!(parse_ts_secs("not a date").is_none());2720 assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");2721 }2722 2723 #[test]2724 fn output_budget_reports_file_size_and_count() {2725 let dir = mktemp("budget");2726 std::fs::write(dir.join("small.txt"), "ok").unwrap();2727 std::fs::create_dir_all(dir.join("nested")).unwrap();2728 std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();2729 assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");2730 assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");2731 assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");2732 std::fs::remove_dir_all(&dir).unwrap();2733 }2734 2735 #[test]2736 fn hostile_dates_are_rejected() {2737 // Hostile `.forge` timestamps must not panic (f0rge #82).2738 assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());2739 assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());2740 assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());2741 assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());2742 assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());2743 assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());2744 assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());2745 assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());2746 assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());2747 assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());2748 }2749 2750 #[test]2751 fn relative_age_fixed_clock() {2752 // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.2753 let now = 1_786_000_000_i64;2754 assert_eq!(relative_age(now, now), "just now");2755 assert_eq!(relative_age(now, now - 5), "just now");2756 assert_eq!(relative_age(now, now - 30), "30s ago");2757 assert_eq!(relative_age(now, now - 5 * 60), "5m ago");2758 assert_eq!(relative_age(now, now - 3 * 3600), "3h ago");2759 assert_eq!(relative_age(now, now - 3 * 86_400), "3d ago");2760 assert_eq!(relative_age(now, now - 10 * 86_400), "10d ago");2761 assert_eq!(relative_age(now, now - 60 * 86_400), "2mo ago");2762 assert_eq!(relative_age(now, now - 400 * 86_400), "1y ago");2763 assert_eq!(relative_age(now, now + 9999), "just now", "future clamps");2764 }2765 2766 #[test]2767 fn language_math_exact_shares_and_tiny_grouping() {2768 // f0rge #33 rubric: known bytes -> exact %.2769 let files = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 300_u64)];2770 let stats = compute_languages(&files);2771 assert_eq!(stats.len(), 2);2772 assert_eq!(stats[0].lang, "Python");2773 assert_eq!(stats[0].bytes, 300);2774 assert!(2775 (stats[0].pct - 75.0).abs() < 1e-9,2776 "py 75%, got {}",2777 stats[0].pct2778 );2779 assert_eq!(stats[1].lang, "Rust");2780 assert!(2781 (stats[1].pct - 25.0).abs() < 1e-9,2782 "rs 25%, got {}",2783 stats[1].pct2784 );2785 // <1% merges into Other.2786 let mut files = vec![("big.rs".to_string(), 10_000_u64)];2787 files.push(("tiny.py".to_string(), 50_u64));2788 let stats = compute_languages(&files);2789 let langs: Vec<&str> = stats.iter().map(|s| s.lang.as_str()).collect();2790 assert!(2791 !langs.contains(&"Python"),2792 "tiny <1% grouped, got {langs:?}"2793 );2794 assert!(langs.contains(&"Other"), "other carries tiny share");2795 // Extension map covers the issue list; unknown -> Other.2796 for (path, want) in [2797 ("x.rs", "Rust"),2798 ("x.py", "Python"),2799 ("x.js", "JavaScript"),2800 ("x.ts", "TypeScript"),2801 ("x.go", "Go"),2802 ("x.sh", "Shell"),2803 ("x.css", "CSS"),2804 ("x.html", "HTML"),2805 ("x.md", "Markdown"),2806 ("x.toml", "TOML"),2807 ("x.yaml", "YAML"),2808 ("x.yml", "YAML"),2809 ("x.json", "JSON"),2810 ("x.c", "C"),2811 ("x.h", "C"),2812 ("x.txt", "Other"),2813 ("Dockerfile", "Other"),2814 ] {2815 assert_eq!(language_of(path), want, "map for {path}");2816 }2817 }2818 2819 #[test]2820 fn language_bar_hidden_without_code() {2821 // f0rge #33: bar hidden when no code; note shown instead. No JS.2822 let txt_only = vec![2823 ("notes.txt".to_string(), 24_u64),2824 ("data.bin".to_string(), 10_u64),2825 ];2826 let html = language_bar(&txt_only);2827 assert!(html.contains("No languages yet."), "note when no code");2828 assert!(!html.contains("lang-bar"), "bar hidden when no code");2829 assert!(!html.contains("<script"), "no JS in lang block");2830 // With code: stacked bar + legend with %.2831 let code = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 100_u64)];2832 let html = language_bar(&code);2833 assert!(html.contains("lang-bar"), "bar shown with code");2834 assert!(html.contains("lang-legend"), "legend shown");2835 assert!(html.contains("Rust"), "rust legend");2836 assert!(html.contains("50.0%"), "exact share in legend");2837 assert!(html.contains("background:#"), "Tokyo Night hues inline");2838 }2839 2840 #[test]2841 fn language_bar_shows_on_index_with_legend() {2842 // Integration: balanced code repo -> stacked bar + legend.2843 // (The 10-file fixture is 300KB of .txt, so its tiny .rs/.md2844 // shares correctly collapse into Other per the <1% rule.)2845 let dir = mktemp("lang-index");2846 git(&dir, &["init", "-b", "main"]);2847 git(&dir, &["config", "user.name", "hu13-test"]);2848 git(&dir, &["config", "user.email", "[email protected]"]);2849 git(&dir, &["config", "commit.gpgsign", "false"]);2850 std::fs::write(dir.join("a.rs"), "fn a() {}\n".repeat(20)).unwrap();2851 std::fs::write(dir.join("b.py"), "print(1)\n".repeat(20)).unwrap();2852 std::fs::write(dir.join("README.md"), "# hi\n").unwrap();2853 git(&dir, &["add", "-A"]);2854 git(&dir, &["commit", "-m", "lang mix"]);2855 let out = mktemp("out-lang-index");2856 run_build(&dir, &out, &opts()).expect("build");2857 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2858 assert!(index.contains("lang-bar"), "stacked bar on code tab");2859 assert!(index.contains("lang-legend"), "legend on code tab");2860 assert!(index.contains("Rust"), "rust in legend");2861 assert!(index.contains("Python"), "python in legend");2862 assert!(index.contains('%'), "legend carries %");2863 // The big-file fixture still shows a bar (100% Other), never empty.2864 let (_repo2, out2) = build_fixture();2865 let index2 = std::fs::read_to_string(out2.join("index.html")).expect("index2");2866 assert!(index2.contains("lang-bar"), "fixture bar present (Other)");2867 assert!(index2.contains("Other"), "tiny shares group into Other");2868 }2869 2870 #[test]2871 fn escaping_evil_filename_and_message() {2872 // f0rge #33 rubric: escaping everywhere (filename + message).2873 let dir = mktemp("evil-names");2874 git(&dir, &["init", "-b", "main"]);2875 git(&dir, &["config", "user.name", "hu13-test"]);2876 git(&dir, &["config", "user.email", "[email protected]"]);2877 git(&dir, &["config", "commit.gpgsign", "false"]);2878 std::fs::write(dir.join("README.md"), "# hi\n").unwrap();2879 std::fs::write(dir.join("evil<script>.txt"), "evil file\n").unwrap();2880 std::fs::write(dir.join("ok.rs"), "fn main() {}\n").unwrap();2881 git(&dir, &["add", "-A"]);2882 // Evil + over-long message (also exercises 72-char truncation).2883 let evil_msg = "<script>alert(1)</script> ".to_string() + &"A".repeat(100);2884 git(&dir, &["commit", "-m", &evil_msg]);2885 let out = mktemp("out-evil");2886 run_build(&dir, &out, &opts()).expect("build");2887 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2888 assert!(2889 !index.contains("<script>alert(1)</script>"),2890 "no raw script in TABLE"2891 );2892 assert!(index.contains("<script>"), "escaped message in TABLE");2893 assert!(!index.contains("evil<script>.txt"), "no raw filename");2894 assert!(index.contains("evil<script>.txt"), "escaped filename");2895 assert!(index.contains("…"), "long message truncated in TABLE");2896 // File page for the evil name is escaped too.2897 let evil_page = std::fs::read_to_string(out.join("files/evil<script>.txt.html"));2898 assert!(2899 evil_page.is_ok() || out.join("files").exists(),2900 "evil file page emitted"2901 );2902 if let Ok(html) = evil_page {2903 assert!(2904 !html.contains("<script>alert(1)</script>"),2905 "no raw on file page"2906 );2907 }2908 }2909 2910 #[test]2911 fn clone_details_collapsed_with_hint_and_contents() {2912 // f0rge #34 rubric: native <details>, closed by default, all2913 // three methods + sizes inside, branch/sha hint in summary.2914 let repo = fixture_repo();2915 let out = mktemp("out-details");2916 let mut o = opts();2917 o.clone_url_flag = Some("https://example.com/x.git".to_string());2918 run_build(&repo, &out, &o).expect("build");2919 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2920 assert!(index.contains("<details"), "details present");2921 assert!(index.contains("class=\"clonebox"), "clonebox class");2922 assert!(index.contains("clone-block"), "clone-block class kept");2923 assert!(index.contains("Clone & download"), "summary label");2924 assert!(index.contains("main"), "branch hint in summary");2925 // Closed by default: no `open` attr on the clone details.2926 let d0 = index2927 .find("<details class=\"clonebox")2928 .expect("details pos");2929 let d1 = index[d0..].find('>').expect("details end") + d0;2930 let tag = &index[d0..d1];2931 assert!(!tag.contains("open"), "closed by default, got {tag:?}");2932 // Contents intact: all three methods + sizes.2933 assert!(2934 index.contains("git clone https://example.com/x.git"),2935 "url method"2936 );2937 assert!(index.contains("git clone repo.bundle"), "bundle method");2938 assert!(index.contains("tar -xzf repo.tar.gz"), "tarball method");2939 assert!(index.contains("repo.bundle"), "bundle size/link");2940 assert!(index.contains("repo.tar.gz"), "tarball size/link");2941 // Keyboard accessible native element, zero JS.2942 assert!(!index.contains("<script"), "zero JS for clone block");2943 }2944 2945 #[test]2946 fn clone_block_with_url_leads_with_git_clone() {2947 let repo = fixture_repo();2948 let out = mktemp("out-clone-url");2949 let mut o = opts();2950 o.clone_url_flag = Some("https://example.com/x.git".to_string());2951 run_build(&repo, &out, &o).expect("build");2952 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2953 assert!(index.contains("clone-block"), "clone block");2954 assert!(2955 index.contains("git clone https://example.com/x.git"),2956 "url clone cmd"2957 );2958 assert!(index.contains("git clone repo.bundle"), "bundle cmd");2959 assert!(index.contains("tar -xzf repo.tar.gz"), "tarball cmd");2960 assert!(index.contains("repo.bundle"), "bundle link");2961 assert!(index.contains("repo.tar.gz"), "tarball link");2962 let url_pos = index2963 .find("git clone https://example.com/x.git")2964 .expect("url pos");2965 let bundle_pos = index.find("git clone repo.bundle").expect("bundle pos");2966 assert!(url_pos < bundle_pos, "url-first with clone url");2967 }2968 2969 #[test]2970 fn clone_block_bundle_first_without_url() {2971 // Fixture has no origin remote: bundle-first presentation.2972 let (_repo, out) = build_fixture();2973 let index = std::fs::read_to_string(out.join("index.html")).expect("index");2974 assert!(index.contains("clone-block"), "clone block");2975 assert!(index.contains("git clone repo.bundle"), "bundle-first cmd");2976 assert!(!index.contains("git clone http"), "no url cmd");2977 let bundle_pos = index.find("repo.bundle").expect("bundle pos");2978 let tar_pos = index.find("repo.tar.gz").expect("tar pos");2979 assert!(bundle_pos < tar_pos, "bundle before tarball");2980 }2981 2982 #[test]2983 fn code_head_ci_badge_green_red() {2984 // f0rge #43: code repo-head CI badge is green/red via2985 // `.badge.pass` / `.badge.fail` (running/cancelled keep tones).2986 for verdict in ["pass", "fail"] {2987 let repo = fixture_repo();2988 let run_dir = repo.join(".forge").join("ci/runs/2026-09-10T00-00-00Z");2989 std::fs::create_dir_all(&run_dir).unwrap();2990 std::fs::write(2991 run_dir.join("summary.md"),2992 format!(2993 "---\nrun: 2026-09-10T00-00-00Z\nverdict: {verdict}\ncreated: 2026-09-10T00:00:00Z\n---\nlog\n"2994 ),2995 )2996 .unwrap();2997 git(&repo, &["add", "-A"]);2998 git(&repo, &["commit", "-m", &format!("ci {verdict}")]);2999 let out = mktemp(&format!("out-ci-{verdict}"));3000 run_build(&repo, &out, &opts()).expect("build");3001 let index = std::fs::read_to_string(out.join("index.html")).expect("index");3002 let summ = index3003 .find("<summary><span class=\"cb-seg\">Clone")3004 .expect("summary");3005 let send = index[summ..].find("</summary>").expect("summary end") + summ;3006 let summary = &index[summ..send];3007 assert!(3008 summary.contains(&format!("badge {verdict}")),3009 "clone summary carries badge {verdict}"3010 );3011 assert!(!index.contains("repo-meta"), "no dupe meta");3012 assert!(!index.contains("<p>CI:"), "no dupe CI line");3013 }3014 }3015 3016 #[test]3017 fn clone_summary_followed_by_body_no_gap() {3018 // f0rge #44: summary immediately followed by content wrapper —3019 // no empty <p>, no margin-collapse gap when collapsed.3020 let (_repo, out) = build_fixture();3021 let index = std::fs::read_to_string(out.join("index.html")).expect("index");3022 assert!(3023 index.contains("</summary>\n<div class=\"clone-body\">"),3024 "summary immediately followed by clone-body wrapper"3025 );3026 let s = index.find("</summary>").expect("summary end");3027 let after = &index[s..s + 200];3028 assert!(3029 !after.contains("<p></p>"),3030 "no empty paragraph after summary"3031 );3032 assert!(3033 !after.contains("<p> </p>"),3034 "no blank paragraph after summary"3035 );3036 // Wrapper closes before the details closes (structure intact).3037 assert!(3038 index.contains("</div>\n</details>"),3039 "clone-body closes inside details"3040 );3041 }3042 3043 #[test]3044 fn is_binary_sniffs_first_8kib() {3045 // f0rge #37: NUL in the first 8KiB means binary for math.3046 assert!(!is_binary(&[]), "empty is text");3047 assert!(!is_binary(b"fn main() {}\n"), "plain text");3048 assert!(is_binary(b"ab\x00cd"), "early NUL");3049 assert!(!is_binary(&[b'a'; 100]), "no NUL anywhere");3050 // NUL at the last sniffed byte still counts; just past it does not.3051 let mut edge_in = vec![b'a'; BINARY_SNIFF_BYTES];3052 edge_in[BINARY_SNIFF_BYTES - 1] = 0;3053 assert!(is_binary(&edge_in), "NUL at byte 8191");3054 let mut edge_out = vec![b'a'; BINARY_SNIFF_BYTES + 1];3055 edge_out[BINARY_SNIFF_BYTES] = 0;3056 assert!(!is_binary(&edge_out), "NUL past 8KiB ignored");3057 assert_eq!(BINARY_SNIFF_BYTES, 8 * 1024, "8KiB sniff window");3058 }3059 3060 #[test]3061 fn lang_rows_skips_ignored_and_binaries() {3062 // Pure unit: ignored paths + NUL blobs drop out, real text3063 // (even unmapped extensions) stays for `Other`.3064 use std::collections::{BTreeMap, HashSet};3065 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();3066 blobs.insert("a.rs".to_string(), b"fn a() {}\n".to_vec());3067 blobs.insert("notes.txt".to_string(), b"hello\n".to_vec());3068 blobs.insert("blob.bin".to_string(), b"ab\x00cd".to_vec());3069 blobs.insert("ignored/s.rs".to_string(), b"fn s() {}\n".to_vec());3070 let ignored: HashSet<String> = ["ignored/s.rs".to_string()].into_iter().collect();3071 let mut rows = lang_rows(&blobs, &ignored);3072 rows.sort();3073 let paths: Vec<&str> = rows.iter().map(|(p, _)| p.as_str()).collect();3074 assert_eq!(paths, vec!["a.rs", "notes.txt"], "only text kept");3075 // Sizes are exact byte lengths.3076 let size = |name: &str| rows.iter().find(|(p, _)| p == name).unwrap().1;3077 assert_eq!(size("a.rs"), b"fn a() {}\n".len() as u64);3078 assert_eq!(size("notes.txt"), b"hello\n".len() as u64);3079 }3080 3081 #[test]3082 fn lang_rows_skips_forge_metadata() {3083 // f0rge #37 follow-up: .forge/ (issues, PR diffs, runs) is not3084 // project code and stays out of the mix entirely.3085 use std::collections::{BTreeMap, HashSet};3086 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();3087 blobs.insert("src/main.rs".to_string(), b"fn main() {}\n".to_vec());3088 blobs.insert(3089 ".forge/prs/uuid/pr.md".to_string(),3090 b"---\nid: x\n---\nbody\n".to_vec(),3091 );3092 blobs.insert(3093 ".forge/prs/uuid/patch.diff".to_string(),3094 b"diff --git a\n+line\n".to_vec(),3095 );3096 let rows = lang_rows(&blobs, &HashSet::new());3097 let paths: Vec<&str> = rows.iter().map(|(p, _)| p.as_str()).collect();3098 assert_eq!(paths, vec!["src/main.rs"], "forge metadata excluded");3099 }3100 3101 #[test]3102 fn language_math_excludes_gitignored_and_binaries() {3103 // f0rge #37 rubric: fixture with a gitignored dir + a binary +3104 // code files; ignored/binary bytes are excluded and the3105 // legend percentages sum to counted text only.3106 let dir = mktemp("lang-ignore");3107 git(&dir, &["init", "-b", "main"]);3108 git(&dir, &["config", "user.name", "hu13-test"]);3109 git(&dir, &["config", "user.email", "[email protected]"]);3110 git(&dir, &["config", "commit.gpgsign", "false"]);3111 std::fs::write(dir.join(".gitignore"), "ignored/\n*.log\n").unwrap();3112 std::fs::create_dir_all(dir.join("src")).unwrap();3113 let rs = "fn a() {}\n".repeat(20);3114 let py = "print(1)\n".repeat(20);3115 std::fs::write(dir.join("src").join("a.rs"), &rs).unwrap();3116 std::fs::write(dir.join("src").join("b.py"), &py).unwrap();3117 std::fs::write(dir.join("notes.txt"), "plain notes\n").unwrap();3118 std::fs::create_dir_all(dir.join("assets")).unwrap();3119 let mut bin: Vec<u8> = (0u8..=255).collect();3120 bin.extend_from_slice(b"\x00\x01binary tail");3121 std::fs::write(dir.join("assets").join("blob.bin"), &bin).unwrap();3122 std::fs::create_dir_all(dir.join("ignored")).unwrap();3123 // Large ignored code file: would dominate the bar if counted.3124 std::fs::write(3125 dir.join("ignored").join("secret.rs"),3126 "fn s() {}\n".repeat(500),3127 )3128 .unwrap();3129 std::fs::write(dir.join("debug.log"), "log line\n".repeat(500)).unwrap();3130 git(&dir, &["add", "-A"]);3131 git(&dir, &["commit", "-m", "base"]);3132 // Force-add so the ignored paths are tracked (hu13 lists tracked3133 // files); the language filter must still drop them via --no-index.3134 git(&dir, &["add", "-f", "ignored/secret.rs", "debug.log"]);3135 git(&dir, &["commit", "-m", "forced ignored"]);3136 3137 // gitx layer: ignored subset is exactly the forced paths.3138 let tracked = vec![3139 "src/a.rs".to_string(),3140 "src/b.py".to_string(),3141 "notes.txt".to_string(),3142 "assets/blob.bin".to_string(),3143 "ignored/secret.rs".to_string(),3144 "debug.log".to_string(),3145 ];3146 let ignored = gitx::ignored_paths(&dir, &tracked).expect("ignored_paths");3147 assert!(ignored.contains("ignored/secret.rs"), "dir rule");3148 assert!(ignored.contains("debug.log"), "glob rule");3149 assert!(!ignored.contains("src/a.rs"), "code kept");3150 assert!(3151 !ignored.contains("assets/blob.bin"),3152 "binary kept here (binary rule drops it)"3153 );3154 3155 // Build layer: filtered rows are text only.3156 let out = mktemp("out-lang-ignore");3157 run_build(&dir, &out, &opts()).expect("build");3158 let index = std::fs::read_to_string(out.join("index.html")).expect("index");3159 let sec_start = index3160 .find("<section class=\"langs\"")3161 .expect("langs section");3162 let sec_end = index[sec_start..].find("</section>").expect("langs end") + sec_start;3163 let langs = &index[sec_start..sec_end];3164 // Ignored + binary paths never appear in the math section3165 // (they still have tree rows + raw downloads elsewhere).3166 for needle in ["secret.rs", "debug.log", "blob.bin"] {3167 assert!(3168 !langs.contains(needle),3169 "{needle} excluded from language math"3170 );3171 }3172 assert!(langs.contains("Rust"), "rust counted");3173 assert!(langs.contains("Python"), "python counted");3174 // Percentages in the legend sum to ~100 (text-only total).3175 // Parse only the legend list (the bar spans carry duplicate3176 // `width:x%` values that must not be double-counted).3177 let ul_start = langs3178 .find("<ul class=\"lang-legend\">")3179 .expect("legend list");3180 let legend = &langs[ul_start..];3181 let mut pct_sum = 0.0_f64;3182 let mut count = 0_usize;3183 let mut rest = legend;3184 while let Some(i) = rest.find('%') {3185 let start = rest[..i].rfind(['>', ' ']).map(|p| p + 1).unwrap_or(0);3186 let num: f64 = rest[start..i].trim().parse().unwrap_or(f64::NAN);3187 assert!(3188 num.is_finite(),3189 "legend % parses, got {:?}",3190 &rest[start..i]3191 );3192 pct_sum += num;3193 count += 1;3194 rest = &rest[i + 1..];3195 }3196 assert!(count >= 2, "legend has shares, got {langs:?}");3197 assert!(3198 (pct_sum - 100.0).abs() < 0.2,3199 "shares sum to 100, got {pct_sum} in {langs:?}"3200 );3201 // Binary is still downloadable with a placeholder page.3202 let bin_page =3203 std::fs::read_to_string(out.join("files/assets/blob.bin.html")).expect("bin page");3204 assert!(bin_page.contains("placeholder"), "binary placeholder kept");3205 assert!(3206 out.join("raw/assets/blob.bin").exists(),3207 "binary downloadable"3208 );3209 assert!(3210 out.join("raw/ignored/secret.rs").exists(),3211 "ignored still in raw"3212 );3213 }3214 3215 #[test]3216 fn finish_page_heads_and_clone_summary() {3217 // m6 finish (f0rge #50 + #53): one head pattern everywhere, branch+CI3218 // only in the clone summary, no dupe line.3219 let (_repo, out) = build_fixture();3220 let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);3221 let index = read("index.html");3222 // Code tab: repo-head with h1, summary carries branch @ sha + CI.3223 let head_pos = index.find("<div class=\"repo-head\">").expect("repo-head");3224 assert!(index[head_pos..].contains("<h1>"), "code h1 in repo-head");3225 let s0 = index3226 .find("<summary><span class=\"cb-seg\">Clone")3227 .expect("summary");3228 let s1 = index[s0..].find("</summary>").expect("summary end") + s0;3229 let summary = &index[s0..s1];3230 assert!(summary.contains("Clone & download"), "summary label");3231 assert!(summary.contains("main"), "branch in summary");3232 assert!(summary.contains("CI"), "CI in summary");3233 assert!(summary.contains("badge"), "CI badge in summary");3234 assert!(!index.contains("repo-meta"), "no dupe repo-meta");3235 assert!(!index.contains("<p>CI:"), "no dupe CI line");3236 // Branch appears in summary; outside details it is not repeated as3237 // a meta line (description/counts carry no branch text here).3238 let details_pos = index.find("<details class=\"clonebox").expect("details");3239 assert!(3240 s0 > head_pos && s0 >= details_pos,3241 "summary inside code head"3242 );3243 // Every index/Form page opens with the same page-head h1 row.3244 for (rel, h1) in [3245 ("issues/index.html", "<h1>Issues</h1>"),3246 ("pulls/index.html", "<h1>Pulls</h1>"),3247 ("commits.html", "<h1>Commits</h1>"),3248 ("tasks/index.html", "<h1>Tasks</h1>"),3249 ("ci/index.html", "<h1>CI</h1>"),3250 ("releases/index.html", "<h1>Releases</h1>"),3251 ("wiki/index.html", "<h1>Wiki</h1>"),3252 ("new-issue.html", "<h1>Suggest an issue</h1>"),3253 ("new-pr.html", "<h1>Suggest a change</h1>"),3254 ] {3255 let html = read(rel);3256 let hpos = html.find("<div class=\"page-head\">").expect(rel);3257 let h1pos = html.find(h1).unwrap_or_else(|| panic!("{rel} missing h1"));3258 let close = html[hpos..].find("</div>").expect("close") + hpos;3259 assert!(h1pos > hpos && h1pos < close, "{rel}: h1 inside page-head");3260 }3261 // Agents guide renders its markdown h1; spacing comes from the3262 // shared `main>h1:first-child` gap-var rule (CSS assert in layout).3263 let agents = read("agents.html");3264 assert!(agents.contains("<h1"), "agents h1");3265 }3266 }