From 729259a60df1a1524ab2fa9079f54e9bcc1d2da9 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 07:01:52 +0100 Subject: [PATCH] m6/code-conv: breadcrumbs, L-anchors, meta bar, clone block (149 tests) --- src/build.rs | 368 ++++++++++++++++++++++++++++++++++++++++------- static/style.css | 16 +++ 2 files changed, 335 insertions(+), 49 deletions(-) diff --git a/src/build.rs b/src/build.rs index 7eb9b0d..5083fa7 100644 --- a/src/build.rs +++ b/src/build.rs @@ -183,17 +183,165 @@ fn href_esc(path: &str) -> String { esc(path) } -/// Render one text file body: escaped `
` with line numbers, wrapped in
-/// a horizontal-scroll container so long lines never break the 360px layout.
+/// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars
+/// and listings (m6, f0rge issue #20).
+#[must_use]
+pub fn human_size(n: u64) -> String {
+    if n < 1024 {
+        format!("{n} B")
+    } else if n < 1024 * 1024 {
+        format!("{:.1} KiB", n as f64 / 1024.0)
+    } else {
+        format!("{:.1} MiB", n as f64 / 1024.0 / 1024.0)
+    }
+}
+
+/// Breadcrumb trail for a repo-relative `path`: `root` plus one cumulative
+/// link per segment (`/files/.html`), so every segment is clickable
+/// (m6, f0rge issue #20). No JS; the nav element labels it for AT.
+#[must_use]
+pub fn breadcrumb(path: &str) -> String {
+    let mut out =
+        String::from("\n");
+    out
+}
+
+/// File meta bar: full path text (selectable, no-JS copy affordance),
+/// humanized size, optional line count, and a Raw download link
+/// (m6, f0rge issue #20).
+#[must_use]
+pub fn file_meta_bar(path: &str, size_bytes: u64, lines: Option) -> String {
+    let mut out = format!(
+        "
{}\n\ + {}", + esc(path), + human_size(size_bytes) + ); + if let Some(n) = lines { + out.push_str(&format!("\n{n} lines")); + } + out.push_str(&format!( + "\nRaw
\n", + href_esc(path) + )); + out +} + +/// Content sha of a snapshot artifact via `git hash-object` (no new deps; +/// git is already required to build). `None` when git cannot hash. +#[must_use] +pub fn file_hash(path: &Path) -> Option { + let out = std::process::Command::new("git") + .arg("hash-object") + .arg(path) + .output() + .ok()?; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + None +} + +/// Prominent Clone & download section for the code tab (m6, f0rge +/// issue #20): stacked no-JS sections with selectable `
` commands —
+/// `git clone ` first when a clone URL is configured, otherwise
+/// bundle-first — plus the bundle/tarball links with sizes and shas.
+#[must_use]
+pub fn clone_block(
+    clone_url: Option<&str>,
+    branch: &str,
+    bundle_size: u64,
+    bundle_sha: Option<&str>,
+    tarball_size: u64,
+    tarball_sha: Option<&str>,
+) -> String {
+    let bundle_link = match bundle_sha {
+        Some(s) => format!(
+            "repo.bundle ({}; sha {})",
+            human_size(bundle_size),
+            esc(s)
+        ),
+        None => format!(
+            "repo.bundle ({})",
+            human_size(bundle_size)
+        ),
+    };
+    let tarball_link = match tarball_sha {
+        Some(s) => format!(
+            "repo.tar.gz ({}; sha {})",
+            human_size(tarball_size),
+            esc(s)
+        ),
+        None => format!(
+            "repo.tar.gz ({})",
+            human_size(tarball_size)
+        ),
+    };
+    let mut out = String::from(
+        "
\n\ +

Clone & download

\n", + ); + if let Some(url) = clone_url { + out.push_str("

Clone with git

\n"); + out.push_str(&format!( + "
git clone {}
\n", + esc(url) + )); + } + out.push_str("

Clone the bundle

\n"); + out.push_str(&format!( + "
git clone repo.bundle -b {}
\n

{bundle_link}

\n", + esc(branch) + )); + out.push_str("

Download the tarball

\n"); + out.push_str(&format!( + "
tar -xzf repo.tar.gz
\n

{tarball_link}

\n" + )); + if clone_url.is_none() { + out.push_str( + "

No clone URL is configured for this mirror — \ + the bundle and tarball above carry the full snapshot.

\n", + ); + } + out.push_str("
\n"); + out +} + +/// Render one text file body: escaped `
` with clickable per-line
+/// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container
+/// so long lines never break the 360px layout.
 fn render_text_pre(display: &str) -> String {
     let mut out = String::from("
\n");
     if display.is_empty() {
-        out.push_str("1 \n");
+        out.push_str("1 \n");
     } else {
         for (i, line) in display.split('\n').enumerate() {
-            out.push_str("");
-            out.push_str(&(i + 1).to_string());
-            out.push_str(" ");
+            let n = i + 1;
+            out.push_str("");
+            out.push_str(&n.to_string());
+            out.push_str(" ");
             out.push_str(&esc(line));
             out.push_str("\n");
         }
@@ -308,20 +456,24 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result dir[..i].to_string(),
             None => String::new(),
         };
-        let parent_link = if parent.is_empty() {
-            ".. (root)".to_string()
+        let parent_href = if parent.is_empty() {
+            "/".to_string()
         } else {
-            format!(
-                ".. ({})",
-                href_esc(&parent),
-                esc(&parent)
-            )
+            format!("/files/{}.html", href_esc(&parent))
         };
-        let mut body = format!(
-            "

dir: {}

\n

{} · code

\n
    \n", + let parent_label = if parent.is_empty() { + ".. (root)".to_string() + } else { + format!(".. ({})", esc(&parent)) + }; + let mut body = breadcrumb(dir); + body.push_str(&format!( + "

    dir: {}

    \n
      \n", esc(dir), - parent_link - ); + )); + body.push_str(&format!( + "
    • {parent_label}
    • \n" + )); for e in &entries { let full = format!("{dir}/{}", e.name); if e.is_dir { @@ -331,8 +483,14 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result{} (raw)\n", + "
    • {} \ + {size} \ + (raw)
    • \n", href_esc(&full), esc(&e.name), href_esc(&full) @@ -352,26 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result f[..i].to_string(), - None => String::new(), - }; - let parent_link = if parent.is_empty() { - ".. (root)".to_string() - } else { - format!( - ".. ({})", - href_esc(&parent), - esc(&parent) - ) - }; - let mut body = format!( - "

      {}

      \n

      {} · raw · code

      \n", - esc(f), - parent_link, - href_esc(f) - ); + let mut body = breadcrumb(f); + body.push_str(&format!("

      {}

      \n", esc(f))); if is_placeholder(&bytes) { + body.push_str(&file_meta_bar(f, bytes.len() as u64, None)); let kind = if bytes.len() >= PREVIEW_MAX_BYTES { format!("too large to preview ({} bytes)", bytes.len()) } else { @@ -384,6 +526,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultview raw

      \n", @@ -391,6 +538,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Result{}

      \n", esc(d))); } - if let Some(url) = &clone_url { - body.push_str(&format!( - "

      Clone: git clone {}

      \n", - esc(url) - )); - } body.push_str(&format!( "

      Branch {} @ {}

      \n", esc(&branch), @@ -465,9 +611,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> ResultCI: {}

      \n", forge::verdict_badge(forge_data.ci.latest_verdict()), )); - body.push_str(&format!( - "

      repo.bundle ({bundle_size} bytes) · \ - repo.tar.gz ({tarball_size} bytes)

      \n" + body.push_str(&clone_block( + clone_url.as_deref(), + &branch, + bundle_size, + file_hash(&snap.bundle).as_deref(), + tarball_size, + file_hash(&snap.tarball).as_deref(), )); body.push_str("
\n"); @@ -481,8 +631,13 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result{} (raw)\n", + "
  • {} \ + {size} (raw)
  • \n", href_esc(&e.name), esc(&e.name), href_esc(&e.name) @@ -661,11 +816,20 @@ mod tests { assert!(index.contains("tasks (0)"), "task counts as 0"); assert!(index.contains("CI:"), "CI verdict shown"); assert!(index.contains("badge"), "CI verdict badge"); - // Snapshot links with sizes. + // Clone block (bundle-first: no origin remote is configured). + assert!(index.contains("clone-block"), "clone block present"); assert!(index.contains("repo.bundle"), "bundle linked"); assert!(index.contains("repo.tar.gz"), "tarball linked"); + assert!(index.contains("git clone repo.bundle"), "bundle clone docs"); + assert!( + index.contains("tar -xzf repo.tar.gz"), + "tarball extract docs" + ); // No clone URL line when no origin remote is configured. - assert!(!index.contains("git clone"), "no URL line without remote"); + assert!( + !index.contains("git clone http"), + "no URL line without remote" + ); } #[test] @@ -827,4 +991,110 @@ mod tests { "legit markdown intact" ); } + + #[test] + fn human_size_formats() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(24), "24 B"); + assert_eq!(human_size(1023), "1023 B"); + assert_eq!(human_size(2048), "2.0 KiB"); + assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB"); + } + + #[test] + fn breadcrumb_links_each_segment() { + let (_repo, out) = build_fixture(); + // File page: root plus every segment linked. + let html = + std::fs::read_to_string(out.join("files/sub/nested.txt.html")).expect("file page"); + assert!(html.contains("root"), "root crumb"); + assert!( + html.contains("sub"), + "dir segment linked" + ); + assert!( + html.contains("nested.txt"), + "file segment linked" + ); + // Dir page carries a breadcrumb too. + let dir = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page"); + assert!(dir.contains("root"), "dir root crumb"); + assert!( + dir.contains("sub"), + "dir self crumb" + ); + } + + #[test] + fn line_numbers_are_shareable_anchors() { + let (_repo, out) = build_fixture(); + let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page"); + assert!(html.contains("id=\"L1\""), "line 1 anchor"); + assert!(html.contains("href=\"#L1\""), "line 1 link"); + assert!(html.contains("id=\"L2\""), "line 2 anchor"); + assert!(html.contains("href=\"#L2\""), "line 2 link"); + } + + #[test] + fn file_meta_bar_has_size_lines_raw_and_path() { + let (_repo, out) = build_fixture(); + let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page"); + assert!(html.contains("file-meta"), "meta bar"); + assert!(html.contains("notes.txt"), "path text"); + // "plain notes\nsecond line\n" is 24 bytes in 2 lines. + assert!(html.contains("24 B"), "humanized size"); + assert!(html.contains("2 lines"), "line count"); + assert!(html.contains("/raw/notes.txt"), "raw button"); + } + + #[test] + fn dir_page_has_parent_row_and_sizes() { + let (_repo, out) = build_fixture(); + let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page"); + assert!( + html.contains(".. (root)"), + "parent row to root" + ); + assert!(html.contains("nested.txt"), "lists file"); + assert!(html.contains("/raw/sub/nested.txt"), "raw link"); + // "nested\n" is 7 bytes. + assert!(html.contains("7 B"), "file size shown"); + } + + #[test] + fn clone_block_with_url_leads_with_git_clone() { + let repo = fixture_repo(); + let out = mktemp("out-clone-url"); + let mut o = opts(); + o.clone_url_flag = Some("https://example.com/x.git".to_string()); + run_build(&repo, &out, &o).expect("build"); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("clone-block"), "clone block"); + assert!( + index.contains("git clone https://example.com/x.git"), + "url clone cmd" + ); + assert!(index.contains("git clone repo.bundle"), "bundle cmd"); + assert!(index.contains("tar -xzf repo.tar.gz"), "tarball cmd"); + assert!(index.contains("repo.bundle"), "bundle link"); + assert!(index.contains("repo.tar.gz"), "tarball link"); + let url_pos = index + .find("git clone https://example.com/x.git") + .expect("url pos"); + let bundle_pos = index.find("git clone repo.bundle").expect("bundle pos"); + assert!(url_pos < bundle_pos, "url-first with clone url"); + } + + #[test] + fn clone_block_bundle_first_without_url() { + // Fixture has no origin remote: bundle-first presentation. + let (_repo, out) = build_fixture(); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("clone-block"), "clone block"); + assert!(index.contains("git clone repo.bundle"), "bundle-first cmd"); + assert!(!index.contains("git clone http"), "no url cmd"); + let bundle_pos = index.find("repo.bundle").expect("bundle pos"); + let tar_pos = index.find("repo.tar.gz").expect("tar pos"); + assert!(bundle_pos < tar_pos, "bundle before tarball"); + } } diff --git a/static/style.css b/static/style.css index 8a0e3b2..1c4a0fc 100644 --- a/static/style.css +++ b/static/style.css @@ -124,3 +124,19 @@ footer.site-footer .wrap{padding:1rem} @media (min-width:1100px){ .wrap{max-width:72rem;padding:0 1.5rem} } +/* Code conventions (m6, f0rge #20): breadcrumbs, file meta, line anchors, clone block. */ +.crumbs{margin:.5rem 0;font-size:.95rem} +.crumbs a{color:var(--cyan);text-decoration:none} +.crumbs a:hover,.crumbs a:focus{text-decoration:underline} +.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)} +.file-path{overflow-wrap:anywhere;word-break:break-all} +.file-size,.file-lines{color:var(--muted);white-space:nowrap} +.file-raw{display:inline-flex;align-items:center;min-height:40px} +.line:target{background:var(--bg-th)} +.ln{color:var(--muted);text-decoration:none} +a.ln:hover,a.ln:focus{text-decoration:underline} +.clone-block{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)} +.clone-block h2{margin:.75rem 0 .25rem} +.clone-block h3{margin:1rem 0 .25rem} +.clone-block p{margin:.5rem 0} +.clone-cmd code{user-select:all;overflow-wrap:anywhere;word-break:break-all} -- 2.55.0