src/wiki.rs 16.0 KiB 449 lines Raw
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("&lt;script&gt;"), "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 }