#5 m2/commits-wiki

mergedpassopened
details
branches
master ← m2/commits-wiki
updated
id
d87e454a-045b-4d91-8924-5acac01a1d65

Patch

src/build.rs+47 −7
diff --git a/src/build.rs b/src/build.rsindex 5280528..4530f1a 100644--- a/src/build.rs+++ b/src/build.rs@@ -1,17 +1,19 @@1-//! `hu13 build` — code-tab static site (m1, f0rge issue #4).1+//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5).22 //!33 //! Reads one branch via [`crate::gitx`], emits `dist/`:44 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5-//! `repo.bundle` + `repo.tar.gz`, and `style.css`.5+//! `commits.html`, `commit/<sha>.html`, `wiki/index.html`,6+//! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.67 //!78 //! Bodies from the repo are semi-trusted but treated as untrusted:8-//! markdown goes through [`markdown_to_html`], which escapes raw HTML.9+//! markdown goes through [`markdown_to_html`], which escapes raw HTML;10+//! commit messages and diffs are escaped `<pre>` (never markdown).911 1012 use std::collections::BTreeMap;1113 use std::path::Path;1214 13-use crate::gitx;1415 use crate::layout::esc;16+use crate::{commits, gitx, wiki};1517 1618 /// Files at or above this size get a placeholder page + download link.1719 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;@@ -25,7 +27,7 @@ pub struct BuildOptions {2527     pub clone_url_flag: Option<String>,2628     /// `--title` site title override (else repo dir name).2729     pub title_override: Option<String>,28-    /// `--max-log` (accepted for CLI parity; commits land in m2).30+    /// `--max-log`: max commits listed in `commits.html` (default 100).2931     pub max_log: usize,3032 }3133 @@ -144,7 +146,7 @@ fn render_text_pre(display: &str) -> String {144146 }145147 146148 fn nav_code() -> String {147-    "<a href=\"/\">code</a> <!-- TODO(m3): commits/issues/pulls/tasks/ci/releases/wiki tabs -->"149+    "<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 -->"148150         .to_string()149151 }150152 @@ -156,7 +158,6 @@ fn write_style(out: &Path) -> Result<(), String> {156158 157159 /// Build the full static site. Returns a summary on success.158160 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {159-    let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity160161     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())161162         .map_err(|e| format!("hu13: {e}"))?;162163     let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;@@ -333,6 +334,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu333334         std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;334335     }335336 337+    // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.338+    let nav = nav_code();339+    commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;340+    wiki::render(repo, out, &name, &nav)?;341+336342     // Snapshot last so the index can link sizes.337343     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;338344     let bundle_size = std::fs::metadata(&snap.bundle)@@ -675,6 +681,40 @@ mod tests {675681         assert_eq!(path, "README.md");676682     }677683 684+    #[test]685+    fn build_emits_commits_and_wiki() {686+        let repo = fixture_repo();687+        // On-disk wiki with one page (proves the .forge — not git — read path).688+        std::fs::create_dir_all(repo.join(".forge").join("wiki")).unwrap();689+        std::fs::write(690+            repo.join(".forge").join("wiki").join("notes.md"),691+            "# Build notes\n\nwiki body\n",692+        )693+        .unwrap();694+        git(&repo, &["add", "-A"]);695+        git(&repo, &["commit", "-m", "add wiki"]);696+        let out = mktemp("out-commits-wiki");697+        run_build(&repo, &out, &opts()).expect("build");698+        // Commits wired in.699+        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");700+        assert!(list.contains("fixture commit"));701+        assert!(list.contains("add wiki"));702+        assert!(list.contains("/commit/"), "detail links");703+        let log = gitx::log_n(&repo, "main", 100).expect("log");704+        assert!(out705+            .join("commit")706+            .join(format!("{}.html", log[0].sha))707+            .exists());708+        // Wiki wired in (index + per-page), nav links present.709+        let index = std::fs::read_to_string(out.join("index.html")).expect("index");710+        assert!(index.contains("commits.html"), "nav links commits");711+        assert!(index.contains("wiki/index.html"), "nav links wiki");712+        let windex =713+            std::fs::read_to_string(out.join("wiki").join("index.html")).expect("wiki index");714+        assert!(windex.contains("Build notes"), "H1 title");715+        assert!(out.join("wiki").join("notes.md.html").exists());716+    }717+678718     #[test]679719     fn markdown_escapes_raw_html() {680720         let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");
src/commits.rs+243 −0
diff --git a/src/commits.rs b/src/commits.rsnew file mode 100644index 0000000..834971c--- /dev/null+++ b/src/commits.rs@@ -0,0 +1,243 @@1+//! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).2+//!3+//! Commit subjects, messages, and diffs are untrusted: everything goes4+//! through [`crate::layout::esc`] into an escaped `<pre>`. Diffs come from5+//! [`crate::gitx::diff`] (binary content noted, output truncated past6+//! 2000 lines with a marker). Nothing here goes through markdown.7+8+use std::path::Path;9+10+use crate::gitx;11+use crate::layout::{esc, page};12+13+/// Short display form of a full sha (first 7 hex chars, git-style).14+#[must_use]15+pub fn short_sha(sha: &str) -> &str {16+    sha.get(..7).unwrap_or(sha)17+}18+19+/// Render `commits.html` plus one `commit/<sha>.html` per listed commit.20+///21+/// Lists at most `max_log` newest-first commits (see `--max-log`).22+/// Returns the number of commits listed.23+pub fn render(24+    repo: &Path,25+    out: &Path,26+    branch: &str,27+    max_log: usize,28+    site_name: &str,29+    nav: &str,30+) -> Result<usize, String> {31+    let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;32+33+    let mut body = String::from("<h1>Commits</h1>\n");34+    if log.is_empty() {35+        body.push_str("<p class=\"muted\">No commits yet.</p>\n");36+    } else {37+        body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");38+        for c in &log {39+            body.push_str(&format!(40+                "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",41+                esc(&c.sha),42+                esc(short_sha(&c.sha)),43+                esc(&c.subject),44+                esc(&c.author),45+                esc(&c.date),46+            ));47+        }48+        body.push_str("</tbody>\n</table>\n");49+    }50+    let html = page(&format!("commits — {site_name}"), nav, &body);51+    std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;52+    std::fs::write(out.join("commits.html"), html)53+        .map_err(|e| format!("hu13: write commits.html: {e}"))?;54+55+    std::fs::create_dir_all(out.join("commit"))56+        .map_err(|e| format!("hu13: create commit dir: {e}"))?;57+    for c in &log {58+        let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;59+        let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;60+        let detail = format!(61+            "<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",62+            esc(short_sha(&c.sha)),63+            esc(&c.sha),64+            esc(&c.author),65+            esc(&c.date),66+            esc(&message),67+            esc(&diff),68+        );69+        let html = page(70+            &format!("commit {} — {site_name}", short_sha(&c.sha)),71+            nav,72+            &detail,73+        );74+        std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)75+            .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;76+    }77+    Ok(log.len())78+}79+80+#[cfg(test)]81+mod tests {82+    use super::*;83+    use std::path::PathBuf;84+    use std::process::Command;85+    use std::sync::atomic::{AtomicU64, Ordering};86+87+    static COUNTER: AtomicU64 = AtomicU64::new(0);88+89+    fn mktemp(prefix: &str) -> PathBuf {90+        let id = COUNTER.fetch_add(1, Ordering::SeqCst);91+        let dir =92+            std::env::temp_dir().join(format!("hu13-commits-{prefix}-{}-{id}", std::process::id()));93+        std::fs::create_dir_all(&dir).expect("mktemp");94+        dir95+    }96+97+    fn git(repo: &Path, args: &[&str]) {98+        let st = Command::new("git")99+            .arg("-C")100+            .arg(repo)101+            .args(args)102+            .status()103+            .expect("spawn git");104+        assert!(st.success(), "git {args:?} failed in {}", repo.display());105+    }106+107+    /// Fixture: 3 commits — plain text, evil message + binary change, huge diff.108+    fn fixture_repo() -> PathBuf {109+        let dir = mktemp("fixture");110+        git(&dir, &["init", "-b", "main"]);111+        git(&dir, &["config", "user.name", "hu13-test"]);112+        git(&dir, &["config", "user.email", "[email protected]"]);113+        git(&dir, &["config", "commit.gpgsign", "false"]);114+115+        // Commit 1: plain text file.116+        std::fs::write(dir.join("notes.txt"), "line one\n").unwrap();117+        git(&dir, &["add", "-A"]);118+        git(&dir, &["commit", "-m", "first commit"]);119+120+        // Commit 2: evil message + text hunk + binary file change.121+        std::fs::write(dir.join("notes.txt"), "line one\nline two added\n").unwrap();122+        let bin: Vec<u8> = (0u8..=255).collect();123+        std::fs::write(dir.join("bin.dat"), &bin).unwrap();124+        git(&dir, &["add", "-A"]);125+        git(126+            &dir,127+            &["commit", "-m", "second commit <script>alert(1)</script>"],128+        );129+130+        // Commit 3: huge diff (>2000 lines).131+        let mut big = String::new();132+        for i in 0..2500 {133+            big.push_str(&format!("line {i}\n"));134+        }135+        std::fs::write(dir.join("big.txt"), &big).unwrap();136+        git(&dir, &["add", "-A"]);137+        git(&dir, &["commit", "-m", "third commit huge"]);138+139+        dir140+    }141+142+    fn shas(repo: &Path) -> Vec<String> {143+        gitx::log_n(repo, "main", 100)144+            .expect("log")145+            .iter()146+            .map(|c| c.sha.clone())147+            .collect()148+    }149+150+    #[test]151+    fn list_order_newest_first_with_links() {152+        let repo = fixture_repo();153+        let out = mktemp("out");154+        let n = render(&repo, &out, "main", 100, "test", "<nav>").expect("render");155+        assert_eq!(n, 3);156+        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");157+        let p3 = list.find("third commit huge").expect("c3");158+        let p2 = list.find("second commit").expect("c2");159+        let p1 = list.find("first commit").expect("c1");160+        assert!(p3 < p2 && p2 < p1, "newest first");161+        for sha in shas(&repo) {162+            assert!(163+                list.contains(&format!("/commit/{sha}.html")),164+                "detail link for {sha}"165+            );166+            assert!(167+                list.contains(sha.get(..7).unwrap_or(&sha)),168+                "short sha for {sha}"169+            );170+        }171+        assert!(list.contains("hu13-test"), "author shown");172+    }173+174+    #[test]175+    fn detail_escapes_message_and_hunk() {176+        let repo = fixture_repo();177+        let out = mktemp("out");178+        render(&repo, &out, "main", 100, "test", "<nav>").expect("render");179+        let sha = shas(&repo)[1].clone(); // evil-message + binary commit180+        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))181+            .expect("detail");182+        assert!(183+            !detail.contains("<script>alert(1)</script>"),184+            "message escaped"185+        );186+        assert!(detail.contains("&lt;script&gt;"), "escaped form present");187+        assert!(188+            detail.contains("line two added"),189+            "diff hunk present: {detail}"190+        );191+        assert!(192+            detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),193+            "binary change noted"194+        );195+        assert!(detail.contains("<pre class=\"diff\">"), "diff in <pre>");196+    }197+198+    #[test]199+    fn huge_diff_truncates_with_marker() {200+        let repo = fixture_repo();201+        let out = mktemp("out");202+        render(&repo, &out, "main", 100, "test", "<nav>").expect("render");203+        let sha = shas(&repo)[0].clone(); // huge commit204+        let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))205+            .expect("detail");206+        assert!(207+            detail.contains(gitx::DIFF_TRUNCATED_MARKER),208+            "truncation marker shown"209+        );210+    }211+212+    #[test]213+    fn max_log_limits_list_and_details() {214+        let repo = fixture_repo();215+        let out = mktemp("out");216+        let n = render(&repo, &out, "main", 2, "test", "<nav>").expect("render");217+        assert_eq!(n, 2);218+        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");219+        assert!(list.contains("third commit huge"));220+        assert!(list.contains("second commit"));221+        assert!(!list.contains("first commit"), "oldest commit cut off");222+        let all = shas(&repo);223+        assert!(out.join("commit").join(format!("{}.html", all[0])).exists());224+        assert!(!out.join("commit").join(format!("{}.html", all[2])).exists());225+    }226+227+    #[test]228+    fn empty_log_renders_none_yet() {229+        let dir = mktemp("empty");230+        git(&dir, &["init", "-b", "main"]);231+        git(&dir, &["config", "user.name", "hu13-test"]);232+        git(&dir, &["config", "user.email", "[email protected]"]);233+        git(&dir, &["config", "commit.gpgsign", "false"]);234+        std::fs::write(dir.join("empty.txt"), "x\n").unwrap();235+        git(&dir, &["add", "-A"]);236+        git(&dir, &["commit", "-m", "only commit"]);237+        let out = mktemp("out");238+        let n = render(&dir, &out, "main", 0, "test", "<nav>").expect("render");239+        assert_eq!(n, 0);240+        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");241+        assert!(list.contains("No commits yet."));242+    }243+}
src/gitx.rs+31 −0
diff --git a/src/gitx.rs b/src/gitx.rsindex 10d0874..a4e11eb 100644--- a/src/gitx.rs+++ b/src/gitx.rs@@ -352,6 +352,25 @@ pub fn log_n(repo: &Path, branch: &str, n: usize) -> Result<Vec<Commit>, GitErro352352     Ok(out)353353 }354354 355+/// Full commit message body for `sha` (`git show -s --format=%B <sha>`).356+///357+/// Lossy UTF-8; callers must still escape it for HTML.358+pub fn message(repo: &Path, sha: &str) -> Result<String, GitError> {359+    let a_show: &OsStr = OsStr::new("show");360+    let a_silent: &OsStr = OsStr::new("-s");361+    let a_fmt: &OsStr = OsStr::new("--format=%B");362+    let os_sha = OsString::from(sha);363+    let args: Vec<&OsStr> = vec![a_show, a_silent, a_fmt, os_sha.as_os_str()];364+    let out = run_git(repo, &args)?;365+    if !out.status_ok {366+        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();367+        return Err(GitError::NotFound(format!(368+            "commit `{sha}` not found: {stderr}"369+        )));370+    }371+    Ok(String::from_utf8_lossy(&out.stdout).into_owned())372+}373+355374 /// Directories-first listing of `branch:path` (`path` empty = root).356375 ///357376 /// Within each group (dirs, then files) entries are sorted by exact@@ -704,6 +723,18 @@ mod tests {704723         }705724     }706725 726+    #[test]727+    fn message_returns_full_body() {728+        let (repo, branch) = fixture_repo();729+        let log = log_n(&repo, &branch, 100).expect("log");730+        let msg = message(&repo, &log[1].sha).expect("message");731+        assert!(732+            msg.contains("second commit with binary"),733+            "full body: {msg}"734+        );735+        assert!(message(&repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").is_err());736+    }737+707738     #[test]708739     fn diff_contains_hunk() {709740         let (repo, branch) = fixture_repo();
src/main.rs+2 −0
diff --git a/src/main.rs b/src/main.rsindex f7902e5..589e3bd 100644--- a/src/main.rs+++ b/src/main.rs@@ -1,6 +1,8 @@11 mod build;2+mod commits;23 mod gitx;34 mod layout;5+mod wiki;46 57 const VERSION: &str = env!("CARGO_PKG_VERSION");68 
src/wiki.rs+322 −1
diff --git a/src/wiki.rs b/src/wiki.rsnew file mode 100644index 0000000..899cb1e--- /dev/null+++ b/src/wiki.rs@@ -0,0 +1,322 @@1+//! Wiki pages (m2, f0rge issue #5): `wiki/index.html` + `wiki/<path>.html`.2+//!3+//! Source is the on-disk `<repo>/.forge/wiki/` tree (NOT git objects):4+//! symlinks are skipped, files over 256 KiB are skipped, titles come from5+//! the first `# H1` else the filename, entries sort by path. Wiki pages6+//! carry no frontmatter required fields; any file that cannot be read as7+//! UTF-8 (or cannot be read at all) is skipped with an `eprintln!`8+//! warning and the build still exits 0.9+//!10+//! SECURITY: bodies are untrusted markdown rendered with11+//! [`crate::build::markdown_to_html`] (raw HTML escaped); titles and12+//! paths go through [`crate::layout::esc`].13+14+use std::collections::BTreeMap;15+use std::path::Path;16+17+use crate::build::markdown_to_html;18+use crate::layout::{esc, page};19+20+/// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).21+pub const WIKI_MAX_BYTES: u64 = 256 * 1024;22+23+/// One rendered wiki page.24+#[derive(Debug, Clone, PartialEq, Eq)]25+pub struct WikiPage {26+    /// Wiki-relative `/`-separated path (e.g. `docs/guide.md`).27+    pub path: String,28+    /// First `# H1` else the filename.29+    pub title: String,30+}31+32+fn is_h1_space(c: char) -> bool {33+    c == ' ' || c == '\t'34+}35+36+/// First `# H1` in `body`, else `filename`.37+///38+/// Only level-1 headings count (`##` is ignored); an empty `#` keeps39+/// searching; an optional CommonMark closing run (`# Title #`) is stripped.40+#[must_use]41+pub fn page_title(body: &str, filename: &str) -> String {42+    for line in body.lines() {43+        let t = line.trim_start();44+        let Some(rest) = t.strip_prefix('#') else {45+            continue;46+        };47+        if rest.starts_with('#') {48+            continue;49+        }50+        if !rest.is_empty() && !rest.starts_with(is_h1_space) {51+            continue;52+        }53+        let mut title = rest.trim().to_string();54+        if let Some(hash) = title.rfind('#') {55+            let (head, tail) = title.split_at(hash);56+            if tail.chars().all(|c| c == '#') && head.ends_with(is_h1_space) {57+                title = head.trim_end().to_string();58+            }59+        }60+        if !title.is_empty() {61+            return title;62+        }63+    }64+    filename.to_string()65+}66+67+/// Render `wiki/index.html` plus one `wiki/<path>.html` per page.68+///69+/// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki70+/// pages yet.") with no warning. Returns the pages sorted by path.71+pub fn render(72+    repo: &Path,73+    out: &Path,74+    site_name: &str,75+    nav: &str,76+) -> Result<Vec<WikiPage>, String> {77+    let root = repo.join(".forge").join("wiki");78+    let mut rel_files: Vec<String> = Vec::new();79+    if root.exists() {80+        // Iterative walk over `/`-separated relative dirs. Symlinked dirs81+        // are skipped (never descended); symlinked files are skipped later.82+        let mut stack: Vec<String> = vec![String::new()];83+        while let Some(dir) = stack.pop() {84+            let abs = if dir.is_empty() {85+                root.clone()86+            } else {87+                root.join(&dir)88+            };89+            let rd = match std::fs::read_dir(&abs) {90+                Ok(rd) => rd,91+                Err(e) => {92+                    let show = if dir.is_empty() {93+                        ".".to_string()94+                    } else {95+                        dir.clone()96+                    };97+                    eprintln!("hu13: wiki: cannot list {show}: {e}");98+                    continue;99+                }100+            };101+            for ent in rd {102+                let ent = match ent {103+                    Ok(ent) => ent,104+                    Err(e) => {105+                        eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");106+                        continue;107+                    }108+                };109+                let meta = match std::fs::symlink_metadata(ent.path()) {110+                    Ok(m) => m,111+                    Err(e) => {112+                        eprintln!(113+                            "hu13: wiki: cannot stat {}: {e}",114+                            ent.file_name().to_string_lossy()115+                        );116+                        continue;117+                    }118+                };119+                if meta.file_type().is_symlink() {120+                    eprintln!(121+                        "hu13: wiki: skipping symlink {}",122+                        ent.path().to_string_lossy()123+                    );124+                    continue;125+                }126+                let name = ent.file_name().to_string_lossy().into_owned();127+                let rel = if dir.is_empty() {128+                    name129+                } else {130+                    format!("{dir}/{name}")131+                };132+                if meta.is_dir() {133+                    stack.push(rel);134+                } else if meta.is_file() {135+                    rel_files.push(rel);136+                }137+            }138+        }139+    }140+    rel_files.sort();141+142+    let mut pages: Vec<WikiPage> = Vec::new();143+    let mut bodies: BTreeMap<String, String> = BTreeMap::new();144+    for rel in rel_files {145+        let abs = root.join(&rel);146+        let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);147+        if len > WIKI_MAX_BYTES {148+            eprintln!("hu13: wiki: skipping {rel}: over 256 KiB ({len} bytes)");149+            continue;150+        }151+        let bytes = match std::fs::read(&abs) {152+            Ok(b) => b,153+            Err(e) => {154+                eprintln!("hu13: wiki: skipping {rel}: cannot read: {e}");155+                continue;156+            }157+        };158+        if bytes.len() as u64 > WIKI_MAX_BYTES {159+            eprintln!("hu13: wiki: skipping {rel}: over 256 KiB");160+            continue;161+        }162+        let text = match String::from_utf8(bytes) {163+            Ok(s) => s,164+            Err(_) => {165+                eprintln!("hu13: wiki: skipping {rel}: not valid UTF-8");166+                continue;167+            }168+        };169+        let filename = rel.rsplit('/').next().unwrap_or(&rel);170+        let title = page_title(&text, filename);171+        bodies.insert(rel.clone(), text);172+        pages.push(WikiPage { path: rel, title });173+    }174+175+    let wiki_dir = out.join("wiki");176+    std::fs::create_dir_all(&wiki_dir).map_err(|e| format!("hu13: create wiki dir: {e}"))?;177+178+    let mut body = String::from("<h1>Wiki</h1>\n");179+    if pages.is_empty() {180+        body.push_str("<p class=\"muted\">No wiki pages yet.</p>\n");181+    } else {182+        body.push_str("<ul class=\"wiki-index\">\n");183+        for p in &pages {184+            body.push_str(&format!(185+                "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",186+                esc(&p.path),187+                esc(&p.title),188+                esc(&p.path),189+            ));190+        }191+        body.push_str("</ul>\n");192+    }193+    std::fs::write(wiki_dir.join("index.html"), page("wiki", nav, &body))194+        .map_err(|e| format!("hu13: write wiki index: {e}"))?;195+    let _ = site_name;196+197+    for p in &pages {198+        let text = bodies.get(&p.path).cloned().unwrap_or_default();199+        let mut body = format!(200+            "<h1>{}</h1>\n<p class=\"muted\"><code>{}</code> · <a href=\"/wiki/index.html\">wiki index</a></p>\n",201+            esc(&p.title),202+            esc(&p.path),203+        );204+        body.push_str(&markdown_to_html(&text));205+        let html = page(&format!("{} — wiki", p.title), nav, &body);206+        let dest = wiki_dir.join(format!("{}.html", p.path));207+        if let Some(parent) = dest.parent() {208+            std::fs::create_dir_all(parent)209+                .map_err(|e| format!("hu13: create wiki parent: {e}"))?;210+        }211+        std::fs::write(&dest, html)212+            .map_err(|e| format!("hu13: write wiki page {}: {e}", p.path))?;213+    }214+    Ok(pages)215+}216+217+#[cfg(test)]218+mod tests {219+    use super::*;220+    use std::path::PathBuf;221+    use std::sync::atomic::{AtomicU64, Ordering};222+223+    static COUNTER: AtomicU64 = AtomicU64::new(0);224+225+    fn mktemp(prefix: &str) -> PathBuf {226+        let id = COUNTER.fetch_add(1, Ordering::SeqCst);227+        let dir =228+            std::env::temp_dir().join(format!("hu13-wiki-{prefix}-{}-{id}", std::process::id()));229+        std::fs::create_dir_all(&dir).expect("mktemp");230+        dir231+    }232+233+    fn wiki_file(repo: &Path, rel: &str, bytes: &[u8]) {234+        let dest = repo.join(".forge").join("wiki").join(rel);235+        std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");236+        std::fs::write(&dest, bytes).expect("write wiki file");237+    }238+239+    /// Fixture: H1 page, no-H1 fallback, nested page, symlink, oversize, bad UTF-8.240+    fn fixture_repo() -> PathBuf {241+        let dir = mktemp("fixture");242+        wiki_file(243+            &dir,244+            "hello.md",245+            b"# Hello World\n\nSome **bold** text.\n\n<script>alert(1)</script>\n",246+        );247+        wiki_file(&dir, "plain.md", b"Just some text without heading.\n");248+        wiki_file(&dir, "docs/nested.md", b"# Nested Page\n\nnested body\n");249+        #[cfg(unix)]250+        std::os::unix::fs::symlink(251+            dir.join(".forge").join("wiki").join("hello.md"),252+            dir.join(".forge").join("wiki").join("link.md"),253+        )254+        .expect("symlink");255+        wiki_file(&dir, "big.md", &vec![b'a'; 300_000]);256+        wiki_file(&dir, "bad.md", b"\xff\xfe not valid utf-8\n");257+        dir258+    }259+260+    #[test]261+    fn title_rules() {262+        assert_eq!(page_title("# Hello\nbody\n", "f.md"), "Hello");263+        assert_eq!(page_title("no heading\n", "plain.md"), "plain.md");264+        assert_eq!(page_title("## Not H1\n# Real\n", "f.md"), "Real");265+        assert_eq!(page_title("#nodot\n# Spaced\n", "f.md"), "Spaced");266+        assert_eq!(page_title("#\n# Later\n", "f.md"), "Later");267+        assert_eq!(page_title("# Title #\n", "f.md"), "Title");268+    }269+270+    #[test]271+    fn index_titles_sorted_skips_bad() {272+        let repo = fixture_repo();273+        let out = mktemp("out");274+        let pages = render(&repo, &out, "test", "<nav>").expect("render");275+        let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();276+        assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);277+        assert_eq!(pages[0].title, "Nested Page");278+        assert_eq!(pages[1].title, "Hello World");279+        assert_eq!(pages[2].title, "plain.md", "no-H1 falls back to filename");280+281+        let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");282+        let n = index.find("docs/nested.md").expect("nested");283+        let h = index.find("hello.md").expect("hello");284+        let p = index.find("plain.md").expect("plain");285+        assert!(n < h && h < p, "sorted by path");286+        assert!(index.contains("Hello World"));287+        assert!(!index.contains("link.md"), "symlink skipped");288+        assert!(!index.contains("big.md"), "oversize skipped");289+        assert!(!index.contains("bad.md"), "invalid UTF-8 skipped");290+    }291+292+    #[test]293+    fn pages_render_markdown_escaped() {294+        let repo = fixture_repo();295+        let out = mktemp("out");296+        render(&repo, &out, "test", "<nav>").expect("render");297+        let hello =298+            std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");299+        assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");300+        assert!(301+            hello.contains("<strong>bold</strong>"),302+            "inline markdown intact"303+        );304+        assert!(305+            !hello.contains("<script>alert(1)</script>"),306+            "raw HTML escaped"307+        );308+        assert!(hello.contains("&lt;script&gt;"), "escaped form present");309+        assert!(out.join("wiki").join("docs/nested.md.html").exists());310+        assert!(out.join("wiki").join("plain.md.html").exists());311+    }312+313+    #[test]314+    fn missing_wiki_dir_renders_empty_index() {315+        let dir = mktemp("nowiki");316+        let out = mktemp("out");317+        let pages = render(&dir, &out, "test", "<nav>").expect("render");318+        assert!(pages.is_empty());319+        let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");320+        assert!(index.contains("No wiki pages yet."));321+    }322+}0-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls