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