#21 m6/code-conv

mergedpassopened
details
branches
master ← m6/code-conv
updated
id
d00c6f43-57f2-4917-8e8f-f57c17922f53

Patch

src/build.rs+319 −49
diff --git a/src/build.rs b/src/build.rsindex 7eb9b0d..5083fa7 100644--- 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 &amp; 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
diff --git a/static/style.css b/static/style.cssindex 8a0e3b2..1c4a0fc 100644--- 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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls