#18 m6/favicon
Patch
src/build.rs+15 −0--- 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--- 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--- 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--- /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--- /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"}
tests/favicon.rs+289 −1--- /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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.