.forge/prs/d00c6f43-57f2-4917-8e8f-f57c17922f53/patch.diff 19.8 KiB 499 lines Raw
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 &amp; 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