#15 m6/header-config

mergedpassopened
details
branches
master ← m6/header-config
updated
id
f5cb4350-ea86-4190-ad3e-f50125da3c1b

Patch

DRAFT.md+4 −1
diff --git a/DRAFT.md b/DRAFT.mdindex fe17448..c0527b1 100644--- 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
diff --git a/Dockerfile b/Dockerfileindex 31b427c..f3997b5 100644--- 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
diff --git a/entrypoint.sh b/entrypoint.shindex 3cc51e5..6d4f149 100755--- 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
diff --git a/src/agents.rs b/src/agents.rsindex 67caf5a..f0f3646 100644--- 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 `&amp;` 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
diff --git a/src/build.rs b/src/build.rsindex 09c8f51..d743377 100644--- 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
diff --git a/src/commits.rs b/src/commits.rsindex a98c017..5abea19 100644--- 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
diff --git a/src/feed.rs b/src/feed.rsindex 437d787..d55d1c2 100644--- 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
diff --git a/src/forge.rs b/src/forge.rsindex 868449e..5a7b3ec 100644--- 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
diff --git a/src/inbox.rs b/src/inbox.rsindex c6f23ef..4998fb0 100644--- 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
diff --git a/src/layout.rs b/src/layout.rsindex 21f9214..af05dbd 100644--- 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
diff --git a/src/main.rs b/src/main.rsindex ee41cba..8e59824 100644--- 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
diff --git a/src/releases.rs b/src/releases.rsindex e05fe4c..ed79d48 100644--- 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
diff --git a/src/wiki.rs b/src/wiki.rsindex 345792d..4f20af8 100644--- 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
diff --git a/tests/header_config.rs b/tests/header_config.rsnew file mode 100644index 0000000..ef81f20--- /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 &lt;script&gt;alert(1)&lt;/script&gt; Hub";183+    let desc_e = "Desc &lt;script&gt;alert(2)&lt;/script&gt; &amp; &quot;quoted&quot;";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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls