#55 Code view: build-time syntax highlighting, gutter rule, Raw button (#73)
Patch
src/build.rs+11 −7--- 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--- /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("<script>"));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--- 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--- 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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.