#15 m6/header-config
Patch
DRAFT.md+4 −1--- a/DRAFT.md+++ b/DRAFT.md@@ -205,7 +205,10 @@ corners only (`border-radius: 0`). Semantic HTML so `curl` + reader-mode205205 206206 ```207207 hu13 build /data/repo -o /srv/www [--branch X] [--clone-url URL]208- [--title S] [--max-log 100]208+ [--repo-name S] [--description S] [--max-log 100]209+ # repo-name default: HU13_REPO_NAME else repo dir name (never blank;210+ # --title still works as a deprecated alias)211+ # description default: HU13_DESCRIPTION else hidden (no empty <p>)209212 # clone-url default: git remote get-url origin at build, else none210213 # caps tunable: --max-title 300 --max-body 20000 --max-bytes 32768211214 # --rate-per-hour 2 --quota 500 (or HU13_* env)
Dockerfile+4 −0--- a/Dockerfile+++ b/Dockerfile@@ -30,6 +30,10 @@ RUN apt-get update \3030 COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu133131 COPY entrypoint.sh /usr/local/bin/entrypoint.sh3232 RUN chmod +x /usr/local/bin/entrypoint.sh33+# Header config defaults (m6, issues #21 + #24): empty means fallback —34+# repo dir name for the name (never blank), hidden line for the description.35+ENV HU13_REPO_NAME=""36+ENV HU13_DESCRIPTION=""3337 USER hu133438 EXPOSE 393133539 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
entrypoint.sh+9 −0--- a/entrypoint.sh+++ b/entrypoint.sh@@ -8,6 +8,9 @@88 # Env:99 # HU13_BRANCH branch to build (default: main/master auto-detect)1010 # HU13_CLONE_URL clone URL to advertise (default: repo origin remote)11+# HU13_REPO_NAME repo display name (default: repo dir name; never blank)12+# HU13_DESCRIPTION13+# site description (default: hidden, no empty <p>)1114 # HU13_LISTEN serve address (default: 0.0.0.0:39313)1215 # HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES /1316 # HU13_RATE_PER_HOUR / HU13_QUOTA@@ -30,6 +33,12 @@ fi3033 if [ -n "${HU13_CLONE_URL:-}" ]; then3134 BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL"3235 fi36+if [ -n "${HU13_REPO_NAME:-}" ]; then37+ BUILD_ARGS="$BUILD_ARGS --repo-name $HU13_REPO_NAME"38+fi39+if [ -n "${HU13_DESCRIPTION:-}" ]; then40+ BUILD_ARGS="$BUILD_ARGS --description $HU13_DESCRIPTION"41+fi3342 3443 # shellcheck disable=SC20863544 hu13 build /data/repo -o /srv/www $BUILD_ARGS
src/agents.rs+9 −2--- a/src/agents.rs+++ b/src/agents.rs@@ -15,7 +15,7 @@1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page};18+use crate::layout::{esc, page_site};1919 2020 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).2121 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,@@ -103,6 +103,7 @@ pub fn render(103103 clone_url: Option<&str>,104104 branch: &str,105105 short_sha: &str,106+ description: Option<&str>,106107 ) -> Result<(), String> {107108 let site_e = esc(site_name);108109 let branch_e = esc(branch);@@ -113,7 +114,13 @@ pub fn render(113114 // source so the raw `agents.md` and rendered `agents.html` agree. The114115 // markdown renderer escapes raw HTML again, so `&` etc. stay safe.115116 let html_body = markdown_to_html(&md);116- let html = page(&format!("agents — {site_name}"), nav, &html_body);117+ let html = page_site(118+ &format!("agents — {site_name}"),119+ nav,120+ &html_body,121+ site_name,122+ description,123+ );117124 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;118125 std::fs::write(out.join("agents.html"), html)119126 .map_err(|e| format!("hu13: write agents.html: {e}"))?;
src/build.rs+76 −39--- a/src/build.rs+++ b/src/build.rs@@ -33,12 +33,60 @@ pub struct BuildOptions {3333 pub branch_flag: Option<String>,3434 /// `--clone-url` flag (else git origin remote, else none).3535 pub clone_url_flag: Option<String>,36- /// `--title` site title override (else repo dir name).37- pub title_override: Option<String>,36+ /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).37+ /// `--title` is accepted as a deprecated alias for the same field.38+ /// Empty/whitespace always falls back (the header is never blank).39+ pub repo_name_flag: Option<String>,40+ /// `--description` flag (else `$HU13_DESCRIPTION`, else none).41+ /// Empty/whitespace means absent: the header description line is42+ /// hidden entirely (no empty `<p>`).43+ pub description_flag: Option<String>,3844 /// `--max-log`: max commits listed in `commits.html` (default 100).3945 pub max_log: usize,4046 }4147 48+/// Resolve the repo display name (m6, f0rge issue #24).49+///50+/// Precedence: `--repo-name` flag > `$HU13_REPO_NAME` > directory name of51+/// `repo` > `"repo"`. Flag and env values are trimmed; empty values fall52+/// through so the header is never blank.53+#[must_use]54+pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {55+ if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {56+ return f.to_string();57+ }58+ if let Ok(env) = std::env::var("HU13_REPO_NAME") {59+ let t = env.trim();60+ if !t.is_empty() {61+ return t.to_string();62+ }63+ }64+ repo.file_name()65+ .map(|n| n.to_string_lossy().into_owned())66+ .map(|n| n.trim().to_string())67+ .filter(|n| !n.is_empty())68+ .unwrap_or_else(|| "repo".to_string())69+}70+71+/// Resolve the site description (m6, f0rge issue #21).72+///73+/// Precedence: `--description` flag > `$HU13_DESCRIPTION` > absent74+/// (`None`). Flag and env values are trimmed; empty values fall through75+/// to absent, and an absent description hides the header line entirely.76+#[must_use]77+pub fn resolve_description(flag: Option<&str>) -> Option<String> {78+ if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {79+ return Some(f.to_string());80+ }81+ if let Ok(env) = std::env::var("HU13_DESCRIPTION") {82+ let t = env.trim();83+ if !t.is_empty() {84+ return Some(t.to_string());85+ }86+ }87+ None88+}89+4290 /// Summary of one successful build (for logs/tests).4391 #[derive(Debug, Clone)]4492 pub struct BuildSummary {@@ -181,14 +229,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu181229 let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;182230 let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());183231 184- let name = match &opts.title_override {185- Some(t) if !t.trim().is_empty() => t.trim().to_string(),186- _ => repo187- .file_name()188- .map(|n| n.to_string_lossy().into_owned())189- .filter(|n| !n.is_empty())190- .unwrap_or_else(|| "repo".to_string()),191- };232+ let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());233+ let description = resolve_description(opts.description_flag.as_deref());234+ let desc_opt = description.as_deref();192235 193236 // Walk the whole tree (dirs-first order from gitx), remembering each194237 // dir's entries so dir pages need no second pass.@@ -216,21 +259,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu216259 dirs = dir_entries.keys().cloned().collect();217260 218261 let readme = find_readme(repo, &branch);219- let description = readme.as_ref().and_then(|(_, blob)| {220- blob.display221- .lines()222- .map(str::trim)223- .find(|l| !l.is_empty())224- .map(|l| {225- let l = l.strip_prefix('#').unwrap_or(l).trim();226- if l.len() > 200 {227- l[..200].to_string()228- } else {229- l.to_string()230- }231- })232- .filter(|l| !l.is_empty())233- });234262 235263 std::fs::create_dir_all(out.join("files"))236264 .map_err(|e| format!("hu13: create files dir: {e}"))?;@@ -297,7 +325,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu297325 }298326 }299327 body.push_str("</ul>\n");300- let html = crate::layout::page(&format!("{dir} — {name}"), &nav, &body);328+ let html =329+ crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);301330 let dest = out.join("files").join(format!("{dir}.html"));302331 if let Some(parent) = dest.parent() {303332 std::fs::create_dir_all(parent)@@ -349,7 +378,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu349378 let display = String::from_utf8_lossy(&bytes).into_owned();350379 body.push_str(&render_text_pre(&display));351380 }352- let html = crate::layout::page(&format!("{f} — {name}"), &nav, &body);381+ let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);353382 let dest = out.join("files").join(format!("{f}.html"));354383 if let Some(parent) = dest.parent() {355384 std::fs::create_dir_all(parent)@@ -361,17 +390,25 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu361390 // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):362391 // one struct pass — the same loaded vecs feed both HTML and JSON.363392 let commit_log = commits::load(repo, &branch, opts.max_log)?;364- commits::render_loaded(repo, out, &commit_log, &name, &nav)?;365- feed::render_loaded(repo, out, &branch, &name, &commit_log)?;366- let wiki_pages = wiki::render(repo, out, &name, &nav)?;393+ commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;394+ feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;395+ let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;367396 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.368- forge::render_loaded(&forge_data, out, &name, &nav)?;397+ forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;369398 let release_data = releases::load(repo);370- releases::render_loaded(&release_data, repo, out, &name, &nav)?;399+ releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;371400 api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;372- agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;401+ agents::render(402+ out,403+ &name,404+ &nav,405+ clone_url.as_deref(),406+ &branch,407+ &short_sha,408+ desc_opt,409+ )?;373410 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.374- crate::inbox::render_forms(out, &name, &nav)?;411+ crate::inbox::render_forms(out, &name, &nav, desc_opt)?;375412 376413 // Snapshot last so the index can link sizes.377414 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;@@ -385,9 +422,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu385422 // index.html header: stacked repo-head (name + clone + meta) so narrow386423 // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.387424 let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));388- match &description {389- Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),390- None => body.push_str("<p class=\"muted\">No description.</p>\n"),425+ if let Some(d) = &description {426+ body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));391427 }392428 if let Some(url) = &clone_url {393429 body.push_str(&format!(@@ -455,7 +491,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu455491 }456492 body.push_str("</section></div>\n");457493 458- let index = crate::layout::page(&name, &nav, &body);494+ let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);459495 std::fs::write(out.join("index.html"), index)460496 .map_err(|e| format!("hu13: write index.html: {e}"))?;461497 @@ -518,7 +554,8 @@ mod tests {518554 BuildOptions {519555 branch_flag: None,520556 clone_url_flag: None,521- title_override: None,557+ repo_name_flag: None,558+ description_flag: None,522559 max_log: 100,523560 }524561 }
src/commits.rs+19 −9--- a/src/commits.rs+++ b/src/commits.rs@@ -8,7 +8,7 @@88 use std::path::Path;99 1010 use crate::gitx;11-use crate::layout::{esc, page};11+use crate::layout::{esc, page_site};1212 1313 /// Short display form of a full sha (first 7 hex chars, git-style).1414 #[must_use]@@ -30,6 +30,7 @@ pub fn render_loaded(3030 log: &[gitx::Commit],3131 site_name: &str,3232 nav: &str,33+ description: Option<&str>,3334 ) -> Result<usize, String> {3435 let mut body = String::from("<h1>Commits</h1>\n");3536 if log.is_empty() {@@ -48,7 +49,13 @@ pub fn render_loaded(4849 }4950 body.push_str("</tbody>\n</table></div>\n");5051 }51- let html = page(&format!("commits — {site_name}"), nav, &body);52+ let html = page_site(53+ &format!("commits — {site_name}"),54+ nav,55+ &body,56+ site_name,57+ description,58+ );5259 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;5360 std::fs::write(out.join("commits.html"), html)5461 .map_err(|e| format!("hu13: write commits.html: {e}"))?;@@ -67,10 +74,12 @@ pub fn render_loaded(6774 esc(&message),6875 esc(&diff),6976 );70- let html = page(77+ let html = page_site(7178 &format!("commit {} — {site_name}", short_sha(&c.sha)),7279 nav,7380 &detail,81+ site_name,82+ description,7483 );7584 std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)7685 .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;@@ -90,9 +99,10 @@ pub fn render(9099 max_log: usize,91100 site_name: &str,92101 nav: &str,102+ description: Option<&str>,93103 ) -> Result<usize, String> {94104 let log = load(repo, branch, max_log)?;95- render_loaded(repo, out, &log, site_name, nav)105+ render_loaded(repo, out, &log, site_name, nav, description)96106 }97107 98108 #[cfg(test)]@@ -169,7 +179,7 @@ mod tests {169179 fn list_order_newest_first_with_links() {170180 let repo = fixture_repo();171181 let out = mktemp("out");172- let n = render(&repo, &out, "main", 100, "test", "<nav>").expect("render");182+ let n = render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");173183 assert_eq!(n, 3);174184 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");175185 let p3 = list.find("third commit huge").expect("c3");@@ -193,7 +203,7 @@ mod tests {193203 fn detail_escapes_message_and_hunk() {194204 let repo = fixture_repo();195205 let out = mktemp("out");196- render(&repo, &out, "main", 100, "test", "<nav>").expect("render");206+ render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");197207 let sha = shas(&repo)[1].clone(); // evil-message + binary commit198208 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))199209 .expect("detail");@@ -217,7 +227,7 @@ mod tests {217227 fn huge_diff_truncates_with_marker() {218228 let repo = fixture_repo();219229 let out = mktemp("out");220- render(&repo, &out, "main", 100, "test", "<nav>").expect("render");230+ render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");221231 let sha = shas(&repo)[0].clone(); // huge commit222232 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))223233 .expect("detail");@@ -231,7 +241,7 @@ mod tests {231241 fn max_log_limits_list_and_details() {232242 let repo = fixture_repo();233243 let out = mktemp("out");234- let n = render(&repo, &out, "main", 2, "test", "<nav>").expect("render");244+ let n = render(&repo, &out, "main", 2, "test", "<nav>", None).expect("render");235245 assert_eq!(n, 2);236246 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");237247 assert!(list.contains("third commit huge"));@@ -253,7 +263,7 @@ mod tests {253263 git(&dir, &["add", "-A"]);254264 git(&dir, &["commit", "-m", "only commit"]);255265 let out = mktemp("out");256- let n = render(&dir, &out, "main", 0, "test", "<nav>").expect("render");266+ let n = render(&dir, &out, "main", 0, "test", "<nav>", None).expect("render");257267 assert_eq!(n, 0);258268 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");259269 assert!(list.contains("No commits yet."));
src/feed.rs+30 −9--- a/src/feed.rs+++ b/src/feed.rs@@ -138,14 +138,26 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F138138 }139139 140140 /// Render the full `feed.atom` document for `entries`.141+///142+/// `site_name` becomes the feed `<title>`; `description` (m6, issue #21),143+/// when present, becomes the feed `<subtitle>` (escaped; hostile markup144+/// renders as text). `None` omits `<subtitle>` entirely.141145 #[must_use]142-pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> String {146+pub fn render_feed(147+ site_name: &str,148+ branch: &str,149+ entries: &[FeedEntry],150+ description: Option<&str>,151+) -> String {143152 let updated = entries144153 .first()145154 .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));146155 let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");147156 out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");148157 out.push_str(&format!("<title>{}</title>\n", esc(site_name)));158+ if let Some(d) = description {159+ out.push_str(&format!("<subtitle>{}</subtitle>\n", esc(d)));160+ }149161 out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));150162 out.push_str(&format!("<updated>{updated}</updated>\n"));151163 out.push_str("<author><name>hu13</name></author>\n");@@ -165,9 +177,15 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri165177 /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]166178 /// right after commits). Returns the number of entries written.167179 #[allow(dead_code)]168-pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {180+pub fn render(181+ repo: &Path,182+ out: &Path,183+ branch: &str,184+ site_name: &str,185+ description: Option<&str>,186+) -> Result<usize, String> {169187 let entries = collect_entries(repo, branch)?;170- render_entries(out, site_name, branch, &entries)188+ render_entries(out, site_name, branch, &entries, description)171189 }172190 173191 /// Build `feed.atom` from already-loaded commits (one struct pass).@@ -177,9 +195,10 @@ pub fn render_loaded(177195 branch: &str,178196 site_name: &str,179197 log: &[gitx::Commit],198+ description: Option<&str>,180199 ) -> Result<usize, String> {181200 let entries = collect_entries_loaded(repo, log)?;182- render_entries(out, site_name, branch, &entries)201+ render_entries(out, site_name, branch, &entries, description)183202 }184203 185204 fn render_entries(@@ -187,8 +206,9 @@ fn render_entries(187206 site_name: &str,188207 branch: &str,189208 entries: &[FeedEntry],209+ description: Option<&str>,190210 ) -> Result<usize, String> {191- let doc = render_feed(site_name, branch, entries);211+ let doc = render_feed(site_name, branch, entries, description);192212 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;193213 std::fs::write(out.join("feed.atom"), doc)194214 .map_err(|e| format!("hu13: write feed.atom: {e}"))?;@@ -337,7 +357,7 @@ mod tests {337357 let repo = fixture_repo();338358 let entries = collect_entries(&repo, "main").expect("collect");339359 assert!(entries.len() <= MAX_ENTRIES);340- let xml = render_feed("test-site", "main", &entries);360+ let xml = render_feed("test-site", "main", &entries, None);341361 check_well_formed(&xml);342362 // Newest-first on the dated fixture.343363 let titles = entry_titles(&xml);@@ -373,7 +393,7 @@ mod tests {373393 let mut sorted = entries.clone();374394 sorted.sort_by(|a, b| b.updated.cmp(&a.updated));375395 sorted.truncate(MAX_ENTRIES);376- let xml = render_feed("test", "main", &sorted);396+ let xml = render_feed("test", "main", &sorted, None);377397 check_well_formed(&xml);378398 assert_eq!(xml.matches("<entry>").count(), 50, "capped at 50");379399 }@@ -390,7 +410,7 @@ mod tests {390410 )391411 .unwrap();392412 let entries = collect_entries(&repo, "main").expect("collect");393- let xml = render_feed("test", "main", &entries);413+ let xml = render_feed("test", "main", &entries, None);394414 check_well_formed(&xml);395415 assert!(396416 xml.contains("v1.0"),@@ -411,7 +431,8 @@ mod tests {411431 let opts = crate::build::BuildOptions {412432 branch_flag: None,413433 clone_url_flag: None,414- title_override: None,434+ repo_name_flag: None,435+ description_flag: None,415436 max_log: 100,416437 };417438 crate::build::run_build(&repo, &out, &opts).expect("build");
src/forge.rs+66 −22--- a/src/forge.rs+++ b/src/forge.rs@@ -23,7 +23,7 @@ use std::path::Path;2323 use serde::Deserialize;2424 2525 use crate::build::markdown_to_html;26-use crate::layout::{esc, page};26+use crate::layout::{esc, page_site};2727 2828 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2929 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;@@ -795,7 +795,12 @@ fn render_comment(c: &Comment) -> String {795795 out796796 }797797 798-fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {798+fn render_issue_page(799+ issue: &Issue,800+ site_name: &str,801+ nav: &str,802+ description: Option<&str>,803+) -> String {799804 let mut body = format!(800805 "<h1>#{n} {title}</h1>\n",801806 n = issue.number,@@ -843,14 +848,16 @@ fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {843848 body.push_str("</ul>\n");844849 }845850 body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");846- page(851+ page_site(847852 &format!("#{} {} — {site_name}", issue.number, issue.title),848853 nav,849854 &body,855+ site_name,856+ description,850857 )851858 }852859 853-fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {860+fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {854861 let mut body = format!(855862 "<h1>#{n} {title}</h1>\n",856863 n = pr.number,@@ -925,14 +932,21 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {925932 body.push_str("</ul>\n");926933 }927934 body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");928- page(935+ page_site(929936 &format!("#{} {} — {site_name}", pr.number, pr.title),930937 nav,931938 &body,939+ site_name,940+ description,932941 )933942 }934943 935-fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<String, String> {944+fn render_issues_index(945+ data: &ForgeData,946+ site_name: &str,947+ nav: &str,948+ description: Option<&str>,949+) -> Result<String, String> {936950 let mut body = String::from("<h1>Issues</h1>\n");937951 if data.issues.is_empty() {938952 body.push_str("<p class=\"muted\">No issues yet.</p>\n");@@ -969,10 +983,16 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S969983 }970984 body.push_str("</ul>\n");971985 }972- Ok(page(&format!("issues — {site_name}"), nav, &body))986+ Ok(page_site(987+ &format!("issues — {site_name}"),988+ nav,989+ &body,990+ site_name,991+ description,992+ ))973993 }974994 975-fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {995+fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {976996 let mut body = String::from("<h1>Pulls</h1>\n");977997 if prs.is_empty() {978998 body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");@@ -992,10 +1012,21 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {9921012 }9931013 body.push_str("</tbody>\n</table></div>\n");9941014 }995- page(&format!("pulls — {site_name}"), nav, &body)1015+ page_site(1016+ &format!("pulls — {site_name}"),1017+ nav,1018+ &body,1019+ site_name,1020+ description,1021+ )9961022 }9971023 998-fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String {1024+fn render_tasks_index(1025+ tasks: &[TaskItem],1026+ site_name: &str,1027+ nav: &str,1028+ description: Option<&str>,1029+) -> String {9991030 let mut body = String::from("<h1>Tasks</h1>\n");10001031 if tasks.is_empty() {10011032 body.push_str("<p class=\"muted\">No tasks yet.</p>\n");@@ -1029,10 +1060,16 @@ fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String10291060 }10301061 }10311062 }1032- page(&format!("tasks — {site_name}"), nav, &body)1063+ page_site(1064+ &format!("tasks — {site_name}"),1065+ nav,1066+ &body,1067+ site_name,1068+ description,1069+ )10331070 }10341071 1035-fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {1072+fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {10361073 let mut body = String::from("<h1>CI</h1>\n");10371074 body.push_str(&meta_row("runner:", &esc(&ci.runner)));10381075 if ci.steps.is_empty() {@@ -1086,7 +1123,13 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {10861123 }10871124 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),10881125 }1089- page(&format!("ci — {site_name}"), nav, &body)1126+ page_site(1127+ &format!("ci — {site_name}"),1128+ nav,1129+ &body,1130+ site_name,1131+ description,1132+ )10901133 }10911134 10921135 fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {@@ -1103,42 +1146,43 @@ pub fn render_loaded(11031146 out: &Path,11041147 site_name: &str,11051148 nav: &str,1149+ description: Option<&str>,11061150 ) -> Result<(), String> {11071151 write_page(11081152 out,11091153 "issues/index.html",1110- &render_issues_index(data, site_name, nav)?,1154+ &render_issues_index(data, site_name, nav, description)?,11111155 )?;11121156 for issue in &data.issues {11131157 write_page(11141158 out,11151159 &format!("issues/{}.html", issue.id),1116- &render_issue_page(issue, site_name, nav),1160+ &render_issue_page(issue, site_name, nav, description),11171161 )?;11181162 }11191163 11201164 write_page(11211165 out,11221166 "pulls/index.html",1123- &render_pulls_index(&data.prs, site_name, nav),1167+ &render_pulls_index(&data.prs, site_name, nav, description),11241168 )?;11251169 for pr in &data.prs {11261170 write_page(11271171 out,11281172 &format!("pulls/{}.html", pr.id),1129- &render_pr_page(pr, site_name, nav),1173+ &render_pr_page(pr, site_name, nav, description),11301174 )?;11311175 }11321176 11331177 write_page(11341178 out,11351179 "tasks/index.html",1136- &render_tasks_index(&data.tasks, site_name, nav),1180+ &render_tasks_index(&data.tasks, site_name, nav, description),11371181 )?;11381182 write_page(11391183 out,11401184 "ci/index.html",1141- &render_ci_index(&data.ci, site_name, nav),1185+ &render_ci_index(&data.ci, site_name, nav, description),11421186 )?;11431187 11441188 Ok(())@@ -1269,7 +1313,7 @@ mod tests {12691313 assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));12701314 12711315 let out = mktemp("pr-out");1272- render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1316+ render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");12731317 let html =12741318 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))12751319 .expect("pr page");@@ -1314,7 +1358,7 @@ mod tests {13141358 assert_eq!(data.ci.latest_verdict(), "fail");13151359 13161360 let out = mktemp("taskci-out");1317- render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1361+ render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");13181362 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");13191363 for status in ["todo", "doing", "blocked", "done"] {13201364 assert!(tasks.contains(status), "group {status}");@@ -1335,7 +1379,7 @@ mod tests {13351379 assert!(data.tasks.is_empty());13361380 assert_eq!(data.ci.latest_verdict(), "none");13371381 let out = mktemp("empty-out");1338- render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1382+ render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");13391383 for page in [13401384 "issues/index.html",13411385 "pulls/index.html",
src/inbox.rs+19 −4--- a/src/inbox.rs+++ b/src/inbox.rs@@ -17,7 +17,7 @@1717 1818 use std::path::Path;1919 20-use crate::layout::{esc, page};20+use crate::layout::{esc, page, page_site};2121 2222 /// Max chars for `title` (both kinds).2323 pub const MAX_TITLE_CHARS: usize = 300;@@ -605,6 +605,7 @@ fn license_block() -> String {605605 out606606 }607607 608+#[allow(clippy::too_many_arguments)]608609 fn form_page(609610 site_name: &str,610611 nav: &str,@@ -613,6 +614,7 @@ fn form_page(613614 action: &str,614615 body_name: &str,615616 body_label: &str,617+ description: Option<&str>,616618 ) -> String {617619 let body = format!(618620 "<h1>{heading}</h1>\n<p>{intro}</p>\n\@@ -641,11 +643,22 @@ fn form_page(641643 body_label = esc(body_label),642644 license = license_block(),643645 );644- page(&format!("{heading} — {site_name}"), nav, &body)646+ page_site(647+ &format!("{heading} — {site_name}"),648+ nav,649+ &body,650+ site_name,651+ description,652+ )645653 }646654 647655 /// Write `new-issue.html` + `new-pr.html` into the built site.648-pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String> {656+pub fn render_forms(657+ out: &Path,658+ site_name: &str,659+ nav: &str,660+ description: Option<&str>,661+) -> Result<(), String> {649662 let issue = form_page(650663 site_name,651664 nav,@@ -654,6 +667,7 @@ pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String654667 InboxType::Issue.route(),655668 "body",656669 "Body",670+ description,657671 );658672 let pr = form_page(659673 site_name,@@ -663,6 +677,7 @@ pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String663677 InboxType::PrPrompt.route(),664678 "prompt",665679 "Prompt",680+ description,666681 );667682 std::fs::write(out.join("new-issue.html"), issue)668683 .map_err(|e| format!("hu13: write new-issue.html: {e}"))?;@@ -1015,7 +1030,7 @@ mod tests {10151030 .unwrap_or(0)10161031 ));10171032 std::fs::create_dir_all(&dir).unwrap();1018- render_forms(&dir, "test", "").unwrap();1033+ render_forms(&dir, "test", "", None).unwrap();10191034 let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();10201035 let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();10211036 assert!(issue.contains("action=\"/inbox/issue\""));
src/layout.rs+38 −9--- a/src/layout.rs+++ b/src/layout.rs@@ -29,19 +29,48 @@ pub fn esc(input: &str) -> String {2929 #[allow(dead_code)]3030 #[must_use]3131 pub fn page(title: &str, nav: &str, body: &str) -> String {32- let title = esc(title);33- format!(34- "<!DOCTYPE html>\n\35-<html lang=\"en\">\n\36-<head>\n\37-<meta charset=\"utf-8\">\n\32+ page_site(title, nav, body, "hu13", None)33+}34+35+/// Render a full HTML page with site identity (m6 header config, f0rge36+/// issues #21 + #24): the header brand shows `site_name`, `<title>` is37+/// the escaped page `title` (callers build it as `"{page} \u2014 {site}"`38+/// so the repo name is in every tab), `og:title` mirrors the page title,39+/// and `og:description` / `meta[name=description]` carry `description`40+/// when present. `site_name` and `description` are escaped (hostile41+/// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`].42+#[must_use]43+pub fn page_site(44+ title: &str,45+ nav: &str,46+ body: &str,47+ site_name: &str,48+ description: Option<&str>,49+) -> String {50+ let title_e = esc(title);51+ let brand = esc(site_name);52+ let mut head = format!(53+ "<meta charset=\"utf-8\">\n\3854 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\3955 <meta name=\"color-scheme\" content=\"dark light\">\n\4056 <link rel=\"stylesheet\" href=\"/style.css\">\n\41-<title>{title}</title>\n\42-</head>\n\57+<link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\58+<title>{title_e}</title>\n\59+<meta property=\"og:title\" content=\"{title_e}\">\n"60+ );61+ if let Some(d) = description {62+ let d_e = esc(d);63+ head.push_str(&format!(64+ "<meta name=\"description\" content=\"{d_e}\">\n\65+<meta property=\"og:description\" content=\"{d_e}\">\n"66+ ));67+ }68+ format!(69+ "<!DOCTYPE html>\n\70+<html lang=\"en\">\n\71+<head>\n{head}</head>\n\4372 <body>\n\44-<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">hu13</span></div></header>\n\73+<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">{brand}</span></div></header>\n\4574 <nav class=\"site-nav\"><div class=\"wrap\">{nav}</div></nav>\n\4675 <main class=\"wrap\">\n{body}\n</main>\n\4776 </body>\n\
src/main.rs+20 −7--- a/src/main.rs+++ b/src/main.rs@@ -49,7 +49,10 @@ OPTIONS:4949 -o <OUT> Output directory for the static site5050 --branch <NAME> Branch to pin (else HU13_BRANCH, else try main/master)5151 --clone-url <URL> Clone URL to advertise (else git origin remote, else none)52- --title <S> Site title override52+ --repo-name <S> Repo display name (else HU13_REPO_NAME, else repo dir name;53+ --title is a deprecated alias; empty falls back, never blank)54+ --description <S> Site description (else HU13_DESCRIPTION, else hidden;55+ empty hides the header line, no empty <p>)5356 --max-log <N> Max commits in log [default: 100]5457 -h, --help Print this help5558 "@@ -114,7 +117,8 @@ fn run_build(args: &[String]) -> i32 {114117 let mut out: Option<String> = None;115118 let mut branch: Option<String> = None;116119 let mut clone_url: Option<String> = None;117- let mut title: Option<String> = None;120+ let mut repo_name: Option<String> = None;121+ let mut description: Option<String> = None;118122 let mut max_log: usize = 100;119123 let mut i = 0;120124 while i < args.len() {@@ -143,13 +147,21 @@ fn run_build(args: &[String]) -> i32 {143147 }144148 clone_url = Some(args[i].clone());145149 }146- "--title" => {150+ "--title" | "--repo-name" => {147151 i += 1;148152 if i >= args.len() {149- eprintln!("hu13 build: --title needs a value");153+ eprintln!("hu13 build: --repo-name needs a value");150154 return 2;151155 }152- title = Some(args[i].clone());156+ repo_name = Some(args[i].clone());157+ }158+ "--description" => {159+ i += 1;160+ if i >= args.len() {161+ eprintln!("hu13 build: --description needs a value");162+ return 2;163+ }164+ description = Some(args[i].clone());153165 }154166 "--max-log" => {155167 i += 1;@@ -180,13 +192,14 @@ fn run_build(args: &[String]) -> i32 {180192 i += 1;181193 }182194 let (Some(repo), Some(out)) = (repo, out) else {183- eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--title S] [--max-log N]");195+ eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--repo-name S] [--description S] [--max-log N]");184196 return 2;185197 };186198 let opts = build::BuildOptions {187199 branch_flag: branch,188200 clone_url_flag: clone_url,189- title_override: title,201+ repo_name_flag: repo_name,202+ description_flag: description,190203 max_log,191204 };192205 match build::run_build(
src/releases.rs+19 −5--- a/src/releases.rs+++ b/src/releases.rs@@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};2222 use serde::Deserialize;2323 2424 use crate::build::markdown_to_html;25-use crate::layout::{esc, page};25+use crate::layout::{esc, page_site};2626 2727 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2828 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;@@ -318,7 +318,12 @@ fn artifact_href(version: &str, path: &str) -> String {318318 format!("/releases/{}/{path}", esc(version))319319 }320320 321-fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {321+fn render_release_page(322+ rel: &Release,323+ site_name: &str,324+ nav: &str,325+ description: Option<&str>,326+) -> String {322327 let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));323328 body.push_str(&format!(324329 "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",@@ -359,10 +364,12 @@ fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {359364 }360365 }361366 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");362- page(367+ page_site(363368 &format!("{} — releases — {site_name}", rel.display_title()),364369 nav,365370 &body,371+ site_name,372+ description,366373 )367374 }368375 @@ -374,6 +381,7 @@ pub fn render_loaded(374381 out: &Path,375382 site_name: &str,376383 nav: &str,384+ description: Option<&str>,377385 ) -> Result<(), String> {378386 let root = repo.join(".forge").join("releases");379387 let mut body = String::from("<h1>Releases</h1>\n");@@ -393,7 +401,13 @@ pub fn render_loaded(393401 }394402 body.push_str("</tbody>\n</table></div>\n");395403 }396- let index = page(&format!("releases — {site_name}"), nav, &body);404+ let index = page_site(405+ &format!("releases — {site_name}"),406+ nav,407+ &body,408+ site_name,409+ description,410+ );397411 let dest = out.join("releases").join("index.html");398412 if let Some(parent) = dest.parent() {399413 std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;@@ -409,7 +423,7 @@ pub fn render_loaded(409423 copy_tree(&src_dir, &dest_dir)?;410424 }411425 }412- let html = render_release_page(rel, site_name, nav);426+ let html = render_release_page(rel, site_name, nav, description);413427 let page_dest = out.join("releases").join(format!("{}.html", rel.version));414428 if let Some(parent) = page_dest.parent() {415429 std::fs::create_dir_all(parent)
src/wiki.rs+23 −8--- a/src/wiki.rs+++ b/src/wiki.rs@@ -15,7 +15,7 @@ use std::collections::BTreeMap;1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page};18+use crate::layout::{esc, page_site};1919 2020 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).2121 pub const WIKI_MAX_BYTES: u64 = 256 * 1024;@@ -75,6 +75,7 @@ pub fn render(7575 out: &Path,7676 site_name: &str,7777 nav: &str,78+ description: Option<&str>,7879 ) -> Result<Vec<WikiPage>, String> {7980 let root = repo.join(".forge").join("wiki");8081 let mut rel_files: Vec<String> = Vec::new();@@ -197,9 +198,17 @@ pub fn render(197198 }198199 body.push_str("</ul>\n");199200 }200- std::fs::write(wiki_dir.join("index.html"), page("wiki", nav, &body))201- .map_err(|e| format!("hu13: write wiki index: {e}"))?;202- let _ = site_name;201+ std::fs::write(202+ wiki_dir.join("index.html"),203+ page_site(204+ &format!("wiki — {site_name}"),205+ nav,206+ &body,207+ site_name,208+ description,209+ ),210+ )211+ .map_err(|e| format!("hu13: write wiki index: {e}"))?;203212 204213 for p in &pages {205214 let text = bodies.get(&p.path).cloned().unwrap_or_default();@@ -209,7 +218,13 @@ pub fn render(209218 esc(&p.path),210219 );211220 body.push_str(&markdown_to_html(&text));212- let html = page(&format!("{} — wiki", p.title), nav, &body);221+ let html = page_site(222+ &format!("{} — wiki — {site_name}", p.title),223+ nav,224+ &body,225+ site_name,226+ description,227+ );213228 let dest = wiki_dir.join(format!("{}.html", p.path));214229 if let Some(parent) = dest.parent() {215230 std::fs::create_dir_all(parent)@@ -278,7 +293,7 @@ mod tests {278293 fn index_titles_sorted_skips_bad() {279294 let repo = fixture_repo();280295 let out = mktemp("out");281- let pages = render(&repo, &out, "test", "<nav>").expect("render");296+ let pages = render(&repo, &out, "test", "<nav>", None).expect("render");282297 let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();283298 assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);284299 assert_eq!(pages[0].title, "Nested Page");@@ -300,7 +315,7 @@ mod tests {300315 fn pages_render_markdown_escaped() {301316 let repo = fixture_repo();302317 let out = mktemp("out");303- render(&repo, &out, "test", "<nav>").expect("render");318+ render(&repo, &out, "test", "<nav>", None).expect("render");304319 let hello =305320 std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");306321 assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");@@ -321,7 +336,7 @@ mod tests {321336 fn missing_wiki_dir_renders_empty_index() {322337 let dir = mktemp("nowiki");323338 let out = mktemp("out");324- let pages = render(&dir, &out, "test", "<nav>").expect("render");339+ let pages = render(&dir, &out, "test", "<nav>", None).expect("render");325340 assert!(pages.is_empty());326341 let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");327342 assert!(index.contains("No wiki pages yet."));
tests/header_config.rs+283 −1--- /dev/null+++ b/tests/header_config.rs@@ -0,0 +1,283 @@1+//! Header config (m6, f0rge issues #21 + #24):2+//! `--repo-name`/`HU13_REPO_NAME` (flag > env > dirname, never blank) +3+//! `--description`/`HU13_DESCRIPTION` (flag > env > hidden, no empty `<p>`),4+//! threaded into header brand, `<title>`, og tags, and feed title/subtitle.5+//! Hostile `<script>` input renders as escaped text everywhere.6+7+use std::path::{Path, PathBuf};8+use std::process::Command;9+use std::sync::Mutex;10+11+static ENV_LOCK: Mutex<()> = Mutex::new(());12+13+fn git(repo: &Path, args: &[&str]) {14+ let st = Command::new("git")15+ .arg("-C")16+ .arg(repo)17+ .args(args)18+ .status()19+ .expect("spawn git");20+ assert!(st.success(), "git {args:?} failed");21+}22+23+fn fresh(prefix: &str) -> PathBuf {24+ let dir = std::env::temp_dir().join(format!(25+ "hu13-header-{prefix}-{}-{}",26+ std::process::id(),27+ std::time::SystemTime::now()28+ .duration_since(std::time::UNIX_EPOCH)29+ .map(|d| d.as_nanos())30+ .unwrap_or(0)31+ ));32+ std::fs::create_dir_all(&dir).unwrap();33+ dir34+}35+36+/// Minimal repo: README (proves the README line is NOT used as the37+/// description fallback) + one file, one commit, no `.forge/`.38+fn fixture_repo() -> PathBuf {39+ let dir = fresh("repo");40+ git(&dir, &["init", "-b", "main"]);41+ git(&dir, &["config", "user.name", "hu13-test"]);42+ git(&dir, &["config", "user.email", "[email protected]"]);43+ git(&dir, &["config", "commit.gpgsign", "false"]);44+ std::fs::write(dir.join("README.md"), "# Readme headline\n\nbody\n").unwrap();45+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();46+ git(&dir, &["add", "-A"]);47+ git(&dir, &["commit", "-m", "init"]);48+ dir49+}50+51+fn build_with(repo: &Path, name_flag: Option<String>, desc_flag: Option<String>) -> PathBuf {52+ let out = fresh("out");53+ let bin = env!("CARGO_BIN_EXE_hu13");54+ let mut cmd = Command::new(bin);55+ cmd.arg("build").arg(repo).arg("-o").arg(&out);56+ if let Some(n) = name_flag {57+ cmd.arg("--repo-name").arg(n);58+ }59+ if let Some(d) = desc_flag {60+ cmd.arg("--description").arg(d);61+ }62+ let st = cmd.status().expect("spawn hu13 build");63+ assert!(st.success(), "build exits 0");64+ out65+}66+67+fn read(rel: &Path, join: &str) -> String {68+ std::fs::read_to_string(rel.join(join)).expect(join)69+}70+71+fn set_env(k: &str, v: Option<&str>) {72+ unsafe {73+ match v {74+ Some(val) => std::env::set_var(k, val),75+ None => std::env::remove_var(k),76+ }77+ }78+}79+80+#[test]81+fn repo_name_chain_flag_beats_env_beats_dirname() {82+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());83+ let repo = fixture_repo();84+ let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();85+86+ // Env only -> env wins over dirname.87+ set_env("HU13_REPO_NAME", Some("Env Name"));88+ set_env("HU13_DESCRIPTION", None);89+ let out = build_with(&repo, None, None);90+ let index = read(&out, "index.html");91+ assert!(92+ index.contains("<title>Env Name</title>"),93+ "env name in title"94+ );95+ assert!(96+ index.contains("<span class=\"brand\">Env Name</span>"),97+ "env name in brand"98+ );99+100+ // Flag beats env.101+ set_env("HU13_REPO_NAME", Some("Env Name"));102+ let out = build_with(&repo, Some("Flag Name".into()), None);103+ let index = read(&out, "index.html");104+ assert!(index.contains("<title>Flag Name</title>"), "flag wins");105+ assert!(!index.contains("Env Name"), "env suppressed by flag");106+107+ // Neither -> dirname fallback, never blank.108+ set_env("HU13_REPO_NAME", None);109+ let out = build_with(&repo, None, None);110+ let index = read(&out, "index.html");111+ assert!(112+ index.contains(&format!("<title>{dirname}</title>")),113+ "dirname fallback"114+ );115+ assert!(116+ index.contains(&format!("<span class=\"brand\">{dirname}</span>")),117+ "brand fallback"118+ );119+ assert!(!index.contains("<h1></h1>"), "header never blank");120+ set_env("HU13_REPO_NAME", None);121+}122+123+#[test]124+fn description_chain_flag_beats_env_beats_hidden() {125+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());126+ let repo = fixture_repo();127+ set_env("HU13_REPO_NAME", None);128+129+ // Env only -> shown.130+ set_env("HU13_DESCRIPTION", Some("Env desc"));131+ let out = build_with(&repo, None, None);132+ let index = read(&out, "index.html");133+ assert!(134+ index.contains("<p class=\"repo-desc\">Env desc</p>"),135+ "env desc shown"136+ );137+138+ // Flag beats env.139+ set_env("HU13_DESCRIPTION", Some("Env desc"));140+ let out = build_with(&repo, None, Some("Flag desc".into()));141+ let index = read(&out, "index.html");142+ assert!(143+ index.contains("<p class=\"repo-desc\">Flag desc</p>"),144+ "flag wins"145+ );146+ assert!(!index.contains("Env desc"), "env suppressed by flag");147+148+ // Neither -> hidden: no description line at all (and the README149+ // headline is NOT used as a fallback).150+ set_env("HU13_DESCRIPTION", None);151+ let out = build_with(&repo, None, None);152+ let index = read(&out, "index.html");153+ assert!(!index.contains("repo-desc"), "no description line");154+ assert!(!index.contains("No description"), "no placeholder text");155+ // The README headline still renders in the README section, but must156+ // not leak into the header/description surfaces.157+ assert!(!index.contains("og:description"), "no og:description");158+ let feed = read(&out, "feed.atom");159+ assert!(!feed.contains("<subtitle>"), "no feed subtitle");160+ set_env("HU13_DESCRIPTION", None);161+}162+163+#[test]164+fn hostile_name_and_description_escape_everywhere() {165+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());166+ let repo = fixture_repo();167+ set_env("HU13_REPO_NAME", None);168+ set_env("HU13_DESCRIPTION", None);169+ let evil_name = "Evil <script>alert(1)</script> Hub";170+ let evil_desc = "Desc <script>alert(2)</script> & \"quoted\"";171+ let out = build_with(&repo, Some(evil_name.into()), Some(evil_desc.into()));172+ let index = read(&out, "index.html");173+ let feed = read(&out, "feed.atom");174+ let commits = read(&out, "commits.html");175+176+ for page in [&index, &commits] {177+ assert!(!page.contains("<script>alert"), "no raw script passthrough");178+ }179+ assert!(!feed.contains("<script>alert"), "feed escapes too");180+181+ // Escaped forms present in every surface.182+ let name_e = "Evil <script>alert(1)</script> Hub";183+ let desc_e = "Desc <script>alert(2)</script> & "quoted"";184+ assert!(index.contains(name_e), "header/brand escapes name");185+ assert!(index.contains(desc_e), "header escapes description");186+ assert!(187+ index.contains(&format!("<title>{name_e}</title>")),188+ "title escapes name"189+ );190+ assert!(191+ index.contains(&format!("og:title\" content=\"{name_e}\"")),192+ "og:title matches"193+ );194+ assert!(195+ index.contains(&format!("og:description\" content=\"{desc_e}\"")),196+ "og:description matches"197+ );198+ assert!(commits.contains(name_e), "subpage brand escapes name");199+ assert!(200+ feed.contains(&format!("<title>{name_e}</title>")),201+ "feed title escapes name"202+ );203+ assert!(204+ feed.contains(&format!("<subtitle>{desc_e}</subtitle>")),205+ "feed subtitle escapes description"206+ );207+ set_env("HU13_REPO_NAME", None);208+ set_env("HU13_DESCRIPTION", None);209+}210+211+#[test]212+fn empty_values_fall_back_never_blank() {213+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());214+ let repo = fixture_repo();215+ let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();216+217+ // Empty flag values fall back.218+ set_env("HU13_REPO_NAME", None);219+ set_env("HU13_DESCRIPTION", None);220+ let out = build_with(&repo, Some("".into()), Some(" ".into()));221+ let index = read(&out, "index.html");222+ assert!(223+ index.contains(&format!("<title>{dirname}</title>")),224+ "empty name flag falls back to dirname"225+ );226+ assert!(!index.contains("repo-desc"), "empty desc flag hides line");227+228+ // Empty env values fall back too.229+ set_env("HU13_REPO_NAME", Some(""));230+ set_env("HU13_DESCRIPTION", Some(" "));231+ let out = build_with(&repo, None, None);232+ let index = read(&out, "index.html");233+ assert!(234+ index.contains(&format!("<title>{dirname}</title>")),235+ "empty name env falls back to dirname"236+ );237+ assert!(!index.contains("repo-desc"), "empty desc env hides line");238+ assert!(!index.contains("<h1></h1>"), "header never blank");239+ set_env("HU13_REPO_NAME", None);240+ set_env("HU13_DESCRIPTION", None);241+}242+243+#[test]244+fn feed_title_matches_header_and_subtitle_matches_og() {245+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());246+ let repo = fixture_repo();247+ set_env("HU13_REPO_NAME", None);248+ set_env("HU13_DESCRIPTION", None);249+ let out = build_with(&repo, Some("Feed Hub".into()), Some("Feed desc".into()));250+ let index = read(&out, "index.html");251+ let feed = read(&out, "feed.atom");252+ assert!(feed.contains("<title>Feed Hub</title>"), "feed title");253+ assert!(254+ feed.contains("<subtitle>Feed desc</subtitle>"),255+ "feed subtitle"256+ );257+ assert!(258+ index.contains("og:title\" content=\"Feed Hub\""),259+ "og:title agrees with feed title"260+ );261+ assert!(262+ index.contains("og:description\" content=\"Feed desc\""),263+ "og:description agrees with feed subtitle"264+ );265+}266+267+#[test]268+fn entrypoint_and_dockerfile_pass_both_envs() {269+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));270+ let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");271+ for needle in [272+ "HU13_REPO_NAME",273+ "HU13_DESCRIPTION",274+ "--repo-name $HU13_REPO_NAME",275+ "--description $HU13_DESCRIPTION",276+ ] {277+ assert!(ep.contains(needle), "entrypoint passes {needle}");278+ }279+ let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");280+ for needle in ["HU13_REPO_NAME", "HU13_DESCRIPTION"] {281+ assert!(docker.contains(needle), "Dockerfile defaults {needle}");282+ }283+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.