#55 Code view: build-time syntax highlighting, gutter rule, Raw button (#73)

mergedpassopened
details
branches
master ← issue-73-syntax-highlight
issues
9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a
updated
id
724937e6-bbef-4f48-bd51-31481ab3bddd

Patch

src/build.rs+11 −7
diff --git a/src/build.rs b/src/build.rsindex 13e79e0..4c00c85 100644--- a/src/build.rs+++ b/src/build.rs@@ -991,11 +991,13 @@ pub fn clone_block(991991 /// Render one text file body: escaped `<pre>` with clickable per-line992992 /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container993993 /// so long lines never break the 360px layout.994-fn render_text_pre(display: &str) -> String {994+fn render_text_pre(display: &str, path: &str) -> String {995995     let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");996996     if display.is_empty() {997-        out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>\n");997+        out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");998998     } else {999+        // Build-time tokens (f0rge #73): unknown/huge files stay plain.1000+        let highlighted = crate::highlight::highlight_lines(path, display);9991001         for (i, line) in display.split('\n').enumerate() {10001002             let n = i + 1;10011003             out.push_str("<span class=\"line\" id=\"L");@@ -1005,8 +1007,11 @@ fn render_text_pre(display: &str) -> String {10051007             out.push_str("\">");10061008             out.push_str(&n.to_string());10071009             out.push_str("</a> ");1008-            out.push_str(&esc(line));1009-            out.push_str("</span>\n");1010+            match &highlighted {1011+                Some(lines) => out.push_str(&lines[i]),1012+                None => out.push_str(&esc(line)),1013+            }1014+            out.push_str("</span>");10101015         }10111016     }10121017     out.push_str("</pre></div>\n");@@ -1298,7 +1303,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu12981303 12991304     for f in &files {13001305         let mut body = breadcrumb(f);1301-        body.push_str(&format!("<h1>{}</h1>\n", esc(f)));13021306         if let Some(&size) = oversize.get(f) {13031307             body.push_str(&file_meta_bar(f, size, None));13041308             body.push_str(&format!(@@ -1339,7 +1343,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu13391343                     bytes.len() as u64,13401344                     Some(display.lines().count()),13411345                 ));1342-                body.push_str(&render_text_pre(&display));1346+                body.push_str(&render_text_pre(&display, f));13431347             }13441348         }13451349         let html = crate::layout::page_site_with_provenance(@@ -1463,7 +1467,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu14631467             if is_markdown(path) || path.to_lowercase().contains("readme") {14641468                 body.push_str(&markdown_to_html(&blob.display));14651469             } else {1466-                body.push_str(&render_text_pre(&blob.display));1470+                body.push_str(&render_text_pre(&blob.display, path));14671471             }14681472         }14691473         None => body.push_str("<p class=\"muted\">No README found.</p>\n"),
src/highlight.rs+403 −0
diff --git a/src/highlight.rs b/src/highlight.rsnew file mode 100644index 0000000..5a79051--- /dev/null+++ b/src/highlight.rs@@ -0,0 +1,403 @@1+//! Build-time syntax highlighting (f0rge #73).2+//!3+//! No JS, no dependencies: a small line scanner that carries only block4+//! comment state between lines. Unknown languages return `None` and the5+//! file page stays plain. Output is escaped HTML with `tok-*` spans;6+//! every byte of input goes through [`esc`], never raw.7+//!8+//! Deliberately conservative: a mis-highlighted token must never change9+//! the text, only its color, and unterminated strings/comments degrade10+//! to "rest of line" rather than swallowing the file.11+12+use crate::layout::esc;13+14+/// Files larger than this stay plain: token spans would triple the page.15+pub const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;16+17+struct Lang {18+    keywords: &'static [&'static str],19+    line_comment: &'static [&'static str],20+    block: Option<(&'static str, &'static str)>,21+    strings: &'static [char],22+}23+24+const RUST: Lang = Lang {25+    keywords: &[26+        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",27+        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",28+        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",29+        "true", "type", "unsafe", "use", "where", "while", "yield",30+    ],31+    line_comment: &["//"],32+    block: Some(("/*", "*/")),33+    strings: &['"'],34+};35+36+const JS: Lang = Lang {37+    keywords: &[38+        "async",39+        "await",40+        "break",41+        "case",42+        "catch",43+        "class",44+        "const",45+        "continue",46+        "default",47+        "delete",48+        "do",49+        "else",50+        "export",51+        "extends",52+        "false",53+        "finally",54+        "for",55+        "from",56+        "function",57+        "if",58+        "import",59+        "in",60+        "instanceof",61+        "let",62+        "new",63+        "null",64+        "of",65+        "return",66+        "static",67+        "super",68+        "switch",69+        "this",70+        "throw",71+        "true",72+        "try",73+        "typeof",74+        "undefined",75+        "var",76+        "void",77+        "while",78+        "yield",79+    ],80+    line_comment: &["//"],81+    block: Some(("/*", "*/")),82+    strings: &['"', '\'', '`'],83+};84+85+const JSON: Lang = Lang {86+    keywords: &["true", "false", "null"],87+    line_comment: &[],88+    block: None,89+    strings: &['"'],90+};91+92+const YAML: Lang = Lang {93+    keywords: &["true", "false", "null", "yes", "no", "on", "off"],94+    line_comment: &["#"],95+    block: None,96+    strings: &['"', '\''],97+};98+99+const TOML: Lang = Lang {100+    keywords: &["true", "false"],101+    line_comment: &["#"],102+    block: None,103+    strings: &['"', '\''],104+};105+106+const SHELL: Lang = Lang {107+    keywords: &[108+        "case", "cd", "do", "done", "echo", "elif", "else", "esac", "exec", "exit", "export", "fi",109+        "for", "function", "if", "in", "local", "read", "return", "set", "shift", "then", "until",110+        "unset", "while",111+    ],112+    line_comment: &["#"],113+    block: None,114+    strings: &['"', '\''],115+};116+117+const PYTHON: Lang = Lang {118+    keywords: &[119+        "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",120+        "elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import",121+        "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True",122+        "try", "while", "with", "yield",123+    ],124+    line_comment: &["#"],125+    block: None,126+    strings: &['"', '\''],127+};128+129+const GO: Lang = Lang {130+    keywords: &[131+        "break",132+        "case",133+        "chan",134+        "const",135+        "continue",136+        "default",137+        "defer",138+        "else",139+        "fallthrough",140+        "for",141+        "func",142+        "go",143+        "goto",144+        "if",145+        "import",146+        "interface",147+        "map",148+        "package",149+        "range",150+        "return",151+        "select",152+        "struct",153+        "switch",154+        "type",155+        "var",156+    ],157+    line_comment: &["//"],158+    block: Some(("/*", "*/")),159+    strings: &['"', '`'],160+};161+162+const CSS: Lang = Lang {163+    keywords: &[164+        "color",165+        "background",166+        "border",167+        "display",168+        "flex",169+        "grid",170+        "margin",171+        "padding",172+        "position",173+        "width",174+        "height",175+        "font",176+        "content",177+        "media",178+        "import",179+    ],180+    line_comment: &[],181+    block: Some(("/*", "*/")),182+    strings: &['"', '\''],183+};184+185+fn lang_for(path: &str) -> Option<&'static Lang> {186+    let ext = path.rsplit('.').next()?.to_ascii_lowercase();187+    match ext.as_str() {188+        "rs" => Some(&RUST),189+        "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" => Some(&JS),190+        "json" | "jsonl" => Some(&JSON),191+        "yaml" | "yml" => Some(&YAML),192+        "toml" | "ini" | "cfg" | "conf" => Some(&TOML),193+        "sh" | "bash" | "zsh" | "fish" => Some(&SHELL),194+        "py" => Some(&PYTHON),195+        "go" => Some(&GO),196+        "css" | "scss" | "less" => Some(&CSS),197+        _ => None,198+    }199+}200+201+fn is_ident_start(c: char) -> bool {202+    c.is_alphabetic() || c == '_' || c == '$'203+}204+205+fn is_ident(c: char) -> bool {206+    c.is_alphanumeric() || c == '_' || c == '$'207+}208+209+fn push_span(out: &mut String, class: &str, text: &str) {210+    if text.is_empty() {211+        return;212+    }213+    out.push_str("<span class=\"tok tok-");214+    out.push_str(class);215+    out.push_str("\">");216+    out.push_str(&esc(text));217+    out.push_str("</span>");218+}219+220+/// Highlight one file into one HTML fragment per input line.221+///222+/// `None` for unknown languages, oversized input, or a path with no223+/// useful lexer (markdown, plain text) — callers then escape as-is.224+#[must_use]225+pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {226+    if text.len() > MAX_HIGHLIGHT_BYTES {227+        return None;228+    }229+    let lang = lang_for(path)?;230+    let mut in_block = false;231+    let mut out = Vec::new();232+    for line in text.split('\n') {233+        out.push(scan_line(lang, line, &mut in_block));234+    }235+    Some(out)236+}237+238+fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {239+    let chars: Vec<char> = line.chars().collect();240+    let mut out = String::with_capacity(line.len() + 16);241+    let mut i = 0;242+    while i < chars.len() {243+        let rest: String = chars[i..].iter().collect();244+245+        if *in_block {246+            if let Some((_, end)) = lang.block {247+                if let Some(pos) = rest.find(end) {248+                    let stop = i + rest[..pos].chars().count() + end.chars().count();249+                    let piece: String = chars[i..stop].iter().collect();250+                    push_span(&mut out, "com", &piece);251+                    *in_block = false;252+                    i = stop;253+                    continue;254+                }255+            }256+            let piece: String = chars[i..].iter().collect();257+            push_span(&mut out, "com", &piece);258+            return out;259+        }260+261+        if let Some((start, end)) = lang.block {262+            if let Some(after_start) = rest.strip_prefix(start) {263+                if let Some(pos) = after_start.find(end) {264+                    let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();265+                    let piece: String = chars[i..stop].iter().collect();266+                    push_span(&mut out, "com", &piece);267+                    i = stop;268+                    continue;269+                }270+                *in_block = true;271+                let piece: String = chars[i..].iter().collect();272+                push_span(&mut out, "com", &piece);273+                return out;274+            }275+        }276+277+        if lang.line_comment.iter().any(|m| rest.starts_with(m)) {278+            let piece: String = chars[i..].iter().collect();279+            push_span(&mut out, "com", &piece);280+            return out;281+        }282+283+        let c = chars[i];284+        if lang.strings.contains(&c) {285+            let mut j = i + 1;286+            let mut escaped = false;287+            while j < chars.len() {288+                let d = chars[j];289+                if escaped {290+                    escaped = false;291+                } else if d == '\\' && c != '\'' {292+                    escaped = true;293+                } else if d == c {294+                    j += 1;295+                    break;296+                }297+                j += 1;298+            }299+            let piece: String = chars[i..j].iter().collect();300+            push_span(&mut out, "str", &piece);301+            i = j;302+            continue;303+        }304+305+        if c.is_ascii_digit() {306+            let mut j = i + 1;307+            while j < chars.len()308+                && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '.' | '_' | 'x' | 'X'))309+            {310+                j += 1;311+            }312+            let piece: String = chars[i..j].iter().collect();313+            push_span(&mut out, "num", &piece);314+            i = j;315+            continue;316+        }317+318+        if is_ident_start(c) {319+            let mut j = i + 1;320+            while j < chars.len() && is_ident(chars[j]) {321+                j += 1;322+            }323+            let word: String = chars[i..j].iter().collect();324+            let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());325+            let call = next_non_space.is_some_and(|c| *c == '(');326+            let class = if lang.keywords.contains(&word.as_str()) {327+                Some("kw")328+            } else if call {329+                Some("fn")330+            } else if word.chars().next().is_some_and(char::is_uppercase) {331+                Some("type")332+            } else {333+                None334+            };335+            match class {336+                Some(class) => push_span(&mut out, class, &word),337+                None => out.push_str(&esc(&word)),338+            }339+            i = j;340+            continue;341+        }342+343+        out.push_str(&esc(&c.to_string()));344+        i += 1;345+    }346+    out347+}348+349+#[cfg(test)]350+mod tests {351+    use super::*;352+353+    #[test]354+    fn rust_tokens_are_classified() {355+        let src = "// hi\nfn main() { let x = 42; println!(\"ok\"); }";356+        let lines = highlight_lines("a.rs", src).expect("rust");357+        assert!(lines[0].contains("tok-com"));358+        assert!(lines[1].contains("tok-kw\">fn<"));359+        assert!(lines[1].contains("tok-fn\">main<"));360+        assert!(lines[1].contains("tok-num\">42<"));361+        assert!(lines[1].contains("tok-str\">"));362+        // Text is preserved exactly once tags are stripped.363+        let mut stripped = String::new();364+        let mut in_tag = false;365+        for c in lines.join("\n").chars() {366+            match c {367+                '<' => in_tag = true,368+                '>' => in_tag = false,369+                _ if !in_tag => stripped.push(c),370+                _ => {}371+            }372+        }373+        assert!(stripped.contains("fn main() { let x = 42;"));374+    }375+376+    #[test]377+    fn unknown_languages_stay_plain() {378+        assert!(highlight_lines("notes.txt", "fn main() {}").is_none());379+        assert!(highlight_lines("README.md", "# hi").is_none());380+    }381+382+    #[test]383+    fn block_comment_carries_across_lines() {384+        let lines = highlight_lines("a.rs", "/* one\ntwo */\nfn x() {}").expect("rust");385+        assert!(lines[0].contains("tok-com"));386+        assert!(lines[1].contains("tok-com"));387+        assert!(lines[2].contains("tok-kw"));388+    }389+390+    #[test]391+    fn hostile_text_is_escaped() {392+        let lines =393+            highlight_lines("a.js", "const x = \"<script>alert(1)</script>\";").expect("js");394+        assert!(!lines[0].contains("<script>"));395+        assert!(lines[0].contains("&lt;script&gt;"));396+    }397+398+    #[test]399+    fn oversized_inputs_stay_plain() {400+        let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);401+        assert!(highlight_lines("a.rs", &big).is_none());402+    }403+}
src/main.rs+1 −0
diff --git a/src/main.rs b/src/main.rsindex d2ee742..9deb176 100644--- a/src/main.rs+++ b/src/main.rs@@ -5,6 +5,7 @@ mod commits;55 mod feed;66 mod forge;77 mod gitx;8+mod highlight;89 mod inbox;910 mod layout;1011 mod logo;
static/style.css+13 −3
diff --git a/static/style.css b/static/style.cssindex c6ca7a5..53ab719 100644--- a/static/style.css+++ b/static/style.css@@ -224,10 +224,21 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align224224 .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)}225225 .file-path{overflow-wrap:anywhere;word-break:break-all}226226 .file-size,.file-lines{color:var(--muted);white-space:nowrap}227-.file-raw{display:inline-flex;align-items:center;min-height:40px}227+.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}228+.file-raw:hover,.file-raw:focus{background:var(--surface-2);text-decoration:none}228229 .line:target{background:var(--bg-th)}229-.ln{color:var(--muted);text-decoration:none}230+/* Code view (f0rge #73): sticky-looking gutter with a rule, token colors. */231+pre.code{background:var(--surface);padding:var(--s2) 0;font-size:.83rem;line-height:1.5;tab-size:4}232+pre.code .line{display:block;padding-right:var(--s4)}233+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}234+pre.code .line:target .ln{color:var(--fg)}230235 a.ln:hover,a.ln:focus{text-decoration:underline}236+.tok-kw{color:var(--tok-kw)}237+.tok-str{color:var(--tok-str)}238+.tok-num{color:var(--tok-num)}239+.tok-com{color:var(--tok-com);font-style:italic}240+.tok-fn{color:var(--tok-fn)}241+.tok-type{color:var(--tok-type)}231242 .clone-block{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}232243 .clone-block h2{margin:.75rem 0 .25rem}233244 .clone-block h3{margin:1rem 0 .25rem}234-- 2.56.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls