.forge/prs/d87e454a-045b-4d91-8924-5acac01a1d65/patch.diff 28.8 KiB 765 lines Raw
1 From be7802d600d44d7725868e1ffc4415f5a364c6c4 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:15:09 +01004 Subject: [PATCH] m2/commits-wiki: commit pages+diffs, wiki index+pages (375  tests)6 7 ---8  src/build.rs   |  54 +++++++--9  src/commits.rs | 243 +++++++++++++++++++++++++++++++++++++10  src/gitx.rs    |  31 +++++11  src/main.rs    |   2 +12  src/wiki.rs    | 322 +++++++++++++++++++++++++++++++++++++++++++++++++13  5 files changed, 645 insertions(+), 7 deletions(-)14  create mode 100644 src/commits.rs15  create mode 100644 src/wiki.rs16 17 diff --git a/src/build.rs b/src/build.rs18 index 5280528..4530f1a 10064419 --- a/src/build.rs20 +++ b/src/build.rs21 @@ -1,17 +1,19 @@22 -//! `hu13 build` — code-tab static site (m1, f0rge issue #4).23 +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5).24  //!25  //! Reads one branch via [`crate::gitx`], emits `dist/`:26  //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),27 -//! `repo.bundle` + `repo.tar.gz`, and `style.css`.28 +//! `commits.html`, `commit/<sha>.html`, `wiki/index.html`,29 +//! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.30  //!31  //! Bodies from the repo are semi-trusted but treated as untrusted:32 -//! markdown goes through [`markdown_to_html`], which escapes raw HTML.33 +//! markdown goes through [`markdown_to_html`], which escapes raw HTML;34 +//! commit messages and diffs are escaped `<pre>` (never markdown).35  36  use std::collections::BTreeMap;37  use std::path::Path;38  39 -use crate::gitx;40  use crate::layout::esc;41 +use crate::{commits, gitx, wiki};42  43  /// Files at or above this size get a placeholder page + download link.44  pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;45 @@ -25,7 +27,7 @@ pub struct BuildOptions {46      pub clone_url_flag: Option<String>,47      /// `--title` site title override (else repo dir name).48      pub title_override: Option<String>,49 -    /// `--max-log` (accepted for CLI parity; commits land in m2).50 +    /// `--max-log`: max commits listed in `commits.html` (default 100).51      pub max_log: usize,52  }53  54 @@ -144,7 +146,7 @@ fn render_text_pre(display: &str) -> String {55  }56  57  fn nav_code() -> String {58 -    "<a href=\"/\">code</a> <!-- TODO(m3): commits/issues/pulls/tasks/ci/releases/wiki tabs -->"59 +    "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · <a href=\"/wiki/index.html\">wiki</a> <!-- TODO(m3): issues/pulls/tasks/ci/releases tabs -->"60          .to_string()61  }62  63 @@ -156,7 +158,6 @@ fn write_style(out: &Path) -> Result<(), String> {64  65  /// Build the full static site. Returns a summary on success.66  pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {67 -    let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity68      let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())69          .map_err(|e| format!("hu13: {e}"))?;70      let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;71 @@ -333,6 +334,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu72          std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;73      }74  75 +    // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.76 +    let nav = nav_code();77 +    commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;78 +    wiki::render(repo, out, &name, &nav)?;79 +80      // Snapshot last so the index can link sizes.81      let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;82      let bundle_size = std::fs::metadata(&snap.bundle)83 @@ -675,6 +681,40 @@ mod tests {84          assert_eq!(path, "README.md");85      }86  87 +    #[test]88 +    fn build_emits_commits_and_wiki() {89 +        let repo = fixture_repo();90 +        // On-disk wiki with one page (proves the .forge — not git — read path).91 +        std::fs::create_dir_all(repo.join(".forge").join("wiki")).unwrap();92 +        std::fs::write(93 +            repo.join(".forge").join("wiki").join("notes.md"),94 +            "# Build notes\n\nwiki body\n",95 +        )96 +        .unwrap();97 +        git(&repo, &["add", "-A"]);98 +        git(&repo, &["commit", "-m", "add wiki"]);99 +        let out = mktemp("out-commits-wiki");100 +        run_build(&repo, &out, &opts()).expect("build");101 +        // Commits wired in.102 +        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");103 +        assert!(list.contains("fixture commit"));104 +        assert!(list.contains("add wiki"));105 +        assert!(list.contains("/commit/"), "detail links");106 +        let log = gitx::log_n(&repo, "main", 100).expect("log");107 +        assert!(out108 +            .join("commit")109 +            .join(format!("{}.html", log[0].sha))110 +            .exists());111 +        // Wiki wired in (index + per-page), nav links present.112 +        let index = std::fs::read_to_string(out.join("index.html")).expect("index");113 +        assert!(index.contains("commits.html"), "nav links commits");114 +        assert!(index.contains("wiki/index.html"), "nav links wiki");115 +        let windex =116 +            std::fs::read_to_string(out.join("wiki").join("index.html")).expect("wiki index");117 +        assert!(windex.contains("Build notes"), "H1 title");118 +        assert!(out.join("wiki").join("notes.md.html").exists());119 +    }120 +121      #[test]122      fn markdown_escapes_raw_html() {123          let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");124 diff --git a/src/commits.rs b/src/commits.rs125 new file mode 100644126 index 0000000..834971c127 --- /dev/null128 +++ b/src/commits.rs129 @@ -0,0 +1,243 @@130 +//! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).131 +//!132 +//! Commit subjects, messages, and diffs are untrusted: everything goes133 +//! through [`crate::layout::esc`] into an escaped `<pre>`. Diffs come from134 +//! [`crate::gitx::diff`] (binary content noted, output truncated past135 +//! 2000 lines with a marker). Nothing here goes through markdown.136 +137 +use std::path::Path;138 +139 +use crate::gitx;140 +use crate::layout::{esc, page};141 +142 +/// Short display form of a full sha (first 7 hex chars, git-style).143 +#[must_use]144 +pub fn short_sha(sha: &str) -> &str {145 +    sha.get(..7).unwrap_or(sha)146 +}147 +148 +/// Render `commits.html` plus one `commit/<sha>.html` per listed commit.149 +///150 +/// Lists at most `max_log` newest-first commits (see `--max-log`).151 +/// Returns the number of commits listed.152 +pub fn render(153 +    repo: &Path,154 +    out: &Path,155 +    branch: &str,156 +    max_log: usize,157 +    site_name: &str,158 +    nav: &str,159 +) -> Result<usize, String> {160 +    let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;161 +162 +    let mut body = String::from("<h1>Commits</h1>\n");163 +    if log.is_empty() {164 +        body.push_str("<p class=\"muted\">No commits yet.</p>\n");165 +    } else {166 +        body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");167 +        for c in &log {168 +            body.push_str(&format!(169 +                "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",170 +                esc(&c.sha),171 +                esc(short_sha(&c.sha)),172 +                esc(&c.subject),173 +                esc(&c.author),174 +                esc(&c.date),175 +            ));176 +        }177 +        body.push_str("</tbody>\n</table>\n");178 +    }179 +    let html = page(&format!("commits — {site_name}"), nav, &body);180 +    std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;181 +    std::fs::write(out.join("commits.html"), html)182 +        .map_err(|e| format!("hu13: write commits.html: {e}"))?;183 +184 +    std::fs::create_dir_all(out.join("commit"))185 +        .map_err(|e| format!("hu13: create commit dir: {e}"))?;186 +    for c in &log {187 +        let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;188 +        let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;189 +        let detail = format!(190 +            "<h1>commit {}</h1>\n<p><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<pre>{}</pre>\n<h2>Diff</h2>\n<pre class=\"diff\">{}</pre>\n",191 +            esc(short_sha(&c.sha)),192 +            esc(&c.sha),193 +            esc(&c.author),194 +            esc(&c.date),195 +            esc(&message),196 +            esc(&diff),197 +        );198 +        let html = page(199 +            &format!("commit {} — {site_name}", short_sha(&c.sha)),200 +            nav,201 +            &detail,202 +        );203 +        std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)204 +            .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;205 +    }206 +    Ok(log.len())207 +}208 +209 +#[cfg(test)]210 +mod tests {211 +    use super::*;212 +    use std::path::PathBuf;213 +    use std::process::Command;214 +    use std::sync::atomic::{AtomicU64, Ordering};215 +216 +    static COUNTER: AtomicU64 = AtomicU64::new(0);217 +218 +    fn mktemp(prefix: &str) -> PathBuf {219 +        let id = COUNTER.fetch_add(1, Ordering::SeqCst);220 +        let dir =221 +            std::env::temp_dir().join(format!("hu13-commits-{prefix}-{}-{id}", std::process::id()));222 +        std::fs::create_dir_all(&dir).expect("mktemp");223 +        dir224 +    }225 +226 +    fn git(repo: &Path, args: &[&str]) {227 +        let st = Command::new("git")228 +            .arg("-C")229 +            .arg(repo)230 +            .args(args)231 +            .status()232 +            .expect("spawn git");233 +        assert!(st.success(), "git {args:?} failed in {}", repo.display());234 +    }235 +236 +    /// Fixture: 3 commits — plain text, evil message + binary change, huge diff.237 +    fn fixture_repo() -> PathBuf {238 +        let dir = mktemp("fixture");239 +        git(&dir, &["init", "-b", "main"]);240 +        git(&dir, &["config", "user.name", "hu13-test"]);241 +        git(&dir, &["config", "user.email", "[email protected]"]);242 +        git(&dir, &["config", "commit.gpgsign", "false"]);243 +244 +        // Commit 1: plain text file.245 +        std::fs::write(dir.join("notes.txt"), "line one\n").unwrap();246 +        git(&dir, &["add", "-A"]);247 +        git(&dir, &["commit", "-m", "first commit"]);248 +249 +        // Commit 2: evil message + text hunk + binary file change.250 +        std::fs::write(dir.join("notes.txt"), "line one\nline two added\n").unwrap();251 +        let bin: Vec<u8> = (0u8..=255).collect();252 +        std::fs::write(dir.join("bin.dat"), &bin).unwrap();253 +        git(&dir, &["add", "-A"]);254 +        git(255 +            &dir,256 +            &["commit", "-m", "second commit <script>alert(1)</script>"],257 +        );258 +259 +        // Commit 3: huge diff (>2000 lines).260 +        let mut big = String::new();261 +        for i in 0..2500 {262 +            big.push_str(&format!("line {i}\n"));263 +        }264 +        std::fs::write(dir.join("big.txt"), &big).unwrap();265 +        git(&dir, &["add", "-A"]);266 +        git(&dir, &["commit", "-m", "third commit huge"]);267 +268 +        dir269 +    }270 +271 +    fn shas(repo: &Path) -> Vec<String> {272 +        gitx::log_n(repo, "main", 100)273 +            .expect("log")274 +            .iter()275 +            .map(|c| c.sha.clone())276 +            .collect()277 +    }278 +279 +    #[test]280 +    fn list_order_newest_first_with_links() {281 +        let repo = fixture_repo();282 +        let out = mktemp("out");283 +        let n = render(&repo, &out, "main", 100, "test", "<nav>").expect("render");284 +        assert_eq!(n, 3);285 +        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");286 +        let p3 = list.find("third commit huge").expect("c3");287 +        let p2 = list.find("second commit").expect("c2");288 +        let p1 = list.find("first commit").expect("c1");289 +        assert!(p3 < p2 && p2 < p1, "newest first");290 +        for sha in shas(&repo) {291 +            assert!(292 +                list.contains(&format!("/commit/{sha}.html")),293 +                "detail link for {sha}"294 +            );295 +            assert!(296 +                list.contains(sha.get(..7).unwrap_or(&sha)),297 +                "short sha for {sha}"298 +            );299 +        }300 +        assert!(list.contains("hu13-test"), "author shown");301 +    }302 +303 +    #[test]304 +    fn detail_escapes_message_and_hunk() {305 +        let repo = fixture_repo();306 +        let out = mktemp("out");307 +        render(&repo, &out, "main", 100, "test", "<nav>").expect("render");308 +        let sha = shas(&repo)[1].clone(); // evil-message + binary commit309 +        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))310 +            .expect("detail");311 +        assert!(312 +            !detail.contains("<script>alert(1)</script>"),313 +            "message escaped"314 +        );315 +        assert!(detail.contains("&lt;script&gt;"), "escaped form present");316 +        assert!(317 +            detail.contains("line two added"),318 +            "diff hunk present: {detail}"319 +        );320 +        assert!(321 +            detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),322 +            "binary change noted"323 +        );324 +        assert!(detail.contains("<pre class=\"diff\">"), "diff in <pre>");325 +    }326 +327 +    #[test]328 +    fn huge_diff_truncates_with_marker() {329 +        let repo = fixture_repo();330 +        let out = mktemp("out");331 +        render(&repo, &out, "main", 100, "test", "<nav>").expect("render");332 +        let sha = shas(&repo)[0].clone(); // huge commit333 +        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))334 +            .expect("detail");335 +        assert!(336 +            detail.contains(gitx::DIFF_TRUNCATED_MARKER),337 +            "truncation marker shown"338 +        );339 +    }340 +341 +    #[test]342 +    fn max_log_limits_list_and_details() {343 +        let repo = fixture_repo();344 +        let out = mktemp("out");345 +        let n = render(&repo, &out, "main", 2, "test", "<nav>").expect("render");346 +        assert_eq!(n, 2);347 +        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");348 +        assert!(list.contains("third commit huge"));349 +        assert!(list.contains("second commit"));350 +        assert!(!list.contains("first commit"), "oldest commit cut off");351 +        let all = shas(&repo);352 +        assert!(out.join("commit").join(format!("{}.html", all[0])).exists());353 +        assert!(!out.join("commit").join(format!("{}.html", all[2])).exists());354 +    }355 +356 +    #[test]357 +    fn empty_log_renders_none_yet() {358 +        let dir = mktemp("empty");359 +        git(&dir, &["init", "-b", "main"]);360 +        git(&dir, &["config", "user.name", "hu13-test"]);361 +        git(&dir, &["config", "user.email", "[email protected]"]);362 +        git(&dir, &["config", "commit.gpgsign", "false"]);363 +        std::fs::write(dir.join("empty.txt"), "x\n").unwrap();364 +        git(&dir, &["add", "-A"]);365 +        git(&dir, &["commit", "-m", "only commit"]);366 +        let out = mktemp("out");367 +        let n = render(&dir, &out, "main", 0, "test", "<nav>").expect("render");368 +        assert_eq!(n, 0);369 +        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");370 +        assert!(list.contains("No commits yet."));371 +    }372 +}373 diff --git a/src/gitx.rs b/src/gitx.rs374 index 10d0874..a4e11eb 100644375 --- a/src/gitx.rs376 +++ b/src/gitx.rs377 @@ -352,6 +352,25 @@ pub fn log_n(repo: &Path, branch: &str, n: usize) -> Result<Vec<Commit>, GitErro378      Ok(out)379  }380  381 +/// Full commit message body for `sha` (`git show -s --format=%B <sha>`).382 +///383 +/// Lossy UTF-8; callers must still escape it for HTML.384 +pub fn message(repo: &Path, sha: &str) -> Result<String, GitError> {385 +    let a_show: &OsStr = OsStr::new("show");386 +    let a_silent: &OsStr = OsStr::new("-s");387 +    let a_fmt: &OsStr = OsStr::new("--format=%B");388 +    let os_sha = OsString::from(sha);389 +    let args: Vec<&OsStr> = vec![a_show, a_silent, a_fmt, os_sha.as_os_str()];390 +    let out = run_git(repo, &args)?;391 +    if !out.status_ok {392 +        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();393 +        return Err(GitError::NotFound(format!(394 +            "commit `{sha}` not found: {stderr}"395 +        )));396 +    }397 +    Ok(String::from_utf8_lossy(&out.stdout).into_owned())398 +}399 +400  /// Directories-first listing of `branch:path` (`path` empty = root).401  ///402  /// Within each group (dirs, then files) entries are sorted by exact403 @@ -704,6 +723,18 @@ mod tests {404          }405      }406  407 +    #[test]408 +    fn message_returns_full_body() {409 +        let (repo, branch) = fixture_repo();410 +        let log = log_n(&repo, &branch, 100).expect("log");411 +        let msg = message(&repo, &log[1].sha).expect("message");412 +        assert!(413 +            msg.contains("second commit with binary"),414 +            "full body: {msg}"415 +        );416 +        assert!(message(&repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").is_err());417 +    }418 +419      #[test]420      fn diff_contains_hunk() {421          let (repo, branch) = fixture_repo();422 diff --git a/src/main.rs b/src/main.rs423 index f7902e5..589e3bd 100644424 --- a/src/main.rs425 +++ b/src/main.rs426 @@ -1,6 +1,8 @@427  mod build;428 +mod commits;429  mod gitx;430  mod layout;431 +mod wiki;432  433  const VERSION: &str = env!("CARGO_PKG_VERSION");434  435 diff --git a/src/wiki.rs b/src/wiki.rs436 new file mode 100644437 index 0000000..899cb1e438 --- /dev/null439 +++ b/src/wiki.rs440 @@ -0,0 +1,322 @@441 +//! Wiki pages (m2, f0rge issue #5): `wiki/index.html` + `wiki/<path>.html`.442 +//!443 +//! Source is the on-disk `<repo>/.forge/wiki/` tree (NOT git objects):444 +//! symlinks are skipped, files over 256 KiB are skipped, titles come from445 +//! the first `# H1` else the filename, entries sort by path. Wiki pages446 +//! carry no frontmatter required fields; any file that cannot be read as447 +//! UTF-8 (or cannot be read at all) is skipped with an `eprintln!`448 +//! warning and the build still exits 0.449 +//!450 +//! SECURITY: bodies are untrusted markdown rendered with451 +//! [`crate::build::markdown_to_html`] (raw HTML escaped); titles and452 +//! paths go through [`crate::layout::esc`].453 +454 +use std::collections::BTreeMap;455 +use std::path::Path;456 +457 +use crate::build::markdown_to_html;458 +use crate::layout::{esc, page};459 +460 +/// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).461 +pub const WIKI_MAX_BYTES: u64 = 256 * 1024;462 +463 +/// One rendered wiki page.464 +#[derive(Debug, Clone, PartialEq, Eq)]465 +pub struct WikiPage {466 +    /// Wiki-relative `/`-separated path (e.g. `docs/guide.md`).467 +    pub path: String,468 +    /// First `# H1` else the filename.469 +    pub title: String,470 +}471 +472 +fn is_h1_space(c: char) -> bool {473 +    c == ' ' || c == '\t'474 +}475 +476 +/// First `# H1` in `body`, else `filename`.477 +///478 +/// Only level-1 headings count (`##` is ignored); an empty `#` keeps479 +/// searching; an optional CommonMark closing run (`# Title #`) is stripped.480 +#[must_use]481 +pub fn page_title(body: &str, filename: &str) -> String {482 +    for line in body.lines() {483 +        let t = line.trim_start();484 +        let Some(rest) = t.strip_prefix('#') else {485 +            continue;486 +        };487 +        if rest.starts_with('#') {488 +            continue;489 +        }490 +        if !rest.is_empty() && !rest.starts_with(is_h1_space) {491 +            continue;492 +        }493 +        let mut title = rest.trim().to_string();494 +        if let Some(hash) = title.rfind('#') {495 +            let (head, tail) = title.split_at(hash);496 +            if tail.chars().all(|c| c == '#') && head.ends_with(is_h1_space) {497 +                title = head.trim_end().to_string();498 +            }499 +        }500 +        if !title.is_empty() {501 +            return title;502 +        }503 +    }504 +    filename.to_string()505 +}506 +507 +/// Render `wiki/index.html` plus one `wiki/<path>.html` per page.508 +///509 +/// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki510 +/// pages yet.") with no warning. Returns the pages sorted by path.511 +pub fn render(512 +    repo: &Path,513 +    out: &Path,514 +    site_name: &str,515 +    nav: &str,516 +) -> Result<Vec<WikiPage>, String> {517 +    let root = repo.join(".forge").join("wiki");518 +    let mut rel_files: Vec<String> = Vec::new();519 +    if root.exists() {520 +        // Iterative walk over `/`-separated relative dirs. Symlinked dirs521 +        // are skipped (never descended); symlinked files are skipped later.522 +        let mut stack: Vec<String> = vec![String::new()];523 +        while let Some(dir) = stack.pop() {524 +            let abs = if dir.is_empty() {525 +                root.clone()526 +            } else {527 +                root.join(&dir)528 +            };529 +            let rd = match std::fs::read_dir(&abs) {530 +                Ok(rd) => rd,531 +                Err(e) => {532 +                    let show = if dir.is_empty() {533 +                        ".".to_string()534 +                    } else {535 +                        dir.clone()536 +                    };537 +                    eprintln!("hu13: wiki: cannot list {show}: {e}");538 +                    continue;539 +                }540 +            };541 +            for ent in rd {542 +                let ent = match ent {543 +                    Ok(ent) => ent,544 +                    Err(e) => {545 +                        eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");546 +                        continue;547 +                    }548 +                };549 +                let meta = match std::fs::symlink_metadata(ent.path()) {550 +                    Ok(m) => m,551 +                    Err(e) => {552 +                        eprintln!(553 +                            "hu13: wiki: cannot stat {}: {e}",554 +                            ent.file_name().to_string_lossy()555 +                        );556 +                        continue;557 +                    }558 +                };559 +                if meta.file_type().is_symlink() {560 +                    eprintln!(561 +                        "hu13: wiki: skipping symlink {}",562 +                        ent.path().to_string_lossy()563 +                    );564 +                    continue;565 +                }566 +                let name = ent.file_name().to_string_lossy().into_owned();567 +                let rel = if dir.is_empty() {568 +                    name569 +                } else {570 +                    format!("{dir}/{name}")571 +                };572 +                if meta.is_dir() {573 +                    stack.push(rel);574 +                } else if meta.is_file() {575 +                    rel_files.push(rel);576 +                }577 +            }578 +        }579 +    }580 +    rel_files.sort();581 +582 +    let mut pages: Vec<WikiPage> = Vec::new();583 +    let mut bodies: BTreeMap<String, String> = BTreeMap::new();584 +    for rel in rel_files {585 +        let abs = root.join(&rel);586 +        let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);587 +        if len > WIKI_MAX_BYTES {588 +            eprintln!("hu13: wiki: skipping {rel}: over 256 KiB ({len} bytes)");589 +            continue;590 +        }591 +        let bytes = match std::fs::read(&abs) {592 +            Ok(b) => b,593 +            Err(e) => {594 +                eprintln!("hu13: wiki: skipping {rel}: cannot read: {e}");595 +                continue;596 +            }597 +        };598 +        if bytes.len() as u64 > WIKI_MAX_BYTES {599 +            eprintln!("hu13: wiki: skipping {rel}: over 256 KiB");600 +            continue;601 +        }602 +        let text = match String::from_utf8(bytes) {603 +            Ok(s) => s,604 +            Err(_) => {605 +                eprintln!("hu13: wiki: skipping {rel}: not valid UTF-8");606 +                continue;607 +            }608 +        };609 +        let filename = rel.rsplit('/').next().unwrap_or(&rel);610 +        let title = page_title(&text, filename);611 +        bodies.insert(rel.clone(), text);612 +        pages.push(WikiPage { path: rel, title });613 +    }614 +615 +    let wiki_dir = out.join("wiki");616 +    std::fs::create_dir_all(&wiki_dir).map_err(|e| format!("hu13: create wiki dir: {e}"))?;617 +618 +    let mut body = String::from("<h1>Wiki</h1>\n");619 +    if pages.is_empty() {620 +        body.push_str("<p class=\"muted\">No wiki pages yet.</p>\n");621 +    } else {622 +        body.push_str("<ul class=\"wiki-index\">\n");623 +        for p in &pages {624 +            body.push_str(&format!(625 +                "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",626 +                esc(&p.path),627 +                esc(&p.title),628 +                esc(&p.path),629 +            ));630 +        }631 +        body.push_str("</ul>\n");632 +    }633 +    std::fs::write(wiki_dir.join("index.html"), page("wiki", nav, &body))634 +        .map_err(|e| format!("hu13: write wiki index: {e}"))?;635 +    let _ = site_name;636 +637 +    for p in &pages {638 +        let text = bodies.get(&p.path).cloned().unwrap_or_default();639 +        let mut body = format!(640 +            "<h1>{}</h1>\n<p class=\"muted\"><code>{}</code> · <a href=\"/wiki/index.html\">wiki index</a></p>\n",641 +            esc(&p.title),642 +            esc(&p.path),643 +        );644 +        body.push_str(&markdown_to_html(&text));645 +        let html = page(&format!("{} — wiki", p.title), nav, &body);646 +        let dest = wiki_dir.join(format!("{}.html", p.path));647 +        if let Some(parent) = dest.parent() {648 +            std::fs::create_dir_all(parent)649 +                .map_err(|e| format!("hu13: create wiki parent: {e}"))?;650 +        }651 +        std::fs::write(&dest, html)652 +            .map_err(|e| format!("hu13: write wiki page {}: {e}", p.path))?;653 +    }654 +    Ok(pages)655 +}656 +657 +#[cfg(test)]658 +mod tests {659 +    use super::*;660 +    use std::path::PathBuf;661 +    use std::sync::atomic::{AtomicU64, Ordering};662 +663 +    static COUNTER: AtomicU64 = AtomicU64::new(0);664 +665 +    fn mktemp(prefix: &str) -> PathBuf {666 +        let id = COUNTER.fetch_add(1, Ordering::SeqCst);667 +        let dir =668 +            std::env::temp_dir().join(format!("hu13-wiki-{prefix}-{}-{id}", std::process::id()));669 +        std::fs::create_dir_all(&dir).expect("mktemp");670 +        dir671 +    }672 +673 +    fn wiki_file(repo: &Path, rel: &str, bytes: &[u8]) {674 +        let dest = repo.join(".forge").join("wiki").join(rel);675 +        std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");676 +        std::fs::write(&dest, bytes).expect("write wiki file");677 +    }678 +679 +    /// Fixture: H1 page, no-H1 fallback, nested page, symlink, oversize, bad UTF-8.680 +    fn fixture_repo() -> PathBuf {681 +        let dir = mktemp("fixture");682 +        wiki_file(683 +            &dir,684 +            "hello.md",685 +            b"# Hello World\n\nSome **bold** text.\n\n<script>alert(1)</script>\n",686 +        );687 +        wiki_file(&dir, "plain.md", b"Just some text without heading.\n");688 +        wiki_file(&dir, "docs/nested.md", b"# Nested Page\n\nnested body\n");689 +        #[cfg(unix)]690 +        std::os::unix::fs::symlink(691 +            dir.join(".forge").join("wiki").join("hello.md"),692 +            dir.join(".forge").join("wiki").join("link.md"),693 +        )694 +        .expect("symlink");695 +        wiki_file(&dir, "big.md", &vec![b'a'; 300_000]);696 +        wiki_file(&dir, "bad.md", b"\xff\xfe not valid utf-8\n");697 +        dir698 +    }699 +700 +    #[test]701 +    fn title_rules() {702 +        assert_eq!(page_title("# Hello\nbody\n", "f.md"), "Hello");703 +        assert_eq!(page_title("no heading\n", "plain.md"), "plain.md");704 +        assert_eq!(page_title("## Not H1\n# Real\n", "f.md"), "Real");705 +        assert_eq!(page_title("#nodot\n# Spaced\n", "f.md"), "Spaced");706 +        assert_eq!(page_title("#\n# Later\n", "f.md"), "Later");707 +        assert_eq!(page_title("# Title #\n", "f.md"), "Title");708 +    }709 +710 +    #[test]711 +    fn index_titles_sorted_skips_bad() {712 +        let repo = fixture_repo();713 +        let out = mktemp("out");714 +        let pages = render(&repo, &out, "test", "<nav>").expect("render");715 +        let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();716 +        assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);717 +        assert_eq!(pages[0].title, "Nested Page");718 +        assert_eq!(pages[1].title, "Hello World");719 +        assert_eq!(pages[2].title, "plain.md", "no-H1 falls back to filename");720 +721 +        let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");722 +        let n = index.find("docs/nested.md").expect("nested");723 +        let h = index.find("hello.md").expect("hello");724 +        let p = index.find("plain.md").expect("plain");725 +        assert!(n < h && h < p, "sorted by path");726 +        assert!(index.contains("Hello World"));727 +        assert!(!index.contains("link.md"), "symlink skipped");728 +        assert!(!index.contains("big.md"), "oversize skipped");729 +        assert!(!index.contains("bad.md"), "invalid UTF-8 skipped");730 +    }731 +732 +    #[test]733 +    fn pages_render_markdown_escaped() {734 +        let repo = fixture_repo();735 +        let out = mktemp("out");736 +        render(&repo, &out, "test", "<nav>").expect("render");737 +        let hello =738 +            std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");739 +        assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");740 +        assert!(741 +            hello.contains("<strong>bold</strong>"),742 +            "inline markdown intact"743 +        );744 +        assert!(745 +            !hello.contains("<script>alert(1)</script>"),746 +            "raw HTML escaped"747 +        );748 +        assert!(hello.contains("&lt;script&gt;"), "escaped form present");749 +        assert!(out.join("wiki").join("docs/nested.md.html").exists());750 +        assert!(out.join("wiki").join("plain.md.html").exists());751 +    }752 +753 +    #[test]754 +    fn missing_wiki_dir_renders_empty_index() {755 +        let dir = mktemp("nowiki");756 +        let out = mktemp("out");757 +        let pages = render(&dir, &out, "test", "<nav>").expect("render");758 +        assert!(pages.is_empty());759 +        let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");760 +        assert!(index.contains("No wiki pages yet."));761 +    }762 +}763 -- 764 2.55.0765