feat integrate highlight.js for syntax highlighting in file view

Add highlight.js 11.11.1 (github-dark theme) from cdnjs. Language detection uses a three-tier fallback: 1. File extension (~35 mappings) 2. Shebang line (#!/usr/bin/env python3, etc.) 3. Editor modelines (Emacs -*- mode: X -*- and Vim ft=X/filetype=X) Vim modelines are checked in both the first and last 5 lines of the file. On pages with a recognized language, the init script highlights each line span individually, preserving the line-number grid layout. No external OCaml dependencies added — all parsing uses stdlib string operations.

Commit
b49d19db0597024992e7ec53b5c80d5c0e24da01
Author
Claude Sonnet 4 <agent@anthropic.com>
Author date
Committer
Claude Sonnet 4 <agent@anthropic.com>
Committer date
Changed files
lib/views/layout.ml
index d3433bad..ca57accf 100644..100644
@@ -95,7 +95,20 @@
95 95 title [] "%s" page_title;
96 96 meta [ name "viewport"; content "width=device-width, initial-scale=1" ];
97 97 link [ rel "stylesheet"; href "/static/styles.css" ];
98 Added: link
99 Added: [
100 Added: rel "stylesheet";
101 Added: href
102 Added: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css";
103 Added: ];
98 104 link [ rel "icon"; type_ "image/x-icon"; href "/static/git_icon.svg" ];
105 Added: script
106 Added: [
107 Added: src
108 Added: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js";
109 Added: defer;
110 Added: ]
111 Added: "";
99 112 ]
100 113
101 114 let body ~user ~root_title page_data =
@@ -111,6 +124,8 @@
111 124 | Some _ -> page_header ~has_repo:true page_data.title page_data.subtitle);
112 125 div [ id "main" ] page_data.content;
113 126 page_footer user;
127 Added: script []
128 Added: {|document.addEventListener("DOMContentLoaded",function(){var b=document.getElementById("blob");if(!b||typeof hljs==="undefined")return;var cls=b.className.match(/language-([\w-]+)/);if(!cls)return;var lang=cls[1];b.querySelectorAll("span.line").forEach(function(el){var r=hljs.highlight(el.textContent,{language:lang,ignoreIllegals:true});el.innerHTML=r.value})});|};
114 129 ]
115 130
116 131 let render ?(page_title = "Ogit") ~user ~root_title body_data =
lib/views/repo.ml
index bafaf369..a4a86b1a 100644..100644
@@ -14,6 +14,242 @@
14 14 let context ~user ~root_title ~repo ~description =
15 15 { repo; description; user; root_title }
16 16
17 Added: let language_of_filename name =
18 Added: match Filename.extension name |> String.lowercase_ascii with
19 Added: | ".ml" | ".mli" -> Some "ocaml"
20 Added: | ".c" | ".h" -> Some "c"
21 Added: | ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp"
22 Added: | ".cs" -> Some "csharp"
23 Added: | ".css" -> Some "css"
24 Added: | ".diff" | ".patch" -> Some "diff"
25 Added: | ".el" | ".lisp" | ".cl" -> Some "lisp"
26 Added: | ".erl" -> Some "erlang"
27 Added: | ".ex" | ".exs" -> Some "elixir"
28 Added: | ".go" -> Some "go"
29 Added: | ".hs" -> Some "haskell"
30 Added: | ".html" | ".htm" -> Some "xml"
31 Added: | ".java" -> Some "java"
32 Added: | ".js" | ".mjs" | ".cjs" -> Some "javascript"
33 Added: | ".json" -> Some "json"
34 Added: | ".kt" -> Some "kotlin"
35 Added: | ".lua" -> Some "lua"
36 Added: | ".md" -> Some "markdown"
37 Added: | ".nix" -> Some "nix"
38 Added: | ".php" -> Some "php"
39 Added: | ".pl" | ".pm" | ".t" -> Some "perl"
40 Added: | ".py" -> Some "python"
41 Added: | ".r" -> Some "r"
42 Added: | ".rb" -> Some "ruby"
43 Added: | ".rs" -> Some "rust"
44 Added: | ".scala" -> Some "scala"
45 Added: | ".sh" | ".bash" | ".zsh" -> Some "bash"
46 Added: | ".sql" -> Some "sql"
47 Added: | ".swift" -> Some "swift"
48 Added: | ".toml" -> Some "ini"
49 Added: | ".ts" | ".tsx" -> Some "typescript"
50 Added: | ".xml" | ".svg" | ".xsl" -> Some "xml"
51 Added: | ".yaml" | ".yml" -> Some "yaml"
52 Added: | ".zig" -> Some "zig"
53 Added: | _ -> None
54 Added:
55 Added: let language_of_shebang line =
56 Added: if not (String.starts_with ~prefix:"#!" line) then None
57 Added: else
58 Added: (* Extract the last path component, ignoring env and arguments *)
59 Added: let rest = String.sub line 2 (String.length line - 2) in
60 Added: let parts = String.split_on_char ' ' (String.trim rest) in
61 Added: let interpreter =
62 Added: match parts with
63 Added: | [] -> ""
64 Added: | cmd :: args ->
65 Added: let base = Filename.basename cmd in
66 Added: if base = "env" then
67 Added: (* /usr/bin/env python3 — take next non-flag argument *)
68 Added: List.find_opt (fun s -> s <> "" && s.[0] <> '-') args
69 Added: |> Option.value ~default:"" |> Filename.basename
70 Added: else base
71 Added: in
72 Added: (* Strip version suffixes: python3.11 -> python, ruby3.2 -> ruby *)
73 Added: let interpreter =
74 Added: match String.split_on_char '.' interpreter with
75 Added: | [] -> ""
76 Added: | base :: _ ->
77 Added: (* Also strip trailing digits: python3 -> python *)
78 Added: let len = String.length base in
79 Added: let i = ref (len - 1) in
80 Added: while !i >= 0 && base.[!i] >= '0' && base.[!i] <= '9' do
81 Added: decr i
82 Added: done;
83 Added: if !i < 0 then base else String.sub base 0 (!i + 1)
84 Added: in
85 Added: match String.lowercase_ascii interpreter with
86 Added: | "sh" | "bash" | "dash" | "ash" | "zsh" -> Some "bash"
87 Added: | "python" -> Some "python"
88 Added: | "ruby" -> Some "ruby"
89 Added: | "perl" -> Some "perl"
90 Added: | "node" | "deno" | "bun" -> Some "javascript"
91 Added: | "lua" -> Some "lua"
92 Added: | "php" -> Some "php"
93 Added: | "elixir" -> Some "elixir"
94 Added: | "awk" | "gawk" | "mawk" -> Some "awk"
95 Added: | "ocaml" -> Some "ocaml"
96 Added: | _ -> None
97 Added:
98 Added: let language_of_emacs_prop line =
99 Added: let find_between s prefix suffix =
100 Added: let plen = String.length prefix in
101 Added: let slen = String.length suffix in
102 Added: let total = String.length s in
103 Added: let rec find_start i =
104 Added: if i > total - plen then None
105 Added: else if String.sub s i plen = prefix then
106 Added: let after = i + plen in
107 Added: let rec find_end j =
108 Added: if j > total - slen then None
109 Added: else if String.sub s j slen = suffix then
110 Added: Some (String.sub s after (j - after) |> String.trim)
111 Added: else find_end (j + 1)
112 Added: in
113 Added: find_end after
114 Added: else find_start (i + 1)
115 Added: in
116 Added: find_start 0
117 Added: in
118 Added: let extract_mode between =
119 Added: let props = String.split_on_char ';' between in
120 Added: let mode_prop =
121 Added: List.find_map
122 Added: (fun prop ->
123 Added: match String.split_on_char ':' (String.trim prop) with
124 Added: | [ key; value ]
125 Added: when String.trim (String.lowercase_ascii key) = "mode" ->
126 Added: Some (String.trim value)
127 Added: | _ -> None)
128 Added: props
129 Added: in
130 Added: match mode_prop with
131 Added: | Some _ -> mode_prop
132 Added: | None ->
133 Added: if
134 Added: (not (String.contains between ':'))
135 Added: && not (String.contains between ';')
136 Added: then Some (String.trim between)
137 Added: else None
138 Added: in
139 Added: let normalize_mode mode =
140 Added: match String.lowercase_ascii mode with
141 Added: | "tuareg" | "caml" | "ocaml" -> Some "ocaml"
142 Added: | "emacs-lisp" | "lisp" | "elisp" -> Some "lisp"
143 Added: | "shell-script" | "sh" | "bash" -> Some "bash"
144 Added: | "python" -> Some "python"
145 Added: | "ruby" -> Some "ruby"
146 Added: | "perl" | "cperl" -> Some "perl"
147 Added: | "c" -> Some "c"
148 Added: | "c++" -> Some "cpp"
149 Added: | "javascript" | "js" -> Some "javascript"
150 Added: | "typescript" -> Some "typescript"
151 Added: | "rust" -> Some "rust"
152 Added: | "go" -> Some "go"
153 Added: | "haskell" -> Some "haskell"
154 Added: | "lua" -> Some "lua"
155 Added: | "sql" -> Some "sql"
156 Added: | "yaml" -> Some "yaml"
157 Added: | "nix" -> Some "nix"
158 Added: | "makefile" -> Some "makefile"
159 Added: | m -> Some m
160 Added: in
161 Added: find_between line "-*-" "-*-"
162 Added: |> Fun.flip Option.bind extract_mode
163 Added: |> Fun.flip Option.bind normalize_mode
164 Added:
165 Added: let language_of_vim_modeline line =
166 Added: let contains_substring s sub =
167 Added: let slen = String.length s in
168 Added: let sublen = String.length sub in
169 Added: let rec check i =
170 Added: if i > slen - sublen then false
171 Added: else if String.sub s i sublen = sub then true
172 Added: else check (i + 1)
173 Added: in
174 Added: sublen <= slen && check 0
175 Added: in
176 Added: let l = String.lowercase_ascii line in
177 Added: let has_vim_prefix =
178 Added: contains_substring l "vim:"
179 Added: || contains_substring l "vi:" || contains_substring l "ex:"
180 Added: in
181 Added: if not has_vim_prefix then None
182 Added: else
183 Added: let find_value prefix s =
184 Added: let plen = String.length prefix in
185 Added: let slen = String.length s in
186 Added: let rec find_at i =
187 Added: if i > slen - plen then None
188 Added: else if String.sub s i plen = prefix then
189 Added: let vstart = i + plen in
190 Added: let rec scan_end j =
191 Added: if j >= slen || s.[j] = ' ' || s.[j] = ':' || s.[j] = '\t' then j
192 Added: else scan_end (j + 1)
193 Added: in
194 Added: let vend = scan_end vstart in
195 Added: Some (String.sub s vstart (vend - vstart))
196 Added: else find_at (i + 1)
197 Added: in
198 Added: find_at 0
199 Added: in
200 Added: let ft =
201 Added: match find_value "ft=" l with
202 Added: | Some _ as r -> r
203 Added: | None -> find_value "filetype=" l
204 Added: in
205 Added: match ft with
206 Added: | None -> None
207 Added: | Some ft -> (
208 Added: match ft with
209 Added: | "sh" | "bash" | "zsh" -> Some "bash"
210 Added: | "python" -> Some "python"
211 Added: | "ruby" -> Some "ruby"
212 Added: | "perl" -> Some "perl"
213 Added: | "javascript" | "js" -> Some "javascript"
214 Added: | "typescript" -> Some "typescript"
215 Added: | "ocaml" -> Some "ocaml"
216 Added: | "c" -> Some "c"
217 Added: | "cpp" -> Some "cpp"
218 Added: | "rust" -> Some "rust"
219 Added: | "go" -> Some "go"
220 Added: | "haskell" -> Some "haskell"
221 Added: | "lua" -> Some "lua"
222 Added: | "make" | "makefile" -> Some "makefile"
223 Added: | "yaml" -> Some "yaml"
224 Added: | "sql" -> Some "sql"
225 Added: | "nix" -> Some "nix"
226 Added: | other -> Some other)
227 Added:
228 Added: let language_of_content content =
229 Added: let lines = String.split_on_char '\n' content in
230 Added: let len = List.length lines in
231 Added: let first_lines =
232 Added: let n = min 5 len in
233 Added: List.filteri (fun i _ -> i < n) lines
234 Added: in
235 Added: let last_lines =
236 Added: let start = max 0 (len - 5) in
237 Added: List.filteri (fun i _ -> i >= start) lines
238 Added: in
239 Added: let try_lines detector lines = List.find_map detector lines in
240 Added: match first_lines with
241 Added: | [] -> None
242 Added: | first :: _ -> (
243 Added: match language_of_shebang first with
244 Added: | Some _ as result -> result
245 Added: | None -> (
246 Added: match try_lines language_of_emacs_prop first_lines with
247 Added: | Some _ as result -> result
248 Added: | None -> (
249 Added: match try_lines language_of_vim_modeline first_lines with
250 Added: | Some _ as result -> result
251 Added: | None -> try_lines language_of_vim_modeline last_lines)))
252 Added:
17 253 let page_title context = context.repo ^ " — " ^ context.description
18 254
19 255 let render_page ?heading context ~active content =
@@ -371,6 +607,21 @@
371 607 ]
372 608
373 609 let file ?(active = Layout.Files) context trail (blob : Resolvers.Blob.t) =
610 Added: let language =
611 Added: let from_filename =
612 Added: match List.rev trail with
613 Added: | (name, _) :: _ -> language_of_filename name
614 Added: | [] -> None
615 Added: in
616 Added: match from_filename with
617 Added: | Some _ -> from_filename
618 Added: | None -> language_of_content blob.content
619 Added: in
620 Added: let blob_attrs =
621 Added: match language with
622 Added: | Some lang -> [ HTML.id "blob"; HTML.class_ "language-%s" lang ]
623 Added: | None -> [ HTML.id "blob" ]
624 Added: in
374 625 let to_numbered_line number line =
375 626 let line_number = number + 1 in
376 627 HTML.
@@ -394,7 +645,7 @@
394 645 HTML.
395 646 [
396 647 toolbar [ breadcrumb_pill context.repo trail ];
397 Removed: div [ id "blob" ] formatted_blob;
648 Added: div blob_attrs formatted_blob;
398 649 ]
399 650
400 651 let commit context (commit : Resolvers.Commit.t) diff =