[OCaml] Mobile-friendly clone of cgit.
1
(* Implementation of the site building blocks. The API and its rationale are
2
documented in ui.mli; comments here cover implementation choices only.
3
4
Attribute order is deliberate and load-bearing for readability of the
5
rendered HTML: id, then href, then class, then ARIA. Keeping it uniform means
6
a page's markup diffs cleanly when a block changes. *)
7
8
open Dream_html
9
10
type node = Dream_html.node
11
12
let classes parts =
13
parts |> List.filter (fun part -> part <> "") |> String.concat " "
14
15
(* Optional attributes collapse to the empty list so they can be concatenated
16
unconditionally at each call site. *)
17
let opt_id = function None -> [] | Some value -> [ HTML.id "%s" value ]
18
let opt_class = function None -> [] | Some value -> [ HTML.class_ "%s" value ]
19
let opt_aria_label = function None -> [] | Some v -> [ Aria.label "%s" v ]
20
let flag_current = function false -> [] | true -> [ Aria.current `page ]
21
let flag_open = function false -> [] | true -> HTML.[ open_ ]
22
23
(* Text and grouping *)
24
25
let nothing = HTML.null []
26
let text value = txt "%s" value
27
let group nodes = HTML.null nodes
28
29
(* Inline *)
30
31
let inline ?class_ ?(decorative = false) children =
32
let hidden = if decorative then [ Aria.hidden true ] else [] in
33
HTML.span (opt_class class_ @ hidden) children
34
35
let inline_text ?class_ ?decorative value =
36
inline ?class_ ?decorative [ text value ]
37
38
(* Links *)
39
40
let link ?id ?class_ ?label ~href children =
41
HTML.a
42
(opt_id id
43
@ [ HTML.href "%s" href ]
44
@ opt_class class_ @ opt_aria_label label)
45
children
46
47
let text_link ?id ?class_ ?label ~href value =
48
link ?id ?class_ ?label ~href [ text value ]
49
50
(* Images *)
51
52
let image ?class_ ?alt ~src () =
53
let describe =
54
match alt with
55
| Some value -> [ HTML.alt "%s" value ]
56
(* An empty alt alone is enough for most readers, but the explicit
57
presentation role removes any doubt. *)
58
| None -> [ HTML.alt ""; HTML.role `presentation ]
59
in
60
HTML.img ((HTML.src "%s" src :: describe) @ opt_class class_)
61
62
(* Blocks *)
63
64
let block ?id ?class_ children =
65
HTML.div (opt_id id @ opt_class class_) children
66
67
let region ?id ?class_ children =
68
HTML.section (opt_id id @ opt_class class_) children
69
70
let paragraph ?class_ children = HTML.p (opt_class class_) children
71
let paragraph_text ?class_ value = paragraph ?class_ [ text value ]
72
73
let heading ?id ?(level = 1) ?class_ children =
74
let element =
75
match level with
76
| 1 -> HTML.h1
77
| 2 -> HTML.h2
78
| 3 -> HTML.h3
79
| 4 -> HTML.h4
80
| 5 -> HTML.h5
81
| _ -> HTML.h6
82
in
83
element (opt_id id @ opt_class class_) children
84
85
let code_block ?class_ children =
86
(* Pre-serialize the entire <code> block into a single raw text node placed
87
directly inside <pre>. This prevents the pretty-printer from injecting
88
visible whitespace between the <pre> open tag and the code content.
89
Dream_html.to_string appends a newline after each element; strip those to
90
avoid spurious line breaks between inline spans. *)
91
let raw_content =
92
children
93
|> List.map (fun node ->
94
let s = Dream_html.to_string node in
95
if String.length s > 0 && s.[String.length s - 1] = '\n' then
96
String.sub s 0 (String.length s - 1)
97
else s)
98
|> String.concat ""
99
in
100
HTML.pre (opt_class class_) [ txt ~raw:true "<code>%s</code>" raw_content ]
101
102
(* Lists *)
103
104
let items ?id ?class_ children = HTML.ul (opt_id id @ opt_class class_) children
105
106
let ordered_items ?id ?class_ children =
107
HTML.ol (opt_id id @ opt_class class_) children
108
109
let item ?class_ ?(current = false) children =
110
HTML.li (opt_class class_ @ flag_current current) children
111
112
let items_of ?id ?class_ render values =
113
items ?id ?class_ (List.map render values)
114
115
let code_inline ?class_ value = HTML.code (opt_class class_) [ text value ]
116
117
(* Badges *)
118
119
let badge ?(base_class = "badge") ?variant ?href value =
120
let classes =
121
match variant with
122
| None -> base_class
123
| Some variant -> Printf.sprintf "%s %s-%s" base_class base_class variant
124
in
125
(* Only the text is linked: a link wrapping the whole badge would make its
126
padding clickable, which reads as a button rather than a label. *)
127
let body =
128
match href with
129
| None -> [ text value ]
130
| Some href -> [ link ~href [ text value ] ]
131
in
132
inline ~class_:classes body
133
134
(* Time *)
135
136
let timestamp ~machine display =
137
HTML.time [ HTML.datetime "%s" machine ] [ text display ]
138
139
(* Definition lists *)
140
141
let definitions ?class_ pairs =
142
(* dt and dd are siblings, not nested, so each pair becomes a flat group. *)
143
let entry (term, description) =
144
group [ HTML.dt [] [ text term ]; HTML.dd [] description ]
145
in
146
HTML.dl (opt_class class_) (List.map entry pairs)
147
148
(* Disclosure *)
149
150
let chevron ?(class_ = "tree-chevron") () =
151
inline ~class_ ~decorative:true [ text "\xe2\x80\xba" ]
152
153
let disclosure ?id ?class_ ?(expanded = false) ?summary_class ~summary children
154
=
155
HTML.details
156
(opt_id id @ opt_class class_ @ flag_open expanded)
157
(HTML.summary (opt_class summary_class) summary :: children)
158
159
let css_toggle ~id:toggle_id ~toggle_class ~control_class ~label:control_label
160
~glyph () =
161
group
162
[
163
HTML.input
164
[
165
HTML.type_ "checkbox";
166
HTML.id "%s" toggle_id;
167
HTML.class_ "%s" toggle_class;
168
];
169
HTML.label
170
[
171
HTML.for_ "%s" toggle_id;
172
HTML.class_ "%s" control_class;
173
Aria.label "%s" control_label;
174
]
175
[ text glyph ];
176
]
177
178
(* Table of contents *)
179
180
type toc_entry = {
181
toc_href : string;
182
toc_label : string;
183
toc_children : toc_entry list;
184
}
185
186
let toc_entry ?(children = []) ~href label =
187
{ toc_href = href; toc_label = label; toc_children = children }
188
189
let rec toc_items entries =
190
items ~class_:"toc-list"
191
(List.map
192
(fun { toc_href; toc_label; toc_children } ->
193
let nested =
194
match toc_children with [] -> [] | kids -> [ toc_items kids ]
195
in
196
item (text_link ~href:toc_href toc_label :: nested))
197
entries)
198
199
let toc ?class_ ~title entries =
200
let rec count = function
201
| [] -> 0
202
| e :: rest -> 1 + count e.toc_children + count rest
203
in
204
match entries with
205
| [] -> nothing
206
| _ when count entries < 2 -> nothing
207
| _ ->
208
let outer_class = classes [ "toc"; Option.value class_ ~default:"" ] in
209
disclosure ~class_:outer_class ~summary_class:"toc-summary"
210
~summary:[ text title ]
211
[ toc_items entries ]
212
213
(* Trees *)
214
215
let tree_leaf ?(modifier = "") ~href label =
216
item ~class_:(classes [ "tree-file"; modifier ]) [ text_link ~href label ]
217
218
let tree_branch ?(modifier = "") ?(expanded = false) ~href label children =
219
item
220
~class_:(classes [ "tree-dir"; modifier ])
221
[
222
disclosure ~expanded ~summary_class:"tree-toggle"
223
~summary:[ chevron (); text_link ~class_:"tree-link" ~href label ]
224
[ items ~class_:"tree-nested" children ];
225
]
226
227
let tree_overflow ?(class_ = "tree-overflow") ~href label =
228
item ~class_ [ text_link ~href label ]
229
230
(* Breadcrumbs *)
231
232
type crumb = { crumb_text : string; crumb_href : string option }
233
234
let crumb ?href text = { crumb_text = text; crumb_href = href }
235
236
let breadcrumb ?id ?class_ ?link_class ?separator_class
237
?(separator_decorative = false) ~separator crumbs =
238
(* The separator precedes every crumb but the first, so the trail has no
239
leading or trailing delimiter. *)
240
let render index { crumb_text; crumb_href } =
241
let body =
242
match crumb_href with
243
| Some href -> text_link ?class_:link_class ~href crumb_text
244
| None -> inline_text ?class_:link_class crumb_text
245
in
246
if index = 0 then body
247
else
248
group
249
[
250
inline_text ?class_:separator_class ~decorative:separator_decorative
251
separator;
252
body;
253
]
254
in
255
HTML.span (opt_id id @ opt_class class_) (List.mapi render crumbs)
256
257
(* Navigation *)
258
259
type nav_link = { nav_href : string; nav_text : string; nav_current : bool }
260
261
let nav_link ?(current = false) ~href text =
262
{ nav_href = href; nav_text = text; nav_current = current }
263
264
let navigation ?id ?class_ ~label children =
265
HTML.nav (opt_id id @ opt_class class_ @ [ Aria.label "%s" label ]) children
266
267
let nav_links ?id ?class_ ?item_class links =
268
(* aria-current goes on the list item rather than the link so the marker
269
survives styling the item as the highlighted row. *)
270
let render { nav_href; nav_text; nav_current } =
271
item ?class_:item_class ~current:nav_current
272
[ text_link ~href:nav_href nav_text ]
273
in
274
items ?id ?class_ (List.map render links)
275
276
(* Toolbars *)
277
278
let toolbar ?id ?class_ ?label children =
279
match children with
280
| [] -> nothing
281
| _ ->
282
HTML.div
283
(opt_id id @ opt_class class_
284
@ [ HTML.role `toolbar ]
285
@ opt_aria_label label)
286
children
287
288
let button_link ?(class_ = "toolbar-button") ?label ~href text =
289
text_link ~class_ ?label ~href text
290
291
let dismissible ?(class_ = "toolbar-filter")
292
?(dismiss_class = "toolbar-dismiss") ~value_class ~dismiss_href
293
~dismiss_label value =
294
inline ~class_
295
[
296
inline_text ~class_:value_class value;
297
text_link ~class_:dismiss_class ~label:dismiss_label ~href:dismiss_href
298
"\xc3\x97";
299
]
300
301
(* Pagination *)
302
303
let pagination ?(label = "Pagination") ?(previous_text = "<") ?(next_text = ">")
304
?(previous_label = "Previous page") ?(next_label = "Next page")
305
?previous_href ?next_href page_number =
306
(* An unavailable neighbour still occupies its slot, so the page number does
307
not shift horizontally as the reader moves through the list. *)
308
let control href_opt glyph control_label =
309
match href_opt with
310
| Some href ->
311
text_link ~class_:"pagination-btn" ~label:control_label ~href glyph
312
| None ->
313
inline_text ~class_:"pagination-btn pagination-disabled"
314
~decorative:true glyph
315
in
316
navigation ~class_:"toolbar-pagination" ~label
317
[
318
control previous_href previous_text previous_label;
319
HTML.span
320
[ HTML.class_ "pagination-page"; Aria.current `page ]
321
[ text (string_of_int page_number) ];
322
control next_href next_text next_label;
323
]
324
325
(* Code *)
326
327
let numbered_lines ?id ?class_ ?(anchor_prefix = "") render_line lines =
328
let numbered_line index line =
329
let number = index + 1 in
330
let name = Printf.sprintf "%s%d" anchor_prefix number in
331
[
332
HTML.a
333
[
334
HTML.id "%s" name;
335
HTML.class_ "line-anchor";
336
HTML.href "#%s" name;
337
Aria.label "Line %d" number;
338
]
339
[ text (string_of_int number) ];
340
HTML.span [ HTML.class_ "line" ] (render_line line);
341
]
342
in
343
block ?id ?class_ (List.mapi numbered_line lines |> List.concat)
344
345
let code_listing ?id ?class_ ?anchor_prefix content =
346
(* Anchor and text alternate as siblings of one grid container, so the
347
stylesheet can align numbers against wrapping lines without a table. The
348
leading tab and trailing newline preserve the source's shape when the
349
listing is copied. *)
350
numbered_lines ?id ?class_ ?anchor_prefix
351
(fun line -> [ txt "\t%s\n" line ])
352
(String.split_on_char '\n' content)
353
354
let highlighted_code_listing ?id ?class_ ?anchor_prefix lines =
355
(* Same grid layout as code_listing, but each line is a list of pre-rendered
356
nodes (highlighted spans) rather than plain text. The tab/newline framing
357
is identical so copy-paste behaviour is preserved. *)
358
numbered_lines ?id ?class_ ?anchor_prefix
359
(fun line_nodes -> txt "\t" :: line_nodes)
360
lines
361
362
(* Diffs *)
363
364
module Diff = struct
365
type change = Unchanged | Added | Removed
366
367
type line = {
368
before : string;
369
after : string;
370
change : change;
371
content : string;
372
}
373
374
type section = { section_heading : string; lines : line list }
375
376
type file = {
377
path : string;
378
detail : string;
379
sections : section list;
380
note : string option;
381
}
382
383
let line_node { before; after; change; content } =
384
let variant, marker, announcement =
385
match change with
386
| Unchanged -> ("context", " ", "")
387
| Added -> ("addition", "+", "Added: ")
388
| Removed -> ("deletion", "-", "Removed: ")
389
in
390
block ~class_:("diff-line " ^ variant)
391
[
392
inline_text ~class_:"line-number" before;
393
inline_text ~class_:"line-number" after;
394
inline_text ~class_:"diff-marker" ~decorative:true marker;
395
(* Restores, for screen readers, the meaning the marker conveys
396
visually. *)
397
inline_text ~class_:"sr-only" announcement;
398
inline_text ~class_:"diff-text" content;
399
]
400
401
let section_node { section_heading; lines } =
402
disclosure ~class_:"diff-hunk" ~expanded:true ~summary_class:"hunk-header"
403
~summary:[ text section_heading ]
404
[
405
(* The inner scroll container keeps long lines from widening the page. *)
406
block ~class_:"diff-lines-scroll"
407
[ block ~class_:"diff-lines" (List.map line_node lines) ];
408
]
409
410
let file_id index = Printf.sprintf "file-%d" (index + 1)
411
412
let file_node index { path; detail; sections; note } =
413
let body =
414
match note with
415
| Some note -> [ paragraph_text ~class_:"binary-diff" note ]
416
| None -> List.map section_node sections
417
in
418
disclosure ~class_:"diff-file" ~expanded:true
419
~summary_class:"diff-file-header"
420
~summary:[ text path ]
421
~id:(file_id index)
422
(block ~class_:"diff-meta" [ text detail ] :: body)
423
424
let file_toc files =
425
toc ~class_:"diff-toc" ~title:"Changed files"
426
(List.mapi
427
(fun index { path; _ } -> toc_entry ~href:("#" ^ file_id index) path)
428
files)
429
430
let view ~empty_message = function
431
| [] -> [ paragraph_text empty_message ]
432
| files -> file_toc files :: List.mapi file_node files
433
end
434
435
(* Document scaffolding *)
436
437
let meta_viewport =
438
HTML.meta
439
[ HTML.name "viewport"; HTML.content "width=device-width, initial-scale=1" ]
440
441
let stylesheet href = HTML.link [ HTML.rel "stylesheet"; HTML.href "%s" href ]
442
443
let icon ?(media_type = "image/x-icon") href =
444
HTML.link [ HTML.rel "icon"; HTML.type_ "%s" media_type; HTML.href "%s" href ]
445
446
let deferred_script src = HTML.script [ HTML.src "%s" src; HTML.defer ] ""
447
let inline_script source = HTML.script [] "%s" source
448
449
let document_head ~title:document_title extra =
450
HTML.head [] (HTML.title [] "%s" document_title :: extra)
451
452
let skip_link ~href label = text_link ~class_:"skip-link" ~href label
453
454
let page_banner ?id ?class_ children =
455
HTML.header (opt_id id @ opt_class class_) children
456
457
let page_content ?id ?class_ children =
458
HTML.main (opt_id id @ opt_class class_) children
459
460
let page_footer ?class_ children = HTML.footer (opt_class class_) children
461
let document_body ?class_ children = HTML.body (opt_class class_) children
462
463
let document ?(lang = "en") ~head ~body () =
464
HTML.html [ HTML.lang "%s" lang ] [ head; body ]
465
466
(* Responses *)
467
468
let respond ?status page =
469
match status with
470
| None -> Dream_html.respond page
471
| Some status -> Dream_html.respond ~status page
472