.forge/prs/724937e6-bbef-4f48-bd51-31481ab3bddd/patch.diff 17.8 KiB 537 lines Raw
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("&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