diff --git a/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/patch.diff b/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/patch.diffnew file mode 100644index 0000000..200fdb2--- /dev/null+++ b/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/patch.diff@@ -0,0 +1,537 @@1+From e9b25c791e9dddbbd8d0a50aa3cc97e9d8b3a7dd Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 16:17:03 +00004+Subject: [PATCH] code view: build-time syntax highlighting, gutter rule, Raw5+ button (#73)6+7+New src/highlight.rs: a conservative, dependency-free line scanner8+for Rust, JS/TS, JSON, YAML, TOML, shell, Python, Go and CSS.9+Tokens are tok-kw/str/num/com/fn/type spans over escaped text;10+block-comment state carries between lines, unterminated strings11+degrade to end-of-line, and unknown/oversized (>512 KiB) files stay12+plain. Hostile text is escaped (tested with <script>).13+14+File pages drop the duplicated h1 (breadcrumb already names the15+path), the gutter gets a rule and AA colors, and Raw becomes a real16+secondary button. Highlighting is per line, so #L42 anchors and17+copying are unchanged.18+---19+ src/build.rs | 18 ++-20+ src/highlight.rs | 403 +++++++++++++++++++++++++++++++++++++++++++++++21+ src/main.rs | 1 +22+ static/style.css | 15 +-23+ 4 files changed, 428 insertions(+), 9 deletions(-)24+ create mode 100644 src/highlight.rs25+26+diff --git a/src/build.rs b/src/build.rs27+index 13e79e0..4c00c85 10064428+--- a/src/build.rs29++++ b/src/build.rs30+@@ -991,11 +991,13 @@ pub fn clone_block(31+ /// Render one text file body: escaped `<pre>` with clickable per-line32+ /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container33+ /// so long lines never break the 360px layout.34+-fn render_text_pre(display: &str) -> String {35++fn render_text_pre(display: &str, path: &str) -> String {36+ let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");37+ if display.is_empty() {38+- out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>\n");39++ out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");40+ } else {41++ // Build-time tokens (f0rge #73): unknown/huge files stay plain.42++ let highlighted = crate::highlight::highlight_lines(path, display);43+ for (i, line) in display.split('\n').enumerate() {44+ let n = i + 1;45+ out.push_str("<span class=\"line\" id=\"L");46+@@ -1005,8 +1007,11 @@ fn render_text_pre(display: &str) -> String {47+ out.push_str("\">");48+ out.push_str(&n.to_string());49+ out.push_str("</a> ");50+- out.push_str(&esc(line));51+- out.push_str("</span>\n");52++ match &highlighted {53++ Some(lines) => out.push_str(&lines[i]),54++ None => out.push_str(&esc(line)),55++ }56++ out.push_str("</span>");57+ }58+ }59+ out.push_str("</pre></div>\n");60+@@ -1298,7 +1303,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu61+ 62+ for f in &files {63+ let mut body = breadcrumb(f);64+- body.push_str(&format!("<h1>{}</h1>\n", esc(f)));65+ if let Some(&size) = oversize.get(f) {66+ body.push_str(&file_meta_bar(f, size, None));67+ body.push_str(&format!(68+@@ -1339,7 +1343,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu69+ bytes.len() as u64,70+ Some(display.lines().count()),71+ ));72+- body.push_str(&render_text_pre(&display));73++ body.push_str(&render_text_pre(&display, f));74+ }75+ }76+ let html = crate::layout::page_site_with_provenance(77+@@ -1463,7 +1467,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu78+ if is_markdown(path) || path.to_lowercase().contains("readme") {79+ body.push_str(&markdown_to_html(&blob.display));80+ } else {81+- body.push_str(&render_text_pre(&blob.display));82++ body.push_str(&render_text_pre(&blob.display, path));83+ }84+ }85+ None => body.push_str("<p class=\"muted\">No README found.</p>\n"),86+diff --git a/src/highlight.rs b/src/highlight.rs87+new file mode 10064488+index 0000000..5a7905189+--- /dev/null90++++ b/src/highlight.rs91+@@ -0,0 +1,403 @@92++//! Build-time syntax highlighting (f0rge #73).93++//!94++//! No JS, no dependencies: a small line scanner that carries only block95++//! comment state between lines. Unknown languages return `None` and the96++//! file page stays plain. Output is escaped HTML with `tok-*` spans;97++//! every byte of input goes through [`esc`], never raw.98++//!99++//! Deliberately conservative: a mis-highlighted token must never change100++//! the text, only its color, and unterminated strings/comments degrade101++//! to "rest of line" rather than swallowing the file.102++103++use crate::layout::esc;104++105++/// Files larger than this stay plain: token spans would triple the page.106++pub const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;107++108++struct Lang {109++ keywords: &'static [&'static str],110++ line_comment: &'static [&'static str],111++ block: Option<(&'static str, &'static str)>,112++ strings: &'static [char],113++}114++115++const RUST: Lang = Lang {116++ keywords: &[117++ "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",118++ "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",119++ "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",120++ "true", "type", "unsafe", "use", "where", "while", "yield",121++ ],122++ line_comment: &["//"],123++ block: Some(("/*", "*/")),124++ strings: &['"'],125++};126++127++const JS: Lang = Lang {128++ keywords: &[129++ "async",130++ "await",131++ "break",132++ "case",133++ "catch",134++ "class",135++ "const",136++ "continue",137++ "default",138++ "delete",139++ "do",140++ "else",141++ "export",142++ "extends",143++ "false",144++ "finally",145++ "for",146++ "from",147++ "function",148++ "if",149++ "import",150++ "in",151++ "instanceof",152++ "let",153++ "new",154++ "null",155++ "of",156++ "return",157++ "static",158++ "super",159++ "switch",160++ "this",161++ "throw",162++ "true",163++ "try",164++ "typeof",165++ "undefined",166++ "var",167++ "void",168++ "while",169++ "yield",170++ ],171++ line_comment: &["//"],172++ block: Some(("/*", "*/")),173++ strings: &['"', '\'', '`'],174++};175++176++const JSON: Lang = Lang {177++ keywords: &["true", "false", "null"],178++ line_comment: &[],179++ block: None,180++ strings: &['"'],181++};182++183++const YAML: Lang = Lang {184++ keywords: &["true", "false", "null", "yes", "no", "on", "off"],185++ line_comment: &["#"],186++ block: None,187++ strings: &['"', '\''],188++};189++190++const TOML: Lang = Lang {191++ keywords: &["true", "false"],192++ line_comment: &["#"],193++ block: None,194++ strings: &['"', '\''],195++};196++197++const SHELL: Lang = Lang {198++ keywords: &[199++ "case", "cd", "do", "done", "echo", "elif", "else", "esac", "exec", "exit", "export", "fi",200++ "for", "function", "if", "in", "local", "read", "return", "set", "shift", "then", "until",201++ "unset", "while",202++ ],203++ line_comment: &["#"],204++ block: None,205++ strings: &['"', '\''],206++};207++208++const PYTHON: Lang = Lang {209++ keywords: &[210++ "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",211++ "elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import",212++ "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True",213++ "try", "while", "with", "yield",214++ ],215++ line_comment: &["#"],216++ block: None,217++ strings: &['"', '\''],218++};219++220++const GO: Lang = Lang {221++ keywords: &[222++ "break",223++ "case",224++ "chan",225++ "const",226++ "continue",227++ "default",228++ "defer",229++ "else",230++ "fallthrough",231++ "for",232++ "func",233++ "go",234++ "goto",235++ "if",236++ "import",237++ "interface",238++ "map",239++ "package",240++ "range",241++ "return",242++ "select",243++ "struct",244++ "switch",245++ "type",246++ "var",247++ ],248++ line_comment: &["//"],249++ block: Some(("/*", "*/")),250++ strings: &['"', '`'],251++};252++253++const CSS: Lang = Lang {254++ keywords: &[255++ "color",256++ "background",257++ "border",258++ "display",259++ "flex",260++ "grid",261++ "margin",262++ "padding",263++ "position",264++ "width",265++ "height",266++ "font",267++ "content",268++ "media",269++ "import",270++ ],271++ line_comment: &[],272++ block: Some(("/*", "*/")),273++ strings: &['"', '\''],274++};275++276++fn lang_for(path: &str) -> Option<&'static Lang> {277++ let ext = path.rsplit('.').next()?.to_ascii_lowercase();278++ match ext.as_str() {279++ "rs" => Some(&RUST),280++ "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" => Some(&JS),281++ "json" | "jsonl" => Some(&JSON),282++ "yaml" | "yml" => Some(&YAML),283++ "toml" | "ini" | "cfg" | "conf" => Some(&TOML),284++ "sh" | "bash" | "zsh" | "fish" => Some(&SHELL),285++ "py" => Some(&PYTHON),286++ "go" => Some(&GO),287++ "css" | "scss" | "less" => Some(&CSS),288++ _ => None,289++ }290++}291++292++fn is_ident_start(c: char) -> bool {293++ c.is_alphabetic() || c == '_' || c == '$'294++}295++296++fn is_ident(c: char) -> bool {297++ c.is_alphanumeric() || c == '_' || c == '$'298++}299++300++fn push_span(out: &mut String, class: &str, text: &str) {301++ if text.is_empty() {302++ return;303++ }304++ out.push_str("<span class=\"tok tok-");305++ out.push_str(class);306++ out.push_str("\">");307++ out.push_str(&esc(text));308++ out.push_str("</span>");309++}310++311++/// Highlight one file into one HTML fragment per input line.312++///313++/// `None` for unknown languages, oversized input, or a path with no314++/// useful lexer (markdown, plain text) — callers then escape as-is.315++#[must_use]316++pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {317++ if text.len() > MAX_HIGHLIGHT_BYTES {318++ return None;319++ }320++ let lang = lang_for(path)?;321++ let mut in_block = false;322++ let mut out = Vec::new();323++ for line in text.split('\n') {324++ out.push(scan_line(lang, line, &mut in_block));325++ }326++ Some(out)327++}328++329++fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {330++ let chars: Vec<char> = line.chars().collect();331++ let mut out = String::with_capacity(line.len() + 16);332++ let mut i = 0;333++ while i < chars.len() {334++ let rest: String = chars[i..].iter().collect();335++336++ if *in_block {337++ if let Some((_, end)) = lang.block {338++ if let Some(pos) = rest.find(end) {339++ let stop = i + rest[..pos].chars().count() + end.chars().count();340++ let piece: String = chars[i..stop].iter().collect();341++ push_span(&mut out, "com", &piece);342++ *in_block = false;343++ i = stop;344++ continue;345++ }346++ }347++ let piece: String = chars[i..].iter().collect();348++ push_span(&mut out, "com", &piece);349++ return out;350++ }351++352++ if let Some((start, end)) = lang.block {353++ if let Some(after_start) = rest.strip_prefix(start) {354++ if let Some(pos) = after_start.find(end) {355++ let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();356++ let piece: String = chars[i..stop].iter().collect();357++ push_span(&mut out, "com", &piece);358++ i = stop;359++ continue;360++ }361++ *in_block = true;362++ let piece: String = chars[i..].iter().collect();363++ push_span(&mut out, "com", &piece);364++ return out;365++ }366++ }367++368++ if lang.line_comment.iter().any(|m| rest.starts_with(m)) {369++ let piece: String = chars[i..].iter().collect();370++ push_span(&mut out, "com", &piece);371++ return out;372++ }373++374++ let c = chars[i];375++ if lang.strings.contains(&c) {376++ let mut j = i + 1;377++ let mut escaped = false;378++ while j < chars.len() {379++ let d = chars[j];380++ if escaped {381++ escaped = false;382++ } else if d == '\\' && c != '\'' {383++ escaped = true;384++ } else if d == c {385++ j += 1;386++ break;387++ }388++ j += 1;389++ }390++ let piece: String = chars[i..j].iter().collect();391++ push_span(&mut out, "str", &piece);392++ i = j;393++ continue;394++ }395++396++ if c.is_ascii_digit() {397++ let mut j = i + 1;398++ while j < chars.len()399++ && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '.' | '_' | 'x' | 'X'))400++ {401++ j += 1;402++ }403++ let piece: String = chars[i..j].iter().collect();404++ push_span(&mut out, "num", &piece);405++ i = j;406++ continue;407++ }408++409++ if is_ident_start(c) {410++ let mut j = i + 1;411++ while j < chars.len() && is_ident(chars[j]) {412++ j += 1;413++ }414++ let word: String = chars[i..j].iter().collect();415++ let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());416++ let call = next_non_space.is_some_and(|c| *c == '(');417++ let class = if lang.keywords.contains(&word.as_str()) {418++ Some("kw")419++ } else if call {420++ Some("fn")421++ } else if word.chars().next().is_some_and(char::is_uppercase) {422++ Some("type")423++ } else {424++ None425++ };426++ match class {427++ Some(class) => push_span(&mut out, class, &word),428++ None => out.push_str(&esc(&word)),429++ }430++ i = j;431++ continue;432++ }433++434++ out.push_str(&esc(&c.to_string()));435++ i += 1;436++ }437++ out438++}439++440++#[cfg(test)]441++mod tests {442++ use super::*;443++444++ #[test]445++ fn rust_tokens_are_classified() {446++ let src = "// hi\nfn main() { let x = 42; println!(\"ok\"); }";447++ let lines = highlight_lines("a.rs", src).expect("rust");448++ assert!(lines[0].contains("tok-com"));449++ assert!(lines[1].contains("tok-kw\">fn<"));450++ assert!(lines[1].contains("tok-fn\">main<"));451++ assert!(lines[1].contains("tok-num\">42<"));452++ assert!(lines[1].contains("tok-str\">"));453++ // Text is preserved exactly once tags are stripped.454++ let mut stripped = String::new();455++ let mut in_tag = false;456++ for c in lines.join("\n").chars() {457++ match c {458++ '<' => in_tag = true,459++ '>' => in_tag = false,460++ _ if !in_tag => stripped.push(c),461++ _ => {}462++ }463++ }464++ assert!(stripped.contains("fn main() { let x = 42;"));465++ }466++467++ #[test]468++ fn unknown_languages_stay_plain() {469++ assert!(highlight_lines("notes.txt", "fn main() {}").is_none());470++ assert!(highlight_lines("README.md", "# hi").is_none());471++ }472++473++ #[test]474++ fn block_comment_carries_across_lines() {475++ let lines = highlight_lines("a.rs", "/* one\ntwo */\nfn x() {}").expect("rust");476++ assert!(lines[0].contains("tok-com"));477++ assert!(lines[1].contains("tok-com"));478++ assert!(lines[2].contains("tok-kw"));479++ }480++481++ #[test]482++ fn hostile_text_is_escaped() {483++ let lines =484++ highlight_lines("a.js", "const x = \"<script>alert(1)</script>\";").expect("js");485++ assert!(!lines[0].contains("<script>"));486++ assert!(lines[0].contains("<script>"));487++ }488++489++ #[test]490++ fn oversized_inputs_stay_plain() {491++ let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);492++ assert!(highlight_lines("a.rs", &big).is_none());493++ }494++}495+diff --git a/src/main.rs b/src/main.rs496+index d2ee742..9deb176 100644497+--- a/src/main.rs498++++ b/src/main.rs499+@@ -5,6 +5,7 @@ mod commits;500+ mod feed;501+ mod forge;502+ mod gitx;503++mod highlight;504+ mod inbox;505+ mod layout;506+ mod logo;507+diff --git a/static/style.css b/static/style.css508+index c6ca7a5..53ab719 100644509+--- a/static/style.css510++++ b/static/style.css511+@@ -224,10 +224,21 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align512+ .file-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;margin:.5rem 0;padding:.5rem .75rem;background:var(--bg-alt);border:1px solid var(--border)}513+ .file-path{overflow-wrap:anywhere;word-break:break-all}514+ .file-size,.file-lines{color:var(--muted);white-space:nowrap}515+-.file-raw{display:inline-flex;align-items:center;min-height:40px}516++.file-raw{display:inline-flex;align-items:center;min-height:40px;padding:0 .75rem;border:1px solid var(--border);color:var(--blue);font-weight:600}517++.file-raw:hover,.file-raw:focus{background:var(--surface-2);text-decoration:none}518+ .line:target{background:var(--bg-th)}519+-.ln{color:var(--muted);text-decoration:none}520++/* Code view (f0rge #73): sticky-looking gutter with a rule, token colors. */521++pre.code{background:var(--surface);padding:var(--s2) 0;font-size:.83rem;line-height:1.5;tab-size:4}522++pre.code .line{display:block;padding-right:var(--s4)}523++pre.code .ln{display:inline-block;width:3.4em;padding-right:var(--s3);margin-right:var(--s3);border-right:1px solid var(--border);text-align:right;color:var(--fg-dim);text-decoration:none;user-select:none}524++pre.code .line:target .ln{color:var(--fg)}525+ a.ln:hover,a.ln:focus{text-decoration:underline}526++.tok-kw{color:var(--tok-kw)}527++.tok-str{color:var(--tok-str)}528++.tok-num{color:var(--tok-num)}529++.tok-com{color:var(--tok-com);font-style:italic}530++.tok-fn{color:var(--tok-fn)}531++.tok-type{color:var(--tok-type)}532+ .clone-block{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}533+ .clone-block h2{margin:.75rem 0 .25rem}534+ .clone-block h3{margin:1rem 0 .25rem}535+-- 536+2.56.0537+