.forge/prs/12527fb2-e82a-432f-bbaa-01329616d572/patch.diff 17.4 KiB 458 lines Raw
1 From 31e8fed49f64e3438a4690b9c6b39a0a3e4c5111 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:50:13 +01004 Subject: [PATCH] m6/favicon: icons, head standards, manifest + mime (1275  tests)6 7 ---8  src/build.rs                |  15 ++9  src/layout.rs               |  34 ++++-10  src/serve.rs                |  10 ++11  static/favicon.svg          |   8 +12  static/manifest.webmanifest |   1 +13  tests/favicon.rs            | 289 ++++++++++++++++++++++++++++++++++++14  6 files changed, 356 insertions(+), 1 deletion(-)15  create mode 100644 static/favicon.svg16  create mode 100644 static/manifest.webmanifest17  create mode 100644 tests/favicon.rs18 19 diff --git a/src/build.rs b/src/build.rs20 index d743377..7eb9b0d 10064421 --- a/src/build.rs22 +++ b/src/build.rs23 @@ -222,6 +222,20 @@ fn write_style(out: &Path) -> Result<(), String> {24      std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))25  }26  27 +/// Write `out/favicon.svg` + `out/manifest.webmanifest` from the embedded28 +/// static files (m6 favicon, f0rge issue #16). SVG-first, zero binary29 +/// blobs: the favicon is the hand-written logo mark, and the manifest30 +/// points at it (`icons` svg, `short_name` hu13, `display` minimal-ui).31 +fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {32 +    let icon = include_str!("../static/favicon.svg");33 +    std::fs::write(out.join("favicon.svg"), icon)34 +        .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;35 +    let manifest = include_str!("../static/manifest.webmanifest");36 +    std::fs::write(out.join("manifest.webmanifest"), manifest)37 +        .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;38 +    Ok(())39 +}40 +41  /// Build the full static site. Returns a summary on success.42  pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {43      let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())44 @@ -264,6 +278,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu45          .map_err(|e| format!("hu13: create files dir: {e}"))?;46      std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;47      write_style(out)?;48 +    write_favicon_and_manifest(out)?;49  50      // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front51      // so every page (including file pages below) gets a nav with live52 diff --git a/src/layout.rs b/src/layout.rs53 index af05dbd..691a85f 10064454 --- a/src/layout.rs55 +++ b/src/layout.rs56 @@ -39,6 +39,13 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {57  /// and `og:description` / `meta[name=description]` carry `description`58  /// when present. `site_name` and `description` are escaped (hostile59  /// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`].60 +///61 +/// Head standards (m6 favicon, f0rge issue #16) ride along on every page:62 +/// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at63 +/// `/favicon.svg` — no binary blobs in the repo), light/dark64 +/// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching65 +/// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`66 +/// link..67  #[must_use]68  pub fn page_site(69      title: &str,70 @@ -54,9 +61,15 @@ pub fn page_site(71  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\72  <meta name=\"color-scheme\" content=\"dark light\">\n\73  <link rel=\"stylesheet\" href=\"/style.css\">\n\74 +<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n\75 +<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">\n\76 +<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">\n\77 +<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">\n\78 +<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n\79  <link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\80  <title>{title_e}</title>\n\81 -<meta property=\"og:title\" content=\"{title_e}\">\n"82 +<meta property=\"og:title\" content=\"{title_e}\">\n\83 +<meta property=\"og:type\" content=\"website\">\n"84      );85      if let Some(d) = description {86          let d_e = esc(d);87 @@ -124,4 +137,23 @@ mod tests {88          assert!(html.contains("ok"));89          assert!(html.contains("<main"));90      }91 +92 +    #[test]93 +    fn head_carries_favicon_manifest_theme_og_standards() {94 +        // m6 favicon (f0rge issue #16): every layout page carries the95 +        // icon links, apple-touch-icon, both theme-colors, og tags,96 +        // and the manifest link.97 +        let html = page("t", "<nav></nav>", "<p>b</p>");98 +        for needle in [99 +            "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",100 +            "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",101 +            "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",102 +            "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",103 +            "<meta property=\"og:title\"",104 +            "<meta property=\"og:type\" content=\"website\">",105 +            "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",106 +        ] {107 +            assert!(html.contains(needle), "head carries {needle}");108 +        }109 +    }110  }111 diff --git a/src/serve.rs b/src/serve.rs112 index ed60894..796c6fa 100644113 --- a/src/serve.rs114 +++ b/src/serve.rs115 @@ -339,6 +339,10 @@ fn content_type_for(name: &str) -> &'static str {116          "text/html; charset=utf-8"117      } else if lower.ends_with(".css") {118          "text/css; charset=utf-8"119 +    } else if lower.ends_with(".svg") {120 +        "image/svg+xml"121 +    } else if lower.ends_with(".webmanifest") {122 +        "application/manifest+json"123      } else if lower.ends_with(".json") {124          "application/json"125      } else if lower.ends_with(".atom") {126 @@ -710,6 +714,12 @@ mod tests {127          assert!(content_type_for("s.css").starts_with("text/css"));128          assert!(content_type_for("x.json").starts_with("application/json"));129          assert!(content_type_for("f.atom").contains("atom"));130 +        // m6 favicon (f0rge issue #16): svg icon + web app manifest.131 +        assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");132 +        assert_eq!(133 +            content_type_for("manifest.webmanifest"),134 +            "application/manifest+json"135 +        );136          assert_eq!(content_type_for("b.bundle"), "application/octet-stream");137      }138  139 diff --git a/static/favicon.svg b/static/favicon.svg140 new file mode 100644141 index 0000000..35a4426142 --- /dev/null143 +++ b/static/favicon.svg144 @@ -0,0 +1,8 @@145 +<?xml version="1.0" encoding="UTF-8"?>146 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">147 +  <title>hu13</title>148 +  <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>149 +  <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>150 +  <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>151 +  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>152 +</svg>153 diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest154 new file mode 100644155 index 0000000..125c919156 --- /dev/null157 +++ b/static/manifest.webmanifest158 @@ -0,0 +1 @@159 +{"background_color":"#1a1b26","description":"tiny static-site generator + text-only inbox for one repo","display":"minimal-ui","icons":[{"purpose":"any","sizes":"any","src":"/favicon.svg","type":"image/svg+xml"}],"name":"hu13","short_name":"hu13","start_url":"/","theme_color":"#1a1b26"}160 \ No newline at end of file161 diff --git a/tests/favicon.rs b/tests/favicon.rs162 new file mode 100644163 index 0000000..4277749164 --- /dev/null165 +++ b/tests/favicon.rs166 @@ -0,0 +1,289 @@167 +//! Favicon + head standards (m6, f0rge issue #16):168 +//! `static/favicon.svg` (SVG-first, no binary blobs) + head tags on every169 +//! built page (icon links, `apple-touch-icon`, light+dark `theme-color`,170 +//! og tags, manifest link) + `manifest.webmanifest` valid JSON in the171 +//! output + correct serve mime types over HTTP.172 +173 +use std::io::{BufRead, BufReader, Read, Write};174 +use std::net::TcpStream;175 +use std::path::{Path, PathBuf};176 +use std::process::{Child, Command, Stdio};177 +use std::time::Duration;178 +179 +fn bin() -> &'static str {180 +    env!("CARGO_BIN_EXE_hu13")181 +}182 +183 +fn manifest_dir() -> PathBuf {184 +    PathBuf::from(env!("CARGO_MANIFEST_DIR"))185 +}186 +187 +fn fresh(prefix: &str) -> PathBuf {188 +    let dir = std::env::temp_dir().join(format!(189 +        "hu13-favicon-{prefix}-{}-{}",190 +        std::process::id(),191 +        std::time::SystemTime::now()192 +            .duration_since(std::time::UNIX_EPOCH)193 +            .map(|d| d.as_nanos())194 +            .unwrap_or(0)195 +    ));196 +    std::fs::create_dir_all(&dir).unwrap();197 +    dir198 +}199 +200 +fn git(repo: &Path, args: &[&str]) {201 +    let st = Command::new("git")202 +        .arg("-C")203 +        .arg(repo)204 +        .args(args)205 +        .status()206 +        .expect("spawn git");207 +    assert!(st.success(), "git {args:?} failed");208 +}209 +210 +/// Minimal repo: README + one file + one subdir file, one commit.211 +fn fixture_repo() -> PathBuf {212 +    let dir = fresh("repo");213 +    git(&dir, &["init", "-b", "main"]);214 +    git(&dir, &["config", "user.name", "hu13-test"]);215 +    git(&dir, &["config", "user.email", "[email protected]"]);216 +    git(&dir, &["config", "commit.gpgsign", "false"]);217 +    std::fs::write(dir.join("README.md"), "# favicon fixture\n").unwrap();218 +    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();219 +    std::fs::create_dir_all(dir.join("sub")).unwrap();220 +    std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();221 +    git(&dir, &["add", "-A"]);222 +    git(&dir, &["commit", "-m", "init"]);223 +    dir224 +}225 +226 +fn build_fixture() -> (PathBuf, PathBuf) {227 +    let repo = fixture_repo();228 +    let out = fresh("out");229 +    let st = Command::new(bin())230 +        .arg("build")231 +        .arg(&repo)232 +        .arg("-o")233 +        .arg(&out)234 +        .status()235 +        .expect("spawn hu13 build");236 +    assert!(st.success(), "fixture build exits 0");237 +    (repo, out)238 +}239 +240 +/// Every head tag the rubric requires on every page.241 +const HEAD_TAGS: &[&str] = &[242 +    "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",243 +    "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",244 +    "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",245 +    "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",246 +    "<meta property=\"og:title\"",247 +    "<meta property=\"og:type\" content=\"website\">",248 +    "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",249 +];250 +251 +fn collect_html(dir: &Path) -> Vec<PathBuf> {252 +    let mut out = Vec::new();253 +    let mut stack = vec![dir.to_path_buf()];254 +    while let Some(d) = stack.pop() {255 +        for ent in std::fs::read_dir(&d).expect("read_dir") {256 +            let p = ent.expect("dir entry").path();257 +            if p.is_dir() {258 +                stack.push(p);259 +            } else if p.extension().is_some_and(|e| e == "html") {260 +                out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());261 +            }262 +        }263 +    }264 +    out.sort();265 +    out266 +}267 +268 +#[test]269 +fn every_built_page_carries_head_standards() {270 +    let (_repo, out) = build_fixture();271 +    let pages = collect_html(&out);272 +    assert!(273 +        pages.len() >= 5,274 +        "fixture emits several pages, got {pages:?}"275 +    );276 +    // Spot-check the headline pages explicitly, then sweep all of them.277 +    for spot in ["index.html", "commits.html", "issues/index.html"] {278 +        assert!(279 +            out.join(spot).exists(),280 +            "spot page {spot} exists, pages: {pages:?}"281 +        );282 +    }283 +    for rel in &pages {284 +        let html = std::fs::read_to_string(out.join(rel)).expect("read page");285 +        for tag in HEAD_TAGS {286 +            assert!(html.contains(tag), "{} carries {tag}", rel.display());287 +        }288 +    }289 +}290 +291 +#[test]292 +fn manifest_emitted_and_valid_json() {293 +    let (_repo, out) = build_fixture();294 +    let raw = std::fs::read_to_string(out.join("manifest.webmanifest"))295 +        .expect("build emits manifest.webmanifest");296 +    let v: serde_json::Value = serde_json::from_str(&raw).expect("manifest parses as JSON");297 +    assert_eq!(v["short_name"], "hu13", "short_name hu13");298 +    assert_eq!(v["name"], "hu13", "name hu13");299 +    assert!(300 +        v["display"].as_str().is_some_and(|d| d.contains("minimal")),301 +        "display minimal, got {}",302 +        v["display"]303 +    );304 +    assert_eq!(v["theme_color"], "#1a1b26", "dark theme color");305 +    let icons = v["icons"].as_array().expect("icons array");306 +    assert!(!icons.is_empty(), "at least one icon");307 +    assert!(308 +        icons309 +            .iter()310 +            .any(|i| i["type"] == "image/svg+xml" && i["src"] == "/favicon.svg"),311 +        "svg icon entry, got {icons:?}"312 +    );313 +    // The emitted manifest matches the static source byte-for-byte.314 +    let src = std::fs::read_to_string(manifest_dir().join("static").join("manifest.webmanifest"))315 +        .expect("static/manifest.webmanifest exists");316 +    assert_eq!(raw, src, "emitted manifest mirrors static source");317 +}318 +319 +#[test]320 +fn favicon_emitted_svg_only_no_binary_blobs() {321 +    let (_repo, out) = build_fixture();322 +    let raw = std::fs::read(out.join("favicon.svg")).expect("build emits favicon.svg");323 +    assert!(!raw.contains(&0), "favicon has no NUL bytes (text SVG)");324 +    let text = String::from_utf8(raw).expect("favicon is valid UTF-8 text");325 +    assert!(text.contains("<svg"), "favicon is an SVG document");326 +    assert!(text.contains("viewBox"), "scales via viewBox");327 +    // Mirrors the logo mark (same diamond-hub geometry, same palette).328 +    let logo =329 +        std::fs::read_to_string(manifest_dir().join("static").join("logo.svg")).expect("logo.svg");330 +    assert_eq!(text, logo, "favicon adapts logo.svg exactly");331 +    // No binary icon formats anywhere in static/ or the build output.332 +    for dir in [manifest_dir().join("static"), out.clone()] {333 +        let mut stack = vec![dir.clone()];334 +        while let Some(d) = stack.pop() {335 +            for ent in std::fs::read_dir(&d).expect("read_dir") {336 +                let p = ent.expect("dir entry").path();337 +                if p.is_dir() {338 +                    stack.push(p);339 +                    continue;340 +                }341 +                let ext = p342 +                    .extension()343 +                    .map(|e| e.to_string_lossy().to_ascii_lowercase())344 +                    .unwrap_or_default();345 +                assert!(346 +                    !["ico", "png", "icns"].contains(&ext.as_str()),347 +                    "no binary icon blobs: {}",348 +                    p.display()349 +                );350 +            }351 +        }352 +    }353 +}354 +355 +// ---- serve mime over HTTP ----------------------------------------------356 +357 +struct Server {358 +    child: Child,359 +    addr: String,360 +}361 +362 +impl Drop for Server {363 +    fn drop(&mut self) {364 +        self.child.kill().ok();365 +        self.child.wait().ok();366 +    }367 +}368 +369 +fn start_server(www: &Path, inbox: &Path) -> Server {370 +    let mut child = Command::new(bin())371 +        .arg("serve")372 +        .arg("--www")373 +        .arg(www)374 +        .arg("--inbox-dir")375 +        .arg(inbox)376 +        .arg("--listen")377 +        .arg("127.0.0.1:0")378 +        .stdout(Stdio::piped())379 +        .stderr(Stdio::piped())380 +        .spawn()381 +        .expect("spawn hu13 serve");382 +    let stdout = child.stdout.take().expect("piped stdout");383 +    let mut lines = BufReader::new(stdout).lines();384 +    let mut addr = String::new();385 +    for _ in 0..50 {386 +        match lines.next() {387 +            Some(Ok(line)) if line.contains("listening on ") => {388 +                addr = line389 +                    .rsplit("listening on ")390 +                    .next()391 +                    .unwrap_or("")392 +                    .trim()393 +                    .to_string();394 +                break;395 +            }396 +            Some(Ok(_)) => continue,397 +            _ => break,398 +        }399 +    }400 +    assert!(!addr.is_empty(), "serve printed no `listening on` line");401 +    std::thread::sleep(Duration::from_millis(100));402 +    Server { child, addr }403 +}404 +405 +fn get(addr: &str, path: &str) -> String {406 +    let mut s = TcpStream::connect(addr).expect("connect");407 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();408 +    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();409 +    let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");410 +    s.write_all(req.as_bytes()).expect("write");411 +    let mut out = Vec::new();412 +    s.read_to_end(&mut out).expect("read");413 +    String::from_utf8_lossy(&out).into_owned()414 +}415 +416 +fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {417 +    let want = name.to_ascii_lowercase();418 +    resp.split("\r\n").find_map(|line| {419 +        let (k, v) = line.split_once(':')?;420 +        if k.trim().to_ascii_lowercase() == want {421 +            Some(v.trim())422 +        } else {423 +            None424 +        }425 +    })426 +}427 +428 +#[test]429 +fn serve_maps_webmanifest_and_svg_mime() {430 +    let (_repo, out) = build_fixture();431 +    let inbox = fresh("inbox");432 +    let srv = start_server(&out, &inbox);433 +434 +    let m = get(&srv.addr, "/manifest.webmanifest");435 +    assert!(436 +        m.split("\r\n").next().unwrap_or("").contains("200"),437 +        "manifest serves 200, got: {m}"438 +    );439 +    assert_eq!(440 +        header(&m, "Content-Type"),441 +        Some("application/manifest+json"),442 +        "webmanifest mime, got: {m}"443 +    );444 +445 +    let f = get(&srv.addr, "/favicon.svg");446 +    assert!(447 +        f.split("\r\n").next().unwrap_or("").contains("200"),448 +        "favicon serves 200, got: {f}"449 +    );450 +    assert_eq!(451 +        header(&f, "Content-Type"),452 +        Some("image/svg+xml"),453 +        "svg mime, got: {f}"454 +    );455 +}456 -- 457 2.55.0458