From 0fcdc135540c2aa61215bdfc45b7bf997567ff94 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 06:23:44 +0100 Subject: [PATCH] m6/responsive: fluid layout, wrapping nav, scroll containers, touch targets (113 tests) --- src/build.rs | 25 ++-- src/commits.rs | 6 +- src/forge.rs | 27 +++-- src/releases.rs | 8 +- static/style.css | 60 ++++++--- tests/responsive.rs | 287 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 44 deletions(-) create mode 100644 tests/responsive.rs diff --git a/src/build.rs b/src/build.rs index f6bd309..09c8f51 100644 --- a/src/build.rs +++ b/src/build.rs @@ -135,9 +135,10 @@ fn href_esc(path: &str) -> String { esc(path) } -/// Render one text file body: escaped `
` with line numbers.
+/// Render one text file body: escaped `
` with line numbers, 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");
+    let mut out = String::from("
\n");
     if display.is_empty() {
         out.push_str("1 \n");
     } else {
@@ -149,7 +150,7 @@ fn render_text_pre(display: &str) -> String {
             out.push_str("\n");
         }
     }
-    out.push_str("
\n"); + out.push_str("
\n"); out } @@ -381,20 +382,21 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result{}\n", esc(&name)); + // index.html header: stacked repo-head (name + clone + meta) so narrow + // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`. + let mut body = format!("

{}

\n", esc(&name)); match &description { Some(d) => body.push_str(&format!("

{}

\n", esc(d))), None => body.push_str("

No description.

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

Clone: git clone {}

\n", + "

Clone: git clone {}

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

Branch {} @ {}

\n", + "

Branch {} @ {}

\n", esc(&branch), esc(&short_sha) )); @@ -417,7 +419,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultrepo.tar.gz ({tarball_size} bytes)

\n" )); - body.push_str("

Files

\n
    \n"); + body.push_str("
\n"); + // Single column on mobile; tree+readme sit side by side only at >=768px. + body.push_str("

Files

\n
    \n"); for e in dir_entries.get("").cloned().unwrap_or_default() { if e.is_dir { body.push_str(&format!( @@ -434,9 +438,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result\n"); - - body.push_str("

    README

    \n"); + body.push_str("
\n
\n

README

\n"); match &readme { Some((path, blob)) => { body.push_str(&format!( @@ -451,6 +453,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result body.push_str("

No README found.

\n"), } + body.push_str("
\n"); let index = crate::layout::page(&name, &nav, &body); std::fs::write(out.join("index.html"), index) diff --git a/src/commits.rs b/src/commits.rs index 593c791..a98c017 100644 --- a/src/commits.rs +++ b/src/commits.rs @@ -35,7 +35,7 @@ pub fn render_loaded( if log.is_empty() { body.push_str("

No commits yet.

\n"); } else { - body.push_str("\n\n\n"); + body.push_str("
shasubjectauthordate
\n\n\n"); for c in log { body.push_str(&format!( "\n", @@ -46,7 +46,7 @@ pub fn render_loaded( esc(&c.date), )); } - body.push_str("\n
shasubjectauthordate
{}{}{}{}
\n"); + body.push_str("\n\n"); } let html = page(&format!("commits — {site_name}"), nav, &body); std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?; @@ -59,7 +59,7 @@ pub fn render_loaded( let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; let detail = format!( - "

commit {}

\n

{}

\n

Author: {}
Date: {}

\n

all commits

\n

Message

\n
{}
\n

Diff

\n
{}
\n", + "

commit {}

\n

{}

\n

Author: {}
Date: {}

\n

all commits

\n

Message

\n
{}
\n

Diff

\n
{}
\n", esc(short_sha(&c.sha)), esc(&c.sha), esc(&c.author), diff --git a/src/forge.rs b/src/forge.rs index 9e4e9c5..868449e 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -889,7 +889,10 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String { body.push_str("

Patch

\n"); match &pr.patch { Some(patch) if !patch.is_empty() => { - body.push_str(&format!("
{}
\n", esc(patch))); + body.push_str(&format!( + "
{}
\n", + esc(patch) + )); } _ => body.push_str("

No patch attached.

\n"), } @@ -934,7 +937,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> ResultNo issues yet.

\n"); } else { - body.push_str("\n\n\n"); + body.push_str("
#titlestatecommentscreated
\n\n\n"); for issue in &data.issues { body.push_str(&format!( "\n", @@ -946,7 +949,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result\n
#titlestatecommentscreated
{n}{title}{state}{comments}{created}
\n"); + body.push_str("\n\n"); } if !data.labels.is_empty() { body.push_str("

Labels

\n
    \n"); @@ -974,7 +977,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String { if prs.is_empty() { body.push_str("

    No pull requests yet.

    \n"); } else { - body.push_str("\n\n\n"); + body.push_str("
    #titlestatehead→basecreated
    \n\n\n"); for pr in prs { let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); let draft = if pr.draft { " draft" } else { "" }; @@ -987,7 +990,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String { created = esc(&pr.created), )); } - body.push_str("\n
    #titlestatehead→basecreated
    \n"); + body.push_str("\n\n"); } page(&format!("pulls — {site_name}"), nav, &body) } @@ -1053,7 +1056,7 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String { if run.steps.is_empty() { body.push_str("

    No step results recorded.

    \n"); } else { - body.push_str("\n\n\n"); + body.push_str("
    stepexitduration_ms
    \n\n\n"); for s in &run.steps { body.push_str(&format!( "\n", @@ -1062,17 +1065,23 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String { s.duration_ms, )); } - body.push_str("\n
    stepexitduration_ms
    {}{}{}
    \n"); + body.push_str("\n\n"); } body.push_str("

    Log

    \n"); if run.body.trim().is_empty() { body.push_str("

    Empty log.

    \n"); } else { - body.push_str(&format!("
    {}
    \n", esc(&run.body))); + body.push_str(&format!( + "
    {}
    \n", + esc(&run.body) + )); } for (name, content) in &run.logs { body.push_str(&format!("

    {}

    \n", esc(name))); - body.push_str(&format!("
    {}
    \n", esc(content))); + body.push_str(&format!( + "
    {}
    \n", + esc(content) + )); } } None => body.push_str("

    No CI runs yet.

    \n"), diff --git a/src/releases.rs b/src/releases.rs index 0abcb92..e05fe4c 100644 --- a/src/releases.rs +++ b/src/releases.rs @@ -350,12 +350,12 @@ fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String { body.push_str("
\n"); let hashes = sha256_lines(&rel.body); if !hashes.is_empty() { - body.push_str("

Checksums

\n
");
+            body.push_str("

Checksums

\n
");
             for h in &hashes {
                 body.push_str(&esc(h));
                 body.push('\n');
             }
-            body.push_str("
\n"); + body.push_str("
\n"); } } body.push_str("

all releases

\n"); @@ -380,7 +380,7 @@ pub fn render_loaded( if releases.is_empty() { body.push_str("

No releases yet.

\n"); } else { - body.push_str("\n\n\n"); + body.push_str("
versiontitlecreatedartifacts
\n\n\n"); for rel in releases { body.push_str(&format!( "\n", @@ -391,7 +391,7 @@ pub fn render_loaded( n = rel.artifacts.len(), )); } - body.push_str("\n
versiontitlecreatedartifacts
{ve}{t}{c}{n}
\n"); + body.push_str("\n\n"); } let index = page(&format!("releases — {site_name}"), nav, &body); let dest = out.join("releases").join("index.html"); diff --git a/static/style.css b/static/style.css index 52f0864..2bac0f6 100644 --- a/static/style.css +++ b/static/style.css @@ -4,47 +4,73 @@ yellow #e0af68, red #f7768e, border #292e42. */ *{box-sizing:border-box;border-radius:0} :root{color-scheme:dark} -html{background:#1a1b26} +html{background:#1a1b26;-webkit-text-size-adjust:100%} body{margin:0;background:#1a1b26;color:#c0caf5; font-family:system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,Cantarell,sans-serif; - line-height:1.55;font-size:16px} -.wrap{max-width:72rem;margin:0 auto;padding:0 1rem} + line-height:1.55;font-size:16px;overflow-x:clip} +img,video,svg{max-width:100%;height:auto} +/* Fluid container: base = mobile single-column, side padding, never fixed px. */ +.wrap{max-width:100%;margin:0 auto;padding:0 1rem;min-width:0} .site-header{background:#16161e;border-bottom:1px solid #292e42} -.site-header .wrap{display:flex;align-items:center;gap:1rem;padding:.75rem 1rem} -.brand{font-weight:700;color:#7aa2f7} +.site-header .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;padding:.75rem 1rem} +.brand{font-weight:700;color:#7aa2f7;overflow-wrap:anywhere} .site-nav{background:#16161e;border-bottom:1px solid #292e42} .site-nav .wrap{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 1rem} -.site-nav a{color:#7dcfff;text-decoration:none;padding:.15rem 0} +.site-nav a{display:inline-flex;align-items:center;min-height:40px;color:#7dcfff;text-decoration:none;padding:.35rem .1rem} .site-nav a:hover,.site-nav a:focus{text-decoration:underline} a{color:#7aa2f7} a:visited{color:#9aa5ce} -main{padding:1.25rem 0 3rem} -h1,h2,h3{color:#c0caf5;line-height:1.25} +main{padding:1.25rem 0 3rem;min-width:0} +/* Repo header stacks on narrow; clone cmd + sha wrap safely. */ +.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0} +.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere} +.clone,.repo-meta{margin:.25rem 0;min-width:0} +.clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all} code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - background:#16161e;color:#9ece6a;padding:.1em .3em} + background:#16161e;color:#9ece6a;padding:.1em .3em;overflow-wrap:anywhere} pre{background:#16161e;color:#c0caf5;border:1px solid #292e42; - padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5} -pre code{background:none;padding:0;color:inherit} + padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5;max-width:100%} +pre code{background:none;padding:0;color:inherit;overflow-wrap:normal;word-break:normal} +/* Scroll containers: tables never break layout; pre scrolls inside too. */ +.table-scroll,.pre-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%} +.table-scroll{margin:1rem 0} +.pre-scroll{margin:1rem 0} +.table-scroll table{margin:0} +.pre-scroll pre{margin:0} table{border-collapse:collapse;width:100%;margin:1rem 0;background:#16161e} -th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top} +th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top;overflow-wrap:anywhere} th{background:#1f2335;color:#7dcfff} tr:nth-child(even) td{background:#1a1b26} -form{max-width:44rem;margin:1rem 0} +td code,th code{word-break:break-all} +/* Single column on mobile; side-by-side only when it fits (>=768px). */ +.cols{display:grid;grid-template-columns:minmax(0,1fr);gap:1.5rem;min-width:0} +.cols>section,.cols>div{min-width:0} +/* Forms: full-width on mobile, >=40px touch targets. */ +form{max-width:44rem;margin:1rem 0;min-width:0} label{display:block;margin:.75rem 0 .25rem;font-weight:600} -input[type=text],textarea{display:block;width:100%;background:#16161e; - color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit} +input[type=text],textarea{display:block;width:100%;max-width:100%;background:#16161e; + color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit;min-height:40px} input[type=text]:focus,textarea:focus{outline:2px solid #7aa2f7;outline-offset:0} textarea{min-height:12rem} -button,input[type=submit]{background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7; +button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center; + min-height:40px;min-width:40px;background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7; padding:.5rem 1rem;font:inherit;font-weight:700;cursor:pointer} button:hover,input[type=submit]:hover{background:#7dcfff} .muted{color:#565f89} .badge{display:inline-block;border:1px solid #292e42;padding:.1em .5em;font-size:.85em} .badge.pass{color:#9ece6a}.badge.fail{color:#f7768e} .badge.running{color:#e0af68}.badge.cancelled{color:#565f89} -.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0} +.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0;overflow-wrap:anywhere} footer.site-footer{border-top:1px solid #292e42;color:#565f89} footer.site-footer .wrap{padding:1rem} +@media (min-width:768px){ + .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)} + .site-nav .wrap{gap:.25rem 1.25rem} + main{padding:1.5rem 0 3rem} +} +@media (min-width:1100px){ + .wrap{max-width:72rem;padding:0 1.5rem} +} @media (prefers-color-scheme:light){ html,body{background:#d5d6db;color:#343b58} .site-header,.site-nav,code,kbd,samp,pre,table,input[type=text],textarea{background:#cbccd3} diff --git a/tests/responsive.rs b/tests/responsive.rs new file mode 100644 index 0000000..1b21cf9 --- /dev/null +++ b/tests/responsive.rs @@ -0,0 +1,287 @@ +//! Responsive pass (m6, f0rge issue #14): +//! viewport meta, breakpoints, scroll wrappers, CSS budget, zero JS. +//! +//! Builds a small fixture repo with the real binary and string-asserts on +//! the emitted HTML + `static/style.css` (no rendering engine needed). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-responsive-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +/// Fixture: README + one long-line text file + 2 commits (list + detail). +fn fixture_repo() -> PathBuf { + let dir = fresh("repo"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("README.md"), "# responsive fixture\n\nhello\n").unwrap(); + // Long unbroken line: would force page scroll without a scroll container. + std::fs::write( + dir.join("notes.txt"), + format!("{}\nsecond line\n", "x".repeat(500)), + ) + .unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "first commit"]); + std::fs::write( + dir.join("notes.txt"), + format!("{}\nsecond line\nthird line\n", "y".repeat(500)), + ) + .unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "second commit"]); + dir +} + +fn build_fixture() -> PathBuf { + let repo = fixture_repo(); + let out = fresh("out"); + let st = Command::new(bin()) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .status() + .expect("spawn hu13 build"); + assert!(st.success(), "fixture build exits 0"); + out +} + +fn css() -> String { + std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css") +} + +#[test] +fn viewport_meta_on_all_pages() { + let out = build_fixture(); + let commit_dir = out.join("commit"); + let sha_page = std::fs::read_dir(&commit_dir) + .expect("commit dir") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|x| x == "html")) + .expect("one commit page"); + for page in [ + out.join("index.html"), + out.join("files").join("notes.txt.html"), + out.join("commits.html"), + sha_page, + ] { + let html = std::fs::read_to_string(&page).expect("read page"); + assert!( + html.contains( + "" + ), + "{} carries the viewport meta", + page.display() + ); + } +} + +#[test] +fn css_breakpoints_present() { + let css = css(); + assert!( + css.contains("@media (min-width:768px)"), + "768px breakpoint present" + ); + assert!( + css.contains("@media (min-width:1100px)"), + "1100px breakpoint present" + ); + let queries = css.matches("@media").count(); + assert!(queries >= 2, "at least 2 media queries, got {queries}"); +} + +#[test] +fn scroll_wrappers_on_file_and_commit_pages() { + let out = build_fixture(); + // File page: long-line
 sits inside a scroll container.
+    let file = std::fs::read_to_string(out.join("files").join("notes.txt.html")).expect("file");
+    assert!(
+        file.contains("pre-scroll"),
+        "file page has pre scroll wrapper"
+    );
+    assert!(file.contains("");
+    // Commit list: table inside a horizontal-scroll container.
+    let list = std::fs::read_to_string(out.join("commits.html")).expect("list");
+    assert!(
+        list.contains("table-scroll"),
+        "commits.html wraps its table"
+    );
+    assert!(list.contains("");
+    // Commit detail: message + diff pres wrapped.
+    let commit_dir = out.join("commit");
+    let sha_page = std::fs::read_dir(&commit_dir)
+        .expect("commit dir")
+        .filter_map(|e| e.ok())
+        .map(|e| e.path())
+        .find(|p| p.extension().is_some_and(|x| x == "html"))
+        .expect("one commit page");
+    let detail = std::fs::read_to_string(&sha_page).expect("detail");
+    assert!(
+        detail.contains("pre-scroll"),
+        "commit detail wraps its pres"
+    );
+    // CSS side: wrappers scroll, pre scrolls, table never forces the page.
+    let css = css();
+    for needle in ["table-scroll", "pre-scroll", "overflow-x:auto"] {
+        assert!(css.contains(needle), "CSS mentions {needle}");
+    }
+}
+
+#[test]
+fn css_budget_and_sharp_corners() {
+    let path = manifest().join("static").join("style.css");
+    let len = std::fs::metadata(&path).expect("style.css exists").len();
+    assert!(
+        len < 15360,
+        "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"
+    );
+    let css = css();
+    for line in css.lines() {
+        let mut rest = line;
+        while let Some(i) = rest.find("border-radius") {
+            let after = &rest[i + "border-radius".len()..];
+            let value: String = after
+                .chars()
+                .skip_while(|c| *c == ':' || c.is_whitespace())
+                .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t'))
+                .collect();
+            assert!(
+                value == "0",
+                "only allowed border-radius value is 0, got {value:?} in {line:?}"
+            );
+            rest = &rest[i + "border-radius".len()..];
+        }
+    }
+}
+
+#[test]
+fn no_fixed_pixel_widths_on_layout() {
+    let css = css();
+    for line in css.lines() {
+        let bytes = line.as_bytes();
+        let mut i = 0;
+        while let Some(pos) = line[i..].find("width:") {
+            let abs = i + pos;
+            // Skip `min-width:` / `max-width:` (responsive + touch targets).
+            let is_prefixed =
+                abs > 0 && bytes[abs - 1] == b'-' && line[..abs].ends_with(['-', 'n', 'x']);
+            let prefixed = line[..abs].ends_with("min-") || line[..abs].ends_with("max-");
+            if !is_prefixed && !prefixed {
+                let after = line[abs + "width:".len()..].trim_start();
+                let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
+                let unit: String = after[num.len()..].chars().take(2).collect();
+                assert!(
+                    !(!num.is_empty() && unit == "px"),
+                    "no fixed `width: px` on layout containers, got: {line:?}"
+                );
+            }
+            i = abs + "width:".len();
+        }
+    }
+    // Fluid container: max-width + side padding, never a fixed width.
+    assert!(css.contains(".wrap"), ".wrap container styled");
+    assert!(css.contains("max-width"), ".wrap uses max-width");
+}
+
+#[test]
+fn touch_targets_and_stacking_header() {
+    let css = css();
+    assert!(
+        css.contains("min-height:40px"),
+        ">=40px touch targets for nav/form/buttons"
+    );
+    assert!(
+        css.contains("flex-wrap"),
+        "nav/header wrap on narrow screens"
+    );
+    for needle in [".repo-head", ".clone", ".cols"] {
+        assert!(css.contains(needle), "CSS styles {needle}");
+    }
+    let out = build_fixture();
+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");
+    for needle in ["repo-head", "cols", "repo-meta"] {
+        assert!(index.contains(needle), "index.html has {needle}");
+    }
+}
+
+#[test]
+fn zero_js_and_reader_friendly() {
+    let out = build_fixture();
+    let mut pages = vec![
+        out.join("index.html"),
+        out.join("commits.html"),
+        out.join("files").join("notes.txt.html"),
+    ];
+    if let Ok(rd) = std::fs::read_dir(out.join("commit")) {
+        for e in rd.filter_map(|e| e.ok()) {
+            if e.path().extension().is_some_and(|x| x == "html") {
+                pages.push(e.path());
+                break;
+            }
+        }
+    }
+    for page in &pages {
+        let html = std::fs::read_to_string(page).expect("read page");
+        assert!(
+            !html.contains("",
+            page.display()
+        );
+        assert!(
+            !html.contains("onclick="),
+            "{} has no handlers",
+            page.display()
+        );
+    }
+    // No JS files emitted anywhere under dist/.
+    let mut stack = vec![out.clone()];
+    while let Some(d) = stack.pop() {
+        for e in std::fs::read_dir(&d).expect("read_dir") {
+            let e = e.expect("entry");
+            let p = e.path();
+            if p.is_dir() {
+                stack.push(p);
+            } else {
+                assert!(
+                    p.extension().is_none_or(|x| x != "js"),
+                    "no .js emitted: {}",
+                    p.display()
+                );
+            }
+        }
+    }
+}
-- 
2.55.0