#21 m6/code-conv
Patch
src/build.rs+319 −49--- a/src/build.rs+++ b/src/build.rs@@ -183,17 +183,165 @@ fn href_esc(path: &str) -> String {183183 esc(path)184184 }185185 186-/// Render one text file body: escaped `<pre>` with line numbers, wrapped in187-/// a horizontal-scroll container so long lines never break the 360px layout.186+/// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars187+/// and listings (m6, f0rge issue #20).188+#[must_use]189+pub fn human_size(n: u64) -> String {190+ if n < 1024 {191+ format!("{n} B")192+ } else if n < 1024 * 1024 {193+ format!("{:.1} KiB", n as f64 / 1024.0)194+ } else {195+ format!("{:.1} MiB", n as f64 / 1024.0 / 1024.0)196+ }197+}198+199+/// Breadcrumb trail for a repo-relative `path`: `root` plus one cumulative200+/// link per segment (`/files/<acc>.html`), so every segment is clickable201+/// (m6, f0rge issue #20). No JS; the nav element labels it for AT.202+#[must_use]203+pub fn breadcrumb(path: &str) -> String {204+ let mut out =205+ String::from("<nav class=\"crumbs\" aria-label=\"breadcrumb\"><a href=\"/\">root</a>");206+ if !path.is_empty() {207+ let mut acc = String::new();208+ for seg in path.split('/') {209+ if !acc.is_empty() {210+ acc.push('/');211+ }212+ acc.push_str(seg);213+ out.push_str(&format!(214+ " / <a href=\"/files/{}.html\">{}</a>",215+ href_esc(&acc),216+ esc(seg)217+ ));218+ }219+ }220+ out.push_str("</nav>\n");221+ out222+}223+224+/// File meta bar: full path text (selectable, no-JS copy affordance),225+/// humanized size, optional line count, and a Raw download link226+/// (m6, f0rge issue #20).227+#[must_use]228+pub fn file_meta_bar(path: &str, size_bytes: u64, lines: Option<usize>) -> String {229+ let mut out = format!(230+ "<div class=\"file-meta\"><code class=\"file-path\">{}</code>\n\231+ <span class=\"file-size\">{}</span>",232+ esc(path),233+ human_size(size_bytes)234+ );235+ if let Some(n) = lines {236+ out.push_str(&format!("\n<span class=\"file-lines\">{n} lines</span>"));237+ }238+ out.push_str(&format!(239+ "\n<a class=\"file-raw\" href=\"/raw/{}\">Raw</a></div>\n",240+ href_esc(path)241+ ));242+ out243+}244+245+/// Content sha of a snapshot artifact via `git hash-object` (no new deps;246+/// git is already required to build). `None` when git cannot hash.247+#[must_use]248+pub fn file_hash(path: &Path) -> Option<String> {249+ let out = std::process::Command::new("git")250+ .arg("hash-object")251+ .arg(path)252+ .output()253+ .ok()?;254+ if out.status.success() {255+ let s = String::from_utf8_lossy(&out.stdout).trim().to_string();256+ if !s.is_empty() {257+ return Some(s);258+ }259+ }260+ None261+}262+263+/// Prominent Clone & download section for the code tab (m6, f0rge264+/// issue #20): stacked no-JS sections with selectable `<pre>` commands —265+/// `git clone <url>` first when a clone URL is configured, otherwise266+/// bundle-first — plus the bundle/tarball links with sizes and shas.267+#[must_use]268+pub fn clone_block(269+ clone_url: Option<&str>,270+ branch: &str,271+ bundle_size: u64,272+ bundle_sha: Option<&str>,273+ tarball_size: u64,274+ tarball_sha: Option<&str>,275+) -> String {276+ let bundle_link = match bundle_sha {277+ Some(s) => format!(278+ "<a href=\"/repo.bundle\">repo.bundle</a> ({}; sha <code>{}</code>)",279+ human_size(bundle_size),280+ esc(s)281+ ),282+ None => format!(283+ "<a href=\"/repo.bundle\">repo.bundle</a> ({})",284+ human_size(bundle_size)285+ ),286+ };287+ let tarball_link = match tarball_sha {288+ Some(s) => format!(289+ "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({}; sha <code>{}</code>)",290+ human_size(tarball_size),291+ esc(s)292+ ),293+ None => format!(294+ "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({})",295+ human_size(tarball_size)296+ ),297+ };298+ let mut out = String::from(299+ "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\300+ <h2>Clone & download</h2>\n",301+ );302+ if let Some(url) = clone_url {303+ out.push_str("<h3>Clone with git</h3>\n");304+ out.push_str(&format!(305+ "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n",306+ esc(url)307+ ));308+ }309+ out.push_str("<h3>Clone the bundle</h3>\n");310+ out.push_str(&format!(311+ "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",312+ esc(branch)313+ ));314+ out.push_str("<h3>Download the tarball</h3>\n");315+ out.push_str(&format!(316+ "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"317+ ));318+ if clone_url.is_none() {319+ out.push_str(320+ "<p class=\"muted\">No clone URL is configured for this mirror — \321+ the bundle and tarball above carry the full snapshot.</p>\n",322+ );323+ }324+ out.push_str("</section>\n");325+ out326+}327+328+/// Render one text file body: escaped `<pre>` with clickable per-line329+/// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container330+/// so long lines never break the 360px layout.188331 fn render_text_pre(display: &str) -> String {189332 let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");190333 if display.is_empty() {191- out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");334+ out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>\n");192335 } else {193336 for (i, line) in display.split('\n').enumerate() {194- out.push_str("<span class=\"line\"><span class=\"ln\">");195- out.push_str(&(i + 1).to_string());196- out.push_str("</span> ");337+ let n = i + 1;338+ out.push_str("<span class=\"line\" id=\"L");339+ out.push_str(&n.to_string());340+ out.push_str("\"><a class=\"ln\" href=\"#L");341+ out.push_str(&n.to_string());342+ out.push_str("\">");343+ out.push_str(&n.to_string());344+ out.push_str("</a> ");197345 out.push_str(&esc(line));198346 out.push_str("</span>\n");199347 }@@ -308,20 +456,24 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu308456 Some(i) => dir[..i].to_string(),309457 None => String::new(),310458 };311- let parent_link = if parent.is_empty() {312- "<a href=\"/\">.. (root)</a>".to_string()459+ let parent_href = if parent.is_empty() {460+ "/".to_string()313461 } else {314- format!(315- "<a href=\"/files/{}.html\">.. ({})</a>",316- href_esc(&parent),317- esc(&parent)318- )462+ format!("/files/{}.html", href_esc(&parent))319463 };320- let mut body = format!(321- "<h1>dir: {}</h1>\n<p>{} · <a href=\"/\">code</a></p>\n<ul class=\"tree\">\n",464+ let parent_label = if parent.is_empty() {465+ ".. (root)".to_string()466+ } else {467+ format!(".. ({})", esc(&parent))468+ };469+ let mut body = breadcrumb(dir);470+ body.push_str(&format!(471+ "<h1>dir: {}</h1>\n<ul class=\"tree\">\n",322472 esc(dir),323- parent_link324- );473+ ));474+ body.push_str(&format!(475+ "<li class=\"parent\"><a href=\"{parent_href}\">{parent_label}</a></li>\n"476+ ));325477 for e in &entries {326478 let full = format!("{dir}/{}", e.name);327479 if e.is_dir {@@ -331,8 +483,14 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu331483 esc(&e.name)332484 ));333485 } else {486+ let size = blobs487+ .get(&full)488+ .map(|b| human_size(b.len() as u64))489+ .unwrap_or_default();334490 body.push_str(&format!(335- "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",491+ "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \492+ <span class=\"file-size\">{size}</span> \493+ (<a href=\"/raw/{}\">raw</a>)</li>\n",336494 href_esc(&full),337495 esc(&e.name),338496 href_esc(&full)@@ -352,26 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu352510 353511 for f in &files {354512 let bytes = blobs.get(f).cloned().unwrap_or_default();355- let parent = match f.rfind('/') {356- Some(i) => f[..i].to_string(),357- None => String::new(),358- };359- let parent_link = if parent.is_empty() {360- "<a href=\"/\">.. (root)</a>".to_string()361- } else {362- format!(363- "<a href=\"/files/{}.html\">.. ({})</a>",364- href_esc(&parent),365- esc(&parent)366- )367- };368- let mut body = format!(369- "<h1>{}</h1>\n<p>{} · <a href=\"/raw/{}\">raw</a> · <a href=\"/\">code</a></p>\n",370- esc(f),371- parent_link,372- href_esc(f)373- );513+ let mut body = breadcrumb(f);514+ body.push_str(&format!("<h1>{}</h1>\n", esc(f)));374515 if is_placeholder(&bytes) {516+ body.push_str(&file_meta_bar(f, bytes.len() as u64, None));375517 let kind = if bytes.len() >= PREVIEW_MAX_BYTES {376518 format!("too large to preview ({} bytes)", bytes.len())377519 } else {@@ -384,6 +526,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu384526 ));385527 } else if is_markdown(f) {386528 let display = String::from_utf8_lossy(&bytes).into_owned();529+ body.push_str(&file_meta_bar(530+ f,531+ bytes.len() as u64,532+ Some(display.lines().count()),533+ ));387534 body.push_str(&markdown_to_html(&display));388535 body.push_str(&format!(389536 "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",@@ -391,6 +538,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu391538 ));392539 } else {393540 let display = String::from_utf8_lossy(&bytes).into_owned();541+ body.push_str(&file_meta_bar(542+ f,543+ bytes.len() as u64,544+ Some(display.lines().count()),545+ ));394546 body.push_str(&render_text_pre(&display));395547 }396548 let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);@@ -440,12 +592,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu440592 if let Some(d) = &description {441593 body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));442594 }443- if let Some(url) = &clone_url {444- body.push_str(&format!(445- "<p class=\"clone\">Clone: <code>git clone {}</code></p>\n",446- esc(url)447- ));448- }449595 body.push_str(&format!(450596 "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",451597 esc(&branch),@@ -465,9 +611,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu465611 "<p>CI: {}</p>\n",466612 forge::verdict_badge(forge_data.ci.latest_verdict()),467613 ));468- body.push_str(&format!(469- "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \470- <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"614+ body.push_str(&clone_block(615+ clone_url.as_deref(),616+ &branch,617+ bundle_size,618+ file_hash(&snap.bundle).as_deref(),619+ tarball_size,620+ file_hash(&snap.tarball).as_deref(),471621 ));472622 473623 body.push_str("</div>\n");@@ -481,8 +631,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu481631 esc(&e.name)482632 ));483633 } else {634+ let size = blobs635+ .get(&e.name)636+ .map(|b| human_size(b.len() as u64))637+ .unwrap_or_default();484638 body.push_str(&format!(485- "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",639+ "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \640+ <span class=\"file-size\">{size}</span> (<a href=\"/raw/{}\">raw</a>)</li>\n",486641 href_esc(&e.name),487642 esc(&e.name),488643 href_esc(&e.name)@@ -661,11 +816,20 @@ mod tests {661816 assert!(index.contains("tasks (0)"), "task counts as 0");662817 assert!(index.contains("CI:"), "CI verdict shown");663818 assert!(index.contains("badge"), "CI verdict badge");664- // Snapshot links with sizes.819+ // Clone block (bundle-first: no origin remote is configured).820+ assert!(index.contains("clone-block"), "clone block present");665821 assert!(index.contains("repo.bundle"), "bundle linked");666822 assert!(index.contains("repo.tar.gz"), "tarball linked");823+ assert!(index.contains("git clone repo.bundle"), "bundle clone docs");824+ assert!(825+ index.contains("tar -xzf repo.tar.gz"),826+ "tarball extract docs"827+ );667828 // No clone URL line when no origin remote is configured.668- assert!(!index.contains("git clone"), "no URL line without remote");829+ assert!(830+ !index.contains("git clone http"),831+ "no URL line without remote"832+ );669833 }670834 671835 #[test]@@ -827,4 +991,110 @@ mod tests {827991 "legit markdown intact"828992 );829993 }994+995+ #[test]996+ fn human_size_formats() {997+ assert_eq!(human_size(0), "0 B");998+ assert_eq!(human_size(24), "24 B");999+ assert_eq!(human_size(1023), "1023 B");1000+ assert_eq!(human_size(2048), "2.0 KiB");1001+ assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB");1002+ }1003+1004+ #[test]1005+ fn breadcrumb_links_each_segment() {1006+ let (_repo, out) = build_fixture();1007+ // File page: root plus every segment linked.1008+ let html =1009+ std::fs::read_to_string(out.join("files/sub/nested.txt.html")).expect("file page");1010+ assert!(html.contains("<a href=\"/\">root</a>"), "root crumb");1011+ assert!(1012+ html.contains("<a href=\"/files/sub.html\">sub</a>"),1013+ "dir segment linked"1014+ );1015+ assert!(1016+ html.contains("<a href=\"/files/sub/nested.txt.html\">nested.txt</a>"),1017+ "file segment linked"1018+ );1019+ // Dir page carries a breadcrumb too.1020+ let dir = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");1021+ assert!(dir.contains("<a href=\"/\">root</a>"), "dir root crumb");1022+ assert!(1023+ dir.contains("<a href=\"/files/sub.html\">sub</a>"),1024+ "dir self crumb"1025+ );1026+ }1027+1028+ #[test]1029+ fn line_numbers_are_shareable_anchors() {1030+ let (_repo, out) = build_fixture();1031+ let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");1032+ assert!(html.contains("id=\"L1\""), "line 1 anchor");1033+ assert!(html.contains("href=\"#L1\""), "line 1 link");1034+ assert!(html.contains("id=\"L2\""), "line 2 anchor");1035+ assert!(html.contains("href=\"#L2\""), "line 2 link");1036+ }1037+1038+ #[test]1039+ fn file_meta_bar_has_size_lines_raw_and_path() {1040+ let (_repo, out) = build_fixture();1041+ let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");1042+ assert!(html.contains("file-meta"), "meta bar");1043+ assert!(html.contains("notes.txt"), "path text");1044+ // "plain notes\nsecond line\n" is 24 bytes in 2 lines.1045+ assert!(html.contains("24 B"), "humanized size");1046+ assert!(html.contains("2 lines"), "line count");1047+ assert!(html.contains("/raw/notes.txt"), "raw button");1048+ }1049+1050+ #[test]1051+ fn dir_page_has_parent_row_and_sizes() {1052+ let (_repo, out) = build_fixture();1053+ let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");1054+ assert!(1055+ html.contains("<a href=\"/\">.. (root)</a>"),1056+ "parent row to root"1057+ );1058+ assert!(html.contains("nested.txt"), "lists file");1059+ assert!(html.contains("/raw/sub/nested.txt"), "raw link");1060+ // "nested\n" is 7 bytes.1061+ assert!(html.contains("7 B"), "file size shown");1062+ }1063+1064+ #[test]1065+ fn clone_block_with_url_leads_with_git_clone() {1066+ let repo = fixture_repo();1067+ let out = mktemp("out-clone-url");1068+ let mut o = opts();1069+ o.clone_url_flag = Some("https://example.com/x.git".to_string());1070+ run_build(&repo, &out, &o).expect("build");1071+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");1072+ assert!(index.contains("clone-block"), "clone block");1073+ assert!(1074+ index.contains("git clone https://example.com/x.git"),1075+ "url clone cmd"1076+ );1077+ assert!(index.contains("git clone repo.bundle"), "bundle cmd");1078+ assert!(index.contains("tar -xzf repo.tar.gz"), "tarball cmd");1079+ assert!(index.contains("repo.bundle"), "bundle link");1080+ assert!(index.contains("repo.tar.gz"), "tarball link");1081+ let url_pos = index1082+ .find("git clone https://example.com/x.git")1083+ .expect("url pos");1084+ let bundle_pos = index.find("git clone repo.bundle").expect("bundle pos");1085+ assert!(url_pos < bundle_pos, "url-first with clone url");1086+ }1087+1088+ #[test]1089+ fn clone_block_bundle_first_without_url() {1090+ // Fixture has no origin remote: bundle-first presentation.1091+ let (_repo, out) = build_fixture();1092+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");1093+ assert!(index.contains("clone-block"), "clone block");1094+ assert!(index.contains("git clone repo.bundle"), "bundle-first cmd");1095+ assert!(!index.contains("git clone http"), "no url cmd");1096+ let bundle_pos = index.find("repo.bundle").expect("bundle pos");1097+ let tar_pos = index.find("repo.tar.gz").expect("tar pos");1098+ assert!(bundle_pos < tar_pos, "bundle before tarball");1099+ }8301100 }
static/style.css+16 −1--- a/static/style.css+++ b/static/style.css@@ -124,3 +124,19 @@ footer.site-footer .wrap{padding:1rem}124124 @media (min-width:1100px){125125 .wrap{max-width:72rem;padding:0 1.5rem}126126 }127+/* Code conventions (m6, f0rge #20): breadcrumbs, file meta, line anchors, clone block. */128+.crumbs{margin:.5rem 0;font-size:.95rem}129+.crumbs a{color:var(--cyan);text-decoration:none}130+.crumbs a:hover,.crumbs a:focus{text-decoration:underline}131+.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)}132+.file-path{overflow-wrap:anywhere;word-break:break-all}133+.file-size,.file-lines{color:var(--muted);white-space:nowrap}134+.file-raw{display:inline-flex;align-items:center;min-height:40px}135+.line:target{background:var(--bg-th)}136+.ln{color:var(--muted);text-decoration:none}137+a.ln:hover,a.ln:focus{text-decoration:underline}138+.clone-block{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}139+.clone-block h2{margin:.75rem 0 .25rem}140+.clone-block h3{margin:1rem 0 .25rem}141+.clone-block p{margin:.5rem 0}142+.clone-cmd code{user-select:all;overflow-wrap:anywhere;word-break:break-all}127--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.