#18 m6/favicon

mergedpassopened
details
branches
master ← m6/favicon
updated
id
12527fb2-e82a-432f-bbaa-01329616d572

Patch

src/build.rs+15 −0
diff --git a/src/build.rs b/src/build.rsindex d743377..7eb9b0d 100644--- a/src/build.rs+++ b/src/build.rs@@ -222,6 +222,20 @@ fn write_style(out: &Path) -> Result<(), String> {222222     std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))223223 }224224 225+/// Write `out/favicon.svg` + `out/manifest.webmanifest` from the embedded226+/// static files (m6 favicon, f0rge issue #16). SVG-first, zero binary227+/// blobs: the favicon is the hand-written logo mark, and the manifest228+/// points at it (`icons` svg, `short_name` hu13, `display` minimal-ui).229+fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {230+    let icon = include_str!("../static/favicon.svg");231+    std::fs::write(out.join("favicon.svg"), icon)232+        .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;233+    let manifest = include_str!("../static/manifest.webmanifest");234+    std::fs::write(out.join("manifest.webmanifest"), manifest)235+        .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;236+    Ok(())237+}238+225239 /// Build the full static site. Returns a summary on success.226240 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {227241     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())@@ -264,6 +278,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu264278         .map_err(|e| format!("hu13: create files dir: {e}"))?;265279     std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;266280     write_style(out)?;281+    write_favicon_and_manifest(out)?;267282 268283     // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front269284     // so every page (including file pages below) gets a nav with live
src/layout.rs+33 −1
diff --git a/src/layout.rs b/src/layout.rsindex af05dbd..691a85f 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -39,6 +39,13 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {3939 /// and `og:description` / `meta[name=description]` carry `description`4040 /// when present. `site_name` and `description` are escaped (hostile4141 /// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`].42+///43+/// Head standards (m6 favicon, f0rge issue #16) ride along on every page:44+/// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at45+/// `/favicon.svg` — no binary blobs in the repo), light/dark46+/// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching47+/// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`48+/// link..4249 #[must_use]4350 pub fn page_site(4451     title: &str,@@ -54,9 +61,15 @@ pub fn page_site(5461 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\5562 <meta name=\"color-scheme\" content=\"dark light\">\n\5663 <link rel=\"stylesheet\" href=\"/style.css\">\n\64+<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n\65+<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">\n\66+<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">\n\67+<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">\n\68+<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n\5769 <link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\5870 <title>{title_e}</title>\n\59-<meta property=\"og:title\" content=\"{title_e}\">\n"71+<meta property=\"og:title\" content=\"{title_e}\">\n\72+<meta property=\"og:type\" content=\"website\">\n"6073     );6174     if let Some(d) = description {6275         let d_e = esc(d);@@ -124,4 +137,23 @@ mod tests {124137         assert!(html.contains("ok"));125138         assert!(html.contains("<main"));126139     }140+141+    #[test]142+    fn head_carries_favicon_manifest_theme_og_standards() {143+        // m6 favicon (f0rge issue #16): every layout page carries the144+        // icon links, apple-touch-icon, both theme-colors, og tags,145+        // and the manifest link.146+        let html = page("t", "<nav></nav>", "<p>b</p>");147+        for needle in [148+            "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",149+            "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",150+            "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",151+            "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",152+            "<meta property=\"og:title\"",153+            "<meta property=\"og:type\" content=\"website\">",154+            "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",155+        ] {156+            assert!(html.contains(needle), "head carries {needle}");157+        }158+    }127159 }
src/serve.rs+10 −0
diff --git a/src/serve.rs b/src/serve.rsindex ed60894..796c6fa 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -339,6 +339,10 @@ fn content_type_for(name: &str) -> &'static str {339339         "text/html; charset=utf-8"340340     } else if lower.ends_with(".css") {341341         "text/css; charset=utf-8"342+    } else if lower.ends_with(".svg") {343+        "image/svg+xml"344+    } else if lower.ends_with(".webmanifest") {345+        "application/manifest+json"342346     } else if lower.ends_with(".json") {343347         "application/json"344348     } else if lower.ends_with(".atom") {@@ -710,6 +714,12 @@ mod tests {710714         assert!(content_type_for("s.css").starts_with("text/css"));711715         assert!(content_type_for("x.json").starts_with("application/json"));712716         assert!(content_type_for("f.atom").contains("atom"));717+        // m6 favicon (f0rge issue #16): svg icon + web app manifest.718+        assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");719+        assert_eq!(720+            content_type_for("manifest.webmanifest"),721+            "application/manifest+json"722+        );713723         assert_eq!(content_type_for("b.bundle"), "application/octet-stream");714724     }715725 
static/favicon.svg+8 −0
diff --git a/static/favicon.svg b/static/favicon.svgnew file mode 100644index 0000000..35a4426--- /dev/null+++ b/static/favicon.svg@@ -0,0 +1,8 @@1+<?xml version="1.0" encoding="UTF-8"?>2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">3+  <title>hu13</title>4+  <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>5+  <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>6+  <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>7+  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>8+</svg>
static/manifest.webmanifest+1 −0
diff --git a/static/manifest.webmanifest b/static/manifest.webmanifestnew file mode 100644index 0000000..125c919--- /dev/null+++ b/static/manifest.webmanifest@@ -0,0 +1 @@1+{"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"}\ No newline at end of file
tests/favicon.rs+289 −1
diff --git a/tests/favicon.rs b/tests/favicon.rsnew file mode 100644index 0000000..4277749--- /dev/null+++ b/tests/favicon.rs@@ -0,0 +1,289 @@1+//! Favicon + head standards (m6, f0rge issue #16):2+//! `static/favicon.svg` (SVG-first, no binary blobs) + head tags on every3+//! built page (icon links, `apple-touch-icon`, light+dark `theme-color`,4+//! og tags, manifest link) + `manifest.webmanifest` valid JSON in the5+//! output + correct serve mime types over HTTP.6+7+use std::io::{BufRead, BufReader, Read, Write};8+use std::net::TcpStream;9+use std::path::{Path, PathBuf};10+use std::process::{Child, Command, Stdio};11+use std::time::Duration;12+13+fn bin() -> &'static str {14+    env!("CARGO_BIN_EXE_hu13")15+}16+17+fn manifest_dir() -> PathBuf {18+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))19+}20+21+fn fresh(prefix: &str) -> PathBuf {22+    let dir = std::env::temp_dir().join(format!(23+        "hu13-favicon-{prefix}-{}-{}",24+        std::process::id(),25+        std::time::SystemTime::now()26+            .duration_since(std::time::UNIX_EPOCH)27+            .map(|d| d.as_nanos())28+            .unwrap_or(0)29+    ));30+    std::fs::create_dir_all(&dir).unwrap();31+    dir32+}33+34+fn git(repo: &Path, args: &[&str]) {35+    let st = Command::new("git")36+        .arg("-C")37+        .arg(repo)38+        .args(args)39+        .status()40+        .expect("spawn git");41+    assert!(st.success(), "git {args:?} failed");42+}43+44+/// Minimal repo: README + one file + one subdir file, one commit.45+fn fixture_repo() -> PathBuf {46+    let dir = fresh("repo");47+    git(&dir, &["init", "-b", "main"]);48+    git(&dir, &["config", "user.name", "hu13-test"]);49+    git(&dir, &["config", "user.email", "[email protected]"]);50+    git(&dir, &["config", "commit.gpgsign", "false"]);51+    std::fs::write(dir.join("README.md"), "# favicon fixture\n").unwrap();52+    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();53+    std::fs::create_dir_all(dir.join("sub")).unwrap();54+    std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();55+    git(&dir, &["add", "-A"]);56+    git(&dir, &["commit", "-m", "init"]);57+    dir58+}59+60+fn build_fixture() -> (PathBuf, PathBuf) {61+    let repo = fixture_repo();62+    let out = fresh("out");63+    let st = Command::new(bin())64+        .arg("build")65+        .arg(&repo)66+        .arg("-o")67+        .arg(&out)68+        .status()69+        .expect("spawn hu13 build");70+    assert!(st.success(), "fixture build exits 0");71+    (repo, out)72+}73+74+/// Every head tag the rubric requires on every page.75+const HEAD_TAGS: &[&str] = &[76+    "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",77+    "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",78+    "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",79+    "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",80+    "<meta property=\"og:title\"",81+    "<meta property=\"og:type\" content=\"website\">",82+    "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",83+];84+85+fn collect_html(dir: &Path) -> Vec<PathBuf> {86+    let mut out = Vec::new();87+    let mut stack = vec![dir.to_path_buf()];88+    while let Some(d) = stack.pop() {89+        for ent in std::fs::read_dir(&d).expect("read_dir") {90+            let p = ent.expect("dir entry").path();91+            if p.is_dir() {92+                stack.push(p);93+            } else if p.extension().is_some_and(|e| e == "html") {94+                out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());95+            }96+        }97+    }98+    out.sort();99+    out100+}101+102+#[test]103+fn every_built_page_carries_head_standards() {104+    let (_repo, out) = build_fixture();105+    let pages = collect_html(&out);106+    assert!(107+        pages.len() >= 5,108+        "fixture emits several pages, got {pages:?}"109+    );110+    // Spot-check the headline pages explicitly, then sweep all of them.111+    for spot in ["index.html", "commits.html", "issues/index.html"] {112+        assert!(113+            out.join(spot).exists(),114+            "spot page {spot} exists, pages: {pages:?}"115+        );116+    }117+    for rel in &pages {118+        let html = std::fs::read_to_string(out.join(rel)).expect("read page");119+        for tag in HEAD_TAGS {120+            assert!(html.contains(tag), "{} carries {tag}", rel.display());121+        }122+    }123+}124+125+#[test]126+fn manifest_emitted_and_valid_json() {127+    let (_repo, out) = build_fixture();128+    let raw = std::fs::read_to_string(out.join("manifest.webmanifest"))129+        .expect("build emits manifest.webmanifest");130+    let v: serde_json::Value = serde_json::from_str(&raw).expect("manifest parses as JSON");131+    assert_eq!(v["short_name"], "hu13", "short_name hu13");132+    assert_eq!(v["name"], "hu13", "name hu13");133+    assert!(134+        v["display"].as_str().is_some_and(|d| d.contains("minimal")),135+        "display minimal, got {}",136+        v["display"]137+    );138+    assert_eq!(v["theme_color"], "#1a1b26", "dark theme color");139+    let icons = v["icons"].as_array().expect("icons array");140+    assert!(!icons.is_empty(), "at least one icon");141+    assert!(142+        icons143+            .iter()144+            .any(|i| i["type"] == "image/svg+xml" && i["src"] == "/favicon.svg"),145+        "svg icon entry, got {icons:?}"146+    );147+    // The emitted manifest matches the static source byte-for-byte.148+    let src = std::fs::read_to_string(manifest_dir().join("static").join("manifest.webmanifest"))149+        .expect("static/manifest.webmanifest exists");150+    assert_eq!(raw, src, "emitted manifest mirrors static source");151+}152+153+#[test]154+fn favicon_emitted_svg_only_no_binary_blobs() {155+    let (_repo, out) = build_fixture();156+    let raw = std::fs::read(out.join("favicon.svg")).expect("build emits favicon.svg");157+    assert!(!raw.contains(&0), "favicon has no NUL bytes (text SVG)");158+    let text = String::from_utf8(raw).expect("favicon is valid UTF-8 text");159+    assert!(text.contains("<svg"), "favicon is an SVG document");160+    assert!(text.contains("viewBox"), "scales via viewBox");161+    // Mirrors the logo mark (same diamond-hub geometry, same palette).162+    let logo =163+        std::fs::read_to_string(manifest_dir().join("static").join("logo.svg")).expect("logo.svg");164+    assert_eq!(text, logo, "favicon adapts logo.svg exactly");165+    // No binary icon formats anywhere in static/ or the build output.166+    for dir in [manifest_dir().join("static"), out.clone()] {167+        let mut stack = vec![dir.clone()];168+        while let Some(d) = stack.pop() {169+            for ent in std::fs::read_dir(&d).expect("read_dir") {170+                let p = ent.expect("dir entry").path();171+                if p.is_dir() {172+                    stack.push(p);173+                    continue;174+                }175+                let ext = p176+                    .extension()177+                    .map(|e| e.to_string_lossy().to_ascii_lowercase())178+                    .unwrap_or_default();179+                assert!(180+                    !["ico", "png", "icns"].contains(&ext.as_str()),181+                    "no binary icon blobs: {}",182+                    p.display()183+                );184+            }185+        }186+    }187+}188+189+// ---- serve mime over HTTP ----------------------------------------------190+191+struct Server {192+    child: Child,193+    addr: String,194+}195+196+impl Drop for Server {197+    fn drop(&mut self) {198+        self.child.kill().ok();199+        self.child.wait().ok();200+    }201+}202+203+fn start_server(www: &Path, inbox: &Path) -> Server {204+    let mut child = Command::new(bin())205+        .arg("serve")206+        .arg("--www")207+        .arg(www)208+        .arg("--inbox-dir")209+        .arg(inbox)210+        .arg("--listen")211+        .arg("127.0.0.1:0")212+        .stdout(Stdio::piped())213+        .stderr(Stdio::piped())214+        .spawn()215+        .expect("spawn hu13 serve");216+    let stdout = child.stdout.take().expect("piped stdout");217+    let mut lines = BufReader::new(stdout).lines();218+    let mut addr = String::new();219+    for _ in 0..50 {220+        match lines.next() {221+            Some(Ok(line)) if line.contains("listening on ") => {222+                addr = line223+                    .rsplit("listening on ")224+                    .next()225+                    .unwrap_or("")226+                    .trim()227+                    .to_string();228+                break;229+            }230+            Some(Ok(_)) => continue,231+            _ => break,232+        }233+    }234+    assert!(!addr.is_empty(), "serve printed no `listening on` line");235+    std::thread::sleep(Duration::from_millis(100));236+    Server { child, addr }237+}238+239+fn get(addr: &str, path: &str) -> String {240+    let mut s = TcpStream::connect(addr).expect("connect");241+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();242+    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();243+    let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");244+    s.write_all(req.as_bytes()).expect("write");245+    let mut out = Vec::new();246+    s.read_to_end(&mut out).expect("read");247+    String::from_utf8_lossy(&out).into_owned()248+}249+250+fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {251+    let want = name.to_ascii_lowercase();252+    resp.split("\r\n").find_map(|line| {253+        let (k, v) = line.split_once(':')?;254+        if k.trim().to_ascii_lowercase() == want {255+            Some(v.trim())256+        } else {257+            None258+        }259+    })260+}261+262+#[test]263+fn serve_maps_webmanifest_and_svg_mime() {264+    let (_repo, out) = build_fixture();265+    let inbox = fresh("inbox");266+    let srv = start_server(&out, &inbox);267+268+    let m = get(&srv.addr, "/manifest.webmanifest");269+    assert!(270+        m.split("\r\n").next().unwrap_or("").contains("200"),271+        "manifest serves 200, got: {m}"272+    );273+    assert_eq!(274+        header(&m, "Content-Type"),275+        Some("application/manifest+json"),276+        "webmanifest mime, got: {m}"277+    );278+279+    let f = get(&srv.addr, "/favicon.svg");280+    assert!(281+        f.split("\r\n").next().unwrap_or("").contains("200"),282+        "favicon serves 200, got: {f}"283+    );284+    assert_eq!(285+        header(&f, "Content-Type"),286+        Some("image/svg+xml"),287+        "svg mime, got: {f}"288+    );289+}0-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls