[OCaml] Mobile-friendly clone of cgit.
feat render README files as documentation
Render Markdown and Org README files semantically instead of as line-numbered source listings: - Headings use h1–h6 with clickable local fragment links - Prose uses a sans-serif documentation layout - Markdown and Org lists, paragraphs, and source blocks render as documents - Org TITLE, AUTHOR, DATE, EMAIL, and LANGUAGE directives become metadata - README blobs render as documents while other blobs remain code listings - Repository README lookup now preserves the filename to choose Org vs Markdown - Extract README styles into static/readme.css Add renderer tests for Markdown, Org metadata/source blocks, fragment links, and README filename detection.
Changed files
- .kiro/steering/web-design.md
- lib/handlers.ml
- lib/highlight.ml
- lib/highlight_grammars.ml
- lib/readme.ml
- lib/resolvers.ml
- lib/resolvers.mli
- lib/routes.ml
- lib/static/readme.css
- lib/static/styles.css
- lib/syntax.ml
- lib/views/components.ml
- lib/views/layout.ml
- lib/views/repo.ml
- lib/views/ui.ml
- lib/views/ui.mli
- test/test_ogit.ml
- test/test_readme.ml
.kiro/steering/web-design.md
@@ -42,6 +42,14 @@
42
42
- The colour theme lives in `lib/static/syntax-theme.css` (currently Gruvbox Dark Hard Contrast) and is decoupled from structural styles.
43
43
- CSS selectors use `[class*="-keyword"]` attribute matching for language-agnostic theming.
44
44
45
Added:
## README Documentation
46
Added:
47
Added:
- README files in Markdown and Org mode SHALL render as semantic documentation, not as line-numbered source listings.
48
Added:
- README headings SHALL be native `h1`–`h6` elements with stable fragment IDs; the heading text links to its own `#fragment` URL.
49
Added:
- README prose SHALL use the sans-serif documentation style in `lib/static/readme.css`; source-block syntax colours remain in `syntax-theme.css`.
50
Added:
- Org `#+TITLE`, `#+AUTHOR`, `#+DATE`, and related metadata SHALL render as styled document metadata rather than raw directives.
51
Added:
- Other README formats currently use the Markdown-compatible fallback. Add parser support deliberately before claiming native support for another format.
52
Added:
45
53
## Pages
46
54
47
55
- The repository navigation SHALL expose only: Summary, Commits, Files.
lib/handlers.ml
@@ -261,7 +261,6 @@
261
261
blob.content)
262
262
| `Tree _ ->
263
263
error_response (Resolvers.Bad_request "object is a tree, not a blob")
264
Removed:
265
264
end
266
265
267
266
let project_dir config subdir =
lib/highlight.ml
@@ -1,19 +1,19 @@
1
1
(** Server-side syntax highlighting engine.
2
2
3
3
Tokenizes source code using TextMate grammars (via hilite) and produces
4
Removed:
{!Dream_html.node} spans ready for embedding in the page. Falls back
4
Added:
{!Dream_html.node} spans ready for embedding in the page. Falls back
5
5
gracefully to plain text when no grammar is available for the requested
6
6
language. *)
7
7
8
8
open Dream_html
9
9
10
Removed:
(** A single highlighted line: a list of HTML nodes (spans with classes). *)
11
10
type line = node list
11
Added:
(** A single highlighted line: a list of HTML nodes (spans with classes). *)
12
12
13
13
(** Highlight source code for the given language.
14
14
15
15
Returns a list of lines, each line being a list of [<span>] nodes with
16
Removed:
appropriate CSS classes. If [lang] is [None] or the language is not
16
Added:
appropriate CSS classes. If [lang] is [None] or the language is not
17
17
supported, returns plain-text lines (no spans, just escaped text).
18
18
19
19
The CSS classes follow hilite's convention:
@@ -33,8 +33,8 @@
33
33
| Some scope_name -> (
34
34
let tm = Lazy.force Highlight_grammars.registry in
35
35
match
36
Removed:
Hilite.src_code_to_pairs ~escape:true ~lookup_method:`Scope_name
37
Removed:
~tm ~lang:scope_name source
36
Added:
Hilite.src_code_to_pairs ~escape:true ~lookup_method:`Scope_name ~tm
37
Added:
~lang:scope_name source
38
38
with
39
39
| Error _ -> plain_lines ()
40
40
| Ok pairs ->
lib/highlight_grammars.ml
@@ -1,7 +1,7 @@
1
1
(** Registry of bundled TextMate grammars for syntax highlighting.
2
2
3
3
Loads grammars embedded at build time via [ocaml-crunch] and registers them
4
Removed:
with a shared {!TmLanguage.t} instance. Language lookup is by the canonical
4
Added:
with a shared {!TmLanguage.t} instance. Language lookup is by the canonical
5
5
name string produced by {!Syntax.detect}. *)
6
6
7
7
(** The shared grammar registry, lazily initialised. *)
@@ -17,8 +17,9 @@
17
17
let json = Yojson.Basic.from_string data in
18
18
let grammar = TmLanguage.of_yojson_exn json in
19
19
TmLanguage.add_grammar t grammar
20
Removed:
with _ -> (* Silently skip malformed grammars *)
21
Removed:
())
20
Added:
with _ ->
21
Added:
(* Silently skip malformed grammars *)
22
Added:
())
22
23
in
23
24
(* Register every grammar we ship. *)
24
25
List.iter
@@ -77,7 +78,7 @@
77
78
t)
78
79
79
80
(** Map from the canonical language name (as returned by {!Syntax.detect}) to
80
Removed:
the scopeName used by the grammar file. This bridges the naming gap.
81
Added:
the scopeName used by the grammar file. This bridges the naming gap.
81
82
82
83
Only languages for which a grammar JSON is actually bundled appear here.
83
84
Languages detected by {!Syntax} but without a shipped grammar (e.g. elixir,
lib/readme.ml
@@ -0,0 +1,295 @@
1
Added:
(** Semantic rendering for README documents.
2
Added:
3
Added:
README files are prose, not source listings: Markdown and Org are rendered
4
Added:
as headings, paragraphs, lists, metadata, and unnumbered code blocks. The
5
Added:
renderer deliberately keeps its supported surface compact and safe; every
6
Added:
source fragment is emitted through {!Ui}, so repository content is escaped.
7
Added:
*)
8
Added:
9
Added:
type format = Markdown | Org
10
Added:
11
Added:
type block =
12
Added:
| Heading of int * string
13
Added:
| Paragraph of string
14
Added:
| List of string list
15
Added:
| Code of string option * string
16
Added:
17
Added:
let first_word text =
18
Added:
match
19
Added:
String.split_on_char ' ' text |> List.filter (fun word -> word <> "")
20
Added:
with
21
Added:
| word :: _ -> Some word
22
Added:
| [] -> None
23
Added:
24
Added:
let is_readme_filename filename =
25
Added:
Filename.basename filename |> String.lowercase_ascii
26
Added:
|> String.starts_with ~prefix:"readme"
27
Added:
28
Added:
let format_of_filename filename =
29
Added:
match Filename.extension filename |> String.lowercase_ascii with
30
Added:
| ".org" -> Org
31
Added:
| _ -> Markdown
32
Added:
33
Added:
let trim_end_hashes text =
34
Added:
let text = String.trim text in
35
Added:
let rec last_non_hash index =
36
Added:
if index < 0 || text.[index] <> '#' then index else last_non_hash (index - 1)
37
Added:
in
38
Added:
let last = last_non_hash (String.length text - 1) in
39
Added:
String.sub text 0 (last + 1) |> String.trim
40
Added:
41
Added:
let markdown_heading line =
42
Added:
let length = String.length line in
43
Added:
let rec count_hashes index =
44
Added:
if index < length && line.[index] = '#' then count_hashes (index + 1)
45
Added:
else index
46
Added:
in
47
Added:
let level = count_hashes 0 in
48
Added:
if level = 0 || level > 6 || level >= length || line.[level] <> ' ' then None
49
Added:
else
50
Added:
Some
51
Added:
( level,
52
Added:
String.sub line (level + 1) (length - level - 1) |> trim_end_hashes )
53
Added:
54
Added:
let org_heading line =
55
Added:
let length = String.length line in
56
Added:
let rec count_stars index =
57
Added:
if index < length && line.[index] = '*' then count_stars (index + 1)
58
Added:
else index
59
Added:
in
60
Added:
let level = count_stars 0 in
61
Added:
if level = 0 || level >= length || line.[level] <> ' ' then None
62
Added:
else
63
Added:
Some
64
Added:
( min 6 level,
65
Added:
String.sub line (level + 1) (length - level - 1) |> String.trim )
66
Added:
67
Added:
let heading_of_line format =
68
Added:
match format with Markdown -> markdown_heading | Org -> org_heading
69
Added:
70
Added:
let list_item line =
71
Added:
let length = String.length line in
72
Added:
if length >= 2 && List.mem line.[0] [ '-'; '+'; '*' ] && line.[1] = ' ' then
73
Added:
Some (String.sub line 2 (length - 2) |> String.trim)
74
Added:
else None
75
Added:
76
Added:
let markdown_fence line =
77
Added:
let line = String.trim line in
78
Added:
if String.length line < 3 then None
79
Added:
else
80
Added:
let marker = String.sub line 0 3 in
81
Added:
if marker <> "```" && marker <> "~~~" then None
82
Added:
else
83
Added:
let language =
84
Added:
String.sub line 3 (String.length line - 3) |> String.trim |> first_word
85
Added:
in
86
Added:
Some (marker, language)
87
Added:
88
Added:
let org_src_begin line =
89
Added:
let prefix = "#+begin_src" in
90
Added:
let lower = String.lowercase_ascii (String.trim line) in
91
Added:
if not (String.starts_with ~prefix lower) then None
92
Added:
else
93
Added:
let language =
94
Added:
String.sub lower (String.length prefix)
95
Added:
(String.length lower - String.length prefix)
96
Added:
|> String.trim |> first_word
97
Added:
in
98
Added:
Some language
99
Added:
100
Added:
let is_org_src_end line =
101
Added:
String.trim line |> String.lowercase_ascii
102
Added:
|> String.starts_with ~prefix:"#+end_src"
103
Added:
104
Added:
let rec take_until close collected = function
105
Added:
| [] -> (List.rev collected, [])
106
Added:
| line :: rest when close line -> (List.rev collected, rest)
107
Added:
| line :: rest -> take_until close (line :: collected) rest
108
Added:
109
Added:
let is_code_opener format line =
110
Added:
match format with
111
Added:
| Markdown -> Option.is_some (markdown_fence line)
112
Added:
| Org -> Option.is_some (org_src_begin line)
113
Added:
114
Added:
let is_boundary format line =
115
Added:
String.trim line = ""
116
Added:
|| Option.is_some (heading_of_line format line)
117
Added:
|| Option.is_some (list_item line)
118
Added:
|| is_code_opener format line
119
Added:
120
Added:
let parse_blocks format lines =
121
Added:
let rec take_list collected = function
122
Added:
| line :: rest -> (
123
Added:
match list_item line with
124
Added:
| Some item -> take_list (item :: collected) rest
125
Added:
| None -> (List.rev collected, line :: rest))
126
Added:
| [] -> (List.rev collected, [])
127
Added:
in
128
Added:
let rec take_paragraph collected = function
129
Added:
| line :: _ as rest when is_boundary format line ->
130
Added:
(List.rev collected, rest)
131
Added:
| line :: rest -> take_paragraph (String.trim line :: collected) rest
132
Added:
| [] -> (List.rev collected, [])
133
Added:
in
134
Added:
let rec loop blocks = function
135
Added:
| [] -> List.rev blocks
136
Added:
| line :: rest when String.trim line = "" -> loop blocks rest
137
Added:
| line :: rest -> (
138
Added:
match heading_of_line format line with
139
Added:
| Some (level, text) -> loop (Heading (level, text) :: blocks) rest
140
Added:
| None -> (
141
Added:
match format with
142
Added:
| Markdown -> (
143
Added:
match markdown_fence line with
144
Added:
| Some (marker, language) ->
145
Added:
let lines, rest =
146
Added:
take_until
147
Added:
(fun candidate ->
148
Added:
String.starts_with ~prefix:marker
149
Added:
(String.trim candidate))
150
Added:
[] rest
151
Added:
in
152
Added:
loop
153
Added:
(Code (language, String.concat "\n" lines) :: blocks)
154
Added:
rest
155
Added:
| None -> (
156
Added:
match list_item line with
157
Added:
| Some _ ->
158
Added:
let items, rest = take_list [] (line :: rest) in
159
Added:
loop (List items :: blocks) rest
160
Added:
| None ->
161
Added:
let paragraph, rest =
162
Added:
take_paragraph [] (line :: rest)
163
Added:
in
164
Added:
loop
165
Added:
(Paragraph (String.concat " " paragraph) :: blocks)
166
Added:
rest))
167
Added:
| Org -> (
168
Added:
match org_src_begin line with
169
Added:
| Some language ->
170
Added:
let lines, rest = take_until is_org_src_end [] rest in
171
Added:
loop
172
Added:
(Code (language, String.concat "\n" lines) :: blocks)
173
Added:
rest
174
Added:
| None -> (
175
Added:
match list_item line with
176
Added:
| Some _ ->
177
Added:
let items, rest = take_list [] (line :: rest) in
178
Added:
loop (List items :: blocks) rest
179
Added:
| None ->
180
Added:
let paragraph, rest =
181
Added:
take_paragraph [] (line :: rest)
182
Added:
in
183
Added:
loop
184
Added:
(Paragraph (String.concat " " paragraph) :: blocks)
185
Added:
rest))))
186
Added:
in
187
Added:
loop [] lines
188
Added:
189
Added:
let org_metadata_line line =
190
Added:
let prefix = "#+" in
191
Added:
let line = String.trim line in
192
Added:
if not (String.starts_with ~prefix line) then None
193
Added:
else
194
Added:
match String.index_opt line ':' with
195
Added:
| None -> None
196
Added:
| Some colon ->
197
Added:
let key = String.sub line 2 (colon - 2) |> String.lowercase_ascii in
198
Added:
let value =
199
Added:
String.sub line (colon + 1) (String.length line - colon - 1)
200
Added:
|> String.trim
201
Added:
in
202
Added:
if List.mem key [ "title"; "author"; "date"; "email"; "language" ] then
203
Added:
Some (key, value)
204
Added:
else None
205
Added:
206
Added:
let split_org_metadata lines =
207
Added:
List.fold_left
208
Added:
(fun (metadata, body) line ->
209
Added:
match org_metadata_line line with
210
Added:
| None -> (metadata, line :: body)
211
Added:
| Some entry -> (entry :: metadata, body))
212
Added:
([], []) lines
213
Added:
|> fun (metadata, body) -> (List.rev metadata, List.rev body)
214
Added:
215
Added:
let new_anchor () =
216
Added:
let seen = Hashtbl.create 16 in
217
Added:
fun text ->
218
Added:
let base =
219
Added:
let buffer = Buffer.create (String.length text) in
220
Added:
let pending_separator = ref false in
221
Added:
String.iter
222
Added:
(fun character ->
223
Added:
if
224
Added:
(character >= 'a' && character <= 'z')
225
Added:
|| (character >= 'A' && character <= 'Z')
226
Added:
|| (character >= '0' && character <= '9')
227
Added:
then (
228
Added:
if !pending_separator && Buffer.length buffer > 0 then
229
Added:
Buffer.add_char buffer '-';
230
Added:
pending_separator := false;
231
Added:
Buffer.add_char buffer (Char.lowercase_ascii character))
232
Added:
else pending_separator := true)
233
Added:
text;
234
Added:
if Buffer.length buffer = 0 then "section" else Buffer.contents buffer
235
Added:
in
236
Added:
let count = Option.value (Hashtbl.find_opt seen base) ~default:0 + 1 in
237
Added:
Hashtbl.replace seen base count;
238
Added:
if count = 1 then base else Printf.sprintf "%s-%d" base count
239
Added:
240
Added:
let heading anchor level text =
241
Added:
let id = anchor text in
242
Added:
Ui.heading ~id ~level ~class_:"readme-heading"
243
Added:
[ Ui.text_link ~class_:"readme-heading-link" ~href:("#" ^ id) text ]
244
Added:
245
Added:
let code_block language source =
246
Added:
let nodes =
247
Added:
match language with
248
Added:
| None | Some "" -> [ Ui.text source ]
249
Added:
| Some language ->
250
Added:
Highlight.highlight ~lang:(Some language) source |> List.concat
251
Added:
in
252
Added:
Ui.code_block ~class_:"readme-code-block" nodes
253
Added:
254
Added:
let render_block anchor = function
255
Added:
| Heading (level, text) -> heading anchor level text
256
Added:
| Paragraph text -> Ui.paragraph ~class_:"readme-paragraph" [ Ui.text text ]
257
Added:
| List items ->
258
Added:
Ui.items ~class_:"readme-list"
259
Added:
(List.map (fun item -> Ui.item [ Ui.text item ]) items)
260
Added:
| Code (language, source) -> code_block language source
261
Added:
262
Added:
let render ~filename content =
263
Added:
let format = format_of_filename filename in
264
Added:
let lines = String.split_on_char '\n' content in
265
Added:
let metadata, lines =
266
Added:
match format with
267
Added:
| Markdown -> ([], lines)
268
Added:
| Org -> split_org_metadata lines
269
Added:
in
270
Added:
let anchor = new_anchor () in
271
Added:
let title, metadata =
272
Added:
match List.find_opt (fun (key, _) -> key = "title") metadata with
273
Added:
| None -> (None, metadata)
274
Added:
| Some (_, title) ->
275
Added:
( Some (heading anchor 1 title),
276
Added:
List.filter (fun (key, _) -> key <> "title") metadata )
277
Added:
in
278
Added:
let metadata =
279
Added:
match metadata with
280
Added:
| [] -> Ui.nothing
281
Added:
| metadata ->
282
Added:
Ui.definitions ~class_:"readme-org-metadata"
283
Added:
(List.map
284
Added:
(fun (key, value) ->
285
Added:
(String.capitalize_ascii key, [ Ui.text value ]))
286
Added:
metadata)
287
Added:
in
288
Added:
let class_ =
289
Added:
match format with
290
Added:
| Markdown -> "readme-document readme-markdown"
291
Added:
| Org -> "readme-document readme-org"
292
Added:
in
293
Added:
Ui.region ~class_
294
Added:
(Option.to_list title @ [ metadata ]
295
Added:
@ List.map (render_block anchor) (parse_blocks format lines))
lib/resolvers.ml
@@ -662,17 +662,27 @@
662
662
| Git.Value.Blob blob -> Lwt_result.return (`Blob (Blob.to_t blob))
663
663
| _ -> Lwt_result.fail (Not_found ("no tree or blob matches id " ^ id))
664
664
665
Added:
module Readme = struct
666
Added:
type t = { name : string; content : string }
667
Added:
end
668
Added:
665
669
module Repo = struct
666
670
let readme repository =
667
671
let* tree = Tree.head repository in
668
672
match List.find_opt Entry.is_readme tree.entries with
669
673
| None -> Lwt_result.return None
670
Removed:
| Some readme -> (
671
Removed:
let* hash = hash_of_hex readme.hash in
674
Added:
| Some entry -> (
675
Added:
let* hash = hash_of_hex entry.hash in
672
676
Lwt_result.bind (read_value repository hash) @@ function
673
Removed:
| Git.Value.Blob blob -> Lwt_result.return (Some (Blob.to_t blob))
674
Removed:
| _ -> Lwt_result.fail (Internal ("could not read file " ^ readme.name))
675
Removed:
)
677
Added:
| Git.Value.Blob blob ->
678
Added:
Lwt_result.return
679
Added:
(Some
680
Added:
Readme.
681
Added:
{
682
Added:
name = entry.name;
683
Added:
content = Store.Value.Blob.to_string blob;
684
Added:
})
685
Added:
| _ -> Lwt_result.fail (Internal ("could not read file " ^ entry.name)))
676
686
end
677
687
678
688
let read_root_readme config =
lib/resolvers.mli
@@ -131,8 +131,12 @@
131
131
132
132
(** {1 Repository helpers} *)
133
133
134
Added:
module Readme : sig
135
Added:
type t = { name : string; content : string }
136
Added:
end
137
Added:
134
138
module Repo : sig
135
Removed:
val readme : repository -> (Blob.t option, error) Lwt_result.t
139
Added:
val readme : repository -> (Readme.t option, error) Lwt_result.t
136
140
end
137
141
138
142
(** {1 Root helpers} *)
lib/routes.ml
@@ -43,15 +43,7 @@
43
43
| File_detail of string
44
44
| Raw of string
45
45
46
Removed:
let known_actions =
47
Removed:
[
48
Removed:
"summary";
49
Removed:
"commits";
50
Removed:
"commit";
51
Removed:
"files";
52
Removed:
"file";
53
Removed:
"raw";
54
Removed:
]
46
Added:
let known_actions = [ "summary"; "commits"; "commit"; "files"; "file"; "raw" ]
55
47
56
48
let dispatch path =
57
49
(* path is the full URL path without leading slash, e.g. "sub/repo/commits/" *)
lib/static/readme.css
@@ -0,0 +1,127 @@
1
Added:
/* ==========================================================================
2
Added:
README documentation
3
Added:
4
Added:
Semantic Markdown and Org README presentation. Source-code colours remain in
5
Added:
syntax-theme.css; this file owns prose layout and Org metadata styling.
6
Added:
========================================================================== */
7
Added:
8
Added:
.readme-document {
9
Added:
max-width: 78ch;
10
Added:
margin: 1.5rem auto;
11
Added:
color: var(--color-text);
12
Added:
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
Added:
font-size: 1rem;
14
Added:
line-height: 1.65;
15
Added:
}
16
Added:
17
Added:
.readme-document .readme-heading {
18
Added:
margin: 2.1rem 0 0.75rem;
19
Added:
color: var(--color-text);
20
Added:
font-family: inherit;
21
Added:
line-height: 1.25;
22
Added:
scroll-margin-top: calc(var(--nav-height) + var(--toolbar-height) + 1rem);
23
Added:
}
24
Added:
25
Added:
.readme-document > .readme-heading:first-child {
26
Added:
margin-top: 0;
27
Added:
}
28
Added:
29
Added:
.readme-document h1.readme-heading {
30
Added:
font-size: 2rem;
31
Added:
border-bottom: 1px solid var(--color-border-subtle);
32
Added:
padding-bottom: 0.4rem;
33
Added:
}
34
Added:
35
Added:
.readme-document h2.readme-heading {
36
Added:
font-size: 1.55rem;
37
Added:
}
38
Added:
39
Added:
.readme-document h3.readme-heading {
40
Added:
font-size: 1.25rem;
41
Added:
}
42
Added:
43
Added:
.readme-document h4.readme-heading,
44
Added:
.readme-document h5.readme-heading,
45
Added:
.readme-document h6.readme-heading {
46
Added:
font-size: 1rem;
47
Added:
}
48
Added:
49
Added:
.readme-heading-link {
50
Added:
color: inherit;
51
Added:
text-decoration: none;
52
Added:
}
53
Added:
54
Added:
.readme-heading-link:hover,
55
Added:
.readme-heading-link:focus-visible {
56
Added:
color: var(--color-link);
57
Added:
text-decoration: underline;
58
Added:
text-underline-offset: 0.15em;
59
Added:
}
60
Added:
61
Added:
.readme-paragraph {
62
Added:
margin: 0.75rem 0;
63
Added:
}
64
Added:
65
Added:
.readme-list {
66
Added:
margin: 0.75rem 0;
67
Added:
padding-left: 1.5rem;
68
Added:
}
69
Added:
70
Added:
.readme-list li + li {
71
Added:
margin-top: 0.3rem;
72
Added:
}
73
Added:
74
Added:
.readme-code-block {
75
Added:
margin: 1rem 0;
76
Added:
padding: 0.9rem 1rem;
77
Added:
overflow-x: auto;
78
Added:
border: 1px solid var(--color-border-subtle);
79
Added:
border-radius: 0.25rem;
80
Added:
background-color: #1d2021;
81
Added:
color: #ebdbb2;
82
Added:
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
83
Added:
font-size: 0.9rem;
84
Added:
line-height: 1.5;
85
Added:
tab-size: 4;
86
Added:
}
87
Added:
88
Added:
.readme-code-block code {
89
Added:
font-family: inherit;
90
Added:
}
91
Added:
92
Added:
/* Org mode metadata directives: #+TITLE, #+AUTHOR, #+DATE, and friends. */
93
Added:
.readme-org-metadata {
94
Added:
display: grid;
95
Added:
grid-template-columns: max-content 1fr;
96
Added:
gap: 0.25rem 0.75rem;
97
Added:
margin: 0 0 1.5rem;
98
Added:
padding: 0.75rem 1rem;
99
Added:
border-left: 3px solid #83a598;
100
Added:
background-color: #282828;
101
Added:
color: var(--color-muted);
102
Added:
font-size: 0.92rem;
103
Added:
}
104
Added:
105
Added:
.readme-org-metadata dt {
106
Added:
color: #fabd2f;
107
Added:
font-weight: 600;
108
Added:
}
109
Added:
110
Added:
.readme-org-metadata dd {
111
Added:
margin: 0;
112
Added:
}
113
Added:
114
Added:
@media (max-width: 480px) {
115
Added:
.readme-document {
116
Added:
margin: 1rem 0;
117
Added:
font-size: 0.98rem;
118
Added:
}
119
Added:
120
Added:
.readme-document h1.readme-heading {
121
Added:
font-size: 1.65rem;
122
Added:
}
123
Added:
124
Added:
.readme-document h2.readme-heading {
125
Added:
font-size: 1.35rem;
126
Added:
}
127
Added:
}
lib/static/styles.css
@@ -950,28 +950,6 @@
950
950
text-decoration: underline;
951
951
}
952
952
953
Removed:
/* Inline README display at bottom of directory listings */
954
Removed:
955
Removed:
.readme-inline {
956
Removed:
margin-top: 1.5em;
957
Removed:
border: 1px solid var(--color-border-subtle);
958
Removed:
border-radius: 0.25rem;
959
Removed:
overflow-x: auto;
960
Removed:
}
961
Removed:
962
Removed:
.readme-inline h3 {
963
Removed:
margin: 0;
964
Removed:
padding: 0.6em 1em;
965
Removed:
background-color: #242424;
966
Removed:
font-size: 0.9em;
967
Removed:
color: var(--color-muted);
968
Removed:
border-bottom: 1px solid var(--color-border-subtle);
969
Removed:
}
970
Removed:
971
Removed:
.readme-inline .blob {
972
Removed:
padding: 0.5em 0;
973
Removed:
}
974
Removed:
975
953
.diff-file {
976
954
margin: 1.5em 0;
977
955
border: 1px solid var(--color-border-subtle);
lib/syntax.ml
@@ -16,56 +16,56 @@
16
16
| "dockerfile" -> Some "dockerfile"
17
17
| "dune" | "dune-project" | "dune-workspace" -> Some "dune"
18
18
| "jenkinsfile" -> Some "groovy"
19
Removed:
| _ ->
20
Removed:
match Filename.extension name |> String.lowercase_ascii with
21
Removed:
| ".ml" | ".mli" -> Some "ocaml"
22
Removed:
| ".c" | ".h" -> Some "c"
23
Removed:
| ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure"
24
Removed:
| ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp"
25
Removed:
| ".cs" -> Some "csharp"
26
Removed:
| ".css" -> Some "css"
27
Removed:
| ".dart" -> Some "dart"
28
Removed:
| ".diff" | ".patch" -> Some "diff"
29
Removed:
| ".dune" -> Some "dune"
30
Removed:
| ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp"
31
Removed:
| ".erl" | ".hrl" -> Some "erlang"
32
Removed:
| ".ex" | ".exs" -> Some "elixir"
33
Removed:
| ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran"
34
Removed:
| ".go" -> Some "go"
35
Removed:
| ".gradle" | ".groovy" -> Some "groovy"
36
Removed:
| ".hs" -> Some "haskell"
37
Removed:
| ".html" | ".htm" -> Some "html"
38
Removed:
| ".java" -> Some "java"
39
Removed:
| ".jl" -> Some "julia"
40
Removed:
| ".js" | ".mjs" | ".cjs" -> Some "javascript"
41
Removed:
| ".json" -> Some "json"
42
Removed:
| ".kt" | ".kts" -> Some "kotlin"
43
Removed:
| ".lua" -> Some "lua"
44
Removed:
| ".m" -> Some "objective-c"
45
Removed:
| ".md" -> Some "markdown"
46
Removed:
| ".nix" -> Some "nix"
47
Removed:
| ".opam" -> Some "opam"
48
Removed:
| ".org" -> Some "org"
49
Removed:
| ".php" -> Some "php"
50
Removed:
| ".pl" | ".pm" | ".t" -> Some "perl"
51
Removed:
| ".proto" -> Some "protobuf"
52
Removed:
| ".ps1" | ".psm1" | ".psd1" -> Some "powershell"
53
Removed:
| ".py" -> Some "python"
54
Removed:
| ".r" -> Some "r"
55
Removed:
| ".rb" -> Some "ruby"
56
Removed:
| ".rs" -> Some "rust"
57
Removed:
| ".scala" | ".sc" -> Some "scala"
58
Removed:
| ".sh" | ".bash" | ".zsh" -> Some "bash"
59
Removed:
| ".sql" -> Some "sql"
60
Removed:
| ".swift" -> Some "swift"
61
Removed:
| ".tf" | ".hcl" -> Some "terraform"
62
Removed:
| ".toml" -> Some "toml"
63
Removed:
| ".ts" | ".tsx" -> Some "typescript"
64
Removed:
| ".vue" -> Some "vue"
65
Removed:
| ".xml" | ".svg" | ".xsl" -> Some "xml"
66
Removed:
| ".yaml" | ".yml" -> Some "yaml"
67
Removed:
| ".zig" -> Some "zig"
68
Removed:
| _ -> None
19
Added:
| _ -> (
20
Added:
match Filename.extension name |> String.lowercase_ascii with
21
Added:
| ".ml" | ".mli" -> Some "ocaml"
22
Added:
| ".c" | ".h" -> Some "c"
23
Added:
| ".clj" | ".cljs" | ".cljc" | ".edn" -> Some "clojure"
24
Added:
| ".cpp" | ".cc" | ".cxx" | ".hpp" -> Some "cpp"
25
Added:
| ".cs" -> Some "csharp"
26
Added:
| ".css" -> Some "css"
27
Added:
| ".dart" -> Some "dart"
28
Added:
| ".diff" | ".patch" -> Some "diff"
29
Added:
| ".dune" -> Some "dune"
30
Added:
| ".el" | ".lisp" | ".cl" | ".scm" | ".ss" -> Some "lisp"
31
Added:
| ".erl" | ".hrl" -> Some "erlang"
32
Added:
| ".ex" | ".exs" -> Some "elixir"
33
Added:
| ".f90" | ".f95" | ".f03" | ".f08" | ".f" | ".for" -> Some "fortran"
34
Added:
| ".go" -> Some "go"
35
Added:
| ".gradle" | ".groovy" -> Some "groovy"
36
Added:
| ".hs" -> Some "haskell"
37
Added:
| ".html" | ".htm" -> Some "html"
38
Added:
| ".java" -> Some "java"
39
Added:
| ".jl" -> Some "julia"
40
Added:
| ".js" | ".mjs" | ".cjs" -> Some "javascript"
41
Added:
| ".json" -> Some "json"
42
Added:
| ".kt" | ".kts" -> Some "kotlin"
43
Added:
| ".lua" -> Some "lua"
44
Added:
| ".m" -> Some "objective-c"
45
Added:
| ".md" -> Some "markdown"
46
Added:
| ".nix" -> Some "nix"
47
Added:
| ".opam" -> Some "opam"
48
Added:
| ".org" -> Some "org"
49
Added:
| ".php" -> Some "php"
50
Added:
| ".pl" | ".pm" | ".t" -> Some "perl"
51
Added:
| ".proto" -> Some "protobuf"
52
Added:
| ".ps1" | ".psm1" | ".psd1" -> Some "powershell"
53
Added:
| ".py" -> Some "python"
54
Added:
| ".r" -> Some "r"
55
Added:
| ".rb" -> Some "ruby"
56
Added:
| ".rs" -> Some "rust"
57
Added:
| ".scala" | ".sc" -> Some "scala"
58
Added:
| ".sh" | ".bash" | ".zsh" -> Some "bash"
59
Added:
| ".sql" -> Some "sql"
60
Added:
| ".swift" -> Some "swift"
61
Added:
| ".tf" | ".hcl" -> Some "terraform"
62
Added:
| ".toml" -> Some "toml"
63
Added:
| ".ts" | ".tsx" -> Some "typescript"
64
Added:
| ".vue" -> Some "vue"
65
Added:
| ".xml" | ".svg" | ".xsl" -> Some "xml"
66
Added:
| ".yaml" | ".yml" -> Some "yaml"
67
Added:
| ".zig" -> Some "zig"
68
Added:
| _ -> None)
69
69
70
70
let of_shebang line =
71
71
if not (String.starts_with ~prefix:"#!" line) then None
lib/views/components.ml
@@ -111,8 +111,7 @@
111
111
Ui.css_toggle ~id:"nav-toggle" ~toggle_class:"nav-toggle"
112
112
~control_class:"nav-hamburger" ~label:"Menu" ~glyph:"\xe2\x8b\xae" ();
113
113
Ui.nav_links ~id:"nav-links"
114
Removed:
(List.map (page_link repo ~active)
115
Removed:
[ Summary; Commits; Files ]);
114
Added:
(List.map (page_link repo ~active) [ Summary; Commits; Files ]);
116
115
]
117
116
118
117
(** The same repository destinations as {!repo_nav}, condensed and pinned to the
@@ -162,22 +161,9 @@
162
161
let commit_type_badge ?href commit_type =
163
162
Ui.badge ~base_class:"commit-pill" ~variant:commit_type ?href commit_type
164
163
165
Removed:
(** A README rendered below a listing. Level 3 because it sits under the page
166
Removed:
heading and the listing it accompanies.
164
Added:
(** A README displayed as semantic documentation.
167
165
168
Removed:
When [filename] is given, language detection uses it to select the
169
Removed:
appropriate grammar (e.g. ["README.md"] → markdown). When absent, the
170
Removed:
content is highlighted as markdown by default since that covers the common
171
Removed:
case. *)
172
Removed:
let inline_readme ?filename content =
173
Removed:
let language =
174
Removed:
match filename with
175
Removed:
| Some name -> Syntax.detect ~filename:(Some name) content
176
Removed:
| None -> Some "markdown"
177
Removed:
in
178
Removed:
let lines = Highlight.highlight ~lang:language content in
179
Removed:
Ui.region ~class_:"readme-inline"
180
Removed:
[
181
Removed:
Ui.heading ~level:3 [ Ui.text "README" ];
182
Removed:
Ui.highlighted_code_listing ~class_:"blob" ~anchor_prefix:"readme-" lines;
183
Removed:
]
166
Added:
Markdown and Org mode receive dedicated rendering; other README filenames
167
Added:
use the Markdown-compatible fallback until additional formats are added. *)
168
Added:
let inline_readme ?(filename = "README.md") content =
169
Added:
Readme.render ~filename content
lib/views/layout.ml
@@ -9,10 +9,7 @@
9
9
be built without depending on this module. They are re-exported here because
10
10
callers name them [Layout.Summary], [Layout.site], and so on. *)
11
11
12
Removed:
type page = Components.page =
13
Removed:
| Summary
14
Removed:
| Commits
15
Removed:
| Files
12
Added:
type page = Components.page = Summary | Commits | Files
16
13
17
14
type site = Components.site = {
18
15
user_name : string;
@@ -33,10 +30,7 @@
33
30
}
34
31
35
32
let stylesheets =
36
Removed:
[
37
Removed:
"/static/styles.css";
38
Removed:
"/static/syntax-theme.css";
39
Removed:
]
33
Added:
[ "/static/styles.css"; "/static/syntax-theme.css"; "/static/readme.css" ]
40
34
41
35
let head page_title =
42
36
Ui.document_head ~title:page_title
lib/views/repo.ml
@@ -192,7 +192,8 @@
192
192
let readme_panel =
193
193
match readme with
194
194
| None -> Ui.nothing
195
Removed:
| Some (blob : Resolvers.Blob.t) -> Components.inline_readme blob.content
195
Added:
| Some (readme : Resolvers.Readme.t) ->
196
Added:
Components.inline_readme ~filename:readme.name readme.content
196
197
in
197
198
render_page context ~active:Summary
198
199
~toolbar:
@@ -279,15 +280,12 @@
279
280
let files context trail (entries : Resolvers.Tree.tree_node list) =
280
281
render_page context ~active:Files
281
282
~toolbar:[ path_trail context.repo trail ]
282
Removed:
[
283
Removed:
Ui.items_of ~id:"file-tree" (tree_row context.repo) entries;
284
Removed:
]
283
Added:
[ Ui.items_of ~id:"file-tree" (tree_row context.repo) entries ]
285
284
286
285
let file context trail (blob : Resolvers.Blob.t) =
287
286
let filename =
288
287
match List.rev trail with (name, _) :: _ -> Some name | [] -> None
289
288
in
290
Removed:
let language = Syntax.detect ~filename blob.content in
291
289
let raw_link =
292
290
match List.rev trail with
293
291
| (_, hash) :: _ ->
@@ -295,13 +293,17 @@
295
293
[ Components.route_link (Raw_file (context.repo, hash)) "View raw" ]
296
294
| [] -> Ui.nothing
297
295
in
296
Added:
let body =
297
Added:
match filename with
298
Added:
| Some filename when Readme.is_readme_filename filename ->
299
Added:
Readme.render ~filename blob.content
300
Added:
| _ ->
301
Added:
let language = Syntax.detect ~filename blob.content in
302
Added:
let lines = Highlight.highlight ~lang:language blob.content in
303
Added:
Ui.highlighted_code_listing ~id:"blob" lines
304
Added:
in
298
305
let toolbar = [ path_trail context.repo trail ] in
299
Removed:
render_page context ~active:Files ~toolbar
300
Removed:
[
301
Removed:
raw_link;
302
Removed:
(let lines = Highlight.highlight ~lang:language blob.content in
303
Removed:
Ui.highlighted_code_listing ~id:"blob" lines);
304
Removed:
]
306
Added:
render_page context ~active:Files ~toolbar [ raw_link; body ]
305
307
306
308
let commit context (commit : Resolvers.Commit.t) diff =
307
309
let message = parse_commit_message commit.message in
@@ -386,4 +388,3 @@
386
388
@ [ metadata ]
387
389
@ Ui.Diff.view ~empty_message:"No file changes in this commit."
388
390
(List.map diff_file diff))
389
Removed:
lib/views/ui.ml
@@ -70,7 +70,7 @@
70
70
let paragraph ?class_ children = HTML.p (opt_class class_) children
71
71
let paragraph_text ?class_ value = paragraph ?class_ [ text value ]
72
72
73
Removed:
let heading ?(level = 1) ?class_ children =
73
Added:
let heading ?id ?(level = 1) ?class_ children =
74
74
let element =
75
75
match level with
76
76
| 1 -> HTML.h1
@@ -80,7 +80,10 @@
80
80
| 5 -> HTML.h5
81
81
| _ -> HTML.h6
82
82
in
83
Removed:
element (opt_class class_) children
83
Added:
element (opt_id id @ opt_class class_) children
84
Added:
85
Added:
let code_block ?class_ children =
86
Added:
HTML.pre (opt_class class_) [ HTML.code [] children ]
84
87
85
88
(* Lists *)
86
89
lib/views/ui.mli
@@ -97,12 +97,17 @@
97
97
val paragraph : ?class_:string -> node list -> node
98
98
val paragraph_text : ?class_:string -> string -> node
99
99
100
Removed:
val heading : ?level:int -> ?class_:string -> node list -> node
100
Added:
val heading : ?id:string -> ?level:int -> ?class_:string -> node list -> node
101
101
(** A heading. [level] follows the document outline: 1 for the page's subject, 2
102
102
and 3 for nested sections. Skipping levels breaks screen-reader navigation,
103
103
so pass the level that matches the structure rather than the one that looks
104
Removed:
right. Levels outside 1–6 clamp to 6. *)
104
Added:
right. Levels outside 1–6 clamp to 6. [id] provides a local fragment target.
105
Added:
*)
105
106
107
Added:
val code_block : ?class_:string -> node list -> node
108
Added:
(** A preformatted code block without line numbers. The children can be escaped
109
Added:
text or server-rendered syntax-highlight spans. *)
110
Added:
106
111
(** {1 Lists} *)
107
112
108
113
val items : ?id:string -> ?class_:string -> node list -> node
@@ -301,17 +306,21 @@
301
306
highlighted. [anchor_prefix] namespaces those anchors, which is required
302
307
when one page shows more than one listing.
303
308
304
Removed:
Content is emitted verbatim as plain text with no syntax colouring.
305
Removed:
For highlighted output, use {!highlighted_code_listing} instead. *)
309
Added:
Content is emitted verbatim as plain text with no syntax colouring. For
310
Added:
highlighted output, use {!highlighted_code_listing} instead. *)
306
311
307
312
val highlighted_code_listing :
308
Removed:
?id:string -> ?class_:string -> ?anchor_prefix:string -> node list list -> node
313
Added:
?id:string ->
314
Added:
?class_:string ->
315
Added:
?anchor_prefix:string ->
316
Added:
node list list ->
317
Added:
node
309
318
(** A line-numbered listing of pre-highlighted source code.
310
319
311
320
Like {!code_listing} but accepts lines already tokenized into styled spans
312
Removed:
(e.g. from {!Highlight.highlight}). Each inner list represents one line's
313
Removed:
worth of nodes; the function adds line numbers and anchors in the same
314
Removed:
grid layout as [code_listing]. *)
321
Added:
(e.g. from {!Highlight.highlight}). Each inner list represents one line's
322
Added:
worth of nodes; the function adds line numbers and anchors in the same grid
323
Added:
layout as [code_listing]. *)
315
324
316
325
(** {1 Diffs} *)
317
326
test/test_ogit.ml
@@ -12,5 +12,6 @@
12
12
Test_dispatch.suite;
13
13
Test_list_ext.suite;
14
14
Test_diff.suite;
15
Added:
Test_readme.suite;
15
16
Test_views.suite;
16
17
]
test/test_readme.ml
@@ -0,0 +1,76 @@
1
Added:
(** README documentation rendering. *)
2
Added:
3
Added:
let contains text fragment =
4
Added:
let text_length = String.length text in
5
Added:
let fragment_length = String.length fragment in
6
Added:
let rec loop index =
7
Added:
if index > text_length - fragment_length then false
8
Added:
else if String.sub text index fragment_length = fragment then true
9
Added:
else loop (index + 1)
10
Added:
in
11
Added:
fragment_length <= text_length && loop 0
12
Added:
13
Added:
let render ~filename content =
14
Added:
Ogit.Readme.render ~filename content |> Dream_html.to_string
15
Added:
16
Added:
let test_markdown_document () =
17
Added:
let html =
18
Added:
render ~filename:"README.md"
19
Added:
"# Project\n\nIntroduction.\n\n## Installation\n\n- Download\n- Run\n"
20
Added:
in
21
Added:
Alcotest.(check bool)
22
Added:
"documentation container" true
23
Added:
(contains html "readme-document readme-markdown");
24
Added:
Alcotest.(check bool) "h1 heading" true (contains html "<h1 id=\"project\"");
25
Added:
Alcotest.(check bool)
26
Added:
"heading fragment link" true
27
Added:
(contains html "<a href=\"#project\" class=\"readme-heading-link\">Project");
28
Added:
Alcotest.(check bool)
29
Added:
"nested h2 heading" true
30
Added:
(contains html "<h2 id=\"installation\"");
31
Added:
Alcotest.(check bool)
32
Added:
"no line-number anchors" false
33
Added:
(contains html "line-anchor")
34
Added:
35
Added:
let test_org_document () =
36
Added:
let html =
37
Added:
render ~filename:"README.org"
38
Added:
"#+TITLE: Org Project\n\
39
Added:
#+AUTHOR: Ada Lovelace\n\
40
Added:
#+DATE: 2026-08-01\n\n\
41
Added:
* Overview\n\n\
42
Added:
#+BEGIN_SRC ocaml\n\
43
Added:
let answer = 42\n\
44
Added:
#+END_SRC\n"
45
Added:
in
46
Added:
Alcotest.(check bool)
47
Added:
"Org document container" true
48
Added:
(contains html "readme-document readme-org");
49
Added:
Alcotest.(check bool)
50
Added:
"title heading" true
51
Added:
(contains html "<h1 id=\"org-project\"");
52
Added:
Alcotest.(check bool)
53
Added:
"metadata definition list" true
54
Added:
(contains html "<dl class=\"readme-org-metadata\"");
55
Added:
Alcotest.(check bool) "author metadata" true (contains html "<dt>Author</dt>");
56
Added:
Alcotest.(check bool) "Org heading" true (contains html "<h1 id=\"overview\"");
57
Added:
Alcotest.(check bool)
58
Added:
"highlighted source block" true
59
Added:
(contains html "source.ocaml-keyword")
60
Added:
61
Added:
let test_readme_filename_detection () =
62
Added:
Alcotest.(check bool)
63
Added:
"case-insensitive README name" true
64
Added:
(Ogit.Readme.is_readme_filename "ReadMe.ORG");
65
Added:
Alcotest.(check bool)
66
Added:
"ordinary file is not a README" false
67
Added:
(Ogit.Readme.is_readme_filename "guide.md")
68
Added:
69
Added:
let suite =
70
Added:
( "readme",
71
Added:
[
72
Added:
Alcotest.test_case "Markdown document" `Quick test_markdown_document;
73
Added:
Alcotest.test_case "Org document" `Quick test_org_document;
74
Added:
Alcotest.test_case "README filename detection" `Quick
75
Added:
test_readme_filename_detection;
76
Added:
] )