[OCaml] Mobile-friendly clone of cgit.
1
(** Markdown documentation format.
2
3
Parses ATX headings, fenced code blocks, unordered and ordered lists, and
4
paragraphs. Inline markup is passed through as plain text. *)
5
6
(* {1 Line classifiers} *)
7
8
let trim_end_hashes text =
9
let text = String.trim text in
10
let rec last_non_hash index =
11
if index < 0 || text.[index] <> '#' then index else last_non_hash (index - 1)
12
in
13
let last = last_non_hash (String.length text - 1) in
14
String.sub text 0 (last + 1) |> String.trim
15
16
let heading line =
17
let length = String.length line in
18
let rec count_hashes index =
19
if index < length && line.[index] = '#' then count_hashes (index + 1)
20
else index
21
in
22
let level = count_hashes 0 in
23
if level = 0 || level > 6 || level >= length || line.[level] <> ' ' then None
24
else
25
Some
26
( level,
27
String.sub line (level + 1) (length - level - 1) |> trim_end_hashes )
28
29
let unordered_item = Format.unordered_item
30
let ordered_item = Format.ordered_item
31
32
let fence line =
33
let line = String.trim line in
34
if String.length line < 3 then None
35
else
36
let marker = String.sub line 0 3 in
37
if marker <> "```" && marker <> "~~~" then None
38
else
39
let language =
40
String.sub line 3 (String.length line - 3)
41
|> String.trim |> Format.first_word
42
in
43
Some (marker, language)
44
45
(* {1 Block parsing} *)
46
47
let is_boundary_common = Format.is_boundary_common
48
49
let parse_blocks lines =
50
let is_boundary line =
51
is_boundary_common line
52
|| Option.is_some (heading line)
53
|| Option.is_some (fence line)
54
in
55
let rec take_paragraph collected = function
56
| line :: _ as rest when is_boundary line -> (List.rev collected, rest)
57
| line :: rest -> take_paragraph (String.trim line :: collected) rest
58
| [] -> (List.rev collected, [])
59
in
60
let rec take_unordered collected = function
61
| line :: rest -> (
62
match unordered_item line with
63
| Some first_line ->
64
let continuations, rest = Format.take_continuations rest in
65
let item = String.concat " " (first_line :: continuations) in
66
take_unordered (item :: collected) rest
67
| None -> (List.rev collected, line :: rest))
68
| [] -> (List.rev collected, [])
69
in
70
let rec take_ordered collected = function
71
| line :: rest -> (
72
match ordered_item line with
73
| Some first_line ->
74
let continuations, rest = Format.take_continuations rest in
75
let item = String.concat " " (first_line :: continuations) in
76
take_ordered (item :: collected) rest
77
| None -> (List.rev collected, line :: rest))
78
| [] -> (List.rev collected, [])
79
in
80
let open Format in
81
let rec loop blocks = function
82
| [] -> List.rev blocks
83
| line :: rest when String.trim line = "" -> loop blocks rest
84
| line :: rest -> (
85
match heading line with
86
| Some (level, text) -> loop (Heading (level, text) :: blocks) rest
87
| None -> (
88
match fence line with
89
| Some (marker, language) ->
90
let lines, rest =
91
Format.take_until
92
(fun candidate ->
93
String.starts_with ~prefix:marker (String.trim candidate))
94
[] rest
95
in
96
loop
97
(Code_block (language, String.concat "\n" lines) :: blocks)
98
rest
99
| None -> (
100
match unordered_item line with
101
| Some _ ->
102
let items, rest = take_unordered [] (line :: rest) in
103
loop (Unordered_list items :: blocks) rest
104
| None -> (
105
match ordered_item line with
106
| Some _ ->
107
let items, rest = take_ordered [] (line :: rest) in
108
loop (Ordered_list items :: blocks) rest
109
| None ->
110
let paragraph, rest =
111
take_paragraph [] (line :: rest)
112
in
113
loop
114
(Paragraph (String.concat " " paragraph) :: blocks)
115
rest))))
116
in
117
loop [] lines
118
119
(* {1 Format definition} *)
120
121
let format : Format.format =
122
{
123
name = "markdown";
124
css_class = "readme-document readme-markdown";
125
parse =
126
(fun content ->
127
let lines = String.split_on_char '\n' content in
128
{ Format.title = None; metadata = []; blocks = parse_blocks lines });
129
inline = (fun text -> [ Ui.text text ]);
130
}
131