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::{href_esc, markdown_to_html_in};18 use crate::layout::{esc, page_site_with_provenance};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 /// Byte size of the source file.31 pub size: u64,32 }33 34 fn is_h1_space(c: char) -> bool {35 c == ' ' || c == '\t'36 }37 38 /// First `# H1` in `body`, else `filename`.39 ///40 /// Only level-1 headings count (`##` is ignored); an empty `#` keeps41 /// searching; an optional CommonMark closing run (`# Title #`) is stripped.42 #[must_use]43 pub fn page_title(body: &str, filename: &str) -> String {44 for line in body.lines() {45 let t = line.trim_start();46 let Some(rest) = t.strip_prefix('#') else {47 continue;48 };49 if rest.starts_with('#') {50 continue;51 }52 if !rest.is_empty() && !rest.starts_with(is_h1_space) {53 continue;54 }55 let mut title = rest.trim().to_string();56 if let Some(hash) = title.rfind('#') {57 let (head, tail) = title.split_at(hash);58 if tail.chars().all(|c| c == '#') && head.ends_with(is_h1_space) {59 title = head.trim_end().to_string();60 }61 }62 if !title.is_empty() {63 return title;64 }65 }66 filename.to_string()67 }68 69 /// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38).70 ///71 /// Same walk + filters as [`render`] (symlinked dirs/files skipped,72 /// oversize files skipped, non-UTF-8 files skipped), so the header nav73 /// hides the wiki tab exactly when the index would say "No wiki pages74 /// yet". Read-only: emits no files, only the `eprintln!` warnings that75 /// [`render`] would emit for skipped entries.76 #[must_use]77 pub fn page_count(repo: &Path) -> usize {78 let root = crate::paths::repo_subdir(repo, ".forge/wiki")79 .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));80 if !root.exists() {81 return 0;82 }83 // Same walk as `render`: `/`-separated relative dirs, symlinked84 // dirs never descended, symlinked files skipped later.85 let mut rel_files: Vec<String> = Vec::new();86 let mut stack: Vec<String> = vec![String::new()];87 while let Some(dir) = stack.pop() {88 let abs = if dir.is_empty() {89 root.clone()90 } else {91 root.join(&dir)92 };93 let rd = match std::fs::read_dir(&abs) {94 Ok(rd) => rd,95 Err(e) => {96 eprintln!(97 "hu13: wiki: cannot list {}: {e}",98 if dir.is_empty() { "." } else { &dir }99 );100 continue;101 }102 };103 for ent in rd {104 let ent = match ent {105 Ok(ent) => ent,106 Err(e) => {107 eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");108 continue;109 }110 };111 let meta = match std::fs::symlink_metadata(ent.path()) {112 Ok(m) => m,113 Err(e) => {114 eprintln!(115 "hu13: wiki: cannot stat {}: {e}",116 ent.file_name().to_string_lossy()117 );118 continue;119 }120 };121 if meta.file_type().is_symlink() {122 continue;123 }124 let name = ent.file_name().to_string_lossy().into_owned();125 let rel = if dir.is_empty() {126 name127 } else {128 format!("{dir}/{name}")129 };130 if meta.is_dir() {131 stack.push(rel);132 } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {133 rel_files.push(rel);134 }135 }136 }137 // Same per-file filters as `render`: oversize + unreadable +138 // non-UTF-8 files never become pages, so they never keep the tab.139 let mut count = 0;140 for rel in rel_files {141 let abs = root.join(&rel);142 let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);143 if len > WIKI_MAX_BYTES {144 continue;145 }146 let bytes = match std::fs::read(&abs) {147 Ok(b) => b,148 Err(_) => continue,149 };150 if bytes.len() as u64 > WIKI_MAX_BYTES {151 continue;152 }153 if std::str::from_utf8(&bytes).is_err() {154 continue;155 }156 count += 1;157 }158 count159 }160 /// Render `wiki/index.html` plus one `wiki/<path>.html` per page.161 ///162 /// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki163 /// pages yet.") with no warning. Returns the pages sorted by path.164 pub fn render(165 repo: &Path,166 out: &Path,167 site_name: &str,168 nav: &str,169 branch: &str,170 short_sha: &str,171 description: Option<&str>,172 ) -> Result<Vec<WikiPage>, String> {173 let root = crate::paths::repo_subdir(repo, ".forge/wiki")174 .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));175 let mut rel_files: Vec<String> = Vec::new();176 if root.exists() {177 // Iterative walk over `/`-separated relative dirs. Symlinked dirs178 // are skipped (never descended); symlinked files are skipped later.179 let mut stack: Vec<String> = vec![String::new()];180 while let Some(dir) = stack.pop() {181 let abs = if dir.is_empty() {182 root.clone()183 } else {184 root.join(&dir)185 };186 let rd = match std::fs::read_dir(&abs) {187 Ok(rd) => rd,188 Err(e) => {189 let show = if dir.is_empty() {190 ".".to_string()191 } else {192 dir.clone()193 };194 eprintln!("hu13: wiki: cannot list {show}: {e}");195 continue;196 }197 };198 for ent in rd {199 let ent = match ent {200 Ok(ent) => ent,201 Err(e) => {202 eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");203 continue;204 }205 };206 let meta = match std::fs::symlink_metadata(ent.path()) {207 Ok(m) => m,208 Err(e) => {209 eprintln!(210 "hu13: wiki: cannot stat {}: {e}",211 ent.file_name().to_string_lossy()212 );213 continue;214 }215 };216 if meta.file_type().is_symlink() {217 eprintln!(218 "hu13: wiki: skipping symlink {}",219 ent.path().to_string_lossy()220 );221 continue;222 }223 let name = ent.file_name().to_string_lossy().into_owned();224 let rel = if dir.is_empty() {225 name226 } else {227 format!("{dir}/{name}")228 };229 if meta.is_dir() {230 stack.push(rel);231 } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {232 rel_files.push(rel);233 }234 }235 }236 }237 rel_files.sort();238 239 let mut pages: Vec<WikiPage> = Vec::new();240 let mut bodies: BTreeMap<String, String> = BTreeMap::new();241 for rel in rel_files {242 let abs = root.join(&rel);243 let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);244 if len > WIKI_MAX_BYTES {245 eprintln!("hu13: wiki: skipping {rel}: over 256 KiB ({len} bytes)");246 continue;247 }248 let bytes = match std::fs::read(&abs) {249 Ok(b) => b,250 Err(e) => {251 eprintln!("hu13: wiki: skipping {rel}: cannot read: {e}");252 continue;253 }254 };255 if bytes.len() as u64 > WIKI_MAX_BYTES {256 eprintln!("hu13: wiki: skipping {rel}: over 256 KiB");257 continue;258 }259 let text = match String::from_utf8(bytes) {260 Ok(s) => s,261 Err(_) => {262 eprintln!("hu13: wiki: skipping {rel}: not valid UTF-8");263 continue;264 }265 };266 let filename = rel.rsplit('/').next().unwrap_or(&rel);267 let title = page_title(&text, filename);268 let size = text.len() as u64;269 bodies.insert(rel.clone(), text);270 pages.push(WikiPage {271 path: rel,272 title,273 size,274 });275 }276 277 let wiki_dir = out.join("wiki");278 std::fs::create_dir_all(&wiki_dir).map_err(|e| format!("hu13: create wiki dir: {e}"))?;279 280 let mut body = String::from("<div class=\"page-head\"><h1>Wiki</h1></div>\n");281 if pages.is_empty() {282 body.push_str("<p class=\"muted\">No wiki pages yet.</p>\n");283 } else {284 body.push_str("<ul class=\"wiki-index\">\n");285 for p in &pages {286 // Root pages already read as "hu13 README"; only nested pages287 // need the path hint (was "hu13 README README", f0rge #68).288 let hint = if p.path.contains('/') {289 format!(" <span class=\"muted\">{}</span>", esc(&p.path))290 } else {291 String::new()292 };293 let href = href_esc(&format!("/wiki/{}.html", p.path));294 body.push_str(&format!(295 "<li><a href=\"{href}\">{}</a>{hint}</li>\n",296 esc(&p.title),297 ));298 }299 body.push_str("</ul>\n");300 }301 std::fs::write(302 wiki_dir.join("index.html"),303 page_site_with_provenance(304 &format!("wiki — {site_name}"),305 nav,306 &body,307 site_name,308 description,309 branch,310 short_sha,311 ),312 )313 .map_err(|e| format!("hu13: write wiki index: {e}"))?;314 315 for p in &pages {316 let text = bodies.get(&p.path).cloned().unwrap_or_default();317 let mut body = format!(318 "<h1>{}</h1>\n<p class=\"muted\"><code>{}</code> · <a href=\"/wiki/index.html\">wiki index</a></p>\n",319 esc(&p.title),320 esc(&p.path),321 );322 let base = p.path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");323 body.push_str(&markdown_to_html_in(&text, base, true));324 let html = page_site_with_provenance(325 &format!("{} — wiki — {site_name}", p.title),326 nav,327 &body,328 site_name,329 description,330 branch,331 short_sha,332 );333 let dest = wiki_dir.join(format!("{}.html", p.path));334 if let Some(parent) = dest.parent() {335 std::fs::create_dir_all(parent)336 .map_err(|e| format!("hu13: create wiki parent: {e}"))?;337 }338 std::fs::write(&dest, html)339 .map_err(|e| format!("hu13: write wiki page {}: {e}", p.path))?;340 }341 Ok(pages)342 }343 344 #[cfg(test)]345 mod tests {346 use super::*;347 use std::path::PathBuf;348 use std::sync::atomic::{AtomicU64, Ordering};349 350 static COUNTER: AtomicU64 = AtomicU64::new(0);351 352 fn mktemp(prefix: &str) -> PathBuf {353 let id = COUNTER.fetch_add(1, Ordering::SeqCst);354 let dir =355 std::env::temp_dir().join(format!("hu13-wiki-{prefix}-{}-{id}", std::process::id()));356 std::fs::create_dir_all(&dir).expect("mktemp");357 dir358 }359 360 fn wiki_file(repo: &Path, rel: &str, bytes: &[u8]) {361 let dest = repo.join(".forge").join("wiki").join(rel);362 std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");363 std::fs::write(&dest, bytes).expect("write wiki file");364 }365 366 /// Fixture: H1 page, no-H1 fallback, nested page, symlink, oversize, bad UTF-8.367 fn fixture_repo() -> PathBuf {368 let dir = mktemp("fixture");369 wiki_file(370 &dir,371 "hello.md",372 b"# Hello World\n\nSome **bold** text.\n\n<script>alert(1)</script>\n",373 );374 wiki_file(&dir, "plain.md", b"Just some text without heading.\n");375 wiki_file(&dir, "docs/nested.md", b"# Nested Page\n\nnested body\n");376 #[cfg(unix)]377 std::os::unix::fs::symlink(378 dir.join(".forge").join("wiki").join("hello.md"),379 dir.join(".forge").join("wiki").join("link.md"),380 )381 .expect("symlink");382 wiki_file(&dir, "big.md", &vec![b'a'; 300_000]);383 wiki_file(&dir, "bad.md", b"\xff\xfe not valid utf-8\n");384 dir385 }386 387 #[test]388 fn title_rules() {389 assert_eq!(page_title("# Hello\nbody\n", "f.md"), "Hello");390 assert_eq!(page_title("no heading\n", "plain.md"), "plain.md");391 assert_eq!(page_title("## Not H1\n# Real\n", "f.md"), "Real");392 assert_eq!(page_title("#nodot\n# Spaced\n", "f.md"), "Spaced");393 assert_eq!(page_title("#\n# Later\n", "f.md"), "Later");394 assert_eq!(page_title("# Title #\n", "f.md"), "Title");395 }396 397 #[test]398 fn index_titles_sorted_skips_bad() {399 let repo = fixture_repo();400 let out = mktemp("out");401 let pages = render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");402 let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();403 assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);404 assert_eq!(pages[0].title, "Nested Page");405 assert_eq!(pages[1].title, "Hello World");406 assert_eq!(pages[2].title, "plain.md", "no-H1 falls back to filename");407 408 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");409 let n = index.find("docs/nested.md").expect("nested");410 let h = index.find("hello.md").expect("hello");411 let p = index.find("plain.md").expect("plain");412 assert!(n < h && h < p, "sorted by path");413 assert!(index.contains("Hello World"));414 assert!(!index.contains("link.md"), "symlink skipped");415 assert!(!index.contains("big.md"), "oversize skipped");416 assert!(!index.contains("bad.md"), "invalid UTF-8 skipped");417 }418 419 #[test]420 fn pages_render_markdown_escaped() {421 let repo = fixture_repo();422 let out = mktemp("out");423 render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");424 let hello =425 std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");426 assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");427 assert!(428 hello.contains("<strong>bold</strong>"),429 "inline markdown intact"430 );431 assert!(432 !hello.contains("<script>alert(1)</script>"),433 "raw HTML escaped"434 );435 assert!(hello.contains("<script>"), "escaped form present");436 assert!(out.join("wiki").join("docs/nested.md.html").exists());437 assert!(out.join("wiki").join("plain.md.html").exists());438 }439 440 #[test]441 fn missing_wiki_dir_renders_empty_index() {442 let dir = mktemp("nowiki");443 let out = mktemp("out");444 let pages = render(&dir, &out, "test", "<nav>", "main", "abc1234", None).expect("render");445 assert!(pages.is_empty());446 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");447 assert!(index.contains("No wiki pages yet."));448 }449 }