[OCaml] Mobile-friendly clone of cgit.
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.
Changed files
lib/views/layout.ml
@@ -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
@@ -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 =