[OCaml] Mobile-friendly clone of cgit.
1
(** Shared document AST and renderer for prose formats.
2
3
Format-specific parsing is supplied by the {!format} type; the renderer, TOC
4
generation, and anchor management are format-independent.
5
6
Every text fragment is emitted through {!Ui}, ensuring safe escaping of
7
repository content. *)
8
9
(* {1 Document AST} *)
10
11
type inline =
12
| Text of string
13
| Code of string
14
| Verbatim of string
15
| Link of { href : string; text : string }
16
17
type block =
18
| Heading of int * string
19
| Paragraph of string
20
| Unordered_list of string list
21
| Ordered_list of string list
22
| Definition_list of (string * string) list
23
| Code_block of string option * string
24
25
type document = {
26
title : string option;
27
metadata : (string * string) list;
28
blocks : block list;
29
}
30
31
(* {1 Format interface} *)
32
33
type format = {
34
name : string;
35
css_class : string;
36
parse : string -> document;
37
inline : string -> Ui.node list;
38
}
39
(** A documentation format provides parsing and inline markup rendering. *)
40
41
(* {1 Shared utilities} *)
42
43
let first_word text =
44
match
45
String.split_on_char ' ' text |> List.filter (fun word -> word <> "")
46
with
47
| word :: _ -> Some word
48
| [] -> None
49
50
(** A continuation line belongs to the current list item if it is indented
51
(starts with whitespace) and is not blank. *)
52
let is_continuation line =
53
String.length line > 0
54
&& (line.[0] = ' ' || line.[0] = '\t')
55
&& String.trim line <> ""
56
57
let take_continuations rest =
58
let rec loop acc = function
59
| line :: rest when is_continuation line ->
60
loop (String.trim line :: acc) rest
61
| remaining -> (List.rev acc, remaining)
62
in
63
loop [] rest
64
65
let rec take_until close collected = function
66
| [] -> (List.rev collected, [])
67
| line :: rest when close line -> (List.rev collected, rest)
68
| line :: rest -> take_until close (line :: collected) rest
69
70
(* {1 Shared list-item classifiers} *)
71
72
(** Recognise an unordered list item ([-], [+], or indented [*]). *)
73
let unordered_item line =
74
let length = String.length line in
75
let trimmed = String.trim line in
76
let tlen = String.length trimmed in
77
if tlen >= 2 && List.mem trimmed.[0] [ '-'; '+' ] && trimmed.[1] = ' ' then
78
Some (String.sub trimmed 2 (tlen - 2) |> String.trim)
79
else if
80
tlen >= 2
81
&& trimmed.[0] = '*'
82
&& trimmed.[1] = ' '
83
&& length > 0
84
&& (line.[0] = ' ' || line.[0] = '\t')
85
then Some (String.sub trimmed 2 (tlen - 2) |> String.trim)
86
else None
87
88
(** Recognise an ordered list item ([1.], [2)], etc.). *)
89
let ordered_item line =
90
let trimmed = String.trim line in
91
let tlen = String.length trimmed in
92
let rec digits i =
93
if i < tlen && trimmed.[i] >= '0' && trimmed.[i] <= '9' then digits (i + 1)
94
else i
95
in
96
let d = digits 0 in
97
if d = 0 || d >= tlen then None
98
else if
99
(trimmed.[d] = '.' || trimmed.[d] = ')')
100
&& d + 1 < tlen
101
&& trimmed.[d + 1] = ' '
102
then Some (String.sub trimmed (d + 2) (tlen - d - 2) |> String.trim)
103
else None
104
105
(** True when a line starts a new block (blank, or a list item). *)
106
let is_boundary_common line =
107
String.trim line = ""
108
|| Option.is_some (unordered_item line)
109
|| Option.is_some (ordered_item line)
110
111
(* {1 Anchor generation} *)
112
113
let new_anchor () =
114
let seen = Hashtbl.create 16 in
115
fun text ->
116
let base =
117
let buffer = Buffer.create (String.length text) in
118
let pending_separator = ref false in
119
String.iter
120
(fun character ->
121
if
122
(character >= 'a' && character <= 'z')
123
|| (character >= 'A' && character <= 'Z')
124
|| (character >= '0' && character <= '9')
125
then (
126
if !pending_separator && Buffer.length buffer > 0 then
127
Buffer.add_char buffer '-';
128
pending_separator := false;
129
Buffer.add_char buffer (Char.lowercase_ascii character))
130
else pending_separator := true)
131
text;
132
if Buffer.length buffer = 0 then "section" else Buffer.contents buffer
133
in
134
let count = Option.value (Hashtbl.find_opt seen base) ~default:0 + 1 in
135
Hashtbl.replace seen base count;
136
if count = 1 then base else Printf.sprintf "%s-%d" base count
137
138
(* {1 Rendering} *)
139
140
let render_heading anchor level text =
141
let id = anchor text in
142
Ui.heading ~id ~level ~class_:"readme-heading"
143
[ Ui.text_link ~class_:"readme-heading-link" ~href:("#" ^ id) text ]
144
145
let render_code_block language source =
146
let nodes =
147
match language with
148
| None | Some "" -> [ Ui.text source ]
149
| Some language ->
150
Highlight.Engine.highlight ~lang:(Some language) source |> List.concat
151
in
152
Ui.code_block ~class_:"readme-code-block" nodes
153
154
let render_block format anchor = function
155
| Heading (level, text) -> render_heading anchor level text
156
| Paragraph text ->
157
Ui.paragraph ~class_:"readme-paragraph" (format.inline text)
158
| Unordered_list items ->
159
Ui.items ~class_:"readme-list"
160
(List.map (fun item -> Ui.item (format.inline item)) items)
161
| Ordered_list items ->
162
Ui.ordered_items ~class_:"readme-list readme-ordered-list"
163
(List.map (fun item -> Ui.item (format.inline item)) items)
164
| Definition_list items ->
165
Ui.definitions ~class_:"readme-definition-list"
166
(List.map (fun (term, desc) -> (term, format.inline desc)) items)
167
| Code_block (language, source) -> render_code_block language source
168
169
let render_toc ~title_text body_headings =
170
let toc_anchor = new_anchor () in
171
(match title_text with Some t -> ignore (toc_anchor t) | None -> ());
172
(* Build a nested tree from a flat (level, text) list. Headings at a deeper
173
level than the current base become children of the preceding entry. *)
174
let rec build base_level headings =
175
match headings with
176
| [] -> ([], [])
177
| (level, _) :: _ when level < base_level -> ([], headings)
178
| (level, text) :: rest ->
179
let id = toc_anchor text in
180
let children, rest' = build (level + 1) rest in
181
let entry = Ui.toc_entry ~children ~href:("#" ^ id) text in
182
let siblings, rest'' = build base_level rest' in
183
(entry :: siblings, rest'')
184
in
185
let min_level =
186
List.fold_left (fun acc (l, _) -> min acc l) max_int body_headings
187
in
188
let entries, _ = build min_level body_headings in
189
Ui.toc ~class_:"readme-toc" ~title:"Table of Contents" entries
190
191
(** Render a document using the given format. *)
192
let render format content =
193
let doc = format.parse content in
194
let body_headings =
195
List.filter_map
196
(function Heading (level, text) -> Some (level, text) | _ -> None)
197
doc.blocks
198
in
199
let toc = render_toc ~title_text:doc.title body_headings in
200
let anchor = new_anchor () in
201
let title =
202
match doc.title with
203
| None -> None
204
| Some t -> Some (render_heading anchor 1 t)
205
in
206
let metadata_node =
207
match doc.metadata with
208
| [] -> Ui.nothing
209
| entries ->
210
Ui.definitions ~class_:"readme-org-metadata"
211
(List.map
212
(fun (key, value) ->
213
(String.capitalize_ascii key, [ Ui.text value ]))
214
entries)
215
in
216
Ui.region ~class_:format.css_class
217
(Option.to_list title @ [ metadata_node; toc ]
218
@ List.map (render_block format anchor) doc.blocks)
219