1 From c5a36e994f1541e553aa18b3f11623d69971ca51 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:40:22 +01004 Subject: [PATCH] m6/header-config: repo-name + description flags/env (1195 tests)6 7 ---8 DRAFT.md | 5 +-9 Dockerfile | 4 +10 entrypoint.sh | 9 ++11 src/agents.rs | 11 +-12 src/build.rs | 115 +++++++++++------13 src/commits.rs | 28 ++--14 src/feed.rs | 39 ++++--15 src/forge.rs | 88 +++++++++----16 src/inbox.rs | 23 +++-17 src/layout.rs | 47 +++++--18 src/main.rs | 27 +++-19 src/releases.rs | 24 +++-20 src/wiki.rs | 31 +++--21 tests/header_config.rs | 283 +++++++++++++++++++++++++++++++++++++++++22 14 files changed, 619 insertions(+), 115 deletions(-)23 create mode 100644 tests/header_config.rs24 25 diff --git a/DRAFT.md b/DRAFT.md26 index fe17448..c0527b1 10064427 --- a/DRAFT.md28 +++ b/DRAFT.md29 @@ -205,7 +205,10 @@ corners only (`border-radius: 0`). Semantic HTML so `curl` + reader-mode30 31 ```32 hu13 build /data/repo -o /srv/www [--branch X] [--clone-url URL]33 - [--title S] [--max-log 100]34 + [--repo-name S] [--description S] [--max-log 100]35 + # repo-name default: HU13_REPO_NAME else repo dir name (never blank;36 + # --title still works as a deprecated alias)37 + # description default: HU13_DESCRIPTION else hidden (no empty <p>)38 # clone-url default: git remote get-url origin at build, else none39 # caps tunable: --max-title 300 --max-body 20000 --max-bytes 3276840 # --rate-per-hour 2 --quota 500 (or HU13_* env)41 diff --git a/Dockerfile b/Dockerfile42 index 31b427c..f3997b5 10064443 --- a/Dockerfile44 +++ b/Dockerfile45 @@ -30,6 +30,10 @@ RUN apt-get update \46 COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu1347 COPY entrypoint.sh /usr/local/bin/entrypoint.sh48 RUN chmod +x /usr/local/bin/entrypoint.sh49 +# Header config defaults (m6, issues #21 + #24): empty means fallback —50 +# repo dir name for the name (never blank), hidden line for the description.51 +ENV HU13_REPO_NAME=""52 +ENV HU13_DESCRIPTION=""53 USER hu1354 EXPOSE 3931355 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]56 diff --git a/entrypoint.sh b/entrypoint.sh57 index 3cc51e5..6d4f149 10075558 --- a/entrypoint.sh59 +++ b/entrypoint.sh60 @@ -8,6 +8,9 @@61 # Env:62 # HU13_BRANCH branch to build (default: main/master auto-detect)63 # HU13_CLONE_URL clone URL to advertise (default: repo origin remote)64 +# HU13_REPO_NAME repo display name (default: repo dir name; never blank)65 +# HU13_DESCRIPTION66 +# site description (default: hidden, no empty <p>)67 # HU13_LISTEN serve address (default: 0.0.0.0:39313)68 # HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES /69 # HU13_RATE_PER_HOUR / HU13_QUOTA70 @@ -30,6 +33,12 @@ fi71 if [ -n "${HU13_CLONE_URL:-}" ]; then72 BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL"73 fi74 +if [ -n "${HU13_REPO_NAME:-}" ]; then75 + BUILD_ARGS="$BUILD_ARGS --repo-name $HU13_REPO_NAME"76 +fi77 +if [ -n "${HU13_DESCRIPTION:-}" ]; then78 + BUILD_ARGS="$BUILD_ARGS --description $HU13_DESCRIPTION"79 +fi80 81 # shellcheck disable=SC208682 hu13 build /data/repo -o /srv/www $BUILD_ARGS83 diff --git a/src/agents.rs b/src/agents.rs84 index 67caf5a..f0f3646 10064485 --- a/src/agents.rs86 +++ b/src/agents.rs87 @@ -15,7 +15,7 @@88 use std::path::Path;89 90 use crate::build::markdown_to_html;91 -use crate::layout::{esc, page};92 +use crate::layout::{esc, page_site};93 94 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).95 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,96 @@ -103,6 +103,7 @@ pub fn render(97 clone_url: Option<&str>,98 branch: &str,99 short_sha: &str,100 + description: Option<&str>,101 ) -> Result<(), String> {102 let site_e = esc(site_name);103 let branch_e = esc(branch);104 @@ -113,7 +114,13 @@ pub fn render(105 // source so the raw `agents.md` and rendered `agents.html` agree. The106 // markdown renderer escapes raw HTML again, so `&` etc. stay safe.107 let html_body = markdown_to_html(&md);108 - let html = page(&format!("agents — {site_name}"), nav, &html_body);109 + let html = page_site(110 + &format!("agents — {site_name}"),111 + nav,112 + &html_body,113 + site_name,114 + description,115 + );116 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;117 std::fs::write(out.join("agents.html"), html)118 .map_err(|e| format!("hu13: write agents.html: {e}"))?;119 diff --git a/src/build.rs b/src/build.rs120 index 09c8f51..d743377 100644121 --- a/src/build.rs122 +++ b/src/build.rs123 @@ -33,12 +33,60 @@ pub struct BuildOptions {124 pub branch_flag: Option<String>,125 /// `--clone-url` flag (else git origin remote, else none).126 pub clone_url_flag: Option<String>,127 - /// `--title` site title override (else repo dir name).128 - pub title_override: Option<String>,129 + /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).130 + /// `--title` is accepted as a deprecated alias for the same field.131 + /// Empty/whitespace always falls back (the header is never blank).132 + pub repo_name_flag: Option<String>,133 + /// `--description` flag (else `$HU13_DESCRIPTION`, else none).134 + /// Empty/whitespace means absent: the header description line is135 + /// hidden entirely (no empty `<p>`).136 + pub description_flag: Option<String>,137 /// `--max-log`: max commits listed in `commits.html` (default 100).138 pub max_log: usize,139 }140 141 +/// Resolve the repo display name (m6, f0rge issue #24).142 +///143 +/// Precedence: `--repo-name` flag > `$HU13_REPO_NAME` > directory name of144 +/// `repo` > `"repo"`. Flag and env values are trimmed; empty values fall145 +/// through so the header is never blank.146 +#[must_use]147 +pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {148 + if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {149 + return f.to_string();150 + }151 + if let Ok(env) = std::env::var("HU13_REPO_NAME") {152 + let t = env.trim();153 + if !t.is_empty() {154 + return t.to_string();155 + }156 + }157 + repo.file_name()158 + .map(|n| n.to_string_lossy().into_owned())159 + .map(|n| n.trim().to_string())160 + .filter(|n| !n.is_empty())161 + .unwrap_or_else(|| "repo".to_string())162 +}163 +164 +/// Resolve the site description (m6, f0rge issue #21).165 +///166 +/// Precedence: `--description` flag > `$HU13_DESCRIPTION` > absent167 +/// (`None`). Flag and env values are trimmed; empty values fall through168 +/// to absent, and an absent description hides the header line entirely.169 +#[must_use]170 +pub fn resolve_description(flag: Option<&str>) -> Option<String> {171 + if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {172 + return Some(f.to_string());173 + }174 + if let Ok(env) = std::env::var("HU13_DESCRIPTION") {175 + let t = env.trim();176 + if !t.is_empty() {177 + return Some(t.to_string());178 + }179 + }180 + None181 +}182 +183 /// Summary of one successful build (for logs/tests).184 #[derive(Debug, Clone)]185 pub struct BuildSummary {186 @@ -181,14 +229,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu187 let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;188 let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());189 190 - let name = match &opts.title_override {191 - Some(t) if !t.trim().is_empty() => t.trim().to_string(),192 - _ => repo193 - .file_name()194 - .map(|n| n.to_string_lossy().into_owned())195 - .filter(|n| !n.is_empty())196 - .unwrap_or_else(|| "repo".to_string()),197 - };198 + let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());199 + let description = resolve_description(opts.description_flag.as_deref());200 + let desc_opt = description.as_deref();201 202 // Walk the whole tree (dirs-first order from gitx), remembering each203 // dir's entries so dir pages need no second pass.204 @@ -216,21 +259,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu205 dirs = dir_entries.keys().cloned().collect();206 207 let readme = find_readme(repo, &branch);208 - let description = readme.as_ref().and_then(|(_, blob)| {209 - blob.display210 - .lines()211 - .map(str::trim)212 - .find(|l| !l.is_empty())213 - .map(|l| {214 - let l = l.strip_prefix('#').unwrap_or(l).trim();215 - if l.len() > 200 {216 - l[..200].to_string()217 - } else {218 - l.to_string()219 - }220 - })221 - .filter(|l| !l.is_empty())222 - });223 224 std::fs::create_dir_all(out.join("files"))225 .map_err(|e| format!("hu13: create files dir: {e}"))?;226 @@ -297,7 +325,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu227 }228 }229 body.push_str("</ul>\n");230 - let html = crate::layout::page(&format!("{dir} — {name}"), &nav, &body);231 + let html =232 + crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);233 let dest = out.join("files").join(format!("{dir}.html"));234 if let Some(parent) = dest.parent() {235 std::fs::create_dir_all(parent)236 @@ -349,7 +378,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu237 let display = String::from_utf8_lossy(&bytes).into_owned();238 body.push_str(&render_text_pre(&display));239 }240 - let html = crate::layout::page(&format!("{f} — {name}"), &nav, &body);241 + let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);242 let dest = out.join("files").join(format!("{f}.html"));243 if let Some(parent) = dest.parent() {244 std::fs::create_dir_all(parent)245 @@ -361,17 +390,25 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu246 // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):247 // one struct pass — the same loaded vecs feed both HTML and JSON.248 let commit_log = commits::load(repo, &branch, opts.max_log)?;249 - commits::render_loaded(repo, out, &commit_log, &name, &nav)?;250 - feed::render_loaded(repo, out, &branch, &name, &commit_log)?;251 - let wiki_pages = wiki::render(repo, out, &name, &nav)?;252 + commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;253 + feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;254 + let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;255 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.256 - forge::render_loaded(&forge_data, out, &name, &nav)?;257 + forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;258 let release_data = releases::load(repo);259 - releases::render_loaded(&release_data, repo, out, &name, &nav)?;260 + releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;261 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;262 - agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;263 + agents::render(264 + out,265 + &name,266 + &nav,267 + clone_url.as_deref(),268 + &branch,269 + &short_sha,270 + desc_opt,271 + )?;272 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.273 - crate::inbox::render_forms(out, &name, &nav)?;274 + crate::inbox::render_forms(out, &name, &nav, desc_opt)?;275 276 // Snapshot last so the index can link sizes.277 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;278 @@ -385,9 +422,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu279 // index.html header: stacked repo-head (name + clone + meta) so narrow280 // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.281 let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));282 - match &description {283 - Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),284 - None => body.push_str("<p class=\"muted\">No description.</p>\n"),285 + if let Some(d) = &description {286 + body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));287 }288 if let Some(url) = &clone_url {289 body.push_str(&format!(290 @@ -455,7 +491,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu291 }292 body.push_str("</section></div>\n");293 294 - let index = crate::layout::page(&name, &nav, &body);295 + let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);296 std::fs::write(out.join("index.html"), index)297 .map_err(|e| format!("hu13: write index.html: {e}"))?;298 299 @@ -518,7 +554,8 @@ mod tests {300 BuildOptions {301 branch_flag: None,302 clone_url_flag: None,303 - title_override: None,304 + repo_name_flag: None,305 + description_flag: None,306 max_log: 100,307 }308 }309 diff --git a/src/commits.rs b/src/commits.rs310 index a98c017..5abea19 100644311 --- a/src/commits.rs312 +++ b/src/commits.rs313 @@ -8,7 +8,7 @@314 use std::path::Path;315 316 use crate::gitx;317 -use crate::layout::{esc, page};318 +use crate::layout::{esc, page_site};319 320 /// Short display form of a full sha (first 7 hex chars, git-style).321 #[must_use]322 @@ -30,6 +30,7 @@ pub fn render_loaded(323 log: &[gitx::Commit],324 site_name: &str,325 nav: &str,326 + description: Option<&str>,327 ) -> Result<usize, String> {328 let mut body = String::from("<h1>Commits</h1>\n");329 if log.is_empty() {330 @@ -48,7 +49,13 @@ pub fn render_loaded(331 }332 body.push_str("</tbody>\n</table></div>\n");333 }334 - let html = page(&format!("commits — {site_name}"), nav, &body);335 + let html = page_site(336 + &format!("commits — {site_name}"),337 + nav,338 + &body,339 + site_name,340 + description,341 + );342 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;343 std::fs::write(out.join("commits.html"), html)344 .map_err(|e| format!("hu13: write commits.html: {e}"))?;345 @@ -67,10 +74,12 @@ pub fn render_loaded(346 esc(&message),347 esc(&diff),348 );349 - let html = page(350 + let html = page_site(351 &format!("commit {} — {site_name}", short_sha(&c.sha)),352 nav,353 &detail,354 + site_name,355 + description,356 );357 std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)358 .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;359 @@ -90,9 +99,10 @@ pub fn render(360 max_log: usize,361 site_name: &str,362 nav: &str,363 + description: Option<&str>,364 ) -> Result<usize, String> {365 let log = load(repo, branch, max_log)?;366 - render_loaded(repo, out, &log, site_name, nav)367 + render_loaded(repo, out, &log, site_name, nav, description)368 }369 370 #[cfg(test)]371 @@ -169,7 +179,7 @@ mod tests {372 fn list_order_newest_first_with_links() {373 let repo = fixture_repo();374 let out = mktemp("out");375 - let n = render(&repo, &out, "main", 100, "test", "<nav>").expect("render");376 + let n = render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");377 assert_eq!(n, 3);378 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");379 let p3 = list.find("third commit huge").expect("c3");380 @@ -193,7 +203,7 @@ mod tests {381 fn detail_escapes_message_and_hunk() {382 let repo = fixture_repo();383 let out = mktemp("out");384 - render(&repo, &out, "main", 100, "test", "<nav>").expect("render");385 + render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");386 let sha = shas(&repo)[1].clone(); // evil-message + binary commit387 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))388 .expect("detail");389 @@ -217,7 +227,7 @@ mod tests {390 fn huge_diff_truncates_with_marker() {391 let repo = fixture_repo();392 let out = mktemp("out");393 - render(&repo, &out, "main", 100, "test", "<nav>").expect("render");394 + render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");395 let sha = shas(&repo)[0].clone(); // huge commit396 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))397 .expect("detail");398 @@ -231,7 +241,7 @@ mod tests {399 fn max_log_limits_list_and_details() {400 let repo = fixture_repo();401 let out = mktemp("out");402 - let n = render(&repo, &out, "main", 2, "test", "<nav>").expect("render");403 + let n = render(&repo, &out, "main", 2, "test", "<nav>", None).expect("render");404 assert_eq!(n, 2);405 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");406 assert!(list.contains("third commit huge"));407 @@ -253,7 +263,7 @@ mod tests {408 git(&dir, &["add", "-A"]);409 git(&dir, &["commit", "-m", "only commit"]);410 let out = mktemp("out");411 - let n = render(&dir, &out, "main", 0, "test", "<nav>").expect("render");412 + let n = render(&dir, &out, "main", 0, "test", "<nav>", None).expect("render");413 assert_eq!(n, 0);414 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");415 assert!(list.contains("No commits yet."));416 diff --git a/src/feed.rs b/src/feed.rs417 index 437d787..d55d1c2 100644418 --- a/src/feed.rs419 +++ b/src/feed.rs420 @@ -138,14 +138,26 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F421 }422 423 /// Render the full `feed.atom` document for `entries`.424 +///425 +/// `site_name` becomes the feed `<title>`; `description` (m6, issue #21),426 +/// when present, becomes the feed `<subtitle>` (escaped; hostile markup427 +/// renders as text). `None` omits `<subtitle>` entirely.428 #[must_use]429 -pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> String {430 +pub fn render_feed(431 + site_name: &str,432 + branch: &str,433 + entries: &[FeedEntry],434 + description: Option<&str>,435 +) -> String {436 let updated = entries437 .first()438 .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));439 let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");440 out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");441 out.push_str(&format!("<title>{}</title>\n", esc(site_name)));442 + if let Some(d) = description {443 + out.push_str(&format!("<subtitle>{}</subtitle>\n", esc(d)));444 + }445 out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));446 out.push_str(&format!("<updated>{updated}</updated>\n"));447 out.push_str("<author><name>hu13</name></author>\n");448 @@ -165,9 +177,15 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri449 /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]450 /// right after commits). Returns the number of entries written.451 #[allow(dead_code)]452 -pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {453 +pub fn render(454 + repo: &Path,455 + out: &Path,456 + branch: &str,457 + site_name: &str,458 + description: Option<&str>,459 +) -> Result<usize, String> {460 let entries = collect_entries(repo, branch)?;461 - render_entries(out, site_name, branch, &entries)462 + render_entries(out, site_name, branch, &entries, description)463 }464 465 /// Build `feed.atom` from already-loaded commits (one struct pass).466 @@ -177,9 +195,10 @@ pub fn render_loaded(467 branch: &str,468 site_name: &str,469 log: &[gitx::Commit],470 + description: Option<&str>,471 ) -> Result<usize, String> {472 let entries = collect_entries_loaded(repo, log)?;473 - render_entries(out, site_name, branch, &entries)474 + render_entries(out, site_name, branch, &entries, description)475 }476 477 fn render_entries(478 @@ -187,8 +206,9 @@ fn render_entries(479 site_name: &str,480 branch: &str,481 entries: &[FeedEntry],482 + description: Option<&str>,483 ) -> Result<usize, String> {484 - let doc = render_feed(site_name, branch, entries);485 + let doc = render_feed(site_name, branch, entries, description);486 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;487 std::fs::write(out.join("feed.atom"), doc)488 .map_err(|e| format!("hu13: write feed.atom: {e}"))?;489 @@ -337,7 +357,7 @@ mod tests {490 let repo = fixture_repo();491 let entries = collect_entries(&repo, "main").expect("collect");492 assert!(entries.len() <= MAX_ENTRIES);493 - let xml = render_feed("test-site", "main", &entries);494 + let xml = render_feed("test-site", "main", &entries, None);495 check_well_formed(&xml);496 // Newest-first on the dated fixture.497 let titles = entry_titles(&xml);498 @@ -373,7 +393,7 @@ mod tests {499 let mut sorted = entries.clone();500 sorted.sort_by(|a, b| b.updated.cmp(&a.updated));501 sorted.truncate(MAX_ENTRIES);502 - let xml = render_feed("test", "main", &sorted);503 + let xml = render_feed("test", "main", &sorted, None);504 check_well_formed(&xml);505 assert_eq!(xml.matches("<entry>").count(), 50, "capped at 50");506 }507 @@ -390,7 +410,7 @@ mod tests {508 )509 .unwrap();510 let entries = collect_entries(&repo, "main").expect("collect");511 - let xml = render_feed("test", "main", &entries);512 + let xml = render_feed("test", "main", &entries, None);513 check_well_formed(&xml);514 assert!(515 xml.contains("v1.0"),516 @@ -411,7 +431,8 @@ mod tests {517 let opts = crate::build::BuildOptions {518 branch_flag: None,519 clone_url_flag: None,520 - title_override: None,521 + repo_name_flag: None,522 + description_flag: None,523 max_log: 100,524 };525 crate::build::run_build(&repo, &out, &opts).expect("build");526 diff --git a/src/forge.rs b/src/forge.rs527 index 868449e..5a7b3ec 100644528 --- a/src/forge.rs529 +++ b/src/forge.rs530 @@ -23,7 +23,7 @@ use std::path::Path;531 use serde::Deserialize;532 533 use crate::build::markdown_to_html;534 -use crate::layout::{esc, page};535 +use crate::layout::{esc, page_site};536 537 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).538 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;539 @@ -795,7 +795,12 @@ fn render_comment(c: &Comment) -> String {540 out541 }542 543 -fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {544 +fn render_issue_page(545 + issue: &Issue,546 + site_name: &str,547 + nav: &str,548 + description: Option<&str>,549 +) -> String {550 let mut body = format!(551 "<h1>#{n} {title}</h1>\n",552 n = issue.number,553 @@ -843,14 +848,16 @@ fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {554 body.push_str("</ul>\n");555 }556 body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");557 - page(558 + page_site(559 &format!("#{} {} — {site_name}", issue.number, issue.title),560 nav,561 &body,562 + site_name,563 + description,564 )565 }566 567 -fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {568 +fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {569 let mut body = format!(570 "<h1>#{n} {title}</h1>\n",571 n = pr.number,572 @@ -925,14 +932,21 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {573 body.push_str("</ul>\n");574 }575 body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");576 - page(577 + page_site(578 &format!("#{} {} — {site_name}", pr.number, pr.title),579 nav,580 &body,581 + site_name,582 + description,583 )584 }585 586 -fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<String, String> {587 +fn render_issues_index(588 + data: &ForgeData,589 + site_name: &str,590 + nav: &str,591 + description: Option<&str>,592 +) -> Result<String, String> {593 let mut body = String::from("<h1>Issues</h1>\n");594 if data.issues.is_empty() {595 body.push_str("<p class=\"muted\">No issues yet.</p>\n");596 @@ -969,10 +983,16 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S597 }598 body.push_str("</ul>\n");599 }600 - Ok(page(&format!("issues — {site_name}"), nav, &body))601 + Ok(page_site(602 + &format!("issues — {site_name}"),603 + nav,604 + &body,605 + site_name,606 + description,607 + ))608 }609 610 -fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {611 +fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {612 let mut body = String::from("<h1>Pulls</h1>\n");613 if prs.is_empty() {614 body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");615 @@ -992,10 +1012,21 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {616 }617 body.push_str("</tbody>\n</table></div>\n");618 }619 - page(&format!("pulls — {site_name}"), nav, &body)620 + page_site(621 + &format!("pulls — {site_name}"),622 + nav,623 + &body,624 + site_name,625 + description,626 + )627 }628 629 -fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String {630 +fn render_tasks_index(631 + tasks: &[TaskItem],632 + site_name: &str,633 + nav: &str,634 + description: Option<&str>,635 +) -> String {636 let mut body = String::from("<h1>Tasks</h1>\n");637 if tasks.is_empty() {638 body.push_str("<p class=\"muted\">No tasks yet.</p>\n");639 @@ -1029,10 +1060,16 @@ fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String640 }641 }642 }643 - page(&format!("tasks — {site_name}"), nav, &body)644 + page_site(645 + &format!("tasks — {site_name}"),646 + nav,647 + &body,648 + site_name,649 + description,650 + )651 }652 653 -fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {654 +fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {655 let mut body = String::from("<h1>CI</h1>\n");656 body.push_str(&meta_row("runner:", &esc(&ci.runner)));657 if ci.steps.is_empty() {658 @@ -1086,7 +1123,13 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {659 }660 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),661 }662 - page(&format!("ci — {site_name}"), nav, &body)663 + page_site(664 + &format!("ci — {site_name}"),665 + nav,666 + &body,667 + site_name,668 + description,669 + )670 }671 672 fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {673 @@ -1103,42 +1146,43 @@ pub fn render_loaded(674 out: &Path,675 site_name: &str,676 nav: &str,677 + description: Option<&str>,678 ) -> Result<(), String> {679 write_page(680 out,681 "issues/index.html",682 - &render_issues_index(data, site_name, nav)?,683 + &render_issues_index(data, site_name, nav, description)?,684 )?;685 for issue in &data.issues {686 write_page(687 out,688 &format!("issues/{}.html", issue.id),689 - &render_issue_page(issue, site_name, nav),690 + &render_issue_page(issue, site_name, nav, description),691 )?;692 }693 694 write_page(695 out,696 "pulls/index.html",697 - &render_pulls_index(&data.prs, site_name, nav),698 + &render_pulls_index(&data.prs, site_name, nav, description),699 )?;700 for pr in &data.prs {701 write_page(702 out,703 &format!("pulls/{}.html", pr.id),704 - &render_pr_page(pr, site_name, nav),705 + &render_pr_page(pr, site_name, nav, description),706 )?;707 }708 709 write_page(710 out,711 "tasks/index.html",712 - &render_tasks_index(&data.tasks, site_name, nav),713 + &render_tasks_index(&data.tasks, site_name, nav, description),714 )?;715 write_page(716 out,717 "ci/index.html",718 - &render_ci_index(&data.ci, site_name, nav),719 + &render_ci_index(&data.ci, site_name, nav, description),720 )?;721 722 Ok(())723 @@ -1269,7 +1313,7 @@ mod tests {724 assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));725 726 let out = mktemp("pr-out");727 - render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");728 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");729 let html =730 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))731 .expect("pr page");732 @@ -1314,7 +1358,7 @@ mod tests {733 assert_eq!(data.ci.latest_verdict(), "fail");734 735 let out = mktemp("taskci-out");736 - render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");737 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");738 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");739 for status in ["todo", "doing", "blocked", "done"] {740 assert!(tasks.contains(status), "group {status}");741 @@ -1335,7 +1379,7 @@ mod tests {742 assert!(data.tasks.is_empty());743 assert_eq!(data.ci.latest_verdict(), "none");744 let out = mktemp("empty-out");745 - render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");746 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");747 for page in [748 "issues/index.html",749 "pulls/index.html",750 diff --git a/src/inbox.rs b/src/inbox.rs751 index c6f23ef..4998fb0 100644752 --- a/src/inbox.rs753 +++ b/src/inbox.rs754 @@ -17,7 +17,7 @@755 756 use std::path::Path;757 758 -use crate::layout::{esc, page};759 +use crate::layout::{esc, page, page_site};760 761 /// Max chars for `title` (both kinds).762 pub const MAX_TITLE_CHARS: usize = 300;763 @@ -605,6 +605,7 @@ fn license_block() -> String {764 out765 }766 767 +#[allow(clippy::too_many_arguments)]768 fn form_page(769 site_name: &str,770 nav: &str,771 @@ -613,6 +614,7 @@ fn form_page(772 action: &str,773 body_name: &str,774 body_label: &str,775 + description: Option<&str>,776 ) -> String {777 let body = format!(778 "<h1>{heading}</h1>\n<p>{intro}</p>\n\779 @@ -641,11 +643,22 @@ fn form_page(780 body_label = esc(body_label),781 license = license_block(),782 );783 - page(&format!("{heading} — {site_name}"), nav, &body)784 + page_site(785 + &format!("{heading} — {site_name}"),786 + nav,787 + &body,788 + site_name,789 + description,790 + )791 }792 793 /// Write `new-issue.html` + `new-pr.html` into the built site.794 -pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String> {795 +pub fn render_forms(796 + out: &Path,797 + site_name: &str,798 + nav: &str,799 + description: Option<&str>,800 +) -> Result<(), String> {801 let issue = form_page(802 site_name,803 nav,804 @@ -654,6 +667,7 @@ pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String805 InboxType::Issue.route(),806 "body",807 "Body",808 + description,809 );810 let pr = form_page(811 site_name,812 @@ -663,6 +677,7 @@ pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String813 InboxType::PrPrompt.route(),814 "prompt",815 "Prompt",816 + description,817 );818 std::fs::write(out.join("new-issue.html"), issue)819 .map_err(|e| format!("hu13: write new-issue.html: {e}"))?;820 @@ -1015,7 +1030,7 @@ mod tests {821 .unwrap_or(0)822 ));823 std::fs::create_dir_all(&dir).unwrap();824 - render_forms(&dir, "test", "").unwrap();825 + render_forms(&dir, "test", "", None).unwrap();826 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();827 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();828 assert!(issue.contains("action=\"/inbox/issue\""));829 diff --git a/src/layout.rs b/src/layout.rs830 index 21f9214..af05dbd 100644831 --- a/src/layout.rs832 +++ b/src/layout.rs833 @@ -29,19 +29,48 @@ pub fn esc(input: &str) -> String {834 #[allow(dead_code)]835 #[must_use]836 pub fn page(title: &str, nav: &str, body: &str) -> String {837 - let title = esc(title);838 - format!(839 - "<!DOCTYPE html>\n\840 -<html lang=\"en\">\n\841 -<head>\n\842 -<meta charset=\"utf-8\">\n\843 + page_site(title, nav, body, "hu13", None)844 +}845 +846 +/// Render a full HTML page with site identity (m6 header config, f0rge847 +/// issues #21 + #24): the header brand shows `site_name`, `<title>` is848 +/// the escaped page `title` (callers build it as `"{page} \u2014 {site}"`849 +/// so the repo name is in every tab), `og:title` mirrors the page title,850 +/// and `og:description` / `meta[name=description]` carry `description`851 +/// when present. `site_name` and `description` are escaped (hostile852 +/// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`].853 +#[must_use]854 +pub fn page_site(855 + title: &str,856 + nav: &str,857 + body: &str,858 + site_name: &str,859 + description: Option<&str>,860 +) -> String {861 + let title_e = esc(title);862 + let brand = esc(site_name);863 + let mut head = format!(864 + "<meta charset=\"utf-8\">\n\865 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\866 <meta name=\"color-scheme\" content=\"dark light\">\n\867 <link rel=\"stylesheet\" href=\"/style.css\">\n\868 -<title>{title}</title>\n\869 -</head>\n\870 +<link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\871 +<title>{title_e}</title>\n\872 +<meta property=\"og:title\" content=\"{title_e}\">\n"873 + );874 + if let Some(d) = description {875 + let d_e = esc(d);876 + head.push_str(&format!(877 + "<meta name=\"description\" content=\"{d_e}\">\n\878 +<meta property=\"og:description\" content=\"{d_e}\">\n"879 + ));880 + }881 + format!(882 + "<!DOCTYPE html>\n\883 +<html lang=\"en\">\n\884 +<head>\n{head}</head>\n\885 <body>\n\886 -<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">hu13</span></div></header>\n\887 +<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">{brand}</span></div></header>\n\888 <nav class=\"site-nav\"><div class=\"wrap\">{nav}</div></nav>\n\889 <main class=\"wrap\">\n{body}\n</main>\n\890 </body>\n\891 diff --git a/src/main.rs b/src/main.rs892 index ee41cba..8e59824 100644893 --- a/src/main.rs894 +++ b/src/main.rs895 @@ -49,7 +49,10 @@ OPTIONS:896 -o <OUT> Output directory for the static site897 --branch <NAME> Branch to pin (else HU13_BRANCH, else try main/master)898 --clone-url <URL> Clone URL to advertise (else git origin remote, else none)899 - --title <S> Site title override900 + --repo-name <S> Repo display name (else HU13_REPO_NAME, else repo dir name;901 + --title is a deprecated alias; empty falls back, never blank)902 + --description <S> Site description (else HU13_DESCRIPTION, else hidden;903 + empty hides the header line, no empty <p>)904 --max-log <N> Max commits in log [default: 100]905 -h, --help Print this help906 "907 @@ -114,7 +117,8 @@ fn run_build(args: &[String]) -> i32 {908 let mut out: Option<String> = None;909 let mut branch: Option<String> = None;910 let mut clone_url: Option<String> = None;911 - let mut title: Option<String> = None;912 + let mut repo_name: Option<String> = None;913 + let mut description: Option<String> = None;914 let mut max_log: usize = 100;915 let mut i = 0;916 while i < args.len() {917 @@ -143,13 +147,21 @@ fn run_build(args: &[String]) -> i32 {918 }919 clone_url = Some(args[i].clone());920 }921 - "--title" => {922 + "--title" | "--repo-name" => {923 i += 1;924 if i >= args.len() {925 - eprintln!("hu13 build: --title needs a value");926 + eprintln!("hu13 build: --repo-name needs a value");927 return 2;928 }929 - title = Some(args[i].clone());930 + repo_name = Some(args[i].clone());931 + }932 + "--description" => {933 + i += 1;934 + if i >= args.len() {935 + eprintln!("hu13 build: --description needs a value");936 + return 2;937 + }938 + description = Some(args[i].clone());939 }940 "--max-log" => {941 i += 1;942 @@ -180,13 +192,14 @@ fn run_build(args: &[String]) -> i32 {943 i += 1;944 }945 let (Some(repo), Some(out)) = (repo, out) else {946 - eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--title S] [--max-log N]");947 + eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--repo-name S] [--description S] [--max-log N]");948 return 2;949 };950 let opts = build::BuildOptions {951 branch_flag: branch,952 clone_url_flag: clone_url,953 - title_override: title,954 + repo_name_flag: repo_name,955 + description_flag: description,956 max_log,957 };958 match build::run_build(959 diff --git a/src/releases.rs b/src/releases.rs960 index e05fe4c..ed79d48 100644961 --- a/src/releases.rs962 +++ b/src/releases.rs963 @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};964 use serde::Deserialize;965 966 use crate::build::markdown_to_html;967 -use crate::layout::{esc, page};968 +use crate::layout::{esc, page_site};969 970 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).971 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;972 @@ -318,7 +318,12 @@ fn artifact_href(version: &str, path: &str) -> String {973 format!("/releases/{}/{path}", esc(version))974 }975 976 -fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {977 +fn render_release_page(978 + rel: &Release,979 + site_name: &str,980 + nav: &str,981 + description: Option<&str>,982 +) -> String {983 let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));984 body.push_str(&format!(985 "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",986 @@ -359,10 +364,12 @@ fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {987 }988 }989 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");990 - page(991 + page_site(992 &format!("{} — releases — {site_name}", rel.display_title()),993 nav,994 &body,995 + site_name,996 + description,997 )998 }999 1000 @@ -374,6 +381,7 @@ pub fn render_loaded(1001 out: &Path,1002 site_name: &str,1003 nav: &str,1004 + description: Option<&str>,1005 ) -> Result<(), String> {1006 let root = repo.join(".forge").join("releases");1007 let mut body = String::from("<h1>Releases</h1>\n");1008 @@ -393,7 +401,13 @@ pub fn render_loaded(1009 }1010 body.push_str("</tbody>\n</table></div>\n");1011 }1012 - let index = page(&format!("releases — {site_name}"), nav, &body);1013 + let index = page_site(1014 + &format!("releases — {site_name}"),1015 + nav,1016 + &body,1017 + site_name,1018 + description,1019 + );1020 let dest = out.join("releases").join("index.html");1021 if let Some(parent) = dest.parent() {1022 std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;1023 @@ -409,7 +423,7 @@ pub fn render_loaded(1024 copy_tree(&src_dir, &dest_dir)?;1025 }1026 }1027 - let html = render_release_page(rel, site_name, nav);1028 + let html = render_release_page(rel, site_name, nav, description);1029 let page_dest = out.join("releases").join(format!("{}.html", rel.version));1030 if let Some(parent) = page_dest.parent() {1031 std::fs::create_dir_all(parent)1032 diff --git a/src/wiki.rs b/src/wiki.rs1033 index 345792d..4f20af8 1006441034 --- a/src/wiki.rs1035 +++ b/src/wiki.rs1036 @@ -15,7 +15,7 @@ use std::collections::BTreeMap;1037 use std::path::Path;1038 1039 use crate::build::markdown_to_html;1040 -use crate::layout::{esc, page};1041 +use crate::layout::{esc, page_site};1042 1043 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).1044 pub const WIKI_MAX_BYTES: u64 = 256 * 1024;1045 @@ -75,6 +75,7 @@ pub fn render(1046 out: &Path,1047 site_name: &str,1048 nav: &str,1049 + description: Option<&str>,1050 ) -> Result<Vec<WikiPage>, String> {1051 let root = repo.join(".forge").join("wiki");1052 let mut rel_files: Vec<String> = Vec::new();1053 @@ -197,9 +198,17 @@ pub fn render(1054 }1055 body.push_str("</ul>\n");1056 }1057 - std::fs::write(wiki_dir.join("index.html"), page("wiki", nav, &body))1058 - .map_err(|e| format!("hu13: write wiki index: {e}"))?;1059 - let _ = site_name;1060 + std::fs::write(1061 + wiki_dir.join("index.html"),1062 + page_site(1063 + &format!("wiki — {site_name}"),1064 + nav,1065 + &body,1066 + site_name,1067 + description,1068 + ),1069 + )1070 + .map_err(|e| format!("hu13: write wiki index: {e}"))?;1071 1072 for p in &pages {1073 let text = bodies.get(&p.path).cloned().unwrap_or_default();1074 @@ -209,7 +218,13 @@ pub fn render(1075 esc(&p.path),1076 );1077 body.push_str(&markdown_to_html(&text));1078 - let html = page(&format!("{} — wiki", p.title), nav, &body);1079 + let html = page_site(1080 + &format!("{} — wiki — {site_name}", p.title),1081 + nav,1082 + &body,1083 + site_name,1084 + description,1085 + );1086 let dest = wiki_dir.join(format!("{}.html", p.path));1087 if let Some(parent) = dest.parent() {1088 std::fs::create_dir_all(parent)1089 @@ -278,7 +293,7 @@ mod tests {1090 fn index_titles_sorted_skips_bad() {1091 let repo = fixture_repo();1092 let out = mktemp("out");1093 - let pages = render(&repo, &out, "test", "<nav>").expect("render");1094 + let pages = render(&repo, &out, "test", "<nav>", None).expect("render");1095 let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();1096 assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);1097 assert_eq!(pages[0].title, "Nested Page");1098 @@ -300,7 +315,7 @@ mod tests {1099 fn pages_render_markdown_escaped() {1100 let repo = fixture_repo();1101 let out = mktemp("out");1102 - render(&repo, &out, "test", "<nav>").expect("render");1103 + render(&repo, &out, "test", "<nav>", None).expect("render");1104 let hello =1105 std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");1106 assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");1107 @@ -321,7 +336,7 @@ mod tests {1108 fn missing_wiki_dir_renders_empty_index() {1109 let dir = mktemp("nowiki");1110 let out = mktemp("out");1111 - let pages = render(&dir, &out, "test", "<nav>").expect("render");1112 + let pages = render(&dir, &out, "test", "<nav>", None).expect("render");1113 assert!(pages.is_empty());1114 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");1115 assert!(index.contains("No wiki pages yet."));1116 diff --git a/tests/header_config.rs b/tests/header_config.rs1117 new file mode 1006441118 index 0000000..ef81f201119 --- /dev/null1120 +++ b/tests/header_config.rs1121 @@ -0,0 +1,283 @@1122 +//! Header config (m6, f0rge issues #21 + #24):1123 +//! `--repo-name`/`HU13_REPO_NAME` (flag > env > dirname, never blank) +1124 +//! `--description`/`HU13_DESCRIPTION` (flag > env > hidden, no empty `<p>`),1125 +//! threaded into header brand, `<title>`, og tags, and feed title/subtitle.1126 +//! Hostile `<script>` input renders as escaped text everywhere.1127 +1128 +use std::path::{Path, PathBuf};1129 +use std::process::Command;1130 +use std::sync::Mutex;1131 +1132 +static ENV_LOCK: Mutex<()> = Mutex::new(());1133 +1134 +fn git(repo: &Path, args: &[&str]) {1135 + let st = Command::new("git")1136 + .arg("-C")1137 + .arg(repo)1138 + .args(args)1139 + .status()1140 + .expect("spawn git");1141 + assert!(st.success(), "git {args:?} failed");1142 +}1143 +1144 +fn fresh(prefix: &str) -> PathBuf {1145 + let dir = std::env::temp_dir().join(format!(1146 + "hu13-header-{prefix}-{}-{}",1147 + std::process::id(),1148 + std::time::SystemTime::now()1149 + .duration_since(std::time::UNIX_EPOCH)1150 + .map(|d| d.as_nanos())1151 + .unwrap_or(0)1152 + ));1153 + std::fs::create_dir_all(&dir).unwrap();1154 + dir1155 +}1156 +1157 +/// Minimal repo: README (proves the README line is NOT used as the1158 +/// description fallback) + one file, one commit, no `.forge/`.1159 +fn fixture_repo() -> PathBuf {1160 + let dir = fresh("repo");1161 + git(&dir, &["init", "-b", "main"]);1162 + git(&dir, &["config", "user.name", "hu13-test"]);1163 + git(&dir, &["config", "user.email", "[email protected]"]);1164 + git(&dir, &["config", "commit.gpgsign", "false"]);1165 + std::fs::write(dir.join("README.md"), "# Readme headline\n\nbody\n").unwrap();1166 + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1167 + git(&dir, &["add", "-A"]);1168 + git(&dir, &["commit", "-m", "init"]);1169 + dir1170 +}1171 +1172 +fn build_with(repo: &Path, name_flag: Option<String>, desc_flag: Option<String>) -> PathBuf {1173 + let out = fresh("out");1174 + let bin = env!("CARGO_BIN_EXE_hu13");1175 + let mut cmd = Command::new(bin);1176 + cmd.arg("build").arg(repo).arg("-o").arg(&out);1177 + if let Some(n) = name_flag {1178 + cmd.arg("--repo-name").arg(n);1179 + }1180 + if let Some(d) = desc_flag {1181 + cmd.arg("--description").arg(d);1182 + }1183 + let st = cmd.status().expect("spawn hu13 build");1184 + assert!(st.success(), "build exits 0");1185 + out1186 +}1187 +1188 +fn read(rel: &Path, join: &str) -> String {1189 + std::fs::read_to_string(rel.join(join)).expect(join)1190 +}1191 +1192 +fn set_env(k: &str, v: Option<&str>) {1193 + unsafe {1194 + match v {1195 + Some(val) => std::env::set_var(k, val),1196 + None => std::env::remove_var(k),1197 + }1198 + }1199 +}1200 +1201 +#[test]1202 +fn repo_name_chain_flag_beats_env_beats_dirname() {1203 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());1204 + let repo = fixture_repo();1205 + let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();1206 +1207 + // Env only -> env wins over dirname.1208 + set_env("HU13_REPO_NAME", Some("Env Name"));1209 + set_env("HU13_DESCRIPTION", None);1210 + let out = build_with(&repo, None, None);1211 + let index = read(&out, "index.html");1212 + assert!(1213 + index.contains("<title>Env Name</title>"),1214 + "env name in title"1215 + );1216 + assert!(1217 + index.contains("<span class=\"brand\">Env Name</span>"),1218 + "env name in brand"1219 + );1220 +1221 + // Flag beats env.1222 + set_env("HU13_REPO_NAME", Some("Env Name"));1223 + let out = build_with(&repo, Some("Flag Name".into()), None);1224 + let index = read(&out, "index.html");1225 + assert!(index.contains("<title>Flag Name</title>"), "flag wins");1226 + assert!(!index.contains("Env Name"), "env suppressed by flag");1227 +1228 + // Neither -> dirname fallback, never blank.1229 + set_env("HU13_REPO_NAME", None);1230 + let out = build_with(&repo, None, None);1231 + let index = read(&out, "index.html");1232 + assert!(1233 + index.contains(&format!("<title>{dirname}</title>")),1234 + "dirname fallback"1235 + );1236 + assert!(1237 + index.contains(&format!("<span class=\"brand\">{dirname}</span>")),1238 + "brand fallback"1239 + );1240 + assert!(!index.contains("<h1></h1>"), "header never blank");1241 + set_env("HU13_REPO_NAME", None);1242 +}1243 +1244 +#[test]1245 +fn description_chain_flag_beats_env_beats_hidden() {1246 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());1247 + let repo = fixture_repo();1248 + set_env("HU13_REPO_NAME", None);1249 +1250 + // Env only -> shown.1251 + set_env("HU13_DESCRIPTION", Some("Env desc"));1252 + let out = build_with(&repo, None, None);1253 + let index = read(&out, "index.html");1254 + assert!(1255 + index.contains("<p class=\"repo-desc\">Env desc</p>"),1256 + "env desc shown"1257 + );1258 +1259 + // Flag beats env.1260 + set_env("HU13_DESCRIPTION", Some("Env desc"));1261 + let out = build_with(&repo, None, Some("Flag desc".into()));1262 + let index = read(&out, "index.html");1263 + assert!(1264 + index.contains("<p class=\"repo-desc\">Flag desc</p>"),1265 + "flag wins"1266 + );1267 + assert!(!index.contains("Env desc"), "env suppressed by flag");1268 +1269 + // Neither -> hidden: no description line at all (and the README1270 + // headline is NOT used as a fallback).1271 + set_env("HU13_DESCRIPTION", None);1272 + let out = build_with(&repo, None, None);1273 + let index = read(&out, "index.html");1274 + assert!(!index.contains("repo-desc"), "no description line");1275 + assert!(!index.contains("No description"), "no placeholder text");1276 + // The README headline still renders in the README section, but must1277 + // not leak into the header/description surfaces.1278 + assert!(!index.contains("og:description"), "no og:description");1279 + let feed = read(&out, "feed.atom");1280 + assert!(!feed.contains("<subtitle>"), "no feed subtitle");1281 + set_env("HU13_DESCRIPTION", None);1282 +}1283 +1284 +#[test]1285 +fn hostile_name_and_description_escape_everywhere() {1286 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());1287 + let repo = fixture_repo();1288 + set_env("HU13_REPO_NAME", None);1289 + set_env("HU13_DESCRIPTION", None);1290 + let evil_name = "Evil <script>alert(1)</script> Hub";1291 + let evil_desc = "Desc <script>alert(2)</script> & \"quoted\"";1292 + let out = build_with(&repo, Some(evil_name.into()), Some(evil_desc.into()));1293 + let index = read(&out, "index.html");1294 + let feed = read(&out, "feed.atom");1295 + let commits = read(&out, "commits.html");1296 +1297 + for page in [&index, &commits] {1298 + assert!(!page.contains("<script>alert"), "no raw script passthrough");1299 + }1300 + assert!(!feed.contains("<script>alert"), "feed escapes too");1301 +1302 + // Escaped forms present in every surface.1303 + let name_e = "Evil <script>alert(1)</script> Hub";1304 + let desc_e = "Desc <script>alert(2)</script> & "quoted"";1305 + assert!(index.contains(name_e), "header/brand escapes name");1306 + assert!(index.contains(desc_e), "header escapes description");1307 + assert!(1308 + index.contains(&format!("<title>{name_e}</title>")),1309 + "title escapes name"1310 + );1311 + assert!(1312 + index.contains(&format!("og:title\" content=\"{name_e}\"")),1313 + "og:title matches"1314 + );1315 + assert!(1316 + index.contains(&format!("og:description\" content=\"{desc_e}\"")),1317 + "og:description matches"1318 + );1319 + assert!(commits.contains(name_e), "subpage brand escapes name");1320 + assert!(1321 + feed.contains(&format!("<title>{name_e}</title>")),1322 + "feed title escapes name"1323 + );1324 + assert!(1325 + feed.contains(&format!("<subtitle>{desc_e}</subtitle>")),1326 + "feed subtitle escapes description"1327 + );1328 + set_env("HU13_REPO_NAME", None);1329 + set_env("HU13_DESCRIPTION", None);1330 +}1331 +1332 +#[test]1333 +fn empty_values_fall_back_never_blank() {1334 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());1335 + let repo = fixture_repo();1336 + let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();1337 +1338 + // Empty flag values fall back.1339 + set_env("HU13_REPO_NAME", None);1340 + set_env("HU13_DESCRIPTION", None);1341 + let out = build_with(&repo, Some("".into()), Some(" ".into()));1342 + let index = read(&out, "index.html");1343 + assert!(1344 + index.contains(&format!("<title>{dirname}</title>")),1345 + "empty name flag falls back to dirname"1346 + );1347 + assert!(!index.contains("repo-desc"), "empty desc flag hides line");1348 +1349 + // Empty env values fall back too.1350 + set_env("HU13_REPO_NAME", Some(""));1351 + set_env("HU13_DESCRIPTION", Some(" "));1352 + let out = build_with(&repo, None, None);1353 + let index = read(&out, "index.html");1354 + assert!(1355 + index.contains(&format!("<title>{dirname}</title>")),1356 + "empty name env falls back to dirname"1357 + );1358 + assert!(!index.contains("repo-desc"), "empty desc env hides line");1359 + assert!(!index.contains("<h1></h1>"), "header never blank");1360 + set_env("HU13_REPO_NAME", None);1361 + set_env("HU13_DESCRIPTION", None);1362 +}1363 +1364 +#[test]1365 +fn feed_title_matches_header_and_subtitle_matches_og() {1366 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());1367 + let repo = fixture_repo();1368 + set_env("HU13_REPO_NAME", None);1369 + set_env("HU13_DESCRIPTION", None);1370 + let out = build_with(&repo, Some("Feed Hub".into()), Some("Feed desc".into()));1371 + let index = read(&out, "index.html");1372 + let feed = read(&out, "feed.atom");1373 + assert!(feed.contains("<title>Feed Hub</title>"), "feed title");1374 + assert!(1375 + feed.contains("<subtitle>Feed desc</subtitle>"),1376 + "feed subtitle"1377 + );1378 + assert!(1379 + index.contains("og:title\" content=\"Feed Hub\""),1380 + "og:title agrees with feed title"1381 + );1382 + assert!(1383 + index.contains("og:description\" content=\"Feed desc\""),1384 + "og:description agrees with feed subtitle"1385 + );1386 +}1387 +1388 +#[test]1389 +fn entrypoint_and_dockerfile_pass_both_envs() {1390 + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));1391 + let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");1392 + for needle in [1393 + "HU13_REPO_NAME",1394 + "HU13_DESCRIPTION",1395 + "--repo-name $HU13_REPO_NAME",1396 + "--description $HU13_DESCRIPTION",1397 + ] {1398 + assert!(ep.contains(needle), "entrypoint passes {needle}");1399 + }1400 + let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");1401 + for needle in ["HU13_REPO_NAME", "HU13_DESCRIPTION"] {1402 + assert!(docker.contains(needle), "Dockerfile defaults {needle}");1403 + }1404 +}1405 -- 1406 2.55.01407