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("<script>"), "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("<script>"), "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