record #73 merge

7809a8fgl0w

all commits

Message

record #73 merge

Diff

.forge/merges/2026-10-27T16-17-53.546157837Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T16-17-53.546157837Z.md b/.forge/merges/2026-10-27T16-17-53.546157837Z.mdnew file mode 100644index 0000000..d7870c1--- /dev/null+++ b/.forge/merges/2026-10-27T16-17-53.546157837Z.md@@ -0,0 +1,9 @@1+---2+pr: 724937e6-bbef-4f48-bd51-31481ab3bddd3+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T16:17:53.546157837Z7+---8+clean apply9+patch: 73e1531c7227a41f7f4841adb8085289b1268dfb
.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/patch.diff+537 −0
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("&lt;script&gt;"));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+
.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/pr.md+13 −0
diff --git a/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/pr.md b/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/pr.mdnew file mode 100644index 0000000..129bfd6--- /dev/null+++ b/.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/pr.md@@ -0,0 +1,13 @@1+---2+id: 724937e6-bbef-4f48-bd51-31481ab3bddd3+title: 'Code view: build-time syntax highlighting, gutter rule, Raw button (#73)'4+state: merged5+base: master6+head: issue-73-syntax-highlight7+draft: false8+issues:9+- 9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a10+ci: pass11+created: 2026-10-27T16:17:03.421657079Z12+updated: 2026-10-27T16:17:53.546157837Z13+---