1 From 729259a60df1a1524ab2fa9079f54e9bcc1d2da9 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:01:52 +01004 Subject: [PATCH] m6/code-conv: breadcrumbs, L-anchors, meta bar, clone block5 (149 tests)6 7 ---8 src/build.rs | 368 ++++++++++++++++++++++++++++++++++++++++-------9 static/style.css | 16 +++10 2 files changed, 335 insertions(+), 49 deletions(-)11 12 diff --git a/src/build.rs b/src/build.rs13 index 7eb9b0d..5083fa7 10064414 --- a/src/build.rs15 +++ b/src/build.rs16 @@ -183,17 +183,165 @@ fn href_esc(path: &str) -> String {17 esc(path)18 }19 20 -/// Render one text file body: escaped `<pre>` with line numbers, wrapped in21 -/// a horizontal-scroll container so long lines never break the 360px layout.22 +/// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars23 +/// and listings (m6, f0rge issue #20).24 +#[must_use]25 +pub fn human_size(n: u64) -> String {26 + if n < 1024 {27 + format!("{n} B")28 + } else if n < 1024 * 1024 {29 + format!("{:.1} KiB", n as f64 / 1024.0)30 + } else {31 + format!("{:.1} MiB", n as f64 / 1024.0 / 1024.0)32 + }33 +}34 +35 +/// Breadcrumb trail for a repo-relative `path`: `root` plus one cumulative36 +/// link per segment (`/files/<acc>.html`), so every segment is clickable37 +/// (m6, f0rge issue #20). No JS; the nav element labels it for AT.38 +#[must_use]39 +pub fn breadcrumb(path: &str) -> String {40 + let mut out =41 + String::from("<nav class=\"crumbs\" aria-label=\"breadcrumb\"><a href=\"/\">root</a>");42 + if !path.is_empty() {43 + let mut acc = String::new();44 + for seg in path.split('/') {45 + if !acc.is_empty() {46 + acc.push('/');47 + }48 + acc.push_str(seg);49 + out.push_str(&format!(50 + " / <a href=\"/files/{}.html\">{}</a>",51 + href_esc(&acc),52 + esc(seg)53 + ));54 + }55 + }56 + out.push_str("</nav>\n");57 + out58 +}59 +60 +/// File meta bar: full path text (selectable, no-JS copy affordance),61 +/// humanized size, optional line count, and a Raw download link62 +/// (m6, f0rge issue #20).63 +#[must_use]64 +pub fn file_meta_bar(path: &str, size_bytes: u64, lines: Option<usize>) -> String {65 + let mut out = format!(66 + "<div class=\"file-meta\"><code class=\"file-path\">{}</code>\n\67 + <span class=\"file-size\">{}</span>",68 + esc(path),69 + human_size(size_bytes)70 + );71 + if let Some(n) = lines {72 + out.push_str(&format!("\n<span class=\"file-lines\">{n} lines</span>"));73 + }74 + out.push_str(&format!(75 + "\n<a class=\"file-raw\" href=\"/raw/{}\">Raw</a></div>\n",76 + href_esc(path)77 + ));78 + out79 +}80 +81 +/// Content sha of a snapshot artifact via `git hash-object` (no new deps;82 +/// git is already required to build). `None` when git cannot hash.83 +#[must_use]84 +pub fn file_hash(path: &Path) -> Option<String> {85 + let out = std::process::Command::new("git")86 + .arg("hash-object")87 + .arg(path)88 + .output()89 + .ok()?;90 + if out.status.success() {91 + let s = String::from_utf8_lossy(&out.stdout).trim().to_string();92 + if !s.is_empty() {93 + return Some(s);94 + }95 + }96 + None97 +}98 +99 +/// Prominent Clone & download section for the code tab (m6, f0rge100 +/// issue #20): stacked no-JS sections with selectable `<pre>` commands —101 +/// `git clone <url>` first when a clone URL is configured, otherwise102 +/// bundle-first — plus the bundle/tarball links with sizes and shas.103 +#[must_use]104 +pub fn clone_block(105 + clone_url: Option<&str>,106 + branch: &str,107 + bundle_size: u64,108 + bundle_sha: Option<&str>,109 + tarball_size: u64,110 + tarball_sha: Option<&str>,111 +) -> String {112 + let bundle_link = match bundle_sha {113 + Some(s) => format!(114 + "<a href=\"/repo.bundle\">repo.bundle</a> ({}; sha <code>{}</code>)",115 + human_size(bundle_size),116 + esc(s)117 + ),118 + None => format!(119 + "<a href=\"/repo.bundle\">repo.bundle</a> ({})",120 + human_size(bundle_size)121 + ),122 + };123 + let tarball_link = match tarball_sha {124 + Some(s) => format!(125 + "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({}; sha <code>{}</code>)",126 + human_size(tarball_size),127 + esc(s)128 + ),129 + None => format!(130 + "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({})",131 + human_size(tarball_size)132 + ),133 + };134 + let mut out = String::from(135 + "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\136 + <h2>Clone & download</h2>\n",137 + );138 + if let Some(url) = clone_url {139 + out.push_str("<h3>Clone with git</h3>\n");140 + out.push_str(&format!(141 + "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n",142 + esc(url)143 + ));144 + }145 + out.push_str("<h3>Clone the bundle</h3>\n");146 + out.push_str(&format!(147 + "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",148 + esc(branch)149 + ));150 + out.push_str("<h3>Download the tarball</h3>\n");151 + out.push_str(&format!(152 + "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"153 + ));154 + if clone_url.is_none() {155 + out.push_str(156 + "<p class=\"muted\">No clone URL is configured for this mirror — \157 + the bundle and tarball above carry the full snapshot.</p>\n",158 + );159 + }160 + out.push_str("</section>\n");161 + out162 +}163 +164 +/// Render one text file body: escaped `<pre>` with clickable per-line165 +/// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container166 +/// so long lines never break the 360px layout.167 fn render_text_pre(display: &str) -> String {168 let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");169 if display.is_empty() {170 - out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");171 + out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>\n");172 } else {173 for (i, line) in display.split('\n').enumerate() {174 - out.push_str("<span class=\"line\"><span class=\"ln\">");175 - out.push_str(&(i + 1).to_string());176 - out.push_str("</span> ");177 + let n = i + 1;178 + out.push_str("<span class=\"line\" id=\"L");179 + out.push_str(&n.to_string());180 + out.push_str("\"><a class=\"ln\" href=\"#L");181 + out.push_str(&n.to_string());182 + out.push_str("\">");183 + out.push_str(&n.to_string());184 + out.push_str("</a> ");185 out.push_str(&esc(line));186 out.push_str("</span>\n");187 }188 @@ -308,20 +456,24 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu189 Some(i) => dir[..i].to_string(),190 None => String::new(),191 };192 - let parent_link = if parent.is_empty() {193 - "<a href=\"/\">.. (root)</a>".to_string()194 + let parent_href = if parent.is_empty() {195 + "/".to_string()196 } else {197 - format!(198 - "<a href=\"/files/{}.html\">.. ({})</a>",199 - href_esc(&parent),200 - esc(&parent)201 - )202 + format!("/files/{}.html", href_esc(&parent))203 };204 - let mut body = format!(205 - "<h1>dir: {}</h1>\n<p>{} · <a href=\"/\">code</a></p>\n<ul class=\"tree\">\n",206 + let parent_label = if parent.is_empty() {207 + ".. (root)".to_string()208 + } else {209 + format!(".. ({})", esc(&parent))210 + };211 + let mut body = breadcrumb(dir);212 + body.push_str(&format!(213 + "<h1>dir: {}</h1>\n<ul class=\"tree\">\n",214 esc(dir),215 - parent_link216 - );217 + ));218 + body.push_str(&format!(219 + "<li class=\"parent\"><a href=\"{parent_href}\">{parent_label}</a></li>\n"220 + ));221 for e in &entries {222 let full = format!("{dir}/{}", e.name);223 if e.is_dir {224 @@ -331,8 +483,14 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu225 esc(&e.name)226 ));227 } else {228 + let size = blobs229 + .get(&full)230 + .map(|b| human_size(b.len() as u64))231 + .unwrap_or_default();232 body.push_str(&format!(233 - "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",234 + "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \235 + <span class=\"file-size\">{size}</span> \236 + (<a href=\"/raw/{}\">raw</a>)</li>\n",237 href_esc(&full),238 esc(&e.name),239 href_esc(&full)240 @@ -352,26 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu241 242 for f in &files {243 let bytes = blobs.get(f).cloned().unwrap_or_default();244 - let parent = match f.rfind('/') {245 - Some(i) => f[..i].to_string(),246 - None => String::new(),247 - };248 - let parent_link = if parent.is_empty() {249 - "<a href=\"/\">.. (root)</a>".to_string()250 - } else {251 - format!(252 - "<a href=\"/files/{}.html\">.. ({})</a>",253 - href_esc(&parent),254 - esc(&parent)255 - )256 - };257 - let mut body = format!(258 - "<h1>{}</h1>\n<p>{} · <a href=\"/raw/{}\">raw</a> · <a href=\"/\">code</a></p>\n",259 - esc(f),260 - parent_link,261 - href_esc(f)262 - );263 + let mut body = breadcrumb(f);264 + body.push_str(&format!("<h1>{}</h1>\n", esc(f)));265 if is_placeholder(&bytes) {266 + body.push_str(&file_meta_bar(f, bytes.len() as u64, None));267 let kind = if bytes.len() >= PREVIEW_MAX_BYTES {268 format!("too large to preview ({} bytes)", bytes.len())269 } else {270 @@ -384,6 +526,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu271 ));272 } else if is_markdown(f) {273 let display = String::from_utf8_lossy(&bytes).into_owned();274 + body.push_str(&file_meta_bar(275 + f,276 + bytes.len() as u64,277 + Some(display.lines().count()),278 + ));279 body.push_str(&markdown_to_html(&display));280 body.push_str(&format!(281 "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",282 @@ -391,6 +538,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu283 ));284 } else {285 let display = String::from_utf8_lossy(&bytes).into_owned();286 + body.push_str(&file_meta_bar(287 + f,288 + bytes.len() as u64,289 + Some(display.lines().count()),290 + ));291 body.push_str(&render_text_pre(&display));292 }293 let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);294 @@ -440,12 +592,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu295 if let Some(d) = &description {296 body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));297 }298 - if let Some(url) = &clone_url {299 - body.push_str(&format!(300 - "<p class=\"clone\">Clone: <code>git clone {}</code></p>\n",301 - esc(url)302 - ));303 - }304 body.push_str(&format!(305 "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",306 esc(&branch),307 @@ -465,9 +611,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu308 "<p>CI: {}</p>\n",309 forge::verdict_badge(forge_data.ci.latest_verdict()),310 ));311 - body.push_str(&format!(312 - "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \313 - <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"314 + body.push_str(&clone_block(315 + clone_url.as_deref(),316 + &branch,317 + bundle_size,318 + file_hash(&snap.bundle).as_deref(),319 + tarball_size,320 + file_hash(&snap.tarball).as_deref(),321 ));322 323 body.push_str("</div>\n");324 @@ -481,8 +631,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu325 esc(&e.name)326 ));327 } else {328 + let size = blobs329 + .get(&e.name)330 + .map(|b| human_size(b.len() as u64))331 + .unwrap_or_default();332 body.push_str(&format!(333 - "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",334 + "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \335 + <span class=\"file-size\">{size}</span> (<a href=\"/raw/{}\">raw</a>)</li>\n",336 href_esc(&e.name),337 esc(&e.name),338 href_esc(&e.name)339 @@ -661,11 +816,20 @@ mod tests {340 assert!(index.contains("tasks (0)"), "task counts as 0");341 assert!(index.contains("CI:"), "CI verdict shown");342 assert!(index.contains("badge"), "CI verdict badge");343 - // Snapshot links with sizes.344 + // Clone block (bundle-first: no origin remote is configured).345 + assert!(index.contains("clone-block"), "clone block present");346 assert!(index.contains("repo.bundle"), "bundle linked");347 assert!(index.contains("repo.tar.gz"), "tarball linked");348 + assert!(index.contains("git clone repo.bundle"), "bundle clone docs");349 + assert!(350 + index.contains("tar -xzf repo.tar.gz"),351 + "tarball extract docs"352 + );353 // No clone URL line when no origin remote is configured.354 - assert!(!index.contains("git clone"), "no URL line without remote");355 + assert!(356 + !index.contains("git clone http"),357 + "no URL line without remote"358 + );359 }360 361 #[test]362 @@ -827,4 +991,110 @@ mod tests {363 "legit markdown intact"364 );365 }366 +367 + #[test]368 + fn human_size_formats() {369 + assert_eq!(human_size(0), "0 B");370 + assert_eq!(human_size(24), "24 B");371 + assert_eq!(human_size(1023), "1023 B");372 + assert_eq!(human_size(2048), "2.0 KiB");373 + assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB");374 + }375 +376 + #[test]377 + fn breadcrumb_links_each_segment() {378 + let (_repo, out) = build_fixture();379 + // File page: root plus every segment linked.380 + let html =381 + std::fs::read_to_string(out.join("files/sub/nested.txt.html")).expect("file page");382 + assert!(html.contains("<a href=\"/\">root</a>"), "root crumb");383 + assert!(384 + html.contains("<a href=\"/files/sub.html\">sub</a>"),385 + "dir segment linked"386 + );387 + assert!(388 + html.contains("<a href=\"/files/sub/nested.txt.html\">nested.txt</a>"),389 + "file segment linked"390 + );391 + // Dir page carries a breadcrumb too.392 + let dir = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");393 + assert!(dir.contains("<a href=\"/\">root</a>"), "dir root crumb");394 + assert!(395 + dir.contains("<a href=\"/files/sub.html\">sub</a>"),396 + "dir self crumb"397 + );398 + }399 +400 + #[test]401 + fn line_numbers_are_shareable_anchors() {402 + let (_repo, out) = build_fixture();403 + let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");404 + assert!(html.contains("id=\"L1\""), "line 1 anchor");405 + assert!(html.contains("href=\"#L1\""), "line 1 link");406 + assert!(html.contains("id=\"L2\""), "line 2 anchor");407 + assert!(html.contains("href=\"#L2\""), "line 2 link");408 + }409 +410 + #[test]411 + fn file_meta_bar_has_size_lines_raw_and_path() {412 + let (_repo, out) = build_fixture();413 + let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");414 + assert!(html.contains("file-meta"), "meta bar");415 + assert!(html.contains("notes.txt"), "path text");416 + // "plain notes\nsecond line\n" is 24 bytes in 2 lines.417 + assert!(html.contains("24 B"), "humanized size");418 + assert!(html.contains("2 lines"), "line count");419 + assert!(html.contains("/raw/notes.txt"), "raw button");420 + }421 +422 + #[test]423 + fn dir_page_has_parent_row_and_sizes() {424 + let (_repo, out) = build_fixture();425 + let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");426 + assert!(427 + html.contains("<a href=\"/\">.. (root)</a>"),428 + "parent row to root"429 + );430 + assert!(html.contains("nested.txt"), "lists file");431 + assert!(html.contains("/raw/sub/nested.txt"), "raw link");432 + // "nested\n" is 7 bytes.433 + assert!(html.contains("7 B"), "file size shown");434 + }435 +436 + #[test]437 + fn clone_block_with_url_leads_with_git_clone() {438 + let repo = fixture_repo();439 + let out = mktemp("out-clone-url");440 + let mut o = opts();441 + o.clone_url_flag = Some("https://example.com/x.git".to_string());442 + run_build(&repo, &out, &o).expect("build");443 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");444 + assert!(index.contains("clone-block"), "clone block");445 + assert!(446 + index.contains("git clone https://example.com/x.git"),447 + "url clone cmd"448 + );449 + assert!(index.contains("git clone repo.bundle"), "bundle cmd");450 + assert!(index.contains("tar -xzf repo.tar.gz"), "tarball cmd");451 + assert!(index.contains("repo.bundle"), "bundle link");452 + assert!(index.contains("repo.tar.gz"), "tarball link");453 + let url_pos = index454 + .find("git clone https://example.com/x.git")455 + .expect("url pos");456 + let bundle_pos = index.find("git clone repo.bundle").expect("bundle pos");457 + assert!(url_pos < bundle_pos, "url-first with clone url");458 + }459 +460 + #[test]461 + fn clone_block_bundle_first_without_url() {462 + // Fixture has no origin remote: bundle-first presentation.463 + let (_repo, out) = build_fixture();464 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");465 + assert!(index.contains("clone-block"), "clone block");466 + assert!(index.contains("git clone repo.bundle"), "bundle-first cmd");467 + assert!(!index.contains("git clone http"), "no url cmd");468 + let bundle_pos = index.find("repo.bundle").expect("bundle pos");469 + let tar_pos = index.find("repo.tar.gz").expect("tar pos");470 + assert!(bundle_pos < tar_pos, "bundle before tarball");471 + }472 }473 diff --git a/static/style.css b/static/style.css474 index 8a0e3b2..1c4a0fc 100644475 --- a/static/style.css476 +++ b/static/style.css477 @@ -124,3 +124,19 @@ footer.site-footer .wrap{padding:1rem}478 @media (min-width:1100px){479 .wrap{max-width:72rem;padding:0 1.5rem}480 }481 +/* Code conventions (m6, f0rge #20): breadcrumbs, file meta, line anchors, clone block. */482 +.crumbs{margin:.5rem 0;font-size:.95rem}483 +.crumbs a{color:var(--cyan);text-decoration:none}484 +.crumbs a:hover,.crumbs a:focus{text-decoration:underline}485 +.file-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;margin:.5rem 0;padding:.5rem .75rem;background:var(--bg-alt);border:1px solid var(--border)}486 +.file-path{overflow-wrap:anywhere;word-break:break-all}487 +.file-size,.file-lines{color:var(--muted);white-space:nowrap}488 +.file-raw{display:inline-flex;align-items:center;min-height:40px}489 +.line:target{background:var(--bg-th)}490 +.ln{color:var(--muted);text-decoration:none}491 +a.ln:hover,a.ln:focus{text-decoration:underline}492 +.clone-block{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}493 +.clone-block h2{margin:.75rem 0 .25rem}494 +.clone-block h3{margin:1rem 0 .25rem}495 +.clone-block p{margin:.5rem 0}496 +.clone-cmd code{user-select:all;overflow-wrap:anywhere;word-break:break-all}497 -- 498 2.55.0499