1 From a09687a200711eadcf3678b4ef0bcbcfbf11ffd6 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