#5 m2/commits-wiki
Patch
src/build.rs+47 −7--- 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--- /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("<script>"), "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--- 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--- 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--- /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("<script>"), "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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.